Skip to content

fix(compiler): stop the backtick dedent from eating authored escapes - #4365

Merged
hellovai merged 7 commits into
canaryfrom
vbv/b-1474
Aug 12, 2026
Merged

fix(compiler): stop the backtick dedent from eating authored escapes#4365
hellovai merged 7 commits into
canaryfrom
vbv/b-1474

Conversation

@hellovai

@hellovai hellovai commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Fixes B-1474.

The bug

A trailing \n in a backtick string literal was silently dropped:

`${id.hostname}\n`   // -> "myhost", no newline, no error

This broke generating newline-terminated files (/etc/hostname, /etc/locale.conf). It was invisible in test logs, since a value that differs only by a trailing newline prints identically.

Why

Two things in the §12 dedent conflated layout with content.

  1. Escapes were decoded before the dedent ran. By the time layout was stripped, an authored \n had already become a real newline, indistinguishable from a line break the author typed to lay the literal out across lines. This also meant a literal written on a single source line could be sent down the multi-line path, because the "is this multi-line?" test ran on the decoded text.
  2. The dedent ended in a blanket .trim(), which removed whatever whitespace sat at either end. That newline, blank lines the author left in, and trailing spaces all went.

The fix

baml_base::dedent::dedent_backtick replaces preprocess_template on the backtick path. It strips layout and only layout:

  1. Normalize \r\n / lone \r to \n.
  2. Drop the line break after the opening delimiter, and the line break plus indent before the closing one. Those belong to the delimiters.
  3. Strip the longest common leading-whitespace prefix.

No trim. It runs on the raw source text, before escape decoding, so \n is still two opaque characters while layout is being removed. §13 block-tag whitespace moves ahead of decoding for the same reason: its line scan would otherwise treat an authored \n as a line boundary.

preprocess_template is deleted. #4367 removed the Jinja runtime and with it sys_llm::preprocess_template, its only other caller, so once backticks moved onto dedent_backtick it had none left. Leaving a public function that implements exactly the trimming semantics this PR removes, sitting next to its replacement, is a trap. Its tests move onto dedent_backtick; two of them now record the new behavior:

"    hello\n  world"  ->  "  hello\nworld"   was "hello\nworld"
"\t- foo\n    - bar"  ->  unchanged          was "- foo\n    - bar"

Both differences are the absent trim. The NBSP and U+2028 char-boundary regression tests carry over unchanged — dedent_backtick uses the same helpers they were written to guard — and now assert byte-exact preservation rather than only that nothing panics.

What changes

Multi-line literals laid out the ordinary way dedent to exactly what they did before, so the formatter is unchanged and the compiles/ and diagnostic_errors/ snapshots do not move. The differences:

`${host}\n`          -> "alpha\n"     was "alpha"
` a\n b\t `          -> " a\n b\t "   leading/trailing spaces no longer mangled
`\n  a\n  b\n\n  `   -> "a\nb\n"      a blank line before the closer now means
                                      a trailing newline
`\n  a  \n  b\n`     -> "a  \nb"      trailing spaces on a line are content
`\n  a\n    b\n`     -> "a\n  b"      relative indent preserved, as before

\n still decodes to a newline inside backticks. Making it literal would leave \` and \$ decoding while \n did not, and #"..."# is already the fully-raw form.

One behavior change in the stdlib

The three bytecode_format snapshots move, and it is the same bug at the other edge. Two prompt sections in claude_code/ns_internal/cli.baml open with an explicit \n\n:

`\n\nThe output contract is one object with an outcome field. ...`
`\n\nConversation so far:\n${lines.join("\n")}`

They are concatenated with nothing between them — ${instructions}${transcript_text(...)}${tool_protocol(...)} — so that leading \n\n is the blank line separating sections, and .trim() was eating it. Rendered prompts now have the separator the source asks for. No other bytecode constant moved; I diffed all three snapshots in full.

The second one is worth a look: `\n\nConversation so far:\n${...}` is a single source line and should never have been dedented at all. The old code decided that from the already-decoded text, where the escapes had become real newlines.

Tests

  • baml_base: 17 dedent_backtick cases — 9 new (trailing escape, blank-line-before-closer, trailing spaces, escapes-are-not-indentation, CRLF) plus the 8 inherited from preprocess_template.
  • bex_engine: 5 new end-to-end cases running the real compiled program, including the reported `${host}\n` repro on both single-line and dedented literals.
  • baml_tests: a trailing-escape case added to the formatter value-preservation suite; three bytecode_format snapshots updated as above.
  • Unchanged and passing after merging canary: baml_compiler_parser (154), baml_compiler_syntax (74), baml_compiler2_ast, baml_fmt (124), bex_engine backtick (84) and llm_render (8), and baml_tests --lib at 1503. cargo fmt and cargo clippy --workspace --all-targets clean.

#4367 migrated fixtures from #"..."# to backtick strings, straight into this code path; none of them had edge blank lines the old trim was absorbing.

Local pre-commit hooks could not run (cargo-shear requires rustc 1.95, this toolchain is 1.93), so fmt and clippy were run directly. CI's Pre-commit Checks job passes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Cvpfs1fyG1RbDpTV3Ye28A

A trailing `\n` in a backtick string literal was silently dropped, so
`` `${id.hostname}\n` `` produced a file with no trailing newline and no
error to say so.

Two causes, both in how §12 dedent handled layout vs content:

* Escapes were decoded *before* the dedent ran, so an authored `\n` had
  already become a real newline by the time layout was stripped. The
  dedent could not tell it apart from a line break the author typed to
  lay the literal out.
* The dedent finished with a blanket `.trim()`, which removed whatever
  whitespace it found at either end, including that newline, blank lines
  the author left in, and trailing spaces.

Replace `preprocess_template` on the backtick path with `dedent_backtick`,
which strips layout and only layout: normalize line endings, drop the line
break after the opening delimiter and the line break plus indent before
the closing one, then strip the common leading-whitespace prefix. No trim.
Run it on the raw source text, before escape decoding, so `\n` stays two
opaque characters while layout is being removed; §13 block-tag whitespace
moves ahead of decoding for the same reason.

Normal multi-line literals dedent to exactly what they did before, so
there is no snapshot churn. What changes:

    `${host}\n`          -> "alpha\n"     (was "alpha")
    ` a\n b\t `          -> " a\n b\t "   (spaces no longer mangled)
    `\n  a\n  b\n\n  `   -> "a\nb\n"      blank line before the closer
                                          is now a trailing newline
    `\n  a  \n  b\n`     -> "a  \nb"      trailing spaces are content

`preprocess_template` stays as-is for the legacy Jinja prompt path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cvpfs1fyG1RbDpTV3Ye28A
@linear

linear Bot commented Aug 11, 2026

Copy link
Copy Markdown

B-1474

@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview Aug 12, 2026 5:00pm
promptfiddle2 Ready Ready Preview Aug 12, 2026 5:00pm

Request Review

@github-actions

Copy link
Copy Markdown

⏭️ Performance benchmarks were skipped

Perf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to canary/main.

To run them on this PR, do any of the following, then push a commit (or re-run CI):

  • Add RUN_CODSPEED=1 to the PR description, or
  • Include run-perf or /perf in the PR title or any commit message.

@vercel
vercel Bot temporarily deployed to Preview – beps August 11, 2026 22:52 Inactive
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ad8502c9-0cc7-4956-a2f7-1dae6d1c2c14

📥 Commits

Reviewing files that changed from the base of the PR and between 33e3af8 and 016be51.

📒 Files selected for processing (1)
  • baml_language/crates/baml_compiler_parser/src/parser.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • baml_language/crates/baml_compiler_parser/src/parser.rs

📝 Walkthrough

Walkthrough

The change replaces preprocess_template with dedent_backtick. Backtick processing now dedents raw content before escape decoding. Compiler, formatter, and runtime tests cover escaped content, indentation, delimiter newlines, blank lines, trailing spaces, and CRLF input.

Changes

Backtick dedentation flow

Layer / File(s) Summary
Raw backtick dedentation
baml_language/crates/baml_base/src/dedent.rs
Adds dedent_backtick, newline normalization, delimiter handling, common indentation removal, whitespace preservation, and focused tests.
Compiler escape and layout pipeline
baml_language/crates/baml_compiler_syntax/src/ast.rs, baml_language/crates/baml_compiler_parser/src/parser.rs
Applies dedentation and whitespace control while escapes remain encoded, then decodes text segments.
Formatter and runtime validation
baml_language/crates/baml_fmt/src/ast/tokens.rs, baml_language/crates/baml_tests/tests/backtick_fmt_value_preservation.rs, baml_language/crates/bex_engine/tests/backtick_strings.rs
Dedents raw content before decoding and adds regression coverage for escaped and trailing content.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BacktickStringLiteral
  participant dedent_backtick
  participant EscapeDecoder
  participant FormatterAndRuntimeTests
  BacktickStringLiteral->>dedent_backtick: raw literal with encoded escapes
  dedent_backtick->>BacktickStringLiteral: normalized and dedented literal
  BacktickStringLiteral->>EscapeDecoder: text after layout handling
  EscapeDecoder-->>BacktickStringLiteral: decoded text segments
  FormatterAndRuntimeTests->>BacktickStringLiteral: escaped-content regression cases
Loading

Possibly related PRs

Poem

A rabbit checks each backtick line,
Keeps authored spaces in their place.
Escaped newlines stay as content,
CRLF follows the same trace.
Dedentation now runs cleanly.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preserving authored escapes during backtick dedentation.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch vbv/b-1474

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 11, 2026 22:59 Inactive
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

Binary size checks passed

7 passed

Artifact Platform File Gzip Gated on Baseline Delta Status
baml-cli Linux 🔒 26.2 MB 11.1 MB file 27.4 MB -1.2 MB (-4.4%) OK
packed-program Linux 🔒 16.9 MB 6.8 MB file 18.6 MB -1.7 MB (-9.1%) OK
baml-cli macOS 🔒 20.4 MB 9.7 MB file 21.3 MB -913.9 KB (-4.3%) OK
packed-program macOS 🔒 13.3 MB 6.0 MB file 14.5 MB -1.2 MB (-8.2%) OK
baml-cli Windows 🔒 21.9 MB 9.9 MB file 23.0 MB -1.1 MB (-4.7%) OK
packed-program Windows 🔒 14.1 MB 6.1 MB file 15.5 MB -1.4 MB (-9.3%) OK
bridge_wasm WASM 15.5 MB 🔒 4.2 MB gzip 4.6 MB -440.8 KB (-9.6%) OK

🔒 = the size this artifact is GATED on (ceiling + delta). Binaries gate on file size (installed binary); WASM gates on gzip (download size). The other size is shown for information only.


Generated by cargo size-gate · workflow run

Two stdlib prompt sections in `claude_code/ns_internal/cli.baml` open with
an explicit `\n\n` escape:

    `\n\nThe output contract is one object with an outcome field. ...`
    `\n\nConversation so far:\n${lines.join("\n")}`

They are concatenated with no separator between them —
`${instructions}${transcript_text(...)}${tool_protocol(...)}` — so that
leading `\n\n` *is* the blank line between sections. The old `.trim()`
was eating it, running the sections together in the rendered prompt.
This is the same bug as the trailing case, at the other edge.

The snapshots now show what the source asks for. No other constant moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cvpfs1fyG1RbDpTV3Ye28A
@hellovai
hellovai enabled auto-merge August 12, 2026 01:52
@vercel
vercel Bot temporarily deployed to Preview – beps August 12, 2026 01:53 Inactive
@vercel
vercel Bot temporarily deployed to Preview – beps August 12, 2026 01:55 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 12, 2026 02:01 Inactive
hellovai and others added 2 commits August 11, 2026 23:04
Resolves one conflict in `baml_base/src/dedent.rs`, in the module doc.

The Jinja removal (#4367) took `sys_llm::preprocess_template` with it, and
this branch had already moved backtick literals onto `dedent_backtick`, so
`baml_base::dedent::preprocess_template` was left with no callers. Delete
it: it implements exactly the blanket-trim semantics this branch is
fixing, and leaving it public next to its replacement invites someone to
reach for the wrong one.

Its tests move onto `dedent_backtick`. Two of them now record the new
behavior instead of the old, which is the point of the change:

    "    hello\n  world"  -> "  hello\nworld"   (was "hello\nworld";
                            the common prefix is the shorter indent and
                            no trim follows it)
    "\t- foo\n    - bar"  -> unchanged          (was "- foo\n    - bar";
                            tabs and spaces share no prefix, so both
                            lines keep their own indent)

The NBSP and U+2028 char-boundary regression tests carry over unchanged —
`dedent_backtick` uses the same `leading_whitespace_bytes` and
`strip_leading_indent` helpers they were written to guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cvpfs1fyG1RbDpTV3Ye28A
@vercel
vercel Bot temporarily deployed to Preview – beps August 12, 2026 06:09 Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@baml_language/crates/baml_base/src/dedent.rs`:
- Around line 189-203: Update the tests nbsp_indent_does_not_panic and
line_separator_indent_does_not_panic to assert that dedent_backtick returns the
exact original input, including the NBSP and U+2028 indentation characters,
rather than only checking that the calls do not panic.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3e7a2309-5446-4985-a1d0-7e100ca4e63e

📥 Commits

Reviewing files that changed from the base of the PR and between 21751d7 and 33883f9.

⛔ Files ignored due to path filters (3)
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded_unoptimized.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_textual.snap is excluded by !**/*.snap
📒 Files selected for processing (3)
  • baml_language/crates/baml_base/src/dedent.rs
  • baml_language/crates/baml_compiler_syntax/src/ast.rs
  • baml_language/crates/baml_tests/tests/backtick_fmt_value_preservation.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • baml_language/crates/baml_tests/tests/backtick_fmt_value_preservation.rs
  • baml_language/crates/baml_compiler_syntax/src/ast.rs

Comment thread baml_language/crates/baml_base/src/dedent.rs Outdated
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 12, 2026 06:16 Inactive
The two Unicode-indent tests were written for a crash (a strip column
computed in bytes landing mid-character), so they only called
`dedent_backtick` and discarded the result. That checks the floor. It
says nothing about whether the NBSP or the U+2028 survives, which is the
property this branch is actually about.

Assert the exact output. Both inputs come back byte-for-byte: NBSP and
space are different characters, so by Rule 2 they share no common prefix,
the strip column is zero, and each line keeps the indent its author gave
it. Renamed to match what they now check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cvpfs1fyG1RbDpTV3Ye28A
@vercel
vercel Bot temporarily deployed to Preview – beps August 12, 2026 16:23 Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@baml_language/crates/baml_compiler_parser/src/parser.rs`:
- Around line 11816-11817: Update the comment in the dedent assertion test to
match the asserted behavior: state that the delimiter-only line’s newline and
indentation are removed, while the resulting text ends with `!\nWelcome.`. Do
not describe that delimiter-related whitespace as belonging to the text.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8399eb7a-5bf9-4315-b019-f86e6071b90c

📥 Commits

Reviewing files that changed from the base of the PR and between 33883f9 and 33e3af8.

📒 Files selected for processing (2)
  • baml_language/crates/baml_base/src/dedent.rs
  • baml_language/crates/baml_compiler_parser/src/parser.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • baml_language/crates/baml_base/src/dedent.rs

Comment thread baml_language/crates/baml_compiler_parser/src/parser.rs Outdated
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 12, 2026 16:30 Inactive
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: hellovai <vaibhavtheory@gmail.com>
@vercel
vercel Bot temporarily deployed to Preview – beps August 12, 2026 16:53 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 12, 2026 17:00 Inactive
@hellovai
hellovai added this pull request to the merge queue Aug 12, 2026
Merged via the queue into canary with commit 61d90ff Aug 12, 2026
211 of 217 checks passed
@hellovai
hellovai deleted the vbv/b-1474 branch August 12, 2026 18:43
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.

1 participant