use std::collections::{BTreeMap, BTreeSet};
use super::reader::{end_of_call, find_open_brace, last_identifier_argument, match_brace};
use super::scan::{Token, tokenise};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkflowFacts {
pub typed_entry_function: String,
pub timer_count: usize,
}
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
pub enum FactsError {
#[error(
"workflow source does not import `aion/workflow`; the test-scaffold generator only \
understands workflows that compose the `aion/workflow` primitive vocabulary"
)]
NoWorkflowImport,
#[error(
"workflow source contains no `<alias>.define(...)` call; the typed entry function the \
test scaffold drives is the last argument of that call"
)]
NoDefineCall,
#[error(
"the `<alias>.define(...)` call's last argument is not a bare entry-function reference; \
the test scaffold cannot identify the typed entry function to drive"
)]
EntryFunctionNotIdentifiable,
}
#[derive(Clone, Copy)]
struct FnBody {
start: usize,
end: usize,
}
pub fn extract_workflow_facts(source: &str) -> Result<WorkflowFacts, FactsError> {
let tokens = tokenise(source);
let alias = workflow_alias(&tokens).ok_or(FactsError::NoWorkflowImport)?;
let typed_entry_function = define_entry_function(&tokens, &alias)?;
let functions = map_functions(&tokens);
let mut visited: BTreeSet<String> = BTreeSet::new();
let timer_count = if functions.contains_key(&typed_entry_function) {
count_timers(
&tokens,
&functions,
&alias,
&typed_entry_function,
&mut visited,
)
} else {
0
};
Ok(WorkflowFacts {
typed_entry_function,
timer_count,
})
}
fn workflow_alias(tokens: &[Token]) -> Option<String> {
let mut index = 0;
while index < tokens.len() {
if matches!(&tokens[index], Token::Ident(word) if word == "import")
&& import_path_is_workflow(tokens, index + 1)
{
let after_path = index + 1 + 3;
if matches!(tokens.get(after_path), Some(Token::Ident(word)) if word == "as") {
if let Some(Token::Ident(alias)) = tokens.get(after_path + 1) {
return Some(alias.clone());
}
}
return Some("workflow".to_owned());
}
index += 1;
}
None
}
fn import_path_is_workflow(tokens: &[Token], start: usize) -> bool {
matches!(
(tokens.get(start), tokens.get(start + 1), tokens.get(start + 2)),
(
Some(Token::Ident(first)),
Some(Token::Other('/')),
Some(Token::Ident(second)),
) if first == "aion"
&& second == "workflow"
&& !matches!(tokens.get(start + 3), Some(Token::Other('/')))
)
}
fn define_entry_function(tokens: &[Token], alias: &str) -> Result<String, FactsError> {
let mut index = 0;
while index < tokens.len() {
if let Token::Qualified { left, right } = &tokens[index] {
if left == alias && right == "define" {
let end = end_of_call(tokens, index, tokens.len());
return last_identifier_argument(tokens, index + 1, end)
.ok_or(FactsError::EntryFunctionNotIdentifiable);
}
}
index += 1;
}
Err(FactsError::NoDefineCall)
}
fn count_timers(
tokens: &[Token],
functions: &BTreeMap<String, FnBody>,
alias: &str,
function: &str,
visited: &mut BTreeSet<String>,
) -> usize {
if !visited.insert(function.to_owned()) {
return 0;
}
let Some(body) = functions.get(function).copied() else {
return 0;
};
let mut count = 0;
let mut callees: Vec<String> = Vec::new();
let upper = body.end.min(tokens.len());
let mut index = body.start;
let mut depth: usize = 0;
while index < upper {
match &tokens[index] {
Token::OpenParen => depth += 1,
Token::CloseParen => depth = depth.saturating_sub(1),
Token::Qualified { left, right }
if left == alias && (right == "sleep" || right == "start_timer") =>
{
count += 1;
}
Token::Ident(name) if functions.contains_key(name) => {
let applied = matches!(tokens.get(index + 1), Some(Token::OpenParen));
if applied || depth >= 1 {
callees.push(name.clone());
}
}
_ => {}
}
index += 1;
}
for callee in callees {
count += count_timers(tokens, functions, alias, &callee, visited);
}
count
}
fn map_functions(tokens: &[Token]) -> BTreeMap<String, FnBody> {
let mut functions = BTreeMap::new();
let mut index = 0;
while index < tokens.len() {
if matches!(&tokens[index], Token::Ident(word) if word == "fn") {
if let Some(Token::Ident(name)) = tokens.get(index + 1) {
if let Some(open) = find_open_brace(tokens, index + 2, tokens.len()) {
if let Some(close) = match_brace(tokens, open, tokens.len()) {
functions.insert(
name.clone(),
FnBody {
start: open + 1,
end: close,
},
);
index = close + 1;
continue;
}
}
}
}
index += 1;
}
functions
}
#[cfg(test)]
mod tests {
use super::{FactsError, extract_workflow_facts};
const SAGA: &str = "import aion/workflow\n\
pub fn definition() {\n \
workflow.define(\"order\", a_codec(), b_codec(), c_codec(), execute)\n}\n\
pub fn execute(input) {\n \
let _ = workflow.run(wrappers.charge_activity(input))\n \
let _ = workflow.sleep(duration.seconds(1))\n \
settle(input)\n}\n\
fn settle(input) {\n \
workflow.start_timer(\"deadline\", duration.seconds(5))\n}\n";
#[test]
fn reads_typed_entry_and_counts_reachable_timers() -> Result<(), Box<dyn std::error::Error>> {
let facts = extract_workflow_facts(SAGA)?;
assert_eq!(facts.typed_entry_function, "execute");
assert_eq!(facts.timer_count, 2);
Ok(())
}
#[test]
fn unreachable_timer_is_not_counted() -> Result<(), Box<dyn std::error::Error>> {
let source = "import aion/workflow\n\
pub fn definition() {\n \
workflow.define(\"f\", a(), b(), c(), execute)\n}\n\
pub fn execute(input) {\n \
workflow.run(wrappers.charge_activity(input))\n}\n\
fn dead(input) {\n workflow.sleep(duration.seconds(1))\n}\n";
let facts = extract_workflow_facts(source)?;
assert_eq!(facts.timer_count, 0);
Ok(())
}
#[test]
fn timer_in_a_helper_passed_as_a_value_is_counted() -> Result<(), Box<dyn std::error::Error>> {
let source = "import aion/workflow\n\
pub fn definition() {\n \
workflow.define(\"f\", a(), b(), c(), execute)\n}\n\
pub fn execute(input) {\n \
let _ = list.map(input, delayed)\n \
workflow.run(wrappers.charge_activity(input))\n}\n\
fn delayed(item) {\n workflow.sleep(duration.seconds(1))\n}\n";
let facts = extract_workflow_facts(source)?;
assert_eq!(facts.typed_entry_function, "execute");
assert_eq!(facts.timer_count, 1);
Ok(())
}
#[test]
fn aliased_workflow_import_is_honoured() -> Result<(), Box<dyn std::error::Error>> {
let source = "import aion/workflow as wf\n\
pub fn definition() {\n \
wf.define(\"f\", a(), b(), c(), execute)\n}\n\
pub fn execute(input) {\n wf.sleep(duration.seconds(1))\n}\n";
let facts = extract_workflow_facts(source)?;
assert_eq!(facts.typed_entry_function, "execute");
assert_eq!(facts.timer_count, 1);
Ok(())
}
#[test]
fn missing_workflow_import_is_an_error() {
let source = "pub fn execute(input) {\n Nil\n}\n";
assert_eq!(
extract_workflow_facts(source),
Err(FactsError::NoWorkflowImport)
);
}
#[test]
fn missing_define_call_is_an_error() {
let source = "import aion/workflow\n\
pub fn execute(input) {\n workflow.run(wrappers.charge_activity(input))\n}\n";
assert_eq!(
extract_workflow_facts(source),
Err(FactsError::NoDefineCall)
);
}
}