diff --git a/benchmarks/json.js b/benchmarks/json.js index e72bed91..5cd9c0a1 100644 --- a/benchmarks/json.js +++ b/benchmarks/json.js @@ -157,6 +157,29 @@ suite("JSON.stringify with replacer", () => { const s = JSON.stringify({ name: "test", items: [1, 2, 3], nested: { x: 1 } }, null, "\t"); }, }); + + bench("stringify deeply nested object with 2-space indent", { + setup: () => + Array.from({ length: 50 }).reduce((acc) => ({ child: acc }), { leaf: true }), + run: (deep) => { + const s = JSON.stringify(deep, null, 2); + }, + }); + + bench("stringify deeply nested array with 2-space indent", { + setup: () => Array.from({ length: 50 }).reduce((acc) => [acc], [1]), + run: (deep) => { + const s = JSON.stringify(deep, null, 2); + }, + }); + + bench("stringify very deeply nested object with 2-space indent", { + setup: () => + Array.from({ length: 200 }).reduce((acc) => ({ child: acc }), { leaf: true }), + run: (deep) => { + const s = JSON.stringify(deep, null, 2); + }, + }); }); suite("JSON roundtrip", () => { diff --git a/source/units/Goccia.JSON.pas b/source/units/Goccia.JSON.pas index a7c4ba31..77888ff9 100644 --- a/source/units/Goccia.JSON.pas +++ b/source/units/Goccia.JSON.pas @@ -10,6 +10,7 @@ interface SysUtils, JSONParser, + StringBuffer, Goccia.Values.ArrayValue, Goccia.Values.ObjectValue, @@ -50,10 +51,9 @@ TGocciaJSONStringifier = class function ShouldOmitRootValue(const AValue: TGocciaValue): Boolean; function QuoteJSON5String(const AStr: string): string; function SerializeObjectKey(const AKey: string): string; - function StringifyPreparedValue(const AValue: TGocciaValue; const AIndent: Integer = 0): string; - function StringifyValue(const AValue: TGocciaValue; const AIndent: Integer = 0; const AKey: string = ''): string; - function StringifyObject(const AObj: TGocciaObjectValue; const AIndent: Integer): string; - function StringifyArray(const AArr: TGocciaArrayValue; const AIndent: Integer): string; + procedure WriteValue(var ABuffer: TStringBuffer; const AValue: TGocciaValue; const AIndent: Integer = 0); + procedure WriteObject(var ABuffer: TStringBuffer; const AObj: TGocciaObjectValue; const AIndent: Integer); + procedure WriteArray(var ABuffer: TStringBuffer; const AArr: TGocciaArrayValue; const AIndent: Integer); function MakeIndent(const ALevel: Integer): string; public constructor Create; overload; @@ -66,7 +66,8 @@ TGocciaJSONStringifier = class implementation uses - StringBuffer, + StrUtils, + TextSemantics, Goccia.Arguments.Collection, @@ -458,6 +459,7 @@ function TGocciaJSONStringifier.Stringify(const AValue: TGocciaValue; PreviousPropertyList: TArray; PreviousTraversalStack: TList; RootValue: TGocciaValue; + Buffer: TStringBuffer; begin PreviousGap := FGap; PreviousHasPropertyList := FHasPropertyList; @@ -483,7 +485,11 @@ function TGocciaJSONStringifier.Stringify(const AValue: TGocciaValue; if ShouldOmitRootValue(RootValue) then Result := '' else - Result := StringifyPreparedValue(RootValue); + begin + Buffer := TStringBuffer.Create; + WriteValue(Buffer, RootValue); + Result := Buffer.ToString; + end; finally FTraversalStack.Free; FTraversalStack := PreviousTraversalStack; @@ -503,14 +509,13 @@ function TGocciaJSONStringifier.CircularErrorName: string; end; function TGocciaJSONStringifier.MakeIndent(const ALevel: Integer): string; -var - I: Integer; begin - Result := ''; - if FGap = '' then - Exit; - for I := 1 to ALevel do - Result := Result + FGap; + // ES2026 §25.5.4.5/§25.5.4.6: the indent at depth ALevel is FGap repeated + // ALevel times. DupeString builds it in a single O(ALevel) pass; the previous + // accumulator concatenation copied the growing result each step, making this + // O(ALevel^2) per call and O(depth^3) over a nested chain. DupeString returns + // '' for ALevel <= 0 and for an empty FGap, so no explicit guard is needed. + Result := DupeString(FGap, ALevel); end; // ES2026 §25.5.2.2 SerializeJSONProperty ( state, key, holder ) @@ -737,219 +742,207 @@ function TGocciaJSONStringifier.SerializeObjectKey(const AKey: string): string; Result := '"' + EscapeJSONString(AKey) + '"'; end; -function TGocciaJSONStringifier.StringifyValue(const AValue: TGocciaValue; - const AIndent: Integer; const AKey: string): string; -begin - Result := StringifyPreparedValue(ApplyToJSON(AValue, AKey), AIndent); -end; - -function TGocciaJSONStringifier.StringifyPreparedValue(const AValue: TGocciaValue; - const AIndent: Integer): string; +procedure TGocciaJSONStringifier.WriteValue(var ABuffer: TStringBuffer; + const AValue: TGocciaValue; const AIndent: Integer); var EffectiveValue: TGocciaValue; begin - // ES2026 §25.5.2.2 step 4a: If value has [[IsRawJSON]], return its raw text verbatim. + // ES2026 §25.5.2.2 step 4a: If value has [[IsRawJSON]], write its raw text verbatim. if AValue is TGocciaRawJSONValue then - Exit(TGocciaRawJSONValue(AValue).RawText); + begin + ABuffer.Append(TGocciaRawJSONValue(AValue).RawText); + Exit; + end; // ES2026 §25.5.4.2 steps 4.b-4.d: unwrap boxed primitives. EffectiveValue := CoerceWrappedPrimitive(AValue); if EffectiveValue is TGocciaNullLiteralValue then - Result := 'null' + ABuffer.Append('null') else if EffectiveValue is TGocciaUndefinedLiteralValue then - Result := 'null' + ABuffer.Append('null') else if EffectiveValue is TGocciaHoleValue then - Result := 'null' + ABuffer.Append('null') else if EffectiveValue is TGocciaBooleanLiteralValue then begin if EffectiveValue.ToBooleanLiteral.Value then - Result := 'true' + ABuffer.Append('true') else - Result := 'false'; + ABuffer.Append('false'); end else if EffectiveValue is TGocciaNumberLiteralValue then begin if TGocciaNumberLiteralValue(EffectiveValue).IsInfinity then begin if FMode = jsmJSON5 then - Result := 'Infinity' + ABuffer.Append('Infinity') else - Result := 'null'; + ABuffer.Append('null'); end else if TGocciaNumberLiteralValue(EffectiveValue).IsNegativeInfinity then begin if FMode = jsmJSON5 then - Result := '-Infinity' + ABuffer.Append('-Infinity') else - Result := 'null'; + ABuffer.Append('null'); end else if TGocciaNumberLiteralValue(EffectiveValue).IsNaN then begin if FMode = jsmJSON5 then - Result := 'NaN' + ABuffer.Append('NaN') else - Result := 'null'; + ABuffer.Append('null'); end else - Result := SerializeJSONNumber(EffectiveValue.ToNumberLiteral.Value); + ABuffer.Append(SerializeJSONNumber(EffectiveValue.ToNumberLiteral.Value)); end else if EffectiveValue is TGocciaStringLiteralValue then begin if FMode = jsmJSON5 then - Result := QuoteJSON5String(EffectiveValue.ToStringLiteral.Value) + ABuffer.Append(QuoteJSON5String(EffectiveValue.ToStringLiteral.Value)) else - Result := '"' + EscapeJSONString(EffectiveValue.ToStringLiteral.Value) + '"'; + ABuffer.Append('"' + EscapeJSONString(EffectiveValue.ToStringLiteral.Value) + '"'); end // ES2026 §25.5.2.5 step 10: BigInt values throw TypeError else if EffectiveValue is TGocciaBigIntValue then ThrowTypeError('Do not know how to serialize a BigInt', 'use BigInt.prototype.toString() or a custom replacer') else if EffectiveValue.IsCallable then - Result := 'null' + ABuffer.Append('null') else if EffectiveValue is TGocciaSymbolValue then - Result := 'null' + ABuffer.Append('null') else if EffectiveValue is TGocciaArrayValue then - Result := StringifyArray(TGocciaArrayValue(EffectiveValue), AIndent) + WriteArray(ABuffer, TGocciaArrayValue(EffectiveValue), AIndent) else if EffectiveValue is TGocciaObjectValue then - Result := StringifyObject(TGocciaObjectValue(EffectiveValue), AIndent) + WriteObject(ABuffer, TGocciaObjectValue(EffectiveValue), AIndent) else - Result := 'null'; + ABuffer.Append('null'); end; -function TGocciaJSONStringifier.StringifyObject(const AObj: TGocciaObjectValue; const AIndent: Integer): string; +procedure TGocciaJSONStringifier.WriteObject(var ABuffer: TStringBuffer; + const AObj: TGocciaObjectValue; const AIndent: Integer); var - SB: TStringBuffer; Key: string; Keys: TArray; PropValue: TGocciaValue; Value: TGocciaValue; HasProperties: Boolean; - Separator, ChildIndent, CloseIndent: string; + ChildIndent, CloseIndent: string; begin if FTraversalStack.IndexOf(AObj) <> -1 then ThrowTypeError(CircularErrorName); FTraversalStack.Add(AObj); try - SB := TStringBuffer.Create; - try - HasProperties := False; + HasProperties := False; - if FGap <> '' then - begin - Separator := ',' + #10; - ChildIndent := MakeIndent(AIndent + 1); - CloseIndent := MakeIndent(AIndent); - end - else - begin - Separator := ','; - ChildIndent := ''; - CloseIndent := ''; - end; + if FGap <> '' then + begin + ChildIndent := MakeIndent(AIndent + 1); + CloseIndent := MakeIndent(AIndent); + end + else + begin + ChildIndent := ''; + CloseIndent := ''; + end; - // ES2026 §25.5.4.5 SerializeJSONObject step 5: PropertyList replaces - // own-key enumeration; keys absent from the object serialize as - // undefined and are omitted below. - if FHasPropertyList then - Keys := FPropertyList - else - Keys := AObj.GetEnumerablePropertyNames; + ABuffer.AppendChar('{'); - for Key in Keys do - begin - PropValue := AObj.GetProperty(Key); - if PropValue = nil then - Continue; - Value := ApplyToJSON(PropValue, Key); - if ShouldOmitObjectProperty(Value) then - Continue; - - if HasProperties then - SB.Append(Separator); - SB.Append(ChildIndent); - SB.Append(SerializeObjectKey(Key)); - SB.Append(':'); - if FGap <> '' then - SB.AppendChar(' '); - SB.Append(StringifyPreparedValue(Value, AIndent + 1)); - HasProperties := True; - end; + // ES2026 §25.5.4.5 SerializeJSONObject step 5: PropertyList replaces + // own-key enumeration; keys absent from the object serialize as + // undefined and are omitted below. + if FHasPropertyList then + Keys := FPropertyList + else + Keys := AObj.GetEnumerablePropertyNames; - if not HasProperties then - Result := '{}' - else if FGap <> '' then - begin - if FMode = jsmJSON5 then - Result := '{' + #10 + SB.ToString + ',' + #10 + CloseIndent + '}' - else - Result := '{' + #10 + SB.ToString + #10 + CloseIndent + '}'; - end - else - Result := '{' + SB.ToString + '}'; - finally + for Key in Keys do + begin + PropValue := AObj.GetProperty(Key); + if PropValue = nil then + Continue; + Value := ApplyToJSON(PropValue, Key); + if ShouldOmitObjectProperty(Value) then + Continue; + + if HasProperties then + ABuffer.AppendChar(','); + // The opening newline is deferred to the first emitted property so an + // all-omitted object still serializes as '{}'. + if FGap <> '' then + ABuffer.AppendChar(#10); + ABuffer.Append(ChildIndent); + ABuffer.Append(SerializeObjectKey(Key)); + ABuffer.AppendChar(':'); + if FGap <> '' then + ABuffer.AppendChar(' '); + WriteValue(ABuffer, Value, AIndent + 1); + HasProperties := True; + end; + + if HasProperties and (FGap <> '') then + begin + if FMode = jsmJSON5 then + ABuffer.AppendChar(','); + ABuffer.AppendChar(#10); + ABuffer.Append(CloseIndent); end; + ABuffer.AppendChar('}'); finally FTraversalStack.Delete(FTraversalStack.Count - 1); end; end; -function TGocciaJSONStringifier.StringifyArray(const AArr: TGocciaArrayValue; const AIndent: Integer): string; +procedure TGocciaJSONStringifier.WriteArray(var ABuffer: TStringBuffer; + const AArr: TGocciaArrayValue; const AIndent: Integer); var - SB: TStringBuffer; I: Integer; Len: Integer; Value: TGocciaValue; - Separator, ChildIndent, CloseIndent: string; + ChildIndent, CloseIndent: string; begin if FTraversalStack.IndexOf(AArr) <> -1 then ThrowTypeError(CircularErrorName); FTraversalStack.Add(AArr); - Len := LengthOfArrayLike(AArr); - if Len = 0 then - begin - Result := '[]'; - FTraversalStack.Delete(FTraversalStack.Count - 1); - Exit; - end; - try - if FGap <> '' then - begin - Separator := ',' + #10; - ChildIndent := MakeIndent(AIndent + 1); - CloseIndent := MakeIndent(AIndent); - end - else + Len := LengthOfArrayLike(AArr); + ABuffer.AppendChar('['); + + if Len > 0 then begin - Separator := ','; - ChildIndent := ''; - CloseIndent := ''; - end; + if FGap <> '' then + begin + ChildIndent := MakeIndent(AIndent + 1); + CloseIndent := MakeIndent(AIndent); + end + else + begin + ChildIndent := ''; + CloseIndent := ''; + end; - SB := TStringBuffer.Create; - try for I := 0 to Len - 1 do begin if I > 0 then - SB.Append(Separator); - SB.Append(ChildIndent); + ABuffer.AppendChar(','); + if FGap <> '' then + ABuffer.AppendChar(#10); + ABuffer.Append(ChildIndent); Value := ApplyToJSON(AArr.GetProperty(IntToStr(I)), IntToStr(I)); - SB.Append(StringifyPreparedValue(Value, AIndent + 1)); + WriteValue(ABuffer, Value, AIndent + 1); end; + if FGap <> '' then begin if FMode = jsmJSON5 then - Result := '[' + #10 + SB.ToString + ',' + #10 + CloseIndent + ']' - else - Result := '[' + #10 + SB.ToString + #10 + CloseIndent + ']'; - end - else - Result := '[' + SB.ToString + ']'; - finally + ABuffer.AppendChar(','); + ABuffer.AppendChar(#10); + ABuffer.Append(CloseIndent); + end; end; + ABuffer.AppendChar(']'); finally FTraversalStack.Delete(FTraversalStack.Count - 1); end; diff --git a/tests/built-ins/JSON/stringify.js b/tests/built-ins/JSON/stringify.js index 229c9752..671efade 100644 --- a/tests/built-ins/JSON/stringify.js +++ b/tests/built-ins/JSON/stringify.js @@ -107,6 +107,94 @@ test("JSON.stringify with space string", () => { expect(result).toContain("\t"); }); +test("JSON.stringify pretty-prints nested objects with per-level indentation", () => { + const makeNested = (depth) => + Array.from({ length: depth }).reduce((acc) => ({ child: acc }), { leaf: true }); + const expected = [ + "{", + ' "child": {', + ' "child": {', + ' "child": {', + ' "child": {', + ' "leaf": true', + " }", + " }", + " }", + " }", + "}", + ].join("\n"); + expect(JSON.stringify(makeNested(4), null, 2)).toBe(expected); +}); + +test("JSON.stringify pretty-prints nested arrays with per-level indentation", () => { + const nestArr = (depth) => + Array.from({ length: depth }).reduce((acc) => [acc], [1]); + const expected = [ + "[", + " [", + " [", + " [", + " 1", + " ]", + " ]", + " ]", + "]", + ].join("\n"); + expect(JSON.stringify(nestArr(3), null, 2)).toBe(expected); +}); + +test("JSON.stringify repeats a multi-character gap once per nesting level", () => { + const expected = ['{', 'ab"a": {', 'abab"b": 1', 'ab}', '}'].join("\n"); + expect(JSON.stringify({ a: { b: 1 } }, null, "ab")).toBe(expected); +}); + +test("JSON.stringify indents each deeper nesting level by exactly one more gap", () => { + const makeNested = (depth) => + Array.from({ length: depth }).reduce((acc) => ({ child: acc }), { leaf: true }); + const out = JSON.stringify(makeNested(30), null, 2); + // Each keyed line starts with its indentation followed by a quoted key, so the + // index of the first quote equals the number of leading spaces for that line. + const indentOf = (line) => line.indexOf('"'); + const keyedLines = out + .split("\n") + .filter((line) => line.includes('"child"') || line.includes('"leaf"')); + // 30 wrappers plus the innermost leaf object => 31 keyed lines, each one gap deeper. + expect(keyedLines.length).toBe(31); + keyedLines.forEach((line, i) => { + expect(indentOf(line)).toBe((i + 1) * 2); + }); +}); + +test("JSON.stringify serializes deeply nested objects without a gap", () => { + const makeNested = (depth) => + Array.from({ length: depth }).reduce((acc) => ({ child: acc }), { leaf: true }); + expect(JSON.stringify(makeNested(4))).toBe( + '{"child":{"child":{"child":{"child":{"leaf":true}}}}}', + ); +}); + +test("JSON.stringify serializes deeply nested arrays without a gap", () => { + const nestArr = (depth) => + Array.from({ length: depth }).reduce((acc) => [acc], [1]); + expect(JSON.stringify(nestArr(3))).toBe("[[[[1]]]]"); +}); + +test("JSON.stringify pretty-prints interleaved objects and arrays", () => { + const value = { a: [{ b: [1] }] }; + const expected = [ + "{", + ' "a": [', + " {", + ' "b": [', + " 1", + " ]", + " }", + " ]", + "}", + ].join("\n"); + expect(JSON.stringify(value, null, 2)).toBe(expected); +}); + test("JSON.stringify with replacer function", () => { const obj = { a: 1, b: "hello", c: true }; const result = JSON.stringify(obj, (key, value) => { diff --git a/tests/built-ins/JSON5/stringify.js b/tests/built-ins/JSON5/stringify.js index a62af831..aeea072a 100644 --- a/tests/built-ins/JSON5/stringify.js +++ b/tests/built-ins/JSON5/stringify.js @@ -85,6 +85,25 @@ describe.runIf(hasJSON5)("JSON5.stringify", () => { expect(JSON5.stringify([1], null, "\t")).toBe("[\n\t1,\n]"); }); + test("pretty-prints deeply nested objects with a trailing comma at each level", () => { + const makeNested = (depth) => + Array.from({ length: depth }).reduce((acc) => ({ child: acc }), { + leaf: true, + }); + const expected = [ + "{", + " child: {", + " child: {", + " child: {", + " leaf: true,", + " },", + " },", + " },", + "}", + ].join("\n"); + expect(JSON5.stringify(makeNested(3), null, 2)).toBe(expected); + }); + test("supports replacer arrays and functions", () => { expect(JSON5.stringify({ a: 1, b: 2, 3: 3 }, ["a", 3])).toBe("{a:1,'3':3}"); expect(JSON5.stringify({ a: { a: 1, b: 2 }, b: { a: 3 } }, ["a"])).toBe(