1use std::collections::{BTreeMap, BTreeSet};
21
22use super::reader::{end_of_call, find_open_brace, 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 if let Some(Token::Ident(alias)) = tokens.get(after_path + 1) {
119 return Some(alias.clone());
120 }
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 if left == alias && right == "define" {
151 let end = end_of_call(tokens, index, tokens.len());
152 return last_identifier_argument(tokens, index + 1, end)
153 .ok_or(FactsError::EntryFunctionNotIdentifiable);
154 }
155 }
156 index += 1;
157 }
158 Err(FactsError::NoDefineCall)
159}
160
161fn last_identifier_argument(tokens: &[Token], start: usize, end: usize) -> Option<String> {
166 let upper = end.min(tokens.len());
167 let mut depth = 0_i32;
168 let mut last: Option<String> = None;
169 for token in tokens.iter().take(upper).skip(start) {
170 match token {
171 Token::OpenParen => depth += 1,
172 Token::CloseParen => depth -= 1,
173 Token::Ident(word) if depth == 1 => last = Some(word.clone()),
176 Token::Qualified { .. } if depth == 1 => last = None,
179 _ => {}
180 }
181 }
182 last
183}
184
185fn count_timers(
188 tokens: &[Token],
189 functions: &BTreeMap<String, FnBody>,
190 alias: &str,
191 function: &str,
192 visited: &mut BTreeSet<String>,
193) -> usize {
194 if !visited.insert(function.to_owned()) {
195 return 0;
196 }
197 let Some(body) = functions.get(function).copied() else {
198 return 0;
199 };
200 let mut count = 0;
201 let mut callees: Vec<String> = Vec::new();
202 let upper = body.end.min(tokens.len());
203 let mut index = body.start;
204 let mut depth: usize = 0;
205 while index < upper {
206 match &tokens[index] {
207 Token::OpenParen => depth += 1,
208 Token::CloseParen => depth = depth.saturating_sub(1),
209 Token::Qualified { left, right }
210 if left == alias && (right == "sleep" || right == "start_timer") =>
211 {
212 count += 1;
213 }
214 Token::Ident(name) if functions.contains_key(name) => {
220 let applied = matches!(tokens.get(index + 1), Some(Token::OpenParen));
221 if applied || depth >= 1 {
222 callees.push(name.clone());
223 }
224 }
225 _ => {}
226 }
227 index += 1;
228 }
229 for callee in callees {
230 count += count_timers(tokens, functions, alias, &callee, visited);
231 }
232 count
233}
234
235fn map_functions(tokens: &[Token]) -> BTreeMap<String, FnBody> {
238 let mut functions = BTreeMap::new();
239 let mut index = 0;
240 while index < tokens.len() {
241 if matches!(&tokens[index], Token::Ident(word) if word == "fn") {
242 if let Some(Token::Ident(name)) = tokens.get(index + 1) {
243 if let Some(open) = find_open_brace(tokens, index + 2, tokens.len()) {
244 if let Some(close) = match_brace(tokens, open, tokens.len()) {
245 functions.insert(
246 name.clone(),
247 FnBody {
248 start: open + 1,
249 end: close,
250 },
251 );
252 index = close + 1;
253 continue;
254 }
255 }
256 }
257 }
258 index += 1;
259 }
260 functions
261}
262
263#[cfg(test)]
264mod tests {
265 use super::{FactsError, extract_workflow_facts};
266
267 const SAGA: &str = "import aion/workflow\n\
268 pub fn definition() {\n \
269 workflow.define(\"order\", a_codec(), b_codec(), c_codec(), execute)\n}\n\
270 pub fn execute(input) {\n \
271 let _ = workflow.run(wrappers.charge_activity(input))\n \
272 let _ = workflow.sleep(duration.seconds(1))\n \
273 settle(input)\n}\n\
274 fn settle(input) {\n \
275 workflow.start_timer(\"deadline\", duration.seconds(5))\n}\n";
276
277 #[test]
278 fn reads_typed_entry_and_counts_reachable_timers() -> Result<(), Box<dyn std::error::Error>> {
279 let facts = extract_workflow_facts(SAGA)?;
280 assert_eq!(facts.typed_entry_function, "execute");
281 assert_eq!(facts.timer_count, 2);
284 Ok(())
285 }
286
287 #[test]
288 fn unreachable_timer_is_not_counted() -> Result<(), Box<dyn std::error::Error>> {
289 let source = "import aion/workflow\n\
290 pub fn definition() {\n \
291 workflow.define(\"f\", a(), b(), c(), execute)\n}\n\
292 pub fn execute(input) {\n \
293 workflow.run(wrappers.charge_activity(input))\n}\n\
294 fn dead(input) {\n workflow.sleep(duration.seconds(1))\n}\n";
295 let facts = extract_workflow_facts(source)?;
296 assert_eq!(facts.timer_count, 0);
297 Ok(())
298 }
299
300 #[test]
301 fn timer_in_a_helper_passed_as_a_value_is_counted() -> Result<(), Box<dyn std::error::Error>> {
302 let source = "import aion/workflow\n\
307 pub fn definition() {\n \
308 workflow.define(\"f\", a(), b(), c(), execute)\n}\n\
309 pub fn execute(input) {\n \
310 let _ = list.map(input, delayed)\n \
311 workflow.run(wrappers.charge_activity(input))\n}\n\
312 fn delayed(item) {\n workflow.sleep(duration.seconds(1))\n}\n";
313 let facts = extract_workflow_facts(source)?;
314 assert_eq!(facts.typed_entry_function, "execute");
315 assert_eq!(facts.timer_count, 1);
316 Ok(())
317 }
318
319 #[test]
320 fn aliased_workflow_import_is_honoured() -> Result<(), Box<dyn std::error::Error>> {
321 let source = "import aion/workflow as wf\n\
322 pub fn definition() {\n \
323 wf.define(\"f\", a(), b(), c(), execute)\n}\n\
324 pub fn execute(input) {\n wf.sleep(duration.seconds(1))\n}\n";
325 let facts = extract_workflow_facts(source)?;
326 assert_eq!(facts.typed_entry_function, "execute");
327 assert_eq!(facts.timer_count, 1);
328 Ok(())
329 }
330
331 #[test]
332 fn missing_workflow_import_is_an_error() {
333 let source = "pub fn execute(input) {\n Nil\n}\n";
334 assert_eq!(
335 extract_workflow_facts(source),
336 Err(FactsError::NoWorkflowImport)
337 );
338 }
339
340 #[test]
341 fn missing_define_call_is_an_error() {
342 let source = "import aion/workflow\n\
343 pub fn execute(input) {\n workflow.run(wrappers.charge_activity(input))\n}\n";
344 assert_eq!(
345 extract_workflow_facts(source),
346 Err(FactsError::NoDefineCall)
347 );
348 }
349}