feat: minor fixes for spec v1.4 compliance

This commit is contained in:
Johann Schopplich
2025-11-05 19:04:00 +01:00
parent 1b87cfe58b
commit 840626dc90
10 changed files with 143 additions and 33 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -38,6 +38,6 @@
"test": "vitest"
},
"devDependencies": {
"@toon-format/spec": "^1.3.3"
"@toon-format/spec": "^1.4.0"
}
}

View File

@@ -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 } {

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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<string, unknown>)?.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) {

View File

@@ -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')
})
})
})

10
pnpm-lock.yaml generated
View File

@@ -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: