Skip to content

fix(syntax): implement Hy's string prefixes, shebang and bracket strings - #103

Merged
takeokunn merged 2 commits into
mainfrom
fix/hy-string-prefixes
Aug 3, 2026
Merged

fix(syntax): implement Hy's string prefixes, shebang and bracket strings#103
takeokunn merged 2 commits into
mainfrom
fix/hy-string-prefixes

Conversation

@takeokunn

Copy link
Copy Markdown
Collaborator

Hy string prefixes were unimplemented across the board — f"…", r"…", b"…", rb"…", t"…". The atom scanner ran past the opening quote and stopped at a delimiter inside the literal, so r"a)b" closed its enclosing list early. f-strings are one instance of this, not the whole bug — it accounts for 417 of 469 baseline parse failures.

Two more, both silent:

  • #! shebangs not stripped — 393 files parsed at exit 0 with the shebang as two junk atoms.
  • Bracket strings #[[…]] parsed as code#[[(defn evil [] 1)]] yielded a real defn node inside a raw string. That's the shape where a lint rule fires on text that isn't code. #[delim[…]delim] was silent too, not a loud failure as my brief claimed.

Measured against HEAD, with Hy itself as oracle

Same code, both sides, 2824 readable files:

HEAD merged
parse failures 493 116
files we refuse that Hy accepts 308 15

378 newly parse, 1 newly fails — and that one is a true positive: real Hy refuses it too (LexException: invalid string prefix). It previously "passed" only because the f-string was never scanned. Every failure class strictly decreased; none went up. All 15 remaining over-refusals already failed at HEAD with byte-identical errors, so there are zero new over-refusals.

f-strings are one opaque atom, deliberately

read_fcomponent calls parse_one_form, so interpolations are arbitrary code and finding the closing quote needs a sub-reader — f"{(str "}")}" is legal, as are f"{f"{x}"}" and f"{#[[raw]]}".

But ExpressionKind is only Root/List/Atom, so interleaved text-and-forms needs a new node kind rippling through the formatter, edit engine and every rule — and children are editable, so the formatter would reindent inside the literal segments between interpolations. Exposing them trades the #[[…]] silent-corruption class for a new one. Blind beats wrong, and today the whole file is invisible.

A surprise worth recording: format specs are nearly unreachable. : is not in NON_IDENT, so f"{x:>10}" interpolates the symbol x:>10, and f"{x:>{width}}" is a LexException. You need f"{x :>10}".

~ deliberately excluded, though it was implemented and measured

Several formatter paths opened a child list without writing that child's prefixes, so a prefixed list was emitted with its prefix deleted. Making ~ a prefix aimed that at the most common construct in Hy macro code: edit format newly changed the meaning of 14 files. PR #100 has since fixed that family, so ~ is the obvious next step; hy_unquote_is_not_yet_a_reader_prefix pins the known-wrong reading so whoever does it finds the reasoning.

Other dialects: 42,142 file-parses, zero differences

Full-tree hash pre- vs post-change across carp/clojure/commonlisp/emacslisp/fennel/janet/lfe/racket/scheme plus all 21,071 under the permissive Unknown reader. A unit test pins has_prefixed_strings/has_bracket_strings false and #[[a]] as a list for all ten.

Rebase: resolved arm by arm, not by side

This crossed #96, #98 and #100, all of which edit the same files. The classify_reader_macro match Dialect table is the dangerous one — base had the group Unknown | Lfe | Hy | Carp; #98 pulled Lfe out and this change pulls Hy out, so each side's group line still named the other's dialect. Taking either side would have silently deleted a dialect's reader support with every gate still green.

A 29-case probe confirms every recent PR's behaviour survives: Janet's ' prefix, Clojure's spaced #:ns {…}, LFE's #B(/#M(/#S(/#"…"/#\(/|foo bar|, and #100's MissingReaderForm on (a ') across five dialects — plus non-leakage checks that f"a b" in Common Lisp is still four children.

One probe assertion of mine was wrong and got corrected rather than papered over: I'd asserted a later-line #! is refused; it isn't, and the branch's own test documents that it keeps its pre-existing reading.

One pre-existing issue found, not touched

13 of the 15 remaining over-refusals are the ,-as-Unquote divergence colliding with #100. Hy's empty tuple is written (,), and a trailing , before } is common; with , read as a reader prefix, #100 turns those into a hard MissingReaderForm. classify_hy's comment says , "occurs at a token start essentially never" — the corpus says 13 files, including hylang/simalq, kanaka/mal and Hy's own contrib/walk.hy. Live on main today, not a merge defect, but the comment's claim is measurably too strong.

Verification

cargo build --workspace, cargo test --workspace (152 suites), cargo test --test cli, cargo fmt --all --check, cargo clippy --all-targets --all-features -- -D warnings — all exit 0. All four prior PRs' own tests ran and passed by name. No golden or pinned count moved.

Hy string prefixes were unimplemented across the board -- `f"..."`,
`r"..."`, `b"..."`, `rb"..."`, `t"..."`. The atom scanner ran past the
opening quote and stopped at a delimiter *inside* the literal, so
`r"a)b"` closed its enclosing list early. f-strings are one instance of
this, not the whole bug: it accounts for **417 of 469** baseline parse
failures.

Two more, both silent: `#!` shebangs were not stripped (393 files parsed
at exit 0 with the shebang as two junk atoms), and bracket strings
`#[[...]]` were parsed as *code* -- `#[[(defn evil [] 1)]]` yielded a
real `defn` node inside a raw string, which is the shape where a lint
rule fires on text that is not code. `#[delim[...]delim]` was silent
too, not a loud failure as first reported.

Measured against HEAD with the same code, over 2824 readable files:
**493 failures -> 116**, with 378 newly parsing and 1 newly failing.
Every failure class strictly decreased. Against **Hy 1.3.0's own
reader** as external oracle, files we refuse that Hy accepts fell from
**308 to 15**, with zero new over-refusals; all 15 already failed at
HEAD with byte-identical errors.

The one newly-failing file is a true positive -- real Hy refuses it too
(`LexException: invalid string prefix`). It previously "passed" only
because the f-string was never scanned.

f-strings are modelled as **one opaque atom**, deliberately.
`read_fcomponent` calls `parse_one_form`, so the interpolations are
arbitrary code and finding the closing quote needs a sub-reader --
`f"{(str "}")}"` is legal. But `ExpressionKind` is only Root/List/Atom,
so interleaved text-and-forms needs a new node kind rippling through the
formatter, edit engine and every rule; and children are *editable*, so
the formatter would reindent inside the literal segments between
interpolations. Exposing them trades the `#[[...]]` silent-corruption
class for a new one. Blind beats wrong, and today the whole file is
invisible.

A surprise worth recording: **format specs are nearly unreachable.**
`:` is not in `NON_IDENT`, so `f"{x:>10}"` interpolates the *symbol*
`x:>10`, and `f"{x:>{width}}"` is a LexException. You need `f"{x :>10}"`.

`~` as a reader prefix is deliberately **not** included, though it was
implemented and measured. Several formatter paths open a child list
without writing that child's prefixes, so a prefixed list is emitted
with its prefix deleted -- making `~` a prefix aimed that at the most
common construct in Hy macro code, and `edit format` newly changed the
meaning of 14 files. PR #100 has since fixed that family; `~` is the
obvious next step, and `hy_unquote_is_not_yet_a_reader_prefix` pins the
known-wrong reading so whoever does it finds the reasoning.

Other dialects: **42142 file-parses, zero differences**, full-tree hash
pre- vs post-change across all nine plus the permissive `Unknown`
reader. A unit test pins `has_prefixed_strings`/`has_bracket_strings`
false and `#[[a]]` as a *list* for all ten.

Rebased across #96, #98 and #100, all of which edit the same files. The
`classify_reader_macro` dispatch table was resolved arm by arm rather
than by side: base had the group `Unknown | Lfe | Hy | Carp`, #98 pulled
`Lfe` out and this change pulls `Hy` out, so each side's group line
still named the other's dialect. Taking either side would have silently
deleted a dialect's reader support. A 29-case probe confirms every
recent PR's behaviour survives -- Janet's `'`, Clojure's spaced
`#:ns {...}`, LFE's `#B(`/`#M(`/`#S(`/`#"`/`#\`/`|...|`, and #100's
`MissingReaderForm` on `(a ')`.

One pre-existing issue found and not touched: 13 of the 15 remaining
over-refusals are the `,`-as-`Unquote` divergence colliding with #100.
Hy's empty tuple is `(,)`, and a trailing `,` before `}` is common, so
those are now hard refusals. `classify_hy`'s comment claims `,` "occurs
at a token start essentially never" -- the corpus says 13 files,
including `hylang/simalq`, `kanaka/mal` and Hy's own `contrib/walk.hy`.
Live on main today, and the obvious next Hy item.
…path

CI's bench gate measured `parse-scaling/reader-conditional` +11.7% at
1MiB and +10.6% at 8MiB. Both sizes moving together by the same amount
is the real-regression signature, not the `edit-all-loop` measurement
artifact -- and unlike that one, this branch genuinely does touch the
parser's inner loop.

`at_hy_string` is called once per *form* from `parse_form`'s match and
again from the discarded-form scanner. Its dialect test sat inside
`hy_string_prefix_width`, one call deep, with a second
`has_bracket_strings()` test after it -- so for the other nine dialects
it was two guarded calls and two byte reads per form rather than one
comparison against a loop-invariant field.

Hoisted the dialect gate to the top and marked the function `#[inline]`.
On an 8MiB Common Lisp document that is millions of calls that can only
ever answer `false`.

This is the ordering the Carp reader change adopted deliberately for the
same reason: `self.dialect` is loop-invariant across the per-byte and
per-form calls, so testing it first lets the rest fold away.
@takeokunn
takeokunn merged commit fcb8de5 into main Aug 3, 2026
9 of 10 checks passed
@takeokunn
takeokunn deleted the fix/hy-string-prefixes branch August 3, 2026 15:09
takeokunn added a commit that referenced this pull request Aug 3, 2026
`,` is not a reader macro in Hy. Its `@reader_for` table has no `,`
entry, and `NON_IDENT` -- the complete set of identifier terminators --
is `set("()[]{};\"'\`~")`, which lists `~` and not `,`. Confirmed by
running Hy 1.3.1: `(hy.read-many "(foo ,bar)")` gives
`Expression([Symbol('foo'), Symbol(',bar')])`, one symbol.

So classifying `,` as a prefix was wrong at the root, not merely at a
closing delimiter. It stayed invisible while a dangling prefix was
tolerated, because the mis-parse only changed the tree's shape. Once
#100 made a prefix with no following form a hard `MissingReaderForm`,
it started refusing ordinary Hy outright:

- `(,)` -- an expression whose single element is the symbol `,`, which
  Hy's tuple constructor uses for the empty tuple.
- A trailing comma before `}` or `]`, as in `{"a" 1 ,}`.

Over 2825 real `.hy` files those two shapes account for **13 outright
parse failures**, including Hy's own `contrib/walk.hy`, `hylang/simalq`
and `kanaka/mal`. #103's comment claiming a leading `,` "occurs at a
token start essentially never" is corrected in place rather than left
standing, since the corpus refutes it.

Because `,` is not a reader macro, the fix is to stop classifying it as
one -- not to special-case "prefix before `)`", which would have undone
#100's fix. `'` and `` ` `` really are Hy reader macros taking exactly
one following form, so a closing delimiter after one is still refused,
pinned by a test.

Dropping the comma is enough on its own: `is_atom_boundary` never
treated `,` as a terminator, so `,bar` and `1,` already scanned as
single atoms and now agree with Hy at token start too.

With Hy as oracle: files paredit refuses that Hy accepts **308 -> 295**,
of which `MissingReaderForm` **17 -> 4**; structural agreement
217/2037 -> 230/2037, **+13 newly agreeing and 0 newly disagreeing**;
25 files newly parse and 0 newly fail. The 4 remaining
`MissingReaderForm` are f-string/raw-string cases, which #103 covers.

Other nine dialects: **24879 files, 0 changed, 0 newly parsing, 0 newly
failing**, by canonical full-tree dump. Pinned by test that `,` still
yields `Unquote` in CL/Elisp/Scheme/Racket/Fennel/LFE/Janet/Carp/Unknown
and is still whitespace in Clojure. The formatter round-trips all 2356
parseable Hy files with 0 non-idempotent and 0 shape-changed.

`~`/`~@` are the matching gap in the other direction and are left: they
cause no refusals, and #103 records that adding them changed the
*meaning* of 14 files through a formatter prefix-drop that #100 has
since fixed. That is now a clean follow-up rather than a blocker.

The dialect test stays where it was -- inside the existing
`match self.dialect` at the top of `classify_reader_macro`, so the
`b','` check is reachable only from the `Dialect::Hy` arm and the other
nine pay nothing. Inverting that ordering is what caused the
`parse-scaling/reader-conditional` regression on #103.

Merge note: git applied this patch as a *second* `classify_hy` rather
than merging its arm into #103's, which would not have compiled. The
two are combined here into one `match (byte, next)` carrying both the
`#[` and `,` arms.
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