diff --git a/src/adf/editor-normal.test.ts b/src/adf/editor-normal.test.ts index f640a48..bb0dd40 100644 --- a/src/adf/editor-normal.test.ts +++ b/src/adf/editor-normal.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import type { AdfNode } from './document.ts' +import type { JsonValue } from '../json-value.ts' import { toEditorNormal } from './editor-normal.ts' test('merges adjacent text nodes carrying identical marks, at every level', () => { @@ -59,7 +60,7 @@ test('reads an empty attrs object, marks array or content array as the absent ke assert.deepEqual(toEditorNormal({ content: [], type: 'doc', version: 1 }), { type: 'doc', version: 1 }) }) -test('normalizes a document nesting far past the levels a recursive walk survives', () => { +test('normalizes blocks and mark attributes nesting far past the levels a recursive walk survives', () => { const levels = 100000 let node: AdfNode = { content: [], type: 'paragraph' } for (let level = 0; level < levels; level += 1) node = { content: [node], type: 'blockquote' } @@ -68,4 +69,9 @@ test('normalizes a document nesting far past the levels a recursive walk survive for (; normal?.content !== undefined; depth += 1) normal = normal.content[0] assert.equal(depth, levels) assert.deepEqual(normal, { type: 'paragraph' }) + let deep: JsonValue = 1 + for (let level = 0; level < 2 * levels; level += 1) deep = [deep] + const marks = [{ attrs: { deep }, type: 'textColor' }] + const merged = toEditorNormal({ content: [{ content: [{ marks, text: 'a', type: 'text' }, { marks, text: 'b', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }) + assert.deepEqual(merged.content?.[0]?.content?.map((text) => text.text), ['ab']) }) diff --git a/src/canonical-json.test.ts b/src/canonical-json.test.ts index b6fdaf4..26cdf8f 100644 --- a/src/canonical-json.test.ts +++ b/src/canonical-json.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' +import type { JsonValue } from './json-value.ts' import { serializeCanonicalJson } from './canonical-json.ts' test('sorts object keys recursively', () => { @@ -39,3 +40,15 @@ test('leaves non-ASCII raw', () => { test('spells scalars in canonical JSON', () => { assert.equal(serializeCanonicalJson([null, true, false, 0, -1.5, 'a"b'], 'compact'), '[null,true,false,0,-1.5,"a\\"b"]') }) + +test('spells a value nesting far past the levels a recursive walk survives', () => { + const levels = 200000 + let array: JsonValue = 1 + let object: JsonValue = 1 + for (let level = 0; level < levels; level += 1) { + array = [array] + object = { a: object } + } + assert.equal(serializeCanonicalJson(array, 'compact'), `${'['.repeat(levels)}1${']'.repeat(levels)}`) + assert.equal(serializeCanonicalJson(object, 'compact'), `${'{"a":'.repeat(levels)}1${'}'.repeat(levels)}`) +}) diff --git a/src/canonical-json.ts b/src/canonical-json.ts index 2f08e20..487dd99 100644 --- a/src/canonical-json.ts +++ b/src/canonical-json.ts @@ -2,28 +2,42 @@ import type { JsonValue } from './json-value.ts' export type JsonSpelling = 'compact' | 'two-space' +type Member = { label: string; value: JsonValue } + +type Pending = string | { depth: number; value: JsonValue } + export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling): string { - return serialize(value, spelling === 'compact' ? '' : ' ', 0) + const indent = spelling === 'compact' ? '' : ' ' + const text: string[] = [] + const pending: Pending[] = [{ depth: 0, value }] + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + if (typeof next === 'string') { + text.push(next) + continue + } + const { depth, value: held } = next + if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth) + else if (held !== null && typeof held === 'object') schedule(pending, '{', objectMembers(held, indent), '}', indent, depth) + else text.push(JSON.stringify(held)) + } + return text.join('') } -function serialize(value: JsonValue, indent: string, depth: number): string { - if (Array.isArray(value)) { - if (value.length === 0) return '[]' - const items = value.map((item) => serialize(item, indent, depth + 1)) - return `[${join(items, indent, depth)}]` - } - if (value !== null && typeof value === 'object') { - const keys = Object.keys(value).sort() - if (keys.length === 0) return '{}' - const separator = indent === '' ? ':' : ': ' - const entries = keys.map((key) => `${JSON.stringify(key)}${separator}${serialize(value[key] ?? null, indent, depth + 1)}`) - return `{${join(entries, indent, depth)}}` - } - return JSON.stringify(value) +function objectMembers(value: { [key: string]: JsonValue }, indent: string): Member[] { + const separator = indent === '' ? ':' : ': ' + return Object.keys(value) + .sort() + .map((key) => ({ label: `${JSON.stringify(key)}${separator}`, value: value[key] ?? null })) } -function join(parts: readonly string[], indent: string, depth: number): string { - if (indent === '') return parts.join(',') - const inner = `\n${indent.repeat(depth + 1)}` - return `${inner}${parts.join(`,${inner}`)}\n${indent.repeat(depth)}` +function schedule(pending: Pending[], open: string, members: readonly Member[], close: string, indent: string, depth: number): void { + if (members.length === 0) { + pending.push(`${open}${close}`) + return + } + const inner = indent === '' ? '' : `\n${indent.repeat(depth + 1)}` + const scheduled: Pending[] = [] + for (const [index, member] of members.entries()) scheduled.push(`${index === 0 ? open : ','}${inner}${member.label}`, { depth: depth + 1, value: member.value }) + scheduled.push(indent === '' ? close : `\n${indent.repeat(depth)}${close}`) + for (const item of scheduled.reverse()) pending.push(item) }