Skip to content

Normalize string literal representation across the compiler - #8606

Merged
cknitt merged 30 commits into
masterfrom
codex/string-literal-representation
Sep 3, 2026
Merged

Normalize string literal representation across the compiler#8606
cknitt merged 30 commits into
masterfrom
codex/string-literal-representation

Conversation

@cknitt

@cknitt cknitt commented Sep 1, 2026

Copy link
Copy Markdown
Member

This is the second PR in the two-PR String Theory stack, built on #8605. It replaces the compiler’s historical delimiter protocol with explicit representations for semantic strings, templates, JSON literals, and raw JavaScript.

Consistent representations

Each layer now distinguishes runtime string values from source text that must be preserved:

Concept Representation
Ordinary string Source and semantic value in the Parsetree; semantic value only after type checking
Ordinary template Explicit template node with validated segments containing source and semantic forms
Tagged template Explicit tagged-template node with raw source segments
JSON literal Dedicated validated JavaScript-source representation
Raw JavaScript Dedicated representation carrying its raw-code kind

This structure continues through the typed tree, Lambda, and JavaScript IR. The frozen AST v0 bridge converts to and from its legacy encoding at that single compatibility boundary.

A shared String_literal module now owns JavaScript escape decoding, canonical encoding, surrogate handling, UTF-16 string operations, and template line-ending semantics.

String and template payloads use an abstract, kind-indexed representation (string_kind and template_kind). Valid payloads can only be created through decoding, semantic encoding, explicit conversion, or template concatenation. Template concatenation preserves combined source spelling only when it still decodes to the combined semantic value; otherwise it falls back to canonical encoding. Invalid parser input has a separate recovery representation that retains its original spelling.

This removes:

  • the DNone, DStarJ, DBackQuotes, and DNoQuotes delimiter protocol
  • the "js", "*j", and "bq" marker strings
  • hidden ++ trees and attributes for interpolated templates
  • synthetic calls used to represent tagged templates
  • the legacy Ast_utf8_string and Ast_utf8_string_interp implementations

Unicode handling

Compiler-owned UTF-8 handling now uses OCaml’s standard String, Uchar, and Buffer APIs instead of maintaining separate byte classifiers, decoders, and encoders.

  • UTF-8 is validated as Unicode scalar values, rejecting overlong encodings, encoded surrogates, values above U+10FFFF, truncated sequences, and stray continuation bytes.
  • String and template decoding reject malformed UTF-8 before it can diverge from JavaScript runtime behavior.
  • JavaScript output preserves valid UTF-8 and safely escapes malformed bytes.
  • The parser, generated-code position tracking, and source maps consistently count UTF-16 code units, including surrogate pairs.
  • The syntax-layer and compiler utility implementations now share the standard library’s validation and encoding behavior.

Output behavior

Template literals now remain explicit throughout compilation and carry over into the generated JavaScript:

  • Ordinary backquoted literals are emitted as backquoted JavaScript literals, preserving their source spelling.
  • Interpolated templates are emitted as native JavaScript template literals instead of being lowered to hidden ++ applications.
  • Tagged templates are emitted as genuine JavaScript tagged templates, preserving their raw segments and JavaScript cooked/raw behavior.
  • Interpolated values retain their original left-to-right, once-only evaluation behavior.
  • Ordinary strings are emitted from their semantic value through one canonical escaping path.
  • JSON literals and raw JavaScript remain source payloads and are emitted as JavaScript syntax rather than quoted strings.

Behavior fixes

The normalized representation also resolves several correctness issues:

  • Equivalent patterns such as "a" and "\x61" now compare by runtime value, preserve source order, and produce the expected redundant-pattern warning.
  • Constant folding uses JavaScript UTF-16 semantics for string length and indexing.
  • Valid UTF-16 surrogate pairs are accepted, while lone surrogates, malformed UTF-8, and invalid escapes are rejected.
  • Template CR and CRLF source line endings are normalized according to JavaScript semantics.
  • U+2028 and U+2029 line continuations are decoded correctly.
  • Interpolation-free templates remain nonexpansive and preserve value generalization.
  • Tagged templates are rejected in patterns because patterns cannot invoke their tag.
  • json interpolation and unsupported uses of JSON literals are rejected.
  • Ordinary template escapes are validated while tagged-template raw segments may still contain invalid escapes.
  • Invalid template escapes continue to produce a diagnostic even when an earlier error has already been reported, avoiding lost parser errors during recovery.
  • Constant template folding cannot synthesize interpolation openers, legacy decimal escapes, or CRLF boundary changes; folded source is checked against its semantic value.
  • External PPX round trips preserve template spelling and escape-sensitive raw payloads.
  • PPX-rewritten semantic strings are reconstructed correctly for both ordinary and tagged templates.
  • GenType correctly escapes semantic @as values and Unicode line separators in generated paths.
  • Editor analysis traverses template tags and interpolations through the shared typed-tree iterator, preserving completion for locally declared modules.

Testing

Coverage spans parsing and printing, the AST v0 bridge, type checking, Lambda and JavaScript IR, constant folding, generated JavaScript, GenType, analysis, and end-to-end behavior. Unicode regressions include overlong UTF-8, encoded surrogates, values above U+10FFFF, JavaScript escaping of malformed bytes, and UTF-16 position accounting.

Verified with:

  • make test
  • make test-syntax
  • make test-syntax-roundtrip
  • make test-gentype
  • make test-analysis

Closes #8602.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T07:27:36.136954Z 8eeaeda Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cknitt
cknitt force-pushed the codex/string-literal-representation branch from a6f1445 to c292e07 Compare September 1, 2026 19:04
@cknitt cknitt changed the title codex/string literal representation Normalize string literal representation across the compiler Sep 1, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a6f1445259

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread compiler/ml/parsetree.ml Outdated
@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.64865% with 284 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.32%. Comparing base (58d7fee) to head (13eb86b).

Files with missing lines Patch % Lines
tests/ounit_tests/ounit_string_literal_tests.ml 90.79% 37 Missing ⚠️
compiler/ml/ast_mapper_from0.ml 78.02% 20 Missing ⚠️
tests/ounit_tests/ounit_ast_mapper0_tests.ml 91.48% 20 Missing ⚠️
compiler/ml/printast.ml 0.00% 15 Missing ⚠️
compiler/ml/printtyped.ml 0.00% 14 Missing ⚠️
analysis/src/dump_ast.ml 20.00% 12 Missing ⚠️
compiler/core/js_exp_make.ml 83.33% 11 Missing ⚠️
analysis/reanalyze/src/annotation.ml 57.89% 8 Missing ⚠️
compiler/core/lam_compile_primitive.ml 38.46% 8 Missing ⚠️
compiler/ml/string_literal.ml 96.05% 8 Missing ⚠️
... and 45 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8606      +/-   ##
==========================================
+ Coverage   77.00%   77.32%   +0.32%     
==========================================
  Files         467      467              
  Lines       62461    63342     +881     
==========================================
+ Hits        48100    48982     +882     
+ Misses      14361    14360       -1     
Files with missing lines Coverage Δ
analysis/reanalyze/src/side_effects.ml 77.04% <100.00%> (+0.38%) ⬆️
analysis/src/completion_jsx.ml 75.00% <ø> (ø)
analysis/src/process_cmt.ml 82.37% <100.00%> (+0.36%) ⬆️
analysis/src/type_utils.ml 63.82% <100.00%> (ø)
compiler/bsc/rescript_compiler_main.ml 71.49% <100.00%> (ø)
compiler/core/j.ml 100.00% <ø> (ø)
compiler/core/js_dump_string.ml 97.95% <100.00%> (+53.13%) ⬆️
compiler/core/js_of_lam_variant.ml 57.89% <100.00%> (ø)
compiler/core/js_record_fold.ml 88.81% <100.00%> (+0.14%) ⬆️
compiler/core/js_record_iter.ml 95.49% <100.00%> (+0.08%) ⬆️
... and 88 more

... and 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cristianoc

Copy link
Copy Markdown
Collaborator

Heads-up on a likely conflict with #8608 (merging the Lam IR into Lambda), and a suggestion on ordering.

Overlap: 26 compiler files. The part that matters is that this PR modifies seven files that #8608 deletes:

file changes here
compiler/core/lam.ml +9 / −14
compiler/core/lam_primitive.ml / .mli +13 / −4, +11 / −1
compiler/frontend/lam_constant.ml / .mli +8 / −3, +6 / −1
compiler/core/lam_convert.ml +5 / −4
compiler/core/lam_print.ml +3 / −2
compiler/core/lam_constant_convert.ml +2 / −2

About +57 / −31 in total. As far as I can tell those edits are all the same thing: propagating the changed Const_string payload through each layer of a representation that happened to be duplicated.

Suggestion: land #8608 first. The reason is that this duplication is exactly what it removes. After it, Lam_constant, Lam_primitive, Lam_print and the converters are gone, and the same change lands once in compiler/ml/lambda.ml and compiler/ml/printlambda.ml instead of eight times. The delete/modify conflicts resolve by taking the deletion and re-homing one small, well-understood change — so rebasing this PR on top should make it slightly smaller, not larger.

The reverse order looks worse: #8608 would have to carry this PR's in-flight string representation into files it is simultaneously deleting. Same work, harder direction. It would also cost that PR its main verification property — it claims generated JavaScript is unchanged, checked against runtime, Belt and the 620 modules in tests/tests, whereas this PR intentionally changes output (native template literals). Rebasing it onto this one means re-establishing that against a moved baseline, and any residual diff becomes ambiguous between the two changes.

One more thing worth knowing: compiler/ml/typecore.ml is the only file where both PRs make substantial edits (+81 / −69 here). #8608's change there is a single unrelated commit — a fix for a compiler crash on a polymorphic variant whose numeric name exceeds the int32 range — sitting next to the integer-literal decoding this PR reworks. That commit can be split out of #8608 into its own PR, which would leave the conflicts confined to the Lambda area. Happy to do that if it helps.

No urgency from my side — mostly flagging it early so the order is a decision rather than a surprise at rebase time.

@cknitt
cknitt force-pushed the codex/string-literal-representation branch from c292e07 to 513d122 Compare September 1, 2026 19:41
@cknitt

cknitt commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

@codex review

@pkg-pr-new

pkg-pr-new Bot commented Sep 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript-lang/rescript@8606

@rescript/belt

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/belt@8606

@rescript/darwin-arm64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/darwin-arm64@8606

@rescript/darwin-x64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/darwin-x64@8606

@rescript/linux-arm64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/linux-arm64@8606

@rescript/linux-x64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/linux-x64@8606

@rescript/runtime

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/runtime@8606

@rescript/win32-x64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/win32-x64@8606

commit: 13eb86b

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 513d122385

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cknitt
cknitt force-pushed the codex/string-literal-representation branch from cdf45ee to 3ceae27 Compare September 2, 2026 10:23
@cknitt
cknitt requested a review from cristianoc September 2, 2026 10:24
@cknitt

cknitt commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3ceae2769a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread compiler/ml/string_literal.ml Outdated
@cknitt

cknitt commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 373a3cea69

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread compiler/ml/ast_mapper_to0.ml Outdated

@cristianoc cristianoc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed by diffing pr8605...pr8606, building both branches, running ounit_tests and syntax_tests (both green here), and running a differential: rebuilding the whole @rescript/runtime (105 modules) and tests/tests/src (356 modules) with both compilers and diffing the emitted JS.

That differential is a strong result and worth stating up front: 0 diffs across the 105 runtime modules, and of 356 test modules only 9 differ — every one an improvement ("\x61b""ab", "'" === "\'" folded to true, "variant0" !== "variant0" folded away, cross-delimiter concat folding now working). The {source; semantic} split is the right factoring and it visibly pays for itself.

Below are the problems I could reproduce, each with the design decision behind it where there is one.


1. Compiler crash on a character range spanning the surrogate block (regression)

let f = c =>
  switch c {
  | '\u{D7FE}' .. '\u{E001}' => 1
  | _ => 0
  }
$ bsc -nostdlib -nopervasives repro.res
Fatal error: exception Invalid_argument("DFFF is not a Unicode scalar value")

exit code 2, no location. Backtrace:

Called from Ext_utf8.encode_codepoint in file "compiler/ext/ext_utf8.ml", line 43
Called from Typecore.type_pat_aux.char in file "compiler/ml/typecore.ml", line 1361
Called from Typecore.type_pat_aux.loop in file "compiler/ml/typecore.ml", line 1365

pr8605 compiles this fine. 'a' .. '\u{FFFF}' hits it too; any interval crossing D800..DFFF does.

Cause, and the design point behind it: Ext_utf8.encode_codepoint and Res_utf8.encode_code_point were turned from total functions into partial ones — Uchar.of_int raises on surrogates and values above U+10FFFF — without any signature change to flag it. Three callers still hand them a raw int that is not guaranteed to be a scalar value:

  • typecore.ml:1358-1365, the Ppat_interval expansion, which walks every code point in the range;
  • Ext_util.string_of_int_as_char (reached from js_dump.ml:770 and pprintast.ml:244);
  • ast_mapper_from0.ml:107, reconstructing Pconst_char source across the PPX bridge.

The interval expansion is the reachable one. It calls String_literal.encode_char_source on every code point purely to synthesize a source field for ghost patterns that are never printed. Cheapest fixes: make encode_char_source total for non-scalar values, or skip the source for the synthetic patterns.

(Aside: Res_utf8.encode_code_point now has no callers at all — it is dead, but still exported in res_utf8.mli.)

2. decode_js_template_escapes accepts escapes that JavaScript rejects in templates, and the emitted JS does not parse

let a = `a\1b`

compiles clean and emits let a = `a\1b`;, which node rejects:

SyntaxError: Octal escape sequences are not allowed in template strings.

Same for `a\01b` and `a\8b`. In string_literal.ml, \1/\8 fall through to the "non-escape character" branch (lines 117-123) and \0 (line 89) does not check for a following digit.

This symptom predates the PR — pr8605 emits the same broken output — so it is not a regression. I am raising it here because this PR is where "segments are validated" becomes the stated contract and where source is emitted verbatim by design, so the new validator is the natural home for the missing rejection. As it stands the semantic value and the emitted source disagree, and the emitted source is not valid JavaScript.

3. Template escape errors are type errors with whole-template locations, and the formatter accepts them

let a = `bad \xZZ escape`
let b = "bad \xZZ escape"
  • b: syntax error at 1:14-15, "unknown escape sequence".
  • a: res_parser -print res accepts and formats it; bsc reports "We've found a bug for you!" at 1:9-25 with "Invalid string escape sequence".

Same class of mistake, two phases, two messages, two locations — and only one of them is caught by rescript format or by editor syntax diagnostics.

Design point: the validation boundary is split three ways. Ordinary strings validate in the parser (res_core.ml:1003), template patterns validate in the parser (parse_template_constant), but template expressions validate in Typecore (typecore.ml:2548). The invalid_ordinary_template_escape.res.expected fixture bakes the asymmetry in. Validating segments in parse_template_expr, with per-segment locations, would collapse all three onto one rule.

4. Invalid UTF-8 is reported as an escape-sequence error, and the diagnostic can be swallowed

A .res file containing a raw 0xFF byte inside a string literal:

let a = "bad <0xFF> byte"

now reports "Invalid string escape sequence" — there is no escape sequence involved. pr8605 said "Invalid code point", which was accurate.

Worse, parse_string_constant gates the diagnostic on if p.diagnostics = [] (res_core.ml:1007), so with an earlier parse error it disappears entirely:

let x = (1,
let a = "bad <0xFF> byte"

reports only the first error. This is exactly the diagnostic-loss fixed for templates in 8a6b38d ("Report invalid template escapes after earlier diagnostics"); the string path still has it.

5. Codegen quality regressions

Both correct, both uglier than before.

external \"++": (string, string) => string = "%string_concat"
let a = `abc` ++ `def`
// pr8605
let a = `abcdef`;
// pr8606
let a = `abc` + `def`;

And tests/tests/src/template.res:

// pr8605
return `
    display:\r flex;
   ` + bla2;
// pr8606
return `${`
    display:\r flex;
   `}${bla2}`;

Design point: Template_literal {source; semantic} propagates a stylistic goal — preserving backtick spelling in the output — through the entire backend, and every optimization then has to pick a field. concat_string_literals (js_exp_make.ml:676) simply returns None whenever either side is backquoted, and nothing collapses a Template_literal that has been inlined into an interpolation slot back into the surrounding segments. Worth considering whether Template_literal should decay to Str once the source spelling has served its purpose, or whether js_dump should choose backticks from the semantic value instead of carrying the source down.

@cristianoc

Copy link
Copy Markdown
Collaborator

Follow-up to my review above: one more reproducible regression, on a different axis — the ast0 PPX bridge. An identity PPX stops being a no-op.

Repro

M1.res, a doc comment containing one non-UTF-8 byte:

$ od -c M1.res
0000000   /   *   *       d   o   c     377       b   y   t   e       *
0000020   /  \n   l   e   t       a       =       1  \n

/bin/cp is an exact identity PPX here — the protocol in cmd_ppx_apply.ml:39-44 is ppx <infile> <outfile>.

no ppx -ppx /bin/cp
pr8605 (base) exit 0 exit 0
pr8606 (this PR) exit 0 exit 2
$ bsc -ppx /bin/cp M1.res
  We've found a bug for you!
  M1.res:1:1-17

  1 │ /** doc <0xFF> byte */

  Invalid string escape sequence

So the same file compiles without a PPX and fails with an identity PPX, and only on this branch. It is a clean diagnostic, not a crash (exit 2, proper location, no backtrace).

Mechanism

Doc-comment text is raw source bytes that never go through the string scanner, so nothing has UTF-8-validated it:

  1. res_core.ml:7605 (doc_comment_to_attribute) builds the attribute payload with Ast_helper.Const.string s.
  2. Const.string (ast_helper.ml:49) derives source with String_literal.encode_js_string, which passes bytes >= 0x80 through unchanged. The record is therefore constructed with decode(source) <> Some semantic already false.
  3. Nothing notices until the round trip: to0 emits Pconst_string (source, Some "js"), and from0's source_string / decode_js_string (ast_mapper_from0.ml:96-100) now decodes on the way back in. copy_utf8 rejects the byte and raises.

Step 3 is what this PR adds; on pr8605 map_constant was Pconst_string (s, q) -> Pconst_string (s, q) and nothing ever decoded.

Scope

Narrow, and the rest of the bridge looks solid. I rebuilt all 356 modules of tests/tests/src with -ppx /bin/cp: all compile on this branch, none regress against base, and the emitted JS is byte-identical to the no-PPX build on both branches. I also round-tripped a file combining tagged templates, json literals, %raw, astral char literals, surrogate-pair escapes and templates carrying invalid escapes — identical output. Only compiler-synthesised strings that were never UTF-8-validated are affected, and doc comments are the reachable source of those.

Underlying invariant

This is the encode direction of the {source; semantic} invariant being partial. decode(source) = Some semantic holds in one direction only, and encode_js_string does not guarantee its output decodes: it cannot, because a byte string that is not valid UTF-8 has no double-quoted ReScript spelling that decodes back to those exact bytes (\xFF decodes to U+00FF, not to byte 0xFF).

So the fix has to pick a place to establish "semantic is valid UTF-8":

  • validate (or reject, or replace) comment text where it enters, in the scanner, so Const.string is never handed non-UTF-8; or
  • have Const.string validate its argument and fail loudly at construction rather than silently building a record that violates its own invariant.

Patching only from0 to tolerate it would leave the invariant broken at construction time and just move the symptom.

@cristianoc

Copy link
Copy Markdown
Collaborator

One more comment, and this one is a tentative design direction rather than a review finding — I have not tried it, and I am not confident every part survives contact with the code. Take it as a sketch to poke holes in.

The bug I reported just above and the Const.char oddity both come from the same place: {source; semantic} carries an invariant that nothing enforces. It seems worth asking whether it could be enforced by construction.

The invariant, stated

decode(source) = Some semantic. It holds in one direction only: semantic is fully determined by source, so it is a memoized derived field, not independent data. The reverse is one-to-many ("a", "\x61", "a", "\u{61}" all mean a), and source exists purely so the formatter can reprint what the user wrote.

Sketch

Make the payload a private record in a module that has an .mliString_literal already owns encode/decode, so it is the natural home. parsetree.ml and asttypes.ml have no .mli, so the type would have to move there rather than being made private in place:

(* string_literal.mli *)
type t = private {source: string; semantic: string}
val of_source   : string -> t option   (* parser: preserves spelling *)
val of_semantic : string -> t option   (* compiler: canonical spelling *)

with Pconst_string of String_literal.t and template_segment = String_literal.segment. A private record still allows field access and pattern matching, so {semantic} reads everywhere stay as they are; only construction is blocked. I grepped for functional updates ({c with source = ...}) on these records and found none outside the vendored flow parser, so that part looks cheap.

Two caveats I am reasonably confident about:

  • This does not remove the check in from0. Parsetree0 crosses to external PPXes via Marshal (cmd_ppx_apply.ml:27-70), which ignores abstraction entirely, so a PPX can hand back a value with arbitrary field contents and input_value will materialise it. from0 has to keep validating. What private buys is the in-process construction sites — which is exactly where the doc-comment bug is.
  • Conversely, Parsetree v5 is never marshalled outward, so a private payload there should be fully enforceable.

The part I am least sure about: is "every source must validate" too strict?

There are fewer construction sites than one might fear:

  • Pconst_string — 4: res_core.ml:1005, res_core.ml:1010, ast_helper.ml:50, ast_mapper_from0.ml:97,100
  • Pconst_char — 5: res_core.ml:1039, ast_helper.ml:48, from0:107, untypeast.ml:21, typecore.ml:1361
  • template_segment — 1: typecore.ml:2547

All but one are already either "decode succeeded" or "derive canonically from semantic". The single site that genuinely wants an invalid node is the parser's error recovery:

Parsetree.Pconst_string {source; semantic = ""}     (* res_core.ml:1010 *)

As far as I can tell that path is already meant to be unreachable: everything decode_js_escapes rejects — \xZZ, \u{110000}, \u{}, lone surrogates, trailing backslash — the scanner already rejects first, with a better per-escape location. The only thing I found actually reaching the None branch is invalid UTF-8, which would explain both the misworded message ("Invalid string escape sequence" for a byte problem, where base said "Invalid code point") and the defensive if p.diagnostics = [] gate. I have not exhaustively proved there is no other input that reaches it — that is the first thing that would need checking.

If that holds, the direction would be to move UTF-8 validation into the scanner (it already walks the bytes and already uses Res_utf8.is_valid_code_point for char literals at res_scanner.ml:533,576). Then of_source is total on scanner output, the recovery branch goes away with the error it was recovering from, and the doc-comment bug is fixed at construction instead of at the bridge.

The practical risk there, and the reason this needs validating rather than just doing: rejecting non-UTF-8 in comments is a behaviour change for files that compile today. Whether that is acceptable, or whether comment text should be sanitised instead, is a call I do not have the context to make.

If a recovery node is wanted anyway — defensible, since the formatter and editor would like to format a file that has one bad escape — then it seems better to make the invalidity visible than to fake it with semantic = "", e.g. semantic: (string, error) result, so consumers are forced to notice rather than silently type-checking "" as a real string.

Side benefit, if it works out

Two nominal types would also separate something that is currently structural: Pconst_string's payload and Asttypes.template_segment are the same record shape but governed by different decoders (decode_js_escapes vs decode_js_template_escapes, which normalises CR/CRLF to LF). They are freely interchangeable today but not interchangeable in meaning:

external slength: string => int = "%string_length"
let a = slength("x<CR><LF>y")    // 4
let b = slength(`x<CR><LF>y`)    // 3

And Ast_helper.Const.char : char -> constant would not survive in its present form, which seems like a good outcome: its argument is a byte, its payload is an int code point, so it cannot express anything above U+00FF — and it currently has no callers anywhere in compiler/, analysis/ or tools/. (That mismatch predates this PR; it only becomes visible because string now takes a semantic value while char still takes a byte.)

@cknitt

cknitt commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Thanks, this is a useful framing. I agree that {source; semantic} carries an invariant that would ideally be enforced by construction, and that ordinary strings and template segments should probably be distinct nominal types because they use different decoders.

For this PR I have kept the fix narrow in 739f0b85a: doc and module comments are validated before becoming AST string payloads. Invalid UTF-8 is diagnosed, and the recovery payload replaces malformed sequences so it remains safe across the AST0/identity-PPX round trip.

I would prefer to handle private payload types in a focused follow-up. That design also needs an explicit recovery policy: semantic = "" is not a good representation of invalid input, while of_source : string -> t option probably does not retain enough information for precise diagnostics. A result carrying the error kind and offset seems more appropriate.

Agreed as well that validation in ast_mapper_from0 must remain, since the marshalled PPX boundary cannot enforce the abstraction. Removing or reshaping the currently unused Ast_helper.Const.char also seems worthwhile, although synthetic surrogate values created during interval expansion need to be addressed first.

@cknitt

cknitt commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c0b3929924

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread compiler/ext/config.ml
Comment thread compiler/core/js_exp_make.ml Outdated
Comment thread compiler/ml/ast_mapper_from0.ml
@cknitt

cknitt commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

@codex review

@cknitt
cknitt force-pushed the codex/string-literal-representation branch from 269b045 to c08c60f Compare September 2, 2026 19:07

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 269b0458d8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread compiler/ml/ast_mapper_from0.ml Outdated
@cristianoc

Copy link
Copy Markdown
Collaborator

Re-ran every repro from my review and the follow-up against the updated branch (rebuilt from scratch, since the artifact magic numbers changed). All of them are fixed.

# Issue Before Now
1 char range spanning surrogates Fatal error: Invalid_argument("DFFF …"), exit 2 compiles; JS byte-identical to base
2 `a\1b`, `a\01b`, `a\8b` emitted JS that node rejects syntax error at parse time
3 template escape validated in Typecore type error; formatter accepted it syntax error at the segment location; formatter rejects it too
4 invalid UTF-8 in a string "Invalid string escape sequence", swallowed after an earlier error "Invalid code point", per-literal location, both diagnostics reported
5 doc comment + identity PPX compiled without PPX, failed with -ppx /bin/cp rejected identically in both modes
6 codegen `abc` + `def`; nested `${`…`}${bla2}` "abcdef"; single flat template

Corpus re-verification: runtime 105 modules, 0 JS diffs against base; 356 modules of tests/tests/src, nothing that compiled on base fails now; identity-PPX sweep 0 diffs against the no-PPX build across all 356; no Fatal error anywhere; ounit_tests 249/249.

No over-rejection either — a template exercising \n \t \r \\ \ $ \u{...} \x41 \0 \v \f \bstill compiles andnode --check accepts the output, surrogate-pair strings still work, and folding is intact (String.length("a\u{1F600}b")-> 4,String.length("😀")` -> 2).

269b0458d also covers the tagged-template segment assert false I had flagged as unreproduced, which I could not build a repro for.

One thing left: changelog

There are still three entries for this PR — tagged templates in patterns, surrogate-pair support, pattern-matching equivalence — and none of them covers the new rejections. At least one is a breaking change for code that compiles on main today:

                    no ppx   identity ppx
base                exit 0   exit 0
this PR             exit 1   exit 1      <- /** doc <0xFF> byte */

Same for legacy escapes in templates (`a\1b`) and for \u{110000} / \u{}, which the scanner accepted before. I think rejecting all three is the right call, but they are the kind of thing someone hits on upgrade and wants an entry to point at.

Not re-raising the other two loose ends: char source spelling not surviving the AST0 bridge is already documented in ast_mapper_from0.ml, and escape-level (rather than segment-level) locations for template escapes are covered by your note about a result carrying error kind and offset.

@cknitt

cknitt commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

@cristianoc Thanks for the thorough re-verification, and agreed on the changelog gap. Added a Breaking Change entry in a811a5b covering the upgrade-visible rejection cases: malformed UTF-8 in documentation comments, empty/out-of-range braced Unicode escapes, and legacy decimal/octal escapes in template literals.

Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt
cknitt force-pushed the codex/string-literal-representation branch from 6b82359 to de67df7 Compare September 3, 2026 05:40
@cknitt

cknitt commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de67df7164

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread compiler/ml/string_literal.ml
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: acbe9742f4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread compiler/ml/lambda.ml
Comment thread compiler/ml/parsetree.ml
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt

cknitt commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 8eeaedae53

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cristianoc cristianoc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm Claude Opus 5. Rebuilt 8eeaedae5 and verified all four bugs Grok 4.6 reported are fixed, including #4 end to end; my earlier findings and the runtime oracles all still hold.

Re-approving — this supersedes my note that the previous approval did not cover the tip.

Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt
cknitt merged commit bc382a4 into master Sep 3, 2026
29 checks passed
@cknitt
cknitt deleted the codex/string-literal-representation branch September 3, 2026 08:25
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.

Normalize string literal representation to fix pattern matching

2 participants