Skip to content
Merged
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
39 changes: 28 additions & 11 deletions crates/allium-parser/src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4343,10 +4343,11 @@ impl Ctx<'_> {
// `^\s*deferred\s+([A-Za-z_][A-Za-z0-9_.]*)(.*)$` and applies the
// predicate to the suffix after the captured name. Replay that match
// from the `deferred` keyword rather than trusting the parsed path's
// span — the parsed expression can extend past the flat name (qualified
// `alias/Name` paths, expression-shaped paths like `Foo("x")`) or start
// after it (`deferred (Foo)`), which would move the suffix boundary and
// flip the verdict. Scanning the suffix (not the whole line) matters for
// span — the path grammar stops before a dangling `.` that the
// TypeScript capture includes (`deferred Foo.`), and a qualified
// `alias/Name` path extends past the flat name the capture stops at —
// either would move the suffix boundary and flip the verdict or the
// reported name. Scanning the suffix (not the whole line) matters for
// the URL markers, whose leading letters would otherwise be misread as
// part of an unspaced path (e.g. `Foohttps://x`).
// The JavaScript `m` flag anchors `^`/`$` at `\n`, `\r`, U+2028 and
Expand Down Expand Up @@ -6258,16 +6259,31 @@ mod tests {

#[test]
fn deferred_expression_path_with_quote_suppresses() {
// The parsed path of an expression-shaped deferred declaration extends past
// the flat name, so a suffix scan anchored at `path.span().end` would miss
// the quote. The check replays the TypeScript name capture instead, so the
// quote lands in the suffix and suppresses — matching the TS analyzer.
// An expression-shaped line still forms a `DeferredDecl` for the flat
// name (the leftover tokens error at declaration level), and the
// replayed TypeScript capture puts the quote in the suffix — so the
// warning stays suppressed, matching the TS analyzer's regex lane.
let ds = analyze_src("deferred Foo(\"x\")\n");
assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
let ds = analyze_src("deferred Foo = \"x\"\n");
assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
}

#[test]
fn deferred_trailing_dot_warns_with_captured_name() {
// The path grammar stops before the dangling `.`, so the declaration
// still forms and the hint check runs; the TypeScript capture includes
// the dot, so both sides warn under `Dangling.` (plus a parse error on
// the leftover dot, identical in both front ends).
let ds = analyze_src("deferred Dangling.\n");
let hints: Vec<&Diagnostic> = ds
.iter()
.filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
.collect();
assert_eq!(hints.len(), 1);
assert!(hints[0].message.contains("'Dangling.'"));
}

#[test]
fn deferred_lone_cr_is_a_line_boundary() {
// JavaScript `m`-flag anchors treat a bare `\r` as a line terminator while
Expand All @@ -6286,9 +6302,10 @@ mod tests {

#[test]
fn deferred_unmatchable_path_stays_silent() {
// The TypeScript regex requires `deferred` + whitespace + `[A-Za-z_]`; a
// parenthesised path never matches it (the paren is not part of the parsed
// path's span, so anchoring on the span would wrongly find a name).
// A parenthesised path fails the path grammar (`expected deferred
// name`), so no `DeferredDecl` forms and no warning fires; the
// TypeScript regex lane never matches the paren either. Both sides
// surface the same parse error instead.
let ds = analyze_src("deferred (Foo)\n");
assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
}
Expand Down
9 changes: 8 additions & 1 deletion crates/allium-parser/src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,18 @@ pub struct VariantDecl {
pub items: Vec<BlockItem>,
}

/// `deferred path.expression`
/// `deferred Name.field` / `deferred alias/Name.field`, with an optional
/// trailing quoted location hint: `deferred Foo.bar "detailed/foo.allium"`.
///
/// The path is constrained to a dotted name with an optional `alias/Name`
/// qualifier, but stays an [`Expr`] (`Ident`, `QualifiedName`, or
/// `MemberAccess` chains over them) so qualified-reference collection and the
/// WASM AST mirror consume it unchanged.
#[derive(Debug, Clone, Serialize)]
pub struct DeferredDecl {
pub span: Span,
pub path: Expr,
pub location_hint: Option<StringLiteral>,
}

/// `open question "text"`
Expand Down
190 changes: 188 additions & 2 deletions crates/allium-parser/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -606,13 +606,92 @@ impl<'s> Parser<'s> {

fn parse_deferred_decl(&mut self) -> Option<DeferredDecl> {
let start = self.expect(TokenKind::Deferred)?.span;
let path = self.parse_expr(0)?;
let path = self.parse_deferred_path()?;

// Optional quoted location hint on the same line:
// `deferred Foo.bar "detailed/foo.allium"`. A string on a later line
// is stray top-level input, not this declaration's hint.
let location_hint = if self.at(TokenKind::String)
&& self.same_line(path.span().end, self.peek().span.start)
{
self.parse_string()
} else {
None
};

let end = location_hint
.as_ref()
.map_or(path.span(), |hint| hint.span);
Some(DeferredDecl {
span: start.merge(path.span()),
span: start.merge(end),
path,
location_hint,
})
}

/// Parse the path of a `deferred` declaration: a dotted name with an
/// optional `alias/Name` qualifier (`Name`, `Name.field.sub`,
/// `alias/Name`, `alias/Name.field`). Unlike `parse_expr`, this never
/// absorbs operators, calls, or string literals into the path, and it
/// stops before a trailing `.` with no following identifier (e.g.
/// `deferred Foo.`) so the declaration still forms and the leftover token
/// errors at declaration level — keeping the location-hint check running
/// on the line, in step with the TypeScript analyzer.
///
/// In declaration position there is no division to disambiguate from, so
/// a `/` after the leading identifier always introduces a qualifier,
/// mirroring `fulfils alias/Name` in the contracts clause.
///
/// The path must sit on one line: newlines are not tokens and keywords
/// are word tokens, so without the line guards a dangling `.` or `/`
/// would absorb the next declaration's leading keyword as a path segment.
fn parse_deferred_path(&mut self) -> Option<Expr> {
let first = self.parse_ident_in("deferred name")?;

let mut expr = if self.at(TokenKind::Slash)
&& self.same_line(first.span.end, self.peek().span.start)
{
self.advance(); // consume `/`
if !self.peek_kind().is_word()
|| !self.same_line(first.span.end, self.peek().span.start)
{
let tok = self.peek();
self.error(
tok.span,
format!("expected deferred name after '/', found {}", tok.kind),
);
return None;
}
let name = self.parse_ident_in("deferred name after '/'")?;
Expr::QualifiedName(QualifiedName {
span: first.span.merge(name.span),
qualifier: Some(first.name),
name: name.name,
})
} else {
Expr::Ident(first)
};

while self.at(TokenKind::Dot)
&& self.peek_at(1).kind.is_word()
&& self.same_line(expr.span().end, self.peek_at(1).span.start)
{
self.advance(); // consume `.`
let field = self.parse_ident_in("field name")?;
expr = Expr::MemberAccess {
span: expr.span().merge(field.span),
object: Box::new(expr),
field,
};
}
Some(expr)
}

/// Whether the source between two byte offsets contains no line break.
fn same_line(&self, from: usize, to: usize) -> bool {
!self.source[from..to].contains(['\n', '\r'])
}

// -- open question --------------------------------------------------

fn parse_open_question_decl(&mut self) -> Option<OpenQuestionDecl> {
Expand Down Expand Up @@ -2668,6 +2747,113 @@ mod tests {
let src = "deferred InterviewerMatching.suggest";
let r = parse_ok(src);
assert_eq!(r.diagnostics.len(), 0);
let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
assert!(matches!(&d.path, Expr::MemberAccess { .. }));
assert!(d.location_hint.is_none());
}

#[test]
fn deferred_qualified_path() {
let src = "deferred billing/InvoiceWorkflow";
let r = parse_ok(src);
assert_eq!(r.diagnostics.len(), 0);
let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
let Expr::QualifiedName(q) = &d.path else {
panic!("expected QualifiedName, got {:?}", d.path)
};
assert_eq!(q.qualifier.as_deref(), Some("billing"));
assert_eq!(q.name, "InvoiceWorkflow");
}

#[test]
fn deferred_qualified_path_with_member() {
let src = "deferred billing/InvoiceWorkflow.initiate";
let r = parse_ok(src);
assert_eq!(r.diagnostics.len(), 0);
let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
let Expr::MemberAccess { object, field, .. } = &d.path else {
panic!("expected MemberAccess, got {:?}", d.path)
};
assert!(matches!(object.as_ref(), Expr::QualifiedName(_)));
assert_eq!(field.name, "initiate");
}

#[test]
fn deferred_with_quoted_location_hint() {
let src = "deferred Foo.bar \"detailed/foo.allium\"";
let r = parse_ok(src);
assert_eq!(r.diagnostics.len(), 0);
let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
let hint = d.location_hint.as_ref().expect("location hint parsed");
assert_eq!(hint.text(), "detailed/foo.allium");
assert_eq!(d.span.end, hint.span.end, "declaration span covers the hint");
}

#[test]
fn deferred_hint_string_must_share_the_line() {
// A string on the next line is stray top-level input, not this
// declaration's hint.
let src = "deferred Foo\n\"detailed/foo.allium\"";
let r = parse_ok(src);
let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
assert!(d.location_hint.is_none());
assert!(r.diagnostics.iter().any(|d| d.message.contains("expected declaration")));
}

#[test]
fn deferred_path_rejects_expression_shapes() {
// The path is a dotted name, not an expression: calls and comparisons
// leave their leftover tokens to error at declaration level, while the
// declaration itself still forms with the flat name.
for src in ["deferred Foo(\"x\")", "deferred Foo = \"x\""] {
let r = parse_ok(src);
let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
assert!(matches!(&d.path, Expr::Ident(id) if id.name == "Foo"));
assert!(
r.diagnostics.iter().any(|d| d.message.contains("expected declaration")),
"leftover tokens must error in {src:?}"
);
}
}

#[test]
fn deferred_parenthesised_path_errors() {
let r = parse_ok("deferred (Foo)");
assert!(r.diagnostics.iter().any(|d| d.message.contains("expected deferred name")));
}

#[test]
fn deferred_trailing_dot_keeps_declaration() {
// The path stops before a dangling `.`; the declaration still forms
// (so the location-hint check covers the line) and the dot errors.
let r = parse_ok("deferred Dangling.");
let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
assert!(matches!(&d.path, Expr::Ident(id) if id.name == "Dangling"));
assert!(r.diagnostics.iter().any(|d| d.message.contains("expected declaration")));
}

#[test]
fn deferred_dangling_qualifier_errors() {
let r = parse_ok("deferred billing/");
assert!(r.diagnostics.iter().any(|d| d.message.contains("expected deferred name after '/'")));
}

#[test]
fn deferred_path_does_not_cross_lines() {
// Newlines are not tokens and keywords are word tokens, so without
// line guards `Dangling.` would absorb the next declaration's
// `deferred` keyword as a member name.
let src = "deferred Dangling.\ndeferred Next.step";
let r = parse_ok(src);
let deferreds: Vec<_> = r
.module
.declarations
.iter()
.filter(|d| matches!(d, Decl::Deferred(_)))
.collect();
assert_eq!(deferreds.len(), 2, "both declarations survive");
let Decl::Deferred(d) = deferreds[0] else { panic!() };
assert!(matches!(&d.path, Expr::Ident(id) if id.name == "Dangling"));
}

#[test]
Expand Down
19 changes: 19 additions & 0 deletions docs/allium-v3-language-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1378,6 +1378,25 @@ deferred SlotRecovery.initiate -- see: slot-recovery.allium

This allows the main specification to remain succinct while acknowledging that detail exists elsewhere.

The deferred path is a dotted name — not a general expression — with an optional `use`-alias qualifier, following the same coordinate system as other cross-module references:

```
deferred Name
deferred Name.field.sub
deferred billing/InvoiceWorkflow -- imported via `use ... as billing`
deferred billing/InvoiceWorkflow.initiate
```

Calls, operators, and parenthesised forms are not valid deferred paths. The whole path must sit on one line.

A location hint tells readers and tooling where the detail lives. It is either a trailing comment on the declaration line — a `-- see: <path>` marker or a URL — or a quoted path that is part of the declaration itself:

```
deferred SlotRecovery.initiate "detailed/slot-recovery.allium"
```

The checker warns on deferred declarations that carry no location hint in either form.

Deferred specifications are invoked at call sites using dot notation. They can appear as standalone ensures clauses or as expressions that return a value:

```
Expand Down
4 changes: 2 additions & 2 deletions docs/project/rust-checker-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ The Rust `check_deferred_location_hints` previously emitted the warning for ever

A deferred declaration is treated as carrying a location hint when that suffix includes a quoted path, a URL (`http://`/`https://`), or the `-- see:` comment convention shown in the language reference. The TypeScript predicate was broadened from quoted-path/URL-only to also recognise `-- see:`, so the documented `deferred X -- see: path.allium` form is now accepted by both implementations.

Replaying the match — rather than trusting the parsed path's span — is what keeps the two in step: the Rust parser reads the path as a full expression, which can extend past the flat name (qualified `billing/InvoiceWorkflow` paths, expression-shaped paths like `Foo("x")`) or start after it (`deferred (Foo)`), moving the suffix boundary or fabricating a name the TypeScript pattern would never capture — either flips the verdict. With the replayed match, both sides also name the warning after the same flat name. Scanning the suffix rather than the whole line matters for the URL markers: a URL glued to the identifier (`deferred Foohttps://x`) is an unspaced path with no hint, and warns in both. See issue #20 and the review on PR #23.
Replaying the match — rather than trusting the parsed path's span — is what keeps the two in step: the deferred path grammar (issue #24) stops before a dangling `.` that the TypeScript capture includes (`deferred Foo.` captures `Foo.`), and a qualified `billing/InvoiceWorkflow` path extends past the flat name the capture stops at — either would move the suffix boundary or change the reported name. With the replayed match, both sides name the warning after the same flat name. Scanning the suffix rather than the whole line matters for the URL markers: a URL glued to the identifier (`deferred Foohttps://x`) is an unspaced path with no hint, and warns in both. See issue #20 and the review on PR #23.

Scope of the guarantee: parity covers every line that parses into a `DeferredDecl`. The replayed match treats `\n`, `\r`, U+2028 and U+2029 as line terminators — the JavaScript `m`-flag set — so CRLF and lone-CR files behave identically on both sides. The remaining divergences are inputs the two front ends read differently before this check runs: a malformed deferred line that fails the Rust expression parser (`deferred Foo.`, `deferred Foo == "x"`) surfaces a parse error instead of this warning, and the TypeScript pattern — which runs over raw text with no comment or string awareness, and never consumes parse diagnostics — can fire on `deferred`-shaped text the Rust lexer reads as comment or string content (e.g. after a lone `\r` inside a `--` comment). Diagnostic-set parity on such inputs is out of scope for this check.
Scope of the guarantee: parity covers every line that parses into a `DeferredDecl`. Since the deferred path grammar landed (issue #24), that is every line whose path starts with an identifier: the path parser stops at the first token it does not accept, the declaration still forms, and the leftover tokens surface as parse errors — identical on both sides, since the extension consumes the same WASM parser's diagnostics (§7). So `deferred Foo.` warns under `Foo.` on both sides plus one shared parse error, and expression-shaped lines like `deferred Foo("x")` suppress on both (the quote lands in the suffix) plus one shared parse error. A line whose path fails at the first token (`deferred (Foo)`) forms no declaration and warns on neither. The replayed match treats `\n`, `\r`, U+2028 and U+2029 as line terminators — the JavaScript `m`-flag set — so CRLF and lone-CR files behave identically on both sides. The remaining divergence: the TypeScript pattern — which runs over raw text with no comment or string awareness — can fire on `deferred`-shaped text the Rust lexer reads as comment or string content (e.g. after a lone `\r` inside a `--` comment). Diagnostic-set parity on such inputs is out of scope for this check.

### 7. Parse diagnostics surfaced (issue #25)

Expand Down
10 changes: 10 additions & 0 deletions docs/project/specs/allium-check-tool-behaviour.allium
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,13 @@ rule DeferredWithLocationHintSuppressesWarning {

ensures: FindingNotObserved(code: "allium.deferred.missingLocationHint", declaration: deferred_declaration)
}

rule DeferredPathConstrainedToDottedName {
when: CheckCommandInvokedWithInputs(inputs)
requires: file contains deferred_declaration
requires: deferred_declaration path is not a dotted name with an optional use-alias qualifier
-- accepted forms: Name, Name.field.sub, alias/Name, alias/Name.field,
-- each optionally followed by a quoted location hint on the same line

ensures: FindingObserved(severity: error)
}
1 change: 1 addition & 0 deletions extensions/allium/src/language-tools/wasm-ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export interface WasmVariantDecl {
export interface WasmDeferredDecl {
span: WasmSpan;
path: WasmExpr;
location_hint: WasmStringLiteral | null;
}

export interface WasmOpenQuestionDecl {
Expand Down
Loading
Loading