@@ -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" ) ;
0 commit comments