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 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 count_timers(
164 tokens: &[Token],
165 functions: &BTreeMap<String, FnBody>,
166 alias: &str,
167 function: &str,
168 visited: &mut BTreeSet<String>,
169) -> usize {
170 if !visited.insert(function.to_owned()) {
171 return 0;
172 }
173 let Some(body) = functions.get(function).copied() else {
174 return 0;
175 };
176 let mut count = 0;
177 let mut callees: Vec<String> = Vec::new();
178 let upper = body.end.min(tokens.len());
179 let mut index = body.start;
180 let mut depth: usize = 0;
181 while index < upper {
182 match &tokens[index] {
183 Token::OpenParen => depth += 1,
184 Token::CloseParen => depth = depth.saturating_sub(1),
185 Token::Qualified { left, right }
186 if left == alias && (right == "sleep" || right == "start_timer") =>
187 {
188 count += 1;
189 }
190 Token::Ident(name) if functions.contains_key(name) => {
196 let applied = matches!(tokens.get(index + 1), Some(Token::OpenParen));
197 if applied || depth >= 1 {
198 callees.push(name.clone());
199 }
200 }
201 _ => {}
202 }
203 index += 1;
204 }
205 for callee in callees {
206 count += count_timers(tokens, functions, alias, &callee, visited);
207 }
208 count
209}
210
211fn map_functions(tokens: &[Token]) -> BTreeMap<String, FnBody> {
214 let mut functions = BTreeMap::new();
215 let mut index = 0;
216 while index < tokens.len() {
217 if matches!(&tokens[index], Token::Ident(word) if word == "fn") {
218 if let Some(Token::Ident(name)) = tokens.get(index + 1) {
219 if let Some(open) = find_open_brace(tokens, index + 2, tokens.len()) {
220 if let Some(close) = match_brace(tokens, open, tokens.len()) {
221 functions.insert(
222 name.clone(),
223 FnBody {
224 start: open + 1,
225 end: close,
226 },
227 );
228 index = close + 1;
229 continue;
230 }
231 }
232 }
233 }
234 index += 1;
235 }
236 functions
237}
238
239#[cfg(test)]
240mod tests {
241 use super::{FactsError, extract_workflow_facts};
242
243 const SAGA: &str = "import aion/workflow\n\
244 pub fn definition() {\n \
245 workflow.define(\"order\", a_codec(), b_codec(), c_codec(), execute)\n}\n\
246 pub fn execute(input) {\n \
247 let _ = workflow.run(wrappers.charge_activity(input))\n \
248 let _ = workflow.sleep(duration.seconds(1))\n \
249 settle(input)\n}\n\
250 fn settle(input) {\n \
251 workflow.start_timer(\"deadline\", duration.seconds(5))\n}\n";
252
253 #[test]
254 fn reads_typed_entry_and_counts_reachable_timers() -> Result<(), Box<dyn std::error::Error>> {
255 let facts = extract_workflow_facts(SAGA)?;
256 assert_eq!(facts.typed_entry_function, "execute");
257 assert_eq!(facts.timer_count, 2);
260 Ok(())
261 }
262
263 #[test]
264 fn unreachable_timer_is_not_counted() -> Result<(), Box<dyn std::error::Error>> {
265 let source = "import aion/workflow\n\
266 pub fn definition() {\n \
267 workflow.define(\"f\", a(), b(), c(), execute)\n}\n\
268 pub fn execute(input) {\n \
269 workflow.run(wrappers.charge_activity(input))\n}\n\
270 fn dead(input) {\n workflow.sleep(duration.seconds(1))\n}\n";
271 let facts = extract_workflow_facts(source)?;
272 assert_eq!(facts.timer_count, 0);
273 Ok(())
274 }
275
276 #[test]
277 fn timer_in_a_helper_passed_as_a_value_is_counted() -> Result<(), Box<dyn std::error::Error>> {
278 let source = "import aion/workflow\n\
283 pub fn definition() {\n \
284 workflow.define(\"f\", a(), b(), c(), execute)\n}\n\
285 pub fn execute(input) {\n \
286 let _ = list.map(input, delayed)\n \
287 workflow.run(wrappers.charge_activity(input))\n}\n\
288 fn delayed(item) {\n workflow.sleep(duration.seconds(1))\n}\n";
289 let facts = extract_workflow_facts(source)?;
290 assert_eq!(facts.typed_entry_function, "execute");
291 assert_eq!(facts.timer_count, 1);
292 Ok(())
293 }
294
295 #[test]
296 fn aliased_workflow_import_is_honoured() -> Result<(), Box<dyn std::error::Error>> {
297 let source = "import aion/workflow as wf\n\
298 pub fn definition() {\n \
299 wf.define(\"f\", a(), b(), c(), execute)\n}\n\
300 pub fn execute(input) {\n wf.sleep(duration.seconds(1))\n}\n";
301 let facts = extract_workflow_facts(source)?;
302 assert_eq!(facts.typed_entry_function, "execute");
303 assert_eq!(facts.timer_count, 1);
304 Ok(())
305 }
306
307 #[test]
308 fn missing_workflow_import_is_an_error() {
309 let source = "pub fn execute(input) {\n Nil\n}\n";
310 assert_eq!(
311 extract_workflow_facts(source),
312 Err(FactsError::NoWorkflowImport)
313 );
314 }
315
316 #[test]
317 fn missing_define_call_is_an_error() {
318 let source = "import aion/workflow\n\
319 pub fn execute(input) {\n workflow.run(wrappers.charge_activity(input))\n}\n";
320 assert_eq!(
321 extract_workflow_facts(source),
322 Err(FactsError::NoDefineCall)
323 );
324 }
325}