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:
parent
da38a3db5f
commit
9eb87e486a
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user