flan/lib/reader.ml
Joseph Ferano 3295f2f640 A location is a span, because a column cannot draw a squiggle
Loc.t grows an exclusive end, defaulting to the start, so a location nobody
widened is a zero-width span at a point and every existing call site keeps
its old meaning. Only the reader knows where a form ends, so only the reader
fills them in — one helper in the one place that holds both ends, which is
why nothing above Reader had to learn a span exists.

The width assertion is the point of the tests: the field could exist, nothing
could fill it, and every underline would be one character long while the
feature looked finished.
2026-09-13 07:47:59 +07:00

261 lines
9.8 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.fail 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.fail 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.fail 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.fail 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.fail (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.fail (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.fail (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.fail loc "unexpected character %C" (peek st);
if text.[0] = ':' then begin
if String.length text = 1 then
Loc.fail (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.fail loc "unexpected end of input"
| '(' | '[' | '{' as open_c -> read_seq st open_c loc
| ')' | ']' | '}' 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;
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"
| 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
Loc.fail loc "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
Loc.fail (here st) "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))