From 840626dc906301cb2d9a30bd7d6b89cc3755fc7f Mon Sep 17 00:00:00 2001 From: Johann Schopplich Date: Wed, 5 Nov 2025 19:04:00 +0100 Subject: [PATCH] feat: minor fixes for spec v1.4 compliance --- README.md | 4 +- SPEC.md | 2 +- packages/toon/package.json | 2 +- packages/toon/src/decode/parser.ts | 16 +-- packages/toon/src/index.ts | 2 +- packages/toon/src/shared/literal-utils.ts | 4 +- packages/toon/test/decode.test.ts | 6 ++ packages/toon/test/encode.test.ts | 15 +-- packages/toon/test/normalization.test.ts | 115 ++++++++++++++++++++++ pnpm-lock.yaml | 10 +- 10 files changed, 143 insertions(+), 33 deletions(-) create mode 100644 packages/toon/test/normalization.test.ts diff --git a/README.md b/README.md index 270c6e6..3eb1226 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![CI](https://github.com/toon-format/toon/actions/workflows/ci.yml/badge.svg)](https://github.com/toon-format/toon/actions) [![npm version](https://img.shields.io/npm/v/@toon-format/toon.svg)](https://www.npmjs.com/package/@toon-format/toon) -[![SPEC v1.3](https://img.shields.io/badge/spec-v1.3-lightgray)](https://github.com/toon-format/spec) +[![SPEC v1.4](https://img.shields.io/badge/spec-v1.4-lightgray)](https://github.com/toon-format/spec) [![npm downloads (total)](https://img.shields.io/npm/dt/@toon-format/toon.svg)](https://www.npmjs.com/package/@toon-format/toon) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) @@ -1022,7 +1022,7 @@ Task: Return only users with role "user" as TOON. Use the same header. Set [N] t ## Other Implementations > [!NOTE] -> When implementing TOON in other languages, please follow the [specification](https://github.com/toon-format/spec/blob/main/SPEC.md) (currently v1.3) to ensure compatibility across implementations. The [conformance tests](https://github.com/toon-format/spec/tree/main/tests) provide language-agnostic test fixtures that validate implementations across any language. +> When implementing TOON in other languages, please follow the [specification](https://github.com/toon-format/spec/blob/main/SPEC.md) (currently v1.4) to ensure compatibility across implementations. The [conformance tests](https://github.com/toon-format/spec/tree/main/tests) provide language-agnostic test fixtures that validate implementations across any language. ### Official Implementations diff --git a/SPEC.md b/SPEC.md index 513c4c3..9456f79 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,7 +4,7 @@ The TOON specification has moved to a dedicated repository: [github.com/toon-for ## Current Version -**Version 1.3** (2025-10-31) +**Version 1.4** (2025-11-05) ## Quick Links diff --git a/packages/toon/package.json b/packages/toon/package.json index 6e104a6..9004e66 100644 --- a/packages/toon/package.json +++ b/packages/toon/package.json @@ -38,6 +38,6 @@ "test": "vitest" }, "devDependencies": { - "@toon-format/spec": "^1.3.3" + "@toon-format/spec": "^1.4.0" } } diff --git a/packages/toon/src/decode/parser.ts b/packages/toon/src/decode/parser.ts index 847f32c..1bd17d5 100644 --- a/packages/toon/src/decode/parser.ts +++ b/packages/toon/src/decode/parser.ts @@ -219,7 +219,9 @@ export function parsePrimitiveToken(token: string): JsonPrimitive { // Numeric literal if (isNumericLiteral(trimmed)) { - return Number.parseFloat(trimmed) + const parsedNumber = Number.parseFloat(trimmed) + // Normalize negative zero to positive zero + return Object.is(parsedNumber, -0) ? 0 : parsedNumber } // Unquoted string @@ -227,26 +229,26 @@ export function parsePrimitiveToken(token: string): JsonPrimitive { } export function parseStringLiteral(token: string): string { - const trimmed = token.trim() + const trimmedToken = token.trim() - if (trimmed.startsWith(DOUBLE_QUOTE)) { + if (trimmedToken.startsWith(DOUBLE_QUOTE)) { // Find the closing quote, accounting for escaped quotes - const closingQuoteIndex = findClosingQuote(trimmed, 0) + const closingQuoteIndex = findClosingQuote(trimmedToken, 0) if (closingQuoteIndex === -1) { // No closing quote was found throw new SyntaxError('Unterminated string: missing closing quote') } - if (closingQuoteIndex !== trimmed.length - 1) { + if (closingQuoteIndex !== trimmedToken.length - 1) { throw new SyntaxError('Unexpected characters after closing quote') } - const content = trimmed.slice(1, closingQuoteIndex) + const content = trimmedToken.slice(1, closingQuoteIndex) return unescapeString(content) } - return trimmed + return trimmedToken } export function parseUnquotedKey(content: string, start: number): { key: string, end: number } { diff --git a/packages/toon/src/index.ts b/packages/toon/src/index.ts index bedf973..ef2a9c5 100644 --- a/packages/toon/src/index.ts +++ b/packages/toon/src/index.ts @@ -30,7 +30,7 @@ export function decode(input: string, options?: DecodeOptions): JsonValue { const scanResult = toParsedLines(input, resolvedOptions.indent, resolvedOptions.strict) if (scanResult.lines.length === 0) { - throw new TypeError('Cannot decode empty input: input must be a non-empty string') + return {} } const cursor = new LineCursor(scanResult.lines, scanResult.blankLines) diff --git a/packages/toon/src/shared/literal-utils.ts b/packages/toon/src/shared/literal-utils.ts index 201ea53..9682917 100644 --- a/packages/toon/src/shared/literal-utils.ts +++ b/packages/toon/src/shared/literal-utils.ts @@ -23,6 +23,6 @@ export function isNumericLiteral(token: string): boolean { } // Check if it's a valid number - const num = Number(token) - return !Number.isNaN(num) && Number.isFinite(num) + const numericValue = Number(token) + return !Number.isNaN(numericValue) && Number.isFinite(numericValue) } diff --git a/packages/toon/test/decode.test.ts b/packages/toon/test/decode.test.ts index d17fb77..0ee94c2 100644 --- a/packages/toon/test/decode.test.ts +++ b/packages/toon/test/decode.test.ts @@ -5,19 +5,25 @@ import arraysTabular from '@toon-format/spec/tests/fixtures/decode/arrays-tabula import blankLines from '@toon-format/spec/tests/fixtures/decode/blank-lines.json' import delimiters from '@toon-format/spec/tests/fixtures/decode/delimiters.json' import indentationErrors from '@toon-format/spec/tests/fixtures/decode/indentation-errors.json' +import numbers from '@toon-format/spec/tests/fixtures/decode/numbers.json' import objects from '@toon-format/spec/tests/fixtures/decode/objects.json' import primitives from '@toon-format/spec/tests/fixtures/decode/primitives.json' +import rootForm from '@toon-format/spec/tests/fixtures/decode/root-form.json' import validationErrors from '@toon-format/spec/tests/fixtures/decode/validation-errors.json' +import whitespace from '@toon-format/spec/tests/fixtures/decode/whitespace.json' import { describe, expect, it } from 'vitest' import { decode } from '../src/index' const fixtureFiles = [ primitives, + numbers, objects, arraysPrimitive, arraysTabular, arraysNested, delimiters, + whitespace, + rootForm, validationErrors, indentationErrors, blankLines, diff --git a/packages/toon/test/encode.test.ts b/packages/toon/test/encode.test.ts index 25abfab..173387e 100644 --- a/packages/toon/test/encode.test.ts +++ b/packages/toon/test/encode.test.ts @@ -5,13 +5,12 @@ import arraysObjects from '@toon-format/spec/tests/fixtures/encode/arrays-object import arraysPrimitive from '@toon-format/spec/tests/fixtures/encode/arrays-primitive.json' import arraysTabular from '@toon-format/spec/tests/fixtures/encode/arrays-tabular.json' import delimiters from '@toon-format/spec/tests/fixtures/encode/delimiters.json' -import normalization from '@toon-format/spec/tests/fixtures/encode/normalization.json' import objects from '@toon-format/spec/tests/fixtures/encode/objects.json' import options from '@toon-format/spec/tests/fixtures/encode/options.json' import primitives from '@toon-format/spec/tests/fixtures/encode/primitives.json' import whitespace from '@toon-format/spec/tests/fixtures/encode/whitespace.json' import { describe, expect, it } from 'vitest' -import { decode, DEFAULT_DELIMITER, encode } from '../src/index' +import { DEFAULT_DELIMITER, encode } from '../src/index' const fixtureFiles = [ primitives, @@ -21,22 +20,10 @@ const fixtureFiles = [ arraysNested, arraysObjects, delimiters, - normalization, whitespace, options, ] as Fixtures[] -// Special test for round-trip fidelity (not in JSON fixtures) -describe('round-trip fidelity', () => { - it('preserves precision for repeating decimals', () => { - const value = 1 / 3 - const encodedValue = encode({ value }) - const decodedValue = decode(encodedValue) - expect((decodedValue as Record)?.value).toBe(value) // Round-trip fidelity - expect(encodedValue).toContain('0.3333333333333333') // Default JS precision - }) -}) - for (const fixtures of fixtureFiles) { describe(fixtures.description, () => { for (const test of fixtures.tests) { diff --git a/packages/toon/test/normalization.test.ts b/packages/toon/test/normalization.test.ts new file mode 100644 index 0000000..7344d9e --- /dev/null +++ b/packages/toon/test/normalization.test.ts @@ -0,0 +1,115 @@ +/* eslint-disable test/prefer-lowercase-title */ +import { describe, expect, it } from 'vitest' +import { decode, encode } from '../src/index' + +describe('JavaScript-specific type normalization', () => { + describe('BigInt normalization', () => { + it('converts BigInt within safe integer range to number', () => { + const result = encode(BigInt(123)) + expect(result).toBe('123') + }) + + it('converts BigInt at MAX_SAFE_INTEGER boundary to number', () => { + const result = encode(BigInt(Number.MAX_SAFE_INTEGER)) + expect(result).toBe('9007199254740991') + }) + + it('converts BigInt beyond safe integer range to quoted string', () => { + const result = encode(BigInt('9007199254740992')) + expect(result).toBe('"9007199254740992"') + }) + + it('converts large BigInt to quoted decimal string', () => { + const result = encode(BigInt('12345678901234567890')) + expect(result).toBe('"12345678901234567890"') + }) + }) + + describe('Date normalization', () => { + it('converts Date to ISO 8601 quoted string', () => { + const result = encode(new Date('2025-01-01T00:00:00.000Z')) + expect(result).toBe('"2025-01-01T00:00:00.000Z"') + }) + + it('converts Date with milliseconds to ISO quoted string', () => { + const result = encode(new Date('2025-11-05T12:34:56.789Z')) + expect(result).toBe('"2025-11-05T12:34:56.789Z"') + }) + }) + + describe('Set normalization', () => { + it('converts Set to array', () => { + const input = new Set(['a', 'b', 'c']) + const encoded = encode(input) + const decoded = decode(encoded) + expect(decoded).toEqual(['a', 'b', 'c']) + }) + + it('converts empty Set to empty array', () => { + const result = encode(new Set()) + expect(result).toBe('[0]:') + }) + }) + + describe('Map normalization', () => { + it('converts Map to object', () => { + const input = new Map([['key1', 'value1'], ['key2', 'value2']]) + const encoded = encode(input) + const decoded = decode(encoded) + expect(decoded).toEqual({ key1: 'value1', key2: 'value2' }) + }) + + it('converts empty Map to empty object', () => { + const input = new Map() + const result = encode(input) + expect(result).toBe('') + }) + + it('converts Map with numeric keys to object with quoted string keys', () => { + const input = new Map([[1, 'one'], [2, 'two']]) + const result = encode(input) + expect(result).toBe('"1": one\n"2": two') + }) + }) + + describe('undefined, function, and Symbol normalization', () => { + it('converts undefined to null', () => { + const result = encode(undefined) + expect(result).toBe('null') + }) + + it('converts function to null', () => { + const result = encode(() => {}) + expect(result).toBe('null') + }) + + it('converts Symbol to null', () => { + const result = encode(Symbol('test')) + expect(result).toBe('null') + }) + }) + + describe('NaN and Infinity normalization', () => { + it('converts NaN to null', () => { + const result = encode(Number.NaN) + expect(result).toBe('null') + }) + + it('converts Infinity to null', () => { + const result = encode(Number.POSITIVE_INFINITY) + expect(result).toBe('null') + }) + + it('converts negative Infinity to null', () => { + const result = encode(Number.NEGATIVE_INFINITY) + expect(result).toBe('null') + }) + }) + + describe('negative zero normalization', () => { + it('normalizes -0 to 0', () => { + const result = encode(-0) + expect(result).toBe('0') + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0e08a7..46a7b15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,8 +102,8 @@ importers: packages/toon: devDependencies: '@toon-format/spec': - specifier: ^1.3.3 - version: 1.3.3 + specifier: ^1.4.0 + version: 1.4.0 packages: @@ -833,8 +833,8 @@ packages: peerDependencies: eslint: '>=9.0.0' - '@toon-format/spec@1.3.3': - resolution: {integrity: sha512-AgOQGwv6EJUGj1zWjaSXMfFn3imsvwC3NHdaXLFmI6zF3dJ3LtrBzU4sg5meVJMO6bxgl4Wrl3/U/b53aDRSPA==} + '@toon-format/spec@1.4.0': + resolution: {integrity: sha512-SSI+mJ0PJW38A0n7JdnMjKEkXoecYAQHz7UG/Rl83mbwi5i0JcKeHIToLS+Q04OQZGlu9bt2Jzq5t+SaiMdsMg==} '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -3042,7 +3042,7 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.3 - '@toon-format/spec@1.3.3': {} + '@toon-format/spec@1.4.0': {} '@tybys/wasm-util@0.10.1': dependencies: