fix(syntax): implement Hy's string prefixes, shebang and bracket strings - #103
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, sor"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.#[[…]]parsed as code —#[[(defn evil [] 1)]]yielded a realdefnnode 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:
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_fcomponentcallsparse_one_form, so interpolations are arbitrary code and finding the closing quote needs a sub-reader —f"{(str "}")}"is legal, as aref"{f"{x}"}"andf"{#[[raw]]}".But
ExpressionKindis onlyRoot/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 inNON_IDENT, sof"{x:>10}"interpolates the symbolx:>10, andf"{x:>{width}}"is aLexException. You needf"{x :>10}".~deliberately excluded, though it was implemented and measuredSeveral 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 formatnewly 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_prefixpins 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
Unknownreader. A unit test pinshas_prefixed_strings/has_bracket_stringsfalse 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_macromatch Dialecttable is the dangerous one — base had the groupUnknown | Lfe | Hy | Carp; #98 pulledLfeout and this change pullsHyout, 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'sMissingReaderFormon(a ')across five dialects — plus non-leakage checks thatf"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-Unquotedivergence 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 hardMissingReaderForm.classify_hy's comment says,"occurs at a token start essentially never" — the corpus says 13 files, includinghylang/simalq,kanaka/maland Hy's owncontrib/walk.hy. Live onmaintoday, 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.