Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
168 changes: 142 additions & 26 deletions crates/allium-parser/src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4418,12 +4418,21 @@ impl Ctx<'_> {
match &item.kind {
BlockItemKind::ForBlock {
binding,
collection,
items,
..
} => {
check_unbound_source_expr(
collection,
&bound,
rule_name,
&mut self.diagnostics,
);
let mut inner_bound = bound.clone();
match binding {
ForBinding::Single(id) => { inner_bound.insert(&id.name); }
ForBinding::Single(id) => {
inner_bound.insert(&id.name);
}
ForBinding::Destructured(ids, _) => {
for id in ids {
inner_bound.insert(&id.name);
Expand All @@ -4433,7 +4442,12 @@ impl Ctx<'_> {
for sub_item in items {
if let BlockItemKind::Clause { keyword, value } = &sub_item.kind {
if keyword == "ensures" || keyword == "requires" {
check_unbound_roots(value, &inner_bound, rule_name, &mut self.diagnostics);
check_unbound_roots(
value,
&inner_bound,
rule_name,
&mut self.diagnostics,
);
}
}
}
Expand Down Expand Up @@ -4485,8 +4499,14 @@ fn collect_bound_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
}
Expr::Call { args, .. } => {
for arg in args {
if let CallArg::Positional(Expr::Ident(id)) = arg {
out.insert(&id.name);
match arg {
CallArg::Positional(Expr::Ident(id)) => {
out.insert(&id.name);
}
CallArg::Named(named) if is_type_annotation_expr(&named.value) => {
out.insert(&named.name.name);
}
_ => {}
}
}
}
Expand All @@ -4498,6 +4518,21 @@ fn collect_bound_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
}
}

fn is_type_annotation_expr(expr: &Expr) -> bool {
match expr {
Expr::Ident(id) => starts_uppercase(&id.name),
Expr::QualifiedName(q) => starts_uppercase(&q.name),
Expr::GenericType { name, args, .. } => {
is_type_annotation_expr(name) && args.iter().all(is_type_annotation_expr)
}
Expr::Pipe { left, right, .. } => {
is_type_annotation_expr(left) && is_type_annotation_expr(right)
}
Expr::TypeOptional { inner, .. } => is_type_annotation_expr(inner),
_ => false,
}
}

fn check_unbound_roots(
expr: &Expr,
bound: &HashSet<&str>,
Expand All @@ -4507,21 +4542,7 @@ fn check_unbound_roots(
match expr {
Expr::MemberAccess { object, .. } => {
if let Expr::Ident(id) = object.as_ref() {
if !starts_uppercase(&id.name)
&& !bound.contains(id.name.as_str())
&& !is_builtin_name(&id.name)
{
diagnostics.push(
Diagnostic::error(
id.span,
format!(
"Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
id.name
),
)
.with_code("allium.rule.undefinedBinding"),
);
}
push_unbound_root_ident(id, bound, rule_name, diagnostics);
}
}
Expr::Comparison { left, right, .. } => {
Expand All @@ -4544,11 +4565,13 @@ fn check_unbound_roots(
}
}
Expr::For { binding, collection, body, .. } => {
check_unbound_roots(collection, bound, rule_name, diagnostics);
check_unbound_source_expr(collection, bound, rule_name, diagnostics);
// Skip filter (where clause) — fields are implicitly scoped to the binding
let mut inner = bound.clone();
match binding {
ForBinding::Single(id) => { inner.insert(id.name.as_str()); }
ForBinding::Single(id) => {
inner.insert(id.name.as_str());
}
ForBinding::Destructured(ids, _) => {
for id in ids {
inner.insert(id.name.as_str());
Expand Down Expand Up @@ -4583,15 +4606,18 @@ fn check_unbound_roots(
CallArg::Positional(e) => {
check_unbound_roots(e, &call_bound, rule_name, diagnostics);
}
CallArg::Named(n) => check_unbound_roots(&n.value, &call_bound, rule_name, diagnostics),
CallArg::Named(n) => {
check_unbound_roots(&n.value, &call_bound, rule_name, diagnostics)
}
}
}
}
Expr::Not { operand, .. }
| Expr::Exists { operand, .. }
| Expr::NotExists { operand, .. } => {
Expr::Not { operand, .. } => {
check_unbound_roots(operand, bound, rule_name, diagnostics);
}
Expr::Exists { operand, .. } | Expr::NotExists { operand, .. } => {
check_unbound_source_expr(operand, bound, rule_name, diagnostics);
}
Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
check_unbound_roots(element, bound, rule_name, diagnostics);
check_unbound_roots(collection, bound, rule_name, diagnostics);
Expand All @@ -4609,8 +4635,49 @@ fn check_unbound_roots(
}
}

fn check_unbound_source_expr(
expr: &Expr,
bound: &HashSet<&str>,
rule_name: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
if let Expr::Ident(id) = expr {
push_unbound_root_ident(id, bound, rule_name, diagnostics);
} else {
check_unbound_roots(expr, bound, rule_name, diagnostics);
}
}

fn push_unbound_root_ident(
id: &Ident,
bound: &HashSet<&str>,
rule_name: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
if starts_uppercase(&id.name)
|| bound.contains(id.name.as_str())
|| is_builtin_name(&id.name)
{
return;
}

diagnostics.push(
Diagnostic::error(
id.span,
format!(
"Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
id.name
),
)
.with_code("allium.rule.undefinedBinding"),
);
}

fn is_builtin_name(name: &str) -> bool {
matches!(name, "config" | "now" | "this" | "within" | "true" | "false" | "null")
matches!(
name,
"config" | "now" | "this" | "within" | "true" | "false" | "null"
)
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -5092,6 +5159,55 @@ mod tests {
assert!(!has_code(&ds, "allium.surface.unusedBinding"));
}

// -- Rule bindings --

#[test]
fn typed_call_trigger_param_is_bound() {
let ds = analyze_src(
"entity Account {\n name: String\n}\n\n\
entity Greeting {\n label: String\n}\n\n\
rule TypedParam {\n when: AccountSeen(account: Account)\n \
ensures: Greeting.created(label: account.name)\n}\n",
);
assert!(!has_code(&ds, "allium.rule.undefinedBinding"));
}

#[test]
fn generic_typed_call_trigger_param_is_bound() {
let ds = analyze_src(
"entity Account {\n name: String\n}\n\n\
entity Greeting {\n label: String\n}\n\n\
rule GenericTypedParam {\n when: AccountsSeen(accounts: List<Account>)\n \
ensures: Greeting.created(label: accounts.count)\n}\n",
);
assert!(!has_code(&ds, "allium.rule.undefinedBinding"));
}

#[test]
fn named_literal_trigger_arg_is_not_binding() {
let ds = analyze_src(
"rule LiteralFilter {\n when: CommandInvoked(name: \"npm run test\")\n \
requires: name.status = active\n ensures: Done()\n}\n",
);
assert!(has_code(&ds, "allium.rule.undefinedBinding"));
}

#[test]
fn exists_unbound_operand_is_reported() {
let ds = analyze_src(
"rule Check {\n when: Ping()\n requires: exists candidate\n ensures: Done()\n}\n",
);
assert!(has_code(&ds, "allium.rule.undefinedBinding"));
}

#[test]
fn for_block_unbound_collection_is_reported() {
let ds = analyze_src(
"rule Iterate {\n when: Ping()\n for item in items:\n ensures: Done()\n}\n",
);
assert!(has_code(&ds, "allium.rule.undefinedBinding"));
}

// -- Status state machine --

#[test]
Expand Down
14 changes: 11 additions & 3 deletions crates/allium-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn parse(source: &str) -> String {
let result = allium_parser::parse(source);
serde_json::to_string(&result).unwrap_or_else(|e| {
format!(r#"{{"error":"serialisation failed: {e}"}}"#)
})
serde_json::to_string(&result)
.unwrap_or_else(|e| format!(r#"{{"error":"serialisation failed: {e}"}}"#))
}

/// Run semantic analysis over an Allium source string and return diagnostics as JSON.
#[wasm_bindgen]
pub fn analyze(source: &str) -> String {
let result = allium_parser::parse(source);
let diagnostics = allium_parser::analyze(&result.module, source);
serde_json::to_string(&diagnostics)
.unwrap_or_else(|e| format!(r#"{{"error":"serialisation failed: {e}"}}"#))
}
12 changes: 8 additions & 4 deletions docs/project/rust-checker-parity.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
# Rust checker parity: analysis and remaining work

The Rust CLI (`allium check`) now has a semantic analysis pass in `crates/allium-parser/src/analysis.rs`. It implements 16 diagnostic checks with `-- allium-ignore` suppression. This document describes the remaining gaps against the TypeScript reference implementation and the issues discovered during validation.
The Rust CLI (`allium check`) now has a semantic analysis pass in `crates/allium-parser/src/analysis.rs`. It implements 16 diagnostic checks with `-- allium-ignore` suppression. This document describes the remaining gaps against editor/LSP diagnostics and the issues discovered during validation.

## Architecture

The TypeScript analyzer:
- `extensions/allium/src/language-tools/analyzer.ts` — drives the VS Code extension and LSP
- `extensions/allium/src/language-tools/analyzer.ts` — drives the VS Code extension and LSP. Most editor diagnostics still live here, but migrated checks can delegate to Rust via the WASM package.

The Rust analyzer:
- `crates/allium-parser/src/analysis.rs` — drives the Rust CLI (`allium check`)
- `crates/allium-parser/src/analysis.rs` — drives the Rust CLI (`allium check`) and exposes migrated diagnostics to TypeScript through `crates/allium-wasm`.

The TypeScript version uses regex over raw source text. The Rust version walks the typed AST produced by the parser. The Rust approach is structurally more reliable but needs to handle all AST node shapes the parser produces.

`allium.rule.undefinedBinding` is now a migrated check: the editor/LSP path calls Rust analysis through WASM and maps the Rust diagnostic spans back into TypeScript findings, instead of maintaining a parallel TypeScript rule-binding parser.

The language reference at `docs/allium-v3-language-reference.md` is the definitive source for language semantics. When the two implementations disagree, consult it.

## Validation test beds
Expand Down Expand Up @@ -56,6 +58,8 @@ Per language reference rule 1: "All referenced entities and values exist." This

`check_unbound_roots` now accumulates `LetExpr` bindings when walking `Block` expressions, so variables defined by `let` in ensures blocks are in scope for subsequent expressions. Also added `Expr::For` handling (with binding scope) and `Expr::BinaryOp` recursion.

The editor/LSP diagnostic for this code is sourced from the Rust analyzer via WASM, so the binding rules have one implementation across CLI and TypeScript surfaces.

### 2. `rule.unreachableTrigger` — 20 now matching

Replaced the deep expression walker (`collect_call_names`) with `collect_leading_ensures_call` for the emitted trigger set. The new function only collects the first `Call` expression in each ensures clause value, matching the TS regex which captures only the first identifier after `ensures:`. Also restricted collection to `ensures` clauses only (not requires/when).
Expand Down Expand Up @@ -207,7 +211,7 @@ issue #19:
| `allium.definition.unused` | warning | Yes | Yes |
| `allium.deferred.missingLocationHint` | warning | Yes | Yes |
| `allium.rule.invalidTrigger` | error | Yes | Yes |
| `allium.rule.undefinedBinding` | error | Yes | Yes |
| `allium.rule.undefinedBinding` | error | Yes | Yes (via Rust/WASM) |
| `allium.let.duplicateBinding` | error | Yes | Yes |
| `allium.config.undefinedReference` | warning | Yes | Yes |
| `allium.surface.unusedPath` | info | Disabled | Yes |
Expand Down
Loading
Loading