diff --git a/CHANGELOG.md b/CHANGELOG.md index ae6da83..4ccd414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ## Unreleased +* Added macro feature to Toolproof + ## v0.5.0 (November 28, 2024) * Add `before_all` commands to the Toolproof config diff --git a/toolproof/src/main.rs b/toolproof/src/main.rs index 660e526..4eae2ea 100644 --- a/toolproof/src/main.rs +++ b/toolproof/src/main.rs @@ -8,7 +8,7 @@ use std::{collections::HashMap, time::Instant}; use console::{style, Term}; use futures::future::join_all; use normalize_path::NormalizePath; -use parser::{ToolproofFileType, ToolproofPlatform}; +use parser::{parse_macro, ToolproofFileType, ToolproofPlatform}; use schematic::color::owo::OwoColorize; use segments::ToolproofSegments; use similar_string::compare_similarity; @@ -54,6 +54,16 @@ pub struct ToolproofTestFile { pub file_directory: String, } +#[derive(Debug, Clone)] +pub struct ToolproofMacroFile { + pub macro_segments: ToolproofSegments, + pub macro_orig: String, + pub steps: Vec, + pub original_source: String, + pub file_path: String, + pub file_directory: String, +} + #[derive(Debug, Clone, PartialEq)] pub enum ToolproofTestSuccess { Skipped, @@ -77,6 +87,14 @@ pub enum ToolproofTestStep { state: ToolproofTestStepState, platforms: Option>, }, + Macro { + step_macro: ToolproofSegments, + args: HashMap, + orig: String, + hydrated_steps: Option>, + state: ToolproofTestStepState, + platforms: Option>, + }, Instruction { step: ToolproofSegments, args: HashMap, @@ -110,8 +128,11 @@ impl Display for ToolproofTestStep { Instruction { orig, .. } | Assertion { orig, .. } => { write!(f, "{}", orig) } + Macro { orig, .. } => { + write!(f, "run steps from macro: {}", orig) + } Ref { orig, .. } => { - write!(f, "run steps from: {}", orig) + write!(f, "run steps from file: {}", orig) } Snapshot { orig, .. } => { write!(f, "snapshot: {}", orig) @@ -146,6 +167,7 @@ impl ToolproofTestStep { match self { Ref { state, .. } + | Macro { state, .. } | Instruction { state, .. } | Assertion { state, .. } | Snapshot { state, .. } => state.clone(), @@ -208,6 +230,33 @@ async fn main_inner() -> Result<(), ()> { let start = Instant::now(); + let mut errors = vec![]; + + let macro_glob = Glob::new("**/*.toolproof.macro.yml").expect("Valid glob"); + let macro_walker = macro_glob + .walk(ctx.params.root.clone().unwrap_or(".".into())) + .flatten(); + + let loaded_macros = macro_walker + .map(|entry| { + let file = entry.path().to_path_buf(); + async { (file.clone(), read_to_string(file).await) } + }) + .collect::>(); + + let macros = join_all(loaded_macros).await; + + let all_macros: HashMap<_, _> = macros + .into_iter() + .filter_map(|(p, i)| match parse_macro(&i.unwrap(), p.clone()) { + Ok(f) => Some((f.macro_segments.clone(), f)), + Err(e) => { + errors.push(e); + return None; + } + }) + .collect(); + let glob = Glob::new("**/*.toolproof.yml").expect("Valid glob"); let walker = glob .walk(ctx.params.root.clone().unwrap_or(".".into())) @@ -224,7 +273,6 @@ async fn main_inner() -> Result<(), ()> { let mut names_thus_far: Vec<(String, String)> = vec![]; - let mut errors = vec![]; let all_tests: BTreeMap<_, _> = files .into_iter() .filter_map(|(p, i)| { @@ -259,6 +307,11 @@ async fn main_inner() -> Result<(), ()> { return Err(()); } + let macro_comparisons: Vec<_> = all_macros + .keys() + .map(|k| k.get_comparison_string()) + .collect(); + let all_instructions = register_instructions(); let instruction_comparisons: Vec<_> = all_instructions .keys() @@ -280,6 +333,8 @@ async fn main_inner() -> Result<(), ()> { let universe = Arc::new(Universe { browser: OnceCell::new(), tests: all_tests, + macros: all_macros, + macro_comparisons, instructions: all_instructions, instruction_comparisons, retrievers: all_retrievers, @@ -436,20 +491,38 @@ async fn main_inner() -> Result<(), ()> { log_err_preamble(); println!("{}", "--- ERROR ---".on_yellow().bold()); match &e.step { - ToolproofTestStep::Ref { - other_file, - orig, - hydrated_steps, - state, - platforms, - } => println!("{}", &e.red()), - ToolproofTestStep::Instruction { - step, - args, - orig, - state, - platforms, + ToolproofTestStep::Ref { .. } => println!("{}", &e.red()), + ToolproofTestStep::Macro { + step_macro, orig, .. } => { + let closest = log_closest( + "Macro", + &orig, + &step_macro, + &universe.macro_comparisons, + ); + + let matches = closest + .into_iter() + .map(|m| { + let (actual_segments, _) = universe + .macros + .get_key_value(&m) + .expect("should exist in the global set"); + format!( + "• {}", + style(actual_segments.get_as_string()).cyan() + ) + }) + .collect::>(); + + if matches.is_empty() { + eprintln!("{}", "No similar macro found".red()); + } else { + eprintln!("Closest macro:\n{}", matches.join("\n")); + } + } + ToolproofTestStep::Instruction { step, orig, .. } => { let closest = log_closest( "Instruction", &orig, @@ -480,10 +553,8 @@ async fn main_inner() -> Result<(), ()> { ToolproofTestStep::Assertion { retrieval, assertion, - args, orig, - state, - platforms, + .. } => { if !universe.retrievers.contains_key(&retrieval) { let closest = log_closest( diff --git a/toolproof/src/parser.rs b/toolproof/src/parser.rs index 0d94504..e68be61 100644 --- a/toolproof/src/parser.rs +++ b/toolproof/src/parser.rs @@ -7,7 +7,7 @@ use crate::{ errors::ToolproofInputError, platforms::normalize_line_endings, segments::{ToolproofSegment, ToolproofSegments}, - ToolproofTestFile, ToolproofTestStep, ToolproofTestStepState, + ToolproofMacroFile, ToolproofTestFile, ToolproofTestStep, ToolproofTestStepState, }; struct ToolproofTestInput { @@ -17,6 +17,13 @@ struct ToolproofTestInput { file_directory: String, } +struct ToolproofMacroInput { + parsed: RawToolproofMacroFile, + original_source: String, + file_path: String, + file_directory: String, +} + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ToolproofFileType { @@ -40,6 +47,12 @@ struct RawToolproofTestFile { steps: Vec, } +#[derive(serde::Serialize, serde::Deserialize)] +struct RawToolproofMacroFile { + r#macro: String, + steps: Vec, +} + #[derive(serde::Serialize, serde::Deserialize)] #[serde(untagged)] enum RawToolproofTestStep { @@ -47,6 +60,12 @@ enum RawToolproofTestStep { r#ref: String, platforms: Option>, }, + Macro { + r#macro: String, + platforms: Option>, + #[serde(flatten)] + other: Map, + }, BareStep(String), StepWithParams { step: String, @@ -83,6 +102,26 @@ impl TryFrom for ToolproofTestFile { } } +impl TryFrom for ToolproofMacroFile { + type Error = ToolproofInputError; + + fn try_from(value: ToolproofMacroInput) -> Result { + let mut steps = Vec::with_capacity(value.parsed.steps.len()); + for step in value.parsed.steps { + steps.push(step.try_into()?); + } + + Ok(ToolproofMacroFile { + macro_segments: parse_segments(&value.parsed.r#macro)?, + macro_orig: value.parsed.r#macro, + steps, + original_source: value.original_source, + file_path: value.file_path, + file_directory: value.file_directory, + }) + } +} + impl TryFrom for ToolproofTestStep { type Error = ToolproofInputError; @@ -100,6 +139,18 @@ impl TryFrom for ToolproofTestStep { state: ToolproofTestStepState::Dormant, platforms, }), + RawToolproofTestStep::Macro { + r#macro, + platforms, + other, + } => Ok(ToolproofTestStep::Macro { + step_macro: parse_segments(&r#macro)?, + args: HashMap::from_iter(other.into_iter()), + orig: r#macro, + hydrated_steps: None, + state: ToolproofTestStepState::Dormant, + platforms, + }), RawToolproofTestStep::BareStep(step) => parse_step(step, None, HashMap::new()), RawToolproofTestStep::StepWithParams { step, @@ -147,6 +198,21 @@ fn parse_step( } } +pub fn parse_macro(s: &str, p: PathBuf) -> Result { + let raw_macro = serde_yaml::from_str::(s)?; + + ToolproofMacroInput { + parsed: raw_macro, + original_source: normalize_line_endings(s), + file_path: p.to_slash_lossy().into_owned(), + file_directory: p + .parent() + .map(|p| p.to_slash_lossy().into_owned()) + .unwrap_or_else(|| ".".to_string()), + } + .try_into() +} + pub fn parse_file(s: &str, p: PathBuf) -> Result { let raw_test = serde_yaml::from_str::(s)?; diff --git a/toolproof/src/runner.rs b/toolproof/src/runner.rs index 6343621..cd4e6c0 100644 --- a/toolproof/src/runner.rs +++ b/toolproof/src/runner.rs @@ -35,7 +35,7 @@ pub async fn run_toolproof_experiment( universe, }; - let res = run_toolproof_steps(&input.file_directory, &mut input.steps, &mut civ).await; + let res = run_toolproof_steps(&input.file_directory, &mut input.steps, &mut civ, None).await; civ.shutdown().await; @@ -47,6 +47,7 @@ async fn run_toolproof_steps( file_directory: &String, steps: &mut Vec, civ: &mut Civilization<'_>, + transient_placeholders: Option>, ) -> Result { for cur_step in steps.iter_mut() { let marked_base_step = cur_step.clone(); @@ -94,6 +95,69 @@ async fn run_toolproof_steps( &target_file.file_directory, hydrated_steps.as_mut().unwrap(), civ, + None, + ) + .await + { + Ok(_) => { + *state = ToolproofTestStepState::Passed; + } + Err(e) => { + *state = ToolproofTestStepState::Failed; + return Err(e); + } + } + } else { + *state = ToolproofTestStepState::Skipped; + } + } + crate::ToolproofTestStep::Macro { + step_macro, + args, + orig: _, + hydrated_steps, + state, + platforms, + } => { + let Some((reference_segments, defined_macro)) = + civ.universe.macros.get_key_value(step_macro) + else { + *state = ToolproofTestStepState::Failed; + return Err(mark_and_return_step_error( + ToolproofStepError::External(ToolproofInputError::NonexistentStep), + state, + )); + }; + let variable_names = reference_segments.get_variable_names(); + + let defined_macro = defined_macro.clone(); + let macro_args = SegmentArgs::build( + reference_segments, + step_macro, + args, + Some(&civ), + transient_placeholders.as_ref(), + ) + .map_err(|e| mark_and_return_step_error(e.into(), state))?; + + let mut macro_placeholders = HashMap::with_capacity(variable_names.len()); + for name in variable_names { + match macro_args.get_string(&name) { + Ok(res) => { + macro_placeholders.insert(name, res); + } + Err(e) => return Err(mark_and_return_step_error(e.into(), state)), + } + } + + *hydrated_steps = Some(defined_macro.steps.clone()); + + if platform_matches(platforms) { + match run_toolproof_steps( + &defined_macro.file_directory, + hydrated_steps.as_mut().unwrap(), + civ, + Some(macro_placeholders), ) .await { @@ -126,9 +190,14 @@ async fn run_toolproof_steps( )); }; - let instruction_args = - SegmentArgs::build(reference_segments, step, args, Some(&civ)) - .map_err(|e| mark_and_return_step_error(e.into(), state))?; + let instruction_args = SegmentArgs::build( + reference_segments, + step, + args, + Some(&civ), + transient_placeholders.as_ref(), + ) + .map_err(|e| mark_and_return_step_error(e.into(), state))?; if platform_matches(platforms) { instruction @@ -158,8 +227,14 @@ async fn run_toolproof_steps( )); }; - let retrieval_args = SegmentArgs::build(reference_ret, retrieval, args, Some(&civ)) - .map_err(|e| mark_and_return_step_error(e.into(), state))?; + let retrieval_args = SegmentArgs::build( + reference_ret, + retrieval, + args, + Some(&civ), + transient_placeholders.as_ref(), + ) + .map_err(|e| mark_and_return_step_error(e.into(), state))?; let value = if platform_matches(platforms) { retrieval_step @@ -179,9 +254,14 @@ async fn run_toolproof_steps( )); }; - let assertion_args = - SegmentArgs::build(reference_assert, assertion, args, Some(&civ)) - .map_err(|e| mark_and_return_step_error(e.into(), state))?; + let assertion_args = SegmentArgs::build( + reference_assert, + assertion, + args, + Some(&civ), + transient_placeholders.as_ref(), + ) + .map_err(|e| mark_and_return_step_error(e.into(), state))?; if platform_matches(platforms) { assertion_step @@ -211,8 +291,14 @@ async fn run_toolproof_steps( )); }; - let retrieval_args = SegmentArgs::build(reference_ret, snapshot, args, Some(&civ)) - .map_err(|e| mark_and_return_step_error(e.into(), state))?; + let retrieval_args = SegmentArgs::build( + reference_ret, + snapshot, + args, + Some(&civ), + transient_placeholders.as_ref(), + ) + .map_err(|e| mark_and_return_step_error(e.into(), state))?; if platform_matches(platforms) { let value = retrieval_step diff --git a/toolproof/src/segments.rs b/toolproof/src/segments.rs index c37b5bd..073e9d4 100644 --- a/toolproof/src/segments.rs +++ b/toolproof/src/segments.rs @@ -51,6 +51,16 @@ impl PartialEq for ToolproofSegments { impl Eq for ToolproofSegments {} impl ToolproofSegments { + pub fn get_variable_names(&self) -> Vec { + self.segments + .iter() + .filter_map(|s| match s { + ToolproofSegment::Variable(name) => Some(name.clone()), + _ => None, + }) + .collect() + } + pub fn get_comparison_string(&self) -> String { use ToolproofSegment::*; @@ -97,6 +107,7 @@ impl<'a> SegmentArgs<'a> { supplied_instruction: &'a ToolproofSegments, supplied_args: &'a HashMap, civ: Option<&Civilization>, + transient_placeholders: Option<&HashMap>, ) -> Result, ToolproofInputError> { let mut args = HashMap::new(); @@ -148,6 +159,10 @@ impl<'a> SegmentArgs<'a> { } } + if let Some(transient_placeholders) = transient_placeholders { + placeholders.extend(transient_placeholders.clone().into_iter()); + } + Ok(Self { args, placeholders, @@ -257,7 +272,7 @@ mod test { let input = HashMap::new(); - let args = SegmentArgs::build(&segments_def, &user_instruction, &input, None) + let args = SegmentArgs::build(&segments_def, &user_instruction, &input, None, None) .expect("Args built successfully"); let Ok(str) = args.get_string("name") else { @@ -275,7 +290,7 @@ mod test { .expect("Valid instruction"); let user_instruction = - parse_segments("I have a \"index.%ext%\" file with the contents ':)'") + parse_segments("I have a \"%prefix%index.%ext%\" file with the contents ':)'") .expect("Valid instruction"); let input = HashMap::new(); @@ -290,6 +305,8 @@ mod test { let universe = Universe { browser: OnceCell::new(), tests: BTreeMap::new(), + macros: HashMap::new(), + macro_comparisons: vec![], instructions: HashMap::new(), instruction_comparisons: vec![], retrievers: HashMap::new(), @@ -310,8 +327,14 @@ mod test { universe: Arc::new(universe), }; - let args = SegmentArgs::build(&instruction_def, &user_instruction, &input, Some(&civ)) - .expect("Args built successfully"); + let args = SegmentArgs::build( + &instruction_def, + &user_instruction, + &input, + Some(&civ), + Some(&HashMap::from([("prefix".to_string(), "__".to_string())])), + ) + .expect("Args built successfully"); let Ok(str) = args.get_string("name") else { panic!( @@ -319,7 +342,7 @@ mod test { args.get_string("name") ); }; - assert_eq!(str, "index.pdf"); + assert_eq!(str, "__index.pdf"); } // Segments should alias to each other regardless of the contents of their diff --git a/toolproof/src/universe.rs b/toolproof/src/universe.rs index 4748bc4..98150a8 100644 --- a/toolproof/src/universe.rs +++ b/toolproof/src/universe.rs @@ -8,12 +8,14 @@ use crate::{ }, options::ToolproofContext, segments::ToolproofSegments, - ToolproofTestFile, + ToolproofMacroFile, ToolproofTestFile, }; pub struct Universe<'u> { pub browser: OnceCell, pub tests: BTreeMap, + pub macros: HashMap, + pub macro_comparisons: Vec, pub instructions: HashMap, pub instruction_comparisons: Vec, pub retrievers: HashMap, diff --git a/toolproof/test_suite/hooks/macros.toolproof.yml b/toolproof/test_suite/hooks/macros.toolproof.yml new file mode 100644 index 0000000..a624499 --- /dev/null +++ b/toolproof/test_suite/hooks/macros.toolproof.yml @@ -0,0 +1,37 @@ +name: Toolproof runs macros + +steps: + - step: I have a "misc_file" file with the content "lorem ipsum" + - step: I have a "append.toolproof.macro.yml" file with the content {yaml} + yaml: |- + macro: I append {something} twice + steps: + - I run 'echo " %something%" >> %toolproof_test_directory%/misc_file' + - step: I run {cmd} + cmd: echo "and %something%" >> %toolproof_test_directory%/misc_file + - step: I have a "my_test.toolproof.yml" file with the content {yaml} + yaml: |- + name: Inner macro user + + steps: + - macro: I append "yay" twice + - step: I run "cat %toolproof_test_directory%/misc_file" + - snapshot: stdout + snapshot_content: |- + ╎lorem ipsum yay + ╎and yay + - I run "%toolproof_path% --porcelain" + - snapshot: stdout + snapshot_content: |- + ╎ + ╎Running tests + ╎ + ╎✓ Inner macro user + ╎ + ╎Finished running tests + ╎ + ╎Passing tests: 1 + ╎Failing tests: 0 + ╎Skipped tests: 0 + ╎ + ╎All tests passed diff --git a/toolproof/test_suite/refs/ref-file.toolproof.yml b/toolproof/test_suite/refs/ref-file.toolproof.yml index 0c95218..4a9bd62 100644 --- a/toolproof/test_suite/refs/ref-file.toolproof.yml +++ b/toolproof/test_suite/refs/ref-file.toolproof.yml @@ -56,12 +56,12 @@ steps: ╎ ╎✘ Inner failing test ╎--- STEPS --- - ╎✓ run steps from: ../refs/common.toolproof.yml + ╎✓ run steps from file: ../refs/common.toolproof.yml ╎ ↳ ✓ I have a "public/index.html" file with the content "

Hello World

" ╎ ↳ ✓ I serve the directory "public" ╎ ↳ ✓ In my browser, I load "/" ╎✓ In my browser, I evaluate {js} - ╎✘ run steps from: ../refs/assert.toolproof.yml + ╎✘ run steps from file: ../refs/assert.toolproof.yml ╎ ↳ ✘ In my browser, I evaluate {js} ╎--- ERROR --- ╎Error in step "In my browser, I evaluate {js}":