Indentation ported from clojure-mode, and #_ discards a form

This commit is contained in:
Joseph Ferano 2026-09-12 16:18:57 +07:00
commit 5ad429d69f
7 changed files with 615 additions and 30 deletions

17
NEXT.md
View File

@ -286,9 +286,11 @@ BUILT.md, "The colon belongs to keys".
**What it left for the Emacs lane, both verified.** `render.ml` still *prints* a struct with colons, deliberately:
`emacs/flan-inspect.el:165` parses that output and hard-codes the colon when it reads a field out, so the printer
has to move in the same commit as its reader. And `flan-mode.el:61` font-locks `:name` as a constant with nothing
matching `.name`, so a field label is now unfontified where it used to be coloured. Neither is urgent; both belong
with whoever next opens `emacs/`.
has to move in the same commit as its reader. That half is still open and belongs with whoever next opens the
inspector. ~~And `flan-mode.el:61` font-locks `:name` as a constant with nothing matching `.name`, so a field label
is now unfontified where it used to be coloured.~~ **The font-lock half is done:** a field is drawn as a constant in
both of the spellings that exist while the corpus moves, so `{.x 1}` and the accessor `(.x v)` read alike, and the
keyword rule stayed where it was because the colon still means an enum member and a map key.
**7. `Map` follows Odin's implementation.** Read `base/runtime/dynamic_map_internal.odin` before writing any of it;
the checkout is at `~/Repositories/Odin`. Three properties are the ones worth copying, and they are stated in its own
@ -423,9 +425,12 @@ run one lane at a time; item 4 is disjoint and runs alongside any of them.
whole of the resource-cleanup answer.
4. **The Emacs batch. Disjoint from the compiler, so it runs in parallel with anything above.** Globals in the break
buffer; the buffer opening itself when the program stops; the indentation rewrite with `clojure-mode` as the
reference; `#_`; hex, binary and addresses on primitives in the inspector. The indentation one is worth doing first
within this batch — it costs friction on every keystroke today.
buffer; the buffer opening itself when the program stops; ~~the indentation rewrite with `clojure-mode` as the
reference~~; ~~`#_`~~; hex, binary and addresses on primitives in the inspector. **The indentation rewrite and `#_`
are done.** The indenter is ported from `clojure-mode`'s source rather than derived from it — `flan-mode` still
requires nothing outside stock Emacs — and it aligns a binding vector name-under-name, which is the bug that cost
friction on every keystroke. `defn` parameter lists and `restart-case` clause parameters were the same shape and
came with it. What remains in this batch is the break buffer and the inspector, and they are independent.
5. **Union values, then the macro expander, then `Result`/`try`.** Promoted above `Handle` on the author's call —
macros are the thing most worth wanting, and unions are the only thing between here and them.

View File

@ -263,6 +263,49 @@ the inner binding.
---
## Writing it
`TAB` indents the line, and `C-M-q` the form under point. The rules are ported
from `clojure-mode`'s, because Clojure has the shapes Flan has and Emacs Lisp
does not — vectors that bind, bracket variety, and keys inside braces.
**A binding vector lines up name under name.** The second and later bindings of a
`let` sit under the first one's *name*, not under its value:
```
(let [vel (+ gravity (at velocity row col))
y (min (- rows 1) (+ row (i32 vel)))]
…)
```
The same rule draws `defn` parameter lists, `restart-case` and `handler-bind`
clause parameters, and both spellings of a struct literal — they are all a vector
or a brace read in pairs, so they are all indented as one.
**A body indents two.** `let`, `if`, `when`, `while`, `match`, `restart-case`,
`handler-bind`, `defn` and the `def…` forms all put their body two columns in
from the head. What differs between them is only how many forms come *before*
the body and stay on the head's line — a `let`'s binding vector, an `if`'s test,
a `restart-case`'s protected form — and the indenter knows that count per form.
A form it has no entry for is treated as a call: the arguments line up under the
first argument, not two in. That is the fallback, and it is what you want for
`(rl/draw-rectangle x y w h)` and for every function you write.
`defn` carries a return type between the parameter vector and the body, and it is
optional. The indenter does not need to know which — everything after the head
indents two, which is the right answer for the name, the parameters, a return type
if one is written, and the body alike.
**A field is drawn as a constant**, in the accessor `(.x v)` and as a label in
`{.x 1.0}`. The colon is still a constant too; it means an enum member, `:green`,
and a key in a map.
Nothing here needs a running program. Indentation and colouring are the major
mode's, so they work in a file you have only opened.
---
## Getting around
| Key | Does |

View File

@ -1,13 +1,51 @@
;;; flan-mode.el --- Major mode for Flan -*- lexical-binding: t; -*-
;; Derived from lisp-mode, which is most of the work: Flan is s-expressions, so
;; sexp motion, paren matching, `beginning-of-defun' and indentation all
;; already do the right thing. What is left is what the language actually adds
;; — its own literals and its own set of forms that indent like a body.
;; Derived from `prog-mode', borrowing `lisp-mode''s machinery for the parts
;; that are simply s-expressions: sexp motion, paren matching and
;; `beginning-of-defun' already do the right thing.
;;
;; Indentation is the part that does not, and it is ported from
;; `clojure-mode''s source rather than from `lisp-mode''s. That is a
;; deliberate change of reference and it is worth being exact about what it
;; means: `clojure-mode' is neither an ancestor nor a dependency here — this
;; mode ships in the Flan repository and requires nothing outside stock Emacs —
;; it is the file whose rules were read and written out again below.
;;
;; The reason is that Emacs Lisp has none of the shapes Flan is written in. It
;; has no vectors-as-bindings, no maps, no bracket variety, so
;; `lisp-indent-function' treats `[a 1 b 2]' as a function call and aligns
;; continuation lines under `1' — the first *argument* — instead of under `a',
;; the first *binding*. That was the reported bug in `sand.flan''s `settle',
;; and it was never one missing rule: every rule has to be added by hand when
;; the base language does not have the shape. Clojure's rules already cover
;; brackets-mean-binding, pairs-align, maps and `#_'.
;;
;; Where Flan diverges from Clojure it diverges on purpose, and each divergence
;; is written down at the place that handles it:
;;
;; - `defn' carries a **return type between the parameter vector and the
;; body**, and it is optional — `(defn show [x f32] …)' has none. Both are
;; handled by not caring: the spec is `:defn', so everything after the head
;; indents two, which is right for the name, the parameters, a return type
;; that is there and a body whether or not one preceded it.
;; - field access is `(.x v)', an ordinary call whose head happens to begin
;; with a dot. `.' is a symbol constituent in the syntax table below, so
;; nothing special is needed for it to read as a head.
;; - field labels are moving from `:x' to `.x', so `{.x 1.0}' is a struct
;; literal and not a map with symbol keys. The indenter is correct for
;; both spellings *because* it never looks at the key: a brace aligns under
;; its first element whatever that element is spelled like.
;;; Code:
(require 'lisp-mode)
;; `thing-at-point', which the indenter reads the enclosing form's head with.
(require 'thingatpt)
;; Bound by `calculate-lisp-indent' around the call to `lisp-indent-function',
;; and declared in `lisp-mode' without a `defvar', so say so here rather than
;; let the byte-compiler call it a free variable.
(defvar calculate-lisp-indent-last-sexp)
;; For `imenu-generic-expression', which is set below and which would
;; otherwise be made buffer-local before its own defvar had run.
(require 'imenu)
@ -33,6 +71,8 @@
(autoload 'flan-dev "flan-dev" nil t)
(autoload 'flan-dev-quit "flan-dev" nil t)
(autoload 'flan-dev-restart-program "flan-dev" nil t)
;; Bound below, like the rest, and it was the one missing an autoload.
(autoload 'flan-disassemble "flan-dev" nil t)
(defgroup flan nil
"Editing and evaluating Flan."
@ -59,6 +99,11 @@
;; A keyword resolves against an enum at the call site, so it reads as a
;; constant rather than as a string.
("\\_<:\\(?:\\sw\\|\\s_\\)+" . font-lock-constant-face)
;; A field, in both of the spellings that exist while the corpus moves from
;; `{:x 1}' to `{.x 1}': the label in a struct literal and the accessor
;; `(.x v)' are the same name and are drawn the same way, which is also
;; what the keyword rule above did for the spelling being replaced.
("\\_<\\.\\(?:\\sw\\|\\s_\\)+" . font-lock-constant-face)
;; The machine types, which are ordinary symbols but never anything else.
("\\_<\\(?:[iu]\\(?:8\\|16\\|32\\|64\\)\\|f\\(?:32\\|64\\)\\|bool\\|string\\|Unit\\|Never\\|Ptr\\|Option\\)\\_>"
. font-lock-type-face)
@ -160,25 +205,231 @@ line is off screen."
(setq-local comment-add 1)
(setq-local font-lock-defaults '(flan-font-lock-keywords))
(setq-local indent-line-function #'lisp-indent-line)
;; Spaces. The whole corpus is written with them, and alignment that is
;; correct here is alignment under a specific *column* — a tab makes that
;; depend on a setting the file cannot carry.
(setq-local indent-tabs-mode nil)
(setq-local lisp-indent-function #'flan-indent-function)
(setq-local outline-regexp ";;;;+[ \t]*")
(setq-local imenu-generic-expression flan-imenu-generic-expression)
;; Buffer-locally, because this answers for Flan and nothing else.
(add-hook 'which-func-functions #'flan-current-defun-name nil t))
;;; Indentation
;; Ported from `clojure-mode', as the header says. Three pieces, and the
;; middle one is where the reported bug lived.
(defconst flan-indent-specs
;; A number N: the first N arguments are *special* and the rest are a body.
;; `:defn': everything after the head is a body. These are the same two
;; values `clojure-mode' uses, and they mean the same thing here.
'(;; Binding forms. The vector is the one special argument; the body
;; follows it. This is the entry that makes `let' correct, but note that
;; it is *not* what fixed the reported bug — alignment inside the vector is
;; `flan--data-form-p' below, and it would be right even with no entry
;; here.
("let" . 1)
("loop" . 1)
("dotimes" . 1)
;; The clause vector, then the protected body. Same shape as `let'.
("handler-bind" . 1)
("handler-case" . 1)
;; Test first, body after.
("if" . 1)
("when" . 1)
("unless" . 1)
("while" . 1)
("until" . 1)
("match" . 1)
("with-allocator" . 1)
;; The protected form, then the clauses. It has to be 1 rather than 0:
;; with 0 the clauses are ordinary arguments, and a protected form written
;; on the head's line — `(restart-case (middle n)' — would drag every
;; clause out to align under it.
("restart-case" . 1)
;; All body.
("do" . 0)
("cond" . 0)
("defer" . 0)
("try" . 0)
;; `defn' is `:defn' rather than a count because the return type between
;; the parameters and the body is optional; a count would have to know
;; whether one is there, and `:defn' does not care.
("defn" . :defn)
;; `(declare-c NAME [params] RET "CSymbol")'. The name is the one special
;; argument; everything after it is written down the page in one column.
("declare" . 1)
("declare-c" . 1))
"How each form indents, by name.
Anything not named here that begins with `def' is treated as `:defn' by
`flan-indent-function'; anything else indents as a function call.")
(defun flan--indent-spec (name)
"The indent spec for the form called NAME, or nil."
(and name (cdr (assoc name flan-indent-specs))))
(defun flan--non-logical-sexp-p ()
"Non-nil if what follows point is read but produces no form.
Today that is only `#_', the discard reader macro see `lib/reader.ml'. A
discarded form is skipped rather than counted, so `(let [#_a b 1] )' still
aligns as the pairs it will be once the reader is done with it."
(looking-at-p "#_"))
(defun flan--forward-sexp (&optional n)
"Move forward over N sexps, skipping discarded ones."
(setq n (or n 1))
(let ((forward-sexp-function nil))
(while (> n 0)
(while (flan--non-logical-sexp-p) (forward-sexp 1))
(forward-sexp 1)
(setq n (1- n)))))
(defun flan--backward-sexp (&optional n)
"Move backward over N sexps, skipping discarded ones."
(setq n (or n 1))
(let ((forward-sexp-function nil))
(while (> n 0)
(backward-sexp 1)
(while (and (not (bobp))
(ignore-errors
(save-excursion (backward-sexp 1)
(flan--non-logical-sexp-p))))
(backward-sexp 1))
(setq n (1- n)))))
(defun flan--data-form-p ()
"Non-nil if the form at point is data rather than a call.
Point is on the opening delimiter.
**This is the fix.** A `[' or a `{' is not a function call, so nothing in it
is an argument and there is no first argument to align under. Aligning under
the first *element* instead is what makes a binding vector line its names up
name-under-name, and by the same rule it lines up `defn' parameter lists,
`restart-case' clause parameters, `handler-bind' clause vectors, and both
spellings of a struct literal `{:x 1}' and `{.x 1}' with no case for any
of them, because the rule is about the bracket and never about what is
written inside it.
A head that is not a symbol is the third case: `((f x) y)' has nothing to look
a spec up under."
(or (memq (char-after) '(?\[ ?\{))
(not (looking-at ".\\(?:\\sw\\|\\s_\\)"))))
(defun flan--normal-indent (last-sexp)
"Align with the argument above, as an ordinary call does.
Point is just after the open paren of the enclosing form; LAST-SEXP is where
the sexp before the one being indented starts."
(goto-char last-sexp)
(forward-sexp 1)
(flan--backward-sexp 1)
(let ((last-sexp-start nil))
(if (ignore-errors
;; Back up until we reach a sexp that starts its own line: that is
;; the one every Lisp aligns under.
(while (string-match "[^[:blank:]]"
(buffer-substring (line-beginning-position)
(point)))
(setq last-sexp-start (prog1 (point) (forward-sexp -1))))
t)
(current-column)
;; Nothing above but the head itself, so there are two cases and Flan
;; answers them differently from Clojure's default.
(if (and last-sexp-start (< last-sexp-start (line-end-position)))
;; An argument shares the head's line. Align under it — this is the
;; alignment every Lisp agrees on.
(progn (goto-char last-sexp-start) (current-column))
;; The head is alone on its line. Clojure's default would align the
;; arguments under the *head*; the Flan corpus indents them by a body
;; instead, which is `clojure-indent-style''s `align-arguments' and is
;; what every hand-written call in the tree does:
;;
;; (rl/draw-rectangle-lines-ex
;; (rl/Rectangle {.x 0.0 .y 0.0})
;; (f32 2.0) (rl/get-color 0x303030FF))
(+ (current-column) lisp-body-indent -1)))))
(defun flan--clause-form-p ()
"Non-nil if the form at point is a clause: `(name [params] body…)'.
Point is just after the open paren, on the head.
This is `defn' with the name left off, and it is how `handler-bind',
`handler-case' and `restart-case' all write their clauses. Their heads are
condition classes and restart names things a program invents so no table
here could ever list them; the shape is what can be recognised."
(save-excursion
(ignore-errors
(flan--forward-sexp 1) ; over the head
(skip-chars-forward " \t\n\r,")
(eq (char-after) ?\[))))
(defun flan--count-indent (method indent-point last-sexp head-column)
"Indent inside a form whose first METHOD arguments are special.
INDENT-POINT, LAST-SEXP and HEAD-COLUMN are as in `flan-indent-function';
point is just after the open paren."
(let ((pos -1))
(condition-case nil
(while (and (<= (point) indent-point) (not (eobp)))
(flan--forward-sexp 1)
(setq pos (1+ pos)))
;; Past the last sexp in the form: count as if one more were here, which
;; is what indenting an empty line at the end of a form means.
(scan-error (setq pos (1+ pos))))
(cond
;; The first argument that is body rather than special.
((= pos (1+ method)) (+ lisp-body-indent head-column))
;; Further body arguments line up with the one above.
((> pos (1+ method)) (flan--normal-indent last-sexp))
;; Still in the special arguments. Clojure indents these to twice the
;; body indent so they cannot be mistaken for body; Flan uses one, because
;; the corpus is written that way throughout —
;;
;; (handler-bind
;; [(StorageExhausted [c] …)]
;; (load-all))
;;
;; — and there is nothing to confuse: the special arguments come first, so
;; a reader never has to tell them apart by column.
(t (+ lisp-body-indent head-column)))))
(defun flan-indent-function (indent-point state)
"Indent like Lisp, with Flan's body forms as special forms.
INDENT-POINT and STATE are as for `lisp-indent-function'."
(let ((open (elt state 1)))
(or (and open
(save-excursion
(goto-char (1+ open))
(let ((head (and (looking-at "\\(\\sw\\|\\s_\\)+")
(match-string 0))))
(when (member head '("defn" "let" "if" "while" "until"
"dotimes" "match" "do" "loop" "defer"))
(+ (current-column) 1)))))
(lisp-indent-function indent-point state))))
"Indent a line inside a Flan form.
INDENT-POINT and STATE are as for `lisp-indent-function'; the spec for the
enclosing form comes from `flan-indent-specs'. Returns nil to leave the
decision to `calculate-lisp-indent'."
(goto-char (elt state 1))
(if (flan--data-form-p)
;; A vector, a map, or a head that is not a symbol: align under the
;; first element.
(1+ (current-column))
(forward-char 1)
(let* ((name (thing-at-point 'symbol))
(method (flan--indent-spec name))
(last-sexp calculate-lisp-indent-last-sexp)
(head-column (1- (current-column))))
(cond
((integerp method)
(flan--count-indent method indent-point last-sexp head-column))
((eq method :defn) (+ lisp-body-indent head-column))
;; No spec. Anything else spelled `def…' is a definition and indents
;; like one, which covers `defstruct', `defunion', `defenum', `defvar',
;; `defconst' and `defalias' without naming them.
((and name (string-match-p "\\`def" name))
(+ lisp-body-indent head-column))
;; A clause: `(name [params] body…)'. `handler-bind', `handler-case'
;; and `restart-case' all write their clauses this way, and the head is
;; a condition class or a restart name — something the program invented,
;; so it can never be in a table here. What can be recognised is the
;; *shape*, which is `defn' with the name left off: a parameter vector
;; where the first argument goes, and a body after it.
;;
;; `clojure-mode' reaches the same clauses by backtracking out to the
;; enclosing form and reading a nested spec off it. That machinery buys
;; generality this language has no other use for — these three forms are
;; the whole of it — and the shape is unambiguous on its own.
((flan--clause-form-p) (flan--count-indent 1 indent-point last-sexp
head-column))
(t (flan--normal-indent last-sexp))))))
;;;###autoload
(add-to-list 'auto-mode-alist '("\\.flan\\'" . flan-mode))

View File

@ -379,16 +379,18 @@
(test-flan--check "and the number past the last is abort"
(equal sent '(:op "abort"))))))
;; The stack and its locals. Nothing produces this data today; the fixture is
;; the shape a `backtrace' verb would have to answer with, and building the
;; buffer against it is how it is ready when one exists.
;; The stack and its locals. The fixture is the shape `backtrace' and `locals'
;; answer with, and both frames carry `:fetched t' because their locals are
;; already here: without it `flan-cnr-toggle-frame' goes and asks the daemon
;; for them, and there is no daemon behind these tests on purpose.
(let* ((state (list :condition "Missing"
:restarts '("retry")
:stack (list (list :fn "sim/settle" :loc "sand.flan:42:3"
:fetched t
:locals '(("i" "i32" "7")
("b" "Blob" "(Blob {:id 7})")))
(list :fn "sim/step" :loc "sand.flan:60:1"
:locals nil))))
:fetched t :locals nil))))
(buf (test-flan--cnr state))
(text (with-current-buffer buf (buffer-string))))
(test-flan--check "frames are numbered, innermost first"
@ -430,7 +432,8 @@
(flan-inspect-buffer " *test-inspect*"))
(with-current-buffer (test-flan--cnr
(list :condition "Missing" :restarts '("retry")
:stack (list (list :fn "f" :locals '(("b" "Blob" ""))))))
:stack (list (list :fn "f" :fetched t
:locals '(("b" "Blob" ""))))))
(goto-char (point-min))
(search-forward " 0: > f")
(flan-cnr-toggle-frame)
@ -494,6 +497,9 @@
(buffer-string))))
(test-flan--check "the condition's name is what `layout' is asked for"
(equal (plist-get (car (last asked)) :op) "break"))
;; By op rather than by position: `flan-cnr-show' asks three things now —
;; `break', `layout', `backtrace' — and which of them is last is not what
;; this is testing.
(test-flan--check "and it is sent back verbatim, qualified as it came"
(equal (plist-get
(seq-find (lambda (f)
@ -539,6 +545,15 @@
(or (test-flan--caught #'flan-cnr-show) ""))))
;; `flan-mode' itself — indentation, which is a function from text to text and
;; so belongs with the other fixture-driven checks rather than with anything
;; that needs a daemon. Loaded rather than run separately because
;; `emacs/*.el' is already a dependency of test/dune's stanza, so a file here
;; needs no build change to be run.
(load (expand-file-name "test-flan-mode.el"
(file-name-directory load-file-name))
nil t)
(message "\n%d checks, %d failures" test-flan--ran test-flan--failures)
(kill-emacs (if (> test-flan--failures 0) 1 0))

208
emacs/test-flan-mode.el Normal file
View File

@ -0,0 +1,208 @@
;;; test-flan-mode.el --- Indentation, from written-out shapes -*- lexical-binding: t; -*-
;; Loaded by test-flan-cider.el, which runs under `dune test'. It is not a
;; file of its own in test/dune deliberately: `emacs/*.el' is already a
;; dependency of that stanza, so a new `.el' here needs no build change.
;;
;; Every case is a piece of Flan written the way the corpus writes it, with
;; every line's indentation thrown away and put back. That is the whole test:
;; if what comes out is what went in, the indenter agrees with the code that
;; already exists. A case that is *not* in the corpus is written from
;; `clojure-mode''s behaviour and says so.
;;
;; The cases come from the bug report and from reading the tree for the shapes
;; that share it: a `let' binding vector, `defn' parameters with and without a
;; return type, `handler-bind' and `restart-case' clause parameters, and struct
;; literals in both the `:x' and the `.x' spelling — the two must agree,
;; because a lane is converting the corpus from one to the other and the
;; indenter is not allowed to notice.
;;; Code:
(require 'flan-mode)
;; The harness, which is test-flan-cider.el's: it counts and it reports, and
;; this file is loaded from there.
(declare-function test-flan--check "test-flan-cider" (name ok))
(defun test-flan-mode--reindent (text)
"Strip TEXT's indentation and let `flan-mode' put it back."
;; Quiet: `indent-region' reports progress, and in batch that lands in the
;; middle of the line a failure is being printed on.
(let ((inhibit-message t))
(with-temp-buffer
(insert text)
(flan-mode)
(goto-char (point-min))
;; Leading whitespace off every line but the first: the first line's column
;; is the buffer's, not the indenter's, and a form is indented relative to
;; where it starts. `eobp' rather than `forward-line''s return value,
;; which is 0 for a move to the end of a buffer that has no final newline
;; and would take the whole last line with it.
(forward-line 1)
(while (not (eobp))
(skip-chars-forward " \t")
(delete-region (line-beginning-position) (point))
(forward-line 1))
(indent-region (point-min) (point-max))
(buffer-substring-no-properties (point-min) (point-max)))))
(defun test-flan-mode--check (name text)
"Check that TEXT is what `flan-mode' indents it to."
(let ((got (test-flan-mode--reindent text)))
(test-flan--check name (equal got text))
(unless (equal got text)
;; Line by line, and quoted: the difference is always a count of leading
;; spaces, which is exactly what two blocks of text printed as-is make
;; hardest to see.
(let ((want (split-string text "\n")) (had (split-string got "\n")))
(while (or want had)
(unless (equal (car want) (car had))
(message " want %S\n got %S" (car want) (car had)))
(setq want (cdr want) had (cdr had)))))))
(message "\n-- indentation")
;; The bug, as reported: sand.flan's `settle'. The second and later bindings
;; went one column too far, because the enclosing open is `[' and the symbol
;; after it is the first binding's *name*, so the old check fell through to
;; `lisp-indent-function', which treated the vector as a call and aligned under
;; the first argument — `(+ gravity …)' — instead of under `vel'.
(test-flan-mode--check
"a let's bindings align name under name"
"(defn settle [row i32 col i32]
(let [vel (+ gravity (at velocity row col))
y (min (- rows 1) (+ row (i32 vel)))]
(while (> y row)
(set y (- y 1)))))")
;; The same shape one level in, which is where the report was actually hit: a
;; binding whose value is a struct literal, so the vector's alignment has to
;; survive a `{' in the middle of it.
(test-flan-mode--check
"and still does with a struct literal in a value"
"(let [tip (rl/Vector2 {:x 15.0 :y 12.0})
vel (+ gravity 1.0)]
(draw tip vel))")
;; The two spellings must be indented identically. Nothing in the indenter
;; looks at the key — a brace aligns under its first element whatever that
;; element is — and this is the test that keeps it that way while the corpus
;; moves from `:x' to `.x'.
(test-flan-mode--check
"a struct literal aligns under its first field, keyword spelling"
"(rl/Rectangle {:x 0.0 :y 0.0
:width (f32 screen-width)
:height (f32 screen-height)})")
(test-flan-mode--check
"and identically in the dot spelling it is moving to"
"(rl/Rectangle {.x 0.0 .y 0.0
.width (f32 screen-width)
.height (f32 screen-height)})")
;; `defn' carries a return type between the parameters and the body, and it is
;; optional. Both spellings indent the body by two, which is why the spec is
;; `:defn' and not a count: a count would have to know whether the type is
;; there.
(test-flan-mode--check
"a defn with a return type indents its body by two"
"(defn look [n i64 label string] i64
(print label)
n)")
(test-flan-mode--check
"and a defn without one indents it the same"
"(defn show-trim [s string]
(print s)
(println \"\"))")
;; A parameter list that wraps is the binding-vector rule again: it is a
;; vector, so it aligns under its first element and not under its second.
(test-flan-mode--check
"a wrapped parameter list aligns under the first parameter"
"(defn move-grain [row i32 col i32
to-row i32 to-col i32
vel f32]
(set moved true))")
;; `handler-bind' clauses: the vector is the special argument, and each clause
;; inside it is `(Name [params] body…)' — `defn' with the name left off. The
;; body indents by two from the clause's own paren, which is the rule the head
;; cannot be looked up for: `StorageExhausted' is a name the program invented.
(test-flan-mode--check
"a handler-bind clause indents its body under its own paren"
"(handler-bind
[(StorageExhausted [c]
(set failures (+ failures 1))
(invoke-restart 'retry))]
(load-all))")
(test-flan-mode--check
"and two clauses in one vector line up with each other"
"(handler-bind [(AssetMissing [c] (set seen (+ seen 10)))
(Corrupt [c] (set other (+ other 1)))]
(load-all))")
;; `restart-case' is 1 rather than 0: with 0 a protected form written on the
;; head's line would drag every clause out to align under it.
(test-flan-mode--check
"restart-case clauses indent by two under a protected form on the head's line"
"(defn fetch [n i32] i32
(restart-case (middle n)
(use-placeholder [] -1)
(retry [] 7)))")
(test-flan-mode--check
"and by two when the protected form is on its own line"
"(restart-case
(do (agent/poll)
(game-update))
(continue [] (do)))")
;; `declare-c' writes its parameters, return type and C symbol down the page in
;; one column.
(test-flan-mode--check
"declare-c puts everything after the name in one column"
"(declare-c mouse-button-pressed?
[button MouseButton] bool
\"IsMouseButtonPressed\")")
;; An ordinary call, in the two cases every Lisp separates.
(test-flan-mode--check
"a call with an argument on the head's line aligns under that argument"
"(rl/draw-text \"hello\"
10 20 30)")
(test-flan-mode--check
"a call whose head is alone on its line indents its arguments by a body"
"(rl/draw-rectangle-lines-ex
(rl/Rectangle {.x 0.0 .y 0.0})
(f32 2.0) (rl/get-color 0x303030FF))")
;; A `do' is all body, so its second form aligns under its first.
(test-flan-mode--check
"a do aligns its forms with the first one"
"(do (agent/poll)
(game-update))")
;; Field access is an ordinary call whose head begins with a dot. `.' is a
;; symbol constituent, so nothing special is needed — but if it ever stopped
;; being one, `(.x v)' would start indenting as data and this is what would
;; say so.
(test-flan-mode--check
"field access indents as the call it is"
"(set total
(+ (.bytes c)
(.align c)))")
;; `#_' discards the form after it, so the reader sees pairs either way and the
;; alignment must not shift. See `lib/reader.ml'.
(test-flan-mode--check
"a discarded binding does not disturb the pairs around it"
"(let [#_a #_1 b 2
c 3]
(print c))")
(provide 'test-flan-mode)
;;; test-flan-mode.el ends here

View File

@ -13,6 +13,17 @@
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. *)
@ -156,7 +167,7 @@ let wrap open_c items =
| _ -> assert false
let rec read_form st =
skip_trivia st;
skip_ignorable st;
let loc = here st in
match peek st with
| '\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
| _ -> 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. *)
@ -190,7 +219,10 @@ and read_seq st open_c loc =
advance st;
let want = closer open_c in
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
Loc.fail loc "unclosed %C, expected %C" open_c want
else
@ -206,7 +238,9 @@ and read_seq st open_c loc =
let read_all ~file src =
let st = of_string ~file src in
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)
in
go []

View File

@ -162,6 +162,35 @@ let () =
reads "backtick ends a name" "(f a`b)" "(f a (quasiquote 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. *)
let rec bad_names f =
let open Form in