diff --git a/NEXT.md b/NEXT.md index 03cea3c..3417d0b 100644 --- a/NEXT.md +++ b/NEXT.md @@ -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** | | `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** | -| `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** | | `bin/main.ml` | `flan read \| parse \| check \| emit \| build \| run \| reload \| dev` | | `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-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-r` | a prompt on the running program (`*flan-repl*`) | | `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` @@ -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 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 -- **A REPL buffer.** `C-x C-e` echoes into the minibuffer; there is no prompt - 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. +- **Editor comforts**: completion, eldoc, jump-to-definition, error overlays. **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 diff --git a/emacs/flan-mode.el b/emacs/flan-mode.el index e5287df..1b3caf8 100644 --- a/emacs/flan-mode.el +++ b/emacs/flan-mode.el @@ -18,6 +18,7 @@ (declare-function flan-disconnect "flan-dev") (declare-function flan-describe "flan-dev") (declare-function flan-show-output "flan-dev") +(declare-function flan-repl "flan-repl") (defgroup flan nil "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-d") #'flan-describe) (define-key map (kbd "C-c C-o") #'flan-show-output) + (define-key map (kbd "C-c C-r") #'flan-repl) map) "Keymap for `flan-mode'.") diff --git a/emacs/flan-repl.el b/emacs/flan-repl.el new file mode 100644 index 0000000..340c09b --- /dev/null +++ b/emacs/flan-repl.el @@ -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 "")) + (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 diff --git a/emacs/test-flan-dev.el b/emacs/test-flan-dev.el index c77eae5..ab9dce3 100644 --- a/emacs/test-flan-dev.el +++ b/emacs/test-flan-dev.el @@ -12,6 +12,7 @@ (require 'flan-mode) (require 'flan-dev) +(require 'flan-repl) (defvar test-flan--failures 0) @@ -75,6 +76,54 @@ (string-match-p "HELLO" (buffer-string))))) (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) (test-flan--check "disconnected" (not (process-live-p flan-dev--connection))) diff --git a/lib/session.ml b/lib/session.ml index 5a5fe91..d4d6922 100644 --- a/lib/session.ml +++ b/lib/session.ml @@ -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.Bool -> [ 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 -> [ call emit_str { Tast.e = Tast.Prim (Tast.Bytes, [ e ]); diff --git a/test/test_repl.ml b/test/test_repl.ml index 6930ba8..42bdb91 100644 --- a/test/test_repl.ml +++ b/test/test_repl.ml @@ -102,6 +102,10 @@ let () = (* An enum's members are erased to i32 before the backend sees them, so the name is recovered from the checker's table. *) 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 [ticks] every frame, so two evaluations of it must disagree. A copy