A backtick was a name character, which is how the apostrophe used to be

`(a b) came back as the unknown name "`" — precisely the failure the
reader's own header warns about for the apostrophe, one sigil over and
still open. Same fix: the sigil reads as a wrapper and the reader stays
dumb about what it means.

Clojure's ` ~ ~@ rather than Common Lisp's ` , ,@ because a comma is
already whitespace here, and every binding vector in the corpus is
written assuming that. Changing is_delim to free up the comma would
rewrite more of the language than macros are worth.

Backtick and tilde join is_delimiter so that a~b is two things and can
never be one name. No symbol in the corpus contains either character, so
closing the class costs nothing now and would cost a migration later.
This commit is contained in:
Joseph Ferano 2026-09-11 20:13:25 +07:00
parent da38a3db5f
commit 9eb87e486a
2 changed files with 80 additions and 12 deletions

View File

@ -7,9 +7,15 @@
restart names are quoted symbols ([(invoke-restart 'skip-form)]) and without
it the apostrophe would silently become part of the symbol's name.
Not handled yet: quasiquote/unquote (milestone 5, with macros) and metadata
([^:async]). Metadata is rejected rather than read as a symbol, so it cannot
rot into a silently-wrong name the way quote would have. *)
[`x], [~x] and [~@x] read as [(quasiquote x)], [(unquote x)] and
[(unquote-splicing x)] by the same rule: the reader stays dumb, and what
those names mean is settled later. Clojure's spelling rather than Common
Lisp's, because [,] is already whitespace here (see [is_delimiter]) and
every binding vector in the corpus relies on that.
Not handled yet: metadata ([^:async]). It is rejected rather than read as a
symbol, so it cannot rot into a silently-wrong name the way quote would
have. *)
type state = {
src : string;
@ -35,9 +41,13 @@ let advance st =
end
(* Symbol constituents. Note '-' and '?' and '!' and '/' and '.' are all
ordinary: `empty-at?`, `rl/draw-fps`, `.pos`, `->>` are single symbols. *)
ordinary: [empty-at?], [rl/draw-fps], [.pos], [->>] are single symbols.
'`' and '~' end a symbol, so [~x] is two things and never one name. That is
the same guard the apostrophe wants and does not have; the corpus has no
symbol containing either character, so closing the class costs nothing. *)
let is_delimiter = function
| '(' | ')' | '[' | ']' | '{' | '}' | '"' | ';' | '\000' -> true
| '(' | ')' | '[' | ']' | '{' | '}' | '"' | ';' | '`' | '~' | '\000' -> true
| c -> c = ' ' || c = '\t' || c = '\n' || c = '\r' || c = ','
let is_digit c = c >= '0' && c <= '9'
@ -154,10 +164,12 @@ let rec read_form st =
| ')' | ']' | '}' as c -> Loc.fail loc "unbalanced %C" c
| '"' -> read_string st
| '\\' -> read_byte st
| '\'' ->
| '\'' -> read_sugar st loc "quote"
| '`' -> read_sugar st loc "quasiquote"
| '~' ->
advance st;
let quoted = read_form st in
Form.make (Form.List [ Form.make (Form.Sym "quote") loc; quoted ]) loc
if peek st = '@' then (advance st; read_wrapped st loc "unquote-splicing")
else read_wrapped st loc "unquote"
| '^' ->
Loc.fail loc "metadata (^) is not supported yet"
@ -165,6 +177,15 @@ let rec read_form st =
| ('-' | '+') when is_digit (peek2 st) -> read_number st
| _ -> read_symbol_or_keyword st
(* One sigil character, then the form it applies to, wrapped in a name. The
name's location is the sigil's, so an error inside the wrapper points at the
character the reader saw rather than at the form after it. *)
and read_sugar st loc name = advance st; read_wrapped st loc name
and read_wrapped st loc name =
let inner = read_form st in
Form.make (Form.List [ Form.make (Form.Sym name) loc; inner ]) loc
and read_seq st open_c loc =
advance st;
let want = closer open_c in

View File

@ -11,6 +11,11 @@ let check name cond =
Printf.printf "FAIL %s\n" name
end
let contains hay needle =
let n = String.length needle and h = String.length hay in
let rec go i = i + n <= h && (String.sub hay i n = needle || go (i + 1)) in
n = 0 || go 0
let reads name src expected =
match Reader.read_all ~file:"<test>" src with
| forms ->
@ -25,10 +30,18 @@ let reads name src expected =
Printf.printf "FAIL %s\n src: %s\n error: %s: %s\n"
name src (Loc.to_string loc) msg
let rejects name src =
(* [needle] is the point: a read error that fires for the wrong reason is not
the test passing. Without it "backtick at end of input" would be green even
if the backtick were still an ordinary symbol character. *)
let rejects ?needle name src =
match Reader.read_all ~file:"<test>" src with
| _ -> incr failures; Printf.printf "FAIL %s: expected a read error\n" name
| exception Loc.Error _ -> ()
| exception Loc.Error (_, msg) ->
(match needle with
| Some n when not (contains msg n) ->
incr failures;
Printf.printf "FAIL %s\n error: %s\n wanted: ...%s...\n" name msg n
| _ -> ())
let () =
(* ── Atoms ─────────────────────────────────────────────────────── *)
@ -77,16 +90,45 @@ let () =
"(invoke-restart (quote use-placeholder))";
reads "quote list" "'(a b)" "(quote (a b))";
(* ── Quasiquote ────────────────────────────────────────────────── *)
(* The bug this closes: a backtick was an ordinary symbol character, so
`(a b) came back as the unknown name "`" the apostrophe's old failure
mode, still open one sigil over. Clojure's ` ~ ~@ rather than Common
Lisp's ` , ,@ because a comma is whitespace here and every binding vector
depends on that. *)
reads "quasiquote list" "`(a b)" "(quasiquote (a b))";
reads "unquote" "`(a ~b)" "(quasiquote (a (unquote b)))";
reads "unquote-splicing" "`(a ~@bs)" "(quasiquote (a (unquote-splicing bs)))";
reads "unquote a call" "`(+ ~(f x) 1)"
"(quasiquote (+ (unquote (f x)) 1))";
(* Nesting: the reader does not count levels, it just wraps again. Which
level an unquote belongs to is the expander's problem, not the reader's. *)
reads "nested quasiquote" "`(a `(b ~c))"
"(quasiquote (a (quasiquote (b (unquote c)))))";
(* An unquote outside any quasiquote still reads. It has to: the reader is
dumb and has no idea where it is. Parse refuses it see the parse tests. *)
reads "unquote alone" "~x" "(unquote x)";
reads "splice alone" "~@x" "(unquote-splicing x)";
(* A quote inside a quasiquote stays a quote; the two sigils do not merge. *)
reads "quote in quasi" "`(a 'b)" "(quasiquote (a (quote b)))";
(* The delimiter half of the fix: without it ~x is one symbol named "~x". *)
reads "tilde ends a name" "(f a~b)" "(f a (unquote b))";
reads "backtick in vec" "[`a ~b]" "[(quasiquote a) (unquote b)]";
(* The whole class: no reader-significant character may end up inside a name. *)
let rec bad_names f =
let open Form in
match f.v with
| Sym s | Kw s ->
if String.exists (fun c -> c = '\'' || c = '^') s then [ s ] else []
if String.exists (fun c -> c = '\'' || c = '^' || c = '`' || c = '~') s
then [ s ] else []
| List l | Vec l | Map l -> List.concat_map bad_names l
| _ -> []
in
let corpus = "(invoke-restart 'skip-form) (a 'b [c 'd] {:e 'f}) '(g 'h)" in
let corpus =
"(invoke-restart 'skip-form) (a 'b [c 'd] {:e 'f}) '(g 'h) \
`(i ~j ~@k) `(l `(m ~n)) [`o ~p] {:q `r}"
in
check "no sigils leak into names"
(bad_names (Form.make (Form.List (Reader.read_all ~file:"<test>" corpus))
Loc.unknown) = []);
@ -100,6 +142,11 @@ let () =
rejects "unknown char" "\\bogus";
rejects "metadata" "^:async";
rejects "dangling quote" "'";
(* Each of these asserts the reason, not merely that something failed. *)
rejects "backtick at end" "`" ~needle:"unexpected end of input";
rejects "tilde at end" "~" ~needle:"unexpected end of input";
rejects "splice at end" "~@" ~needle:"unexpected end of input";
rejects "quasiquote unclosed" "`(a b" ~needle:"unclosed";
(* ── Locations ─────────────────────────────────────────────────── *)
(match Reader.read_all ~file:"f.flan" "(a)\n (b)" with