1use crate::{parse_expression, Diagnostic, Expr, SourceSpan};
10
11pub fn parse_short_duration_seconds(value: &str) -> Option<u64> {
13 let unit = value.chars().last()?;
14 let number = value.get(..value.len() - 1)?.parse::<u64>().ok()?;
15 let multiplier = match unit {
16 's' => 1,
17 'm' => 60,
18 'h' => 3600,
19 'd' => 86400,
20 _ => return None,
21 };
22 number.checked_mul(multiplier)
23}
24
25pub fn is_iso8601_instant(value: &str) -> bool {
29 let bytes = value.as_bytes();
30 let digits = |range: std::ops::Range<usize>| {
31 bytes
32 .get(range)
33 .is_some_and(|slice| !slice.is_empty() && slice.iter().all(u8::is_ascii_digit))
34 };
35 let field = |range: std::ops::Range<usize>| -> u32 {
36 value
37 .get(range)
38 .and_then(|text| text.parse().ok())
39 .unwrap_or(u32::MAX)
40 };
41 if !(digits(0..4) && bytes.get(4) == Some(&b'-') && digits(5..7))
42 || bytes.get(7) != Some(&b'-')
43 || !digits(8..10)
44 || bytes.get(10) != Some(&b'T')
45 || !digits(11..13)
46 || bytes.get(13) != Some(&b':')
47 || !digits(14..16)
48 || bytes.get(16) != Some(&b':')
49 || !digits(17..19)
50 {
51 return false;
52 }
53 if !(1..=12).contains(&field(5..7))
54 || !(1..=31).contains(&field(8..10))
55 || field(11..13) > 23
56 || field(14..16) > 59
57 || field(17..19) > 60
58 {
59 return false;
60 }
61 let mut index = 19;
62 if bytes.get(index) == Some(&b'.') {
63 index += 1;
64 let start = index;
65 while bytes.get(index).is_some_and(u8::is_ascii_digit) {
66 index += 1;
67 }
68 if index == start {
69 return false;
70 }
71 }
72 match bytes.get(index) {
73 Some(b'Z') => index + 1 == bytes.len(),
74 Some(b'+') | Some(b'-') => {
75 digits(index + 1..index + 3)
76 && bytes.get(index + 3) == Some(&b':')
77 && digits(index + 4..index + 6)
78 && index + 6 == bytes.len()
79 && field(index + 1..index + 3) <= 23
80 && field(index + 4..index + 6) <= 59
81 }
82 _ => false,
83 }
84}
85
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct BodyAst {
88 pub statements: Vec<BodyStmt>,
89}
90
91#[derive(Clone, Debug, Eq, PartialEq)]
92pub enum BodyStmt {
93 Record(RecordStmt),
94 Done {
97 binding: String,
98 replacement: Option<RecordStmt>,
99 span: SourceSpan,
100 },
101 Effect(EffectStmt),
102 After(AfterBlock),
103 Region(RegionBlock),
104 Case(CaseBlock),
105 Terminal(TerminalStmt),
106 Cancel {
107 binding: String,
108 span: SourceSpan,
109 },
110 Milestone {
118 name: String,
119 payload_class: Option<String>,
120 fields: Vec<FieldAssign>,
121 span: SourceSpan,
122 },
123 Redact {
135 source: String,
136 keep: Vec<String>,
137 binding: String,
138 span: SourceSpan,
139 },
140}
141
142#[derive(Clone, Debug, Eq, PartialEq)]
143pub struct RecordStmt {
144 pub schema: String,
145 pub from: Option<String>,
146 pub fields: Vec<FieldAssign>,
147 pub span: SourceSpan,
148}
149
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct FieldAssign {
152 pub name: String,
153 pub value: FieldValue,
154 pub span: SourceSpan,
155}
156
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub enum FieldValue {
159 Shorthand,
161 Expr { source: String, expr: Expr },
164 Nested {
166 schema: String,
167 fields: Vec<FieldAssign>,
168 },
169}
170
171#[derive(Clone, Debug, Eq, PartialEq)]
172pub struct EffectStmt {
173 pub kind: BodyEffectKind,
174 pub binding: Option<String>,
175 pub requires: Vec<String>,
176 pub timeout_seconds: Option<u64>,
178 pub prompt: Option<Prompt>,
179 pub span: SourceSpan,
180}
181
182#[derive(Clone, Debug, Eq, PartialEq)]
187pub struct AccessGrant {
188 pub resource: String,
189 pub operations: Vec<AccessGrantOp>,
190 pub span: SourceSpan,
191}
192
193#[derive(Clone, Debug, Eq, PartialEq)]
197pub struct AccessGrantOp {
198 pub operation: String,
199 pub target: Option<String>,
200 pub globs: Vec<String>,
201 pub span: SourceSpan,
202}
203
204#[derive(Clone, Debug, Eq, PartialEq)]
205pub enum BodyEffectKind {
206 Tell {
207 target: String,
208 access_grants: Vec<AccessGrant>,
209 skills: Vec<String>,
212 },
213 Coerce {
214 name: String,
215 args: Vec<String>,
216 endorsed: bool,
220 declassified: bool,
225 },
226 Prompt {
230 provider: Option<String>,
231 },
232 Decide {
234 result_fields: Vec<(String, String)>,
235 },
236 Call {
237 capability: String,
238 argument: Option<String>,
239 },
240 ConstructCapabilityCall {
241 keyword: String,
242 target_capability: String,
243 fields: Vec<ConstructUseField>,
244 },
245 Invoke {
246 workflow: String,
247 payload: Vec<FieldAssign>,
248 access_grants: Vec<AccessGrant>,
249 },
250 Timer {
251 duration_seconds: u64,
252 duration_source: String,
253 until: Option<String>,
256 },
257 Exec {
258 target: ExecTarget,
259 parse_target: Option<ExecParse>,
262 },
263 TrackerFile {
266 queue: String,
267 fields: Vec<FieldAssign>,
268 },
269 TrackerClaim {
270 item: String,
271 ttl_seconds: Option<u64>,
275 },
276 TrackerRelease {
277 item: String,
278 },
279 TrackerFinish {
280 item: String,
281 fields: Vec<FieldAssign>,
282 },
283 LeaseAcquire {
286 resource: String,
287 key_expr: String,
288 until_ttl: bool,
290 wait_seconds: Option<u64>,
294 },
295 LeaseRenew {
299 acquire_binding: String,
301 ttl_seconds: Option<u64>,
304 },
305 LedgerAppend {
306 ledger: String,
307 schema: String,
308 fields: Vec<FieldAssign>,
309 },
310 CounterConsume {
311 counter: String,
312 key_expr: String,
313 amount_expr: String,
314 },
315 Notify {
319 target_expr: String,
320 event: String,
321 from: Option<String>,
325 fields: Vec<FieldAssign>,
326 },
327 FileRead {
331 format: String,
332 store: String,
333 path: String,
334 },
335 FileWrite {
341 format: String,
342 store: String,
343 path: String,
344 body: String,
345 mode: String,
346 },
347 FileImport {
352 format: String,
353 schema: String,
354 store: String,
355 path: String,
356 },
357 FileExport {
363 format: String,
364 schema: String,
365 store: String,
366 path: String,
367 predicate: Option<String>,
368 mode: String,
369 },
370}
371
372#[derive(Clone, Debug, Eq, PartialEq)]
373pub struct ConstructUseField {
374 pub name: String,
375 pub source: String,
376}
377
378#[derive(Clone, Copy, Debug)]
392enum SlotKind {
393 Identifier,
394 Expression,
395}
396
397#[derive(Clone, Copy, Debug)]
402#[allow(dead_code)]
403enum BindingMode {
404 Required,
405 Optional,
406 None,
407}
408
409#[derive(Clone, Copy, Debug)]
413struct EffectSlotSpec {
414 name: &'static str,
415 kind: SlotKind,
416 connective: Option<&'static str>,
417}
418
419#[derive(Clone, Copy, Debug)]
422struct PayloadFieldSpec {
423 name: &'static str,
424 required: bool,
425}
426
427#[derive(Clone, Copy, Debug)]
429struct EffectOperationSpec {
430 keyword: &'static str,
431 slots: &'static [EffectSlotSpec],
432 payload: Option<&'static [PayloadFieldSpec]>,
433 binding: BindingMode,
434 target_capability: &'static str,
435}
436
437include!(concat!(env!("OUT_DIR"), "/effect_operation_grammar.rs"));
443
444fn effect_operation_spec(keyword: &str) -> Option<&'static EffectOperationSpec> {
446 EFFECT_OPERATION_GRAMMAR
447 .iter()
448 .find(|spec| spec.keyword == keyword)
449}
450
451#[derive(Clone, Debug, Eq, PartialEq)]
452pub enum ExecTarget {
453 RawCommand(String),
454 Capability { name: String, stdin_binding: String },
455}
456
457#[derive(Clone, Debug, Eq, PartialEq)]
460pub struct ExecParse {
461 pub schema: String,
462 pub each: bool,
463}
464
465#[derive(Clone, Debug, Eq, PartialEq)]
466pub struct Prompt {
467 pub text: String,
468 pub content_type: Option<String>,
469}
470
471#[derive(Clone, Debug, Eq, PartialEq)]
478pub struct RegionBlock {
479 pub until: bool,
482 pub condition: String,
484 pub body: Vec<BodyStmt>,
485 pub lapse_binding: Option<String>,
487 pub lapse_body: Vec<BodyStmt>,
488 pub body_span: SourceSpan,
491 pub lapse_span: SourceSpan,
493 pub span: SourceSpan,
494}
495
496#[derive(Clone, Debug, Eq, PartialEq)]
497pub struct AfterBlock {
498 pub binding: String,
499 pub predicate: AfterPredicate,
500 pub alias: Option<String>,
501 pub milestone: Option<String>,
506 pub body: Vec<BodyStmt>,
507 pub span: SourceSpan,
508}
509
510impl AfterPredicate {
511 pub fn kernel_str(&self) -> &'static str {
514 match self {
515 AfterPredicate::Succeeds => "succeeds",
516 AfterPredicate::Fails => "fails",
517 AfterPredicate::Completes => "completes",
518 AfterPredicate::Cancelled => "cancelled",
519 AfterPredicate::TimedOut => "times out",
520 AfterPredicate::Reaches => "reaches",
521 AfterPredicate::Held => "held",
522 AfterPredicate::Contended => "contended",
523 AfterPredicate::Ok => "ok",
524 AfterPredicate::Over => "over",
525 }
526 }
527}
528
529#[derive(Clone, Copy, Debug, Eq, PartialEq)]
530pub enum AfterPredicate {
531 Succeeds,
532 Fails,
533 Completes,
534 TimedOut,
538 Cancelled,
539 Held,
542 Contended,
543 Ok,
544 Over,
545 Reaches,
551}
552
553impl AfterPredicate {
554 pub fn as_str(&self) -> &'static str {
555 match self {
556 Self::Succeeds => "succeeds",
557 Self::Fails => "fails",
558 Self::Completes => "completes",
559 Self::TimedOut => "times out",
560 Self::Cancelled => "cancelled",
561 Self::Held => "held",
562 Self::Contended => "contended",
563 Self::Ok => "ok",
564 Self::Over => "over",
565 Self::Reaches => "reaches",
569 }
570 }
571}
572
573#[derive(Clone, Debug, Eq, PartialEq)]
574pub struct CaseBlock {
575 pub scrutinee: String,
576 pub branches: Vec<CaseBranch>,
577 pub span: SourceSpan,
578}
579
580#[derive(Clone, Debug, Eq, PartialEq)]
581pub struct CaseBranch {
582 pub pattern: String,
583 pub binding: Option<String>,
584 pub guard: Option<String>,
585 pub body: Vec<BodyStmt>,
586 pub span: SourceSpan,
587}
588
589#[derive(Clone, Debug, Eq, PartialEq)]
590pub struct TerminalStmt {
591 pub kind: TerminalKind,
592 pub name: String,
593 pub from: Option<String>,
598 pub fields: Vec<FieldAssign>,
599 pub scalar: Option<FieldValue>,
605 pub span: SourceSpan,
606}
607
608#[derive(Clone, Copy, Debug, Eq, PartialEq)]
609pub enum TerminalKind {
610 Complete,
611 Fail,
612}
613
614#[derive(Clone, Debug, Eq, PartialEq)]
618pub struct SplitFieldAssignment {
619 pub name: String,
620 pub value: Option<String>,
621}
622
623pub fn split_field_assignments(source: &str) -> Vec<SplitFieldAssignment> {
629 let mut diagnostics = Vec::new();
630 let tokens = lex_body(source, 0, &mut diagnostics);
631 let mut parser = BodyParser {
632 source,
633 base: 0,
634 tokens,
635 pos: 0,
636 diagnostics,
637 };
638 let mut assignments = Vec::new();
639 while let Some(token) = parser.peek() {
640 let name_line = token.line;
641 let Tok::Ident(name) = token.tok.clone() else {
642 parser.pos += 1;
643 continue;
644 };
645 parser.pos += 1;
646 let is_shorthand = match parser.peek() {
647 None => true,
648 Some(next) => next.line != name_line,
649 };
650 if is_shorthand {
651 assignments.push(SplitFieldAssignment { name, value: None });
652 continue;
653 }
654 let value_start = parser.pos;
655 if !parser.consume_value_atom() {
656 parser.pos += 1;
657 continue;
658 }
659 loop {
660 match parser.peek().map(|t| t.tok.clone()) {
661 Some(Tok::Op(_)) | Some(Tok::Sym('+')) | Some(Tok::Sym('-'))
662 | Some(Tok::Sym('*')) | Some(Tok::Sym('/')) | Some(Tok::Sym('<'))
663 | Some(Tok::Sym('>')) => {
664 parser.pos += 1;
665 if !parser.consume_value_atom() {
666 break;
667 }
668 }
669 Some(Tok::Ident(word)) if word == "and" || word == "or" || word == "in" => {
670 parser.pos += 1;
671 if !parser.consume_value_atom() {
672 break;
673 }
674 }
675 Some(Tok::Sym('[')) => {
676 parser.consume_balanced('[', ']');
677 }
678 Some(Tok::Sym('{')) => {
682 parser.consume_balanced('{', '}');
683 break;
684 }
685 _ => break,
686 }
687 }
688 let first = &parser.tokens[value_start];
689 let last = &parser.tokens[parser.pos - 1];
690 assignments.push(SplitFieldAssignment {
691 name,
692 value: Some(source[first.start..last.end].to_owned()),
693 });
694 }
695 assignments
696}
697
698#[derive(Clone, Debug, Eq, PartialEq)]
703enum Tok {
704 Ident(String),
705 Str(String),
706 TripleStr {
707 text: String,
708 content_type: Option<String>,
709 },
710 Number(String),
711 Sym(char),
712 Arrow, FatArrow, Op(&'static str),
715}
716
717#[derive(Clone, Debug)]
718struct Token {
719 tok: Tok,
720 start: usize,
721 end: usize,
722 line: usize,
723}
724
725fn line_of(source: &str, offset: usize) -> usize {
726 source[..offset].bytes().filter(|b| *b == b'\n').count()
727}
728
729fn lex_body(source: &str, base: usize, diagnostics: &mut Vec<Diagnostic>) -> Vec<Token> {
730 let bytes = source.as_bytes();
731 let mut tokens = Vec::new();
732 let mut i = 0;
733 while i < bytes.len() {
734 let c = bytes[i] as char;
735 if c.is_whitespace() {
736 i += 1;
737 continue;
738 }
739 let start = i;
740 if source[i..].starts_with("\"\"\"") {
741 let opener_end = source[i + 3..]
743 .find('\n')
744 .map(|offset| i + 3 + offset)
745 .unwrap_or(source.len());
746 let annotation = source[i + 3..opener_end].trim();
747 let content_type = (!annotation.is_empty()).then(|| annotation.to_owned());
748 let Some(close) = source[opener_end..].find("\"\"\"").map(|o| opener_end + o) else {
749 diagnostics.push(Diagnostic {
750 related: Vec::new(),
751 span: SourceSpan {
752 start: base + start,
753 end: base + source.len(),
754 },
755 message: "unterminated multiline string".to_owned(),
756 suggestion: Some("close the prompt with `\"\"\"`".to_owned()),
757 });
758 break;
759 };
760 let raw = &source[opener_end..close];
761 let text = dedent_prompt(raw);
762 tokens.push(Token {
763 tok: Tok::TripleStr { text, content_type },
764 start,
765 end: close + 3,
766 line: line_of(source, start),
767 });
768 i = close + 3;
769 continue;
770 }
771 if c == '"' {
772 let mut j = i + 1;
773 let mut value = String::new();
774 let mut closed = false;
775 while j < bytes.len() {
776 let cj = bytes[j] as char;
777 if cj == '\\' && j + 1 < bytes.len() {
778 value.push(bytes[j + 1] as char);
779 j += 2;
780 continue;
781 }
782 if cj == '"' {
783 closed = true;
784 break;
785 }
786 if cj == '\n' {
787 break;
788 }
789 value.push(cj);
790 j += 1;
791 }
792 if !closed {
793 diagnostics.push(Diagnostic {
794 related: Vec::new(),
795 span: SourceSpan {
796 start: base + start,
797 end: base + j,
798 },
799 message: "unterminated string".to_owned(),
800 suggestion: Some("close the string with `\"`".to_owned()),
801 });
802 i = j;
803 continue;
804 }
805 tokens.push(Token {
806 tok: Tok::Str(value),
807 start,
808 end: j + 1,
809 line: line_of(source, start),
810 });
811 i = j + 1;
812 continue;
813 }
814 if c.is_ascii_digit()
815 || (c == '-'
816 && bytes
817 .get(i + 1)
818 .is_some_and(|b| (*b as char).is_ascii_digit()))
819 {
820 let mut j = i + 1;
821 while j < bytes.len() {
822 let cj = bytes[j] as char;
823 if cj.is_ascii_alphanumeric() || cj == '.' || cj == '_' {
824 j += 1;
825 } else {
826 break;
827 }
828 }
829 tokens.push(Token {
830 tok: Tok::Number(source[i..j].to_owned()),
831 start,
832 end: j,
833 line: line_of(source, start),
834 });
835 i = j;
836 continue;
837 }
838 if c.is_ascii_alphabetic() || c == '_' {
839 let mut j = i + 1;
840 while j < bytes.len() {
841 let cj = bytes[j] as char;
842 if cj.is_ascii_alphanumeric() || cj == '_' || cj == '.' {
843 j += 1;
844 } else {
845 break;
846 }
847 }
848 let mut end = j;
850 while end > i && bytes[end - 1] as char == '.' {
851 end -= 1;
852 }
853 tokens.push(Token {
854 tok: Tok::Ident(source[i..end].to_owned()),
855 start,
856 end,
857 line: line_of(source, start),
858 });
859 i = end.max(i + 1);
860 continue;
861 }
862 if source[i..].starts_with("->") {
863 tokens.push(Token {
864 tok: Tok::Arrow,
865 start,
866 end: i + 2,
867 line: line_of(source, i),
868 });
869 i += 2;
870 continue;
871 }
872 if source[i..].starts_with("=>") {
873 tokens.push(Token {
874 tok: Tok::FatArrow,
875 start,
876 end: i + 2,
877 line: line_of(source, i),
878 });
879 i += 2;
880 continue;
881 }
882 let two_char = [
883 ("==", "=="),
884 ("!=", "!="),
885 ("<=", "<="),
886 (">=", ">="),
887 ("&&", "&&"),
888 ("||", "||"),
889 ]
890 .iter()
891 .find(|(text, _)| source[i..].starts_with(text))
892 .map(|(_, op)| *op);
893 if let Some(op) = two_char {
894 tokens.push(Token {
895 tok: Tok::Op(op),
896 start,
897 end: i + 2,
898 line: line_of(source, i),
899 });
900 i += 2;
901 continue;
902 }
903 if c == '#' || (c == '/' && bytes.get(i + 1) == Some(&b'/')) {
904 let mut k = i;
911 let mut line_leading = true;
912 while k > 0 {
913 let prev = bytes[k - 1] as char;
914 if prev == '\n' {
915 break;
916 }
917 if prev != ' ' && prev != '\t' {
918 line_leading = false;
919 break;
920 }
921 k -= 1;
922 }
923 if line_leading {
924 while i < bytes.len() && bytes[i] as char != '\n' {
925 i += 1;
926 }
927 continue;
928 }
929 }
930 match c {
931 '{' | '}' | '[' | ']' | '(' | ')' | ',' | '.' | '+' | '-' | '*' | '/' | '<' | '>'
932 | '!' | ':' | ';' => {
933 tokens.push(Token {
934 tok: Tok::Sym(c),
935 start,
936 end: i + 1,
937 line: line_of(source, start),
938 });
939 i += 1;
940 }
941 _ => {
942 diagnostics.push(Diagnostic {
943 related: Vec::new(),
944 span: SourceSpan {
945 start: base + i,
946 end: base + i + 1,
947 },
948 message: format!("unexpected character `{c}` in rule body"),
949 suggestion: None,
950 });
951 i += 1;
952 }
953 }
954 }
955 tokens
956}
957
958pub fn blank_full_line_comments(text: &str) -> String {
967 let mut out: Vec<u8> = Vec::with_capacity(text.len());
968 let mut in_fence = false;
969 for line in text.split_inclusive('\n') {
970 let (content, has_newline) = match line.strip_suffix('\n') {
971 Some(content) => (content, true),
972 None => (line, false),
973 };
974 let lead = content.trim_start();
975 if !in_fence && (lead.starts_with('#') || lead.starts_with("//")) {
976 out.resize(out.len() + content.len(), b' ');
977 } else {
978 out.extend_from_slice(content.as_bytes());
979 if content.matches("\"\"\"").count() % 2 == 1 {
980 in_fence = !in_fence;
981 }
982 }
983 if has_newline {
984 out.push(b'\n');
985 }
986 }
987 String::from_utf8_lossy(&out).into_owned()
988}
989
990fn dedent_prompt(raw: &str) -> String {
991 let lines: Vec<&str> = raw.lines().collect();
992 let indent = lines
993 .iter()
994 .filter(|line| !line.trim().is_empty())
995 .map(|line| line.len() - line.trim_start().len())
996 .min()
997 .unwrap_or(0);
998 let mut text = lines
999 .iter()
1000 .map(|line| {
1001 if line.len() >= indent {
1002 &line[indent..]
1003 } else {
1004 line.trim_start()
1005 }
1006 })
1007 .collect::<Vec<_>>()
1008 .join("\n");
1009 while text.starts_with('\n') {
1010 text.remove(0);
1011 }
1012 while text.ends_with('\n') || text.ends_with(' ') {
1013 text.pop();
1014 }
1015 text
1016}
1017
1018pub fn parse_first_statement(source: &str, base: usize) -> (Option<BodyStmt>, Vec<Diagnostic>) {
1027 let mut diagnostics = Vec::new();
1028 let tokens = lex_body(source, base, &mut diagnostics);
1029 let mut parser = BodyParser {
1030 source,
1031 base,
1032 tokens,
1033 pos: 0,
1034 diagnostics,
1035 };
1036 let statement = parser.parse_statement();
1037 (statement, parser.diagnostics)
1038}
1039
1040pub fn parse_rule_body(source: &str, base: usize) -> (BodyAst, Vec<Diagnostic>) {
1041 let mut diagnostics = Vec::new();
1042 let tokens = lex_body(source, base, &mut diagnostics);
1043 let mut parser = BodyParser {
1044 source,
1045 base,
1046 tokens,
1047 pos: 0,
1048 diagnostics,
1049 };
1050 let statements = parser.parse_statements(false);
1051 (BodyAst { statements }, parser.diagnostics)
1052}
1053
1054struct BodyParser<'a> {
1055 source: &'a str,
1056 base: usize,
1057 tokens: Vec<Token>,
1058 pos: usize,
1059 diagnostics: Vec<Diagnostic>,
1060}
1061
1062impl<'a> BodyParser<'a> {
1063 fn peek(&self) -> Option<&Token> {
1064 self.tokens.get(self.pos)
1065 }
1066
1067 fn peek_at(&self, offset: usize) -> Option<&Token> {
1068 self.tokens.get(self.pos + offset)
1069 }
1070
1071 fn advance(&mut self) -> Option<Token> {
1072 let token = self.tokens.get(self.pos).cloned();
1073 if token.is_some() {
1074 self.pos += 1;
1075 }
1076 token
1077 }
1078
1079 fn at_ident(&self, value: &str) -> bool {
1080 matches!(self.peek().map(|t| &t.tok), Some(Tok::Ident(v)) if v == value)
1081 }
1082
1083 fn at_sym(&self, value: char) -> bool {
1084 matches!(self.peek().map(|t| &t.tok), Some(Tok::Sym(v)) if *v == value)
1085 }
1086
1087 fn consume_ident(&mut self, value: &str) -> bool {
1088 if self.at_ident(value) {
1089 self.pos += 1;
1090 true
1091 } else {
1092 false
1093 }
1094 }
1095
1096 fn consume_sym(&mut self, value: char) -> bool {
1097 if self.at_sym(value) {
1098 self.pos += 1;
1099 true
1100 } else {
1101 false
1102 }
1103 }
1104
1105 fn span_here(&self) -> SourceSpan {
1106 match self.peek() {
1107 Some(token) => SourceSpan {
1108 start: self.base + token.start,
1109 end: self.base + token.end,
1110 },
1111 None => SourceSpan {
1112 start: self.base + self.source.len(),
1113 end: self.base + self.source.len(),
1114 },
1115 }
1116 }
1117
1118 fn span_from(&self, start_token: usize) -> SourceSpan {
1119 let start = self
1120 .tokens
1121 .get(start_token)
1122 .map(|t| self.base + t.start)
1123 .unwrap_or(self.base);
1124 let end = self
1125 .tokens
1126 .get(self.pos.saturating_sub(1))
1127 .map(|t| self.base + t.end)
1128 .unwrap_or(start);
1129 SourceSpan { start, end }
1130 }
1131
1132 fn error(&mut self, span: SourceSpan, message: impl Into<String>, suggestion: Option<String>) {
1133 self.diagnostics.push(Diagnostic {
1134 related: Vec::new(),
1135 span,
1136 message: message.into(),
1137 suggestion,
1138 });
1139 }
1140
1141 fn ident_text(&mut self, what: &str) -> Option<String> {
1142 match self.peek().map(|t| t.tok.clone()) {
1143 Some(Tok::Ident(value)) => {
1144 self.pos += 1;
1145 Some(value)
1146 }
1147 _ => {
1148 let span = self.span_here();
1149 self.error(span, format!("expected {what}"), None);
1150 None
1151 }
1152 }
1153 }
1154
1155 fn recover(&mut self) {
1158 let mut depth = 0usize;
1159 while let Some(token) = self.peek() {
1160 match &token.tok {
1161 Tok::Sym('{') => depth += 1,
1162 Tok::Sym('}') if depth == 0 => return,
1163 Tok::Sym('}') => depth -= 1,
1164 Tok::Ident(value)
1165 if depth == 0
1166 && STATEMENT_KEYWORDS.contains(&value.as_str())
1167 && self.pos != 0 =>
1168 {
1169 return
1170 }
1171 _ => {}
1172 }
1173 self.pos += 1;
1174 }
1175 }
1176
1177 fn parse_statements(&mut self, in_block: bool) -> Vec<BodyStmt> {
1178 let mut statements = Vec::new();
1179 loop {
1180 if self.peek().is_none() {
1181 if in_block {
1182 let span = self.span_here();
1183 self.error(
1184 span,
1185 "unclosed block in rule body",
1186 Some("add `}`".to_owned()),
1187 );
1188 }
1189 return statements;
1190 }
1191 if self.at_sym('}') {
1192 if in_block {
1193 self.pos += 1;
1194 }
1195 return statements;
1196 }
1197 let before = self.pos;
1198 if let Some(statement) = self.parse_statement() {
1199 statements.push(statement);
1200 }
1201 if self.pos == before {
1202 self.pos += 1;
1204 self.recover();
1205 }
1206 }
1207 }
1208
1209 fn parse_statement(&mut self) -> Option<BodyStmt> {
1210 let start = self.pos;
1211 let keyword = match self.peek().map(|t| t.tok.clone()) {
1212 Some(Tok::Ident(value)) => value,
1213 _ => {
1214 let span = self.span_here();
1215 let package_verbs = EFFECT_OPERATION_GRAMMAR
1216 .iter()
1217 .map(|spec| spec.keyword)
1218 .collect::<Vec<_>>()
1219 .join(", ");
1220 self.error(
1221 span,
1222 "expected a rule body statement".to_owned(),
1223 Some(format!(
1224 "statements start with record, done, consume, during, until, tell, \
1225 coerce, prompt, decide, call, invoke, read, write, import, export, \
1226 after, case, complete, fail, timer, cancel, exec, file, claim, \
1227 release, finish, acquire, renew, append, emit, redact, or a package \
1228 effect verb ({package_verbs})"
1229 )),
1230 );
1231 self.recover();
1232 return None;
1233 }
1234 };
1235 if let Some(spec) = effect_operation_spec(&keyword) {
1238 return self.parse_effect_operation(spec);
1239 }
1240 match keyword.as_str() {
1241 "record" => self.parse_record_statement().map(BodyStmt::Record),
1242 "consume" if self.looks_like_counter_consume() => self.parse_counter_consume(),
1246 "consume" => self.removed_consume_alias(),
1247 "done" => self.parse_done_statement(),
1248 "during" => self.parse_region(false),
1249 "until" => self.parse_region(true),
1250 "tell" => self.parse_tell(),
1251 "coerce" => self.parse_coerce_call(),
1252 "prompt" => self.parse_prompt_effect(),
1253 "decide" => self.parse_decide(),
1254 "call" => self.parse_call(),
1255 "invoke" => self.parse_invoke(),
1256 "read" => self.parse_read(),
1257 "write" => self.parse_write(),
1258 "import" => self.parse_import(),
1259 "export" => self.parse_export(),
1260 "after" => self.parse_after(),
1261 "case" => self.parse_case(),
1262 "complete" | "fail" => self.parse_terminal(),
1263 "timer" => self.parse_timer(),
1264 "cancel" => self.parse_cancel(),
1265 "exec" => self.parse_exec(),
1266 "file" => self.parse_tracker_file(),
1267 "claim" => self.parse_tracker_claim(),
1268 "release" => self.parse_tracker_release(),
1269 "finish" => self.parse_tracker_finish(),
1270 "acquire" => self.parse_lease_acquire(),
1271 "renew" => self.parse_lease_renew(),
1272 "append" => self.parse_ledger_append(),
1273 "emit" => self.parse_emit_signal(),
1274 "redact" => self.parse_redact(),
1275 "when" | "on" => {
1276 let span = self.span_here();
1277 self.error(
1278 span,
1279 format!("`{keyword}` blocks are not rule body statements"),
1280 Some(
1281 "branch with `case`, guard the rule's `when` clause, or chain \
1282 effects with `then <binding> <- <effect>`"
1283 .to_owned(),
1284 ),
1285 );
1286 self.pos += 1;
1287 self.recover();
1288 None
1289 }
1290 other => {
1291 let span = self.span_here();
1292 self.error(
1293 span,
1294 format!("unknown rule body statement `{other}`"),
1295 Some(
1296 "statements start with record, done, tell, coerce, claim, \
1297 release, finish, file, call, recall, invoke, emit, after, case, complete, \
1298 fail, timer, cancel, decide, prompt, or exec"
1299 .to_owned(),
1300 ),
1301 );
1302 self.pos += 1;
1303 self.recover();
1304 None
1305 }
1306 }
1307 .inspect(|_| {
1308 let _ = start;
1309 })
1310 }
1311
1312 fn parse_record_statement(&mut self) -> Option<RecordStmt> {
1315 let start = self.pos;
1316 self.pos += 1; let schema = self.ident_text("class name after `record`")?;
1318 let from = if self.consume_ident("from") {
1319 Some(self.ident_text("binding name after `from`")?)
1320 } else {
1321 None
1322 };
1323 let fields = self.parse_field_block(from.is_some())?;
1324 Some(RecordStmt {
1325 schema,
1326 from,
1327 fields,
1328 span: self.span_from(start),
1329 })
1330 }
1331
1332 fn parse_done_statement(&mut self) -> Option<BodyStmt> {
1333 let start = self.pos;
1334 self.pos += 1; let binding = self.ident_text("fact binding after `done`")?;
1336 let replacement = if matches!(self.peek().map(|t| &t.tok), Some(Tok::Arrow)) {
1337 self.pos += 1;
1338 if !self.consume_ident("record") {
1339 let span = self.span_here();
1340 self.error(span, "expected `record` after `->`", None);
1341 return None;
1342 }
1343 self.pos -= 1; Some(self.parse_record_statement()?)
1345 } else {
1346 None
1347 };
1348 Some(BodyStmt::Done {
1349 binding,
1350 replacement,
1351 span: self.span_from(start),
1352 })
1353 }
1354
1355 fn removed_consume_alias(&mut self) -> Option<BodyStmt> {
1361 let span = self.span_here();
1362 self.error(
1363 span,
1364 "`consume` was removed; use `done`",
1365 Some("replace `consume` with `done`".to_owned()),
1366 );
1367 self.pos += 1; self.recover();
1372 None
1373 }
1374
1375 fn parse_field_block(&mut self, allow_shorthand: bool) -> Option<Vec<FieldAssign>> {
1379 if !self.consume_sym('{') {
1380 let span = self.span_here();
1381 self.error(span, "expected `{` to open a field block", None);
1382 return None;
1383 }
1384 let mut fields = Vec::new();
1385 loop {
1386 if self.consume_sym('}') {
1387 return Some(fields);
1388 }
1389 if self.peek().is_none() {
1390 let span = self.span_here();
1391 self.error(span, "unclosed field block", Some("add `}`".to_owned()));
1392 return Some(fields);
1393 }
1394 let field_start = self.pos;
1395 let Some(name) = self.ident_text("field name") else {
1396 self.recover();
1397 continue;
1398 };
1399 if matches!(self.peek().map(|t| &t.tok), Some(Tok::Ident(next))
1401 if next.chars().next().is_some_and(char::is_uppercase))
1402 && matches!(self.peek_at(1).map(|t| &t.tok), Some(Tok::Sym('{')))
1403 {
1404 let schema = self.ident_text("payload class name")?;
1405 let nested = self.parse_field_block(false)?;
1406 fields.push(FieldAssign {
1407 name,
1408 value: FieldValue::Nested {
1409 schema,
1410 fields: nested,
1411 },
1412 span: self.span_from(field_start),
1413 });
1414 continue;
1415 }
1416 if allow_shorthand {
1421 let name_line = self
1422 .tokens
1423 .get(field_start)
1424 .map(|t| t.line)
1425 .unwrap_or_default();
1426 let is_shorthand = match self.peek() {
1427 None => true,
1428 Some(token) => matches!(token.tok, Tok::Sym('}')) || token.line != name_line,
1429 };
1430 if is_shorthand {
1431 fields.push(FieldAssign {
1432 name,
1433 value: FieldValue::Shorthand,
1434 span: self.span_from(field_start),
1435 });
1436 continue;
1437 }
1438 }
1439 let Some((source, expr)) = self.parse_value_expression() else {
1440 self.recover();
1441 continue;
1442 };
1443 fields.push(FieldAssign {
1444 name,
1445 value: FieldValue::Expr { source, expr },
1446 span: self.span_from(field_start),
1447 });
1448 }
1449 }
1450
1451 fn parse_value_expression(&mut self) -> Option<(String, Expr)> {
1454 let start_token = self.pos;
1455 if !self.consume_value_atom() {
1456 let span = self.span_here();
1457 self.error(span, "expected a field value expression", None);
1458 return None;
1459 }
1460 loop {
1461 match self.peek().map(|t| t.tok.clone()) {
1462 Some(Tok::Op(_)) | Some(Tok::Sym('+')) | Some(Tok::Sym('-'))
1463 | Some(Tok::Sym('*')) | Some(Tok::Sym('/')) | Some(Tok::Sym('<'))
1464 | Some(Tok::Sym('>')) => {
1465 self.pos += 1;
1466 if !self.consume_value_atom() {
1467 let span = self.span_here();
1468 self.error(span, "expected expression after operator", None);
1469 return None;
1470 }
1471 }
1472 Some(Tok::Ident(word)) if word == "and" || word == "or" || word == "in" => {
1473 self.pos += 1;
1474 if !self.consume_value_atom() {
1475 let span = self.span_here();
1476 self.error(span, "expected expression after operator", None);
1477 return None;
1478 }
1479 }
1480 Some(Tok::Sym('[')) => {
1481 self.consume_balanced('[', ']');
1483 }
1484 _ => break,
1485 }
1486 }
1487 let first = self.tokens.get(start_token)?;
1488 let last = self.tokens.get(self.pos.saturating_sub(1))?;
1489 let source = self.source[first.start..last.end].to_owned();
1490 match parse_expression(&source) {
1491 Ok(expr) => Some((source, expr)),
1492 Err(message) => {
1493 let span = SourceSpan {
1494 start: self.base + first.start,
1495 end: self.base + last.end,
1496 };
1497 self.error(
1498 span,
1499 format!("invalid field value expression: {message}"),
1500 None,
1501 );
1502 None
1503 }
1504 }
1505 }
1506
1507 fn consume_value_atom(&mut self) -> bool {
1508 match self.peek().map(|t| t.tok.clone()) {
1509 Some(Tok::Str(_)) | Some(Tok::Number(_)) | Some(Tok::TripleStr { .. }) => {
1510 self.pos += 1;
1511 true
1512 }
1513 Some(Tok::Sym('[')) => self.consume_balanced('[', ']'),
1514 Some(Tok::Sym('{')) => self.consume_balanced('{', '}'),
1515 Some(Tok::Sym('(')) => self.consume_balanced('(', ')'),
1516 Some(Tok::Sym('!')) | Some(Tok::Sym('-')) => {
1517 self.pos += 1;
1518 self.consume_value_atom()
1519 }
1520 Some(Tok::Ident(word)) if word == "not" => {
1521 self.pos += 1;
1522 self.consume_value_atom()
1523 }
1524 Some(Tok::Ident(_)) => {
1525 self.pos += 1;
1526 if self.at_sym('(') {
1528 self.consume_balanced('(', ')');
1529 }
1530 true
1531 }
1532 _ => false,
1533 }
1534 }
1535
1536 fn consume_balanced(&mut self, open: char, close: char) -> bool {
1537 if !self.consume_sym(open) {
1538 return false;
1539 }
1540 let mut depth = 1;
1541 while depth > 0 {
1542 match self.advance().map(|t| t.tok) {
1543 Some(Tok::Sym(c)) if c == open => depth += 1,
1544 Some(Tok::Sym(c)) if c == close => depth -= 1,
1545 Some(_) => {}
1546 None => {
1547 let span = self.span_here();
1548 self.error(span, format!("unclosed `{open}`"), None);
1549 return false;
1550 }
1551 }
1552 }
1553 true
1554 }
1555
1556 fn parse_effect_modifiers(
1559 &mut self,
1560 binding: &mut Option<String>,
1561 requires: &mut Vec<String>,
1562 timeout_seconds: &mut Option<u64>,
1563 ) -> bool {
1564 loop {
1565 if self.consume_ident("as") {
1566 match self.ident_text("binding name after `as`") {
1567 Some(name) => *binding = Some(name),
1568 None => return false,
1569 }
1570 continue;
1571 }
1572 if self.consume_ident("requires") {
1573 match self.parse_string_array() {
1574 Some(values) => *requires = values,
1575 None => return false,
1576 }
1577 continue;
1578 }
1579 if self.consume_ident("timeout") {
1580 let span = self.span_here();
1581 let Some(Tok::Number(value)) = self.peek().map(|t| t.tok.clone()) else {
1582 self.error(
1583 span,
1584 "expected a duration after `timeout`".to_owned(),
1585 Some(
1586 "use `<n><unit>` with unit s, m, h, or d, e.g. `timeout 10m`"
1587 .to_owned(),
1588 ),
1589 );
1590 return false;
1591 };
1592 self.pos += 1;
1593 match parse_short_duration_seconds(&value) {
1594 Some(seconds) if seconds > 0 => *timeout_seconds = Some(seconds),
1595 _ => {
1596 self.error(
1597 span,
1598 format!("invalid timeout duration `{value}`"),
1599 Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
1600 );
1601 return false;
1602 }
1603 }
1604 continue;
1605 }
1606 return true;
1607 }
1608 }
1609
1610 fn parse_string_array(&mut self) -> Option<Vec<String>> {
1611 if !self.consume_sym('[') {
1612 let span = self.span_here();
1613 self.error(span, "expected `[` to open a string list", None);
1614 return None;
1615 }
1616 let mut values = Vec::new();
1617 loop {
1618 if self.consume_sym(']') {
1619 return Some(values);
1620 }
1621 match self.advance().map(|t| t.tok) {
1622 Some(Tok::Str(value)) => values.push(value),
1623 Some(Tok::Sym(',')) => {}
1624 other => {
1625 let span = self.span_here();
1626 self.error(
1627 span,
1628 format!("expected a string in list, found {other:?}"),
1629 None,
1630 );
1631 return None;
1632 }
1633 }
1634 }
1635 }
1636
1637 fn parse_prompt(&mut self) -> Option<Prompt> {
1638 match self.advance().map(|t| t.tok) {
1639 Some(Tok::Str(text)) => Some(Prompt {
1640 text,
1641 content_type: None,
1642 }),
1643 Some(Tok::TripleStr { text, content_type }) => Some(Prompt { text, content_type }),
1644 _ => {
1645 let span = self.span_here();
1646 self.error(span, "expected a prompt string", None);
1647 None
1648 }
1649 }
1650 }
1651
1652 fn parse_tell(&mut self) -> Option<BodyStmt> {
1653 let start = self.pos;
1654 self.pos += 1; let target = self.ident_text("agent target after `tell`")?;
1656 let mut binding = None;
1657 let mut requires = Vec::new();
1658 let mut timeout_seconds = None;
1659 let mut access_grants = Vec::new();
1660 let mut skills = Vec::new();
1661 if !self.parse_effect_modifiers_with_access(
1664 &mut binding,
1665 &mut requires,
1666 &mut timeout_seconds,
1667 &mut access_grants,
1668 Some(&mut skills),
1669 ) {
1670 return None;
1671 }
1672 let prompt = self.parse_prompt()?;
1673 if !self.parse_effect_modifiers_with_access(
1674 &mut binding,
1675 &mut requires,
1676 &mut timeout_seconds,
1677 &mut access_grants,
1678 Some(&mut skills),
1679 ) {
1680 return None;
1681 }
1682 Some(BodyStmt::Effect(EffectStmt {
1683 kind: BodyEffectKind::Tell {
1684 target,
1685 access_grants,
1686 skills,
1687 },
1688 binding,
1689 requires,
1690 timeout_seconds,
1691 prompt: Some(prompt),
1692 span: self.span_from(start),
1693 }))
1694 }
1695
1696 fn parse_effect_modifiers_with_access(
1699 &mut self,
1700 binding: &mut Option<String>,
1701 requires: &mut Vec<String>,
1702 timeout_seconds: &mut Option<u64>,
1703 access_grants: &mut Vec<AccessGrant>,
1704 mut skills: Option<&mut Vec<String>>,
1705 ) -> bool {
1706 loop {
1707 if !self.parse_effect_modifiers(binding, requires, timeout_seconds) {
1708 return false;
1709 }
1710 if self.at_ident("with") {
1711 if matches!(self.peek_at(1).map(|t| &t.tok), Some(Tok::Ident(v)) if v == "skills") {
1715 if let Some(acc) = skills.as_deref_mut() {
1716 if !self.parse_with_skills(acc) {
1717 return false;
1718 }
1719 continue;
1720 }
1721 }
1722 if !self.parse_access_grant(access_grants) {
1723 return false;
1724 }
1725 continue;
1726 }
1727 return true;
1728 }
1729 }
1730
1731 fn parse_with_skills(&mut self, skills: &mut Vec<String>) -> bool {
1734 self.pos += 1; self.pos += 1; if !self.at_sym('[') {
1737 let span = self.span_here();
1738 self.error(
1739 span,
1740 "expected `[\"skill\", …]` after `with skills`".to_owned(),
1741 None,
1742 );
1743 return false;
1744 }
1745 match self.parse_string_array() {
1746 Some(values) => {
1747 skills.extend(values);
1748 true
1749 }
1750 None => false,
1751 }
1752 }
1753
1754 fn parse_access_grant(&mut self, grants: &mut Vec<AccessGrant>) -> bool {
1760 let start = self.pos;
1761 self.pos += 1; if !self.consume_ident("access") {
1763 let span = self.span_here();
1764 let detail = if self.at_ident("context") || self.at_ident("skills") {
1765 "`with context`/`with skills` turn modifiers are not supported yet"
1766 } else {
1767 "expected `access to <resource> { ... }` after `with`"
1768 };
1769 self.error(span, detail.to_owned(), None);
1770 return false;
1771 }
1772 if !self.consume_ident("to") {
1773 let span = self.span_here();
1774 self.error(span, "expected `to` after `with access`".to_owned(), None);
1775 return false;
1776 }
1777 if self.consume_sym('{') {
1778 let mut resources = 0usize;
1779 loop {
1780 if self.consume_sym('}') {
1781 break;
1782 }
1783 resources += 1;
1784 let grant_start = self.pos;
1785 let Some(resource) =
1786 self.ident_text("resource in the access-grant shorthand block")
1787 else {
1788 return false;
1789 };
1790 if !self.consume_sym('{') {
1791 let span = self.span_here();
1792 self.error(
1793 span,
1794 "expected `{` to open the resource access-grant block".to_owned(),
1795 None,
1796 );
1797 return false;
1798 }
1799 let Some(operations) = self.parse_access_grant_operations() else {
1800 return false;
1801 };
1802 grants.push(AccessGrant {
1803 resource,
1804 operations,
1805 span: self.span_from(grant_start),
1806 });
1807 }
1808 if resources == 0 {
1809 let span = self.span_from(start);
1810 self.error(
1811 span,
1812 "access-grant shorthand block grants no resources".to_owned(),
1813 Some(
1814 "write `with access to <resource> { ... }`, or add resource blocks inside the shorthand"
1815 .to_owned(),
1816 ),
1817 );
1818 return false;
1819 }
1820 return true;
1821 }
1822 let Some(resource) = self.ident_text("resource after `with access to`") else {
1823 return false;
1824 };
1825 if !self.consume_sym('{') {
1826 let span = self.span_here();
1827 self.error(
1828 span,
1829 "expected `{` to open the access-grant block".to_owned(),
1830 None,
1831 );
1832 return false;
1833 }
1834 let Some(operations) = self.parse_access_grant_operations() else {
1835 return false;
1836 };
1837 grants.push(AccessGrant {
1838 resource,
1839 operations,
1840 span: self.span_from(start),
1841 });
1842 true
1843 }
1844
1845 fn parse_access_grant_operations(&mut self) -> Option<Vec<AccessGrantOp>> {
1846 let mut operations = Vec::new();
1847 loop {
1848 if self.consume_sym('}') {
1849 return Some(operations);
1850 }
1851 let op_start = self.pos;
1852 let operation = self.ident_text("operation in the access-grant block")?;
1853 let mut target = None;
1854 if self.consume_ident("for") {
1855 target = Some(self.ident_text("target after `for`")?);
1856 }
1857 let mut globs = Vec::new();
1858 if self.at_sym('[') {
1859 globs = self.parse_string_array()?;
1860 }
1861 operations.push(AccessGrantOp {
1862 operation,
1863 target,
1864 globs,
1865 span: self.span_from(op_start),
1866 });
1867 }
1868 }
1869
1870 fn parse_coerce_call(&mut self) -> Option<BodyStmt> {
1871 let start = self.pos;
1872 self.pos += 1; let name = self.ident_text("coerce function name")?;
1874 if !self.consume_sym('(') {
1875 let span = self.span_here();
1876 self.error(span, "expected `(` after coerce function name", None);
1877 return None;
1878 }
1879 let mut args = Vec::new();
1880 loop {
1881 if self.consume_sym(')') {
1882 break;
1883 }
1884 if self.peek().is_none() {
1885 let span = self.span_here();
1886 self.error(span, "unclosed coerce argument list", None);
1887 return None;
1888 }
1889 let (source, _) = self.parse_value_expression()?;
1890 args.push(source);
1891 self.consume_sym(',');
1892 }
1893 let mut binding = None;
1894 let mut requires = Vec::new();
1895 let mut timeout_seconds = None;
1896 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
1897 return None;
1898 }
1899 let mut endorsed = false;
1901 let mut declassified = false;
1902 loop {
1903 if self.consume_ident("endorsed") {
1904 endorsed = true;
1905 } else if self.consume_ident("declassified") {
1906 declassified = true;
1907 } else {
1908 break;
1909 }
1910 }
1911 Some(BodyStmt::Effect(EffectStmt {
1912 kind: BodyEffectKind::Coerce {
1913 name,
1914 args,
1915 endorsed,
1916 declassified,
1917 },
1918 binding,
1919 requires,
1920 timeout_seconds,
1921 prompt: None,
1922 span: self.span_from(start),
1923 }))
1924 }
1925
1926 fn parse_prompt_effect(&mut self) -> Option<BodyStmt> {
1927 let start = self.pos;
1928 self.pos += 1; let prompt = self.parse_prompt()?;
1930 let provider = if self.consume_ident("using") {
1931 Some(self.ident_text("provider after `using`")?)
1932 } else {
1933 None
1934 };
1935 let mut binding = None;
1936 let mut requires = Vec::new();
1937 let mut timeout_seconds = None;
1938 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
1939 return None;
1940 }
1941 if binding.is_none() {
1942 let span = self.span_from(start);
1943 self.error(
1944 span,
1945 "`prompt` requires an `as` binding".to_owned(),
1946 Some("write `prompt \"Summarize this\" as summary`".to_owned()),
1947 );
1948 return None;
1949 }
1950 Some(BodyStmt::Effect(EffectStmt {
1951 kind: BodyEffectKind::Prompt { provider },
1952 binding,
1953 requires,
1954 timeout_seconds,
1955 prompt: Some(prompt),
1956 span: self.span_from(start),
1957 }))
1958 }
1959
1960 fn parse_decide(&mut self) -> Option<BodyStmt> {
1961 let start = self.pos;
1962 self.pos += 1; let prompt = self.parse_prompt()?;
1964 if !matches!(self.advance().map(|t| t.tok), Some(Tok::Arrow)) {
1965 let span = self.span_here();
1966 self.error(
1967 span,
1968 "expected `->` after the decide prompt".to_owned(),
1969 Some("write `decide \"...\" -> { field type, ... } as name`".to_owned()),
1970 );
1971 return None;
1972 }
1973 if !self.consume_sym('{') {
1974 let span = self.span_here();
1975 self.error(span, "expected `{` to open the decide result shape", None);
1976 return None;
1977 }
1978 let mut result_fields = Vec::new();
1979 loop {
1980 if self.consume_sym('}') {
1981 break;
1982 }
1983 let name = self.ident_text("result field name")?;
1984 let ty = self.ident_text("result field type")?;
1985 result_fields.push((name, ty));
1986 self.consume_sym(',');
1987 }
1988 let mut binding = None;
1989 let mut requires = Vec::new();
1990 let mut timeout_seconds = None;
1991 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
1992 return None;
1993 }
1994 if binding.is_none() {
1995 let span = self.span_from(start);
1996 self.error(
1997 span,
1998 "`decide` requires an `as` binding".to_owned(),
1999 Some(
2000 "the typed result is only reachable through `after <binding> succeeds`"
2001 .to_owned(),
2002 ),
2003 );
2004 }
2005 Some(BodyStmt::Effect(EffectStmt {
2006 kind: BodyEffectKind::Decide { result_fields },
2007 binding,
2008 requires,
2009 timeout_seconds,
2010 prompt: Some(prompt),
2011 span: self.span_from(start),
2012 }))
2013 }
2014
2015 fn parse_call(&mut self) -> Option<BodyStmt> {
2016 let start = self.pos;
2017 self.pos += 1; let capability = self.ident_text("package capability after `call`")?;
2019 let argument = if self.consume_ident("for") {
2020 Some(self.ident_text("argument binding after `for`")?)
2021 } else {
2022 None
2023 };
2024 let mut binding = None;
2025 let mut requires = Vec::new();
2026 let mut timeout_seconds = None;
2027 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2028 return None;
2029 }
2030 Some(BodyStmt::Effect(EffectStmt {
2031 kind: BodyEffectKind::Call {
2032 capability,
2033 argument,
2034 },
2035 binding,
2036 requires,
2037 timeout_seconds,
2038 prompt: None,
2039 span: self.span_from(start),
2040 }))
2041 }
2042
2043 fn parse_effect_operation(&mut self, spec: &EffectOperationSpec) -> Option<BodyStmt> {
2051 let start = self.pos;
2052 self.pos += 1; let mut fields: Vec<ConstructUseField> = Vec::new();
2054 for slot in spec.slots {
2055 if let Some(connective) = slot.connective {
2056 if !self.consume_ident(connective) {
2057 let span = self.span_here();
2058 self.error(
2059 span,
2060 format!("expected `{connective}` after `{}`", spec.keyword),
2061 None,
2062 );
2063 return None;
2064 }
2065 }
2066 let source = match slot.kind {
2067 SlotKind::Identifier => self.ident_text(slot.name)?,
2068 SlotKind::Expression => self.parse_value_expression()?.0,
2069 };
2070 fields.push(ConstructUseField {
2071 name: slot.name.to_owned(),
2072 source,
2073 });
2074 }
2075 if let Some(payload) = spec.payload {
2076 let block_fields = self.parse_field_block(false)?;
2077 let mut seen: Vec<&'static str> = Vec::new();
2078 for field in &block_fields {
2079 let Some(field_spec) = payload.iter().find(|f| f.name == field.name) else {
2080 self.error(
2081 field.span,
2082 format!("unknown `{}` block field `{}`", spec.keyword, field.name),
2083 None,
2084 );
2085 return None;
2086 };
2087 let FieldValue::Expr { source, .. } = &field.value else {
2088 self.error(
2089 field.span,
2090 format!(
2091 "`{}` field `{}` must be an expression",
2092 spec.keyword, field.name
2093 ),
2094 None,
2095 );
2096 return None;
2097 };
2098 seen.push(field_spec.name);
2099 fields.push(ConstructUseField {
2100 name: field.name.clone(),
2101 source: source.clone(),
2102 });
2103 }
2104 for required in payload.iter().filter(|f| f.required) {
2105 if !seen.contains(&required.name) {
2106 let span = self.span_from(start);
2107 self.error(
2108 span,
2109 format!("`{}` requires a `{}` field", spec.keyword, required.name),
2110 None,
2111 );
2112 return None;
2113 }
2114 }
2115 }
2116 let mut binding = None;
2117 let mut requires = Vec::new();
2118 let mut timeout_seconds = None;
2119 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2120 return None;
2121 }
2122 match spec.binding {
2123 BindingMode::Required if binding.is_none() => {
2124 let span = self.span_from(start);
2125 self.error(
2126 span,
2127 format!("`{}` requires an `as` binding", spec.keyword),
2128 None,
2129 );
2130 return None;
2131 }
2132 BindingMode::None if binding.is_some() => {
2133 let span = self.span_from(start);
2134 self.error(
2135 span,
2136 format!("`{}` does not take an `as` binding", spec.keyword),
2137 None,
2138 );
2139 return None;
2140 }
2141 _ => {}
2142 }
2143 Some(BodyStmt::Effect(EffectStmt {
2144 kind: BodyEffectKind::ConstructCapabilityCall {
2145 keyword: spec.keyword.to_owned(),
2146 target_capability: spec.target_capability.to_owned(),
2147 fields,
2148 },
2149 binding,
2150 requires,
2151 timeout_seconds,
2152 prompt: None,
2153 span: self.span_from(start),
2154 }))
2155 }
2156
2157 fn parse_read(&mut self) -> Option<BodyStmt> {
2158 let start = self.pos;
2159 self.pos += 1; let usage = "write `read <format> from <store> at <path> as <binding>`".to_owned();
2161 let format = self.ident_text("file format after `read`")?;
2162 if !matches!(format.as_str(), "text" | "markdown") {
2169 let span = self.span_from(start);
2170 self.error(
2171 span,
2172 format!(
2173 "`read {format}` is not supported in v0 — `read` decodes only `text` or `markdown` bodies"
2174 ),
2175 Some(
2176 "use `read text`/`read markdown` for a body, `import <format> <Schema>` for structured rows, or `read text` + `coerce` to interpret structured content".to_owned(),
2177 ),
2178 );
2179 return None;
2180 }
2181 if !self.consume_ident("from") {
2182 let span = self.span_here();
2183 self.error(
2184 span,
2185 "expected `from` after read format".to_owned(),
2186 Some(usage),
2187 );
2188 return None;
2189 }
2190 let store = self.ident_text("file store after `from`")?;
2191 if !self.consume_ident("at") {
2192 let span = self.span_here();
2193 self.error(
2194 span,
2195 "expected `at` after read store".to_owned(),
2196 Some(usage),
2197 );
2198 return None;
2199 }
2200 let (path, _) = self.parse_value_expression()?;
2201 let mut binding = None;
2202 let mut requires = Vec::new();
2203 let mut timeout_seconds = None;
2204 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2205 return None;
2206 }
2207 if binding.is_none() {
2208 let span = self.span_from(start);
2209 self.error(
2210 span,
2211 "`read` requires an `as` binding".to_owned(),
2212 Some(usage),
2213 );
2214 return None;
2215 }
2216 Some(BodyStmt::Effect(EffectStmt {
2217 kind: BodyEffectKind::FileRead {
2218 format,
2219 store,
2220 path,
2221 },
2222 binding,
2223 requires,
2224 timeout_seconds,
2225 prompt: None,
2226 span: self.span_from(start),
2227 }))
2228 }
2229
2230 fn parse_write(&mut self) -> Option<BodyStmt> {
2231 let start = self.pos;
2232 self.pos += 1; let usage =
2234 "write `write <format> to <store> at <path> { body <expr> mode <mode> } as <binding>`"
2235 .to_owned();
2236 let format = self.ident_text("file format after `write`")?;
2237 if !matches!(format.as_str(), "text" | "markdown") {
2240 let span = self.span_from(start);
2241 self.error(
2242 span,
2243 format!(
2244 "`write {format}` is not supported in v0 — `write` renders only `text` or `markdown` bodies"
2245 ),
2246 Some(
2247 "use `write text`/`write markdown` for a body; structured `export <format> <Schema>` is deferred".to_owned(),
2248 ),
2249 );
2250 return None;
2251 }
2252 if !self.consume_ident("to") {
2253 let span = self.span_here();
2254 self.error(
2255 span,
2256 "expected `to` after write format".to_owned(),
2257 Some(usage),
2258 );
2259 return None;
2260 }
2261 let store = self.ident_text("file store after `to`")?;
2262 if !self.consume_ident("at") {
2263 let span = self.span_here();
2264 self.error(
2265 span,
2266 "expected `at` after write store".to_owned(),
2267 Some(usage),
2268 );
2269 return None;
2270 }
2271 let (path, _) = self.parse_value_expression()?;
2272 let fields = self.parse_field_block(false)?;
2273 let mut body = None;
2274 let mut mode = None;
2275 for field in &fields {
2276 match field.name.as_str() {
2277 "body" => {
2278 if let FieldValue::Expr { source, .. } = &field.value {
2279 body = Some(source.clone());
2280 }
2281 }
2282 "mode" => {
2283 if let FieldValue::Expr { source, .. } = &field.value {
2284 mode = Some(source.trim().trim_matches('"').to_owned());
2285 }
2286 }
2287 other => {
2288 self.error(
2289 field.span,
2290 format!(
2291 "unknown `write` block field `{other}` (expected `body` or `mode`)"
2292 ),
2293 Some(usage.clone()),
2294 );
2295 return None;
2296 }
2297 }
2298 }
2299 let Some(body) = body else {
2300 let span = self.span_from(start);
2301 self.error(
2302 span,
2303 "`write` requires a `body` field".to_owned(),
2304 Some(usage),
2305 );
2306 return None;
2307 };
2308 let Some(mode) = mode else {
2310 let span = self.span_from(start);
2311 self.error(
2312 span,
2313 "`write` requires an explicit `mode` (create/replace/upsert/append) — no silent overwrite".to_owned(),
2314 Some(usage),
2315 );
2316 return None;
2317 };
2318 if !matches!(mode.as_str(), "create" | "replace" | "upsert" | "append") {
2319 let span = self.span_from(start);
2320 self.error(
2321 span,
2322 format!("unknown write mode `{mode}` (expected create/replace/upsert/append)"),
2323 Some(usage),
2324 );
2325 return None;
2326 }
2327 let mut binding = None;
2328 let mut requires = Vec::new();
2329 let mut timeout_seconds = None;
2330 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2331 return None;
2332 }
2333 if binding.is_none() {
2334 let span = self.span_from(start);
2335 self.error(
2336 span,
2337 "`write` requires an `as` binding".to_owned(),
2338 Some(usage),
2339 );
2340 return None;
2341 }
2342 Some(BodyStmt::Effect(EffectStmt {
2343 kind: BodyEffectKind::FileWrite {
2344 format,
2345 store,
2346 path,
2347 body,
2348 mode,
2349 },
2350 binding,
2351 requires,
2352 timeout_seconds,
2353 prompt: None,
2354 span: self.span_from(start),
2355 }))
2356 }
2357
2358 fn parse_import(&mut self) -> Option<BodyStmt> {
2359 let start = self.pos;
2360 self.pos += 1; let usage =
2362 "write `import <format> <Schema> from <store> at <path> as <binding>`".to_owned();
2363 let format = self.ident_text("import format after `import`")?;
2364 if !matches!(format.as_str(), "jsonl" | "json" | "csv") {
2366 let span = self.span_from(start);
2367 self.error(
2368 span,
2369 format!(
2370 "`import {format}` is not supported in v0 — `import` decodes `jsonl`, `json`, or `csv`"
2371 ),
2372 Some(usage),
2373 );
2374 return None;
2375 }
2376 let schema = self.ident_text("row schema after import format")?;
2377 if !self.consume_ident("from") {
2378 let span = self.span_here();
2379 self.error(
2380 span,
2381 "expected `from` after import schema".to_owned(),
2382 Some(usage),
2383 );
2384 return None;
2385 }
2386 let store = self.ident_text("file store after `from`")?;
2387 if !self.consume_ident("at") {
2388 let span = self.span_here();
2389 self.error(
2390 span,
2391 "expected `at` after import store".to_owned(),
2392 Some(usage),
2393 );
2394 return None;
2395 }
2396 let (path, _) = self.parse_value_expression()?;
2397 let mut binding = None;
2398 let mut requires = Vec::new();
2399 let mut timeout_seconds = None;
2400 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2401 return None;
2402 }
2403 if binding.is_none() {
2404 let span = self.span_from(start);
2405 self.error(
2406 span,
2407 "`import` requires an `as` binding".to_owned(),
2408 Some(usage),
2409 );
2410 return None;
2411 }
2412 Some(BodyStmt::Effect(EffectStmt {
2413 kind: BodyEffectKind::FileImport {
2414 format,
2415 schema,
2416 store,
2417 path,
2418 },
2419 binding,
2420 requires,
2421 timeout_seconds,
2422 prompt: None,
2423 span: self.span_from(start),
2424 }))
2425 }
2426
2427 fn parse_export(&mut self) -> Option<BodyStmt> {
2428 let start = self.pos;
2429 self.pos += 1; let usage =
2431 "write `export <format> <Schema> to <store> at <path> { [where <pred>] mode <mode> } as <binding>`"
2432 .to_owned();
2433 let format = self.ident_text("export format after `export`")?;
2434 if !matches!(format.as_str(), "jsonl" | "json" | "csv") {
2435 let span = self.span_from(start);
2436 self.error(
2437 span,
2438 format!(
2439 "`export {format}` is not supported in v0 — `export` writes `jsonl`, `json`, or `csv`"
2440 ),
2441 Some(usage),
2442 );
2443 return None;
2444 }
2445 let schema = self.ident_text("row schema after export format")?;
2446 if !self.consume_ident("to") {
2447 let span = self.span_here();
2448 self.error(
2449 span,
2450 "expected `to` after export schema".to_owned(),
2451 Some(usage),
2452 );
2453 return None;
2454 }
2455 let store = self.ident_text("file store after `to`")?;
2456 if !self.consume_ident("at") {
2457 let span = self.span_here();
2458 self.error(
2459 span,
2460 "expected `at` after export store".to_owned(),
2461 Some(usage),
2462 );
2463 return None;
2464 }
2465 let (path, _) = self.parse_value_expression()?;
2466 if !self.consume_sym('{') {
2467 let span = self.span_here();
2468 self.error(
2469 span,
2470 "expected `{` to open the export block".to_owned(),
2471 Some(usage),
2472 );
2473 return None;
2474 }
2475 let mut predicate = None;
2479 let mut mode = None;
2480 loop {
2481 if self.consume_sym('}') {
2482 break;
2483 }
2484 if self.peek().is_none() {
2485 let span = self.span_here();
2486 self.error(span, "unclosed export block".to_owned(), Some(usage));
2487 return None;
2488 }
2489 if self.consume_ident("where") {
2490 let (source, _) = self.parse_value_expression()?;
2491 predicate = Some(source);
2492 } else if self.consume_ident("mode") {
2493 let value = self.ident_text("write mode after `mode`")?;
2494 mode = Some(value);
2495 } else {
2496 let span = self.span_here();
2497 self.error(
2498 span,
2499 "unknown export block field (expected `where` or `mode`)".to_owned(),
2500 Some(usage.clone()),
2501 );
2502 self.recover();
2503 }
2504 }
2505 let Some(mode) = mode else {
2506 let span = self.span_from(start);
2507 self.error(
2508 span,
2509 "`export` requires an explicit `mode` (create/replace/upsert/append) — no silent overwrite".to_owned(),
2510 Some(usage),
2511 );
2512 return None;
2513 };
2514 if !matches!(mode.as_str(), "create" | "replace" | "upsert" | "append") {
2515 let span = self.span_from(start);
2516 self.error(
2517 span,
2518 format!("unknown write mode `{mode}` (expected create/replace/upsert/append)"),
2519 Some(usage),
2520 );
2521 return None;
2522 }
2523 let mut binding = None;
2524 let mut requires = Vec::new();
2525 let mut timeout_seconds = None;
2526 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2527 return None;
2528 }
2529 if binding.is_none() {
2530 let span = self.span_from(start);
2531 self.error(
2532 span,
2533 "`export` requires an `as` binding".to_owned(),
2534 Some(usage),
2535 );
2536 return None;
2537 }
2538 Some(BodyStmt::Effect(EffectStmt {
2539 kind: BodyEffectKind::FileExport {
2540 format,
2541 schema,
2542 store,
2543 path,
2544 predicate,
2545 mode,
2546 },
2547 binding,
2548 requires,
2549 timeout_seconds,
2550 prompt: None,
2551 span: self.span_from(start),
2552 }))
2553 }
2554
2555 fn parse_invoke(&mut self) -> Option<BodyStmt> {
2556 let start = self.pos;
2557 self.pos += 1; let workflow = self.ident_text("workflow name after `invoke`")?;
2559 let payload = self.parse_field_block(false)?;
2560 let mut binding = None;
2561 let mut requires = Vec::new();
2562 let mut timeout_seconds = None;
2563 let mut access_grants = Vec::new();
2564 if !self.parse_effect_modifiers_with_access(
2565 &mut binding,
2566 &mut requires,
2567 &mut timeout_seconds,
2568 &mut access_grants,
2569 None,
2570 ) {
2571 return None;
2572 }
2573 Some(BodyStmt::Effect(EffectStmt {
2574 kind: BodyEffectKind::Invoke {
2575 workflow,
2576 payload,
2577 access_grants,
2578 },
2579 binding,
2580 requires,
2581 timeout_seconds,
2582 prompt: None,
2583 span: self.span_from(start),
2584 }))
2585 }
2586
2587 fn parse_timer(&mut self) -> Option<BodyStmt> {
2588 let start = self.pos;
2589 self.pos += 1; let span = self.span_here();
2591 if matches!(self.peek().map(|t| &t.tok), Some(Tok::Ident(word)) if word == "until") {
2593 self.pos += 1; let until = match self.peek().map(|t| t.tok.clone()) {
2595 Some(Tok::Str(literal)) => {
2596 self.pos += 1;
2597 if !is_iso8601_instant(&literal) {
2598 self.error(
2599 span,
2600 format!("invalid time literal `{literal}`"),
2601 Some(
2602 "use an ISO-8601 instant such as `\"2026-06-15T09:00:00Z\"`"
2603 .to_owned(),
2604 ),
2605 );
2606 return None;
2607 }
2608 literal
2609 }
2610 Some(Tok::Ident(path)) => {
2611 let mut text = path;
2613 self.pos += 1;
2614 while matches!(self.peek().map(|t| &t.tok), Some(Tok::Sym('.'))) {
2615 self.pos += 1;
2616 if let Some(Tok::Ident(seg)) = self.peek().map(|t| t.tok.clone()) {
2617 text.push('.');
2618 text.push_str(&seg);
2619 self.pos += 1;
2620 } else {
2621 break;
2622 }
2623 }
2624 text
2625 }
2626 _ => {
2627 self.error(
2628 span,
2629 "expected a time literal or path after `timer until`".to_owned(),
2630 Some("e.g. `timer until \"2026-06-15T09:00:00Z\" as deadline` or `timer until ticket.dueAt as deadline`".to_owned()),
2631 );
2632 return None;
2633 }
2634 };
2635 let mut binding = None;
2636 let mut requires = Vec::new();
2637 let mut timeout_seconds = None;
2638 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2639 return None;
2640 }
2641 if binding.is_none() {
2642 let span = self.span_from(start);
2643 self.error(
2644 span,
2645 "`timer` requires an `as` binding".to_owned(),
2646 Some("rules react to the timer with `after <binding> succeeds`".to_owned()),
2647 );
2648 }
2649 return Some(BodyStmt::Effect(EffectStmt {
2650 kind: BodyEffectKind::Timer {
2651 duration_seconds: 0,
2652 duration_source: String::new(),
2653 until: Some(until),
2654 },
2655 binding,
2656 requires,
2657 timeout_seconds,
2658 prompt: None,
2659 span: self.span_from(start),
2660 }));
2661 }
2662 let Some(Tok::Number(value)) = self.peek().map(|t| t.tok.clone()) else {
2663 self.error(
2664 span,
2665 "expected a duration after `timer`".to_owned(),
2666 Some(
2667 "use `<n><unit>` with unit s, m, h, or d, e.g. `timer 24h as deadline`"
2668 .to_owned(),
2669 ),
2670 );
2671 return None;
2672 };
2673 self.pos += 1;
2674 let Some(duration_seconds) = parse_short_duration_seconds(&value).filter(|s| *s > 0) else {
2675 self.error(
2676 span,
2677 format!("invalid timer duration `{value}`"),
2678 Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
2679 );
2680 return None;
2681 };
2682 let mut binding = None;
2683 let mut requires = Vec::new();
2684 let mut timeout_seconds = None;
2685 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2686 return None;
2687 }
2688 if binding.is_none() {
2689 let span = self.span_from(start);
2690 self.error(
2691 span,
2692 "`timer` requires an `as` binding".to_owned(),
2693 Some("rules react to the timer with `after <binding> succeeds`".to_owned()),
2694 );
2695 }
2696 Some(BodyStmt::Effect(EffectStmt {
2697 kind: BodyEffectKind::Timer {
2698 duration_seconds,
2699 duration_source: value,
2700 until: None,
2701 },
2702 binding,
2703 requires,
2704 timeout_seconds,
2705 prompt: None,
2706 span: self.span_from(start),
2707 }))
2708 }
2709
2710 fn parse_cancel(&mut self) -> Option<BodyStmt> {
2711 let start = self.pos;
2712 self.pos += 1; let binding = self.ident_text("effect binding after `cancel`")?;
2714 Some(BodyStmt::Cancel {
2715 binding,
2716 span: self.span_from(start),
2717 })
2718 }
2719
2720 fn parse_redact(&mut self) -> Option<BodyStmt> {
2725 let start = self.pos;
2726 self.pos += 1; let source = self.ident_text("binding to redact after `redact`")?;
2728 if !self.consume_ident("keep") {
2729 let span = self.span_here();
2730 self.error(
2731 span,
2732 "expected `keep [<field>, …]` after the binding".to_owned(),
2733 Some("write `redact customer keep [id, status] as safe`".to_owned()),
2734 );
2735 return None;
2736 }
2737 if !self.consume_sym('[') {
2738 let span = self.span_here();
2739 self.error(
2740 span,
2741 "expected `[` to open the kept-field list".to_owned(),
2742 Some("write `keep [id, status]`".to_owned()),
2743 );
2744 return None;
2745 }
2746 let mut keep = Vec::new();
2747 loop {
2748 if self.consume_sym(']') {
2749 break;
2750 }
2751 if self.peek().is_none() {
2752 let span = self.span_here();
2753 self.error(
2754 span,
2755 "unclosed kept-field list".to_owned(),
2756 Some("add `]`".to_owned()),
2757 );
2758 return None;
2759 }
2760 let field = self.ident_text("kept field name")?;
2761 keep.push(field);
2762 if !self.consume_sym(',') && !self.at_sym(']') {
2763 let span = self.span_here();
2764 self.error(
2765 span,
2766 "expected `,` or `]` in the kept-field list".to_owned(),
2767 None,
2768 );
2769 return None;
2770 }
2771 }
2772 if !self.consume_ident("as") {
2773 let span = self.span_here();
2774 self.error(
2775 span,
2776 "`redact` requires an `as <binding>`".to_owned(),
2777 Some("write `redact customer keep [id] as safe`".to_owned()),
2778 );
2779 return None;
2780 }
2781 let binding = self.ident_text("output binding after `as`")?;
2782 if keep.is_empty() {
2783 let span = self.span_from(start);
2784 self.error(
2785 span,
2786 "`redact` must keep at least one field".to_owned(),
2787 Some("a redaction that keeps nothing has no value to release".to_owned()),
2788 );
2789 return None;
2790 }
2791 Some(BodyStmt::Redact {
2792 source,
2793 keep,
2794 binding,
2795 span: self.span_from(start),
2796 })
2797 }
2798
2799 fn parse_lease_acquire(&mut self) -> Option<BodyStmt> {
2803 let start = self.pos;
2804 self.pos += 1; let resource = self.ident_text("lease name after `acquire`")?;
2806 if !self.consume_ident("for") {
2807 let span = self.span_here();
2808 self.error(
2809 span,
2810 "expected `for <key>` after the lease name".to_owned(),
2811 Some("write `acquire deploy_slot for r.env as slot`".to_owned()),
2812 );
2813 return None;
2814 }
2815 let key_expr = self.dotted_path_text("lease key expression")?;
2816 let mut until_ttl = false;
2817 if self.at_ident("until") {
2818 self.pos += 1;
2819 if !self.consume_ident("ttl") {
2820 let span = self.span_here();
2821 self.error(
2822 span,
2823 "expected `ttl` after `until`".to_owned(),
2824 Some("`acquire ... until ttl` is the fire-and-forget form".to_owned()),
2825 );
2826 return None;
2827 }
2828 until_ttl = true;
2829 }
2830 let mut wait_seconds = None;
2834 if self.at_ident("wait") {
2835 self.pos += 1; let span = self.span_here();
2837 let Some(Tok::Number(value)) = self.peek().map(|t| t.tok.clone()) else {
2838 self.error(
2839 span,
2840 "expected a duration after `wait`".to_owned(),
2841 Some("use `<n><unit>` with unit s, m, h, or d, e.g. `wait 30s`".to_owned()),
2842 );
2843 return None;
2844 };
2845 self.pos += 1;
2846 match parse_short_duration_seconds(&value) {
2847 Some(seconds) if seconds > 0 => wait_seconds = Some(seconds),
2848 _ => {
2849 self.error(
2850 span,
2851 format!("invalid wait duration `{value}`"),
2852 Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
2853 );
2854 return None;
2855 }
2856 }
2857 }
2858 let mut binding = None;
2859 let mut requires = Vec::new();
2860 let mut timeout_seconds = None;
2861 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2862 return None;
2863 }
2864 if binding.is_none() {
2865 let span = self.span_from(start);
2866 self.error(
2867 span,
2868 "`acquire` requires an `as` binding".to_owned(),
2869 Some(
2870 "branch on it with `after <binding> held` and `after <binding> contended`"
2871 .to_owned(),
2872 ),
2873 );
2874 }
2875 Some(BodyStmt::Effect(EffectStmt {
2876 kind: BodyEffectKind::LeaseAcquire {
2877 resource,
2878 key_expr,
2879 until_ttl,
2880 wait_seconds,
2881 },
2882 binding,
2883 requires,
2884 timeout_seconds,
2885 prompt: None,
2886 span: self.span_from(start),
2887 }))
2888 }
2889
2890 fn parse_lease_renew(&mut self) -> Option<BodyStmt> {
2895 let start = self.pos;
2896 self.pos += 1; let acquire_binding = self.ident_text("lease binding after `renew`")?;
2898 let mut ttl_seconds = None;
2902 if self.at_ident("until") {
2903 self.pos += 1; let span = self.span_here();
2905 let Some(Tok::Number(value)) = self.peek().map(|t| t.tok.clone()) else {
2906 self.error(
2907 span,
2908 "expected a duration after `until`".to_owned(),
2909 Some("use `<n><unit>` with unit s, m, h, or d, e.g. `until 300s`".to_owned()),
2910 );
2911 return None;
2912 };
2913 self.pos += 1;
2914 match parse_short_duration_seconds(&value) {
2915 Some(seconds) if seconds > 0 => ttl_seconds = Some(seconds),
2916 _ => {
2917 self.error(
2918 span,
2919 format!("invalid ttl duration `{value}`"),
2920 Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
2921 );
2922 return None;
2923 }
2924 }
2925 }
2926 let mut binding = None;
2927 let mut requires = Vec::new();
2928 let mut timeout_seconds = None;
2929 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2930 return None;
2931 }
2932 if binding.is_none() {
2933 let span = self.span_from(start);
2934 self.error(
2935 span,
2936 "`renew` requires an `as` binding".to_owned(),
2937 Some(
2938 "branch on it with `after <binding> renewed` and `after <binding> notHeld`"
2939 .to_owned(),
2940 ),
2941 );
2942 }
2943 Some(BodyStmt::Effect(EffectStmt {
2944 kind: BodyEffectKind::LeaseRenew {
2945 acquire_binding,
2946 ttl_seconds,
2947 },
2948 binding,
2949 requires,
2950 timeout_seconds,
2951 prompt: None,
2952 span: self.span_from(start),
2953 }))
2954 }
2955
2956 fn parse_ledger_append(&mut self) -> Option<BodyStmt> {
2958 let start = self.pos;
2959 self.pos += 1; let schema = self.ident_text("entry schema after `append`")?;
2961 let fields = self.parse_field_block(false)?;
2962 if !self.consume_ident("to") {
2963 let span = self.span_here();
2964 self.error(
2965 span,
2966 "expected `to <ledger>` after the entry payload".to_owned(),
2967 Some("write `append Decision { ... } to decisions`".to_owned()),
2968 );
2969 return None;
2970 }
2971 let ledger = self.ident_text("ledger name after `to`")?;
2972 let mut binding = None;
2973 let mut requires = Vec::new();
2974 let mut timeout_seconds = None;
2975 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2976 return None;
2977 }
2978 Some(BodyStmt::Effect(EffectStmt {
2979 kind: BodyEffectKind::LedgerAppend {
2980 ledger,
2981 schema,
2982 fields,
2983 },
2984 binding,
2985 requires,
2986 timeout_seconds,
2987 prompt: None,
2988 span: self.span_from(start),
2989 }))
2990 }
2991
2992 fn looks_like_counter_consume(&self) -> bool {
2993 matches!(self.peek_at(1).map(|t| &t.tok), Some(Tok::Ident(_)))
2994 && matches!(self.peek_at(2).map(|t| &t.tok), Some(Tok::Ident(word)) if word == "for")
2995 }
2996
2997 fn parse_counter_consume(&mut self) -> Option<BodyStmt> {
3001 let start = self.pos;
3002 self.pos += 1; let counter = self.ident_text("counter name after `consume`")?;
3004 if !self.consume_ident("for") {
3005 let span = self.span_here();
3006 self.error(
3007 span,
3008 "expected `for <key>` after the counter name".to_owned(),
3009 Some(
3010 "write `consume model_budget for t.customer amount t.estTokens as spend`"
3011 .to_owned(),
3012 ),
3013 );
3014 return None;
3015 }
3016 let key_expr = self.dotted_path_text("counter key expression")?;
3017 if !self.consume_ident("amount") {
3018 let span = self.span_here();
3019 self.error(
3020 span,
3021 "expected `amount <expr>` after the counter key".to_owned(),
3022 Some(
3023 "write `consume model_budget for t.customer amount t.estTokens as spend`"
3024 .to_owned(),
3025 ),
3026 );
3027 return None;
3028 }
3029 let amount_expr = match self.peek().map(|t| t.tok.clone()) {
3030 Some(Tok::Number(value)) => {
3031 self.pos += 1;
3032 value
3033 }
3034 Some(Tok::Ident(_)) => self.dotted_path_text("consume amount")?,
3035 _ => {
3036 let span = self.span_here();
3037 self.error(
3038 span,
3039 "expected a number or path after `amount`".to_owned(),
3040 None,
3041 );
3042 return None;
3043 }
3044 };
3045 let mut binding = None;
3046 let mut requires = Vec::new();
3047 let mut timeout_seconds = None;
3048 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
3049 return None;
3050 }
3051 if binding.is_none() {
3052 let span = self.span_from(start);
3053 self.error(
3054 span,
3055 "`consume` requires an `as` binding".to_owned(),
3056 Some(
3057 "branch on it with `after <binding> ok` and `after <binding> over`".to_owned(),
3058 ),
3059 );
3060 }
3061 Some(BodyStmt::Effect(EffectStmt {
3062 kind: BodyEffectKind::CounterConsume {
3063 counter,
3064 key_expr,
3065 amount_expr,
3066 },
3067 binding,
3068 requires,
3069 timeout_seconds,
3070 prompt: None,
3071 span: self.span_from(start),
3072 }))
3073 }
3074
3075 fn parse_emit_signal(&mut self) -> Option<BodyStmt> {
3077 let start = self.pos;
3078 self.pos += 1; if self.at_ident("milestone") {
3083 return self.parse_emit_milestone(start);
3084 }
3085 if !self.consume_ident("signal") {
3086 let span = self.span_here();
3087 self.error(
3088 span,
3089 "the bare `emit <name>` statement was removed from the language; \
3090 `emit` must be followed by `signal` or `milestone`"
3091 .to_owned(),
3092 Some("write `emit signal deploy.finished to peer.id { ... }`".to_owned()),
3093 );
3094 return None;
3095 }
3096 let event = self.dotted_path_text("signal name after `signal`")?;
3097 if !self.consume_ident("to") {
3098 let span = self.span_here();
3099 self.error(
3100 span,
3101 "expected `to <target>` after the signal name".to_owned(),
3102 Some("write `emit signal deploy.finished to peer.id { ... }`".to_owned()),
3103 );
3104 return None;
3105 }
3106 let target_expr = self.dotted_path_text("target instance after `to`")?;
3107 let from = if self.consume_ident("from") {
3110 Some(self.ident_text("binding name after `from`")?)
3111 } else {
3112 None
3113 };
3114 let fields = if from.is_some() && !self.at_sym('{') {
3115 Vec::new()
3116 } else {
3117 self.parse_field_block(from.is_some())?
3118 };
3119 let mut binding = None;
3120 let mut requires = Vec::new();
3121 let mut timeout_seconds = None;
3122 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
3123 return None;
3124 }
3125 Some(BodyStmt::Effect(EffectStmt {
3126 kind: BodyEffectKind::Notify {
3127 target_expr,
3128 event,
3129 from,
3130 fields,
3131 },
3132 binding,
3133 requires,
3134 timeout_seconds,
3135 prompt: None,
3136 span: self.span_from(start),
3137 }))
3138 }
3139
3140 fn parse_emit_milestone(&mut self, start: usize) -> Option<BodyStmt> {
3144 self.pos += 1; let Some(Tok::Str(name)) = self.peek().map(|t| t.tok.clone()) else {
3146 let span = self.span_here();
3147 self.error(
3148 span,
3149 "expected a quoted milestone name after `milestone`".to_owned(),
3150 Some(
3151 "write `emit milestone \"canary_live\" of CanaryInfo { region \"us\" }`"
3152 .to_owned(),
3153 ),
3154 );
3155 return None;
3156 };
3157 self.pos += 1;
3158 let payload_class = if self.consume_ident("of") {
3161 Some(self.ident_text("payload class after `of`")?)
3162 } else {
3163 None
3164 };
3165 let fields = if matches!(self.peek().map(|t| &t.tok), Some(Tok::Sym('{'))) {
3166 self.parse_field_block(false)?
3167 } else {
3168 Vec::new()
3169 };
3170 Some(BodyStmt::Milestone {
3171 name,
3172 payload_class,
3173 fields,
3174 span: self.span_from(start),
3175 })
3176 }
3177
3178 fn dotted_path_text(&mut self, label: &str) -> Option<String> {
3180 let mut text = self.ident_text(label)?;
3181 while matches!(self.peek().map(|t| &t.tok), Some(Tok::Sym('.'))) {
3182 self.pos += 1;
3183 let Some(Tok::Ident(segment)) = self.peek().map(|t| t.tok.clone()) else {
3184 break;
3185 };
3186 text.push('.');
3187 text.push_str(&segment);
3188 self.pos += 1;
3189 }
3190 Some(text)
3191 }
3192
3193 fn parse_exec(&mut self) -> Option<BodyStmt> {
3194 let start = self.pos;
3195 self.pos += 1; let target = match self.advance().map(|t| t.tok) {
3197 Some(Tok::Str(value)) => ExecTarget::RawCommand(value),
3198 Some(Tok::Ident(name)) => {
3199 if !self.at_ident("with") {
3200 let span = self.span_here();
3201 self.error(
3202 span,
3203 "expected `with <binding>` after exec capability name".to_owned(),
3204 Some(format!(
3205 "write `exec {name} with input -> Report as result`"
3206 )),
3207 );
3208 return None;
3209 }
3210 self.pos += 1; let Some(Tok::Ident(stdin_binding)) = self.peek().map(|t| t.tok.clone()) else {
3212 let span = self.span_here();
3213 self.error(
3214 span,
3215 "expected a record binding after `with`".to_owned(),
3216 Some(format!(
3217 "write `exec {name} with input -> Report as result`"
3218 )),
3219 );
3220 return None;
3221 };
3222 self.pos += 1;
3223 ExecTarget::Capability {
3224 name,
3225 stdin_binding,
3226 }
3227 }
3228 _ => {
3229 let span = self.span_here();
3230 self.error(
3231 span,
3232 "expected a command string or capability name after `exec`".to_owned(),
3233 Some(
3234 "write `exec \"scripts/run-tests.sh\" as tests` or `exec backup_repo with input -> Report as result`"
3235 .to_owned(),
3236 ),
3237 );
3238 return None;
3239 }
3240 };
3241 let mut parse_target = None;
3244 if matches!(self.peek().map(|t| &t.tok), Some(Tok::Arrow)) {
3245 self.pos += 1; let each = if self.at_ident("each") {
3247 self.pos += 1;
3248 true
3249 } else {
3250 false
3251 };
3252 let Some(Tok::Ident(schema)) = self.peek().map(|t| t.tok.clone()) else {
3253 let span = self.span_here();
3254 self.error(
3255 span,
3256 "expected a schema name after `->`".to_owned(),
3257 Some(
3258 "write `exec \"report.sh\" -> Report as x` or `exec \"list.sh\" -> each WorkItem`"
3259 .to_owned(),
3260 ),
3261 );
3262 return None;
3263 };
3264 self.pos += 1;
3265 parse_target = Some(ExecParse { schema, each });
3266 }
3267 let mut binding = None;
3268 let mut requires = Vec::new();
3269 let mut timeout_seconds = None;
3270 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
3271 return None;
3272 }
3273 match &parse_target {
3274 Some(parse) if parse.each && binding.is_some() => {
3275 let span = self.span_from(start);
3276 self.error(
3277 span,
3278 "`-> each` produces a stream of facts, not a single binding".to_owned(),
3279 Some("drop the `as` binding and react with `when <Schema> as item`".to_owned()),
3280 );
3281 }
3282 Some(parse) if !parse.each && binding.is_none() => {
3283 let span = self.span_from(start);
3284 self.error(
3285 span,
3286 "`->` without `each` parses one value and needs an `as` binding".to_owned(),
3287 Some("write `exec \"report.sh\" -> Report as x` and read it with `after x succeeds as r`".to_owned()),
3288 );
3289 }
3290 _ => {}
3291 }
3292 Some(BodyStmt::Effect(EffectStmt {
3293 kind: BodyEffectKind::Exec {
3294 target,
3295 parse_target,
3296 },
3297 binding,
3298 requires,
3299 timeout_seconds,
3300 prompt: None,
3301 span: self.span_from(start),
3302 }))
3303 }
3304
3305 fn parse_tracker_file(&mut self) -> Option<BodyStmt> {
3308 let start = self.pos;
3309 self.pos += 1; if !self.consume_ident("issue") {
3311 let span = self.span_here();
3312 self.error(
3313 span,
3314 "expected `issue` after `file`",
3315 Some("write `file issue into <tracker> { ... }`".to_owned()),
3316 );
3317 return None;
3318 }
3319 if !self.consume_ident("into") {
3320 let span = self.span_here();
3321 self.error(span, "expected `into <tracker>` after `file issue`", None);
3322 return None;
3323 }
3324 let queue = self.ident_text("tracker name")?;
3325 let fields = self.parse_field_block(false)?;
3326 let mut binding = None;
3327 let mut requires = Vec::new();
3328 let mut timeout_seconds = None;
3329 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
3330 return None;
3331 }
3332 Some(BodyStmt::Effect(EffectStmt {
3333 kind: BodyEffectKind::TrackerFile { queue, fields },
3334 binding,
3335 requires,
3336 timeout_seconds,
3337 prompt: None,
3338 span: self.span_from(start),
3339 }))
3340 }
3341
3342 fn parse_tracker_claim(&mut self) -> Option<BodyStmt> {
3343 let start = self.pos;
3344 self.pos += 1; let item = self.ident_text("issue binding after `claim`")?;
3346 if self.at_ident("with") {
3347 let span = self.span_here();
3348 self.error(
3349 span,
3350 "`claim <issue> with ...` is not supported".to_owned(),
3351 Some("declare a `tracker` and write `claim <issue> [ttl <dur>] [as x]`".to_owned()),
3352 );
3353 self.pos += 1;
3354 let _ = self.advance();
3355 }
3356 let mut ttl_seconds = None;
3359 if self.at_ident("ttl") {
3360 self.pos += 1; let span = self.span_here();
3362 let Some(Tok::Number(value)) = self.peek().map(|t| t.tok.clone()) else {
3363 self.error(
3364 span,
3365 "expected a duration after `ttl`".to_owned(),
3366 Some("use `<n><unit>` with unit s, m, h, or d, e.g. `ttl 30m`".to_owned()),
3367 );
3368 return None;
3369 };
3370 self.pos += 1;
3371 match parse_short_duration_seconds(&value) {
3372 Some(seconds) if seconds > 0 => ttl_seconds = Some(seconds),
3373 _ => {
3374 self.error(
3375 span,
3376 format!("invalid ttl duration `{value}`"),
3377 Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
3378 );
3379 return None;
3380 }
3381 }
3382 }
3383 let mut binding = None;
3384 let mut requires = Vec::new();
3385 let mut timeout_seconds = None;
3386 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
3387 return None;
3388 }
3389 Some(BodyStmt::Effect(EffectStmt {
3390 kind: BodyEffectKind::TrackerClaim { item, ttl_seconds },
3391 binding,
3392 requires,
3393 timeout_seconds,
3394 prompt: None,
3395 span: self.span_from(start),
3396 }))
3397 }
3398
3399 fn parse_tracker_release(&mut self) -> Option<BodyStmt> {
3400 let start = self.pos;
3401 self.pos += 1; let item = self.ident_text("issue binding after `release`")?;
3403 Some(BodyStmt::Effect(EffectStmt {
3404 kind: BodyEffectKind::TrackerRelease { item },
3405 binding: None,
3406 requires: Vec::new(),
3407 timeout_seconds: None,
3408 prompt: None,
3409 span: self.span_from(start),
3410 }))
3411 }
3412
3413 fn parse_tracker_finish(&mut self) -> Option<BodyStmt> {
3414 let start = self.pos;
3415 self.pos += 1; let item = self.ident_text("issue binding after `finish`")?;
3417 let fields = if self.at_sym('{') {
3418 self.parse_field_block(false)?
3419 } else {
3420 Vec::new()
3421 };
3422 let mut binding = None;
3426 let mut requires = Vec::new();
3427 let mut timeout_seconds = None;
3428 if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
3429 return None;
3430 }
3431 Some(BodyStmt::Effect(EffectStmt {
3432 kind: BodyEffectKind::TrackerFinish { item, fields },
3433 binding,
3434 requires,
3435 timeout_seconds,
3436 prompt: None,
3437 span: self.span_from(start),
3438 }))
3439 }
3440
3441 fn parse_region(&mut self, until: bool) -> Option<BodyStmt> {
3448 let start = self.pos;
3449 let keyword = if until { "until" } else { "during" };
3450 self.pos += 1;
3451 let cond_start = self.pos;
3452 while self.pos < self.tokens.len() && !self.at_sym('{') {
3453 self.pos += 1;
3454 }
3455 if !self.at_sym('{') {
3456 let span = self.span_here();
3457 self.error(
3458 span,
3459 format!("expected `{{` to open the `{keyword}` region"),
3460 Some(format!(
3461 "write `{keyword} <condition> {{ … }} on lapse {{ … }}`"
3462 )),
3463 );
3464 return None;
3465 }
3466 let condition = if self.pos > cond_start {
3467 let from = self.tokens[cond_start].start;
3468 let to = self.tokens[self.pos - 1].end;
3469 self.source[from..to].trim().to_owned()
3470 } else {
3471 String::new()
3472 };
3473 if condition.is_empty() {
3474 let span = self.span_from(start);
3475 self.error(
3476 span,
3477 format!("`{keyword}` requires a condition"),
3478 Some("the condition is a pure query expression, like a guard".to_owned()),
3479 );
3480 return None;
3481 }
3482 let body_open = self.pos;
3483 self.pos += 1; let body_content_start = self
3485 .tokens
3486 .get(self.pos)
3487 .map(|token| self.base + token.start)
3488 .unwrap_or_else(|| self.base + self.tokens[body_open].end);
3489 let body = self.parse_statements(true);
3490 let body_content_end = self
3492 .tokens
3493 .get(self.pos.saturating_sub(1))
3494 .map(|token| self.base + token.start)
3495 .unwrap_or(body_content_start);
3496 let body_span = SourceSpan {
3497 start: body_content_start,
3498 end: body_content_end,
3499 };
3500 if !(self.consume_ident("on") && self.consume_ident("lapse")) {
3501 let span = self.span_here();
3502 self.error(
3503 span,
3504 format!("a `{keyword}` region requires its `on lapse {{ … }}` arm"),
3505 Some(
3506 "a reactive condition with no declared consequence would lapse silently; \
3507 write `on lapse { … }` (optionally `on lapse as <view> { … }`)"
3508 .to_owned(),
3509 ),
3510 );
3511 return None;
3512 }
3513 let lapse_binding = if self.consume_ident("as") {
3514 Some(self.ident_text("progress-view binding after `as`")?)
3515 } else {
3516 None
3517 };
3518 if !self.consume_sym('{') {
3519 let span = self.span_here();
3520 self.error(span, "expected `{` to open the `on lapse` arm", None);
3521 return None;
3522 }
3523 let lapse_open = self.pos - 1;
3524 let lapse_content_start = self
3525 .tokens
3526 .get(self.pos)
3527 .map(|token| self.base + token.start)
3528 .unwrap_or_else(|| self.base + self.tokens[lapse_open].end);
3529 let lapse_body = self.parse_statements(true);
3530 let lapse_content_end = self
3531 .tokens
3532 .get(self.pos.saturating_sub(1))
3533 .map(|token| self.base + token.start)
3534 .unwrap_or(lapse_content_start);
3535 Some(BodyStmt::Region(RegionBlock {
3536 until,
3537 condition,
3538 body,
3539 lapse_binding,
3540 lapse_body,
3541 body_span,
3542 lapse_span: SourceSpan {
3543 start: lapse_content_start,
3544 end: lapse_content_end,
3545 },
3546 span: self.span_from(start),
3547 }))
3548 }
3549
3550 fn parse_after(&mut self) -> Option<BodyStmt> {
3551 let start = self.pos;
3552 self.pos += 1; let binding = self.ident_text("effect binding after `after`")?;
3554 let mut milestone = None;
3555 let predicate = match self.advance().map(|t| t.tok) {
3556 Some(Tok::Ident(word)) => match word.as_str() {
3557 "succeeds" => AfterPredicate::Succeeds,
3558 "fails" => AfterPredicate::Fails,
3559 "completes" => AfterPredicate::Completes,
3560 "cancelled" => AfterPredicate::Cancelled,
3561 "reaches" => {
3565 let Some(Tok::Str(name)) = self.peek().map(|t| t.tok.clone()) else {
3566 let span = self.span_here();
3567 self.error(
3568 span,
3569 "expected a quoted milestone name after `reaches`".to_owned(),
3570 Some("write `after p reaches \"canary_live\" as m { ... }`".to_owned()),
3571 );
3572 return None;
3573 };
3574 self.pos += 1;
3575 milestone = Some(name);
3576 AfterPredicate::Reaches
3577 }
3578 "times" => {
3581 if !self.consume_ident("out") {
3582 let span = self.span_here();
3583 self.error(span, "expected `out` after `times`", None);
3584 return None;
3585 }
3586 AfterPredicate::TimedOut
3587 }
3588 "held" => AfterPredicate::Held,
3589 "contended" => AfterPredicate::Contended,
3590 "ok" => AfterPredicate::Ok,
3591 "over" => AfterPredicate::Over,
3592 other => {
3593 let span = self.span_from(start);
3594 self.error(
3595 span,
3596 format!("unsupported `after` predicate `{other}`"),
3597 Some(
3598 "use `succeeds`, `fails`, `completes`, `times out`, `cancelled`, or a coordination outcome (`held`, `contended`, `ok`, `over`)"
3599 .to_owned(),
3600 ),
3601 );
3602 return None;
3603 }
3604 },
3605 _ => {
3606 let span = self.span_here();
3607 self.error(
3608 span,
3609 "expected `succeeds`, `fails`, `completes`, `times out`, or `cancelled`",
3610 None,
3611 );
3612 return None;
3613 }
3614 };
3615 let alias = if self.consume_ident("as") {
3616 Some(self.ident_text("alias after `as`")?)
3617 } else {
3618 None
3619 };
3620 if !self.consume_sym('{') {
3621 let span = self.span_here();
3622 self.error(span, "expected `{` to open the `after` block", None);
3623 return None;
3624 }
3625 let body = self.parse_statements(true);
3626 Some(BodyStmt::After(AfterBlock {
3627 binding,
3628 predicate,
3629 alias,
3630 milestone,
3631 body,
3632 span: self.span_from(start),
3633 }))
3634 }
3635
3636 fn parse_case(&mut self) -> Option<BodyStmt> {
3637 let start = self.pos;
3638 self.pos += 1; let scrutinee = self.ident_text("case scrutinee path")?;
3640 if !self.consume_sym('{') {
3641 let span = self.span_here();
3642 self.error(span, "expected `{` to open the `case` block", None);
3643 return None;
3644 }
3645 let mut branches = Vec::new();
3646 loop {
3647 if self.consume_sym('}') {
3648 break;
3649 }
3650 if self.peek().is_none() {
3651 let span = self.span_here();
3652 self.error(span, "unclosed `case` block", Some("add `}`".to_owned()));
3653 break;
3654 }
3655 let branch_start = self.pos;
3656 let pattern = match self.advance().map(|t| t.tok) {
3657 Some(Tok::Ident(value)) => value,
3658 Some(Tok::Str(value)) => format!("{value:?}"),
3659 _ => {
3660 let span = self.span_here();
3661 self.error(span, "expected a case pattern", None);
3662 self.recover();
3663 continue;
3664 }
3665 };
3666 let binding = match self.peek().map(|t| t.tok.clone()) {
3667 Some(Tok::Ident(value)) if value == "as" => {
3670 self.pos += 1;
3671 match self.peek().map(|t| t.tok.clone()) {
3672 Some(Tok::Ident(name)) => {
3673 self.pos += 1;
3674 Some(name)
3675 }
3676 _ => {
3677 let span = self.span_here();
3678 self.error(
3679 span,
3680 "expected a binding name after `as`".to_owned(),
3681 Some("write `Variant as payload => { ... }`".to_owned()),
3682 );
3683 None
3684 }
3685 }
3686 }
3687 Some(Tok::Ident(value)) if value != "where" => {
3688 self.pos += 1;
3689 Some(value)
3690 }
3691 _ => None,
3692 };
3693 let guard = if self.consume_ident("where") {
3694 let guard_start = self.pos;
3695 while self.peek().is_some()
3697 && !matches!(self.peek().map(|t| &t.tok), Some(Tok::FatArrow))
3698 {
3699 self.pos += 1;
3700 }
3701 let first = self.tokens.get(guard_start);
3702 let last = self.tokens.get(self.pos.saturating_sub(1));
3703 match (first, last) {
3704 (Some(first), Some(last)) if guard_start < self.pos => {
3705 Some(self.source[first.start..last.end].to_owned())
3706 }
3707 _ => None,
3708 }
3709 } else {
3710 None
3711 };
3712 if !matches!(self.advance().map(|t| t.tok), Some(Tok::FatArrow)) {
3713 let span = self.span_here();
3714 self.error(span, "expected `=>` after case pattern", None);
3715 self.recover();
3716 continue;
3717 }
3718 if !self.consume_sym('{') {
3719 let span = self.span_here();
3720 self.error(span, "expected `{` to open the case branch", None);
3721 self.recover();
3722 continue;
3723 }
3724 let body = self.parse_statements(true);
3725 branches.push(CaseBranch {
3726 pattern,
3727 binding,
3728 guard,
3729 body,
3730 span: self.span_from(branch_start),
3731 });
3732 }
3733 Some(BodyStmt::Case(CaseBlock {
3734 scrutinee,
3735 branches,
3736 span: self.span_from(start),
3737 }))
3738 }
3739
3740 fn parse_terminal(&mut self) -> Option<BodyStmt> {
3741 let start = self.pos;
3742 let keyword = match self.advance()?.tok {
3743 Tok::Ident(value) => value,
3744 _ => return None,
3745 };
3746 let kind = if keyword == "complete" {
3747 TerminalKind::Complete
3748 } else {
3749 TerminalKind::Fail
3750 };
3751 let name = self.ident_text("terminal contract name")?;
3752 let from = if kind == TerminalKind::Complete && self.consume_ident("from") {
3756 Some(self.ident_text("binding name after `from`")?)
3757 } else {
3758 None
3759 };
3760 let (fields, scalar) =
3764 if from.is_none() && !matches!(self.peek().map(|t| &t.tok), Some(Tok::Sym('{'))) {
3765 let (source, expr) = self.parse_value_expression()?;
3766 (Vec::new(), Some(FieldValue::Expr { source, expr }))
3767 } else {
3768 (self.parse_field_block(from.is_some())?, None)
3769 };
3770 Some(BodyStmt::Terminal(TerminalStmt {
3771 kind,
3772 name,
3773 from,
3774 fields,
3775 scalar,
3776 span: self.span_from(start),
3777 }))
3778 }
3779}
3780
3781const STATEMENT_KEYWORDS: &[&str] = &[
3782 "record", "done", "consume", "tell", "coerce", "prompt", "claim", "release", "renew", "finish",
3783 "file", "call", "recall", "send", "invoke", "read", "write", "import", "export", "after",
3784 "case", "complete", "fail", "timer", "cancel", "decide", "exec", "when", "on", "else", "then",
3785 "redact",
3786];
3787
3788#[cfg(test)]
3789mod tests {
3790 use super::*;
3791
3792 fn parse_ok(source: &str) -> BodyAst {
3793 let (ast, diagnostics) = parse_rule_body(source, 0);
3794 assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
3795 ast
3796 }
3797
3798 #[test]
3799 fn full_line_comments_tokenize_as_nothing() {
3800 let ast = parse_ok(
3801 "# leading comment\nrecord Done {\n note \"x\"\n}\n # indented comment with braces { } and \"quotes\"\n// slash comments match the top-level lexer\ndone item\n",
3802 );
3803 assert_eq!(ast.statements.len(), 2, "comments contribute no statements");
3804 }
3805
3806 #[test]
3807 fn trailing_hash_still_errors() {
3808 let (_, diagnostics) = parse_rule_body("done item # trailing\n", 0);
3809 assert!(
3810 diagnostics
3811 .iter()
3812 .any(|d| d.message.contains("unexpected character `#`")),
3813 "trailing comments stay illegal: {diagnostics:?}"
3814 );
3815 }
3816
3817 #[test]
3818 fn blank_full_line_comments_is_byte_preserving_and_fence_aware() {
3819 let text = " # a comment\n tell a as t \"\"\"markdown\n # heading is content\n \"\"\"\n # after fence\n";
3820 let blanked = blank_full_line_comments(text);
3821 assert_eq!(blanked.len(), text.len(), "byte length preserved");
3822 assert!(!blanked.contains("# a comment"));
3823 assert!(!blanked.contains("# after fence"));
3824 assert!(
3825 blanked.contains("# heading is content"),
3826 "fence interior untouched: {blanked}"
3827 );
3828 }
3829
3830 #[test]
3831 fn generated_effect_operation_grammar_covers_the_std_constructs() {
3832 let table = EFFECT_OPERATION_GRAMMAR
3838 .iter()
3839 .map(|spec| (spec.keyword, spec.target_capability))
3840 .collect::<Vec<_>>();
3841 assert_eq!(
3842 table,
3843 vec![
3844 ("recall", "memory.query"),
3845 ("learn", "memory.write"),
3846 ("curate", "memory.curate"),
3847 ("send", "messaging.send"),
3848 ]
3849 );
3850 }
3851
3852 #[test]
3853 fn parses_redact_projection() {
3854 let ast = parse_ok("redact customer keep [id, status] as safe");
3855 let BodyStmt::Redact {
3856 source,
3857 keep,
3858 binding,
3859 ..
3860 } = &ast.statements[0]
3861 else {
3862 panic!("expected redact, got {:?}", ast.statements[0]);
3863 };
3864 assert_eq!(source, "customer");
3865 assert_eq!(keep, &["id".to_owned(), "status".to_owned()]);
3866 assert_eq!(binding, "safe");
3867 }
3868
3869 #[test]
3870 fn parses_complete_from_projection() {
3871 let ast = parse_ok("complete result from cust {\n id\n status\n}");
3872 let BodyStmt::Terminal(terminal) = &ast.statements[0] else {
3873 panic!("expected terminal, got {:?}", ast.statements[0]);
3874 };
3875 assert_eq!(terminal.kind, TerminalKind::Complete);
3876 assert_eq!(terminal.name, "result");
3877 assert_eq!(terminal.from.as_deref(), Some("cust"));
3878 assert_eq!(terminal.fields.len(), 2);
3879 assert!(terminal
3880 .fields
3881 .iter()
3882 .all(|f| matches!(f.value, FieldValue::Shorthand)));
3883 }
3884
3885 #[test]
3886 fn rejects_redact_keeping_nothing() {
3887 let (_, diagnostics) = parse_rule_body("redact customer keep [] as safe", 0);
3888 assert!(
3889 diagnostics
3890 .iter()
3891 .any(|d| d.message.contains("keep at least one field")),
3892 "expected empty-keep rejection, got {diagnostics:?}"
3893 );
3894 }
3895
3896 #[test]
3897 fn parses_single_line_record_fields() {
3898 let ast = parse_ok(r#"record Item { id "a" status "done" }"#);
3899 let BodyStmt::Record(record) = &ast.statements[0] else {
3900 panic!("expected record");
3901 };
3902 assert_eq!(record.schema, "Item");
3903 assert_eq!(record.fields.len(), 2);
3904 assert_eq!(record.fields[0].name, "id");
3905 assert_eq!(record.fields[1].name, "status");
3906 }
3907
3908 #[test]
3909 fn parses_multi_line_record_with_expressions() {
3910 let ast = parse_ok(
3911 "record Job {\n id job.id\n attempts job.attempts + 1\n status \"pending\"\n}",
3912 );
3913 let BodyStmt::Record(record) = &ast.statements[0] else {
3914 panic!("expected record");
3915 };
3916 assert_eq!(record.fields[1].name, "attempts");
3917 let FieldValue::Expr { source, .. } = &record.fields[1].value else {
3918 panic!("expected expression value");
3919 };
3920 assert_eq!(source, "job.attempts + 1");
3921 }
3922
3923 #[test]
3924 fn parses_done_with_replacement() {
3925 let ast = parse_ok("done task -> record Done {\n id task.id\n}");
3926 let BodyStmt::Done {
3927 binding,
3928 replacement,
3929 ..
3930 } = &ast.statements[0]
3931 else {
3932 panic!("expected done");
3933 };
3934 assert_eq!(binding, "task");
3935 assert!(replacement.is_some());
3936 }
3937
3938 #[test]
3939 fn consume_done_alias_is_removed() {
3940 let (ast, diagnostics) = parse_rule_body("consume task", 0);
3943 assert!(
3944 diagnostics
3945 .iter()
3946 .any(|d| d.message.contains("`consume` was removed")),
3947 "expected a removed-alias diagnostic, got {diagnostics:?}"
3948 );
3949 assert!(
3950 !matches!(ast.statements.first(), Some(BodyStmt::Done { .. })),
3951 "removed alias must not parse as a done terminal"
3952 );
3953 }
3954
3955 #[test]
3956 fn counter_consume_verb_still_parses() {
3957 let ast = parse_ok("consume budget for t.id amount 1 as spend");
3959 assert!(
3960 matches!(
3961 ast.statements.first(),
3962 Some(BodyStmt::Effect(EffectStmt {
3963 kind: BodyEffectKind::CounterConsume { .. },
3964 ..
3965 }))
3966 ),
3967 "counter consume must still parse, got {:?}",
3968 ast.statements.first()
3969 );
3970 }
3971
3972 #[test]
3973 fn parses_tell_with_modifiers_and_prompt() {
3974 let ast = parse_ok(
3975 "tell worker requires [\"agent.tell\"] as turn timeout 10m \"\"\"markdown\nDo it.\n\"\"\"",
3976 );
3977 let BodyStmt::Effect(effect) = &ast.statements[0] else {
3978 panic!("expected effect");
3979 };
3980 assert_eq!(effect.binding.as_deref(), Some("turn"));
3981 assert_eq!(effect.requires, vec!["agent.tell".to_owned()]);
3982 assert_eq!(effect.timeout_seconds, Some(600));
3983 let prompt = effect.prompt.as_ref().expect("prompt");
3984 assert_eq!(prompt.content_type.as_deref(), Some("markdown"));
3985 assert_eq!(prompt.text, "Do it.");
3986 }
3987
3988 #[test]
3989 fn parses_prompt_effect() {
3990 let ast = parse_ok(
3991 "prompt \"\"\"markdown\nSummarize this.\n\"\"\" using fixture requires [\"model.invoke\"] as answer timeout 10m",
3992 );
3993 let BodyStmt::Effect(effect) = &ast.statements[0] else {
3994 panic!("expected effect");
3995 };
3996 let BodyEffectKind::Prompt { provider } = &effect.kind else {
3997 panic!("expected prompt");
3998 };
3999 assert_eq!(provider.as_deref(), Some("fixture"));
4000 assert_eq!(effect.binding.as_deref(), Some("answer"));
4001 assert_eq!(effect.requires, vec!["model.invoke".to_owned()]);
4002 assert_eq!(effect.timeout_seconds, Some(600));
4003 let prompt = effect.prompt.as_ref().expect("prompt");
4004 assert_eq!(prompt.content_type.as_deref(), Some("markdown"));
4005 assert_eq!(prompt.text, "Summarize this.");
4006 }
4007
4008 #[test]
4009 fn parses_tell_with_access_grants() {
4010 let ast = parse_ok(
4011 "tell coder as turn\n with access to project_memory {\n recall for issue\n learn for issue\n }\n with access to project_files {\n read [\"docs/**\"]\n }\n\"Work the issue.\"",
4012 );
4013 let BodyStmt::Effect(effect) = &ast.statements[0] else {
4014 panic!("expected effect");
4015 };
4016 let BodyEffectKind::Tell {
4017 target,
4018 access_grants,
4019 ..
4020 } = &effect.kind
4021 else {
4022 panic!("expected tell");
4023 };
4024 assert_eq!(target, "coder");
4025 assert_eq!(effect.binding.as_deref(), Some("turn"));
4026 assert_eq!(access_grants.len(), 2);
4027
4028 let memory = &access_grants[0];
4029 assert_eq!(memory.resource, "project_memory");
4030 assert_eq!(memory.operations.len(), 2);
4031 assert_eq!(memory.operations[0].operation, "recall");
4032 assert_eq!(memory.operations[0].target.as_deref(), Some("issue"));
4033 assert_eq!(memory.operations[1].operation, "learn");
4034
4035 let files = &access_grants[1];
4036 assert_eq!(files.resource, "project_files");
4037 assert_eq!(files.operations.len(), 1);
4038 assert_eq!(files.operations[0].operation, "read");
4039 assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
4040 }
4041
4042 #[test]
4043 fn reports_unsupported_with_context_modifier() {
4044 let (_, diagnostics) = parse_rule_body("tell coder with context memory \"go\"", 0);
4045 assert!(
4046 diagnostics
4047 .iter()
4048 .any(|d| d.message.contains("not supported yet")),
4049 "{diagnostics:?}"
4050 );
4051 }
4052
4053 #[test]
4054 fn parses_tell_with_turn_scoped_skills() {
4055 let ast = parse_ok(
4057 "tell coder as turn\n with skills [\"review\", \"lint\"]\n with access to project_files {\n read [\"src/**\"]\n }\n\"Work it.\"",
4058 );
4059 let BodyStmt::Effect(effect) = &ast.statements[0] else {
4060 panic!("expected effect");
4061 };
4062 let BodyEffectKind::Tell {
4063 skills,
4064 access_grants,
4065 ..
4066 } = &effect.kind
4067 else {
4068 panic!("expected tell");
4069 };
4070 assert_eq!(skills, &vec!["review".to_owned(), "lint".to_owned()]);
4071 assert_eq!(
4072 access_grants.len(),
4073 1,
4074 "access grant still parsed alongside"
4075 );
4076
4077 let (_, diagnostics) = parse_rule_body("invoke Build { x task.x } with skills [\"a\"]", 0);
4079 assert!(
4080 !diagnostics.is_empty(),
4081 "invoke must reject a turn-scoped skills pin"
4082 );
4083 }
4084
4085 #[test]
4086 fn rejects_unknown_statement() {
4087 let (_, diagnostics) = parse_rule_body("frobnicate task", 0);
4088 assert!(diagnostics.iter().any(|d| d
4089 .message
4090 .contains("unknown rule body statement `frobnicate`")));
4091 }
4092
4093 #[test]
4094 fn parses_emit_signal() {
4095 let ast = parse_ok(
4096 "emit signal deploy.finished to peer.id {\n service deployed.service\n status deployed.status\n} as sent",
4097 );
4098 let BodyStmt::Effect(effect) = &ast.statements[0] else {
4099 panic!("expected effect");
4100 };
4101 assert_eq!(effect.binding.as_deref(), Some("sent"));
4102 let BodyEffectKind::Notify {
4103 target_expr,
4104 event,
4105 fields,
4106 ..
4107 } = &effect.kind
4108 else {
4109 panic!("expected signal delivery effect");
4110 };
4111 assert_eq!(target_expr, "peer.id");
4112 assert_eq!(event, "deploy.finished");
4113 assert_eq!(fields.len(), 2);
4114 }
4115
4116 #[test]
4117 fn rejects_emit_without_signal_delivery_shape() {
4118 let (_, diagnostics) = parse_rule_body("emit event.name", 0);
4119 assert!(diagnostics
4120 .iter()
4121 .any(|d| d.message.contains("was removed from the language")));
4122 }
4123
4124 #[test]
4125 fn parses_nested_after_blocks() {
4126 let ast = parse_ok(
4127 "tell worker as turn \"go\"\n\nafter turn succeeds as done {\n coerce review(done.summary) as verdict\n\n after verdict succeeds as v {\n record Out {\n ok v.ok\n }\n }\n}",
4128 );
4129 assert_eq!(ast.statements.len(), 2);
4130 let BodyStmt::After(after) = &ast.statements[1] else {
4131 panic!("expected after");
4132 };
4133 assert_eq!(after.predicate, AfterPredicate::Succeeds);
4134 assert_eq!(after.alias.as_deref(), Some("done"));
4135 assert!(matches!(after.body[1], BodyStmt::After(_)));
4136 }
4137
4138 #[test]
4139 fn parses_after_times_out_branch() {
4140 let ast = parse_ok(
4141 "exec \"report.sh\" -> Report as job\n\nafter job times out as t {\n cancel job\n}",
4142 );
4143 let BodyStmt::After(after) = &ast.statements[1] else {
4144 panic!("expected after");
4145 };
4146 assert_eq!(after.predicate, AfterPredicate::TimedOut);
4147 assert_eq!(after.predicate.as_str(), "times out");
4148 assert_eq!(after.alias.as_deref(), Some("t"));
4149 }
4150
4151 #[test]
4152 fn parses_after_cancelled_branch() {
4153 let ast = parse_ok(
4154 "exec \"report.sh\" -> Report as job\n\nafter job cancelled as c {\n cancel job\n}",
4155 );
4156 let BodyStmt::After(after) = &ast.statements[1] else {
4157 panic!("expected after");
4158 };
4159 assert_eq!(after.predicate, AfterPredicate::Cancelled);
4160 assert_eq!(after.predicate.as_str(), "cancelled");
4161 assert_eq!(after.alias.as_deref(), Some("c"));
4162 }
4163
4164 #[test]
4165 fn rejects_times_without_out() {
4166 let (_, diagnostics) = parse_rule_body("after job times { cancel job }", 0);
4167 assert!(diagnostics
4168 .iter()
4169 .any(|d| d.message.contains("expected `out` after `times`")));
4170 }
4171
4172 #[test]
4173 fn rejects_unknown_after_predicate() {
4174 let (_, diagnostics) = parse_rule_body("after job explodes { cancel job }", 0);
4175 assert!(diagnostics.iter().any(|d| d
4176 .message
4177 .contains("unsupported `after` predicate `explodes`")));
4178 }
4179
4180 #[test]
4181 fn parses_timer_and_cancel() {
4182 let ast =
4183 parse_ok("timer 24h as deadline\n\nafter deadline succeeds {\n cancel signoff\n}");
4184 let BodyStmt::Effect(effect) = &ast.statements[0] else {
4185 panic!("expected timer effect");
4186 };
4187 assert!(matches!(
4188 effect.kind,
4189 BodyEffectKind::Timer {
4190 duration_seconds: 86400,
4191 ..
4192 }
4193 ));
4194 let BodyStmt::After(after) = &ast.statements[1] else {
4195 panic!("expected after");
4196 };
4197 assert!(matches!(after.body[0], BodyStmt::Cancel { .. }));
4198 }
4199
4200 #[test]
4201 fn parses_decide_with_result_shape() {
4202 let ast = parse_ok("decide \"Fixed?\" -> { fixed bool, reason string } as verdict");
4203 let BodyStmt::Effect(effect) = &ast.statements[0] else {
4204 panic!("expected effect");
4205 };
4206 let BodyEffectKind::Decide { result_fields } = &effect.kind else {
4207 panic!("expected decide");
4208 };
4209 assert_eq!(result_fields.len(), 2);
4210 assert_eq!(effect.binding.as_deref(), Some("verdict"));
4211 }
4212
4213 #[test]
4214 fn parses_tracker_verbs() {
4215 let ast = parse_ok(
4216 "file issue into backlog {\n title \"Fix login\"\n body \"Repro...\"\n}\n\nclaim item as lease\nrelease item\nfinish item {\n summary turn.summary\n}",
4217 );
4218 assert_eq!(ast.statements.len(), 4);
4219 assert!(matches!(
4220 &ast.statements[0],
4221 BodyStmt::Effect(EffectStmt { kind: BodyEffectKind::TrackerFile { queue, .. }, .. }) if queue == "backlog"
4222 ));
4223 assert!(matches!(
4224 &ast.statements[1],
4225 BodyStmt::Effect(EffectStmt { kind: BodyEffectKind::TrackerClaim { .. }, binding: Some(b), .. }) if b == "lease"
4226 ));
4227 }
4228
4229 #[test]
4230 fn parses_exec() {
4231 let ast = parse_ok("exec \"scripts/run-tests.sh\" as tests timeout 5m");
4232 let BodyStmt::Effect(effect) = &ast.statements[0] else {
4233 panic!("expected effect");
4234 };
4235 assert!(matches!(&effect.kind, BodyEffectKind::Exec {
4236 target: ExecTarget::RawCommand(command),
4237 ..
4238 } if command == "scripts/run-tests.sh"));
4239 assert_eq!(effect.timeout_seconds, Some(300));
4240 }
4241
4242 #[test]
4243 fn parses_coerce_endorsed_marker() {
4244 let ast = parse_ok("coerce classify(msg.content) as verdict endorsed");
4246 let BodyStmt::Effect(effect) = &ast.statements[0] else {
4247 panic!("expected effect");
4248 };
4249 assert!(matches!(
4250 &effect.kind,
4251 BodyEffectKind::Coerce { name, endorsed: true, .. } if name == "classify"
4252 ));
4253 let plain = parse_ok("coerce classify(msg.content) as verdict");
4255 let BodyStmt::Effect(effect) = &plain.statements[0] else {
4256 panic!("expected effect");
4257 };
4258 assert!(matches!(
4259 &effect.kind,
4260 BodyEffectKind::Coerce {
4261 endorsed: false,
4262 declassified: false,
4263 ..
4264 }
4265 ));
4266 let both = parse_ok("coerce classify(msg.content) as verdict endorsed declassified");
4268 let BodyStmt::Effect(effect) = &both.statements[0] else {
4269 panic!("expected effect");
4270 };
4271 assert!(matches!(
4272 &effect.kind,
4273 BodyEffectKind::Coerce {
4274 endorsed: true,
4275 declassified: true,
4276 ..
4277 }
4278 ));
4279 }
4280
4281 #[test]
4282 fn parses_exec_capability() {
4283 let ast = parse_ok("exec backup_repo with request -> Report as result");
4284 let BodyStmt::Effect(effect) = &ast.statements[0] else {
4285 panic!("expected effect");
4286 };
4287 assert!(matches!(&effect.kind, BodyEffectKind::Exec {
4288 target: ExecTarget::Capability { name, stdin_binding },
4289 parse_target: Some(ExecParse { schema, each: false }),
4290 } if name == "backup_repo" && stdin_binding == "request" && schema == "Report"));
4291 assert_eq!(effect.binding.as_deref(), Some("result"));
4292 }
4293
4294 #[test]
4295 fn parses_case_with_branches() {
4296 let ast = parse_ok(
4297 "after turn completes {\n case turn {\n Completed as done => {\n record Ok {\n summary done.summary\n }\n }\n Failed as failure => {\n record Bad {\n reason failure.reason\n }\n }\n }\n}",
4298 );
4299 let BodyStmt::After(after) = &ast.statements[0] else {
4300 panic!("expected after");
4301 };
4302 let BodyStmt::Case(case) = &after.body[0] else {
4303 panic!("expected case");
4304 };
4305 assert_eq!(case.branches.len(), 2);
4306 assert_eq!(case.branches[0].pattern, "Completed");
4307 assert_eq!(case.branches[0].binding.as_deref(), Some("done"));
4308 }
4309
4310 #[test]
4311 fn rule_mode_rejects_flow_statements() {
4312 let (_, diagnostics) = parse_rule_body("on fails {\n cancel x\n}", 0);
4313 assert!(diagnostics
4314 .iter()
4315 .any(|d| d.message.contains("not rule body statements")));
4316 }
4317
4318 #[test]
4319 fn unknown_effect_modifier_is_rejected_with_span() {
4320 let (_, diagnostics) = parse_rule_body("tell worker as turn frobnicate \"go\"", 0);
4321 assert!(
4322 diagnostics
4323 .iter()
4324 .any(|d| d.message.contains("expected a prompt string")),
4325 "{diagnostics:?}"
4326 );
4327 }
4328
4329 #[test]
4330 fn from_block_supports_shorthand_and_overrides() {
4331 let ast = parse_ok(
4332 "done task -> record ReviewedPoem from task {\n provider poet\n language\n topic\n turn poemTurn\n status \"reviewed\"\n}",
4333 );
4334 let BodyStmt::Done {
4335 replacement: Some(record),
4336 ..
4337 } = &ast.statements[0]
4338 else {
4339 panic!("expected replacement record");
4340 };
4341 assert_eq!(record.from.as_deref(), Some("task"));
4342 let names: Vec<_> = record.fields.iter().map(|f| f.name.as_str()).collect();
4343 assert_eq!(
4344 names,
4345 vec!["provider", "language", "topic", "turn", "status"]
4346 );
4347 assert!(matches!(record.fields[1].value, FieldValue::Shorthand));
4348 assert!(matches!(record.fields[3].value, FieldValue::Expr { .. }));
4349 }
4350
4351 #[test]
4352 fn invoke_with_nested_payload() {
4353 let ast = parse_ok(
4354 "invoke ReviewPhase {\n phase PhaseReviewRequest {\n id phase.id\n title phase.title\n }\n} as review",
4355 );
4356 let BodyStmt::Effect(effect) = &ast.statements[0] else {
4357 panic!("expected effect");
4358 };
4359 let BodyEffectKind::Invoke {
4360 workflow, payload, ..
4361 } = &effect.kind
4362 else {
4363 panic!("expected invoke");
4364 };
4365 assert_eq!(workflow, "ReviewPhase");
4366 assert!(matches!(payload[0].value, FieldValue::Nested { .. }));
4367 }
4368
4369 #[test]
4370 fn parses_invoke_with_access_grants() {
4371 let ast = parse_ok(
4372 "invoke Child {\n task Task { id ticket.id }\n}\n with access to project_files {\n read [\"docs/**\"]\n }\n as child",
4373 );
4374 let BodyStmt::Effect(effect) = &ast.statements[0] else {
4375 panic!("expected effect");
4376 };
4377 let BodyEffectKind::Invoke {
4378 workflow,
4379 payload,
4380 access_grants,
4381 } = &effect.kind
4382 else {
4383 panic!("expected invoke");
4384 };
4385 assert_eq!(workflow, "Child");
4386 assert_eq!(effect.binding.as_deref(), Some("child"));
4387 assert!(matches!(payload[0].value, FieldValue::Nested { .. }));
4388 assert_eq!(access_grants.len(), 1);
4389 assert_eq!(access_grants[0].resource, "project_files");
4390 assert_eq!(access_grants[0].operations[0].operation, "read");
4391 assert_eq!(
4392 access_grants[0].operations[0].globs,
4393 vec!["docs/**".to_owned()]
4394 );
4395 }
4396
4397 #[test]
4398 fn parses_invoke_with_resource_less_access_grant_shorthand() {
4399 let ast = parse_ok(
4400 "invoke Child {\n task Task { id ticket.id }\n}\n with access to {\n project_memory {\n recall for ticket\n }\n project_files {\n read [\"docs/**\"]\n }\n }\n as child",
4401 );
4402 let BodyStmt::Effect(effect) = &ast.statements[0] else {
4403 panic!("expected effect");
4404 };
4405 let BodyEffectKind::Invoke { access_grants, .. } = &effect.kind else {
4406 panic!("expected invoke");
4407 };
4408 assert_eq!(effect.binding.as_deref(), Some("child"));
4409 assert_eq!(access_grants.len(), 2);
4410
4411 let memory = &access_grants[0];
4412 assert_eq!(memory.resource, "project_memory");
4413 assert_eq!(memory.operations[0].operation, "recall");
4414 assert_eq!(memory.operations[0].target.as_deref(), Some("ticket"));
4415
4416 let files = &access_grants[1];
4417 assert_eq!(files.resource, "project_files");
4418 assert_eq!(files.operations[0].operation, "read");
4419 assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
4420 }
4421
4422 #[test]
4423 fn rejects_empty_resource_less_access_grant_shorthand() {
4424 let (_, diagnostics) = parse_rule_body(
4425 "invoke Child { task task }\n with access to {\n }\n as child",
4426 0,
4427 );
4428 assert!(
4429 diagnostics
4430 .iter()
4431 .any(|d| d.message.contains("grants no resources")),
4432 "{diagnostics:?}"
4433 );
4434 }
4435
4436 #[test]
4437 fn single_line_terminal_payload_parses() {
4438 let ast = parse_ok("complete result { total 2 }");
4439 let BodyStmt::Terminal(terminal) = &ast.statements[0] else {
4440 panic!("expected terminal");
4441 };
4442 assert_eq!(terminal.fields.len(), 1);
4443 assert_eq!(terminal.fields[0].name, "total");
4444 }
4445
4446 #[test]
4447 fn spans_are_absolute() {
4448 let (ast, _) = parse_rule_body("record Item {\n id \"a\"\n}", 100);
4449 let BodyStmt::Record(record) = &ast.statements[0] else {
4450 panic!("expected record");
4451 };
4452 assert_eq!(record.span.start, 100);
4453 assert!(record.span.end > 100);
4454 }
4455}