From 0d549a6b6e390ce0501b804f7f53ea48cca430f3 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 15:01:11 +0700 Subject: [PATCH 1/3] Indentation is ported from clojure-mode, which has the shapes Flan uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported bug — the second and later bindings of a let one column too far — was never one missing rule. `flan-indent-function' checked the head of the enclosing form, and inside a binding vector the enclosing open is `[' and the symbol after it is the first binding's name, so it fell through to Emacs's `lisp-indent-function', which treats the vector as a call and aligns under the first argument instead of the first binding. Emacs Lisp is the wrong reference. It has no vectors-as-bindings, no maps and no bracket variety, so every rule Flan needs has to be added by hand and the binding vector is simply the first one hit. The indenter is rewritten from clojure-mode's source instead: `clojure-mode' is neither an ancestor nor a dependency — flan-mode still needs nothing beyond stock Emacs — it is the file whose rules were read and written out again. A bracket aligns under its first element, and that one rule fixes the binding vector, `defn' parameter lists, `restart-case' and `handler-bind' clause parameters and both spellings of a struct literal at once. `{:x 1}' and `{.x 1}' indent identically because nothing here looks at the key, which is what the colon-to-dot lane needs of it. Where Flan diverges it is handled on purpose. `defn' is `:defn' rather than a count because the return type between the parameters and the body is optional. A clause — `(Name [params] body)' — is recognised by its shape, since its head is a condition class or a restart name and can never be in a table; clojure-mode reaches the same clauses by backtracking out to the enclosing form, which buys generality this language has no other use for. Special arguments indent by one body rather than Clojure's two, and a call whose head is alone on its line indents its arguments by a body rather than aligning them under the head, because that is how the whole corpus is written. Checked by reindenting every .flan file in the tree: the only lines that move are sand.flan's reported bug, raylib.flan's hand-wrapped parameter vectors — which is the fix — and lone-`;' comment continuations, which stock `lisp-indent-line' has always moved. test-flan-mode.el is loaded from test-flan-cider.el rather than given a stanza of its own, because emacs/*.el is already a dependency of that test. Two failures in test-flan-cider.el that predate this: fixture frames lacked `:fetched', so folding one open went looking for a daemon, and `layout' was identified by being the last request when `flan-cnr-show' now makes three. --- emacs/flan-mode.el | 283 ++++++++++++++++++++++++++++++++++++--- emacs/test-flan-cider.el | 25 +++- emacs/test-flan-mode.el | 208 ++++++++++++++++++++++++++++ 3 files changed, 495 insertions(+), 21 deletions(-) create mode 100644 emacs/test-flan-mode.el diff --git a/emacs/flan-mode.el b/emacs/flan-mode.el index 27424a6..f9a9ccc 100644 --- a/emacs/flan-mode.el +++ b/emacs/flan-mode.el @@ -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)) diff --git a/emacs/test-flan-cider.el b/emacs/test-flan-cider.el index 4ce93b0..89dab19 100644 --- a/emacs/test-flan-cider.el +++ b/emacs/test-flan-cider.el @@ -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)) diff --git a/emacs/test-flan-mode.el b/emacs/test-flan-mode.el new file mode 100644 index 0000000..f8d9c34 --- /dev/null +++ b/emacs/test-flan-mode.el @@ -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 From c9e9d93a919d7561878607380995b7f83eea5dc0 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 15:03:33 +0700 Subject: [PATCH 2/3] #_ discards the next form, so commenting one out is not paren counting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/reader.ml | 40 +++++++++++++++++++++++++++++++++++++--- test/test_flan.ml | 29 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/lib/reader.ml b/lib/reader.ml index 803a1b1..7141a99 100644 --- a/lib/reader.ml +++ b/lib/reader.ml @@ -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 [] diff --git a/test/test_flan.ml b/test/test_flan.ml index 191f879..9bf98b2 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -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 From 64342c406e0166bae83b3a84c837867d0744ee4f Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 16:15:37 +0700 Subject: [PATCH 3/3] The manual says how a form is indented, and NEXT loses what landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The indentation rules were written and tested but never described anywhere a user would look. MANUAL.md had no section on editing at all — it starts at `C-c C-c' and assumes the file is already written — so the rule that cost the friction, a binding vector lining up name under name, was only visible by trying it. What is written down is what was checked, not what the port was aimed at: the call fallback, the `handler-bind' clause vector, `defn' parameter alignment with a return type after it, and `restart-case' clause bodies were each reindented from scratch and the manual quotes the result. NEXT.md keeps the half of the field-label handover that is still open. The printer in render.ml has to move in the same commit as the inspector that parses it, and that is the inspector lane's; the font-lock half is done here, so only that half is struck. --- NEXT.md | 17 +++++++++++------ emacs/MANUAL.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/NEXT.md b/NEXT.md index f0d0c03..18791e1 100644 --- a/NEXT.md +++ b/NEXT.md @@ -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. diff --git a/emacs/MANUAL.md b/emacs/MANUAL.md index 1ac431c..e12fc0d 100644 --- a/emacs/MANUAL.md +++ b/emacs/MANUAL.md @@ -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 |