A kind is a stable id per error, so a test can assert which error this is without matching on prose and a message can be reworded without breaking anything. The reader's fourteen refusals all have one; in the checker they go on the errors a test names and the handful that are common enough to be worth classifying. Not a hundred of them, because jank has a hundred from being mature and the number is not the feature. The notes are the part that could not be said before. A duplicate definition now points at the second and notes the first; a duplicate parameter and a duplicate field do the same; an unknown field, an unknown struct and a non-exhaustive match all note the declaration and list what is actually there, so the reader's next move arrives with the question instead of after it. The reader's unclosed bracket is the clearest case — the error sits on the bracket, because that is where the fix goes, and the note sits where the file ran out, because that is the surprise. No message text changed, so every existing needle still means what it meant. The new assertions are on kinds and on note positions, which is the house rule about asserting the reason, made stable.
274 lines
11 KiB
OCaml
274 lines
11 KiB
OCaml
(** S-expression reader.
|
|
|
|
Hand-written rather than ocamllex/menhir: a Lisp needs no parser generator,
|
|
locations come out cleaner, and it keeps the compiler dependency-free.
|
|
|
|
['x] reads as [(quote x)]. That is not macro support — it is here because
|
|
restart names are quoted symbols ([(invoke-restart 'skip-form)]) and without
|
|
it the apostrophe would silently become part of the symbol's name.
|
|
|
|
[`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.
|
|
|
|
[#_] discards the form after it, as in Clojure: it is read and thrown away,
|
|
so commenting out a form does not mean counting its closing parens. Repeated
|
|
([#_#_]) discards that many following forms, which falls out of the
|
|
recursion rather than being counted — the discard reads *a form*, and the
|
|
form it reads may itself begin with a discard.
|
|
|
|
It is a property of [read_form] rather than of the sequence readers, so it
|
|
works in every position a form can appear: at the top level, inside a list
|
|
or a vector or a map, and after a quote. A trailing [#_] with nothing after
|
|
it is the one error, and it is the same error an unterminated form gives.
|
|
|
|
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;
|
|
file : string;
|
|
mutable pos : int;
|
|
mutable line : int;
|
|
mutable col : int;
|
|
}
|
|
|
|
let of_string ~file src = { src; file; pos = 0; line = 1; col = 1 }
|
|
|
|
let here st = Loc.make st.file st.line st.col
|
|
|
|
(* A form's location is the span it occupies, so every reader below takes the
|
|
location it started at and closes it where the cursor now is. Doing it here,
|
|
in the one place that knows both ends, is why nothing above [Reader] has to
|
|
know a span exists. *)
|
|
let spanned st loc v = Form.make v (Loc.upto loc (here st))
|
|
let at_end st = st.pos >= String.length st.src
|
|
let peek st = if at_end st then '\000' else st.src.[st.pos]
|
|
let peek2 st =
|
|
if st.pos + 1 >= String.length st.src then '\000' else st.src.[st.pos + 1]
|
|
|
|
let advance st =
|
|
if not (at_end st) then begin
|
|
if st.src.[st.pos] = '\n' then (st.line <- st.line + 1; st.col <- 1)
|
|
else st.col <- st.col + 1;
|
|
st.pos <- st.pos + 1
|
|
end
|
|
|
|
(* Symbol constituents. Note '-' and '?' and '!' and '/' and '.' are all
|
|
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
|
|
| c -> c = ' ' || c = '\t' || c = '\n' || c = '\r' || c = ','
|
|
|
|
let is_digit c = c >= '0' && c <= '9'
|
|
|
|
let rec skip_trivia st =
|
|
match peek st with
|
|
| ' ' | '\t' | '\n' | '\r' | ',' -> advance st; skip_trivia st
|
|
| ';' ->
|
|
while (not (at_end st)) && peek st <> '\n' do advance st done;
|
|
skip_trivia st
|
|
| _ -> ()
|
|
|
|
let take_while st pred =
|
|
let start = st.pos in
|
|
while (not (at_end st)) && pred (peek st) do advance st done;
|
|
String.sub st.src start (st.pos - start)
|
|
|
|
(* ── Atoms ─────────────────────────────────────────────────────────── *)
|
|
|
|
let read_string st =
|
|
let loc = here st in
|
|
advance st; (* opening quote *)
|
|
let buf = Buffer.create 16 in
|
|
let rec go () =
|
|
if at_end st then Loc.failk "reader/unterminated-string" loc "unterminated string"
|
|
else match peek st with
|
|
| '"' -> advance st
|
|
| '\\' ->
|
|
advance st;
|
|
let c = peek st in
|
|
advance st;
|
|
Buffer.add_char buf
|
|
(match c with
|
|
| 'n' -> '\n' | 't' -> '\t' | 'r' -> '\r'
|
|
| '\\' -> '\\' | '"' -> '"' | '0' -> '\000'
|
|
| c -> Loc.failk "reader/unknown-string-escape" loc "unknown string escape \\%c" c);
|
|
go ()
|
|
| c -> advance st; Buffer.add_char buf c; go ()
|
|
in
|
|
go ();
|
|
spanned st loc (Form.Str (Buffer.contents buf))
|
|
|
|
(* \space \tab \newline \return \nul, or \<any single char> *)
|
|
let read_byte st =
|
|
let loc = here st in
|
|
advance st; (* backslash *)
|
|
if at_end st then Loc.failk "reader/incomplete-character" loc "expected a character after \\";
|
|
let first = peek st in
|
|
advance st;
|
|
let rest = take_while st (fun c -> not (is_delimiter c)) in
|
|
let name = String.make 1 first ^ rest in
|
|
let code = match name with
|
|
| "space" -> 32
|
|
| "tab" -> 9
|
|
| "newline" -> 10
|
|
| "return" -> 13
|
|
| "nul" -> 0
|
|
| n when String.length n = 1 -> Char.code n.[0]
|
|
| n -> Loc.failk "reader/unknown-character" loc "unknown character literal \\%s" n
|
|
in
|
|
spanned st loc (Form.Byte code)
|
|
|
|
(* A token that started with a digit, or with '-'/'+' followed by a digit. *)
|
|
let read_number st =
|
|
let loc = here st in
|
|
let text = take_while st (fun c -> not (is_delimiter c)) in
|
|
let is_hex =
|
|
String.length text > 2
|
|
&& text.[0] = '0'
|
|
&& (text.[1] = 'x' || text.[1] = 'X')
|
|
in
|
|
if is_hex then
|
|
match Int64.of_string_opt text with
|
|
| Some i -> spanned st loc (Form.Int i)
|
|
| None -> Loc.failk "reader/malformed-number" (Loc.upto loc (here st))
|
|
"malformed hex literal %s" text
|
|
else if String.contains text '.' || String.contains text 'e' then
|
|
match float_of_string_opt text with
|
|
| Some f -> spanned st loc (Form.Float f)
|
|
| None -> Loc.failk "reader/malformed-number" (Loc.upto loc (here st))
|
|
"malformed float literal %s" text
|
|
else
|
|
match Int64.of_string_opt text with
|
|
| Some i -> spanned st loc (Form.Int i)
|
|
| None -> Loc.failk "reader/malformed-number" (Loc.upto loc (here st))
|
|
"malformed integer literal %s" text
|
|
|
|
let read_symbol_or_keyword st =
|
|
let loc = here st in
|
|
let text = take_while st (fun c -> not (is_delimiter c)) in
|
|
if text = "" then Loc.failk "reader/unexpected-character" loc "unexpected character %C" (peek st);
|
|
if text.[0] = ':' then begin
|
|
if String.length text = 1 then
|
|
Loc.failk "reader/empty-keyword" (Loc.upto loc (here st)) "empty keyword";
|
|
spanned st loc (Form.Kw (String.sub text 1 (String.length text - 1)))
|
|
end else
|
|
spanned st loc (Form.Sym text)
|
|
|
|
(* ── Forms ─────────────────────────────────────────────────────────── *)
|
|
|
|
let closer = function
|
|
| '(' -> ')' | '[' -> ']' | '{' -> '}'
|
|
| _ -> assert false
|
|
|
|
let wrap open_c items =
|
|
match open_c with
|
|
| '(' -> Form.List items
|
|
| '[' -> Form.Vec items
|
|
| '{' -> Form.Map items
|
|
| _ -> assert false
|
|
|
|
let rec read_form st =
|
|
skip_ignorable st;
|
|
let loc = here st in
|
|
match peek st with
|
|
| '\000' -> Loc.failk "reader/unexpected-eof" loc "unexpected end of input"
|
|
| '(' | '[' | '{' as open_c -> read_seq st open_c loc
|
|
| ')' | ']' | '}' as c -> Loc.failk "reader/unbalanced" loc "unbalanced %C" c
|
|
| '"' -> read_string st
|
|
| '\\' -> read_byte st
|
|
| '\'' -> read_sugar st loc "quote"
|
|
| '`' -> read_sugar st loc "quasiquote"
|
|
| '~' ->
|
|
advance st;
|
|
if peek st = '@' then (advance st; read_wrapped st loc "unquote-splicing")
|
|
else read_wrapped st loc "unquote"
|
|
| '^' ->
|
|
Loc.failk "reader/metadata" loc "metadata (^) is not supported yet"
|
|
|
|
| c when is_digit c -> read_number st
|
|
| ('-' | '+') when is_digit (peek2 st) -> read_number st
|
|
| _ -> read_symbol_or_keyword st
|
|
|
|
(* Whitespace, comments, and forms that are read only to be thrown away.
|
|
[#_] is handled here rather than in [read_form]'s match so that it is gone
|
|
before *anything* looks at what comes next: a discard is not a form, so a
|
|
sequence must not count it as an element and a top-level loop must not stop
|
|
on it.
|
|
|
|
[#_#_ a b c] discards [a] and [b] with no counting. The outer discard reads
|
|
one form; that read is [read_form], which sees the inner [#_], discards [a]
|
|
and returns [b]; the outer discard then throws [b] away. What is left is
|
|
[c]. *)
|
|
and skip_ignorable st =
|
|
skip_trivia st;
|
|
if peek st = '#' && peek2 st = '_' then begin
|
|
advance st; advance st;
|
|
ignore (read_form st : Form.t);
|
|
skip_ignorable st
|
|
end
|
|
|
|
(* 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
|
|
spanned st loc (Form.List [ Form.make (Form.Sym name) loc; inner ])
|
|
|
|
and read_seq st open_c loc =
|
|
advance st;
|
|
let want = closer open_c in
|
|
let rec go acc =
|
|
(* [skip_ignorable], not [skip_trivia]: a discard just before the closer —
|
|
[(a #_b)] — has to be gone before the closer is looked for, or the
|
|
sequence would try to read a form and find [)]. *)
|
|
skip_ignorable st;
|
|
if at_end st then
|
|
(* Two places, and the second is the one that is usually news. The error
|
|
is at the bracket that is still open, because that is where the fix
|
|
goes; the note is where the file ran out, because that is how far the
|
|
reader got believing the form was still being written. *)
|
|
Loc.failk "reader/unclosed" loc
|
|
~notes:[ Loc.note (here st) "the input ends here, still inside it" ]
|
|
"unclosed %C, expected %C" open_c want
|
|
else
|
|
let c = peek st in
|
|
if c = want then (advance st; List.rev acc)
|
|
else if c = ')' || c = ']' || c = '}' then
|
|
(* The wrong closer is where the mistake reads, and the opener is what
|
|
makes it wrong. Neither alone says which bracket to change. *)
|
|
Loc.failk "reader/mismatched-closer" (here st)
|
|
~notes:[ Loc.note loc (Printf.sprintf "%C is opened here" open_c) ]
|
|
"expected %C to close %C, found %C" want open_c c
|
|
else go (read_form st :: acc)
|
|
in
|
|
let items = go [] in
|
|
spanned st loc (wrap open_c items)
|
|
|
|
(** All top-level forms in a source string. *)
|
|
let read_all ~file src =
|
|
let st = of_string ~file src in
|
|
let rec go acc =
|
|
(* Same reason as in [read_seq]: a file ending in [#_(defn …)] has read
|
|
everything there is to read, and must not then be asked for a form. *)
|
|
skip_ignorable st;
|
|
if at_end st then List.rev acc else go (read_form st :: acc)
|
|
in
|
|
go []
|
|
|
|
let read_file path =
|
|
let ic = open_in_bin path in
|
|
Fun.protect ~finally:(fun () -> close_in ic) (fun () ->
|
|
let n = in_channel_length ic in
|
|
read_all ~file:path (really_input_string ic n))
|