Three things the first cut got wrong by being global when it had no business
being.
The modeline entry was added to `mode-line-misc-info' at load. It returns nil
outside a Flan buffer, so it was invisible — but it was still evaluated on
every redisplay of every buffer in the session, for someone who loads the
client and then spends the afternoon in dired. It is installed buffer-locally
by `flan-dev-setup' now, which already runs in exactly the buffers that want
it. The `derived-mode-p' guard stays: cheap, and it keeps the function honest
wherever it is called from.
`flan-dev-setup' switched eldoc on. Contributing a documentation source is
this file's business; whether eldoc runs at all is the user's, and turning it
on overrules someone who has `global-eldoc-mode' off deliberately. It is on by
default, so nearly everyone gets the same behaviour either way.
And a reconnect forgot the name cache without asking for it again. An empty
cache is honest but silent — eldoc goes quiet, M-. falls through to whatever
else is registered, and nothing says why — until the next install happens to
refill it. It refreshes straight after reconnecting, which is safe from there
because the connection is live by that point and the request does not come
back round through the same function.
All three want the same three facts about a name — what it is, what it looks
like, and where it was written — so the daemon answers all three in one
`defs` reply and the client keeps the last one.
`defs` is its own op rather than more fields on `describe`. `describe` is what
an editor *polls*: it is how the program's output gets drained, and the
existing tests ask it in loops. Signatures riding on that would be paid for
every time anyone glanced at the output buffer. This is asked once on connect
and again after each accepted install, which is exactly when the answer can
have changed — so a `defn` typed a second ago completes.
It is a cache rather than a request per keystroke because of where these are
called from: eldoc fires on an idle timer and completion inside redisplay, and
neither may block on a socket or signal.
Three refusals rather than three guesses. A global has no location because
`Tast.global` carries no `Loc`, and searching the buffer for "(defvar ticks"
instead would find the wrong one in a program of several files. The prelude is
a string inside the compiler, so its location names a file nobody can visit. A
short name that could be several of the program's package-qualified ones is
ambiguous, and picking would be a guess about which function you meant — a
name that is the tail of exactly *one* is not a guess, and resolves.
Functions the checker invented — a lifted handler-bind clause, which carries
an `fparent` — are left out entirely: nobody wrote that name, so completing it
is noise and jumping to it is meaningless.
And the daemon now makes its own source path absolute before building, because
every location it reports derives from it. `flan dev src/game.flan` from a
project root answered `src/game.flan:12:7`, which an editor can only resolve by
guessing what it was relative to.
lib/dev.ml is the only compiler file touched: a `defs` op, its three list
builders, and the one `realpath` in `start`. Nothing existing changed shape —
`describe`, `eval` and `eval-expr` answer byte for byte what they did.
An install that reports nothing is indistinguishable from one that failed
silently, which is the one thing this loop cannot afford: the whole promise is
that the running program now has the body you just wrote.
The names come from the reply rather than from what was typed, because the
daemon is the one that knows which of them it installed — a `defvar' the
program already had is not among them, and the reply already says so with
`:note'. That case now reads "nothing to install" instead of quoting a
build time for a build that did not happen. `:fns` and `:names' are reported
separately for the same reason: a buffer of five functions and two vars
should not report as five of anything.
A long list is counted and then sampled rather than truncated, since an echo
area cut off in the middle of the tenth name tells you neither how many there
were nor which.
And the region that was sent is flashed, which answers a question the echo
area cannot: `beginning-of-defun' may well have found a different form from
the one you thought point was in.
Whether a program is on the other end is the one fact worth a permanent place
on screen, because every command in the client is a lie without it. Until now
it was discovered by something failing, which is the worst moment to learn it.
Three states, not two. `off' is never connected; `lost' is a daemon that has
gone away, which is the ordinary case rather than an error — `flan dev' ends
when its program does, and a program under development exits all the time. So
`lost' is reconnected from, on the socket it was on, the next time anything is
sent.
The reconnect is strictly *before* a send and never after one. A connection
that dies mid-request might have died after the daemon took the request and
ran it; resending would install a definition twice, or evaluate a
side-effecting expression twice. That case now reports what happened and says
it was not resent, rather than silently doing it again.
A socket that is not there is refused by name with the path, and a deliberate
`flan-disconnect' forgets the socket, so the next command says "not connected"
instead of quietly reopening what was just closed.
An error that only reaches the echo area is gone the moment you type, and the
location was the useful half of it. So the client draws an overlay at the
`:loc` the daemon sent, with the message beside the code, and clears it the
next time that buffer's evaluation is accepted — a marker left behind after a
fix is a lie about the running program.
Two things had to be right first, and neither was.
The column in a `:loc` is a *byte* offset: lib/reader.ml walks the source a
byte at a time and OCaml strings are bytes. The old code did `forward-char`
with it, which is the same mistake as counting a frame's length in characters,
in a different place — one accented character earlier on the line puts the
marker as many columns to the right. It goes through `byte-to-position` from
the line's start now, and is clamped to the end of the line, which the old code
also needed: a column past a short line walked into the next one and pointed at
innocent code.
And the daemon numbers lines from the start of what it was *sent*, so `C-c C-c`
on a defn halfway down a buffer came back saying line 1. Every overlay would
have sat on the file's first line. The fix is leading newlines: the reader
skips them, and the reply's line numbers are then the buffer's own. No protocol
change, and nothing the daemon has to know.
Marking the error must not itself signal — the error the caller is owed is the
daemon's, and losing it to a bad location would report the wrong thing.
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.
Its stdout is a pipe into the daemon now, and whatever it printed since the
last reply rides along with the next one into *flan-output*. Arriving with a
reply rather than by a separate request is the point: the output an evaluation
itself caused is the output anyone wants to see.
Draining that pipe is a liveness requirement, not a nicety. A pipe nobody reads
fills at 64K and the next write blocks the program forever, so it is read from
the accept loop's select whether or not an editor is asking, and the buffer is
capped - a program printing every frame must not grow the daemon without limit,
and the newest text is the useful end.
test_dev read the program's transcript off the daemon's stdout, which is no
longer where it goes; it collects :output from replies instead, which is also
what the editor does. The emacs test moved to a fixture that keeps running,
since it now evaluates more times than the old one had reloads to give.
A different primitive from redefining a name. There is no name to install a
body into, so the expression is wrapped in a function with nowhere to be called
from; the module exports flan_reload_call to say "run this once", and the agent
calls it after the install - on the game thread, at a frame boundary, so an
expression that reads the program's state sees a point the program agrees is
consistent.
Nothing is marshalled back because nothing could be. A Flan value carries no
header, so no code at run time can say what it is; the compiler knows the type
and renders it there, in the thunk. That is the layout decision's bill, and it
is why the printer set is the scalars rather than everything.
The rendering does not go through stdout. Stdout belongs to the program, it is
in the hot path for anything that prints, and a dev-only feature must not put a
branch in it - so flan_rt.c is untouched and the value goes to flan_dev_result,
read back over the agent's socket. Safe without a handshake because the
generation counter is bumped last: the daemon waits for it to move rather than
assuming the program has reached a frame boundary.
u64 refuses by name, because i64->bytes is signed and anything past 2^63 would
come back negative. Everything without a derived printer refuses the same way.
A number that is quietly wrong is the failure this whole thing exists to
prevent.
An evaluation is not a declaration: the thunk is built against the program and
never spliced into it, so describe does not fill up with an eval/N for every
expression ever typed.
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 from a copy of the program's state, would not.
Asked whether a defconst could be redefined, probed it, and got ":status ok"
for a change that did nothing at all - the module was built, delivered,
installed, and the program went on using the old value. That is the
silent-wrongness class the house rule exists to prevent, so it is now four
refusals and a fix.
A defconst's value is folded into its call sites - into an array length at
worst, which is decided before any type resolves - so it lives in the running
program's code and not only in its storage. Refused. A defenum member is the
same thing: :space is erased to an i32 literal in the caller. Refused, and
compared over declarations rather than over Tast.program, which carries no
enums at all for exactly that reason.
A defvar's initial value is deliberately not refused. Its storage holds live
state the program moved past long ago, and refusing to change the initialiser
would be refusing "edit the code, keep the sand". Same Tast.global record as a
defconst, opposite answers, told apart by gconst.
The value comparison is structural and conservative - anything it does not
recognise counts as changed. Comparing emitted text would be wrong, since
Emit.const on a string allocates a name off a per-module counter and two
different strings in two throwaway modules both come out as @".str.0".
Third: a new global's declared initial value was being dropped. flan_dev_global
callocs, so (defvar n i64 42) added at run time was silently zero. It now takes
the initial value as a blob, copies it on the allocation and ignores it
afterwards - the second half being where "a reload must not reset the program's
state" lives. In the allocation path rather than a branch at the call site, so
it cannot be got wrong at one of them.
Fourth: a change with no body to publish and no storage to allocate now answers
"nothing to install" instead of shipping an empty module. That is what the
defconst probe actually did, and it cost the program a frame's worth of reload
it did not need.
C-c C-c recompiles the top-level form at point and installs it in a running
program at that program's next frame boundary. Verified against sand: an
unsaved buffer edit to game-draw, and 240 consecutive frames drew it.
flan-mode.el derives from prog-mode with lisp-mode's syntax table, which is
most of the work - Flan is s-expressions, so sexp motion, paren matching,
beginning-of-defun and indentation are already right. What it adds is Flan's
own brackets ([ and { are brackets and not symbol characters, since every
binding list and every type is written with them), the characters a name may
contain, and its keywords.
flan-dev.el has no parser in it, which is what the protocol choice bought:
prin1 writes a request, read reads a reply. C-c C-k sends a buffer as one
module rather than a form at a time, because a defvar and the function using it
have to arrive in the same load or the first refers to storage that does not
exist yet. An error comes back with a location and point moves there.
Framing is in bytes and Emacs counts characters, so every length goes through
string-bytes and the process is binary. Otherwise one non-ASCII character in a
buffer puts the reply stream out of step by exactly as many bytes as the
payload has of them - a bug that reads as a corrupt protocol and only appears
for some people. test_emacs.ml drives the real client against a real daemon for
that reason: it is not the same claim as the daemon answering correctly, and a
mistake in the framing, in beginning-of-defun over Flan's syntax table, or in
the reply reader passes test_dev.ml and fails here.