From 3295f2f640cc08b4b4447cc2222151bb8dcab62e Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 13 Sep 2026 07:47:59 +0700 Subject: [PATCH] A location is a span, because a column cannot draw a squiggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/loc.ml | 48 +++++++++++++++++++++++++++++++++++++++++------ lib/reader.ml | 34 ++++++++++++++++++++------------- test/test_flan.ml | 32 +++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 19 deletions(-) diff --git a/lib/loc.ml b/lib/loc.ml index 5589e73..4c66be5 100644 --- a/lib/loc.ml +++ b/lib/loc.ml @@ -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 = ""; line = 0; col = 0 } +let make file line col = { file; line; col; eline = line; ecol = col } +let unknown = make "" 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 diff --git a/lib/reader.ml b/lib/reader.ml index 7141a99..602d241 100644 --- a/lib/reader.ml +++ b/lib/reader.ml @@ -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 \ *) 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 = diff --git a/test/test_flan.ml b/test/test_flan.ml index bca099d..48544e3 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -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;