Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0350b82
chore(diag): renumber E0020-E0022 to free slots for ownership pass
artefactop Jun 3, 2026
4c8ad8e
feat(lexer): add 'move' keyword token
artefactop Jun 3, 2026
62160b4
feat(ast): add is_move flag to Param
artefactop Jun 3, 2026
148283f
feat(parser): accept 'move' before parameter name
artefactop Jun 3, 2026
42090de
feat(uir): thread is_move through UirParam
artefactop Jun 3, 2026
7b4a318
feat(tir): thread is_move through TirParam
artefactop Jun 3, 2026
89eb33b
feat(sema): warn on 'move' annotation for Copy-typed parameters
artefactop Jun 3, 2026
7a1f127
feat(ownership): add ownership module skeleton + pipeline wiring
artefactop Jun 3, 2026
8a1bab6
feat(ownership): Copy/Move classification + state lattice types
artefactop Jun 3, 2026
18d0d03
feat(ownership): forward walk — producers + aliasing reads
artefactop Jun 3, 2026
52ede54
feat(diag): add ownership diagnostic codes
artefactop Jun 3, 2026
96791f3
feat(ownership): VarDecl/Assign consumption + first diagnostics
artefactop Jun 3, 2026
94c34d2
feat(ownership): Return + Call argument consumption
artefactop Jun 3, 2026
e3562e8
fix(ownership): remove redundant call-site UAM check
artefactop Jun 3, 2026
e751882
feat(ownership): if/else CFG joins via snapshot + merge
artefactop Jun 3, 2026
0130bd8
feat(ownership): loop fixed-point analysis
artefactop Jun 4, 2026
e0ca17d
feat(ownership): W0001 dead-store warning for unused Move values
artefactop Jun 4, 2026
f673cce
feat(ownership): multi-label diagnostics with binding names + helps
artefactop Jun 4, 2026
2c5985d
fix(ownership): drop redundant E0020 at consume sites
artefactop Jun 4, 2026
c948300
refactor(ownership): apply /simplify cleanups
artefactop Jun 4, 2026
eccccff
fix(ownership): merge current_owner reassignments per binding
artefactop Jun 4, 2026
20cdd02
fix(pipeline): consolidate diagnostic render path across run/build/ir
artefactop Jun 4, 2026
5f04567
refactor(ownership): unify consume + return state-match via BorrowedA…
artefactop Jun 4, 2026
1c58b47
docs(issues): record M8.1b deferred follow-ups
artefactop Jun 4, 2026
e9b7e27
fix(ownership): emit W0001 dead-store warning on unused reassignments
artefactop Jun 4, 2026
12f1583
refactor(ownership,pipeline): apply /simplify F1 + S3
artefactop Jun 4, 2026
269fb8e
fix(ownership,astgen): address PR review findings
artefactop Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,51 @@ Resolved entries are removed (not kept around as a changelog). Look at `git log`
**Summary:** The runtime staticlib only uses `std::alloc`, `std::process::abort()`, and `eprintln!`, yet linking against precompiled `std` bundles objects with `_Unwind_*` symbol references. This forces the linker to pass `-lunwind` on Linux (workaround in `src/linker.rs`). Migrating to `#![no_std]` with `extern crate alloc` eliminates the dependency entirely.
**Resolution:** Replace `std::alloc` with `alloc::alloc` (identical API). Replace `eprintln!` + `process::abort()` with `extern "C" { fn abort() -> !; }`. Add `#[panic_handler]` that aborts. Keep the `rlib` crate-type for `cargo test` via a `#[cfg(test)]` std gate. `ryu` already supports `no_std`. Benefits: smaller archive, faster link times, no hidden unwind dependency, simpler cross-compilation.

### I-045 — Loop fixed-point uses a scratch sink and re-walks the body to suppress duplicates
**Files:** `src/ownership.rs` (`analyze_while_loop`, `analyze_for_range`)
**Summary:** Both loop helpers walk the body once into a throwaway `DiagSink`, compare entry vs. post-body Moved-ness via `states_differ`, and then either replay the scratch diagnostics (converged) or re-walk the body from the merged state against the real sink (didn't converge). Diagnostic output is implicitly a function of *which iteration emitted it*, not of the converged lattice. Today this happens to work because the M8.1 patterns converge in ≤2 iterations, but the body-twice-with-discard shape couples diagnostic emission to control-flow analysis instead of running checks against a fixed-point.
**Resolution:** Refactor to a propagate-only first pass (state mutations only, no diagnostics), iterate to fixed-point, then a single check pass at the converged lattice that emits diagnostics. Removes the scratch sink entirely and makes the loop helpers symmetric with `analyze_if_stmt`.

### I-047 — UIR `is_move` field is a pass-through
**Files:** `src/uir.rs` (`UirParam`), `src/astgen.rs`, `src/sema.rs`
**Summary:** `is_move` is threaded lexer → parser → AST → UIR → TIR. The UIR copy is never read: astgen propagates the AST flag in, sema reads it back out into `TirParam`, and no UIR pass inspects it. UIR is structural lowering with no semantic meaning, so `UirParam::is_move` is dead weight that exists only to bridge two layers it shouldn't.
**Resolution:** Drop `UirParam::is_move`. Sema can read the flag straight from the AST `FuncBody` (or via a side-channel keyed by FuncBody) when it constructs `TirParam`. Wait until any other UIR-level pass needs the flag before re-introducing it.

### I-048 — Ownership pass looks up call conventions by name at every Call site
**Files:** `src/ownership.rs` (`visit_expr` Call arm), `src/sema.rs`
**Summary:** For every `Call` instruction, the ownership walker rebuilds a `by_name: HashMap<StringId, &Tir>` over all functions and indexes `params[i].is_move` to decide whether each arg should consume or borrow. The map is threaded through nine functions, and builtins need a special "no entry → all borrow" branch. Sema already knows the callee signature when it lowers the call; encoding the per-arg convention into TIR there would let ownership read it directly.
**Resolution:** Add an `arg_modes: ExtraRange` (or a per-arg `ParamMode` enum) alongside `args` in the TIR `Call` view, populated by sema. Ownership then reads `tir.call_view(r).arg_modes[i]` and the `by_name` plumbing disappears. Builtins become uniform (sema stamps `Borrow` for them). Also gives a place to put future indirect-call / fn-pointer conventions.

### I-049 — `synthetic_param_ref` encoding is informal; model `Owner` as an enum
**Files:** `src/ownership.rs` (`synthetic_param_ref`, `Ownership::states`/`origin`)
**Summary:** Parameters live in the same `states: HashMap<TirRef, OwnerState>` as instruction refs by encoding their key as `u32::MAX - name.raw()`. Correctness depends on real per-function `TirRef`s never approaching `u32::MAX/2` and on `name.raw()` never being zero — neither asserted, both relying on convention. A future change to either `TirRef` numbering or `StringId` interning silently breaks the assumption. Cleaner shape: model owners as an explicit `enum Owner { Param(StringId), Inst(TirRef) }` and key the lattice maps on `Owner`.
**Resolution:** Introduce `Owner` and migrate `Ownership::states`/`origin`/`pending_dead_store` and `current_owner` value types over to it. Drops the `synthetic_param_ref` helper, the implicit numbering coupling, and gives a clean place to add `Owner::Borrow(...)` once real borrow expressions land.

### I-050 — Var-arm holds UAM detection; consume sites are silent by policy
**Files:** `src/ownership.rs` (`visit_expr` Var arm, `consume_for_assignment`, `analyze_return`, Call arm)
**Summary:** Use-after-move detection only fires in the `Var` arm of `visit_expr`. The four consume sites (VarDecl, Assign, Return, move-Call) all carry comments explaining why they *don't* re-emit, and the policy works only because every consumable operand currently flows through `Var`. Three reviewers (and one bug fix during M8.1b) flagged this as the wrong altitude: any future producer pattern that bypasses `Var` (e.g., a directly-passed `Call` result that was already moved upstream) silently sidesteps the check.
**Resolution:** Invert responsibility. The consume helper becomes the single authority on UAM (it already inspects `underlying_owner` + state); the `Var` arm restricts itself to bookkeeping (origin link + dead-store clear). Pair the change with regression coverage that exercises a non-Var operand path so the new authority is observably tested.

### I-051 — `analyze_while_loop` / `analyze_for_range` are 90% identical
**Files:** `src/ownership.rs`
**Summary:** After their distinct preludes (visit `cond` vs visit `start` + `end`), the bodies are byte-for-byte the same — entry snapshot, scratch-sink walk, `states_differ` check, optional re-walk, final `merge_two`. Two near-clones of a non-trivial fixed-point loop is exactly where divergence creeps in (only one gets a fix when behavior needs to change).
**Resolution:** Extract `analyze_loop_body(tir, pool, own, sink, by_name, body: &[TirRef])` after the caller has visited the loop's prelude. Folds with I-045 (propagate-only + check pass) — both refactors touch the same bodies.

### I-053 — `OwnerState::Borrowed` is currently parameter-only
**Files:** `src/ownership.rs` (`OwnerState`)
**Summary:** `OwnerState::Borrowed` is set only at parameter init; no expression produces it. The two sites that read it (E0021, E0022) could equivalently look up `tir.params` for the underlying owner's source param and check `is_move`. The state is anticipating real borrow expressions (`&x`) which the spec migrated to in commit 2ccf6b6 but the compiler doesn't lower yet.
**Resolution:** Document the invariant inline ("only ever set at param init in M8.1b; transitions arrive when `&x` borrow expressions land in a future milestone"). No code change today.

### I-054 — `parse_source` and lex error paths bypass `finalize_diags`
**Files:** `src/pipeline.rs` (`parse_source`, `display_tokens`)
**Summary:** `finalize_diags` consolidates the drain + render + `Err(CompilerError::Diagnostics(_))` shape for the sink-using stages (`lower_and_analyze`, `ir_command`). The lex error paths and `parse_source`'s parse-error branch still hand-roll the same pattern over a `Vec<Diag>` they build directly (no `DiagSink`). Drift risk is real: parse/lex paths skip the `Severity::Error` filter (they assume every diag they emit is an error, which holds today but isn't enforced), and any future change to the rendering convention has to be applied in three places.
**Resolution:** Generalize `finalize_diags` to take `Vec<Diag>` (or `impl IntoIterator<Item = Diag>`); have `DiagSink::into_diags()` feed the new entry point. Then the three lex/parse error paths become `finalize_diags(vec![diag], input, &name)` and the render+wrap pattern lives in exactly one place. Folds naturally with I-014's lexer-DiagSink migration.

### I-055 — `pending_dead_store.insert` duplicated between VarDecl and Assign
**Files:** `src/ownership.rs` (`analyze_var_decl`, `analyze_assign`)
**Summary:** Both helpers register a Move-typed binding into `own.pending_dead_store` with `(name, span)` after `rebind_to_init`. The pattern was a single site before commit 20cdd02 added W0001 reassignment coverage; it is now a 2-site duplication. If W0001 ever needs different policy at one site (e.g., distinguishing fresh declaration from reassignment), the duplication will silently allow it.
**Resolution:** Extract `register_pending_dead_store(own, owner, name, span)`, or fold the registration into `rebind_to_init` (which already takes the binding) and have it accept the span/name pair. Either is a 3-line change.

---

## Cross-References
Expand Down
5 changes: 4 additions & 1 deletion src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ impl Statement {
println!("{}FunctionDef: {}", prefix, pool.str(func.name.name));
let inner = format!("{} ", prefix);
for param in &func.params {
let move_prefix = if param.is_move { "move " } else { "" };
println!(
"{}├── param: {}: {}",
"{}├── param: {}{}: {}",
inner,
move_prefix,
pool.str(param.name.name),
pool.str(param.type_annotation.name),
);
Expand Down Expand Up @@ -255,6 +257,7 @@ pub struct FunctionDef {
pub struct Param {
pub name: Ident,
pub type_annotation: TypeExpr,
pub is_move: bool,
pub span: SimpleSpan,
}

Expand Down
3 changes: 2 additions & 1 deletion src/astgen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ fn gen_function_def(
pool,
sink,
),
span: p.name.span,
is_move: p.is_move,
span: p.span,
})
.collect();

Expand Down
31 changes: 30 additions & 1 deletion src/diag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ pub struct Diag {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
#[allow(dead_code)]
Warning,
#[allow(dead_code)]
Note,
Expand Down Expand Up @@ -97,6 +96,10 @@ pub enum DiagCode {
RangeArgType,
/// User attempted to declare a function or variable with a reserved builtin name.
ReservedBuiltinName,
/// `move` annotation on a Copy-typed parameter (int, float, bool).
/// Accepted, but redundant — Copy types are duplicated on
/// assignment regardless. W-prefixed because it's a warning.
RedundantMove,

/// A declaration's resolution requires its own resolution to be
/// already complete — e.g. a chain of decls whose types depend
Expand All @@ -107,6 +110,16 @@ pub enum DiagCode {
/// comptime / inferred-return-type work doesn't stack-overflow.
CycleInResolution,

// --- ownership (M8.1b) ---
/// Use of a value after it has been moved.
UseAfterMove,
/// Attempted to move out of a borrowed parameter.
MoveOutOfBorrowedParam,
/// Attempted to return a borrowed value (Rule 5).
ReturnBorrowedValue,
/// A Move-typed value is declared (or assigned) but never used.
DeadStore,

// --- parser ---
ParseError,

Expand Down Expand Up @@ -140,6 +153,16 @@ impl Diag {
}
}

pub fn warning(span: Span, code: DiagCode, message: impl Into<String>) -> Self {
Diag {
span,
severity: Severity::Warning,
code,
message: message.into(),
notes: Vec::new(),
}
}

#[allow(dead_code)]
pub fn with_note(mut self, span: Option<Span>, message: impl Into<String>) -> Self {
self.notes.push(DiagNote {
Expand All @@ -148,6 +171,12 @@ impl Diag {
});
self
}

/// Top-level help note (no span). Renders with a "help: " prefix
/// so callers don't need to encode the convention.
pub fn with_help(self, message: impl Into<String>) -> Self {
self.with_note(None, format!("help: {}", message.into()))
}
}

/// Accumulator for diagnostics emitted by a single compilation pass.
Expand Down
12 changes: 12 additions & 0 deletions src/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub enum Token {
Else,
Return,
Mut,
Move,
Struct,
Enum,
Match,
Expand Down Expand Up @@ -116,6 +117,7 @@ impl fmt::Display for Token {
Self::Else => write!(f, "else"),
Self::Return => write!(f, "return"),
Self::Mut => write!(f, "mut"),
Self::Move => write!(f, "move"),
Self::Struct => write!(f, "struct"),
Self::Enum => write!(f, "enum"),
Self::Match => write!(f, "match"),
Expand Down Expand Up @@ -191,6 +193,8 @@ pub(crate) enum RawToken<'a> {
Return,
#[token("mut")]
Mut,
#[token("move")]
Move,
#[token("struct")]
Struct,
#[token("enum")]
Expand Down Expand Up @@ -423,6 +427,7 @@ fn intern_token(raw: RawToken<'_>, span: Span, pool: &mut InternPool) -> Result<
RawToken::Else => Token::Else,
RawToken::Return => Token::Return,
RawToken::Mut => Token::Mut,
RawToken::Move => Token::Move,
RawToken::Struct => Token::Struct,
RawToken::Enum => Token::Enum,
RawToken::Match => Token::Match,
Expand Down Expand Up @@ -514,6 +519,13 @@ mod tests {
assert_eq!(toks[7], Token::Match);
}

#[test]
fn lex_move_keyword() {
let (toks, _) = lex_strings("move");
assert_eq!(toks.len(), 1);
assert!(matches!(toks[0], Token::Move));
}

#[test]
fn lex_simple_identifier() {
let (toks, pool) = lex_strings("foo");
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod errors;
mod indent;
mod lexer;
mod linker;
mod ownership;
mod parser;
mod pipeline;
#[allow(dead_code)]
Expand Down
Loading
Loading