16 Commits

Author SHA1 Message Date
61ca469a7c A restart that stopped at the quit has not restarted anything 2026-09-11 20:29:48 +07:00
f925a79475 Say at the top that the terminal is optional now 2026-09-11 20:28:13 +07:00
fc3cd2361a Point at the reason rather than naming the buffer it is in
A program that does not compile kills the daemon before it binds, which is
the failure anyone starting one from Emacs will actually hit. Showing that
buffer is the difference between a message and an answer.

The prompt also offers the program last started: a restart after a quit is
the common case, and it is rarely the buffer you happen to be reading when
you decide on it. C-c C-x does the restart without the prompt at all.
2026-09-11 20:27:50 +07:00
aee8a032b1 Some changes are not a reload, and saying so is the feature
A struct whose layout moved cannot be installed into a program built with the
old one, and the daemon says so. There is no smaller answer than a rebuild: a
session's layouts and global types describe a process only if that session
compiled it, so the program and everything in its memory go too. That is the
cost, and it is why this is its own command and not something C-c C-c falls
back to.

Emacs owns the daemon now, so this is stop-and-start rather than a new op.
The old one is waited out first: it unlinks the socket as it leaves and would
otherwise take its successor's with it.

Also: quitting a daemon that would not close now says its program may have
outlived it, because killing the daemon skips the cleanup that signals the
child — and C-c C-v rather than C-c C-h for the doc buffer, which was
shadowing the way anyone discovers what is under C-c.
2026-09-11 20:26:36 +07:00
76f84071df A signature in the echo area is gone the moment you type
C-c C-h puts what the daemon knows about a name in a buffer instead: kind,
signature, and a button on the place it is written. No new protocol — defs
has carried all four facts since it existed.

Where there is no location it says so in M-.'s own words rather than leaving
the line out, because a missing line reads as "this name has no home" and
the truth is that Tast.global carries no Loc.

imenu and which-function come with it, and neither needs a program running:
they read the buffer, so they work on a file nobody has built yet and keep
working while it is stopped. Anchored at column 0, so a defn inside a let is
not offered as a definition of anything.
2026-09-11 20:23:00 +07:00
ab2a31d002 A dev loop should not need a terminal window
The daemon owns the program's lifetime, so the terminal it was started in
was also the only place that program could be stopped from. M-x flan-dev
builds, launches and connects; M-x flan-dev-quit ends it.

It waits for a connection rather than for the socket file to appear: the
daemon unlinks a stale socket before binding, so waiting on the file either
succeeds instantly against nothing or races the unlink. And when the daemon
dies before binding — which for a program that does not compile is the
ordinary failure — the refusal names its buffer, because that is where the
compiler's reason is and nothing this end sees says it.
2026-09-11 20:17:08 +07:00
5f4005e61d A stopped program, driven from Emacs
The break loop was reachable from a raw socket. This is the half that makes
it reachable from an editor, and it all follows from one fact: a program
stops at a moment nobody asked about.

So the state is learned twice, on purpose. It rides on every reply, beside
the program's output and for the same reason -- the likeliest instant for a
program to stop is the one just after an evaluation, which is a reply the
client is already reading, and learning it a second later from a poll would
mean learning it after the echo area had said the evaluation was fine. And a
timer asks anyway, once a second with `describe', because a program that
stops in a frame of its own game loop produces no reply at all and folding
state into replies that never come says nothing. The timer never reconnects
-- that would quietly erase the `lost' state that exists to be seen -- and
skips while a request is in flight, since accept-process-output runs timers
and a poll firing inside a read would eat that read's reply.

Three ops: `break' for the restart names, `restart' and `abort'. The
annotation owns :stopped and :condition rather than the ops, so one place in
the daemon decides whether the program is stopped and the poll and the prompt
cannot disagree. "ok" from `restart' means accepted, not resumed: the choice
is validated against the stopped stack and taken when that thread next comes
round, so it says so and the client clears its own flag rather than polling
once, finding it stopped, and re-opening the prompt it just answered.

The agent grew one verb, `status', answered in both states. Everything else
the break loop offers is refused while running, rightly; but the question an
editor asks without already knowing had to have an answer either way or there
would be nothing to poll.

And flan_agent_poll had to become re-entrant, which was a bug rather than an
addition. A C-x C-e thunk may itself error, and the break loop that catches
it polls again from inside that call. The old loop cached both indices and
stored tail at the end, rewinding over everything the nested poll consumed --
re-running the thunk that had just stopped the program, which is an unbounded
recursion of breaks. Each job is now claimed before it is run. test_dev.ml
evaluates an expression that errors and resumes it, which fails against the
old shape.
2026-09-11 19:39:29 +07:00
Joseph Ferano
6f3ec2bb91 Keep the indicator, eldoc and the cache to buffers that asked for them
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.
2026-09-11 17:58:34 +07:00
Joseph Ferano
7118d6106d eldoc, completion and M-. off one cached reply
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.
2026-09-11 17:56:37 +07:00
Joseph Ferano
9e7eba1479 Say what landed and what it cost, and flash the form it came from
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.
2026-09-11 17:50:00 +07:00
Joseph Ferano
12f99702b4 Say in the modeline whether there is a program, and reconnect to one
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.
2026-09-11 17:48:06 +07:00
Joseph Ferano
aab6c28450 Show a rejection where it is, on the line it is actually on
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.
2026-09-11 17:46:35 +07:00
8a94f16acd The program's output goes where someone is looking at it
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.
2026-09-11 07:05:50 +07:00
7ce1d09900 C-x C-e: an expression, evaluated inside the running program
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.
2026-09-11 07:00:58 +07:00
52d3898116 Four ways C-c C-c could lie, found by trying a defconst
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.
2026-09-10 22:36:55 +07:00
56395edd59 The Emacs client, and the loop is closed
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.
2026-09-10 22:16:44 +07:00