In a .fln buffer a let's binding lines belong to its statement, TAB offers the column under its first name, and C-c C-e sends the group

This commit is contained in:
Joseph Ferano 2026-09-26 06:32:09 +07:00
parent 12cd4feff0
commit 25e7db944f
5 changed files with 127 additions and 17 deletions

View File

@ -652,11 +652,6 @@ after it (so a second block lambda, or one not last) is refused, and the fix nam
Held 2026-09-26 by the author: F#-style ~f x y~ or Nim-style one-argument calls
without parentheses. Collides with space-separated vector and index elements.
** NEXT A let takes several bindings on indented lines
Decided 2026-09-26: lines indented under a ~let~ that are ~name = v~ or ~name: T = v~ are
more bindings of the same let; anything else there stays refused. flan convert writes
consecutive lets this way.
** TODO Hard-coded code in messages is still paren syntax in a .fln file
Types follow the code's syntax now (=Types.spell=). Hints written into a message's
text — =(Ptr %s)=, =(clone v)=, =(the T x)= in most of =check.ml= and =parse.ml=, the

View File

@ -1197,7 +1197,7 @@ Use `C-c C-g` if you need frames.
| `C-c C-c`, `C-M-x` | same | the top-level form: a declaration installed, anything else evaluated |
| `C-u C-c C-c` | same | ...and stop at the innermost bracket group, else the statement on point's line: an elif's condition, an else's block or its value on the line, a match arm's value (`C-u C-u`: on entry) |
| `C-x C-e` | same, cursor on the line's last character | at a line's end, the innermost statement ending there: a match arm's value, an if/elif/while condition, or the whole statement a header or clause line opens (a one-line `if c then a` with `else` under it included); elsewhere, the term before point |
| `C-c C-e` | same | the statement at point with its body and clauses, or the region's whole lines; on a `let`, the `let` and the rest of its block |
| `C-c C-e` | same | the statement at point with its body and clauses, or the region's whole lines; on a `let` or one of its binding lines, the `let`, its bindings and the rest of its block |
| `C-c C-n` | same | `C-c C-e`, then move to the next statement |
| `C-c C-s` | same | step through the top-level `fn` at point |
| `C-c C-k` | same | the whole buffer |
@ -1205,7 +1205,7 @@ Use `C-c C-g` if you need frames.
| `M-a` / `M-e` | `(` / `)` | statement: start / end (`)`: start of the next) |
| `C-M-u` | same | up to the enclosing bracket, or the line that owns the block |
| `C-M-f` / `C-M-b` | same | brackets and terms, as everywhere |
| `TAB` | same | a line at a valid column stays; an empty or misplaced line goes deepest; each repeat steps out a level. One level deeper only after a line that opens a block: never after a `let`, unless its value goes on under it (`= match x`, `= if c`, `= loop i = 0`, a lambda header). After a line ending in `=>`, one level in from that line, inside brackets too; a line of that block keeps to the block's columns. Inside open brackets, under the first element after the opener, or one level in from the opener's line when it ends that line; a line that starts with the closer goes there too, so `RET` in `and(a|)` leaves room for the next argument. The closer of a bracket that holds a lambda's block goes to the call's column |
| `TAB` | same | a line at a valid column stays; an empty or misplaced line goes deepest; each repeat steps out a level. One level deeper only after a line that opens a block: never after a `let`, unless its value goes on under it (`= match x`, `= if c`, `= loop i = 0`, a lambda header). After a `let` line, the column under its first name is offered second, for its next binding (`let a = 1` and `b = 2` under `a`); after such a binding line it comes first, and `DEL` steps out to the `let`'s column. After a line ending in `=>`, one level in from that line, inside brackets too; a line of that block keeps to the block's columns. Inside open brackets, under the first element after the opener, or one level in from the opener's line when it ends that line; a line that starts with the closer goes there too, so `RET` in `and(a|)` leaves room for the next argument. The closer of a bracket that holds a lambda's block goes to the call's column |
| `DEL` in indentation | same | drop one level |
| `C-c <` / `C-c >` | `<` / `>` | shift the region's lines a level |
| `M-<up>` / `M-<down>` | same | move the statement past its neighbour |
@ -1225,7 +1225,7 @@ enclosing statement, top-level form.
- **term**: a run with no space outside brackets — `f(a, b)`, `grid[r, c]`, `p.x`.
- **group**: a bracket pair and what is inside it.
- **statement**: a line, the deeper lines under it, lines inside brackets it leaves open, lines an operator continues, and `else`/`elif`/`on`/`restart` at its column. Blank and comment lines inside never end it. A lambda's block inside brackets, `sort-by(xs, fn(a, b) =>` and the lines under it, is part of the call's statement, and each of its lines is a statement too: `C-c C-e` or `C-u C-c C-c` there takes that line, not the call, and never the call's closing `)` at the end of it.
- **statement**: a line, the deeper lines under it, lines inside brackets it leaves open, lines an operator continues, and `else`/`elif`/`on`/`restart` at its column. A `let`'s binding lines, lined up under its first name, belong to the `let`. Blank and comment lines inside never end it. A lambda's block inside brackets, `sort-by(xs, fn(a, b) =>` and the lines under it, is part of the call's statement, and each of its lines is a statement too: `C-c C-e` or `C-u C-c C-c` there takes that line, not the call, and never the call's closing `)` at the end of it.
- **body**: a statement's own block, up to its first clause.
- **clause**: one `else`/`elif`/`on`/`restart` line and its block, or the value on its line (`else x`).
- **top-level form**: a column-0 line that is code, not a clause and not a continuation, through the last code line before the next one.

View File

@ -480,11 +480,52 @@ on its line, which is not the statement's."
(or (flan-fln--prev-code bol) (flan-fln--next-code bol))
bol)))
(defun flan-fln--let-line-p (l)
"Non-nil if the line L starts with `let', at any column."
(save-excursion (goto-char (flan-fln--first-char l)) (looking-at "let[ \t]")))
(defun flan-fln--binding-column (l)
"The column of the first name on the `let' line L: where the let's other
bindings line up."
(save-excursion
(goto-char (flan-fln--first-char l))
(skip-chars-forward "let")
(skip-chars-forward " \t")
(current-column)))
(defun flan-fln--binding-shape-p (l)
"Non-nil if the joined line L reads as a binding: a name, `~x', or a
`[...]' or `{...}' pattern, then ` = ' or `: T = '."
(save-excursion
(goto-char (flan-fln--first-char l))
(let ((end (flan-fln--joined-end l)))
(cond
((looking-at "[[{]")
(let ((c (ignore-errors (scan-lists (point) 1 0))))
(and c (<= c end)
(progn (goto-char c) (looking-at "[ \t]+=\\(?:[ \t]\\|$\\)")))))
((looking-at "~?[^][ \t\n(){},;\":.~][^][ \t\n(){},;\":.]*\\(?:\\([ \t]+=\\(?:[ \t]\\|$\\)\\)\\|:[ \t]\\)")
(or (match-beginning 1)
(flan-fln--find-top "[ \t]=\\(?:[ \t]\\|$\\)" (match-end 0) end)))))))
(defun flan-fln--binding-let (l)
"The `let' line whose binding the joined line L is, or nil.
Lines indented under a let whose value ends on its line are more bindings
of it (lib/indent_reader.ml's `binding_lines'), lined up with its first
name."
(and (not (flan-fln--continuation-p l))
(flan-fln--binding-shape-p l)
(let ((p (flan-fln--parent l)))
(and p (flan-fln--let-line-p p)
(not (flan-fln--opener-p p (flan-fln--logical-end p)))
p))))
(defun flan-fln--line-statement (pos)
"The first line of the statement POS's line starts or continues.
A clause line answers for itself."
A clause line answers for itself; a let's binding line gives the let."
(let ((l (flan-fln--code-line-at pos)))
(and l (flan-fln--logical-start l))))
(and l (let ((s (flan-fln--logical-start l)))
(or (flan-fln--binding-let s) s)))))
(defun flan-fln--statement-start-at (pos)
"The first line of the statement at POS; a clause gives its header's."
@ -792,6 +833,9 @@ forms, where the clause line itself is not."
(cons (flan-fln--group-form-start (car g)) (cdr g)))
(arm (plist-get arm :value))
((and l (flan-fln--clause-line-p l)) (flan-fln--clause-target l))
;; A let's binding line starts no form; its value is what runs there.
((let ((raw (flan-fln--logical-start (flan-fln--code-line-at pos))))
(and (flan-fln--binding-let raw) (flan-fln--binding-value raw))))
(t (let ((s (flan-fln--statement-start-at pos)))
(and s (or (flan-fln--merged-let-value s)
(flan-fln--statement-bounds s))))))))
@ -803,11 +847,19 @@ The reader merges consecutive lets into one binding vector, so a second
the form that runs where the line stands."
(let ((p (and (flan-fln--let-p s) (flan-fln--sibling s -1))))
(when (and p (flan-fln--let-p p))
(let ((v (flan-fln--value-start s))
(end (flan-fln--joined-end s)))
(if (and v (< v end))
(cons v (cdr (flan-fln--statement-bounds s)))
(flan-fln--body-bounds s))))))
(flan-fln--binding-value s))))
(defun flan-fln--binding-value (s)
"Bounds of the value the binding line S binds: after its `=' on the line,
or the block under it."
(let ((v (flan-fln--value-start s))
(end (flan-fln--joined-end s)))
(cond ((not (and v (< v end))) (flan-fln--body-bounds s))
;; `= match x', a lambda header: the value takes the lines under it.
((flan-fln--opener-p s (flan-fln--logical-end s))
(cons v (cdr (flan-fln--statement-bounds s))))
;; Otherwise the lines under a let are its other bindings.
(t (cons v end)))))
(defun flan-fln--clause-target (l)
"What a clause line L stops at: an elif's condition, else its block.
@ -1022,9 +1074,10 @@ before point. With ARG, stop there instead, as \\[flan-eval-last-sexp] does."
(skip-chars-backward " \t\n" first) (point))))))
(defun flan-fln--let-p (start)
"Non-nil if START's statement is a `let'.
"Non-nil if START's statement is a local `let'.
A let takes no block: lines under it are its value's (`= match x', a lambda
header), and its name lasts to the end of the block it is in."
header) or more bindings of it, and its names last to the end of the block
it is in."
(and (> (flan-fln--indent-at start) 0)
(save-excursion (goto-char (flan-fln--first-char start))
(looking-at "let[ \t]"))))
@ -1305,6 +1358,13 @@ whose brackets have closed is no block POS can join."
(cons (+ (flan-fln--indent-at (flan-fln--logical-start prev))
flan-fln-indent-offset)
stack))
;; After a let's line, its next binding's column too, lined up with
;; its first name -- second, so RET after a let stays at its column.
;; After a binding line, that column is on the stack, first.
((and prev (flan-fln--let-line-p (flan-fln--logical-start prev)))
(let ((b (flan-fln--binding-column (flan-fln--logical-start prev))))
(if (memq b stack) stack
(cons (car stack) (cons b (cdr stack))))))
(t stack))))
(defun flan-fln--block-levels (pos)

View File

@ -165,6 +165,16 @@ comment():
0
let x = 3
twice(x) + 1
fn grp(k: i64) -> i64
let a = k + 1
b = a * 2
a + b
comment():
let u = 2
v = u * 5
u + v
")
(defun test-flan-fln-live--run (backend args)
@ -240,6 +250,12 @@ comment():
(test-flan--check (funcall name "C-c C-e on a bare let sends its scope with it")
(funcall shows "13"))
;; A let's bindings on the lines under it go with it, and read.
(funcall goto "v = u * 5")
(flan-fln-eval-statement)
(test-flan--check (funcall name "C-c C-e on a binding line sends its let's group and scope")
(funcall shows "12"))
;; A region of several statements reads as one (do ...).
(funcall goto "twice(4)")
(transient-mark-mode 1)
@ -403,6 +419,7 @@ comment():
("loop x = a" "a loop, at its word")
("if y == 0" "a loop's block")
("let r = loop" "a let-bound loop, at its let")
("b = a * 2" "a binding under a let, at its value")
("if i > n" "a let-bound loop's block")))
(funcall goto (car c))
(let ((reply (flan-fln-eval-defun '(4))))

View File

@ -422,6 +422,34 @@ fn twice(n: i64) -> i64 = n * 2
(test-flan-fln--is "a free parenthesis marks the value inside it"
(plist-get r :pause) '(2 6))))
;;; A let's bindings on the lines under it
(defconst test-flan-fln--group
"fn f(p) -> i64
let a = 1
b = a + 1
{x .x} = p
g(a, b)
a + x
")
(test-flan-fln--in (test-flan-fln--at test-flan-fln--group "b = a")
(test-flan-fln--is "a binding line is part of its let's statement"
(test-flan-fln--thing 'flan-fln-statement)
"let a = 1\n b = a + 1\n {x .x} = p"))
(test-flan-fln--in (test-flan-fln--at test-flan-fln--group "{x .x}")
(let ((r (test-flan-fln--sending (flan-fln-eval-statement))))
(test-flan-fln--is "C-c C-e on a binding line sends the group and its scope"
(test-flan-fln--sent-code r)
"let a = 1\n b = a + 1\n {x .x} = p\n g(a, b)\n a + x")))
(test-flan-fln--in (test-flan-fln--at test-flan-fln--group "a + 1")
(let ((r (test-flan-fln--sending (flan-fln-eval-defun '(4)))))
(test-flan-fln--is "C-u C-c C-c on a binding line marks its value"
(plist-get r :pause) '(3 11))))
(test-flan-fln--in (test-flan-fln--at "fn f()\n let f = fn(x) =>\n y = x\n y\n f\n" "y = x")
(test-flan-fln--is "a line of a lambda value's block is no binding"
(test-flan-fln--thing 'flan-fln-statement) "y = x"))
;;; Match arms, header lines, clauses, comments
(defconst test-flan-fln--arms
@ -878,6 +906,16 @@ defconst(k, 3)
(test-flan-fln--tabs " if a and\n|b" 1) 4)
(test-flan-fln--is "never deeper after a let"
(test-flan-fln--tabs "fn f()\n let r = 3\n|" 1) 2)
(test-flan-fln--is "TAB after a let stays at its column first"
(test-flan-fln--tabs "fn f()\n let a = 1\n|" 1) 2)
(test-flan-fln--is "and offers its next binding's column, under the first name"
(test-flan-fln--tabs "fn f()\n let a = 1\n|" 2) 6)
(test-flan-fln--is "after a binding line, that column comes first"
(test-flan-fln--tabs "fn f()\n let a = 1\n b = 2\n|" 1) 6)
(test-flan-fln--is "and a binding written there stays"
(test-flan-fln--tabs "fn f()\n let a = 1\n| b = 2" 1) 6)
(test-flan-fln--is "at the top level too"
(test-flan-fln--tabs "let a = 1\n|" 2) 4)
(dolist (c '(("let r = match n" "a let's match")
("let r = if c" "a let's if")
("x = if c" "an assignment's if")