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