1use std::collections::{BTreeMap, BTreeSet};
21
22use super::reader::{end_of_call, find_open_brace, last_identifier_argument, match_brace};
23use super::scan::{Token, tokenise};
24
25#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct WorkflowFacts {
28 pub typed_entry_function: String,
31 pub timer_count: usize,
34}
35
36#[derive(thiserror::Error, Debug, PartialEq, Eq)]
38pub enum FactsError {
39 #[error(
43 "workflow source does not import `aion/workflow`; the test-scaffold generator only \
44 understands workflows that compose the `aion/workflow` primitive vocabulary"
45 )]
46 NoWorkflowImport,
47
48 #[error(
51 "workflow source contains no `<alias>.define(...)` call; the typed entry function the \
52 test scaffold drives is the last argument of that call"
53 )]
54 NoDefineCall,
55
56 #[error(
59 "the `<alias>.define(...)` call's last argument is not a bare entry-function reference; \
60 the test scaffold cannot identify the typed entry function to drive"
61 )]
62 EntryFunctionNotIdentifiable,
63}
64
65#[derive(Clone, Copy)]
67struct FnBody {
68 start: usize,
69 end: usize,
70}
71
72pub fn extract_workflow_facts(source: &str) -> Result<WorkflowFacts, FactsError> {
81 let tokens = tokenise(source);
82 let alias = workflow_alias(&tokens).ok_or(FactsError::NoWorkflowImport)?;
83 let typed_entry_function = define_entry_function(&tokens, &alias)?;
84
85 let functions = map_functions(&tokens);
86 let mut visited: BTreeSet<String> = BTreeSet::new();
87 let timer_count = if functions.contains_key(&typed_entry_function) {
88 count_timers(
89 &tokens,
90 &functions,
91 &alias,
92 &typed_entry_function,
93 &mut visited,
94 )
95 } else {
96 0
100 };
101
102 Ok(WorkflowFacts {
103 typed_entry_function,
104 timer_count,
105 })
106}
107
108fn workflow_alias(tokens: &[Token]) -> Option<String> {
111 let mut index = 0;
112 while index < tokens.len() {
113 if matches!(&tokens[index], Token::Ident(word) if word == "import")
114 && import_path_is_workflow(tokens, index + 1)
115 {
116 let after_path = index + 1 + 3;
117 if matches!(tokens.get(after_path), Some(Token::Ident(word)) if word == "as")
118 && let Some(Token::Ident(alias)) = tokens.get(after_path + 1)
119 {
120 return Some(alias.clone());
121 }
122 return Some("workflow".to_owned());
123 }
124 index += 1;
125 }
126 None
127}
128
129fn import_path_is_workflow(tokens: &[Token], start: usize) -> bool {
132 matches!(
133 (tokens.get(start), tokens.get(start + 1), tokens.get(start + 2)),
134 (
135 Some(Token::Ident(first)),
136 Some(Token::Other('/')),
137 Some(Token::Ident(second)),
138 ) if first == "aion"
139 && second == "workflow"
140 && !matches!(tokens.get(start + 3), Some(Token::Other('/')))
141 )
142}
143
144fn define_entry_function(tokens: &[Token], alias: &str) -> Result<String, FactsError> {
147 let mut index = 0;
148 while index < tokens.len() {
149 if let Token::Qualified { left, right } = &tokens[index]
150 && left == alias
151 && right == "define"
152 {
153 let end = end_of_call(tokens, index, tokens.len());
154 return last_identifier_argument(tokens, index + 1, end)
155 .ok_or(FactsError::EntryFunctionNotIdentifiable);
156 }
157 index += 1;
158 }
159 Err(FactsError::NoDefineCall)
160}
161
162fn count_timers(
165 tokens: &[Token],
166 functions: &BTreeMap<String, FnBody>,
167 alias: &str,
168 function: &str,
169 visited: &mut BTreeSet<String>,
170) -> usize {
171 if !visited.insert(function.to_owned()) {
172 return 0;
173 }
174 let Some(body) = functions.get(function).copied() else {
175 return 0;
176 };
177 let mut count = 0;
178 let mut callees: Vec<String> = Vec::new();
179 let upper = body.end.min(tokens.len());
180 let mut index = body.start;
181 let mut depth: usize = 0;
182 while index < upper {
183 match &tokens[index] {
184 Token::OpenParen => depth += 1,
185 Token::CloseParen => depth = depth.saturating_sub(1),
186 Token::Qualified { left, right }
187 if left == alias && (right == "sleep" || right == "start_timer") =>
188 {
189 count += 1;
190 }
191 Token::Ident(name) if functions.contains_key(name) => {
197 let applied = matches!(tokens.get(index + 1), Some(Token::OpenParen));
198 if applied || depth >= 1 {
199 callees.push(name.clone());
200 }
201 }
202 _ => {}
203 }
204 index += 1;
205 }
206 for callee in callees {
207 count += count_timers(tokens, functions, alias, &callee, visited);
208 }
209 count
210}
211
212fn map_functions(tokens: &[Token]) -> BTreeMap<String, FnBody> {
215 let mut functions = BTreeMap::new();
216 let mut index = 0;
217 while index < tokens.len() {
218 if matches!(&tokens[index], Token::Ident(word) if word == "fn")
219 && let Some(Token::Ident(name)) = tokens.get(index + 1)
220 && let Some(open) = find_open_brace(tokens, index + 2, tokens.len())
221 && let Some(close) = match_brace(tokens, open, tokens.len())
222 {
223 functions.insert(
224 name.clone(),
225 FnBody {
226 start: open + 1,
227 end: close,
228 },
229 );
230 index = close + 1;
231 continue;
232 }
233 index += 1;
234 }
235 functions
236}
237
238#[cfg(test)]
239mod tests {
240 use super::{FactsError, extract_workflow_facts};
241
242 const SAGA: &str = "import aion/workflow\n\
243 pub fn definition() {\n \
244 workflow.define(\"order\", a_codec(), b_codec(), c_codec(), execute)\n}\n\
245 pub fn execute(input) {\n \
246 let _ = workflow.run(wrappers.charge_activity(input))\n \
247 let _ = workflow.sleep(duration.seconds(1))\n \
248 settle(input)\n}\n\
249 fn settle(input) {\n \
250 workflow.start_timer(\"deadline\", duration.seconds(5))\n}\n";
251
252 #[test]
253 fn reads_typed_entry_and_counts_reachable_timers() -> Result<(), Box<dyn std::error::Error>> {
254 let facts = extract_workflow_facts(SAGA)?;
255 assert_eq!(facts.typed_entry_function, "execute");
256 assert_eq!(facts.timer_count, 2);
259 Ok(())
260 }
261
262 #[test]
263 fn unreachable_timer_is_not_counted() -> Result<(), Box<dyn std::error::Error>> {
264 let source = "import aion/workflow\n\
265 pub fn definition() {\n \
266 workflow.define(\"f\", a(), b(), c(), execute)\n}\n\
267 pub fn execute(input) {\n \
268 workflow.run(wrappers.charge_activity(input))\n}\n\
269 fn dead(input) {\n workflow.sleep(duration.seconds(1))\n}\n";
270 let facts = extract_workflow_facts(source)?;
271 assert_eq!(facts.timer_count, 0);
272 Ok(())
273 }
274
275 #[test]
276 fn timer_in_a_helper_passed_as_a_value_is_counted() -> Result<(), Box<dyn std::error::Error>> {
277 let source = "import aion/workflow\n\
282 pub fn definition() {\n \
283 workflow.define(\"f\", a(), b(), c(), execute)\n}\n\
284 pub fn execute(input) {\n \
285 let _ = list.map(input, delayed)\n \
286 workflow.run(wrappers.charge_activity(input))\n}\n\
287 fn delayed(item) {\n workflow.sleep(duration.seconds(1))\n}\n";
288 let facts = extract_workflow_facts(source)?;
289 assert_eq!(facts.typed_entry_function, "execute");
290 assert_eq!(facts.timer_count, 1);
291 Ok(())
292 }
293
294 #[test]
295 fn aliased_workflow_import_is_honoured() -> Result<(), Box<dyn std::error::Error>> {
296 let source = "import aion/workflow as wf\n\
297 pub fn definition() {\n \
298 wf.define(\"f\", a(), b(), c(), execute)\n}\n\
299 pub fn execute(input) {\n wf.sleep(duration.seconds(1))\n}\n";
300 let facts = extract_workflow_facts(source)?;
301 assert_eq!(facts.typed_entry_function, "execute");
302 assert_eq!(facts.timer_count, 1);
303 Ok(())
304 }
305
306 #[test]
307 fn missing_workflow_import_is_an_error() {
308 let source = "pub fn execute(input) {\n Nil\n}\n";
309 assert_eq!(
310 extract_workflow_facts(source),
311 Err(FactsError::NoWorkflowImport)
312 );
313 }
314
315 #[test]
316 fn missing_define_call_is_an_error() {
317 let source = "import aion/workflow\n\
318 pub fn execute(input) {\n workflow.run(wrappers.charge_activity(input))\n}\n";
319 assert_eq!(
320 extract_workflow_facts(source),
321 Err(FactsError::NoDefineCall)
322 );
323 }
324}