Map: core / typecheck
core/src/typecheck/ is Hindley–Milner inference over the CBPV
IR. Types sit on Val and Comp after
elaboration.
Entry points (typecheck.rs):
typecheck(top: &Toplevel, SessionSchemes) -> Result<Toplevel, Vec<TypeError>>— this function checks a program: infer each phrase in order, extendingTyEnvat eachDefine, thenannotate::annotate_toplevelwrites the verdict back, on success returning an annotatedToplevel.annotatewrites two things onto the rebuilt IR: a generalisedSchemeper name aPhrase::Definebinds — landed on its own phrase, not a shared spine — resolved against the final unifier and closed by quantifying its residuals, that is, generalised against the empty environment; and aCapture/Decodepair around each computation a value demand captured (ir). A pipeline’s value is its final stage’s, so there is nothing per-pipeline to annotate (pipes-are-positional-byte-wires). The seed for a check is oneSessionSchemes { bindings, aliases, builtins }(session-scheme-continuity): the scope’s name-to-Option<Arc<Scheme>>map, the alias arms’ schemes, and the shell’sManifest— Σ, the checker’s own view of the builtins: oneDeclper row (name,Convention, doc, type rule, diagnostic, boundary flag, and one cached curry spine for arity and settle-at-unit), never a body. The table projects each installed set into it once;SessionSchemes::new(manifest)andSessionSchemes::from_prelude(&Toplevel, manifest)build a seed with no live shell. A scheme never changes once built, so oneArccarries it from theTyEnvontoPhrase::Define, into the scope’sBinding, and back into the next run’sTyEnv: neither seeding nor a lookup copies one. Builtins are shell-scoped, so the checker types against exactly the surface the booted shell dispatches (builtins).seed_envis the one seeding routine, and the manifest reaches it as two: a table entry’s rule, and a base-frame row’s scheme, which lands in the env so thatlookup_handlerfinds a base frame as it finds a handler (argv-is-a-list-of-strings). Every name the seed and the program bind is therefore known to the checker: a$nameoutside them isUnboundVariable(T0071), and a binding of a non-thunk run as a program (bare head,$name args,!$name) isHeadBoundToValue(T0072) (type-inference).bake_prelude(phrases, &Manifest) -> Toplevel— called byboot::bake_prelude_to_out_dirfrom each host’s build script: returns the annotated preludeTopleveland nothing else. Every phrase already carries its schemes, andSessionSchemes::from_preludereads them back throughToplevel::exported_schemes, so no second blob of schemes is baked.alias_arm_scheme(head, param, body, SessionSchemes) -> Result<Scheme, Box<TypeError>>— infers an alias arm under the runtime handler calling convention, holds it to whatheadstands in for (Inferencer::stands_in), and closes it, forinstall_aliasandWithinScope::parseto store on a frame. A handler or alias arm is a fixed-arity lambda — its calling convention is the surface form, not the runtime value’s shape, soparamis non-optional andinfer_alias_armtypes the armFun(List(elem), body), forcing it on the argv list (fixed-arity, handlers-and-aliases-are-lambdas). Staticallyinfer_handler_compstill types a non-Lamthunk (e.g. a computedalias g $h) by its bare body, binding it sog xis an arity mismatch rather than a silently discarded argument; the runtime install boundary is the sole complete gate on shape.
The sorts split with CBPV:
- value types
Tydescribe data; - computation types
CompTyareReturn(Ty),Fun(Ty, CompTy), andVar; - records are open-row-polymorphic (row-types:
Row/RowVar).
Generalisation happens at Bind; recursive bindings (LetRec / Rec) stay
monomorphic to keep generalisation sound.
Internals:
infer.rs— theInferencer;infer_comp;grade.rs— the grade rules, as oneimpl Inferencer: the bind rule (rhs_bound_ty), a block meeting a demand of the other producer kind (adaptto anAdaptation,coerce), the join of a form’s arms (join_arms;join_targettakes the greatestVerdict), and a stdout redirect’sdischarge.spinereads aCompTyas aSpine(ty.rs, ty: parameters and tail;producer()theProducerpast them,overthe same parameters on another tail;CompTy::arrowsis the constructor);index.rs— the deferred readsLbl(a bare label) andIdx(a computed key): ownsInferCtx::pending_labelsandpending_indexesand their settlement. A pending label is closed as a record at the boundary that owns it; anIdx’s variables are weak, nothing settles it at a generalisation, andsettle_pending_indexesdrains both at the unit’s end and refuses what is left (IndexContainerUnknown, T0075);unify.rs—Unifier; binding refuses a cycle that crosses no data (CyclicType, T0073) and a head its variable’s kind does not admit (KindMismatch, T0074); the weak set (mark_weak, inherited throughunite), whichgeneralize.rssubtracts,seed_envre-seeds (reseed_weak) andtypechecksettles (settle_weak). Each union-find store is typed by its sort (Store<T: Unifiable>, keyed byT::Var:TyVar,CompTyVar,RowVar,GradeVar), so a type root cannot index the computation store, andScheme’s cyclic bindings andVisitedcarry typed roots too.reachis the one guarded walk over the variables under aty::Term(whosechildrenis the one enumeration of sub-terms): free-variable collection and the weak-source search are both a fold over it;Site(data incore/src/ty/site.rs, built bytypecheck/site.rs, walked and admitted bycore/src/types/admit.rs) — the type solved at a boundary call, frozen at the unit’s end byInferCtx::snapshot_sitesand written onto theExecbyannotate;Site::exportshands the module door one scratch unifier over a whole export record, so export types that share variables are checked together; the unifier keeps the span that first bound each variable so a refusal can point at the use that imposed what the value met;- the type language (
Ty,CompTy,Grade, rows,Kind,Scheme,Sitedata) is ty, below this module; error.rs— the error taxonomy:TypeError/TypeErrorKind, with constraint provenance as data (Reason,Standing,UnitCall);explain.rs— the single home of every user-facing type-checker sentence (hints andTypeErrorKind::render_label), a pure function of the error data so each message is unit-testable. Its wildcard-freeReasonmatch gives each reason prose or deliberately lists it as hintless: the constraint’s other side is fresh, or the error kind is already its own diagnosis;annotate.rs— the write-back pass (annotate) that rebuilds the checked IR with schemes, boundary sites, stage types, andCapture/Decodenodes; its side tables inInferCtxare keyed byNode, a node address taggedComp,ValorGroup;generalize.rs;env.rs—TyEnv,InferCtx;site.rs—impl Site { snapshot, instantiate, exports }: freezing the solved type at a boundary, and the scratch unifier the module door checks exports in; type display isDisplayforTyandScheme(core/src/ty/fmt.rs);builtins.rs— Σ:Decl, a manifest row minus its body (name,Convention, doc, type rule, diagnostic,boundary, and one lazily computedSpineof arity and settles-at-Unit), andManifest, the sets of them a table projects at install; the scheme factory each entry names, and the readings taken off it (fixed_arity,builtin_type_hint), which is where fixed-arity is enforced: every entry in the table declares its arguments, so it has an arity and a value form.audit_recordtypesaudit { }’s report as{outcome: ``ok v |err E``, trail: [Observation]};observed_tygives eachObservation’swhata closed variant, one arm per kind (command,write,read,grep,check,worker,act), each its own closed record — so reading a fact’s fields is ordinary row typing rather than aMaplookup audit;scope.rs— the five structural scope nodes, andcheck_options: the rule that holds each written option ofwithinandgrantto a declared table, key by key — unknown key T0076, refused key T0026, a clash at the key’s own sentence.Holds::CatchAll(within’shandler) alone takes contextual handler inference at the written form;Holds::Shapedinfers an ordinary value and unifies it with the minted type, andHolds::Tablequeues the value’s nested check with its span (the value is inferred normally and never constrained). One helper builds the expected type for inline checking and scratch ascription;contract.rs— the declared tables themselves (within,grant, the rc file, the plugin manifest), each a closed keyset whose labels are held at a ground type, left to their decoder, or refused with a sentence of their own. A table’s key holds a ground type, a shaped type minted from the Rust enums (EditorVerb,ShellVerb,XdgKind), a nestedTableor a catch-all.ascribeholds a contract file’s finished return to its table, after inference and in a scratch copy of the unifier (unknown key T0076, not a record T0077, and the row errors); a nested table is ascribed by the same recursive function, from the queuetypecheckdrains once, after the pending labels settle and before the contract ascription (a map or()where a nested table belongs is refused as a non-record). The one-shot sessions drop their queue unread. A clash at a closed row names the row’s tags and the nearest one. It is the single registration point, so the runtime doors that read the same keysets —RcPlan::decode,LoadedPlugin::parse,decode_capability_map— dispatch off it rather than each keeping a copy. No table reaches the unifier (a-table-never-enters-the-unifier).
infer.rs’s infer_case is left whole by decision
(infer-case-stays-whole). Its one
companion, infer_case_arm, is a premise of the rule rather than a surface
helper: an arm is syntax, so typing one — bind its pattern, infer its body,
force its payload to agree with the scrutinee at that label — is a judgment
that stands alone
(case-is-syntax-try-is-not).
The argv rule, and the exec gate
argv_ty (infer.rs) is the one rule for every argv boundary — a handler arm, a
base frame, an external — and yields Ty::argv(), List String: each element is
inferred under its own span for errors inside it — as is each list-literal
element and each map-literal value — and constrains the argv not at all, a ...
must still spread a list, and every element crosses rendered
(argv-is-a-list-of-strings).
It carries which boundary it is at — ArgvBoundary::InShell or
Exec(shown) — and that is the whole of the difference between them. Exec
sends each written element through gate_exec_arg, which reads
RefusedArg::of_ty (core/src/ty/exec_arg.rs) — the same declaration
runtime::command::vet reads at the spawn — and raises
TypeErrorKind::ExecArgNotText (T0057) where the resolved shape is refused,
saying nothing about a type variable or about a spread’s elements.
explain.rs composes the message and takes the guidance from
RefusedArg::remedy, so the static refusal and the pre-spawn one carry one
sentence per shape
(exec-argv-is-words,
exec-boundary-gated-statically).
The pipeline rule
infer_pipeline (infer.rs) has no adjacency loop. It infers each stage,
forces it to ready Return shape with force_ready_shape under
Reason::PipelineStageShape, and returns the final stage’s value as the
pipeline’s own. Every stage but the last must write (stage_writes): it is
accepted at Unit in any grade, but a decoder is refused by its own mark
(stage_decoder, DecoderMidPipeline, T0078), and a value or block literal in
stage position under Reason::PipelineStageWrites (T0011). A stage typed Fun is a function still waiting for an argument; the
hint says to apply it rather than pipe into it.
One further premise, about a stage’s redirects rather than its type: past the
first position, stage_root_stdin_feed reads the stage’s root — an Exec’s
fused redirects, a ScopeOp::Redirect frame, or the same past the binders
elaboration hoists out of a redirect target — and a < f or << w on fd 0
there is TypeErrorKind::DeadPipeEdge (T0070), whose StdinFeed names which
of the two the message spells. Nothing else about a stage is checked, and
nothing inspects an Ast node to decide whether a pipeline is well formed — so
an unforced block literal in stage position is an ordinary value-returning
stage, accepted, and a read nested inside a stage is left alone.
Capture by type
A command is a computation of type F^w Unit, printed Command; its value is
its output (graded-f). CompTy::Return(Grade, Ty) carries Grade::{Value, Output, Var}; the grade store lives in unify.rs
beside the type, computation and row stores, Scheme.grade_vars quantifies it,
and force_return_shape opens a computation variable into a fresh grade.
HeadClass and head_class live in infer.rs, beside exec_comp_ty: they give
a bare head the class the runtime’s lookup order gives it: Binding,
Value(entry) (a builtin row), Arm (a handler or base frame standing in for
a head), or External. No separate pass decides capture.
grade.rs holds the grade rules. rhs_bound_ty (the Bind and
Phrase::Define right-hand side) is the bind rule: Return(Output, _) records
the right-hand side’s address in InferCtx::captured and binds String,
Return(Value, a) binds a, and Return(Var(g), a) binds g := Value and
a. adapt says how a block meets a demand of the other producer kind — a
command where a value is wanted is Adaptation::Capture, a () producer
where a command is wanted is Adaptation::Upcast — and coerce records a
captured block and unifies the type it is read at with the demand, reporting
a failure between what the two produce past their parameters
(InferCtx::unify_comp_ty_as). capture_block records a literal block by
its body under exactly its arity of written lambdas in captured; anything
else — a value in hand, or a literal whose arrows are not all written, map { !$f } $xs — goes in InferCtx::captured_vals with its arity and is η-wrapped
whole, since a Capture frame between a lambda and its pending argument would
drop the lambda. apply_args_capped applies the two to a block argument, and
join_arms to each arm against the join. discharge turns a redirected
stdout’s grade to Value.
annotate.rs is a plain structural rebuild, the methods of Annotate { ctx, eta }.
It wraps each recorded node through the one constructor captured_string,
which builds Capture(body) to x. Decode(x) — x a fresh name from
InferCtx::fresh_name — with the captured node’s span on both the bind and
the decode; a recorded value in hand becomes a lambda whose body captures the
forced call. CompKind::Capture(body) types as F Bytes with body unified
to F^w Unit; CompKind::Decode(val) types as F String with val unified to
Bytes. Both rules fire only when re-inferring a tree that already carries
annotate-inserted nodes — a stored handler or thunk re-checked at a later
install. Where a computation is held as a function of unknown arity,
eta_expand_arrow η-expands it so every function-typed thunk’s body is a
syntactic Lam.
Arms and joins
{ … } is a thunk in every position: if and case carry Spanned<Val>
arms (ir), and check_arm types the one it is handed against
the type its form’s arms share. join_arms (grade.rs; if, infer_case
and infer_try all route through it) joins arms in two passes: each arm is
inferred against a fresh result, then join_target settles the join on the
greatest of the arms’ Verdicts — Value (F^p τ), Command (F^w Unit)
or Open (plain unification) — and each arm meets it as a block meets a
demand (adapt, coerce), command arms last so a disagreement is reported
against the value the other arms fixed; a () arm stands as a command only
where the join is one. A disagreement
is T0010 / T0020 between two values; T0011 is a disagreement of shape or
grade. The join reasons — IfBranches, CaseArms, TryArms (shared by try
and ?, which elaborates to nested try) — carry no writer: explain.rs
derives the hint from the two types, saying that one arm is a command, whose
value is its output.
Inferencer::stands_in(name, arm) holds an arm to the command it stands in
for. stands_for is the scheme of the base frame or arm already in force
under name, else [String] → Command; Reason::StandsIn(Standing) names
which (Standing::{Command, Own, EveryCommand}). catch_all_stands_in is the
same check for the catch-all handler:. alias_arm_scheme
(typecheck.rs) and the free catch_all_stands_in apply them at the install
door, and HandlerEntry::vet / parse_catch_all render the error through
types::refused_arm.
Display and diagnostics
fmt_comp_ty_ctx (fmt.rs) renders Return(Value, A) as Returns A,
Return(Output, Unit) as Command, and Return(Var, A) as ν A with the
grade drawn from ν ξ ο π (FmtCtx::grade_names); a block that prints is
{Command}, one that returns text {Returns String}. Open
variant rows mark their tail with the same backtick as their arms
([`...] / [`...ρ]), while record tails stay [...] / [...ρ].
TypeErrorKind::CompTyMismatch { expected, actual } is T0011, with
CommandNotFunction; T0012 is retired and never renumbered. The two
constraint notes beyond the join hint are data on the error: Reason:: PipelineStageWrites { stage, next } (T0011) says a stage feeds the next by
writing, DecoderMidPipeline (T0078) that a decoder ends the byte pipeline, and TypeError.unit (UnitCall, recorded by note_unit_read from
TyEnv::note_called / InferCtx::unit_reads) says that a name let bound to
what a call returned is (), and what to write instead. () is neither a word
nor an interpolant (kind scalar, RefusedArg::Unit).
docs/SPEC.md has the typing judgments.