diff --git a/NEXT.md b/NEXT.md index 41f1e41..5b6370f 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 | 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 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