Skip to content

Decode \uXXXX and \UXXXXXXXX in string literals; add <<'…' and <<"…" - #62

Merged
rjose merged 8 commits into
mainfrom
fix/big-8659-unicode-escapes
Oct 19, 2026
Merged

rjose merged 8 commits into
mainfrom
fix/big-8659-unicode-escapes

Conversation

@rjose

@rjose rjose commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Problem

Models write Unicode escapes in Forthic strings: \u2014 for a dash, \u0027 for an apostrophe, \uD83D\uDE00 for an emoji, and the Python form \U0001F600. Forthic kept each one as text, so a user saw \u2014 in a reply. The bigb host repairs some of them before it parses the program (BIG-8657, bigb#7596), but that repair cannot decode ', " or \: it cannot know if a literal will be parsed again as code, where a decoded quote can close the inner literal. It also covers only one host path.

Also, a marked string had to be triple-quoted. <<"streamed reply" tokenized as unknown words, so a reply in a regular string could not stream (BIG-8660 needs it).

Options and choices

  • Where to decode: in the host, before parsing, or in the tokenizer. Tokenizer. It decodes once for each literal it parses, and decoded text is content: it is never scanned again for a delimiter. In a code string, each level decodes its own escapes, as in any language. Every Forthic caller gets it.
  • Long form: \UXXXXXXXX (Python; about 5 prod programs each week), \u{…} (JavaScript), both, or neither. \U only (Rino).
  • Lone surrogate: keep the escape text, decode the lone UTF-16 unit, U+FFFD, or an error. Keep the text (Rino). This is the rule for every unknown escape, and a string never holds invalid UTF-16.
  • <<'…': supported, next to <<"…" (Rino). Same escapes and holdback as the triple forms.
  • Streaming holdback: scan the tail of the open string in get_string_value() for a partial escape, or stop gathering at an escape that the input ends inside. Stop gathering. The open string then never holds undecided text, one rule covers both widths, and the old backslash holdback goes away. Only the triple-quote delimiter holdback stays. One visible effect: a settled \\ at the end of a chunk now reaches the sink one chunk earlier than in 0.21.0, which held every trailing backslash.
  • Token end_pos: it was start_pos plus the decoded length, so it fell short after every escape (\n by one, a surrogate pair by ten). A closed string now records where its content ends in the source.

Key changes

  • src/forthic/tokenizer.ts
    • read_escape(index) returns the escape's text and length, null (the backslash is content), or PENDING (the input ends first: \, a partial \u/\U, or a high surrogate that a low surrogate escape can still follow). read_hex() reads the digits.
    • gather_escape() serves both gather loops. On PENDING the loop stops, so a streaming chunk returns the open string without the undecided escape, and run() throws UnterminatedStringError as before.
    • is_string_redirect_start() accepts << glued to any quote. transition_from_GATHER_STRING takes is_string_redirect, sets string_redirect_open, and marks its token. It no longer advances before the escape check, like the triple-quote loop.
    • get_string_value() holds back only a trailing run of the triple-quote delimiter.
    • Both string loops set token_end_pos at the closing delimiter, and get_token_location() uses it for end_pos. The error underline in errors.ts uses end_pos.

Breaking changes

Tests

  • src/forthic/tests/unit/core/tokenizer.test.ts: "Unicode escapes (\uXXXX, \UXXXXXXXX)": every non-raw width and marked strings, hex case, surrogate pairs, lone surrogates, incomplete escapes, \U limits, \u{…} and raw literals unchanged, \\u, decoded quotes are content. "Marked single-width strings": both quotes, escapes, << "x" with a space, unterminated.
  • src/forthic/tests/unit/core/tokenizer.test.ts: "String token positions after escapes": end_pos after escapes and without them.
  • src/forthic/tests/integration/string_escapes.test.ts: "Unicode escapes in code strings": RUN and MAP code strings; a decoded quote in the outer literal closes the inner one (UnknownWordError).
  • src/forthic/tests/integration/string_redirect.test.ts: "escapes stream as their decoded text": four programs, every cut; the sink text is always a prefix of the final string and equals it at the end.

Why other files changed

  • CHANGELOG.md: two breaking entries under [Unreleased], with a note that regex and JSON want a raw literal.
  • scripts/generate-docs.ts: the prompt template. It names the new escapes and has a short "Unicode escapes" section. Its two code → expected claims run in npm run smoke.
  • docs/forthic-prompt.md: regenerated from the template.

Limits

  • An error inside code that RUN or MAP runs reports a position in the decoded code text. After an escape in the outer literal, that position does not match the source. \n has the same limit in 0.21.0.

Closes BIG-8659.

🤖 Generated with Claude Code

Rino Jose and others added 8 commits October 16, 2026 16:25
…nd <<"…" (BIG-8659)

Tests for the U2.1 language decisions (2026-10-16): a lone surrogate keeps
its escape text, \UXXXXXXXX is the only long form, and <<'…' and <<"…"
are both marked strings. Streaming tests feed every cut of a program and
check that the sink only ever receives a prefix of the finished string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… <<'…' and <<"…" (BIG-8659)

One read_escape() serves both string loops. It returns the decoded text,
null (the backslash is content), or PENDING when the input ends before the
escape is decided: a bare backslash, a partial \u or \U, or a high
surrogate that a low surrogate escape can still follow. On PENDING the loop
stops, so an open string never holds text that more input can change, and
a redirect sink only ever receives a prefix of the finished string.

A lone surrogate, a \U value outside Unicode, and an incomplete escape keep
their text, as every unknown escape does. A marked string now opens with
<< glued to any quote; the single-width form gathers with the same escapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ckslash holdback (BIG-8659)

Both gather loops had the same escape block, so it moves into
gather_escape(). get_string_value() no longer holds back a trailing
backslash: the gather never appends an undecided one now, because
read_escape() reports it pending. A trailing quote is still held back. No
test changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CHANGELOG: two breaking entries under [Unreleased]. The prompt doc gets a
short Unicode escapes section with two claims that the smoke test runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or (BIG-8659)

Local review F2: the program in "each level decodes its own escapes" had
lost its backslash when the file was written, so it passed on 0.21.0. It
now fails on main ("\\u0027") and passes on the branch. Review F6: the
outer-quote test asserts the error type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nes (BIG-8659)

Local review F4: get_token_location() sets end_pos to start_pos plus the
decoded length, so it falls short after every escape (\n by one, a
surrogate pair by ten). The error underline in errors.ts uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rce (BIG-8659)

Both string loops record the source position of the closing delimiter in
token_end_pos (a field that was declared but unused), and
get_token_location() uses it. Other tokens keep start_pos plus their length,
which equals their source span.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gex and JSON (BIG-8659)

Local review F1: docs/forthic-prompt.md is generated by
scripts/generate-docs.ts, and npm run docs:build removed the new section.
The section now lives in the template, and the escape list names the two
new escapes. Running docs:build again changes nothing.

Local review F3: CHANGELOG and prompt say that a decoded character is plain
content (a decoded . in a regex, a decoded quote in JSON text), and that
regex and JSON want a raw literal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rjose
rjose merged commit 9e27982 into main Oct 19, 2026
2 checks passed
@rjose rjose mentioned this pull request Oct 19, 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.

1 participant