Skip to content

Commit 3267c39

Browse files
committed
Fix parser: comment-only clauses, for-each with filter, tuple destructuring, dot-path assignments; bump to 0.1.7
1 parent 7035af1 commit 3267c39

8 files changed

Lines changed: 182 additions & 16 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ resolver = "2"
33
members = ["crates/allium-parser", "crates/allium"]
44

55
[workspace.package]
6-
version = "0.1.6"
6+
version = "0.1.7"
77
edition = "2021"
88
license = "MIT"
99
repository = "https://github.com/juxt/allium-tools"

crates/allium-parser/src/ast.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ pub enum BlockItemKind {
145145
EnumVariant { name: Ident },
146146
/// `for binding in collection [where filter]: ...` at block level (rule iteration)
147147
ForBlock {
148-
binding: Ident,
148+
binding: ForBinding,
149149
collection: Expr,
150150
filter: Option<Expr>,
151151
items: Vec<BlockItem>,
@@ -155,6 +155,8 @@ pub enum BlockItemKind {
155155
branches: Vec<CondBlockBranch>,
156156
else_items: Option<Vec<BlockItem>>,
157157
},
158+
/// `Shard.shard_cache: value` — dot-path reverse relationship
159+
PathAssignment { path: Expr, value: Expr },
158160
/// `open question "text"` (nested within a block)
159161
OpenQuestion { text: StringLiteral },
160162
}
@@ -340,7 +342,7 @@ pub enum Expr {
340342
/// `for x in collection [where cond]: body`
341343
For {
342344
span: Span,
343-
binding: Ident,
345+
binding: ForBinding,
344346
collection: Box<Expr>,
345347
filter: Option<Box<Expr>>,
346348
body: Box<Expr>,
@@ -484,6 +486,14 @@ pub struct CondBlockBranch {
484486
pub items: Vec<BlockItem>,
485487
}
486488

489+
/// Binding in a `for each` loop — either a single identifier or a
490+
/// destructured tuple like `(a, b)`.
491+
#[derive(Debug, Clone)]
492+
pub enum ForBinding {
493+
Single(Ident),
494+
Destructured(Vec<Ident>, Span),
495+
}
496+
487497
// ---------------------------------------------------------------------------
488498
// Shared types
489499
// ---------------------------------------------------------------------------

crates/allium-parser/src/parser.rs

Lines changed: 125 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,14 @@ impl<'s> Parser<'s> {
652652
return self.parse_binding_clause_item(start);
653653
}
654654

655+
// Check for `Name.field:` — dot-path reverse relationship
656+
if self.peek_at(1).kind == TokenKind::Dot
657+
&& self.peek_at(2).kind.is_word()
658+
&& self.peek_at(3).kind == TokenKind::Colon
659+
{
660+
return self.parse_path_assignment_item(start);
661+
}
662+
655663
// Check for `name(` — potential parameterised assignment
656664
if self.peek_at(1).kind == TokenKind::LParen {
657665
return self.parse_param_or_clause_item(start);
@@ -718,7 +726,7 @@ impl<'s> Parser<'s> {
718726
if self.peek_kind().is_word() && self.text(self.peek().span) == "each" {
719727
self.advance();
720728
}
721-
let binding = self.parse_ident_in("loop variable")?;
729+
let binding = self.parse_for_binding()?;
722730
self.expect(TokenKind::In)?;
723731

724732
let collection = self.parse_expr(BP_WITH_WHERE + 1)?;
@@ -882,6 +890,30 @@ impl<'s> Parser<'s> {
882890
})
883891
}
884892

893+
/// Parse `Entity.field: value` — a dot-path reverse relationship declaration.
894+
fn parse_path_assignment_item(&mut self, start: Span) -> Option<BlockItem> {
895+
let obj_tok = self.advance(); // consume first ident
896+
self.advance(); // consume '.'
897+
let field = self.parse_ident_in("field name")?;
898+
self.advance(); // consume ':'
899+
900+
let path = Expr::MemberAccess {
901+
span: obj_tok.span.merge(field.span),
902+
object: Box::new(Expr::Ident(Ident {
903+
span: obj_tok.span,
904+
name: self.text(obj_tok.span).to_string(),
905+
})),
906+
field,
907+
};
908+
909+
let value = self.parse_clause_value(start)?;
910+
let value_span = value.span();
911+
Some(BlockItem {
912+
span: start.merge(value_span),
913+
kind: BlockItemKind::PathAssignment { path, value },
914+
})
915+
}
916+
885917
fn parse_param_or_clause_item(&mut self, start: Span) -> Option<BlockItem> {
886918
// Could be `name(params): value` (param assignment) or
887919
// `name(args)` which is an expression that happens to start a clause
@@ -948,6 +980,23 @@ impl<'s> Parser<'s> {
948980
Some(params)
949981
}
950982

983+
/// Parse a for-loop binding: either a single ident or `(a, b)` destructuring.
984+
fn parse_for_binding(&mut self) -> Option<ForBinding> {
985+
if self.at(TokenKind::LParen) {
986+
let start = self.advance().span; // consume '('
987+
let mut idents = Vec::new();
988+
idents.push(self.parse_ident_in("loop variable")?);
989+
while self.eat(TokenKind::Comma).is_some() {
990+
idents.push(self.parse_ident_in("loop variable")?);
991+
}
992+
let end = self.expect(TokenKind::RParen)?.span;
993+
Some(ForBinding::Destructured(idents, start.merge(end)))
994+
} else {
995+
let ident = self.parse_ident_in("loop variable")?;
996+
Some(ForBinding::Single(ident))
997+
}
998+
}
999+
9511000
/// Parse a clause value, optionally checking for a `name: expr` binding
9521001
/// pattern at the start. Used for when, facing and context clauses where
9531002
/// the first `ident:` is a binding rather than a nested assignment.
@@ -990,8 +1039,18 @@ impl<'s> Parser<'s> {
9901039
let next_line = self.line_of(next.span);
9911040

9921041
if next_line > clause_line {
993-
// Multi-line block
1042+
// Multi-line block — but only if the next token is actually
1043+
// indented past the clause keyword. When a clause has only a
1044+
// comment as its value (stripped by the lexer), the next visible
1045+
// token is a sibling at the same indentation.
9941046
let base_col = self.col_of(next.span);
1047+
let clause_col = self.col_of(clause_start);
1048+
if base_col <= clause_col {
1049+
return Some(Expr::Block {
1050+
span: clause_start,
1051+
items: Vec::new(),
1052+
});
1053+
}
9951054
self.parse_indented_block(base_col)
9961055
} else {
9971056
// Single-line — parse primary expression, then check for suffix predicate
@@ -1822,18 +1881,19 @@ impl<'s> Parser<'s> {
18221881
if self.peek_kind().is_word() && self.text(self.peek().span) == "each" {
18231882
self.advance();
18241883
}
1825-
let binding = self.parse_ident_in("loop variable")?;
1884+
let binding = self.parse_for_binding()?;
18261885
self.expect(TokenKind::In)?;
18271886

18281887
// Parse collection, stopping before `where` and `:`
18291888
let collection = self.parse_expr(BP_WITH_WHERE + 1)?;
18301889

1831-
let filter = if self.eat(TokenKind::Where).is_some() {
1832-
// Parse filter at min_bp 0 — colon terminates naturally.
1833-
Some(Box::new(self.parse_expr(0)?))
1834-
} else {
1835-
None
1836-
};
1890+
let filter =
1891+
if self.eat(TokenKind::Where).is_some() || self.eat(TokenKind::With).is_some() {
1892+
// Parse filter at min_bp 0 — colon terminates naturally.
1893+
Some(Box::new(self.parse_expr(0)?))
1894+
} else {
1895+
None
1896+
};
18371897

18381898
self.expect(TokenKind::Colon)?;
18391899
let body = self.parse_branch_body(start)?;
@@ -2867,6 +2927,62 @@ rule ProcessDigests {
28672927
}
28682928
}
28692929

2930+
#[test]
2931+
fn guidance_clause_comment_only_value() {
2932+
let src = r#"rule R {
2933+
guidance: -- just a comment
2934+
ensures: Done()
2935+
}"#;
2936+
let r = parse_ok(src);
2937+
assert_eq!(r.diagnostics.len(), 0);
2938+
let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
2939+
assert_eq!(b.items.len(), 2);
2940+
// guidance clause should have an empty block value
2941+
let BlockItemKind::Clause { keyword, value } = &b.items[0].kind else { panic!() };
2942+
assert_eq!(keyword, "guidance");
2943+
assert!(matches!(value, Expr::Block { items, .. } if items.is_empty()));
2944+
}
2945+
2946+
#[test]
2947+
fn for_expr_with_filter() {
2948+
let src = r#"rule R {
2949+
when: X(project)
2950+
ensures:
2951+
let total = for each task in project.tasks with task.active: task.effort
2952+
Done(total: total)
2953+
}"#;
2954+
let r = parse_ok(src);
2955+
assert_eq!(r.diagnostics.len(), 0);
2956+
}
2957+
2958+
#[test]
2959+
fn for_each_destructured_binding() {
2960+
let src = r#"rule R {
2961+
when: X()
2962+
for each (key, value) in Pairs where key != null:
2963+
ensures: Processed(key: key, value: value)
2964+
}"#;
2965+
let r = parse_ok(src);
2966+
assert_eq!(r.diagnostics.len(), 0);
2967+
let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
2968+
let BlockItemKind::ForBlock { binding, .. } = &b.items[1].kind else { panic!() };
2969+
assert!(matches!(binding, ForBinding::Destructured(ids, _) if ids.len() == 2));
2970+
}
2971+
2972+
#[test]
2973+
fn dot_path_assignment() {
2974+
let src = r#"entity Shard {
2975+
ShardGroup.shard_cache: Shard with group = this
2976+
}"#;
2977+
let r = parse_ok(src);
2978+
assert_eq!(r.diagnostics.len(), 0);
2979+
let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
2980+
let BlockItemKind::PathAssignment { path, .. } = &b.items[0].kind else {
2981+
panic!("expected PathAssignment, got {:?}", b.items[0].kind);
2982+
};
2983+
assert!(matches!(path, Expr::MemberAccess { .. }));
2984+
}
2985+
28702986
#[test]
28712987
fn language_reference_fixture() {
28722988
let src = include_str!("../tests/fixtures/language-reference-constructs.allium");

crates/allium-parser/tests/fixtures/language-reference-constructs.allium

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,43 @@ rule Lambdas {
263263
requires: items.any(i => i.priority = high)
264264
ensures: Transformed()
265265
}
266+
267+
-- ---------------------------------------------------------------------------
268+
-- Comment-only clause value
269+
-- ---------------------------------------------------------------------------
270+
271+
rule CommentOnlyGuidance {
272+
guidance: -- this is just a comment
273+
when: X()
274+
ensures: Done()
275+
}
276+
277+
-- ---------------------------------------------------------------------------
278+
-- for each with `with` filter (expression-level)
279+
-- ---------------------------------------------------------------------------
280+
281+
rule ExprForWithFilter {
282+
when: Summarise(project)
283+
ensures:
284+
let total = for each task in project.tasks with task.billable: task.hours
285+
Summarised(project: project, total: total)
286+
}
287+
288+
-- ---------------------------------------------------------------------------
289+
-- Tuple destructuring in for each
290+
-- ---------------------------------------------------------------------------
291+
292+
rule ProcessPairs {
293+
when: PairsReady()
294+
for each (key, value) in Pairs where key != null:
295+
ensures: PairProcessed(key: key, value: value)
296+
}
297+
298+
-- ---------------------------------------------------------------------------
299+
-- Dot-path reverse relationship declaration
300+
-- ---------------------------------------------------------------------------
301+
302+
entity Shard {
303+
group: ShardGroup
304+
ShardGroup.shard_cache: Shard with group = this
305+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "allium-tools",
33
"private": true,
4-
"version": "0.1.6",
4+
"version": "0.1.7",
55
"license": "MIT",
66
"workspaces": [
77
"extensions/allium",

packages/allium-cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "allium-cli",
3-
"version": "0.1.6",
3+
"version": "0.1.7",
44
"description": "Standalone allium-check, allium-format, allium-diagram, allium-trace, and allium-drift CLI tools.",
55
"license": "MIT",
66
"bin": {

packages/allium-lsp/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "allium-lsp",
3-
"version": "0.1.6",
3+
"version": "0.1.7",
44
"description": "Language Server Protocol server for the Allium language.",
55
"license": "MIT",
66
"bin": {

packages/tree-sitter-allium/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tree-sitter-allium",
3-
"version": "0.1.6",
3+
"version": "0.1.7",
44
"description": "Tree-sitter grammar for the Allium language.",
55
"license": "MIT",
66
"main": "bindings/node",

0 commit comments

Comments
 (0)