Add JSON.literal compile-time macro (#10)#11
Conversation
JSON.literal parses a JSON string literal during macro expansion and expands to the equivalent JSON constructor expression, so there is no runtime parse and no Result to unwrap. It supports null, booleans, numbers, arrays, objects, and strings with the \" \\ \/ \n \r \t escapes; \b, \f, and \uXXXX are rejected at compile time with a message pointing to JSON.parse. Malformed input is a compile-time error with the offending position. Closes #10.
There was a problem hiding this comment.
Reviewed by checking out claude/json-literal-macro, reproducing CI locally, and fuzzing the macro against the runtime JSON.parse. This is a genuinely nice piece of work — a clean recursive-descent parser with position-carrying error messages — but there are two things to fix before it can merge, one of them a real correctness bug.
Build & Tests
carp -x test/json.carp→ 247 passed, 0 failed locally (incl. the 13 newliteraltests)carp-fmt --check json.carp→ cleancarp -x gendocs.carp→ cleanangler json.carp→ clean with my local angler, but CI's angler fails (see Findings #1). Per the usual drift, CI's freshly-built angler is the stricter one and is ground truth here.
Findings
1. CI is red — angler nested-if-chain (blocks merge, trivial fix)
CI fails the Lint step on both platforms:
./json.carp:957:5: [nested-if-chain] (if c1 x (if c2 ...)) can be a cond expression
at: (if (empty? lst) -1 (if (= (car lst) ch) idx (JSON.lit-index-of ch (cdr lst) (+ idx 1))))
lit-index-of just needs to become a cond:
(defndynamic lit-index-of [ch lst idx]
(cond
(empty? lst) -1
(= (car lst) ch) idx
(JSON.lit-index-of ch (cdr lst) (+ idx 1))))2. Numbers silently overflow at 2³¹ and emit a wrong Double (real bug)
lit-digits accumulates the mantissa with (+ (* acc 10) digit) using the dynamic evaluator's integer arithmetic, which wraps at the 32-bit signed boundary. Any JSON number whose digit run (ignoring the dot) exceeds 2147483647 expands to a corrupted value — silently, with no error. This contradicts the PR's claim that numbers are built "so the emitted Double matches what a Carp literal of the same text would produce."
Reproduced on this branch, JSON.literal "X" vs JSON.parse "X" (serialized, ~6 sig figs):
input JSON.literal JSON.parse
2147483647 (2^31-1) 2.14748e+09 2.14748e+09 ok
2147483648 (2^31) -2.14748e+09 2.14748e+09 SIGN FLIP
9999999999 1.41007e+09 1e+10 wrong
1719600000000 (ms epoch) 1.61308e+09 1.7196e+12 wrong
0.12345678901234567 1.56731e-08 0.123457 wrong
1234567890123456789 (id) 2.11245e+09 1.23457e+18 wrong
The trigger threshold (≥ ~10 significant digits) is well within everyday JSON: millisecond timestamps are 13 digits, Discord/Twitter-style IDs are 18–19, and large counters/financial values overflow too. The existing literal tests only use small numbers (42, -3.14, 2.5e-1, …), so the suite passes while the bug sits in the uncovered range.
Suggested direction: accumulate the mantissa as a Double rather than a 32-bit int. Since JSON.Num already holds a Double, 2^53 is the natural precision ceiling and that matches what the runtime parser itself can represent — so building the mantissa in floating point gives parity with JSON.parse instead of wrapping. Then add a couple of large-number tests (e.g. a 13-digit timestamp and a value just above 2^31) cross-checked against JSON.parse, which is exactly the case the current tests skip.
3. Leading zeros are accepted (minor divergence from the parser)
JSON.literal "01" expands to 1, but the JSON grammar — and this library's own JSON.parse — reject a leading zero. Since the macro otherwise advertises that malformed input is a compile-time error, it's worth rejecting a 0 immediately followed by another digit in lit-number for parity. Low priority.
4. -0 loses its sign (very minor)
JSON.literal "-0" → 0, whereas JSON.parse "-0" → -0. Only matters if exact parity with the parser is a goal; noting for completeness.
On the questions you raised
- The
\b/\f/\uXXXXscoping decision reads fine to me — rejecting them at compile time with a pointer toJSON.parseis a reasonable line to draw for hand-written literals, and it's clearly documented. - Structural handling is solid: I verified trailing commas, bare commas, missing colons/keys, unterminated string/array/object, trailing content, whitespace, and duplicate-key last-wins all match
JSON.parse. The number path is the only place behavior diverges.
One last optional thought: the runtime parser enforces a nesting depth limit (DepthLimitExceeded); the macro has none, so a pathologically nested literal would recurse in the expander. Not worth addressing unless it comes up.
Verdict: revise
CI is red on the angler nested-if (one-line cond fix), and the number path silently corrupts any value past 2^31 (timestamps, IDs) — a real correctness bug that's masked by small-number-only tests. Both are very fixable, and the rest of the macro is in good shape.
|
closed until a human takes a stab at it. |
Closes #10.
JSON.literalparses a JSON string literal at macro-expansion time andexpands to the equivalent constructor expression, so there is no runtime parse
and no
Resultto unwrap:How it works
A small recursive-descent parser written in dynamic Carp (
defndynamichelpers, all
hidden) mirrors the grammar of the runtime parser and returns theconstructor s-expression. Numbers are built with exact integer-mantissa /
power-of-ten arithmetic so the emitted
Doublematches what a Carp literal ofthe same text would produce (verified against
JSON.parsein the tests).Supported / not supported
null, booleans, numbers (incl. exponents), arrays, objects, andstrings with the
\" \\ \/ \n \r \tescapes. Non-ASCII text can be writtendirectly as UTF-8.
\b,\f, and\uXXXXescapes. These are rejected at compiletime with a message pointing to
JSON.parse(they are vanishingly rare inhand-written JSON, and avoiding them keeps raw control bytes out of the
source).
macro-errorreporting the offendingposition.
Tests
13 new assertions in
test/json.carpcover scalars, whitespace handling,negative decimals, exponent notation, empty/nested arrays, string escapes, the
issue's object example, a nested object cross-checked against
JSON.parse, anduse with
JSON.get. Objects are compared against the runtime parser becauseJSONobject key order is not insertion order. Full suite passes 247/247;carp-fmt --check,angler, andgendocsare clean.Marked draft as it is a non-trivial addition worth a careful look — happy to
adjust naming, scope (e.g. the
\b/\f/\udecision), or error wording.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.