Complete the semantic analysis phase (RFC-0023–0028) #20

Merged
crowmaki merged 6 commits from sema/even-more into main 2026-08-26 17:05:50 +00:00
Owner

Finishes the sema phase: check() now runs the full front end, and all 31
tests/fixtures/semantics/ fixtures pass (22 accepted, 9 refused on their marked
line). The .known-red exemption is deleted — cargo test --all is green
(332 passed, 0 failed), clippy is warning-free, and fmt is clean.

Six RFCs, each test-driven, adversarially reviewed, and committed on its own:

  • RFC-0023 — integer promotion, usual arithmetic, assignability, coerce/cast, overload ranking, is + narrowing
  • RFC-0024 — instantiation keys, position-aware lowering, implicit Span and union-typed parameters
  • RFC-0025 — span width derivation, slice formation, for..in
  • RFC-0026 — owned flow tracking, release/delete, destruction order, cycle detection
  • RFC-0027 — ${…} string interpolation (parser + ast + sema)
  • RFC-0028 — wired check(), diagnostic rendering, phase completion

Spec corrections folded in (docs updated with citations)

  • Array assignability is element containment, not T == U (the spec's sum1 requires it).
  • Union-typed parameters are union-passing, not monomorphized (committed is-on-a-union-parameter behavior requires it).
  • owned-rebinding.cat rebuilt around ?*T — field access through a pointer doesn't lower (§ Pointer auto-derefs only method calls).

Deferred (recorded in the manifest)

Interface conformance checking and vtable layout (RFC-0016 q6) — the next phase, which consumes the hir.

Finishes the `sema` phase: `check()` now runs the full front end, and all 31 `tests/fixtures/semantics/` fixtures pass (22 accepted, 9 refused on their marked line). The `.known-red` exemption is deleted — `cargo test --all` is green (332 passed, 0 failed), clippy is warning-free, and fmt is clean. Six RFCs, each test-driven, adversarially reviewed, and committed on its own: - **RFC-0023** — integer promotion, usual arithmetic, assignability, `coerce`/`cast`, overload ranking, `is` + narrowing - **RFC-0024** — instantiation keys, position-aware lowering, implicit `Span` and union-typed parameters - **RFC-0025** — span width derivation, slice formation, `for..in` - **RFC-0026** — `owned` flow tracking, `release`/`delete`, destruction order, cycle detection - **RFC-0027** — `${…}` string interpolation (parser + ast + sema) - **RFC-0028** — wired `check()`, diagnostic rendering, phase completion ### Spec corrections folded in (docs updated with citations) - Array assignability is element *containment*, not `T == U` (the spec's `sum1` requires it). - Union-typed parameters are union-passing, not monomorphized (committed `is`-on-a-union-parameter behavior requires it). - `owned-rebinding.cat` rebuilt around `?*T` — field access through a pointer doesn't lower (§ Pointer auto-derefs only method calls). ### Deferred (recorded in the manifest) Interface conformance checking and vtable layout (RFC-0016 q6) — the next phase, which consumes the `hir`.
Implement the conversion relation the rest of the checker calls: integer
promotion, the usual arithmetic conversions, assignability, and the
`coerce`/`cast` pair that are the only inserters of `hir::Convert` (so code
generation never re-derives an implicit conversion). Build on it the overload
ranking (stage 3 of § Functions ▸ Overload Resolution) and the `is` type test
with two-branch narrowing.

New `sema/src/convert.rs` holds the pure algebra: `promote`, `base_type`,
`usual_arithmetic`, `assignable`, and `conversion_rank`/`Rank`/`better_candidate`
(rank returns `None` exactly where `assignable` is false, so the two cannot
drift). `body.rs` routes `require` through `coerce`, applies usual arithmetic in
`lower_binary`, implements `lower_cast`/`lower_type_test`, and makes `@x` on a
`const` binding a `*const T` so `const-pointer.cat` fires. `infer.rs` carries the
overload stages, including the unannotated-literal rule.

`is` on a union is refused unless the probe is a member or an interface, so a
statically-decidable test never narrows to a type the value can never hold.
Enum operands are peeled to their base before the usual arithmetic conversions,
per the RFC. Every acceptance test from the RFC is present.

Also corrects the RFC's own § Assignability wording: array assignability is
element *containment*, not `T == U` exactly — the spec's `sum1` (`[]float` to
`[](int | float)`) is accepted, which exact-equality would refuse.

`check()` stays the stub; RFC-0028 wires the phase and the fixture driver goes
green there.
Record what code generation would monomorphize, and settle the two forms of
implicit generic the language has.

New `sema/src/generics.rs` holds `InstKey`/`Instantiations` (a deduplicating
set threaded into `hir::Program`), positional `substitute`, and
`infer_type_args` for call-site recovery. `lower_type` gains a `TypePosition`
so a bare `Span` is an implicit generic in annotation/parameter position but
collapses to `Span<size_t>` in a recorded field — including when reached
through an alias (`type W = Span; struct S { w: W; }`), which otherwise left a
dangling inference variable in a non-generic field. A call binds an implicit
`Span` parameter from its argument and records the key; explicit and impl
interface references record theirs.

Follows the codebase's `Var(TypeVar(i))` representation of a type parameter
rather than the RFC draft's `Named`/`ParamId`, documented in the module header
as RFC-0023 documented its `&DefTable` deviation.

A union-typed parameter is checked "valid for every member" at each operator,
with the result the union of per-member results and an invalid member named —
`input * 2` over `number` types as `u32 | s32 | u64 | s64 | f32 | f64`. This
corrects the RFC's drafted reading 2 (monomorphize the body per member), which
cannot coexist with RFC-0023's committed union semantics: `is` narrows a
union-typed parameter in both branches, and monomorphizing it away would leave
no tag to test. The RFC now records reading 1.

Interface conformance stays deferred (RFC-0016 question 6). `check()` stays the
stub; the fixture driver goes green at RFC-0028.
Derive a span's width from its bounds, form a slice from a subscript, and
iterate anything that implements Iterator.

New `sema/src/spans.rs` holds `derive_span_width` (reusing `IntType::smallest_for`
over the reachable index range — `0..256` is `u8` because the exclusive bound is
never reached, `0...256` is `u16`, `-8...8` is `s8`), `check_span_width` for an
explicit `Span<T>`, and `iterator_element`/`iterator_kind`. `body.rs` lowers
`a..b`/`a...b`/`a..` to `SpanLit`, dispatches `arr[span]` to `SliceOf`
(read-only, `Span<size_t>`, negative start rejected) versus `arr[i]` to `Index`,
and resolves `for (x in e)` to its `IterKind`, binding the element type.

Derivation keys signedness on the written bounds, so an empty or backwards span
(`0..0`, `5..0`) stays unsigned. A slice's stored bounds are typed `size_t` and
an explicit span's at its declared width; only the derived path leaves a bound
at its literal type, because the exclusive end of `0..256` legitimately exceeds
the `u8` the reachable range derives.

Completes the fixture edits RFC-0016 scheduled here (`slices`, `spans-inclusive`,
`spans-unbounded`, `object-model-functions`); `spans.cat` was already
self-contained. `check()` stays the stub until RFC-0028.
The one analysis in the phase that is not about types. A flow-sensitive,
intraprocedural, three-state tracker (Live/Empty/Unknown) over owned places
refuses a rebinding that would drop a live owned target silently, per § Memory
Model — with release and delete as the explicit way out.

New `sema/src/ownership.rs`: `Place` roots a field path at a local, a global, or
`this`; `analyze_ownership` sweeps file scope and every function body, running the
state table and filling `Delete.order` and `Block.drops` as it walks;
`destruction_order` is depth-first through owned fields; `check_owned_cycles`
refuses a cycle through non-optional owned pointers (an uninhabitable type) while
letting an `?*T` self-reference — the one legal recursive owned shape — through.

Two soundness rules the design turns on: a loop pre-seeds every owned place its
body assigns to Unknown, so a rebinding across the back-edge is caught rather than
silently dropping the prior iteration's target; and `this`-rooted owned fields
are tracked like a fresh receiver, so an instance method's RAII is checked and
`release this.f` is a real escape hatch.

`owned-rebinding.cat` is rewritten to the only shape that reaches a genuine live
rebind — an `?*T` field assigned twice — because field access through a pointer
does not lower (§ Pointer auto-derefs only method calls) and there is no stack
struct initializer to build a live owned value with. RFC-0016 and RFC-0026 record
why. `check()` stays the stub until RFC-0028, which wires this pass after typing.
The `${...}` holes inside a string literal become real expressions for the first
time. The parser expands an interpolated literal into its parts and re-enters
`expression` for each hole; semantics types the parts and the whole as `string`.

`ast::Expr::StrLit` now carries `Vec<StrPart>` (a `Text` run or a `Hole`
expression) instead of a raw `String`; a hole-free literal is one `Text` part and
dumps as its raw body unchanged, so existing goldens don't churn. The parser's
`expand_string` finds a hole's matching `}` by balancing braces while skipping the
spans of char literals and comments (nesting block comments as the scanner does),
so a `}` inside `'}'` or `// }` no longer closes a hole early; a string literal
still cannot appear in a hole, the one limitation the scanner's raw-body rule
leaves, now recorded as the only one. Hole spans are lifted into file coordinates
so a diagnostic inside a hole points at the source, and a hole must be exactly one
expression.

`sema::lower_str_lit` produces `hir::ExprKind::Str` typed `string`, and RFC-0020's
prelude gains the `string` type it never seeded — an opaque def, enough for
`Type::Named(string, [])` to exist and for a double-quoted literal to have a type.

`check()` stays the stub; RFC-0028 wires the phase.
`check()` runs the whole front end on a real program for the first time:
collect (declarations, prelude, aliases) → owned-cycle check → lower (bodies,
resolution, inference, checking) → ownership over the typed HIR. Cycle checks run
before lowering; ownership runs after typing, so a body that failed to type
raises no spurious ownership error. Every stage appends to one Vec<Diagnostic>;
`check` returns Err only when a diagnostic is an error, so RFC-0020's
prelude-shadowing warning rides along with a successful program. Diagnostics are
sorted by span and deduplicated, and `source::Diagnostic` gains a renderer whose
help line names `release`/`delete`, the way § Memory Model's rule is satisfiable.

Running all 31 fixtures surfaced three constructs earlier RFCs left as `not_yet`
and this one completes: an indirect call through a value of function type
(`contextual-typing.cat`), a numeric literal typed against a union by the
overload rank (`type-tests.cat`), and a `*Struct` forming an interface reference
(`type-tests.cat`) — the last with conformance checking deferred per RFC-0016
question 6, now recorded in the manifest.

Adds the negative fixture `owned-cycle.cat` and its manifest row, adds the
vacuity guard so a checker that drops every item cannot pass, and deletes the
last `.known-red` line. `cargo test --all` is green: 22 positive fixtures accepted
with a non-empty HIR, 9 negative fixtures refused on their marked line. The
semantic-analysis phase is complete.
crowmaki deleted branch sema/even-more 2026-08-26 17:05:50 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crowmaki/catlang!20
No description provided.