Skip to content

Add JSON.literal compile-time macro (#10)#11

Closed
carpentry-agent[bot] wants to merge 1 commit into
mainfrom
claude/json-literal-macro
Closed

Add JSON.literal compile-time macro (#10)#11
carpentry-agent[bot] wants to merge 1 commit into
mainfrom
claude/json-literal-macro

Conversation

@carpentry-agent

Copy link
Copy Markdown

Closes #10.

JSON.literal parses a JSON string literal at macro-expansion time and
expands to the equivalent constructor expression, so there is no runtime parse
and no Result to unwrap:

(JSON.literal "{\"created_fields\": [], \"created_tags\": []}")
; expands to:
;   (JSON.obj [(JSON.entry @"created_fields" (JSON.Arr (the (Array (Box JSON)) [])))
;              (JSON.entry @"created_tags" (JSON.Arr (the (Array (Box JSON)) [])))])

How it works

A small recursive-descent parser written in dynamic Carp (defndynamic
helpers, all hidden) mirrors the grammar of the runtime parser and returns the
constructor s-expression. Numbers are built with exact integer-mantissa /
power-of-ten arithmetic so the emitted Double matches what a Carp literal of
the same text would produce (verified against JSON.parse in the tests).

Supported / not supported

  • Supported: null, booleans, numbers (incl. exponents), arrays, objects, and
    strings with the \" \\ \/ \n \r \t escapes. Non-ASCII text can be written
    directly as UTF-8.
  • Not supported: \b, \f, and \uXXXX escapes. These are rejected at compile
    time with a message pointing to JSON.parse (they are vanishingly rare in
    hand-written JSON, and avoiding them keeps raw control bytes out of the
    source).
  • Malformed input is a compile-time macro-error reporting the offending
    position.

Tests

13 new assertions in test/json.carp cover 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, and
use with JSON.get. Objects are compared against the runtime parser because
JSON object key order is not insertion order. Full suite passes 247/247;
carp-fmt --check, angler, and gendocs are clean.

Marked draft as it is a non-trivial addition worth a careful look — happy to
adjust naming, scope (e.g. the \b/\f/\u decision), or error wording.


Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

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.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.carp247 passed, 0 failed locally (incl. the 13 new literal tests)
  • carp-fmt --check json.carp → clean
  • carp -x gendocs.carp → clean
  • angler 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/\uXXXX scoping decision reads fine to me — rejecting them at compile time with a pointer to JSON.parse is 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.

@hellerve

Copy link
Copy Markdown
Member

closed until a human takes a stab at it.

@hellerve hellerve closed this Jun 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

add a json string macro

1 participant