From 9bc18031316ce37eba0b6ce6e91a1d2676eb908e Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Sun, 25 Oct 2026 13:40:40 +0100 Subject: [PATCH] fix(fmt): format maps and function types as Go does fmt formatted a map as {k:v ...} in insertion order, and %T of a function value as function. A map now formats as map[k:v ...], with its entries sorted by key as Go's fmt sorts them, and in Go syntax for %#v. The Go type string of a type info now spells function types and the empty interface, which %T gives for a function value, and %#v for a nil function. Signed-off-by: Randall C. O'Reilly --- gs/builtin/type.ts | 29 +++++ gs/fmt/fmt.test.ts | 9 +- gs/fmt/fmt.ts | 112 ++++++++++++++++-- tests/tests/fmt_map_sorted/expected.log | 9 ++ tests/tests/fmt_map_sorted/fmt_map_sorted.go | 32 +++++ .../tests/fmt_map_sorted/fmt_map_sorted.gs.ts | 61 ++++++++++ tests/tests/fmt_map_sorted/index.ts | 1 + tests/tests/fmt_map_sorted/tsconfig.json | 25 ++++ tests/tests/fmt_type_func/expected.log | 3 + tests/tests/fmt_type_func/fmt_type_func.go | 17 +++ tests/tests/fmt_type_func/fmt_type_func.gs.ts | 31 +++++ tests/tests/fmt_type_func/index.ts | 1 + tests/tests/fmt_type_func/tsconfig.json | 25 ++++ 13 files changed, 336 insertions(+), 19 deletions(-) create mode 100644 tests/tests/fmt_map_sorted/expected.log create mode 100644 tests/tests/fmt_map_sorted/fmt_map_sorted.go create mode 100644 tests/tests/fmt_map_sorted/fmt_map_sorted.gs.ts create mode 100644 tests/tests/fmt_map_sorted/index.ts create mode 100644 tests/tests/fmt_map_sorted/tsconfig.json create mode 100644 tests/tests/fmt_type_func/expected.log create mode 100644 tests/tests/fmt_type_func/fmt_type_func.go create mode 100644 tests/tests/fmt_type_func/fmt_type_func.gs.ts create mode 100644 tests/tests/fmt_type_func/index.ts create mode 100644 tests/tests/fmt_type_func/tsconfig.json diff --git a/gs/builtin/type.ts b/gs/builtin/type.ts index 56717c8be..c933de44b 100644 --- a/gs/builtin/type.ts +++ b/gs/builtin/type.ts @@ -2947,10 +2947,39 @@ export function typeInfoGoString( prefixTypeString(`map[${key}]`, info.elemType) ) } + case TypeKind.Function: + return functionTypeString(info) + case TypeKind.Interface: + return info.methods.length === 0 ? 'interface {}' : undefined } return undefined } +// functionTypeString returns the Go type string of the function type info, +// such as func(int, ...string) (bool, error), or undefined if one of its +// types cannot be spelled. +function functionTypeString(info: FunctionTypeInfo): string | undefined { + const params = (info.params ?? []).map(typeInfoGoString) + const results = (info.results ?? []).map(typeInfoGoString) + if ([...params, ...results].some((t) => t === undefined)) { + return undefined + } + + // The last parameter of a variadic function is spelled ...T, not []T. + const last = params.length - 1 + if (info.isVariadic && last >= 0 && params[last]!.startsWith('[]')) { + params[last] = '...' + params[last]!.substring(2) + } + const text = 'func(' + params.join(', ') + ')' + if (results.length === 0) { + return text + } + if (results.length === 1) { + return text + ' ' + results[0] + } + return text + ' (' + results.join(', ') + ')' +} + // dynamicTypeName returns the Go type string of info as the dynamic type of // a value, or undefined when info is an interface type, whose values have // dynamic types of their own, or cannot be spelled. diff --git a/gs/fmt/fmt.test.ts b/gs/fmt/fmt.test.ts index d4f7e26f1..f5a204aec 100644 --- a/gs/fmt/fmt.test.ts +++ b/gs/fmt/fmt.test.ts @@ -145,12 +145,9 @@ describe('fmt basic value formatting', () => { const m = new Map() m.set('a', 1) m.set('b', 2) - const out = await fmt.Sprintf('%v', m) - // Order in Map iteration is insertion order; verify shape - expect(out.startsWith('{')).toBe(true) - expect(out.includes('a:1')).toBe(true) - expect(out.includes('b:2')).toBe(true) - expect(out.endsWith('}')).toBe(true) + m.set('', 0) + // A map formats as in Go, sorted by key. + expect(await fmt.Sprintf('%v', m)).toBe('map[:0 a:1 b:2]') const s = new Set([1, 2, 3]) expect(await fmt.Sprintf('%v', s)).toBe('[1 2 3]') diff --git a/gs/fmt/fmt.ts b/gs/fmt/fmt.ts index 5c0100a70..4defb176c 100644 --- a/gs/fmt/fmt.ts +++ b/gs/fmt/fmt.ts @@ -225,6 +225,12 @@ function formatValue(value: any, verb: string, flags = ''): string { return value ? 'true' : 'false' case 'T': // type (approximate Go names for primitives we need) if (hasGoTypeName(value)) return $.goTypeString(value.__goType) + if (typeof value === 'function') { + // A function value has the type information of its signature. + const info = (value as { __typeInfo?: $.TypeInfo }).__typeInfo + const name = $.typeInfoGoString(info) + if (name !== undefined) return name + } if (typeof value === 'number') { return Number.isInteger(value) ? 'int' : 'float64' } @@ -482,6 +488,71 @@ function nilString( return '' } +// compareMapKeys orders two map keys as Go's fmt sorts them: numbers by +// value, with NaN first, strings lexically, false before true, and arrays +// and structs by their elements in order. Keys of different kinds, such as +// those of an interface map, are ordered by kind. +function compareMapKeys(a: unknown, b: unknown): number { + a = basicValue(a) + b = basicValue(b) + const ka = typeof a + const kb = typeof b + if ( + (ka === 'number' || ka === 'bigint') && + (kb === 'number' || kb === 'bigint') + ) { + if (ka === 'bigint' && kb === 'bigint') { + return ( + (a as bigint) < (b as bigint) ? -1 + : (a as bigint) > (b as bigint) ? 1 + : 0 + ) + } + const x = Number(a) + const y = Number(b) + if (Number.isNaN(x) || Number.isNaN(y)) { + return ( + Number.isNaN(x) && Number.isNaN(y) ? 0 + : Number.isNaN(x) ? -1 + : 1 + ) + } + return ( + x < y ? -1 + : x > y ? 1 + : 0 + ) + } + if (ka !== kb) { + return ka < kb ? -1 : 1 + } + if (ka === 'string' || ka === 'boolean') { + return ( + (a as string) < (b as string) ? -1 + : (a as string) > (b as string) ? 1 + : 0 + ) + } + if (a === null || b === null || ka !== 'object') { + return ( + a === b ? 0 + : a === null ? -1 + : 1 + ) + } + + // Arrays compare by element, and structs by field, in order. + const xs = $.isArrayOrGoSlice(a) ? sliceItems(a) : Object.values(a as object) + const ys = $.isArrayOrGoSlice(b) ? sliceItems(b) : Object.values(b as object) + for (let i = 0; i < Math.min(xs.length, ys.length); i++) { + const c = compareMapKeys(xs[i], ys[i]) + if (c !== 0) { + return c + } + } + return xs.length - ys.length +} + /** defaultFormatMaybe renders Go values without exposing their runtime storage. */ function defaultFormatMaybe( value: any, @@ -655,23 +726,38 @@ function defaultFormatMaybe( formattingStructs.delete(storage) } } - // Basic Map/Set rendering + // A map formats as map[k:v ...], with its entries sorted by key as Go + // sorts them, or in Go syntax for %#v. if (value instanceof Map) { - const parts: MaybeString[] = [] - for (const [k, v] of (value as Map).entries()) { - const key = defaultFormatMaybe(k, flags, depth + 1) - const elem = defaultFormatMaybe(v, flags, depth + 1) + const mapInfo = info?.kind === $.TypeKind.Map ? info : undefined + const entries = [...(value as Map).entries()].sort( + ([a], [b]) => compareMapKeys(a, b), + ) + const parts = entries.map(([k, v]): MaybeString => { + const key = defaultFormatMaybe( + k, + flags, + depth + 1, + mapInfo?.keyType, + allowMethods, + ) + const elem = defaultFormatMaybe( + v, + flags, + depth + 1, + mapInfo?.elemType, + allowMethods, + ) if (isPromiseLike(key) || isPromiseLike(elem)) { - parts.push( - Promise.all([Promise.resolve(key), Promise.resolve(elem)]).then( - ([resolvedKey, resolvedElem]) => `${resolvedKey}:${resolvedElem}`, - ), - ) - } else { - parts.push(`${key}:${elem}`) + return Promise.all([key, elem]).then(([rk, re]) => `${rk}:${re}`) } + return `${key}:${elem}` + }) + if (flags.includes('#')) { + const name = $.typeInfoGoString(mapInfo) ?? 'map' + return joinMaybe(parts, ', ', name + '{', '}') } - return joinMaybe(parts, ' ', '{', '}') + return joinMaybe(parts, ' ', 'map[', ']') } if (value instanceof Set) { const parts: MaybeString[] = [] diff --git a/tests/tests/fmt_map_sorted/expected.log b/tests/tests/fmt_map_sorted/expected.log new file mode 100644 index 000000000..a3f109300 --- /dev/null +++ b/tests/tests/fmt_map_sorted/expected.log @@ -0,0 +1,9 @@ +map[-1:a 2:b 3:c] map[NaN:false -1:true 2.5:true] +map[-1:a 2:b 3:c] map[-1:a 2:b 3:c] map[int]string{-1:"a", 2:"b", 3:"c"} +map[:0 a:1 b:2] +map[false:0 true:1] +map[[1 9]:x [2 1]:y] +map[{1 a}:1 {1 b}:2 {2 a}:3] +map[x:1 y:s] map[1:map[2:3]] +map[] map[string]int(nil) map[] +map[k:1] map[k:1] diff --git a/tests/tests/fmt_map_sorted/fmt_map_sorted.go b/tests/tests/fmt_map_sorted/fmt_map_sorted.go new file mode 100644 index 000000000..4b06130c4 --- /dev/null +++ b/tests/tests/fmt_map_sorted/fmt_map_sorted.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "math" +) + +type key struct { + A int + B string +} + +func main() { + // Keys are sorted, numbers by value with NaN first. + ints := map[int]string{3: "c", -1: "a", 2: "b"} + floats := map[float64]bool{2.5: true, math.NaN(): false, -1: true} + fmt.Println(ints, floats) + fmt.Printf("%v %+v %#v\n", ints, ints, ints) + + // Strings, bools, arrays and structs sort by value, field by field. + fmt.Println(map[string]int{"b": 2, "a": 1, "": 0}) + fmt.Println(map[bool]int{true: 1, false: 0}) + fmt.Println(map[[2]int]string{{2, 1}: "y", {1, 9}: "x"}) + fmt.Println(map[key]int{{2, "a"}: 3, {1, "b"}: 2, {1, "a"}: 1}) + + // Interface elements, nested maps and nil and empty maps. + var nilMap map[string]int + fmt.Println(map[string]any{"x": 1, "y": "s"}, map[int]map[int]int{1: {2: 3}}) + fmt.Printf("%v %#v %v\n", nilMap, nilMap, map[string]int{}) + var x any = map[string]int{"k": 1} + fmt.Println(x, fmt.Sprint(x)) +} diff --git a/tests/tests/fmt_map_sorted/fmt_map_sorted.gs.ts b/tests/tests/fmt_map_sorted/fmt_map_sorted.gs.ts new file mode 100644 index 000000000..55d137346 --- /dev/null +++ b/tests/tests/fmt_map_sorted/fmt_map_sorted.gs.ts @@ -0,0 +1,61 @@ +// Generated file based on fmt_map_sorted.go +// Updated when compliance tests are re-run, DO NOT EDIT! + +import * as $ from "@goscript/builtin/index.js" +import * as fmt from "@goscript/fmt/index.js" +import * as math from "@goscript/math/index.js" + +export class key { + public declare A: number + + public declare B: string + + constructor(__goscriptValue?: boolean, A$?: number, B$?: string) { + this.A = A$ ?? 0 + this.B = B$ ?? "" + ;(this as any)[$.structValueMarker] = __goscriptValue === true + } + + public clone(): key { + return new key(true, this.A, this.B) + } + + public __goscriptAssign(source: key): this { + this.A = source.A + this.B = source.B + return this + } + + static __typeInfo = $.registerStructType( + "main.key", + () => new key(), + () => [], + key, + () => [{ name: "A", key: "A", type: /* @__PURE__ */ $.basicType("int") }, { name: "B", key: "B", type: /* @__PURE__ */ $.basicType("string") }] + ) +} + +export async function main(): globalThis.Promise { + // Keys are sorted, numbers by value with NaN first. + let ints = $.makeMap([[3, "c"], [-1, "a"], [2, "b"]]) + let floats = $.makeMap([[2.5, true], [math.NaN(), false], [-1, true]]) + fmt.Println($.interfaceValue(ints, "map[int]string", /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.basicType("int"), /* @__PURE__ */ $.basicType("string"))), $.interfaceValue(floats, "map[float64]bool", /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.basicType("float64"), /* @__PURE__ */ $.basicType("bool")))) + fmt.Printf("%v %+v %#v\n", $.interfaceValue(ints, "map[int]string", /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.basicType("int"), /* @__PURE__ */ $.basicType("string"))), $.interfaceValue(ints, "map[int]string", /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.basicType("int"), /* @__PURE__ */ $.basicType("string"))), $.interfaceValue(ints, "map[int]string", /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.basicType("int"), /* @__PURE__ */ $.basicType("string")))) + + // Strings, bools, arrays and structs sort by value, field by field. + fmt.Println($.interfaceValue($.makeMap([["b", 2], ["a", 1], ["", 0]]), "map[string]int", /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.basicType("string"), /* @__PURE__ */ $.basicType("int")))) + fmt.Println($.interfaceValue($.makeMap([[true, 1], [false, 0]]), "map[bool]int", /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.basicType("bool"), /* @__PURE__ */ $.basicType("int")))) + fmt.Println($.interfaceValue($.makeMap([[$.arrayValue([2, 1]), "y"], [$.arrayValue([1, 9]), "x"]]), "map[[2]int]string", /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.arrayType(/* @__PURE__ */ $.basicType("int"), 2), /* @__PURE__ */ $.basicType("string")))) + fmt.Println($.interfaceValue($.makeMap([[new key(true, 2, "a"), 3], [new key(true, 1, "b"), 2], [new key(true, 1, "a"), 1]]), "map[main.key]int", /* @__PURE__ */ $.mapType("main.key", /* @__PURE__ */ $.basicType("int")))) + + // Interface elements, nested maps and nil and empty maps. + let nilMap: globalThis.Map | null = null! as globalThis.Map | null + fmt.Println($.interfaceValue($.makeMap([["x", $.basicInterfaceValue(1, "int")], ["y", "s"]]), "map[string]interface{}", /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.basicType("string"), { kind: $.TypeKind.Interface, methods: [] })), $.interfaceValue($.makeMap | null>([[1, $.makeMap([[2, 3]])]]), "map[int]map[int]int", /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.basicType("int"), /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.basicType("int"), /* @__PURE__ */ $.basicType("int"))))) + fmt.Printf("%v %#v %v\n", $.interfaceValue(nilMap, "map[string]int", /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.basicType("string"), /* @__PURE__ */ $.basicType("int"))), $.interfaceValue(nilMap, "map[string]int", /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.basicType("string"), /* @__PURE__ */ $.basicType("int"))), $.interfaceValue($.makeMap([]), "map[string]int", /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.basicType("string"), /* @__PURE__ */ $.basicType("int")))) + let x: any = $.interfaceValue($.makeMap([["k", 1]]), "map[string]int", /* @__PURE__ */ $.mapType(/* @__PURE__ */ $.basicType("string"), /* @__PURE__ */ $.basicType("int"))) + fmt.Println(x, (fmt.Sprint(x) as string)) +} + +if ($.isMainScript(import.meta)) { + await main() +} diff --git a/tests/tests/fmt_map_sorted/index.ts b/tests/tests/fmt_map_sorted/index.ts new file mode 100644 index 000000000..cb8047ac3 --- /dev/null +++ b/tests/tests/fmt_map_sorted/index.ts @@ -0,0 +1 @@ +export { key } from "./fmt_map_sorted.gs.ts" diff --git a/tests/tests/fmt_map_sorted/tsconfig.json b/tests/tests/fmt_map_sorted/tsconfig.json new file mode 100644 index 000000000..2bd9e68c0 --- /dev/null +++ b/tests/tests/fmt_map_sorted/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "paths": { + "*": [ + "./*" + ], + "@go/github.com/s4wave/goscript/*": [ + "../../../*" + ], + "@goscript/*": [ + "../../../tests/tests/fmt_map_sorted/run/output/@goscript/*", + "../../../gs/*", + "../../../tests/deps/*" + ], + "@goscript/github.com/s4wave/goscript/tests/tests/fmt_map_sorted/*": [ + "./*" + ] + } + }, + "extends": "../../../tests/tsconfig.base.json", + "include": [ + "fmt_map_sorted.gs.ts", + "index.ts" + ] +} diff --git a/tests/tests/fmt_type_func/expected.log b/tests/tests/fmt_type_func/expected.log new file mode 100644 index 000000000..72eaf6099 --- /dev/null +++ b/tests/tests/fmt_type_func/expected.log @@ -0,0 +1,3 @@ +func(int) string|func(int, int) int|main.handler|func(...interface {}) (int, error) +func() +func(int) string diff --git a/tests/tests/fmt_type_func/fmt_type_func.go b/tests/tests/fmt_type_func/fmt_type_func.go new file mode 100644 index 000000000..68c5d9686 --- /dev/null +++ b/tests/tests/fmt_type_func/fmt_type_func.go @@ -0,0 +1,17 @@ +package main + +import "fmt" + +type handler func(string) error + +func add(a, b int) int { return a + b } + +func main() { + f := func(a int) string { return "" } + var g func() + var h handler = func(string) error { return nil } + fmt.Printf("%T|%T|%T|%T\n", f, add, h, func(...any) (int, error) { return 0, nil }) + fmt.Printf("%T\n", g) + var x any = f + fmt.Println(fmt.Sprintf("%T", x)) +} diff --git a/tests/tests/fmt_type_func/fmt_type_func.gs.ts b/tests/tests/fmt_type_func/fmt_type_func.gs.ts new file mode 100644 index 000000000..d7ae9a3cd --- /dev/null +++ b/tests/tests/fmt_type_func/fmt_type_func.gs.ts @@ -0,0 +1,31 @@ +// Generated file based on fmt_type_func.go +// Updated when compliance tests are re-run, DO NOT EDIT! + +import * as $ from "@goscript/builtin/index.js" +import * as fmt from "@goscript/fmt/index.js" + +export type handler = ((_p0: string) => $.GoError | globalThis.Promise<$.GoError>) | null + +export function add(a: number, b: number): number { + return a + b +} + +export async function main(): globalThis.Promise { + let f = $.functionValue((a: number): string => { + return "" + }, ({ kind: $.TypeKind.Function, params: [/* @__PURE__ */ $.basicType("int")], results: [/* @__PURE__ */ $.basicType("string")] } as $.FunctionTypeInfo)) + let g: (() => void) | null = null! as (() => void) | null + let h: handler | null = $.functionValue((_p0: string): $.GoError => { + return null + }, ({ kind: $.TypeKind.Function, params: [/* @__PURE__ */ $.basicType("string")], results: ["error"] } as $.FunctionTypeInfo)) + fmt.Printf("%T|%T|%T|%T\n", $.interfaceValue(f, "func(a int) string", ({ kind: $.TypeKind.Function, params: [/* @__PURE__ */ $.basicType("int")], results: [/* @__PURE__ */ $.basicType("string")] } as $.FunctionTypeInfo)), $.interfaceValue($.goFunc(add, "main.add"), "func(a int, b int) int", ({ kind: $.TypeKind.Function, params: [/* @__PURE__ */ $.basicType("int"), /* @__PURE__ */ $.basicType("int")], results: [/* @__PURE__ */ $.basicType("int")] } as $.FunctionTypeInfo)), $.interfaceValue(h, "main.handler", ({ kind: $.TypeKind.Function, name: "main.handler", params: [/* @__PURE__ */ $.basicType("string")], results: ["error"] } as $.FunctionTypeInfo)), $.interfaceValue($.functionValue((_p0: $.Slice): [number, $.GoError] => { + return [0, null] + }, ({ kind: $.TypeKind.Function, params: [/* @__PURE__ */ $.sliceType({ kind: $.TypeKind.Interface, methods: [] })], results: [/* @__PURE__ */ $.basicType("int"), "error"], isVariadic: true } as $.FunctionTypeInfo)), "func(...any) (int, error)", ({ kind: $.TypeKind.Function, params: [/* @__PURE__ */ $.sliceType({ kind: $.TypeKind.Interface, methods: [] })], results: [/* @__PURE__ */ $.basicType("int"), "error"], isVariadic: true } as $.FunctionTypeInfo))) + fmt.Printf("%T\n", $.interfaceValue(g, "func()", ({ kind: $.TypeKind.Function, params: [], results: [] } as $.FunctionTypeInfo))) + let x: any = $.interfaceValue(f, "func(a int) string", ({ kind: $.TypeKind.Function, params: [/* @__PURE__ */ $.basicType("int")], results: [/* @__PURE__ */ $.basicType("string")] } as $.FunctionTypeInfo)) + fmt.Println((fmt.Sprintf("%T", x) as string)) +} + +if ($.isMainScript(import.meta)) { + await main() +} diff --git a/tests/tests/fmt_type_func/index.ts b/tests/tests/fmt_type_func/index.ts new file mode 100644 index 000000000..460fdd560 --- /dev/null +++ b/tests/tests/fmt_type_func/index.ts @@ -0,0 +1 @@ +export type { handler } from "./fmt_type_func.gs.ts" diff --git a/tests/tests/fmt_type_func/tsconfig.json b/tests/tests/fmt_type_func/tsconfig.json new file mode 100644 index 000000000..d73212f58 --- /dev/null +++ b/tests/tests/fmt_type_func/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "paths": { + "*": [ + "./*" + ], + "@go/github.com/s4wave/goscript/*": [ + "../../../*" + ], + "@goscript/*": [ + "../../../tests/tests/fmt_type_func/run/output/@goscript/*", + "../../../gs/*", + "../../../tests/deps/*" + ], + "@goscript/github.com/s4wave/goscript/tests/tests/fmt_type_func/*": [ + "./*" + ] + } + }, + "extends": "../../../tests/tsconfig.base.json", + "include": [ + "fmt_type_func.gs.ts", + "index.ts" + ] +}