Skip to content

Commit a4db8e5

Browse files
committed
fix: improve fmt.ts compliance
Signed-off-by: Christian Stewart <[email protected]>
1 parent 01224da commit a4db8e5

File tree

5 files changed

+235
-11
lines changed

5 files changed

+235
-11
lines changed

.vscode/extensions.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"recommendations": [
33
"github.copilot-chat",
44
"golang.go",
5-
"esbenp.prettier-vscode"
5+
"esbenp.prettier-vscode",
6+
"vitest.explorer"
67
]
78
}

gs/fmt/fmt.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest'
2+
import * as fmt from './fmt.js'
3+
4+
// Helper to capture stdout via internal stdout.write
5+
// We will monkey-patch global process.stdout.write if available
6+
function captureStdout(run: () => void): string {
7+
let buf = ''
8+
const hasProcess =
9+
typeof process !== 'undefined' &&
10+
(process as any).stdout &&
11+
typeof (process as any).stdout.write === 'function'
12+
13+
if (hasProcess) {
14+
const orig = (process as any).stdout.write
15+
;(process as any).stdout.write = (chunk: any) => {
16+
buf += typeof chunk === 'string' ? chunk : String(chunk)
17+
return true
18+
}
19+
try {
20+
run()
21+
} finally {
22+
;(process as any).stdout.write = orig
23+
}
24+
} else {
25+
// Fallback: spy on console.log for environments without process
26+
const origLog = console.log
27+
;(console as any).log = (msg: any) => {
28+
buf += String(msg) + '\n'
29+
}
30+
try {
31+
run()
32+
} finally {
33+
console.log = origLog
34+
}
35+
}
36+
37+
return buf
38+
}
39+
40+
describe('fmt basic value formatting', () => {
41+
it('%T approximations for primitives', () => {
42+
// routed through Sprintf which uses format parsing
43+
expect(fmt.Sprintf('Type: %T', 123)).toBe('Type: int')
44+
expect(fmt.Sprintf('Type: %T', 3.14)).toBe('Type: float64')
45+
expect(fmt.Sprintf('Type: %T', 'hello')).toBe('Type: string')
46+
expect(fmt.Sprintf('Type: %T', true)).toBe('Type: bool')
47+
})
48+
49+
it('%d truncation behavior including negatives', () => {
50+
expect(fmt.Sprintf('%d', 42.9)).toBe('42')
51+
expect(fmt.Sprintf('%d', -42.9)).toBe('-42')
52+
})
53+
54+
it('%q quoted string and rune', () => {
55+
expect(fmt.Sprintf('%q', 'hello')).toBe(JSON.stringify('hello'))
56+
// rune-like number
57+
expect(fmt.Sprintf('%q', 97)).toBe(JSON.stringify('a'))
58+
})
59+
60+
it('%p pointer-ish formatting fallback', () => {
61+
expect(fmt.Sprintf('%p', {})).toBe('0x0')
62+
expect(fmt.Sprintf('%p', { __address: 255 })).toBe('0xff')
63+
})
64+
65+
it('%v default formats for arrays/maps/sets', () => {
66+
expect(fmt.Sprintf('%v', [1, 2, 3])).toBe('[1 2 3]')
67+
const m = new Map<any, any>()
68+
m.set('a', 1)
69+
m.set('b', 2)
70+
const out = fmt.Sprintf('%v', m)
71+
// Order in Map iteration is insertion order; verify shape
72+
expect(out.startsWith('{')).toBe(true)
73+
expect(out.includes('a:1')).toBe(true)
74+
expect(out.includes('b:2')).toBe(true)
75+
expect(out.endsWith('}')).toBe(true)
76+
77+
const s = new Set<any>([1, 2, 3])
78+
expect(fmt.Sprintf('%v', s)).toBe('[1 2 3]')
79+
})
80+
81+
it('error and stringer precedence', () => {
82+
const err = {
83+
Error() {
84+
return 'some error'
85+
},
86+
}
87+
expect(fmt.Sprintf('%v', err)).toBe('some error')
88+
89+
const stringer = {
90+
String() {
91+
return 'I am stringer'
92+
},
93+
}
94+
expect(fmt.Sprintf('%v', stringer)).toBe('I am stringer')
95+
96+
const goStringer = {
97+
GoString() {
98+
return '<go stringer>'
99+
},
100+
}
101+
// We prefer GoString() first
102+
expect(fmt.Sprintf('%v', goStringer)).toBe('<go stringer>')
103+
})
104+
})
105+
106+
describe('fmt spacing rules', () => {
107+
it('Sprint: space only between non-strings', () => {
108+
// Two non-strings => one space
109+
expect(fmt.Sprint(1, 2)).toBe('1 2')
110+
// If either is string => no automatic space
111+
expect(fmt.Sprint('a', 'b')).toBe('ab')
112+
expect(fmt.Sprint('a', 1)).toBe('a1')
113+
expect(fmt.Sprint(1, 'b')).toBe('1b')
114+
// Mixed 3 args
115+
expect(fmt.Sprint('a', 1, 'b')).toBe('a1b')
116+
// Go's Sprint inserts a space only when both adjacent operands are non-strings.
117+
// Between 'b' (string) and 2 (number) there is no automatic space.
118+
expect(fmt.Sprint(1, 'b', 2)).toBe('1b2')
119+
})
120+
121+
it('Print: same spacing as Sprint, outputs to stdout', () => {
122+
const output = captureStdout(() => {
123+
fmt.Print(1, 2, 'x', 3)
124+
})
125+
expect(output).toBe('1 2x3')
126+
})
127+
128+
it('Println: always separates by spaces and appends newline', () => {
129+
const output = captureStdout(() => {
130+
fmt.Println('hi', 'there', 1, 2)
131+
})
132+
expect(output).toBe('hi there 1 2\n')
133+
})
134+
135+
it('Fprint/Fprintln behave like Print/Println with writers', () => {
136+
const chunks: Uint8Array[] = []
137+
const writer = {
138+
Write(b: Uint8Array): [number, any] {
139+
chunks.push(b)
140+
return [b.length, null]
141+
},
142+
}
143+
144+
let [n, err] = fmt.Fprint(writer, 1, 2, 'x', 3)
145+
expect(err).toBeNull()
146+
expect(n).toBe(5) // "1 2x3".length
147+
expect(new TextDecoder().decode(chunks[0])).toBe('1 2x3')
148+
149+
;[n, err] = fmt.Fprintln(writer, 'hi', 'there', 1, 2)
150+
expect(err).toBeNull()
151+
expect(new TextDecoder().decode(chunks[1])).toBe('hi there 1 2\n')
152+
})
153+
})
154+
155+
describe('fmt parseFormat basic cases', () => {
156+
it('Printf with %d, %s, %f, width and precision', () => {
157+
expect(fmt.Sprintf('n=%d s=%s f=%f', 42, 'ok', 3.5)).toBe('n=42 s=ok f=3.5')
158+
expect(fmt.Sprintf("'%5s'", 'hi')).toBe("' hi'")
159+
expect(fmt.Sprintf("'%-.3f'", 3.14159)).toBe("'3.142'") // JS rounds
160+
expect(fmt.Sprintf("'%6.2f'", 3.14159)).toBe("' 3.14'")
161+
})
162+
163+
it('Printf with %% and missing args', () => {
164+
expect(fmt.Sprintf('100%% done')).toBe('100% done')
165+
// When the first argument is present but the second is missing,
166+
// Go prints the formatted first arg followed by the missing marker for the second.
167+
expect(fmt.Sprintf('%d %s', 1)).toBe('1 %!s(MISSING)')
168+
})
169+
170+
it('Printf hex/octal/bin', () => {
171+
expect(fmt.Sprintf('%x %X %o %b', 255, 255, 8, 5)).toBe('ff FF 10 101')
172+
})
173+
174+
it('Printf %c for code points', () => {
175+
expect(fmt.Sprintf('%c', 65)).toBe('A')
176+
})
177+
})

gs/fmt/fmt.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ function formatValue(value: any, verb: string): string {
3434
case 'v': // default format
3535
return defaultFormat(value)
3636
case 'd': // decimal integer
37-
return String(Math.floor(Number(value)))
37+
return String(Math.trunc(Number(value)))
3838
case 'f': // decimal point, no exponent
3939
return Number(value).toString()
4040
case 's': // string
@@ -66,10 +66,18 @@ function formatValue(value: any, verb: string): string {
6666
return Number(value).toPrecision()
6767
case 'G': // %E for large exponents, %F otherwise
6868
return Number(value).toPrecision().toUpperCase()
69-
case 'q': // quoted string
69+
case 'q': // quoted string / rune
70+
if (typeof value === 'number' && Number.isInteger(value)) {
71+
// emulate quoted rune for integers in basic range
72+
const ch = String.fromCodePoint(value)
73+
return JSON.stringify(ch)
74+
}
7075
return JSON.stringify(String(value))
71-
case 'p': // pointer (address)
72-
return '0x' + (value as any)?.__address?.toString(16) || '0'
76+
case 'p': { // pointer (address)
77+
const addr = (value as any)?.__address
78+
if (typeof addr === 'number') return '0x' + addr.toString(16)
79+
return '0x0'
80+
}
7381
default:
7482
return String(value)
7583
}
@@ -83,6 +91,15 @@ function defaultFormat(value: any): string {
8391
if (Array.isArray(value))
8492
return '[' + value.map(defaultFormat).join(' ') + ']'
8593
if (typeof value === 'object') {
94+
// Prefer GoStringer if present
95+
if (
96+
(value as any).GoString &&
97+
typeof (value as any).GoString === 'function'
98+
) {
99+
try {
100+
return (value as any).GoString()
101+
} catch {}
102+
}
86103
// Prefer error interface if present
87104
if ((value as any).Error && typeof (value as any).Error === 'function') {
88105
try {
@@ -95,8 +112,26 @@ function defaultFormat(value: any): string {
95112
return (value as any).String()
96113
} catch {}
97114
}
115+
// Basic Map/Set rendering
116+
if (value instanceof Map) {
117+
const parts: string[] = []
118+
for (const [k, v] of (value as Map<any, any>).entries()) {
119+
parts.push(`${defaultFormat(k)}:${defaultFormat(v)}`)
120+
}
121+
return `{${parts.join(' ')}}`
122+
}
123+
if (value instanceof Set) {
124+
const parts: string[] = []
125+
for (const v of (value as Set<any>).values()) {
126+
parts.push(defaultFormat(v))
127+
}
128+
return '[' + parts.join(' ') + ']'
129+
}
98130
// Default object representation
99-
if ((value as any).constructor?.name && (value as any).constructor.name !== 'Object') {
131+
if (
132+
(value as any).constructor?.name &&
133+
(value as any).constructor.name !== 'Object'
134+
) {
100135
return `{${Object.entries(value as Record<string, any>)
101136
.map(([k, v]) => `${k}:${defaultFormat(v)}`)
102137
.join(' ')}}`
@@ -300,9 +335,18 @@ export function Sprintln(...a: any[]): string {
300335

301336
// Fprint functions (write to Writer) - simplified implementation
302337
export function Fprint(w: any, ...a: any[]): [number, $.GoError | null] {
303-
const result = a.map(defaultFormat).join(' ')
338+
// Same spacing as Print
339+
let out = ''
340+
for (let i = 0; i < a.length; i++) {
341+
if (i > 0) {
342+
const prevIsString = typeof a[i - 1] === 'string'
343+
const currIsString = typeof a[i] === 'string'
344+
if (!prevIsString && !currIsString) out += ' '
345+
}
346+
out += defaultFormat(a[i])
347+
}
304348
if (w && w.Write) {
305-
return w.Write(new TextEncoder().encode(result))
349+
return w.Write(new TextEncoder().encode(out))
306350
}
307351
return [0, $.newError('Writer does not implement Write method')]
308352
}
@@ -320,7 +364,9 @@ export function Fprintf(
320364
}
321365

322366
export function Fprintln(w: any, ...a: any[]): [number, $.GoError | null] {
323-
const result = a.map(defaultFormat).join(' ') + '\n'
367+
// Same behavior as Println
368+
const body = a.map(defaultFormat).join(' ')
369+
const result = body + '\n'
324370
if (w && w.Write) {
325371
return w.Write(new TextEncoder().encode(result))
326372
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"prepublishOnly": "npm run build",
4848
"example": "cd ./example/simple && bash run.bash",
4949
"test": "npm run test:go && npm run test:js",
50-
"test:go": "go test -v ./...",
50+
"test:go": "go test ./...",
5151
"test:js": "npm run typecheck && vitest run",
5252
"typecheck": "tsgo --noEmit -p tsconfig.build.json",
5353
"format": "npm run format:go && npm run format:js && npm run format:config",

vitest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url'
44

55
export default defineConfig({
66
test: {
7-
exclude: [...configDefaults.exclude, 'dist', 'vendor'],
7+
exclude: [...configDefaults.exclude, 'dist', 'vendor', '**/vendor'],
88
},
99
resolve: {
1010
alias: [

0 commit comments

Comments
 (0)