use std::collections::BTreeSet;
use crate::data::{CallKind, FileData};
use crate::rendering::markdown::display_name;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct Call {
caller: String,
target: String,
file: String,
by_expression: bool,
kind: CallKind,
}
fn sequences_in(files: &[FileData]) -> BTreeSet<String> {
files
.iter()
.flat_map(|file| file.sequences.iter().map(|sequence| sequence.name.clone()))
.collect()
}
fn calls_in(files: &[FileData]) -> BTreeSet<Call> {
let mut calls = BTreeSet::new();
for file in files {
for sequence in &file.sequences {
for (_, steps) in sequence.step_groups_in_execution_order() {
for step in steps {
if step.target_sequence.is_empty() {
continue;
}
calls.insert(Call {
caller: sequence.name.clone(),
target: step.target_sequence.clone(),
file: step.module_path.clone(),
by_expression: step.target_by_expression,
kind: step.call_kind,
});
}
}
}
}
calls
}
#[must_use]
pub fn render(files: &[FileData]) -> Vec<String> {
let calls = calls_in(files);
if calls.is_empty() {
return Vec::new();
}
let mut md = vec![
"---".to_owned(),
String::new(),
"## Call Hierarchy".to_owned(),
String::new(),
];
let known = sequences_in(files);
let mut current = String::new();
for call in &calls {
if call.caller != current {
call.caller.clone_into(&mut current);
md.push(format!("- **{}** calls", call.caller));
}
let where_from = if call.file.is_empty() {
String::new()
} else {
format!(" in `{}`", display_name(&call.file))
};
let how = call
.kind
.describe()
.map_or_else(String::new, |described| format!(", {described}"));
if call.by_expression {
md.push(format!(
" - a sequence chosen at run time{where_from}{how}, from `{}`",
call.target
));
} else if call.file.is_empty() && !known.contains(&call.target) {
md.push(format!(
" - `{}`{how} (not found in the documented files)",
call.target
));
} else {
md.push(format!(" - `{}`{where_from}{how}", call.target));
}
}
md.push(String::new());
md
}
#[cfg(test)]
mod tests {
use super::render;
use crate::data::{FileData, SequenceData, StepData};
use std::collections::BTreeMap;
fn calling(caller: &str, target: &str, file: &str, by_expression: bool) -> SequenceData {
let mut groups = BTreeMap::new();
groups.insert(
"Main".to_owned(),
vec![StepData {
name: format!("Call {target}"),
step_type: "SequenceCall".to_owned(),
target_sequence: target.to_owned(),
module_path: file.to_owned(),
target_by_expression: by_expression,
..StepData::default()
}],
);
SequenceData {
name: caller.to_owned(),
step_groups: groups,
..SequenceData::default()
}
}
fn file_with(sequences: Vec<SequenceData>) -> FileData {
FileData {
name: "Test.seq".to_owned(),
sequences,
..FileData::default()
}
}
#[test]
fn a_file_without_calls_renders_nothing() {
assert!(render(&[file_with(vec![SequenceData::default()])]).is_empty());
}
#[test]
fn a_caller_lists_what_it_reaches() {
let files = [file_with(vec![
calling("MainSequence", "Voltage Tests", "", false),
calling("Voltage Tests", "Measure", "", false),
])];
let md = render(&files).join("\n");
assert!(md.contains("- **MainSequence** calls"), "{md}");
assert!(md.contains(" - `Voltage Tests`"), "{md}");
assert!(md.contains("- **Voltage Tests** calls"), "{md}");
}
#[test]
fn an_external_target_names_its_file() {
let files = [file_with(vec![calling(
"MainSequence",
"Shared Setup",
r"C:\lab\sequences\Common.seq",
false,
)])];
let md = render(&files).join("\n");
assert!(md.contains("in `Common.seq`"), "{md}");
assert!(
!md.contains(r"C:\lab"),
"the full path should not appear:\n{md}"
);
}
#[test]
fn an_expression_target_is_reported_as_unresolved() {
let files = [file_with(vec![calling(
"MainSequence",
"Locals.WhichTest",
"",
true,
)])];
let md = render(&files).join("\n");
assert!(md.contains("chosen at run time"), "{md}");
assert!(
md.contains("`Locals.WhichTest`"),
"the expression is still shown, as the expression it is:\n{md}"
);
}
#[test]
fn a_call_that_starts_an_execution_is_marked() {
let mut groups = BTreeMap::new();
groups.insert(
"Main".to_owned(),
vec![StepData {
step_type: "SequenceCall".to_owned(),
target_sequence: "Dialog".to_owned(),
call_kind: crate::data::CallKind::NewExecution,
..StepData::default()
}],
);
let files = [file_with(vec![SequenceData {
name: "MainSequence".to_owned(),
step_groups: groups,
..SequenceData::default()
}])];
let md = render(&files).join(
"
",
);
assert!(md.contains("as a new execution"), "{md}");
}
#[test]
fn a_target_that_does_not_exist_is_reported() {
let files = [file_with(vec![calling("MainSequence", "Gone", "", false)])];
let md = render(&files).join(
"
",
);
assert!(md.contains("not found in the documented files"), "{md}");
}
#[test]
fn a_target_in_another_file_is_not_called_missing() {
let files = [file_with(vec![calling(
"MainSequence",
"Shared",
r"C:\lab\Common.seq",
false,
)])];
let md = render(&files).join(
"
",
);
assert!(!md.contains("not found"), "{md}");
}
#[test]
fn repeated_calls_collapse_into_one_entry() {
let mut groups = BTreeMap::new();
let call = StepData {
step_type: "SequenceCall".to_owned(),
target_sequence: "Measure".to_owned(),
..StepData::default()
};
groups.insert("Main".to_owned(), vec![call.clone(), call]);
let files = [file_with(vec![SequenceData {
name: "MainSequence".to_owned(),
step_groups: groups,
..SequenceData::default()
}])];
let md = render(&files).join("\n");
assert_eq!(md.matches("`Measure`").count(), 1, "{md}");
}
}