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