diff --git a/lib/reader.ml b/lib/reader.ml index 6ab0f74..803a1b1 100644 --- a/lib/reader.ml +++ b/lib/reader.ml @@ -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 diff --git a/test/test_flan.ml b/test/test_flan.ml index 9bf26b7..12c2e60 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -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:"" 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:"" 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:"" 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