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.
This commit is contained in:
Joseph Ferano 2026-09-13 07:47:59 +07:00
parent 54027ca942
commit 3295f2f640
3 changed files with 95 additions and 19 deletions

View File

@ -1,15 +1,51 @@
(** Source locations. Every form carries one: error messages, the step debugger
and nREPL's find-definition all need them, and retrofitting locations onto a
reader is far worse than carrying them from the start. *)
reader is far worse than carrying them from the start.
A location is a *span*, not a point. The start is what [file:line:col]
prints and what every consumer that wants one place uses; the end is what
lets an error underline the thing it is about. A column number cannot draw
a squiggle and a span can, which is the whole reason the two extra fields
are here.
The end is *exclusive* and defaults to the start, so a location nobody
widened is a zero-width span at a point and every old call site keeps its
old meaning. Only the reader knows where a form ends, so only the reader
fills these in; a location the checker invents for a node with no syntax
stays a point. *)
type t = {
file : string;
line : int; (* 1-based *)
col : int; (* 1-based *)
file : string;
line : int; (* 1-based *)
col : int; (* 1-based *)
eline : int; (* 1-based, exclusive end *)
ecol : int;
}
let make file line col = { file; line; col }
let unknown = { file = "<unknown>"; line = 0; col = 0 }
let make file line col = { file; line; col; eline = line; ecol = col }
let unknown = make "<unknown>" 0 0
(** [upto start stop] is [start] widened to end where [stop] begins. A [stop]
that is not after [start], or is in another file, leaves it alone: a span
that runs backwards would draw nonsense. *)
let upto (start : t) (stop : t) =
if
stop.file = start.file
&& (stop.line > start.line
|| (stop.line = start.line && stop.col > start.col))
then { start with eline = stop.line; ecol = stop.col }
else start
(** True when the span covers more than its first line. The underline is drawn
on the start line either way a form that spans twenty lines is pointed
at, not boxed so this is what tells the renderer to run the underline to
the end of that line rather than to [ecol]. *)
let multiline (t : t) = t.eline > t.line
(** How many columns to underline on the start line, or [None] when the span
was never widened and there is nothing but a point to draw. *)
let width (t : t) =
if t.eline = t.line && t.ecol > t.col then Some (t.ecol - t.col) else None
let to_string t = Printf.sprintf "%s:%d:%d" t.file t.line t.col

View File

@ -39,6 +39,12 @@ type state = {
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 =
@ -99,7 +105,7 @@ let read_string st =
| c -> advance st; Buffer.add_char buf c; go ()
in
go ();
Form.make (Form.Str (Buffer.contents buf)) loc
spanned st loc (Form.Str (Buffer.contents buf))
(* \space \tab \newline \return \nul, or \<any single char> *)
let read_byte st =
@ -119,7 +125,7 @@ let read_byte st =
| n when String.length n = 1 -> Char.code n.[0]
| n -> Loc.fail loc "unknown character literal \\%s" n
in
Form.make (Form.Byte code) loc
spanned st loc (Form.Byte code)
(* A token that started with a digit, or with '-'/'+' followed by a digit. *)
let read_number st =
@ -132,26 +138,27 @@ let read_number st =
in
if is_hex then
match Int64.of_string_opt text with
| Some i -> Form.make (Form.Int i) loc
| None -> Loc.fail loc "malformed hex literal %s" text
| 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 -> Form.make (Form.Float f) loc
| None -> Loc.fail loc "malformed float literal %s" text
| 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 -> Form.make (Form.Int i) loc
| None -> Loc.fail loc "malformed integer literal %s" text
| 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 "empty keyword";
Form.make (Form.Kw (String.sub text 1 (String.length text - 1))) loc
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
Form.make (Form.Sym text) loc
spanned st loc (Form.Sym text)
(* ── Forms ─────────────────────────────────────────────────────────── *)
@ -213,7 +220,7 @@ 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
spanned st loc (Form.List [ Form.make (Form.Sym name) loc; inner ])
and read_seq st open_c loc =
advance st;
@ -232,7 +239,8 @@ and read_seq st open_c loc =
Loc.fail (here st) "expected %C to close %C, found %C" want open_c c
else go (read_form st :: acc)
in
Form.make (wrap open_c (go [])) loc
let items = go [] in
spanned st loc (wrap open_c items)
(** All top-level forms in a source string. *)
let read_all ~file src =

View File

@ -249,6 +249,38 @@ let () =
| exception Loc.Error (loc, _) ->
check "unclosed reports opening loc" (loc.line = 1 && loc.col = 1));
(* ── Spans ─────────────────────────────────────────────────────
A location ends where the form ends, which is what an underline needs
and what a column number cannot give. Asserted on the width rather than
on the end column alone: a span that never got widened is zero wide, and
that is the failure mode worth catching the field would exist, nothing
would fill it, and every squiggle would be one character long. *)
(match read ~file:"f.flan" "(foo bar)" with
| [ l ] ->
check "span covers the list" (Loc.width l.loc = Some 9);
(match l.Form.v with
| Form.List [ head; arg ] ->
check "span covers the head symbol" (Loc.width head.loc = Some 3);
check "span covers the argument" (Loc.width arg.loc = Some 3);
check "span starts at the symbol" (arg.loc.col = 6)
| _ -> check "span: two elements" false)
| _ -> check "span: one form" false);
(match read ~file:"f.flan" "\"hi\" 42 :kw" with
| [ s; n; k ] ->
check "span covers a string with its quotes" (Loc.width s.loc = Some 4);
check "span covers a number" (Loc.width n.loc = Some 2);
check "span covers a keyword with its colon" (Loc.width k.loc = Some 3)
| _ -> check "span: three atoms" false);
(* A form that runs over a line end has no width on its first line, and says
so rather than reporting a negative one. *)
(match read ~file:"f.flan" "(a\n b)" with
| [ l ] ->
check "multi-line span is flagged" (Loc.multiline l.loc);
check "multi-line span has no single-line width" (Loc.width l.loc = None)
| _ -> check "span: one multi-line form" false);
if !failures = 0 then print_endline "reader: all tests passed"
else begin
Printf.printf "\n%d failure(s)\n" !failures;