#_ discards the next form, so commenting one out is not paren counting

Clojure's spelling and Clojure's semantics. Repeated — #_#_ a b c — discards
that many following forms, and that 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, so the outer one throws away what the inner one already
stepped past.

It belongs to read_form rather than to the sequence readers, which is what makes
it work in every position a form can appear — top level, inside a list or a
vector or a map, before or after a quote. The two loops that look for a closer
or for end of input skip it as well, because a discard is not an element and a
file ending in one has read everything there is to read.

A trailing #_ with nothing after it is an error, and it is the same error an
unterminated form already gives.
This commit is contained in:
Joseph Ferano 2026-09-12 15:03:33 +07:00
parent 0d549a6b6e
commit c9e9d93a91
2 changed files with 66 additions and 3 deletions

View File

@ -13,6 +13,17 @@
Lisp's, because [,] is already whitespace here (see [is_delimiter]) and Lisp's, because [,] is already whitespace here (see [is_delimiter]) and
every binding vector in the corpus relies on that. 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 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 symbol, so it cannot rot into a silently-wrong name the way quote would
have. *) have. *)
@ -156,7 +167,7 @@ let wrap open_c items =
| _ -> assert false | _ -> assert false
let rec read_form st = let rec read_form st =
skip_trivia st; skip_ignorable st;
let loc = here st in let loc = here st in
match peek st with match peek st with
| '\000' -> Loc.fail loc "unexpected end of input" | '\000' -> Loc.fail loc "unexpected end of input"
@ -177,6 +188,24 @@ let rec read_form st =
| ('-' | '+') when is_digit (peek2 st) -> read_number st | ('-' | '+') when is_digit (peek2 st) -> read_number st
| _ -> read_symbol_or_keyword 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 (* 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 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. *) character the reader saw rather than at the form after it. *)
@ -190,7 +219,10 @@ and read_seq st open_c loc =
advance st; advance st;
let want = closer open_c in let want = closer open_c in
let rec go acc = let rec go acc =
skip_trivia st; (* [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 if at_end st then
Loc.fail loc "unclosed %C, expected %C" open_c want Loc.fail loc "unclosed %C, expected %C" open_c want
else else
@ -206,7 +238,9 @@ and read_seq st open_c loc =
let read_all ~file src = let read_all ~file src =
let st = of_string ~file src in let st = of_string ~file src in
let rec go acc = let rec go acc =
skip_trivia st; (* 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) if at_end st then List.rev acc else go (read_form st :: acc)
in in
go [] go []

View File

@ -162,6 +162,35 @@ let () =
reads "backtick ends a name" "(f a`b)" "(f a (quasiquote b))"; reads "backtick ends a name" "(f a`b)" "(f a (quasiquote b))";
reads "backtick in vec" "[`a ~b]" "[(quasiquote a) (unquote b)]"; reads "backtick in vec" "[`a ~b]" "[(quasiquote a) (unquote b)]";
(* ── Discard ───────────────────────────────────────────────────── *)
(* [#_] reads the next form and throws it away, so commenting out a form does
not mean counting its closing parens. Clojure's spelling and Clojure's
semantics, including the repeated form. *)
reads "discard in a call" "(f #_a b)" "(f b)";
reads "discard the last" "(f a #_b)" "(f a)";
reads "discard the first" "(#_f g a)" "(g a)";
reads "discard a list" "(f #_(g x) b)" "(f b)";
reads "discard in a vector" "[a #_b c]" "[a c]";
reads "discard in a map" "{:a 1 #_:b #_2 :c 3}" "{:a 1 :c 3}";
(* Two discards drop two forms, and that is the recursion rather than a
count: the outer discard reads one form, and the form it reads is itself a
discard that returns the one after. *)
reads "two discards" "(f #_#_a b c)" "(f c)";
reads "three discards" "(f #_#_#_a b c d)" "(f d)";
(* Every position a form can appear in. *)
reads "discard at top level" "#_(defn a [] 1) (defn b [] 2)" "(defn b [] 2)";
reads "discard a whole file" "#_(defn a [] 1)" "";
reads "discard before quote" "(f #_a 'b)" "(f (quote b))";
reads "discard of a quote" "(f #_'a b)" "(f b)";
(* Nested, which the recursive read gives for free. *)
reads "discard inside a discarded form" "(f #_(g #_h i) j)" "(f j)";
(* A name may still contain '#' — it is only a discard at the start of a
form, after trivia. *)
reads "hash inside a name" "(f a#_b)" "(f a#_b)";
(* Nothing to discard is an error, not a silent nothing. *)
rejects ~needle:"end of input" "discard at end of input" "(f a #_";
rejects ~needle:"unbalanced" "discard of a closing paren" "(f a #_)";
(* The whole class: no reader-significant character may end up inside a name. *) (* The whole class: no reader-significant character may end up inside a name. *)
let rec bad_names f = let rec bad_names f =
let open Form in let open Form in