A prompt on the running program

flan-repl.el is a comint buffer whose every line goes through the same
eval-expr request C-x C-e uses - no new protocol, no compiler support. Deriving
from comint rather than hand-rolling a prompt is the same call as deriving
flan-mode from lisp-mode: history, the input ring and kill/yank already exist
and are not worth rewriting. There is no subprocess behind it; the "process" is
a stub comint needs in order to have a prompt.

It is program-scoped: a name typed at the prompt resolves against the running
program's top-level namespace, so in sand you write sim/settle. A buffer
visiting a package's file gets the alias applied for it because the file says
which package it belongs to, and a prompt has no file to derive one from. RET
on a half-typed form opens a line instead of sending it, with balance checked
through the Flan syntax table so a paren inside a string does not count.

A value and the program's output are different things and arrive by different
routes: the value is the result of the request and appears at the prompt, while
anything printed rides along on the same reply into *flan-output*. Showing them
in one place would be convenient and wrong, so there is a test for the
separation - and it caught a real bug. The renderer's Unit case emitted () with
no evaluation at all, so (print-line "x"), the most ordinary thing anyone types
at a prompt, answered while nothing happened. A Unit expression is almost
always a call made for its effect; it is evaluated and then reported.
This commit is contained in:
Joseph Ferano 2026-09-11 07:21:37 +07:00
parent 20fedd4ad8
commit 5993875539
6 changed files with 222 additions and 6 deletions

39
NEXT.md
View File

@ -46,7 +46,7 @@ reader ✅ → parse ✅ → load ✅ → check ✅ → emit ✅ → clang ✅
| `runtime/flan_dev.c` | **dev only: the by-name registry a run-time-new name needs** | | `runtime/flan_dev.c` | **dev only: the by-name registry a run-time-new name needs** |
| `vendor/raylib/` | **the raylib package: `raylib.flan`, `shim.c`, `link`** | | `vendor/raylib/` | **the raylib package: `raylib.flan`, `shim.c`, `link`** |
| `vendor/agent/` | **the dev agent: a socket, a loader thread, install at a frame boundary** | | `vendor/agent/` | **the dev agent: a socket, a loader thread, install at a frame boundary** |
| `emacs/` | **`flan-mode.el` and `flan-dev.el`: the editor half of the dev loop** | | `emacs/` | **`flan-mode.el`, `flan-dev.el`, `flan-repl.el`: the editor half of the dev loop** |
| `sand-sim/` | **the falling-sand simulation, with no raylib in it** | | `sand-sim/` | **the falling-sand simulation, with no raylib in it** |
| `bin/main.ml` | `flan read \| parse \| check \| emit \| build \| run \| reload \| dev` | | `bin/main.ml` | `flan read \| parse \| check \| emit \| build \| run \| reload \| dev` |
| `test/test_flan.ml` | reader, parser and checker | | `test/test_flan.ml` | reader, parser and checker |
@ -687,6 +687,7 @@ of the protocol choice: `prin1` writes a request and `read` reads a reply.
| `C-x C-e` | the expression before point, evaluated *in the running program* | | `C-x C-e` | the expression before point, evaluated *in the running program* |
| `C-c C-z` / `C-c C-q` | connect (finds `.flan-dev.sock` upward) / disconnect | | `C-c C-z` / `C-c C-q` | connect (finds `.flan-dev.sock` upward) / disconnect |
| `C-c C-o` | the running program's own output, in `*flan-output*` | | `C-c C-o` | the running program's own output, in `*flan-output*` |
| `C-c C-r` | a prompt on the running program (`*flan-repl*`) |
| `C-c C-d` | what the running program currently defines | | `C-c C-d` | what the running program currently defines |
`C-c C-k` sends one module rather than a form at a time on purpose: a `defvar` `C-c C-k` sends one module rather than a form at a time on purpose: a `defvar`
@ -802,11 +803,39 @@ The test that matters is the same expression twice: the fixture increments
`ticks` every frame, so two evaluations must disagree. A value computed in the `ticks` every frame, so two evaluations must disagree. A value computed in the
compiler, or read out of a copy of the program's state, would not. compiler, or read out of a copy of the program's state, would not.
### The REPL buffer
`flan-repl.el` is a `comint-mode` buffer whose every line goes through the same
`eval-expr` request `C-x C-e` uses. No new protocol and no compiler support.
Deriving from `comint` rather than hand-rolling a prompt is the same call as
deriving `flan-mode` from `lisp-mode`: history, the input ring and kill/yank
already exist. There is no subprocess behind it — the "process" is a stub
comint needs in order to have a prompt at all.
Three things about it that are decisions:
- **It is program-scoped.** A name typed at the prompt resolves against the
running program's top-level namespace, so in sand you write `sim/settle` and
not `settle`. A buffer visiting a package's own file gets the alias applied
for it because the file says which package it belongs to; a prompt has no
file and nothing to derive one from.
- **RET on a half-typed form opens a line instead of sending it.** Balance is
checked with the Flan syntax table, so a paren inside a string does not
count.
- **A value and the program's output are different things and arrive by
different routes.** The value is the result of the request and appears at the
prompt; anything the program printed while evaluating it rides along on the
same reply and goes to `*flan-output*`. Showing them in one place would be
convenient and wrong, so there is a test for the separation.
That test is what caught a real bug: the renderer's `Unit` case emitted `()`
without evaluating the expression, so `(print-line "x")` — the most ordinary
thing anyone types at a prompt — answered `()` while nothing happened. A Unit
expression is almost always a call made for its effect, and is now evaluated
and *then* reported.
### What is left ### What is left
- **A REPL buffer.** `C-x C-e` echoes into the minibuffer; there is no prompt - **Editor comforts**: completion, eldoc, jump-to-definition, error overlays.
to type at and no history.
- **Printers beyond the scalars.** See below — a struct, an `(Option T)` or a
slice of structs still refuses by name.
**Session identity is the daemon that owns the build.** A session's struct **Session identity is the daemon that owns the build.** A session's struct
layouts and global types have to describe the memory of the process it is layouts and global types have to describe the memory of the process it is

View File

@ -18,6 +18,7 @@
(declare-function flan-disconnect "flan-dev") (declare-function flan-disconnect "flan-dev")
(declare-function flan-describe "flan-dev") (declare-function flan-describe "flan-dev")
(declare-function flan-show-output "flan-dev") (declare-function flan-show-output "flan-dev")
(declare-function flan-repl "flan-repl")
(defgroup flan nil (defgroup flan nil
"Editing and evaluating Flan." "Editing and evaluating Flan."
@ -78,6 +79,7 @@
(define-key map (kbd "C-c C-q") #'flan-disconnect) (define-key map (kbd "C-c C-q") #'flan-disconnect)
(define-key map (kbd "C-c C-d") #'flan-describe) (define-key map (kbd "C-c C-d") #'flan-describe)
(define-key map (kbd "C-c C-o") #'flan-show-output) (define-key map (kbd "C-c C-o") #'flan-show-output)
(define-key map (kbd "C-c C-r") #'flan-repl)
map) map)
"Keymap for `flan-mode'.") "Keymap for `flan-mode'.")

128
emacs/flan-repl.el Normal file
View File

@ -0,0 +1,128 @@
;;; flan-repl.el --- A prompt for a running Flan program -*- lexical-binding: t; -*-
;; A buffer to type expressions at, sent to the program `flan dev' is running
;; and answered with the value they had *there*. It adds no protocol and no
;; compiler support: every line goes through the same `eval-expr' request that
;; C-x C-e uses.
;;
;; Derived from `comint-mode', for the same reason `flan-mode' derives from
;; `lisp-mode': history, the input ring, and kill/yank behaviour already exist
;; and are not worth rewriting. There is no subprocess behind it — the
;; "process" is a stub comint needs in order to have a prompt at all.
;;
;; Two things about it that are decisions, not accidents:
;;
;; - **It is program-scoped.** A name typed here resolves against the running
;; program's top-level namespace, so in sand you write `sim/settle' and not
;; `settle'. A buffer visiting a package's own file gets the alias applied
;; for it, because the file says which package it belongs to; a prompt has no
;; file and nothing to derive it from.
;;
;; - **A value and the program's output are different things** and arrive by
;; different routes. The value of the expression appears at the prompt;
;; anything the program printed while evaluating it goes to *flan-output*,
;; riding along on the same reply. Showing them in one place would be
;; convenient and wrong.
;;; Code:
(require 'comint)
(require 'flan-mode)
(require 'flan-dev)
(defcustom flan-repl-buffer "*flan-repl*"
"Name of the Flan REPL buffer."
:type 'string
:group 'flan)
(defvar flan-repl-prompt "flan> "
"Prompt shown in `flan-repl-mode'.")
(defvar flan-repl-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-c C-o") #'flan-show-output)
(define-key map (kbd "C-c C-d") #'flan-describe)
(define-key map (kbd "C-c C-q") #'flan-disconnect)
map)
"Keymap for `flan-repl-mode'.")
(define-derived-mode flan-repl-mode comint-mode "Flan-REPL"
"Type expressions; they are evaluated in the running program.
\\{flan-repl-mode-map}"
:syntax-table flan-mode-syntax-table
(setq-local comint-prompt-regexp (concat "^" (regexp-quote flan-repl-prompt)))
(setq-local comint-prompt-read-only t)
(setq-local comint-input-sender #'flan-repl--send)
;; Nothing is echoed back by a process, because there is no process.
(setq-local comint-process-echoes nil)
(setq-local font-lock-defaults '(flan-font-lock-keywords)))
(defun flan-repl--complete-p (text)
"Is TEXT a whole form?
Parens balanced and not inside a string or comment. RET on a half-typed form
should open a line, not send something the reader will reject."
(let ((state (with-temp-buffer
(set-syntax-table flan-mode-syntax-table)
(insert text)
(parse-partial-sexp (point-min) (point-max)))))
(and (<= (nth 0 state) 0) ; depth
(not (nth 3 state)) ; in a string
(not (nth 4 state))))) ; in a comment
(defun flan-repl--output (text)
"Insert TEXT into the REPL buffer above the next prompt."
(let ((proc (get-buffer-process (current-buffer))))
(comint-output-filter proc (concat text "\n" flan-repl-prompt))))
(defun flan-repl--send (_proc text)
"Evaluate TEXT in the running program and show what it was."
(let ((code (string-trim text)))
(cond
((string-empty-p code) (flan-repl--output ""))
(t
(let ((reply (condition-case err
(flan-dev--request
(list :op "eval-expr" :code code :file "<repl>"))
(error (list :status "error"
:message (error-message-string err))))))
(flan-repl--output
(if (equal (plist-get reply :status) "ok")
(or (plist-get reply :value) "")
(concat "error: " (or (plist-get reply :message) "rejected")
(let ((loc (plist-get reply :loc)))
(if loc (concat " (" loc ")") ""))))))))))
(defun flan-repl-return ()
"Send the input if it is a whole form, otherwise open a line."
(interactive)
(let ((input (buffer-substring-no-properties
(process-mark (get-buffer-process (current-buffer)))
(point-max))))
(if (flan-repl--complete-p input)
(comint-send-input)
(insert "\n"))))
(define-key flan-repl-mode-map (kbd "RET") #'flan-repl-return)
;;;###autoload
(defun flan-repl ()
"Open a prompt on the program `flan dev' is running.
Connects first if it has to."
(interactive)
(unless (and flan-dev--connection (process-live-p flan-dev--connection))
(call-interactively #'flan-connect))
(let ((buf (get-buffer-create flan-repl-buffer)))
(with-current-buffer buf
(unless (derived-mode-p 'flan-repl-mode)
(flan-repl-mode)
;; comint wants a process to hang a prompt and a process mark off.
;; There is nothing to run, so this one does nothing and is never
;; written to; every request goes over the daemon's socket instead.
(let ((proc (start-process "flan-repl" buf "cat")))
(set-process-query-on-exit-flag proc nil)
(comint-output-filter proc flan-repl-prompt))))
(pop-to-buffer buf)))
(provide 'flan-repl)
;;; flan-repl.el ends here

View File

@ -12,6 +12,7 @@
(require 'flan-mode) (require 'flan-mode)
(require 'flan-dev) (require 'flan-dev)
(require 'flan-repl)
(defvar test-flan--failures 0) (defvar test-flan--failures 0)
@ -75,6 +76,54 @@
(string-match-p "HELLO" (buffer-string))))) (string-match-p "HELLO" (buffer-string)))))
(test-flan--check "the program's output reaches its buffer" seen)) (test-flan--check "the program's output reaches its buffer" seen))
;; The REPL buffer: typed input goes through the same eval-expr request, and
;; the value lands at the prompt while the program's own output goes to
;; *flan-output*. Conflating those two is the bug worth testing for.
(test-flan--check "an incomplete form is not sent"
(not (flan-repl--complete-p "(+ 1")))
(test-flan--check "a whole form is sent" (flan-repl--complete-p "(+ 1 2)"))
(test-flan--check "a paren in a string does not count"
(not (flan-repl--complete-p "(f \"(\"")))
(flan-repl)
(with-current-buffer flan-repl-buffer
(goto-char (point-max))
(insert "(+ 20 3)")
(flan-repl-return)
(let ((deadline (+ (float-time) 15)))
(while (and (not (string-match-p "23" (buffer-string)))
(< (float-time) deadline))
(accept-process-output nil 0.05)))
(test-flan--check "the REPL shows a value"
(string-match-p "23" (buffer-string)))
(goto-char (point-max))
(insert "no-such-thing")
(flan-repl-return)
(let ((deadline (+ (float-time) 15)))
(while (and (not (string-match-p "unknown name" (buffer-string)))
(< (float-time) deadline))
(accept-process-output nil 0.05)))
(test-flan--check "the REPL shows an error"
(string-match-p "unknown name" (buffer-string)))
;; A value and the program's output travel by different routes: the value
;; is the result of the request, the output rides along with the reply.
;; Showing them in one place would be convenient and wrong.
(goto-char (point-max))
(insert "(print-line \"PRINTED\")")
(flan-repl-return)
(let ((deadline (+ (float-time) 15)))
(while (and (not (with-current-buffer flan-dev-output-buffer
(string-match-p "PRINTED" (buffer-string))))
(< (float-time) deadline))
(ignore-errors (flan-dev--request '(:op "describe")))
(accept-process-output nil 0.05)))
(test-flan--check "printed text goes to the output buffer"
(with-current-buffer flan-dev-output-buffer
(string-match-p "PRINTED" (buffer-string))))
;; ...and the prompt got the *value*, which for a call made for its effect
;; is Unit. The text it printed is not the value and does not belong here.
(test-flan--check "and the prompt got the value, not the text"
(string-match-p "()" (buffer-string))))
(flan-disconnect) (flan-disconnect)
(test-flan--check "disconnected" (not (process-live-p flan-dev--connection))) (test-flan--check "disconnected" (not (process-live-p flan-dev--connection)))

View File

@ -428,7 +428,11 @@ let rec render c depth (e : Tast.expr) : Tast.expr list =
| Types.Float _ -> [ call emit_f64 (cast (Types.Float Types.F64) e) ] | Types.Float _ -> [ call emit_f64 (cast (Types.Float Types.F64) e) ]
| Types.Bool -> | Types.Bool ->
[ unit_ (Tast.If (e, lit "true", lit "false")) ] [ unit_ (Tast.If (e, lit "true", lit "false")) ]
| Types.Unit -> [ lit "()" ] (* Evaluated *and then* reported. A Unit expression is almost always a call
made for its effect (print-line "x") is the REPL's most ordinary
input so emitting the literal without running it would make the prompt
answer () while nothing happened. *)
| Types.Unit -> [ e; lit "()" ]
| Types.String -> | Types.String ->
[ call emit_str [ call emit_str
{ Tast.e = Tast.Prim (Tast.Bytes, [ e ]); { Tast.e = Tast.Prim (Tast.Bytes, [ e ]);

View File

@ -102,6 +102,10 @@ let () =
(* An enum's members are erased to i32 before the backend sees them, so (* An enum's members are erased to i32 before the backend sees them, so
the name is recovered from the checker's table. *) the name is recovered from the checker's table. *)
value "an enum" "col" ":blue"; value "an enum" "col" ":blue";
(* A Unit expression is almost always a call made for its effect, so it
has to be *evaluated* and then reported as (). Emitting the literal
without running it made the prompt answer while nothing happened. *)
value "a call made for its effect" "(print-line \"printed\")" "()";
(* The one that proves it ran inside the process: the program increments (* The one that proves it ran inside the process: the program increments
[ticks] every frame, so two evaluations of it must disagree. A copy [ticks] every frame, so two evaluations of it must disagree. A copy