Map: core / diagnostics
How every user-visible message — parse errors, type errors, runtime errors, exit-code hints — is located in source and rendered to the terminal.
Source locations — core/src/source.rs
A source position is a Span — a half-open byte range [start, end)
tagged with a FileId, an opaque per-file handle. Line and column are not
carried on IR or AST nodes; they are recovered at render time
by handing the source text directly to ariadne. Where no narrower position is
available, the position is Option<Span> = None uniformly across the
AST, IR, and typechecker. Span::join merges two spans; Span::range yields
the std::ops::Range<usize> ariadne expects. Carrying only byte offsets keeps
the IR free of presentation state.
Runtime source identity — SourceDb
A runtime error carries the span of the node it broke on, not a cursor the
evaluator wrote down. Error (types/error.rs) holds
span: Option<Span> — None at mint, stamped by the break path with the
innermost enclosing node’s span as it unwinds (Error::at_span). Since a
Span is already tagged with its FileId, the source identity is the
value’s own and no ambient position has to be maintained.
Every registered Source carries its SourceOrigin (source.rs): a
File by the native path it was read from, or a Virtual label —
<stdin>, -c, a hook, an embedded profile — for text no file holds.
That path is a SourceFile, UTF-8 by construction: an origin travels
serialized (on a Run, the module stack, the SourceDb) and serde holds a
path only as text, so SourceFile::new — the one door, through
path::utf8 — refuses a path that is not UTF-8, naming it, before the file
is read, rather than letting serialization fail late. Every loader (batch
script, startup file, plugin, use, capability profile, exarch’s config and
network policy) surfaces that refusal as its own error. The
origin is the source’s identity: the module stack’s cycle key, the
directory a script-relative path resolves beside, and, for a file, what
$SCRIPT bakes. The display name a diagnostic draws and a CallSite
carries is derived from it once, at registration: a file’s path
path::shown, a virtual source’s label as given. A host names a run’s
root source by the origin on its Run.
SourceDb (source.rs) resolves that id at render time. It is the registry
of every source the session has loaded, keyed by FileId, living on
SessionState::sources: append-only for the session’s whole life, so a
nested run can never re-mint a FileId an outer run’s live spans still
name. Shell::install_script_context registers a source and
install_root_context additionally seeds SessionState::root_file with the
current run’s root (None between runs); SourceDb::next_id peeks
the id a registration will mint, so a compiler can stamp a program’s spans
before the source they name is itself in the db. Hosts read the db after a
run returns to render.
This is the structural fix for the cross-source caret: a runtime error raised
inside a used module carries the module’s FileId, so the renderer
resolves the module’s text and draws the caret into the module’s bytes —
not the top-level script’s. An id the renderer cannot resolve (the placeholder
FileId::DUMMY, or an unregistered source) renders messageless rather than
indexing an unrelated text. (structural-bug-prevention class 9.)
CallSite (source.rs, resolved by SourceDb::site) is the audit-and-wire shape a Span resolves
to — script name plus 1-indexed (line, col) — which hosts read off every
observation, command or capability check alike. It rides the
audit collector rather than the run frame, so an
observation carries the position of the dispatch that produced it. Both a
command and a function application stamp it, but one whose span resolves
outside the session’s sources — the baked prelude’s — leaves it alone, so
a prelude function’s inner command is stamped where the user called the function. A dispatch with no position is
None (Shell::site_of, Observation.site), and the ral records that
project it — a trail entry, try’s error — carry an optional
site: `some [script, line, col] | `none.
A compile failure carries its own text: Rejection (diagnostic.rs) pairs
the failure’s Reports with their Source and the status a run that failed so
exits on (2 parse, 1 type), instead of registering into the session’s
SourceDb, since a failed compile leaves no live span to justify a permanent,
unreclaimable registry slot. compile::CompileError (Parse or Types)
becomes one with reject. A run that never reached evaluation carries one in
its StaticDiagnostics (run.rs) — Compile(Rejection), beside Host, a
spanless pre-run failure (an unknown hook, a non-ground argument) — whose
render is what every host on the wire prints through Report::Static
(engine-protocol). Compile is not folded into
Error: a loaded file’s parse error would then raise status 2, not 1. A file
loaded at runtime — a used module, the rc, a plugin, a capability profile —
carries its rejection on the runtime Error instead, as Error::rejection,
from CompileError::into_error at the loaders’ one compile door
(modules::check_source). Its message stays the plain sentence a try
handler reads (use: parse error: …), and its span is left to the break
path’s stamp, so $err[site] names the use that failed: the compile span
cannot go there, its peeked FileId being the one the next registration mints.
Rendering — core/src/diagnostic.rs
Each error draws itself; one drawer draws all. diagnostic is the report
algebra: a Report (code, message, primary Label, optional secondary, hint)
and Rejection. Report::render(&Source) is the single ariadne call, with
byte indexing (IndexType::Byte) so a Span is the label’s own coordinate;
caret widens an empty span to one whole char and anchors end of input on the
last char, so every label is drawable. With no primary label, Report::plain
is the compact one-liner. The builders sit with their errors:
ParseError::report in syntax/report.rs (the L000x messages come from
LexErrorKind::headline alone; a plain P0001 carries its own help, the
message saying what is wrong and the help what to write), TypeError::report in
typecheck/explain.rs, and Error::render / Error::compact
(types/error.rs, resolving the error’s Span against a SourceDb).
Error::render dispatches on where the error came from, not what the input
looked like: a carried rejection draws its own report, so a broken plugin,
module, rc or profile prints, at every door, the report its own run as a script
would. Otherwise it takes compact_root: Option<FileId> — Some(root) when
the input compiled to a single command, carrying that input’s own id — and
renders compact only when the error’s span is absent or names root. A single
command that dispatches into an rc alias, a used function or a lambda from an
earlier run faults in text the user cannot see, so it gets the caret.
The shell’s own stderr lines sit below the renderer, in
terminal/stderr.rs: cmd_error, shell_warning, and the macros outln! /
err! / errln!, exported for the front ends’ own output too. Unlike
println! and eprintln! they drop a failed write rather than panic, since
once the terminal hangs up every write fails with EIO; clippy.toml bans the
std pair.
The raw ingredients of a span underline are exposed so an external renderer
can draw one in its own coordinate system. text::byte_to_char
(core/src/text.rs, byte offset →
character offset, the unit ariadne and a TextArea cursor both count in) and
TypeErrorKind::render_label (typecheck/explain.rs, a kind → its under-caret
label phrase) are pub. The structural frontend reuses
them to paint an in-place type-error underline whose label and caret agree
word-for-word and column-for-column with the post-Enter ariadne report — the
inline rendering belongs to that page, not here. text.rs is also the single
home of the nucleo fuzzy matcher (rank, and rank_by for an item that is
not its own haystack), which serves exarch’s pickers and command popup. Type-error prose generally
lives beside the checker: provenance is data on the error (Reason,
typecheck/error.rs) and every user-facing sentence is a pure function of it
in typecheck/explain.rs (typecheck).
Styling and the colour gate — core/src/ansi.rs, core/src/terminal.rs
ansi is vocabulary only: escape constants, the OSC helpers (osc_set_title,
osc8_link), escape_seq_len, strip, visible, when.
One colour decision, made once: a front end probes a TerminalState and
seats it (TerminalState::seat, first wins); terminal::stderr_color and
terminal::ui_color read that seat. A wire engine seats its EngineSpec.terminal,
the front end’s, never a probe of its own null stdio. Before any seat,
stderr_color answers from one memoised Auto probe, so early-startup errors
still colour, and ui_color is false: only a front end has a UI. The
$TERMINAL map is impl From<&TerminalState> for Value (types/value.rs).
Value-output styling (the REPL’s => prefix) lives instead in the ral
crate’s repl::theme.
Exit-code hints — core/src/types/exit_hints.rs
ExitHints is a pure (command basename, exit-status) → explanation lookup table
(two maps, * the wildcard),
populated via from_text and installed into the Shell; lookup is consulted
when an external command fails. Loading the table is the caller’s concern.
Debug tracing — dbg_trace!
dbg_trace!(tag, …) is the single developer-facing trace primitive: a tagged
stderr line in debug builds (red only where the terminal::stderr_color gate allows,
and through errln!, so never a panic),
nothing in release, no environment switch for the trace itself
(one-debug-path). Its call sites are
permanent instrumentation.