Skip to content

Give template segments the location of their text, and keep template locations through the v0 AST - #9166

Merged
cknitt merged 2 commits into
masterfrom
template-segment-locs
Nov 1, 2026
Merged

cknitt merged 2 commits into
masterfrom
template-segment-locs

Conversation

@cknitt

@cknitt cknitt commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Fixes #9164.

Two commits: the first gives template segments the location of their text; the second keeps a template's location when the parsetree goes through the v0 AST for PPXs. They depend on each other, so they ship together: with only the first one, the v0 round trip would lose both backquotes of a template instead of one.

1. Template segments get the location of their text

Why

A template literal is parsed into an expression with one or more segments: the pieces of text between the interpolations. Each segment has its own location. Until now, that location had a lopsided shape: it started at the delimiter in front of the text (the opening backquote, or the } that closes an interpolation) but stopped before the delimiter after it (${ or the closing backquote).

Columns are 0-based below. ^ marks the characters a location covers.

let y = `a${b}c`

expression   8..16   `a${b}c`     ^^^^^^^^   the whole literal
segment "a"  8..10   `a           ^^         includes the opening backquote
segment "c"  13..15  }c                ^^    includes the }, but not the closing backquote

So a segment's location was neither its text nor its text with its delimiters, and code that needed the full literal had to know the shape. Ast_raw.payload_loc, for example, added one character at the end, but not at the start, to get a %raw template's location.

What changes

A segment's location is now exactly its text, which also matches how JS tooling such as Acorn and Babel place template elements:

let y = `a${b}c`

expression   8..16   `a${b}c`     ^^^^^^^^   unchanged
segment "a"  9..10   a             ^
segment "c"  14..15  c                  ^

More examples:

Source Segments before Segments after
`template` at 8 `template 8..17 template 9..17
`` at 8 ` 8..9 empty, 9..9
`${b}${c}` at 8 ` 8..9, } 12..13, } 16..17 empty 9..9, 13..13, 17..17
  • Parser (parse_template_expr): the scanner already starts a template token after its delimiter. The parser read the segment's start position before scanning the token, at the delimiter; it now reads it after. The template's own location used to be computed from its first and last segments; it now runs from the opening backquote to the end of the literal, so it doesn't depend on the segments. Every caller except error recovery already overrode it.

  • Ast_raw.payload_loc: a %raw template's segment is the text between the backquotes, so the payload location now widens by one character on each side.

  • Invalid escapes in templates are reported on the text, no longer starting at the backquote:

    let invalid = `\unicode`
    before: 2:15-23   (from the backquote)
    after:  2:16-23   (from the backslash)
    
  • parsetree.ml: the doc comment of Pexp_template states the rule.

Tagged templates (sql`select ${x}`) use the same parser code, so their raw_sources follow the same rule.

2. Templates keep their location through the v0 AST (#9164)

Why

PPXs receive the parsetree in the frozen v0 format: Ast_mapper_to0 converts it before they run, and Ast_mapper_from0 converts their output back. The v0 AST has no template node. A template without interpolations is a single string constant marked res.template, and a constant has one location only, while the current tree has two: the expression's and its segment's.

Ast_mapper_to0 stored the segment's location on that constant, and Ast_mapper_from0 then used it for both. So the template came back without its closing backquote (#9164). With the text-only segments from part 1 it would also lose the opening one:

let z = `template`

                                    expression    segment "template"
parsed                              8..18         9..17
round trip, before (master)         8..17         8..17
round trip, part 1 alone            9..17         9..17

Templates with interpolations were never affected. In v0 they are a chain of ^ applications, whose outer node carries the expression's location, and each segment constant keeps its own:

`a${b}c`   →   ("a" ^ b) ^ "c"     (with res.template marks)
               └ outer apply: expression location; "a", "c": segment locations

What changes

  • Ast_mapper_to0: the single constant of a template without interpolations takes the template's location, backquotes included.
  • Ast_mapper_from0: the segment's location is derived from it as the text between the backquotes, one character in from each end. Ghost locations, and ones too short to hold two backquotes, are kept as they are, so templates that PPXs build with Location.none stay as they were.
                                    expression    segment "template"
parsed                              8..18         9..17
v0 constant                         8..18
round trip, after                   8..18         9..17      ✓

Interpolated templates, tagged templates and %raw templates have their own v0 encodings and don't change.

Tests

  • res_test.ml parses plain, empty, interpolated, escaped, multi-line, nested and tagged templates. For each segment it cuts the segment's location out of the source and checks the result is exactly the segment's text. It also checks that the template's location covers the whole literal, backquote to backquote. Before part 1 it fails on the first case: segment "template" has location text "template"`.

  • template_locations_roundtrip_through_ast0 in ounit_ast_mapper0_tests.ml round-trips a whole file through v0 and checks the tree is identical, locations included:

    let a = `template`
    let b = %foo(`payload`)
    let c = `multi
    line`
    let d = `a${b}c${d}`
    let e = sql`select ${x}`
    let f = `a\`b`
    let g = %raw(@foo `"x"`)
    let h = ``

    Without part 2 it fails on the first binding.

  • The expectation of syntaxErrors_invalid_ordinary_template_escape moves by one column, as shown above.

Once this and #9161 are in, todo_roundtrips_through_ast0 can include a template message.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Kbf3gJFAva9685fjvkajub

@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Changelog

🐛 Bug fix

  • Fix the location of template literals without interpolations ending one character early in projects that use PPXs.

💅 Polish

  • Give template literal segments the location of their text, without the surrounding backquotes, ${ or }.

Semantic Diff

2 commits · Full page

Give template segments the location of their text

fa0d7d4 · cknitt + Claude · Nov 01, 2026, 11:14 UTC

Why

A template segment's location started at the delimiter before it (a backquote or }) and ended before the one after it (${ or a backquote), so it was neither the text nor the text with its delimiters. The old shape is what made the v0 bridge lose a character of a template's location (#9164). Segments now cover their text only.

AST modified (template segment locations)

let y = `a${b}c`

text

-expression 8..16   segments "a" 8..10, "c" 13..15
-covering   `a${b}c`   `a          }c
+expression 8..16   segments "a" 9..10, "c" 14..15
+covering   `a${b}c`   a           c

Diagnostic modified (invalid escape in a template)

let invalid = `\unicode`

diagnostic

 Syntax error!
-A.res:1:15-23
+A.res:1:16-23

Review

  • What else reads segment locations? Invalid escape errors, now pointing at the text; Ast_raw.payload_loc, which now widens a %raw template by one character on each side; and the v0 bridge, which keeps each segment's location as it is.
✨ Keep the location of templates without interpolations through ast0

506cb25 · cknitt + Claude · Nov 01, 2026, 08:57 UTC · new

Why

On the v0 wire, a template without interpolations was one string constant at its segment's location, which ends before the closing backquote, so the template lost one character of its location after a PPX round trip. The constant now takes the template's location, and the segment's location is derived from it on the way back (#9164).

AST modified (template after the v0 round trip)

let z = `template`

text

 parsed:              expression [1,0+8]..[1,0+18]
-after the round trip: expression [1,0+8]..[1,0+17]
+after the round trip: expression [1,0+8]..[1,0+18]

@cknitt

cknitt commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

@codex review

@codecov

codecov Bot commented Nov 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.75%. Comparing base (8e26a9c) to head (67bb8b5).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #9166   +/-   ##
=======================================
  Coverage   81.74%   81.75%           
=======================================
  Files         460      460           
  Lines       64944    64948    +4     
=======================================
+ Hits        53091    53097    +6     
+ Misses      11853    11851    -2     
Files with missing lines Coverage Δ
compiler/ml/ast_raw.ml 91.66% <ø> (ø)
compiler/syntax/src/res_core.ml 92.02% <100.00%> (ø)

... and 2 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.

cknitt and others added 2 commits November 1, 2026 11:21
Why:
    A template segment's location started at the delimiter before it (a
    backquote or `}`) and ended before the one after it (`${` or a
    backquote), so it was neither the text nor the text with its
    delimiters. The old shape is what made the v0 bridge lose a character
    of a template's location (#9164). Segments now cover their text only.

AST modified (template segment locations):
Example:
    let y = `a${b}c`
Before (text):
    expression 8..16   segments "a" 8..10, "c" 13..15
    covering   `a${b}c`   `a          }c
After (text):
    expression 8..16   segments "a" 9..10, "c" 14..15
    covering   `a${b}c`   a           c

Diagnostic modified (invalid escape in a template):
Example:
    let invalid = `\unicode`
Before (diagnostic):
    Syntax error!
    A.res:1:15-23
After (diagnostic):
    Syntax error!
    A.res:1:16-23

Review: What else reads segment locations? Invalid escape errors, now
    pointing at the text; `Ast_raw.payload_loc`, which now widens a %raw
    template by one character on each side; and the v0 bridge, which keeps
    each segment's location as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kbf3gJFAva9685fjvkajub
Signed-off-by: Christoph Knittel <ck@cca.io>
Why:
    On the v0 wire, a template without interpolations was one string
    constant at its segment's location, which ends before the closing
    backquote, so the template lost one character of its location after a
    PPX round trip. The constant now takes the template's location, and the
    segment's location is derived from it on the way back (#9164).

AST modified (template after the v0 round trip):
Example:
    let z = `template`
Before (text):
    parsed:              expression [1,0+8]..[1,0+18]
    after the round trip: expression [1,0+8]..[1,0+17]
After (text):
    parsed:              expression [1,0+8]..[1,0+18]
    after the round trip: expression [1,0+8]..[1,0+18]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kbf3gJFAva9685fjvkajub
Signed-off-by: Christoph Knittel <ck@cca.io>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 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-11-01T11:28:23.063578Z 506cb25 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.

@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: 67bb8b553c

ℹ️ 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/syntax/src/res_core.ml Resolved
@cknitt
cknitt force-pushed the template-segment-locs branch from 67bb8b5 to fa0d7d4 Compare November 1, 2026 11:23
@cknitt cknitt changed the title Give template segments the location of their text Give template segments the location of their text, and keep template locations through the v0 AST Nov 1, 2026
@cknitt

cknitt commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

@codex review

@cknitt
cknitt removed this pull request from stack #9167 November 1, 2026 11:28
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 506cb25792

ℹ️ 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".

@pkg-pr-new

pkg-pr-new Bot commented Nov 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@9166

@rescript/belt

npm i https://pkg.pr.new/@rescript/belt@9166

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@9166

@rescript/win32-x64

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

commit: 506cb25

@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown

@cknitt
cknitt marked this pull request as ready for review November 1, 2026 12:26
@cknitt
cknitt enabled auto-merge (squash) November 1, 2026 12:26
@cknitt
cknitt requested a review from cristianoc November 1, 2026 12:26
@cknitt
cknitt merged commit 5424e75 into master Nov 1, 2026
50 of 52 checks passed
@cknitt
cknitt deleted the template-segment-locs branch November 1, 2026 13:52
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.

v0 AST round trip shortens the location of templates without interpolations

2 participants