flan/emacs/test-flan-mode.el
Joseph Ferano 0f3b633449 The printed struct moves to the dot, with the reader that parses it
render.ml's output and emacs/flan-inspect.el's parser are the two ends of one
wire format, which is why the printer was left on the colon when the rest of
the corpus moved: shifting it alone would have broken inspection in the dev
loop without breaking a test that said so. They move together here.

The field list in the inspector is labelled with the dot too, which is the
spelling flan-inspect-step-expr already used to build `(.x b)' — the label and
the expression it stands for now read the same.

One case needed a guard the colon never did: `...' also begins with a dot and
is the renderer saying it stopped, not a field called `..'. A field name never
starts with a second dot, so one character of lookahead separates them.

The colon is not gone from the rendered grammar. An enum member is `:green' and
is a *value*, so the two are now told apart by the character alone, which is
the only thing that distinguishes them.

Also font lock, handed over with the same change: `:name' was the rule that
drew field labels, and with the colon belonging to keywords every label in the
corpus was left unfontified. `.name' is drawn as a constant, in both the places
it appears — the label in `{.x 1.0}' and the accessor in `(.x v)', which are
the same name.
2026-09-12 16:19:55 +07:00

247 lines
9.8 KiB
EmacsLisp

;;; 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"
"(rl/Rectangle {.x 0.0 .y 0.0
.width (f32 screen-width)
.height (f32 screen-height)})")
;; The colon spelling is gone from the language — the parser refuses it — but a
;; map literal will be written this way, and the indenter must not have learned
;; the difference: it aligns under the first element of a brace and never looks
;; at what that element is spelled like.
(test-flan-mode--check
"and a brace of keyword keys aligns exactly the same way"
"(counts {:north 0 :south 1
:east 2
:west 3})")
;; `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))")
;;; Font lock
;; The colon-to-dot change left every field label in the corpus unfontified:
;; the keyword rule used to cover them, and the colon belongs to keywords now.
(defun test-flan-mode--face-at (text needle)
"The face on the first character of NEEDLE in TEXT, under `flan-mode'."
(with-temp-buffer
(insert text)
(flan-mode)
(font-lock-ensure)
(goto-char (point-min))
(search-forward needle)
(get-text-property (- (point) (length needle)) 'face)))
(message "\n-- font lock")
(dolist (case '(("(rl/Vector2 {.x 1.0 .y 2.0})" ".x" "a struct literal's field label")
("(rl/Vector2 {.x 1.0 .y 2.0})" ".y" "and the second one")
("(set total (.bytes c))" ".bytes" "a field accessor")
;; The keyword rule is still there: an enum member is one.
("(rl/mouse-button-down? :left)" ":left" "an enum member")))
(test-flan--check (format "%s is a constant" (nth 2 case))
(eq (test-flan-mode--face-at (nth 0 case) (nth 1 case))
'font-lock-constant-face)))
;; A dot inside a number is not a label, and neither is the dot in a name that
;; has one in the middle.
;; A dot in the middle of a name is not a label: `\_<' anchors the rule to the
;; start of a symbol, and `a.b' is one symbol starting at `a'.
(test-flan--check "a dot inside a name does not start a label"
(null (test-flan-mode--face-at "(f alpha.beta)" ".beta")))
(provide 'test-flan-mode)
;;; test-flan-mode.el ends here