diff --git a/Cargo.lock b/Cargo.lock index e785fbd..e6bf7d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -333,7 +333,7 @@ dependencies = [ [[package]] name = "technique" -version = "0.4.1" +version = "0.4.2" dependencies = [ "clap", "owo-colors", diff --git a/Cargo.toml b/Cargo.toml index 71d614c..41df1e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "technique" -version = "0.4.1" +version = "0.4.2" edition = "2021" description = "A domain specific language for procedures." authors = [ "Andrew Cowie" ] diff --git a/src/formatting/formatter.rs b/src/formatting/formatter.rs index 562b978..6acbe7a 100644 --- a/src/formatting/formatter.rs +++ b/src/formatting/formatter.rs @@ -127,6 +127,12 @@ pub fn render_invocation<'i>(invocation: &'i Invocation, renderer: &dyn Render) render_fragments(&sub.fragments, renderer) } +pub fn render_descriptive<'i>(descriptive: &'i Descriptive, renderer: &dyn Render) -> String { + let mut sub = Formatter::new(78); + sub.append_descriptive(descriptive); + render_fragments(&sub.fragments, renderer) +} + pub fn render_function<'i>(function: &'i Function, renderer: &dyn Render) -> String { let mut sub = Formatter::new(78); sub.append_function(function); @@ -322,16 +328,41 @@ impl<'i> Formatter<'i> { sub.add_fragment_reference(Syntax::Neutral, " "); sub.add_fragment_reference(Syntax::Structure, "}"); sub.flush_current(); - sub.fragments + + let mut combined = String::new(); + for (_syntax, content) in &sub.fragments { + combined.push_str(&content); + } + + vec![(Syntax::Structure, Cow::Owned(combined))] } } } + fn render_string_interpolation(&self, expr: &'i Expression) -> Vec<(Syntax, Cow<'i, str>)> { + let mut sub = self.subformatter(); + sub.add_fragment_reference(Syntax::Structure, "{"); + sub.add_fragment_reference(Syntax::Neutral, " "); + sub.append_expression(expr); + sub.add_fragment_reference(Syntax::Neutral, " "); + sub.add_fragment_reference(Syntax::Structure, "}"); + sub.flush_current(); + sub.fragments + } + fn render_application(&self, invocation: &'i Invocation) -> Vec<(Syntax, Cow<'i, str>)> { let mut sub = self.subformatter(); sub.append_application(invocation); sub.flush_current(); - sub.fragments + + // Combine all fragments into a single atomic fragment to prevent wrapping + let mut combined = String::new(); + for (_syntax, content) in &sub.fragments { + combined.push_str(&content); + } + + // Return as a single fragment + vec![(Syntax::Invocation, Cow::Owned(combined))] } fn render_binding( @@ -363,7 +394,14 @@ impl<'i> Formatter<'i> { sub.add_fragment_reference(Syntax::Neutral, " "); sub.append_variables(variables); sub.flush_current(); - sub.fragments + + // Combine all fragments into a single atomic fragment to prevent wrapping + let mut combined = String::new(); + for (_syntax, content) in &sub.fragments { + combined.push_str(&content); + } + + vec![(Syntax::Structure, Cow::Owned(combined))] } pub fn append_char(&mut self, c: char) { @@ -584,7 +622,16 @@ impl<'i> Formatter<'i> { } } - fn append_descriptives(&mut self, descriptives: &'i Vec) { + // This is a helper for rendering a single descriptives in error messages. + // The real method is append_decriptives() below; this method simply + // creates a single element slice that can be passed to it. + fn append_descriptive(&mut self, descriptive: &'i Descriptive) { + use std::slice; + let slice = slice::from_ref(descriptive); + self.append_descriptives(slice); + } + + fn append_descriptives(&mut self, descriptives: &'i [Descriptive<'i>]) { let syntax = self.current; let mut line = self.builder(); @@ -894,7 +941,7 @@ impl<'i> Formatter<'i> { self.add_fragment_reference(Syntax::String, text); } Piece::Interpolation(expr) => { - let fragments = self.render_inline_code(expr); + let fragments = self.render_string_interpolation(expr); for (syntax, content) in fragments { self.add_fragment(syntax, content); } @@ -1223,30 +1270,41 @@ impl<'a, 'i> Line<'a, 'i> { let fragments = self .output .render_inline_code(expr); - self.add_fragments(fragments); + for (syntax, content) in fragments { + self.add_no_wrap(syntax, content); + } } fn add_application(&mut self, invocation: &'i Invocation) { let fragments = self .output .render_application(invocation); - self.add_fragments(fragments); + for (syntax, content) in fragments { + self.add_no_wrap(syntax, content); + } } fn add_binding(&mut self, inner_descriptive: &'i Descriptive, variables: &'i Vec) { let fragments = self .output .render_binding(inner_descriptive, variables); - self.add_fragments(fragments); - } - - fn add_fragments(&mut self, fragments: Vec<(Syntax, Cow<'i, str>)>) { - // All fragments should be atomic - the formatter is responsible for breaking up content + // Bindings should not wrap - add as a single non-wrapping unit for (syntax, content) in fragments { - self.add_atomic_cow(syntax, content); + self.add_no_wrap(syntax, content); } } + fn add_no_wrap(&mut self, syntax: Syntax, content: Cow<'i, str>) { + // Add content that must never wrap mid-construct (inline code, + // applications, bindings) Unlike add_atomic_cow(), this bypasses + // width checking entirely to preserve the integrity of these language + // constructs on single lines + let len = content.len() as u8; + self.current + .push((syntax, content)); + self.position += len; + } + fn wrap_line(&mut self) { // Emit all current fragments to the output for (syntax, content) in self diff --git a/src/language/types.rs b/src/language/types.rs index 5e8c5a9..7e43d29 100644 --- a/src/language/types.rs +++ b/src/language/types.rs @@ -1,6 +1,6 @@ //! Types representing an Abstract Syntax Tree for the Technique language -use crate::{language::quantity::parse_quantity, regex::*}; +use crate::regex::*; #[derive(Eq, Debug, PartialEq)] pub struct Document<'i> { @@ -214,7 +214,7 @@ pub use crate::language::quantity::Quantity; // the validate functions all need to have start and end anchors, which seems // like it should be abstracted away. -pub fn validate_license(input: &str) -> Option<&str> { +pub(crate) fn validate_license(input: &str) -> Option<&str> { let re = regex!(r"^[A-Za-z0-9.,\-_ \(\)\[\]]+$"); if re.is_match(input) { @@ -224,7 +224,7 @@ pub fn validate_license(input: &str) -> Option<&str> { } } -pub fn validate_copyright(input: &str) -> Option<&str> { +pub(crate) fn validate_copyright(input: &str) -> Option<&str> { let re = regex!(r"^[A-Za-z0-9.,\-_ \(\)\[\]]+$"); if re.is_match(input) { @@ -234,7 +234,7 @@ pub fn validate_copyright(input: &str) -> Option<&str> { } } -pub fn validate_template(input: &str) -> Option<&str> { +pub(crate) fn validate_template(input: &str) -> Option<&str> { let re = regex!(r"^[A-Za-z0-9.,\-]+$"); if re.is_match(input) { @@ -244,7 +244,7 @@ pub fn validate_template(input: &str) -> Option<&str> { } } -pub fn validate_identifier(input: &str) -> Option> { +pub(crate) fn validate_identifier(input: &str) -> Option> { if input.len() == 0 { return None; } @@ -257,7 +257,7 @@ pub fn validate_identifier(input: &str) -> Option> { } } -pub fn validate_forma(input: &str) -> Option> { +pub(crate) fn validate_forma(input: &str) -> Option> { if input.len() == 0 { return None; } @@ -294,7 +294,7 @@ fn parse_tuple(input: &str) -> Option>> { } /// This one copes with (and discards) any internal whitespace encountered. -pub fn validate_genus(input: &str) -> Option> { +pub(crate) fn validate_genus(input: &str) -> Option> { let first = input .chars() .next() @@ -371,33 +371,6 @@ pub fn validate_response(input: &str) -> Option> { Some(Response { value, condition }) } -fn _validate_decimal(_input: &str) -> Option> { - // Test the regex macro availability within types.rs - let _decimal_regex = regex!(r"^\s*-?[0-9]+\.[0-9]+\s*$"); - // For now, just return None since we removed Decimal variant - None -} - -pub fn validate_numeric(input: &str) -> Option> { - if input.is_empty() { - return None; - } - - let input = input.trim_ascii(); - - // Try to parse as a simple Integral first - if let Ok(amount) = input.parse::() { - return Some(Numeric::Integral(amount)); - } - - // Try to parse as a Quantity (scientific notation with units) - if let Some(quantity) = parse_quantity(input) { - return Some(Numeric::Scientific(quantity)); - } - - None -} - #[cfg(test)] mod check { use super::*; @@ -582,18 +555,6 @@ mod check { t1 } - #[test] - fn numeric_rules() { - // Test simple integers - assert_eq!(validate_numeric("42"), Some(Numeric::Integral(42))); - assert_eq!(validate_numeric("0"), Some(Numeric::Integral(0))); - assert_eq!(validate_numeric("-123"), Some(Numeric::Integral(-123))); - assert_eq!( - validate_numeric("9223372036854775807"), - Some(Numeric::Integral(9223372036854775807)) - ); - } - #[test] fn ast_construction() { let t1 = Metadata { diff --git a/src/parsing/checks/errors.rs b/src/parsing/checks/errors.rs new file mode 100644 index 0000000..dba2b87 --- /dev/null +++ b/src/parsing/checks/errors.rs @@ -0,0 +1,267 @@ +use super::*; +use std::path::Path; + +/// Helper function to check if parsing produces the expected error type +fn expect_error(content: &str, expected: ParsingError) { + let result = parse_with_recovery(Path::new("test.tq"), content); + match result { + Ok(_) => panic!( + "Expected parsing to fail, but it succeeded for input: {}", + content + ), + Err(errors) => { + // Check if any error matches the expected type + let found_expected = errors + .iter() + .any(|error| std::mem::discriminant(error) == std::mem::discriminant(&expected)); + + if !found_expected { + panic!( + "Expected error type like {:?} but got: {:?} for input '{}'", + expected, errors, content + ); + } + } + } +} + +#[test] +fn invalid_identifier_uppercase_start() { + expect_error( + r#" +Making_Coffee : Ingredients -> Coffee + "# + .trim_ascii(), + ParsingError::InvalidIdentifier(0, "".to_string()), + ); +} + +#[test] +fn invalid_identifier_mixed_case() { + expect_error( + r#" +makeCoffee : Ingredients -> Coffee + "# + .trim_ascii(), + ParsingError::InvalidIdentifier(0, "".to_string()), + ); +} + +#[test] +fn invalid_identifier_with_dashes() { + expect_error( + r#" +make-coffee : Ingredients -> Coffee + "# + .trim_ascii(), + ParsingError::InvalidIdentifier(0, "".to_string()), + ); +} + +#[test] +fn invalid_identifier_with_spaces() { + expect_error( + r#" +make coffee : Ingredients -> Coffee + "# + .trim_ascii(), + ParsingError::InvalidParameters(0), + ); +} + +#[test] +fn invalid_signature_wrong_arrow() { + expect_error( + r#" +making_coffee : Ingredients => Coffee + "# + .trim_ascii(), + ParsingError::InvalidSignature(0), + ); +} + +#[test] +fn invalid_genus_lowercase_forma() { + expect_error( + r#" +making_coffee : ingredients -> Coffee + "# + .trim_ascii(), + ParsingError::InvalidGenus(16), + ); +} + +#[test] +fn invalid_genus_both_lowercase() { + expect_error( + r#" +making_coffee : ingredients -> coffee + "# + .trim_ascii(), + ParsingError::InvalidGenus(16), + ); +} + +#[test] +fn invalid_signature_missing_arrow() { + expect_error( + r#" +making_coffee : Ingredients Coffee + "# + .trim_ascii(), + ParsingError::InvalidSignature(16), + ); +} + +#[test] +fn invalid_declaration_missing_colon() { + expect_error( + r#" +making_coffee Ingredients -> Coffee + "# + .trim_ascii(), + ParsingError::Unrecognized(0), + ); +} + +#[test] +fn invalid_identifier_in_parameters() { + expect_error( + r#" +making_coffee(BadParam) : Ingredients -> Coffee + "# + .trim_ascii(), + ParsingError::InvalidIdentifier(14, "".to_string()), + ); +} + +#[test] +fn invalid_identifier_empty() { + expect_error( + r#" + : Ingredients -> Coffee + "# + .trim_ascii(), + ParsingError::InvalidDeclaration(0), + ); +} + +#[test] +fn invalid_step_format() { + expect_error( + r#" +making_coffee : + + A. First step (should be lowercase 'a.') + "# + .trim_ascii(), + ParsingError::InvalidStep(21), + ); +} + +#[test] +fn invalid_response_wrong_quotes() { + expect_error( + r#" +making_coffee : + + 1. Do you want coffee? + "Yes" | "No" + "# + .trim_ascii(), + ParsingError::InvalidResponse(52), + ); +} + +#[test] +fn invalid_multiline_missing_closing() { + expect_error( + r#" +making_coffee : + + 1. Do something with ``` + This is missing closing backticks + "# + .trim_ascii(), + ParsingError::InvalidMultiline(41), + ); +} + +#[test] +fn invalid_code_block_missing_closing_brace() { + expect_error( + r#" +making_coffee : + + 1. Do something { exec("command" + "# + .trim_ascii(), + ParsingError::ExpectedMatchingChar(38, "a code block", '{', '}'), + ); +} + +#[test] +fn invalid_step_wrong_ordinal() { + expect_error( + r#" +making_coffee : + + i. Wrong case section + "# + .trim_ascii(), + ParsingError::InvalidStep(21), + ); +} + +#[test] +fn invalid_invocation_malformed() { + expect_error( + r#" +making_coffee : + + 1. Do '), + ); +} + +#[test] +fn invalid_execution_malformed() { + expect_error( + r#" +making_coffee : + + 1. Do something { exec("command" } + "# + .trim_ascii(), + ParsingError::ExpectedMatchingChar(43, "a function call", '(', ')'), + ); +} + +#[test] +fn invalid_invocation_in_repeat() { + expect_error( + r#" +making_coffee : + + 1. { repeat '), + ); +} + +#[test] +fn invalid_substep_uppercase() { + expect_error( + r#" +making_coffee : + + 1. First step + A. This should be lowercase + "# + .trim_ascii(), + ParsingError::InvalidSubstep(37), + ); +} diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs new file mode 100644 index 0000000..08fe4ba --- /dev/null +++ b/src/parsing/checks/parser.rs @@ -0,0 +1,2197 @@ +use super::*; + +#[test] +fn magic_line() { + let mut input = Parser::new(); + input.initialize("% technique v1"); + assert!(is_magic_line(input.source)); + + let result = input.read_magic_line(); + assert_eq!(result, Ok(1)); + + input.initialize("%technique v1"); + assert!(is_magic_line(input.source)); + + let result = input.read_magic_line(); + assert_eq!(result, Ok(1)); + + input.initialize("%techniquev1"); + assert!(is_magic_line(input.source)); + + // this is rejected because the technique keyword isn't present. + let result = input.read_magic_line(); + assert!(result.is_err()); +} + +#[test] +fn magic_line_wrong_keyword_error_position() { + // Test that error position points to the first character of the wrong keyword + assert_eq!(analyze_magic_line("% tecnique v1"), 2); // Points to "t" in "tecnique" + assert_eq!(analyze_magic_line("% tecnique v1"), 3); // Points to "t" in "tecnique" with extra space + assert_eq!(analyze_magic_line("% \ttechniqe v1"), 3); // Points to "t" in "techniqe" with tab + assert_eq!(analyze_magic_line("% wrong v1"), 5); // Points to "w" in "wrong" with multiple spaces + assert_eq!(analyze_magic_line("% foo v1"), 2); // Points to "f" in "foo" + assert_eq!(analyze_magic_line("% TECHNIQUE v1"), 2); // Points to "T" in uppercase "TECHNIQUE" + + // Test missing keyword entirely - should point to position after % + assert_eq!(analyze_magic_line("% v1"), 2); // Points to "v" when keyword is missing + assert_eq!(analyze_magic_line("% v1"), 3); // Points to "v" when keyword is missing with space +} + +#[test] +fn magic_line_wrong_version_error_position() { + // Test that error position points to the version number after "v" in wrong version strings + assert_eq!(analyze_magic_line("% technique v0"), 13); // Points to "0" in "v0" + assert_eq!(analyze_magic_line("% technique v2"), 14); // Points to "2" in "v2" with extra space + assert_eq!(analyze_magic_line("% technique\tv0"), 13); // Points to "0" in "v0" with tab + assert_eq!(analyze_magic_line("% technique vX"), 15); // Points to "X" in "vX" with multiple spaces + assert_eq!(analyze_magic_line("% technique v99"), 13); // Points to "9" in "v99" + assert_eq!(analyze_magic_line("% technique v0.5"), 15); // Points to "0" in "v0.5" with multiple spaces + + // Test edge case where there's no "v" at all - should point to where version should start + assert_eq!(analyze_magic_line("% technique 1.0"), 12); // Points to "1" when there's no "v" + assert_eq!(analyze_magic_line("% technique v1.0"), 14); // Points to "." when there is a "v1" but it has minor version + assert_eq!(analyze_magic_line("% technique 2"), 13); // Points to "2" when there's no "v" with extra space + assert_eq!(analyze_magic_line("% technique beta"), 12); // Points to "b" in "beta" when there's no "v" +} + +#[test] +fn header_spdx() { + let mut input = Parser::new(); + input.initialize("! PD"); + assert!(is_spdx_line(input.source)); + + let result = input.read_spdx_line(); + assert_eq!(result, Ok((Some("PD"), None))); + + input.initialize("! MIT; (c) ACME, Inc."); + assert!(is_spdx_line(input.source)); + + let result = input.read_spdx_line(); + assert_eq!(result, Ok((Some("MIT"), Some("ACME, Inc.")))); + + input.initialize("! MIT; (C) 2024 ACME, Inc."); + assert!(is_spdx_line(input.source)); + + let result = input.read_spdx_line(); + assert_eq!(result, Ok((Some("MIT"), Some("2024 ACME, Inc.")))); + + input.initialize("! CC BY-SA 3.0 [IGO]; (c) 2024 ACME, Inc."); + assert!(is_spdx_line(input.source)); + + let result = input.read_spdx_line(); + assert_eq!( + result, + Ok((Some("CC BY-SA 3.0 [IGO]"), Some("2024 ACME, Inc."))) + ); +} + +#[test] +fn header_template() { + let mut input = Parser::new(); + input.initialize("& checklist"); + assert!(is_template_line(input.source)); + + let result = input.read_template_line(); + assert_eq!(result, Ok(Some("checklist"))); + + input.initialize("& nasa-flight-plan,v4.0"); + assert!(is_template_line(input.source)); + + let result = input.read_template_line(); + assert_eq!(result, Ok(Some("nasa-flight-plan,v4.0"))); +} + +// now we test incremental parsing + +#[test] +fn check_not_eof() { + let mut input = Parser::new(); + input.initialize("Hello World"); + assert!(!input.is_finished()); + + input.initialize(""); + assert!(input.is_finished()); +} + +#[test] +fn consume_whitespace() { + let mut input = Parser::new(); + input.initialize(" hello"); + input.trim_whitespace(); + assert_eq!(input.source, "hello"); + + input.initialize("\n \nthere"); + input.trim_whitespace(); + assert_eq!(input.source, "there"); + assert_eq!(input.offset, 3); +} + +// It is not clear that we will ever actually need parse_identifier(), +// parse_forma(), parse_genus(), or parse_signature() as they are not +// called directly, but even though they are not used in composition of +// the parse_procedure_declaration() parser, it is highly likely that +// someday we will need to be able to parse them individually, perhaps for +// a future language server or code highlighter. So we test them properly +// here; in any event it exercises the underlying validate_*() codepaths. + +#[test] +fn identifier_rules() { + let mut input = Parser::new(); + input.initialize("p"); + let result = input.read_identifier(); + assert_eq!(result, Ok(Identifier("p"))); + + input.initialize("cook_pizza"); + let result = input.read_identifier(); + assert_eq!(result, Ok(Identifier("cook_pizza"))); + + input.initialize("cook-pizza"); + let result = input.read_identifier(); + assert!(result.is_err()); +} + +#[test] +fn signatures() { + let mut input = Parser::new(); + input.initialize("A -> B"); + let result = input.read_signature(); + assert_eq!( + result, + Ok(Signature { + domain: Genus::Single(Forma("A")), + range: Genus::Single(Forma("B")) + }) + ); + + input.initialize("Beans -> Coffee"); + let result = input.read_signature(); + assert_eq!( + result, + Ok(Signature { + domain: Genus::Single(Forma("Beans")), + range: Genus::Single(Forma("Coffee")) + }) + ); + + input.initialize("[Bits] -> Bob"); + let result = input.read_signature(); + assert_eq!( + result, + Ok(Signature { + domain: Genus::List(Forma("Bits")), + range: Genus::Single(Forma("Bob")) + }) + ); + + input.initialize("Complex -> (Real, Imaginary)"); + let result = input.read_signature(); + assert_eq!( + result, + Ok(Signature { + domain: Genus::Single(Forma("Complex")), + range: Genus::Tuple(vec![Forma("Real"), Forma("Imaginary")]) + }) + ); +} + +#[test] +fn declaration_simple() { + let mut input = Parser::new(); + input.initialize("making_coffee :"); + + assert!(is_procedure_declaration(input.source)); + + let result = input.parse_procedure_declaration(); + assert_eq!(result, Ok((Identifier("making_coffee"), None, None))); +} + +#[test] +fn declaration_full() { + let mut input = Parser::new(); + input.initialize("f : A -> B"); + assert!(is_procedure_declaration(input.source)); + + let result = input.parse_procedure_declaration(); + assert_eq!( + result, + Ok(( + Identifier("f"), + None, + Some(Signature { + domain: Genus::Single(Forma("A")), + range: Genus::Single(Forma("B")) + }) + )) + ); + + input.initialize("making_coffee : (Beans, Milk) -> [Coffee]"); + assert!(is_procedure_declaration(input.source)); + + let result = input.parse_procedure_declaration(); + assert_eq!( + result, + Ok(( + Identifier("making_coffee"), + None, + Some(Signature { + domain: Genus::Tuple(vec![Forma("Beans"), Forma("Milk")]), + range: Genus::List(Forma("Coffee")) + }) + )) + ); + + let content = "f : B"; + // we still need to detect procedure declarations with malformed + // signatures; the user's intent will be to declare a procedure though + // it will fail validation in the parser shortly after. + assert!(is_procedure_declaration(content)); + + let content = r#" + connectivity_check(e,s) : LocalEnvironment, TargetService -> NetworkHealth + "#; + + assert!(is_procedure_declaration(content)); +} + +// At one point we had a bug where parsing was racing ahead and taking too +// much content, which was only uncovered when we expanded to be agnostic +// about whitespace in procedure declarations. +#[test] +fn multiline_declaration() { + let content = r#" + making_coffee (b, m) : + (Beans, Milk) + -> Coffee + + And now we will make coffee as follows... + + 1. Add the beans to the machine + 2. Pour in the milk + "#; + + assert!(is_procedure_declaration(content)); +} + +#[test] +fn multiline_signature_parsing() { + let mut input = Parser::new(); + let content = r#" +making_coffee : + Ingredients + -> Coffee + "# + .trim_ascii(); + + input.initialize(content); + let result = input.parse_procedure_declaration(); + + assert_eq!( + result, + Ok(( + Identifier("making_coffee"), + None, + Some(Signature { + domain: Genus::Single(Forma("Ingredients")), + range: Genus::Single(Forma("Coffee")) + }) + )) + ); + + // Test complex multiline signature with parameters and tuple + let content = r#" +making_coffee(b, m) : + (Beans, Milk) + -> Coffee + "# + .trim_ascii(); + + input.initialize(content); + let result = input.parse_procedure_declaration(); + + assert_eq!( + result, + Ok(( + Identifier("making_coffee"), + Some(vec![Identifier("b"), Identifier("m")]), + Some(Signature { + domain: Genus::Tuple(vec![Forma("Beans"), Forma("Milk")]), + range: Genus::Single(Forma("Coffee")) + }) + )) + ); +} + +#[test] +fn character_delimited_blocks() { + let mut input = Parser::new(); + input.initialize("{ todo() }"); + + let result = input.take_block_chars("inline code", '{', '}', true, |parser| { + let text = parser.source; + assert_eq!(text, " todo() "); + Ok(true) + }); + assert_eq!(result, Ok(true)); + + // this is somewhat contrived as we would not be using this to parse + // strings (We will need to preserve whitespace inside strings when + // we find ourselves parsing them, so subparser() won't work. + input.initialize("XhelloX world"); + + let result = input.take_block_chars("", 'X', 'X', false, |parser| { + let text = parser.source; + assert_eq!(text, "hello"); + Ok(true) + }); + assert_eq!(result, Ok(true)); +} + +#[test] +fn skip_string_content_flag() { + let mut input = Parser::new(); + + // Test skip_string_content: true - should ignore braces inside strings + input.initialize(r#"{ "string with { brace" }"#); + let result = input.take_block_chars("code block", '{', '}', true, |parser| { + let text = parser.source; + assert_eq!(text, r#" "string with { brace" "#); + Ok(true) + }); + assert_eq!(result, Ok(true)); + + // Test skip_string_content: false - should treat braces normally + input.initialize(r#""string with } brace""#); + let result = input.take_block_chars("string content", '"', '"', false, |parser| { + let text = parser.source; + assert_eq!(text, "string with } brace"); + Ok(true) + }); + assert_eq!(result, Ok(true)); +} + +#[test] +fn string_delimited_blocks() { + let mut input = Parser::new(); + input.initialize("```bash\nls -l\necho hello```"); + assert_eq!(input.offset, 0); + + let result = input.take_block_delimited("```", |parser| { + let text = parser.source; + assert_eq!(text, "bash\nls -l\necho hello"); + Ok(true) + }); + assert_eq!(result, Ok(true)); + assert_eq!(input.source, ""); + assert_eq!(input.offset, 27); + + // Test with different delimiter + input.initialize("---start\ncontent here\nmore content---end"); + + let result = input.take_block_delimited("---", |parser| { + let text = parser.source; + assert_eq!(text, "start\ncontent here\nmore content"); + Ok(true) + }); + assert_eq!(result, Ok(true)); + + // Test with whitespace around delimiters + input.initialize("``` hello world ``` and now goodbye"); + + let result = input.take_block_delimited("```", |parser| { + let text = parser.source; + assert_eq!(text, " hello world "); + Ok(true) + }); + assert_eq!(result, Ok(true)); + assert_eq!(input.source, " and now goodbye"); + assert_eq!(input.offset, 21); +} + +#[test] +fn taking_until() { + let mut input = Parser::new(); + + // Test take_until() with an identifier up to a limiting character + input.initialize("hello,world"); + let result = input.take_until(&[','], |inner| inner.read_identifier()); + assert_eq!(result, Ok(Identifier("hello"))); + assert_eq!(input.source, ",world"); + + // Test take_until() with whitespace delimiters + input.initialize("test \t\nmore"); + let result = input.take_until(&[' ', '\t', '\n'], |inner| inner.read_identifier()); + assert_eq!(result, Ok(Identifier("test"))); + assert_eq!(input.source, " \t\nmore"); + + // Test take_until() when no delimiter found (it should take everything) + input.initialize("onlytext"); + let result = input.take_until(&[',', ';'], |inner| inner.read_identifier()); + assert_eq!(result, Ok(Identifier("onlytext"))); + assert_eq!(input.source, ""); +} + +#[test] +fn reading_invocations() { + let mut input = Parser::new(); + + // Test simple invocation without parameters + input.initialize(""); + let result = input.read_invocation(); + assert_eq!( + result, + Ok(Invocation { + target: Target::Local(Identifier("hello")), + parameters: None + }) + ); + + // Test invocation with empty parameters + input.initialize("()"); + let result = input.read_invocation(); + assert_eq!( + result, + Ok(Invocation { + target: Target::Local(Identifier("hello_world")), + parameters: Some(vec![]) + }) + ); + + // Test invocation with multiple parameters + input.initialize("(name, title, occupation)"); + let result = input.read_invocation(); + assert_eq!( + result, + Ok(Invocation { + target: Target::Local(Identifier("greetings")), + parameters: Some(vec![ + Expression::Variable(Identifier("name")), + Expression::Variable(Identifier("title")), + Expression::Variable(Identifier("occupation")) + ]) + }) + ); + + // We don't have real support for this yet, but syntactically we will + // support the idea of invoking a procedure at an external URL, so we + // have this case as a placeholder. + input.initialize(""); + let result = input.read_invocation(); + assert_eq!( + result, + Ok(Invocation { + target: Target::Remote(External("https://example.com/proc")), + parameters: None + }) + ); +} + +#[test] +fn step_detection() { + // Test main dependent steps (whitespace agnostic) + assert!(is_step_dependent("1. First step")); + assert!(is_step_dependent(" 1. Indented step")); + assert!(is_step_dependent("10. Tenth step")); + assert!(!is_step_dependent("a. Letter step")); + assert!(!is_step_dependent("1.No space")); + + // Test dependent substeps (whitespace agnostic) + assert!(is_substep_dependent("a. Substep")); + assert!(is_substep_dependent(" a. Indented substep")); + assert!(!is_substep_dependent("2. Substep can't have number")); + assert!(!is_substep_dependent(" 1. Even if it is indented")); + + // Test parallel substeps (whitespace agnostic) + assert!(is_substep_parallel("- Parallel substep")); + assert!(is_substep_parallel(" - Indented parallel")); + assert!(is_substep_parallel(" - Deeper indented")); + assert!(!is_substep_parallel("-No space")); // it's possible we may allow this in the future + assert!(!is_substep_parallel("* Different bullet")); + + // Test top-level parallel steps + assert!(is_step_parallel("- Top level parallel")); + assert!(is_step_parallel(" - Indented parallel")); + assert!(is_step("- Top level parallel")); // general step detection + assert!(is_step("1. Numbered step")); + + // Test recognition of sub-sub-steps + assert!(is_subsubstep_dependent("i. One")); + assert!(is_subsubstep_dependent(" ii. Two")); + assert!(is_subsubstep_dependent("v. Five")); + assert!(is_subsubstep_dependent("vi. Six")); + assert!(is_subsubstep_dependent("ix. Nine")); + assert!(is_subsubstep_dependent("x. Ten")); + assert!(is_subsubstep_dependent("xi. Eleven")); + assert!(is_subsubstep_dependent("xxxix. Thirty-nine")); + + // Test attribute assignments + assert!(is_attribute_assignment("@surgeon")); + assert!(is_attribute_assignment(" @nursing_team")); + assert!(is_attribute_assignment("^kitchen")); + assert!(is_attribute_assignment(" ^garden ")); + assert!(is_attribute_assignment("@chef + ^kitchen")); + assert!(is_attribute_assignment("^room1 + @barista")); + assert!(!is_attribute_assignment("surgeon")); + assert!(!is_attribute_assignment("@123invalid")); + assert!(!is_attribute_assignment("^InvalidPlace")); + + // Test enum responses + assert!(is_enum_response("'Yes'")); + assert!(is_enum_response(" 'No'")); + assert!(is_enum_response("'Not Applicable'")); + assert!(!is_enum_response("Yes")); + assert!(!is_enum_response("'unclosed")); +} + +#[test] +fn read_toplevel_steps() { + let mut input = Parser::new(); + + // Test simple dependent step + input.initialize("1. First step"); + let result = input.read_step_dependent(); + assert_eq!( + result, + Ok(Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text("First step")])], + subscopes: vec![], + }) + ); + + // Test simple parallel step + input.initialize( + r#" + - a top-level task to be one in parallel with + - another top-level task + "#, + ); + let result = input.read_step_parallel(); + assert_eq!( + result, + Ok(Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text( + "a top-level task to be one in parallel with" + )]),], + subscopes: vec![], + }) + ); + let result = input.read_step_parallel(); + assert_eq!( + result, + Ok(Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text("another top-level task")]),], + subscopes: vec![], + }) + ); + + // Test multi-line dependent step + input.initialize( + r#" + 1. Have you done the first thing in the first one? + "#, + ); + let result = input.read_step_dependent(); + assert_eq!( + result, + Ok(Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text( + "Have you done the first thing in the first one?" + )])], + subscopes: vec![], + }) + ); + + // Test invalid step + input.initialize("Not a step"); + let result = input.read_step_dependent(); + assert_eq!(result, Err(ParsingError::InvalidStep(0))); +} + +#[test] +fn reading_substeps_basic() { + let mut input = Parser::new(); + + // Test simple dependent sub-step + input.initialize("a. First subordinate task"); + let result = input.read_substep_dependent(); + assert_eq!( + result, + Ok(Scope::DependentBlock { + ordinal: "a", + description: vec![Paragraph(vec![Descriptive::Text("First subordinate task")])], + subscopes: vec![], + }) + ); + + // Test simple parallel sub-step + input.initialize("- Parallel task"); + let result = input.read_substep_parallel(); + assert_eq!( + result, + Ok(Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text("Parallel task")])], + subscopes: vec![], + }) + ); +} + +#[test] +fn single_step_with_dependent_substeps() { + let mut input = Parser::new(); + + input.initialize( + r#" +1. Main step + a. First substep + b. Second substep + "#, + ); + let result = input.read_step_dependent(); + + assert_eq!( + result, + Ok(Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text("Main step")])], + subscopes: vec![ + Scope::DependentBlock { + ordinal: "a", + description: vec![Paragraph(vec![Descriptive::Text("First substep")])], + subscopes: vec![], + }, + Scope::DependentBlock { + ordinal: "b", + description: vec![Paragraph(vec![Descriptive::Text("Second substep")])], + subscopes: vec![], + }, + ], + }) + ); +} + +#[test] +fn single_step_with_parallel_substeps() { + let mut input = Parser::new(); + + input.initialize( + r#" +1. Main step + - First substep + - Second substep + "#, + ); + let result = input.read_step_dependent(); + + assert_eq!( + result, + Ok(Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text("Main step")])], + subscopes: vec![ + Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text("First substep")])], + subscopes: vec![], + }, + Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text("Second substep")])], + subscopes: vec![], + }, + ], + }) + ); +} + +#[test] +fn multiple_steps_with_substeps() { + let mut input = Parser::new(); + + input.initialize( + r#" +1. First step + a. Substep +2. Second step + "#, + ); + let first_result = input.read_step_dependent(); + let second_result = input.read_step_dependent(); + + assert_eq!( + first_result, + Ok(Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text("First step")])], + subscopes: vec![Scope::DependentBlock { + ordinal: "a", + description: vec![Paragraph(vec![Descriptive::Text("Substep")])], + subscopes: vec![], + }], + }) + ); + + assert_eq!( + second_result, + Ok(Scope::DependentBlock { + ordinal: "2", + description: vec![Paragraph(vec![Descriptive::Text("Second step")])], + subscopes: vec![], + }) + ); +} + +#[test] +fn is_step_with_failing_input() { + let test_input = "1. Have you done the first thing in the first one?\n a. Do the first thing. Then ask yourself if you are done:\n 'Yes' | 'No' but I have an excuse\n2. Do the second thing in the first one."; + + // Test each line that should be a step + assert!(is_step_dependent( + "1. Have you done the first thing in the first one?" + )); + assert!(is_step_dependent( + "2. Do the second thing in the first one." + )); + + // Test lines that should NOT be steps + assert!(!is_step_dependent( + " a. Do the first thing. Then ask yourself if you are done:" + )); + assert!(!is_step_dependent( + " 'Yes' | 'No' but I have an excuse" + )); + + // Finally, test content over multiple lines + assert!(is_step_dependent(test_input)); +} + +#[test] +fn read_step_with_content() { + let mut input = Parser::new(); + + input.initialize( + r#" +1. Have you done the first thing in the first one? + a. Do the first thing. Then ask yourself if you are done: + 'Yes' | 'No' but I have an excuse +2. Do the second thing in the first one. + "#, + ); + + let result = input.read_step_dependent(); + + // Should parse the complete first step with substeps + assert_eq!( + result, + Ok(Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text( + "Have you done the first thing in the first one?" + )])], + subscopes: vec![Scope::DependentBlock { + ordinal: "a", + description: vec![Paragraph(vec![Descriptive::Text( + "Do the first thing. Then ask yourself if you are done:" + )])], + subscopes: vec![Scope::ResponseBlock { + responses: vec![ + Response { + value: "Yes", + condition: None + }, + Response { + value: "No", + condition: Some("but I have an excuse") + } + ] + }] + }], + }) + ); + + assert_eq!( + input.source, + "2. Do the second thing in the first one.\n " + ); +} + +#[test] +fn read_procedure_step_isolation() { + let mut input = Parser::new(); + + input.initialize( + r#" +first : A -> B + +# The First + +This is the first one. + +1. Have you done the first thing in the first one? + a. Do the first thing. Then ask yourself if you are done: + 'Yes' | 'No' but I have an excuse +2. Do the second thing in the first one. + "#, + ); + + let result = input.read_procedure(); + + // This should pass if read_procedure correctly isolates step content + match result { + Ok(procedure) => { + let steps = procedure + .elements + .iter() + .find_map(|element| match element { + Element::Steps(steps) => Some(steps), + _ => None, + }); + assert_eq!( + steps + .unwrap() + .len(), + 2 + ); + } + Err(_e) => { + panic!("read_procedure failed"); + } + } +} + +#[test] +fn take_block_lines_with_is_step() { + let mut input = Parser::new(); + + input.initialize( + r#" +1. Have you done the first thing in the first one? + a. Do the first thing. Then ask yourself if you are done: + 'Yes' | 'No' but I have an excuse +2. Do the second thing in the first one. + "#, + ); + + let result = input.take_block_lines(is_step_dependent, is_step_dependent, |inner| { + Ok(inner.source) + }); + + match result { + Ok(content) => { + // Should isolate first step including substeps, stop at second step + assert!(content.contains("1. Have you done")); + assert!(content.contains("a. Do the first thing")); + assert!(!content.contains("2. Do the second thing")); + + // Remaining should be the second step + assert_eq!( + input.source, + "2. Do the second thing in the first one.\n " + ); + } + Err(_) => { + panic!("take_block_lines() failed"); + } + } +} + +#[test] +fn is_step_line_by_line() { + // Test is_step on each line of our test content + let lines = [ + "1. Have you done the first thing in the first one?", + " a. Do the first thing. Then ask yourself if you are done:", + " 'Yes' | 'No' but I have an excuse", + "2. Do the second thing in the first one.", + ]; + + for (i, line) in lines + .iter() + .enumerate() + { + let is_step_result = is_step_dependent(line); + + match i { + 0 => assert!(is_step_result, "First step line should match is_step"), + 1 | 2 => assert!( + !is_step_result, + "Substep/response lines should NOT match is_step" + ), + 3 => assert!(is_step_result, "Second step line should match is_step"), + _ => {} + } + } +} + +#[test] +fn take_block_lines_title_description_pattern() { + let mut input = Parser::new(); + + // Test the exact pattern used in read_procedure for title/description extraction + input.initialize( + r#" +# The First + +This is the first one. + +1. Have you done the first thing in the first one? + a. Do the first thing. Then ask yourself if you are done: + 'Yes' | 'No' but I have an excuse +2. Do the second thing in the first one. + "#, + ); + + let result = input.take_block_lines( + |_| true, // start predicate (always true) + |line| is_step_dependent(line), // end predicate (stop at first step) + |inner| Ok(inner.source), + ); + + match result { + Ok(content) => { + // The isolated content should be title + description, stopping at first step + assert!(content.contains("# The First")); + assert!(content.contains("This is the first one.")); + assert!(!content.contains("1. Have you done")); + + // The remaining content should include ALL steps and substeps + let remaining = input + .source + .trim_ascii_start(); + assert!(remaining.starts_with("1. Have you done")); + assert!(remaining.contains("a. Do the first thing")); + assert!(remaining.contains("2. Do the second thing")); + } + Err(_e) => { + panic!("take_block_lines failed"); + } + } +} + +#[test] +fn test_potential_procedure_declaration_is_superset() { + // All valid procedure declarations must be matched by potential_procedure_declaration + + // Valid simple declarations + assert!(is_procedure_declaration("foo : A -> B")); + assert!(potential_procedure_declaration("foo : A -> B")); + + assert!(is_procedure_declaration("my_proc :")); + assert!(potential_procedure_declaration("my_proc :")); + + assert!(is_procedure_declaration("step123 : Input -> Output")); + assert!(potential_procedure_declaration("step123 : Input -> Output")); + + // Valid with parameters + assert!(is_procedure_declaration("process(a, b) : X -> Y")); + assert!(potential_procedure_declaration("process(a, b) : X -> Y")); + + assert!(is_procedure_declaration("calc(x) :")); + assert!(potential_procedure_declaration("calc(x) :")); + + // Invalid that should only match potential_ + assert!(!is_procedure_declaration("MyProcedure :")); // Capital letter + assert!(potential_procedure_declaration("MyProcedure :")); + + assert!(!is_procedure_declaration("123foo :")); // Starts with digit + assert!(potential_procedure_declaration("123foo :")); + + // Neither should match sentences with spaces + assert!(!is_procedure_declaration("Ask these questions :")); + assert!(!potential_procedure_declaration("Ask these questions :")); + + // Edge cases with whitespace + assert!(!is_procedure_declaration(" :")); // No name + assert!(!potential_procedure_declaration(" :")); + + assert!(is_procedure_declaration(" foo : ")); // Whitespace around + assert!(potential_procedure_declaration(" foo : ")); + + // Verify the superset property systematically + let test_cases = vec![ + "a :", + "z :", + "abc :", + "test_123 :", + "foo_bar_baz :", + "x() :", + "func(a) :", + "proc(a, b, c) :", + "test(x,y,z) :", + "a_1 :", + "test_ :", + "_test :", // Underscores + ]; + + for case in test_cases { + if is_procedure_declaration(case) { + assert!( + potential_procedure_declaration(case), + "potential_procedure_declaration must match all valid declarations: {}", + case + ); + } + } +} + +#[test] +fn test_take_block_lines_procedure_wrapper() { + let mut input = Parser::new(); + + // Test the outer take_block_lines call that wraps read_procedure + input.initialize( + r#" +first : A -> B + +# The First + +This is the first one. + +1. Have you done the first thing in the first one? + a. Do the first thing. Then ask yourself if you are done: + 'Yes' | 'No' but I have an excuse +2. Do the second thing in the first one. + "#, + ); + + let result = input.take_block_lines( + is_procedure_declaration, + is_procedure_declaration, + |outer| Ok(outer.source), + ); + + match result { + Ok(isolated_content) => { + // Since there's only one procedure, the outer take_block_lines should capture everything + assert!(isolated_content.contains("first : A -> B")); + assert!(isolated_content.contains("# The First")); + assert!(isolated_content.contains("This is the first one.")); + assert!(isolated_content.contains("1. Have you done the first thing in the first one?")); + assert!(isolated_content.contains("a. Do the first thing")); + assert!(isolated_content.contains("2. Do the second thing")); + } + Err(_e) => { + panic!("take_block_lines failed"); + } + } +} + +#[test] +fn code_blocks() { + let mut input = Parser::new(); + + // Test simple identifier in code block + input.initialize("{ count }"); + let result = input.read_code_block(); + assert_eq!(result, Ok(Expression::Variable(Identifier("count")))); + + // Test function with simple parameter + input.initialize("{ sum(count) }"); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Execution(Function { + target: Identifier("sum"), + parameters: vec![Expression::Variable(Identifier("count"))] + })) + ); + + // Test function with multiple parameters + input.initialize("{ consume(apple, banana, chocolate) }"); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Execution(Function { + target: Identifier("consume"), + parameters: vec![ + Expression::Variable(Identifier("apple")), + Expression::Variable(Identifier("banana")), + Expression::Variable(Identifier("chocolate")) + ] + })) + ); + + // Test function with text parameter + input.initialize("{ exec(\"Hello, World\") }"); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Execution(Function { + target: Identifier("exec"), + parameters: vec![Expression::String(vec![Piece::Text("Hello, World")])] + })) + ); + + // Test function with multiline string parameter + input.initialize( + r#"{ exec(```bash +ls -l +echo "Done"```) }"#, + ); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Execution(Function { + target: Identifier("exec"), + parameters: vec![Expression::Multiline( + Some("bash"), + vec!["ls -l", "echo \"Done\""] + )] + })) + ); + + // Test function with quantity parameter (like timer with duration) + input.initialize("{ timer(3 hr) }"); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Execution(Function { + target: Identifier("timer"), + parameters: vec![Expression::Number(Numeric::Scientific(Quantity { + mantissa: Decimal { + number: 3, + precision: 0 + }, + uncertainty: None, + magnitude: None, + symbol: "hr" + }))] + })) + ); + + // Test function with integer quantity parameter + input.initialize("{ measure(100) }"); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Execution(Function { + target: Identifier("measure"), + parameters: vec![Expression::Number(Numeric::Integral(100))] + })) + ); + + // Test function with decimal quantity parameter + input.initialize("{ wait(2.5 s, \"yes\") }"); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Execution(Function { + target: Identifier("wait"), + parameters: vec![ + Expression::Number(Numeric::Scientific(Quantity { + mantissa: Decimal { + number: 25, + precision: 1 + }, + uncertainty: None, + magnitude: None, + symbol: "s" + })), + Expression::String(vec![Piece::Text("yes")]) + ] + })) + ); +} + +#[test] +fn multiline() { + let mut input = Parser::new(); + + // Test multiline with consistent indentation that should be trimmed + input.initialize( + r#"{ exec(```bash + ./stuff + + if [ true ] + then + ./other args + fi```) }"#, + ); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Execution(Function { + target: Identifier("exec"), + parameters: vec![Expression::Multiline( + Some("bash"), + vec![ + "./stuff", + "", + "if [ true ]", + "then", + " ./other args", + "fi" + ] + )] + })) + ); + + // Test multiline without language tag + input.initialize( + r#"{ exec(``` +ls -l +echo "Done"```) }"#, + ); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Execution(Function { + target: Identifier("exec"), + parameters: vec![Expression::Multiline(None, vec!["ls -l", "echo \"Done\""])] + })) + ); + + // Test multiline with intentional empty lines in the middle + input.initialize( + r#"{ exec(```shell +echo "Starting" + +echo "Middle section" + + +echo "Ending"```) }"#, + ); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Execution(Function { + target: Identifier("exec"), + parameters: vec![Expression::Multiline( + Some("shell"), + vec![ + "echo \"Starting\"", + "", + "echo \"Middle section\"", + "", + "", + "echo \"Ending\"" + ] + )] + })) + ); + + // Test that internal indentation relative to the base is preserved, + // and also that nested parenthesis don't break the enclosing + // take_block_chars() used to capture the input to the function. + input.initialize( + r#"{ exec(```python + def hello(): + print("Hello") + if True: + print("World") + + hello()```) }"#, + ); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Execution(Function { + target: Identifier("exec"), + parameters: vec![Expression::Multiline( + Some("python"), + vec![ + "def hello():", + " print(\"Hello\")", + " if True:", + " print(\"World\")", + "", + "hello()" + ] + )] + })) + ); + + // Test that a trailing empty line from the closing delimiter is removed + input.initialize( + r#"{ exec(``` +echo test +```) }"#, + ); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Execution(Function { + target: Identifier("exec"), + parameters: vec![Expression::Multiline(None, vec!["echo test"])] + })) + ); + + // Test various indentation edge cases + input.initialize( + r#"{ exec(```yaml + name: test + items: + - item1 + - item2 + config: + enabled: true```) }"#, + ); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Execution(Function { + target: Identifier("exec"), + parameters: vec![Expression::Multiline( + Some("yaml"), + vec![ + "name: test", + "items:", + " - item1", + " - item2", + "config:", + " enabled: true" + ] + )] + })) + ); +} + +#[test] +fn tablets() { + let mut input = Parser::new(); + + // Test simple single-entry tablet + input.initialize(r#"{ ["name" = "Johannes Grammerly"] }"#); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Tablet(vec![Pair { + label: "name", + value: Expression::String(vec![Piece::Text("Johannes Grammerly")]) + }])) + ); + + // Test multiline tablet with string values + input.initialize( + r#"{ [ + "name" = "Alice of Chains" + "age" = "29" +] }"#, + ); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Tablet(vec![ + Pair { + label: "name", + value: Expression::String(vec![Piece::Text("Alice of Chains")]) + }, + Pair { + label: "age", + value: Expression::String(vec![Piece::Text("29")]) + } + ])) + ); + + // Test tablet with mixed value types + input.initialize( + r#"{ [ + "answer" = 42 + "message" = msg + "timestamp" = now() +] }"#, + ); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Tablet(vec![ + Pair { + label: "answer", + value: Expression::Number(Numeric::Integral(42)) + }, + Pair { + label: "message", + value: Expression::Variable(Identifier("msg")) + }, + Pair { + label: "timestamp", + value: Expression::Execution(Function { + target: Identifier("now"), + parameters: vec![] + }) + } + ])) + ); + + // Test empty tablet + input.initialize("{ [ ] }"); + let result = input.read_code_block(); + assert_eq!(result, Ok(Expression::Tablet(vec![]))); + + // Test tablet with interpolated string values + input.initialize( + r#"{ [ + "context" = "Details about the thing" + "status" = active +] }"#, + ); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Tablet(vec![ + Pair { + label: "context", + value: Expression::String(vec![Piece::Text("Details about the thing")]) + }, + Pair { + label: "status", + value: Expression::Variable(Identifier("active")) + } + ])) + ); +} + +#[test] +fn numeric_literals() { + let mut input = Parser::new(); + + // Test simple integer + input.initialize("{ 42 }"); + let result = input.read_code_block(); + assert_eq!(result, Ok(Expression::Number(Numeric::Integral(42)))); + + // Test negative integer + input.initialize("{ -123 }"); + let result = input.read_code_block(); + assert_eq!(result, Ok(Expression::Number(Numeric::Integral(-123)))); + + // Test zero + input.initialize("{ 0 }"); + let result = input.read_code_block(); + assert_eq!(result, Ok(Expression::Number(Numeric::Integral(0)))); +} + +#[test] +fn reading_identifiers() { + let mut input = Parser::new(); + + // Parse a basic identifier + input.initialize("hello"); + let result = input.read_identifier(); + assert_eq!(result, Ok(Identifier("hello"))); + assert_eq!(input.source, ""); + + // Parse an identifier with trailing content + input.initialize("count more"); + let result = input.read_identifier(); + assert_eq!(result, Ok(Identifier("count"))); + assert_eq!(input.source, " more"); + + // Parse an identifier with leading whitespace and trailing content + input.initialize(" \t test_name after"); + let result = input.read_identifier(); + assert_eq!(result, Ok(Identifier("test_name"))); + assert_eq!(input.source, " after"); + + // Parse an identifier with various delimiters + input.initialize("name(param)"); + let result = input.read_identifier(); + assert_eq!(result, Ok(Identifier("name"))); + assert_eq!(input.source, "(param)"); +} + +#[test] +fn test_foreach_expression() { + let mut input = Parser::new(); + input.initialize("{ foreach item in items }"); + + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Foreach( + vec![Identifier("item")], + Box::new(Expression::Variable(Identifier("items"))) + )) + ); +} + +#[test] +fn foreach_tuple_pattern() { + let mut input = Parser::new(); + input.initialize("{ foreach (design, component) in zip(designs, components) }"); + + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Foreach( + vec![Identifier("design"), Identifier("component")], + Box::new(Expression::Execution(Function { + target: Identifier("zip"), + parameters: vec![ + Expression::Variable(Identifier("designs")), + Expression::Variable(Identifier("components")) + ] + })) + )) + ); + + input.initialize("{ foreach (a, b, c) in zip(list1, list2, list3) }"); + + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Foreach( + vec![Identifier("a"), Identifier("b"), Identifier("c")], + Box::new(Expression::Execution(Function { + target: Identifier("zip"), + parameters: vec![ + Expression::Variable(Identifier("list1")), + Expression::Variable(Identifier("list2")), + Expression::Variable(Identifier("list3")) + ] + })) + )) + ); +} + +#[test] +fn tuple_binding_expression() { + let mut input = Parser::new(); + input.initialize("{ () ~ (x, y) }"); + + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Binding( + Box::new(Expression::Application(Invocation { + target: Target::Local(Identifier("get_coordinates")), + parameters: Some(vec![]) + })), + vec![Identifier("x"), Identifier("y")] + )) + ); +} + +#[test] +fn test_repeat_expression() { + let mut input = Parser::new(); + input.initialize("{ repeat count }"); + + let result = input.read_code_block(); + assert_eq!( + result, + Ok(Expression::Repeat(Box::new(Expression::Variable( + Identifier("count") + )))) + ); +} + +#[test] +fn test_foreach_keyword_boundary() { + // Test that "foreach" must be a complete word + let mut input = Parser::new(); + input.initialize("{ foreachitem in items }"); + + let result = input.read_code_block(); + // Should parse as identifier, not foreach + assert_eq!(result, Ok(Expression::Variable(Identifier("foreachitem")))); +} + +#[test] +fn test_repeat_keyword_boundary() { + // Test that "repeat" must be a complete word + let mut input = Parser::new(); + input.initialize("{ repeater }"); + + let result = input.read_code_block(); + // Should parse as identifier, not repeat + assert_eq!(result, Ok(Expression::Variable(Identifier("repeater")))); +} + +#[test] +fn test_foreach_in_keyword_boundary() { + // Test that "in" must be a complete word in foreach + let mut input = Parser::new(); + input.initialize("{ foreach item instead items }"); + + let result = input.read_code_block(); + // Should fail because "instead" doesn't match "in" + assert!(result.is_err()); +} + +#[test] +fn splitting_by() { + let mut input = Parser::new(); + + // Test splitting simple comma-separated identifiers + input.initialize("apple, banana, cherry"); + let result = input.take_split_by(',', |inner| inner.read_identifier()); + assert_eq!( + result, + Ok(vec![ + Identifier("apple"), + Identifier("banana"), + Identifier("cherry") + ]) + ); + assert_eq!(input.source, ""); + + // Test splitting with extra whitespace + input.initialize(" un | deux | trois "); + let result = input.take_split_by('|', |inner| inner.read_identifier()); + assert_eq!( + result, + Ok(vec![ + Identifier("un"), + Identifier("deux"), + Identifier("trois") + ]) + ); + + // Ensure a single item (no delimiter present in input) works + input.initialize("seulement"); + let result = input.take_split_by(',', |inner| inner.read_identifier()); + assert_eq!(result, Ok(vec![Identifier("seulement")])); + + // an empty chunk causes an error + input.initialize("un,,trois"); + let result = input.take_split_by(',', |inner| inner.read_identifier()); + assert!(result.is_err()); + + // empty trailing chunk causes an error + input.initialize("un,deux,"); + let result = input.take_split_by(',', |inner| inner.read_identifier()); + assert!(result.is_err()); + + // different split character + input.initialize("'Yes'|'No'|'Maybe'"); + let result = input.take_split_by('|', |inner| { + validate_response(inner.source).ok_or(ParsingError::IllegalParserState(inner.offset)) + }); + assert_eq!( + result, + Ok(vec![ + Response { + value: "Yes", + condition: None + }, + Response { + value: "No", + condition: None + }, + Response { + value: "Maybe", + condition: None + } + ]) + ); +} + +#[test] +fn reading_responses() { + let mut input = Parser::new(); + + // Test single response + input.initialize("'Yes'"); + let result = input.read_responses(); + assert_eq!( + result, + Ok(vec![Response { + value: "Yes", + condition: None + }]) + ); + + // Test multiple responses + input.initialize("'Yes' | 'No'"); + let result = input.read_responses(); + assert_eq!( + result, + Ok(vec![ + Response { + value: "Yes", + condition: None + }, + Response { + value: "No", + condition: None + } + ]) + ); + + // Test three responses + input.initialize("'Yes' | 'No' | 'Not Applicable'"); + let result = input.read_responses(); + assert_eq!( + result, + Ok(vec![ + Response { + value: "Yes", + condition: None + }, + Response { + value: "No", + condition: None + }, + Response { + value: "Not Applicable", + condition: None + } + ]) + ); + + // Test response with condition + input.initialize("'Yes' and equipment available"); + let result = input.read_responses(); + assert_eq!( + result, + Ok(vec![Response { + value: "Yes", + condition: Some("and equipment available") + }]) + ); + + // Test responses with whitespace + input.initialize(" 'Option A' | 'Option B' "); + let result = input.read_responses(); + assert_eq!( + result, + Ok(vec![ + Response { + value: "Option A", + condition: None + }, + Response { + value: "Option B", + condition: None + } + ]) + ); +} + +#[test] +fn reading_attributes() { + let mut input = Parser::new(); + + // Test simple role + input.initialize("@chef"); + let result = input.read_attributes(); + assert_eq!(result, Ok(vec![Attribute::Role(Identifier("chef"))])); + + // Test simple place + input.initialize("^kitchen"); + let result = input.read_attributes(); + assert_eq!(result, Ok(vec![Attribute::Place(Identifier("kitchen"))])); + + // Test multiple roles + input.initialize("@master_chef + @barista"); + let result = input.read_attributes(); + assert_eq!( + result, + Ok(vec![ + Attribute::Role(Identifier("master_chef")), + Attribute::Role(Identifier("barista")) + ]) + ); + + // Test multiple places + input.initialize("^kitchen + ^bath_room"); + let result = input.read_attributes(); + assert_eq!( + result, + Ok(vec![ + Attribute::Place(Identifier("kitchen")), + Attribute::Place(Identifier("bath_room")) + ]) + ); + + // Test mixed roles and places + input.initialize("@chef + ^bathroom"); + let result = input.read_attributes(); + assert_eq!( + result, + Ok(vec![ + Attribute::Role(Identifier("chef")), + Attribute::Place(Identifier("bathroom")) + ]) + ); + + // Test mixed places and roles + input.initialize("^kitchen + @barista"); + let result = input.read_attributes(); + assert_eq!( + result, + Ok(vec![ + Attribute::Place(Identifier("kitchen")), + Attribute::Role(Identifier("barista")) + ]) + ); + + // Test complex mixed attributes + input.initialize("@chef + ^kitchen + @barista + ^dining_room"); + let result = input.read_attributes(); + assert_eq!( + result, + Ok(vec![ + Attribute::Role(Identifier("chef")), + Attribute::Place(Identifier("kitchen")), + Attribute::Role(Identifier("barista")), + Attribute::Place(Identifier("dining_room")) + ]) + ); + + // Test invalid - uppercase + input.initialize("^Kitchen"); + let result = input.read_attributes(); + assert!(result.is_err()); + + // Test invalid - no marker + input.initialize("kitchen"); + let result = input.read_attributes(); + assert!(result.is_err()); +} + +#[test] +fn step_with_role_assignment() { + let mut input = Parser::new(); + + // Test step with role assignment + input.initialize( + r#" +1. Check the patient's vital signs + @nurse + "#, + ); + let result = input.read_step_dependent(); + + let scope = result.expect("Expected dependent step with role assignment"); + + assert_eq!( + scope, + Scope::DependentBlock { + ordinal: "1", + + description: vec![Paragraph(vec![Descriptive::Text( + "Check the patient's vital signs" + )])], + subscopes: vec![Scope::AttributeBlock { + attributes: vec![Attribute::Role(Identifier("nurse"))], + subscopes: vec![], + }] + } + ); +} + +#[test] +fn substep_with_role_assignment() { + let mut input = Parser::new(); + + // Test step with role assignment and substep + input.initialize( + r#" +1. Verify patient identity + @surgeon + a. Check ID + "#, + ); + let result = input.read_step_dependent(); + + let scope = result.expect("Expected dependent step with role assignment"); + + assert_eq!( + scope, + Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text( + "Verify patient identity" + )])], + subscopes: vec![Scope::AttributeBlock { + attributes: vec![Attribute::Role(Identifier("surgeon"))], + subscopes: vec![Scope::DependentBlock { + ordinal: "a", + description: vec![Paragraph(vec![Descriptive::Text("Check ID")])], + subscopes: vec![] + }] + }] + } + ); +} + +#[test] +fn parallel_step_with_role_assignment() { + let mut input = Parser::new(); + + // Test step with role assignment and parallel substep + input.initialize( + r#" +1. Monitor patient vitals + @nursing_team + - Check readings + "#, + ); + let result = input.read_step_dependent(); + + let scope = result.expect("Expected dependent step with role assignment"); + + assert_eq!( + scope, + Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text("Monitor patient vitals")])], + subscopes: vec![Scope::AttributeBlock { + attributes: vec![Attribute::Role(Identifier("nursing_team"))], + subscopes: vec![Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text("Check readings")])], + subscopes: vec![] + }] + }] + } + ); +} + +#[test] +fn two_roles_with_substeps() { + let mut input = Parser::new(); + + // Test two roles each with one substep + input.initialize( + r#" +1. Review events. + @surgeon + a. What are the steps? + @nurse + b. What are the concerns? + "#, + ); + + let result = input.read_step_dependent(); + + let scope = result.expect("Failed to parse two roles with substeps"); + + assert_eq!( + scope, + Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text("Review events.")])], + subscopes: vec![ + Scope::AttributeBlock { + attributes: vec![Attribute::Role(Identifier("surgeon"))], + subscopes: vec![Scope::DependentBlock { + ordinal: "a", + description: vec![Paragraph(vec![Descriptive::Text( + "What are the steps?" + )])], + subscopes: vec![] + }] + }, + Scope::AttributeBlock { + attributes: vec![Attribute::Role(Identifier("nurse"))], + subscopes: vec![Scope::DependentBlock { + ordinal: "b", + description: vec![Paragraph(vec![Descriptive::Text( + "What are the concerns?" + )])], + subscopes: vec![] + }] + } + ] + } + ); +} + +#[test] +fn parse_collecting_errors_basic() { + let mut input = Parser::new(); + + // Test with valid content - should have no errors + input.initialize("% technique v1\nvalid_proc : A -> B\n# Title\nDescription"); + let result = input.parse_collecting_errors(); + match result { + Ok(document) => { + assert!(document + .header + .is_some()); + assert!(document + .body + .is_some()); + } + Err(_) => panic!("Expected successful parse for valid content"), + } + + // Test with invalid header - should collect header error + input.initialize("% wrong v1"); + let result = input.parse_collecting_errors(); + match result { + Ok(_) => panic!("Expected errors for invalid content"), + Err(errors) => { + assert!(errors.len() > 0); + assert!(errors + .iter() + .any(|e| matches!(e, ParsingError::InvalidHeader(_)))); + } + } + + // Test that the method returns Result instead of ParseResult + input.initialize("some content"); + let _result: Result> = input.parse_collecting_errors(); + // If this compiles, the method signature is correct +} + +#[test] +fn test_multiple_error_collection() { + use std::path::Path; + + // Create a string with 3 procedures: 2 with errors and 1 valid + let content = r#" +broken_proc1 : A -> + # This procedure has incomplete signature + + 1. Do something + +valid_proc : A -> B + # This is a valid procedure + + 1. Valid step + 2. Another valid step + +broken_proc2 : -> B + # This procedure has incomplete signature (missing domain) + + 1. Do something else + "#; + + let result = parse_with_recovery(Path::new("test.t"), content); + + // Assert that there are at least 2 errors (from the broken procedures) + match result { + Ok(_) => panic!("Result should have errors"), + Err(errors) => { + let l = errors.len(); + assert!(l >= 2, "Should have at least 2 errors, got {}", l) + } + }; +} + +#[test] +fn test_redundant_error_removal_needed() { + use std::path::Path; + + // Create a malformed procedure that could generate multiple errors at the same offset + let content = r#" +% technique v1 + +broken : + This is not a valid signature line + + 1. Step one + "#; + + let result = parse_with_recovery(Path::new("test.tq"), content); + + // Check that we get an error about the invalid signature + match result { + Err(errors) => { + // Debug: print what errors we actually get + eprintln!("Errors: {:?}", errors); + + // Verify no redundant errors at the same offset + let mut offsets = errors + .iter() + .map(|e| e.offset()) + .collect::>(); + offsets.sort(); + let original_len = offsets.len(); + offsets.dedup(); + assert_eq!( + offsets.len(), + original_len, + "Found redundant errors at same offset" + ); + } + Ok(_) => panic!("Expected errors for malformed content"), + } +} + +#[test] +fn test_redundant_error_removal_unclosed_interpolation() { + let mut input = Parser::new(); + + // Test that UnclosedInterpolation error takes precedence over generic + // ExpectedMatchingChar + input.initialize(r#"{ "string with {unclosed interpolation" }"#); + let result = input.read_code_block(); + + // Should get the specific UnclosedInterpolation error, not a generic + // one + match result { + Err(ParsingError::UnclosedInterpolation(_)) => { + // Good - we got the specific error + } + Err(other) => { + panic!("Expected UnclosedInterpolation error, got: {:?}", other); + } + Ok(_) => { + panic!("Expected error for unclosed interpolation, but parsing succeeded"); + } + } +} + +#[test] +fn multiline_code_inline() { + let mut input = Parser::new(); + + // Test multiline code inline in descriptive text + let source = r#" +This is { exec(a, + b, c) + } a valid inline. + "#; + + input.initialize(source); + let result = input.read_descriptive(); + + assert!( + result.is_ok(), + "Multiline code inline should parse successfully" + ); + + let paragraphs = result.unwrap(); + assert_eq!(paragraphs.len(), 1, "Should have exactly one paragraph"); + + let descriptives = ¶graphs[0].0; + assert_eq!( + descriptives.len(), + 3, + "Should have 3 descriptive elements: text, code inline, text" + ); + + // First element should be "This is" + match &descriptives[0] { + Descriptive::Text(text) => assert_eq!(*text, "This is"), + _ => panic!("First element should be text"), + } + + // Second element should be the multiline code inline + match &descriptives[1] { + Descriptive::CodeInline(Expression::Execution(func)) => { + assert_eq!( + func.target + .0, + "exec" + ); + assert_eq!( + func.parameters + .len(), + 3 + ); + // Check that all parameters were parsed correctly + if let Expression::Variable(Identifier(name)) = &func.parameters[0] { + assert_eq!(*name, "a"); + } else { + panic!("First parameter should be variable 'a'"); + } + } + _ => panic!("Second element should be code inline with function execution"), + } + + // Third element should be "a valid inline." + match &descriptives[2] { + Descriptive::Text(text) => assert_eq!(*text, "a valid inline."), + _ => panic!("Third element should be text"), + } +} diff --git a/src/parsing/checks/verify.rs b/src/parsing/checks/verify.rs new file mode 100644 index 0000000..dc5ebfa --- /dev/null +++ b/src/parsing/checks/verify.rs @@ -0,0 +1,1360 @@ +use std::path::Path; +use std::vec; + +use super::*; + +fn trim(s: &str) -> &str { + s.strip_prefix('\n') + .unwrap_or(s) +} + +#[test] +fn technique_header() { + let mut input = Parser::new(); + input.initialize("% technique v1"); + + let metadata = input.read_technique_header(); + assert_eq!( + metadata, + Ok(Metadata { + version: 1, + license: None, + copyright: None, + template: None + }) + ); + + input.initialize(trim( + r#" +% technique v1 +! MIT; (c) ACME, Inc +& checklist + "#, + )); + + let metadata = input.read_technique_header(); + assert_eq!( + metadata, + Ok(Metadata { + version: 1, + license: Some("MIT"), + copyright: Some("ACME, Inc"), + template: Some("checklist") + }) + ); +} + +#[test] +fn procedure_declaration_one() { + let mut input = Parser::new(); + input.initialize(trim( + r#" +making_coffee : (Beans, Milk) -> Coffee + + "#, + )); + + let procedure = input.read_procedure(); + assert_eq!( + procedure, + Ok(Procedure { + name: Identifier("making_coffee"), + parameters: None, + signature: Some(Signature { + domain: Genus::Tuple(vec![Forma("Beans"), Forma("Milk")]), + range: Genus::Single(Forma("Coffee")) + }), + elements: vec![], + }) + ); +} + +#[test] +fn procedure_declaration_two() { + let mut input = Parser::new(); + input.initialize(trim( + r#" +first : A -> B + +second : C -> D + + "#, + )); + + let procedure = input.read_procedure(); + assert_eq!( + procedure, + Ok(Procedure { + name: Identifier("first"), + parameters: None, + signature: Some(Signature { + domain: Genus::Single(Forma("A")), + range: Genus::Single(Forma("B")) + }), + elements: vec![], + }) + ); + + let procedure = input.read_procedure(); + assert_eq!( + procedure, + Ok(Procedure { + name: Identifier("second"), + parameters: None, + signature: Some(Signature { + domain: Genus::Single(Forma("C")), + range: Genus::Single(Forma("D")) + }), + elements: vec![], + }) + ); +} + +#[test] +fn procedure_declaration_with_parameters() { + let mut input = Parser::new(); + input.initialize(trim( + r#" +making_coffee(e) : Ingredients -> Coffee + + "#, + )); + + let procedure = input.read_procedure(); + assert_eq!( + procedure, + Ok(Procedure { + name: Identifier("making_coffee"), + parameters: Some(vec![Identifier("e")]), + signature: Some(Signature { + domain: Genus::Single(Forma("Ingredients")), + range: Genus::Single(Forma("Coffee")) + }), + elements: vec![], + }) + ); +} + +#[test] +fn example_procedure() { + let mut input = Parser::new(); + input.initialize(trim( + r#" +first : A -> B + +# The First + +This is the first one. + +1. Do the first thing in the first one. +2. Do the second thing in the first one. + + "#, + )); + + let procedure = input.read_procedure(); + assert_eq!( + procedure, + Ok(Procedure { + name: Identifier("first"), + parameters: None, + signature: Some(Signature { + domain: Genus::Single(Forma("A")), + range: Genus::Single(Forma("B")) + }), + elements: vec![ + Element::Title("The First"), + Element::Description(vec![Paragraph(vec![Descriptive::Text( + "This is the first one." + )])]), + Element::Steps(vec![ + Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text( + "Do the first thing in the first one." + )])], + + subscopes: vec![] + }, + Scope::DependentBlock { + ordinal: "2", + description: vec![Paragraph(vec![Descriptive::Text( + "Do the second thing in the first one." + )])], + + subscopes: vec![] + } + ]) + ], + }) + ); +} + +#[test] +fn example_with_responses() { + let mut input = Parser::new(); + input.initialize(trim( + r#" +first : A -> B + +# The First + +This is the first one. + +1. Have you done the first thing in the first one? + 'Yes' | 'No' but I have an excuse +2. Do the second thing in the first one. + "#, + )); + + let procedure = input.read_procedure(); + assert_eq!( + procedure, + Ok(Procedure { + name: Identifier("first"), + parameters: None, + signature: Some(Signature { + domain: Genus::Single(Forma("A")), + range: Genus::Single(Forma("B")) + }), + elements: vec![ + Element::Title("The First"), + Element::Description(vec![Paragraph(vec![Descriptive::Text( + "This is the first one." + )])]), + Element::Steps(vec![ + Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text( + "Have you done the first thing in the first one?" + )])], + subscopes: vec![Scope::ResponseBlock { + responses: vec![ + Response { + value: "Yes", + condition: None + }, + Response { + value: "No", + condition: Some("but I have an excuse") + } + ] + }], + }, + Scope::DependentBlock { + ordinal: "2", + description: vec![Paragraph(vec![Descriptive::Text( + "Do the second thing in the first one." + )])], + + subscopes: vec![], + } + ]) + ], + }) + ); +} + +#[test] +fn example_with_substeps() { + let mut input = Parser::new(); + input.initialize(trim( + r#" +first : A -> B + +# The First + +This is the first one. + +1. Have you done the first thing in the first one? + a. Do the first thing. Then ask yourself if you are done: + 'Yes' | 'No' but I have an excuse +2. Do the second thing in the first one. + "#, + )); + + let procedure = input.read_procedure(); + assert_eq!( + procedure, + Ok(Procedure { + name: Identifier("first"), + parameters: None, + signature: Some(Signature { + domain: Genus::Single(Forma("A")), + range: Genus::Single(Forma("B")) + }), + elements: vec![ + Element::Title("The First"), + Element::Description(vec![Paragraph(vec![Descriptive::Text( + "This is the first one." + )])]), + Element::Steps(vec![ + Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text( + "Have you done the first thing in the first one?" + )])], + + subscopes: vec![Scope::DependentBlock { + ordinal: "a", + description: vec![Paragraph(vec![Descriptive::Text( + "Do the first thing. Then ask yourself if you are done:" + )])], + subscopes: vec![Scope::ResponseBlock { + responses: vec![ + Response { + value: "Yes", + condition: None + }, + Response { + value: "No", + condition: Some("but I have an excuse") + } + ] + }] + }] + }, + Scope::DependentBlock { + ordinal: "2", + description: vec![Paragraph(vec![Descriptive::Text( + "Do the second thing in the first one." + )])], + + subscopes: vec![], + } + ]) + ], + }) + ); +} + +#[test] +fn realistic_procedure() { + let mut input = Parser::new(); + input.initialize(trim( + r#" + before_anesthesia : + + # Before induction of anaesthesia + + 1. Has the patient confirmed his/her identity, site, procedure, + and consent? + 'Yes' + 2. Is the site marked? + 'Yes' | 'Not Applicable' + 3. Is the anaesthesia machine and medication check complete? + 'Yes' + 4. Is the pulse oximeter on the patient and functioning? + 'Yes' + 5. Does the patient have a: + - Known allergy? + 'No' | 'Yes' + - Difficult airway or aspiration risk? + 'No' | 'Yes' and equipment/assistance available + - Risk of blood loss > 500 mL? + 'No' | 'Yes' and two IVs planned and fluids available + "#, + )); + let result = input.read_procedure(); + let procedure = result.expect("a parsed Procedure"); + + assert_eq!( + procedure, + Procedure { + name: Identifier("before_anesthesia"), + parameters: None, + signature: None, + elements: vec![ + Element::Title("Before induction of anaesthesia"), + Element::Steps(vec![ + Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![ + Descriptive::Text( + "Has the patient confirmed his/her identity, site, procedure," + ), + Descriptive::Text("and consent?") + ])], + subscopes: vec![Scope::ResponseBlock { + responses: vec![Response { + value: "Yes", + condition: None + }] + }], + }, + Scope::DependentBlock { + ordinal: "2", + description: vec![Paragraph(vec![Descriptive::Text( + "Is the site marked?" + )])], + subscopes: vec![Scope::ResponseBlock { + responses: vec![ + Response { + value: "Yes", + condition: None + }, + Response { + value: "Not Applicable", + condition: None + } + ] + }], + }, + Scope::DependentBlock { + ordinal: "3", + description: vec![Paragraph(vec![Descriptive::Text( + "Is the anaesthesia machine and medication check complete?" + )])], + subscopes: vec![Scope::ResponseBlock { + responses: vec![Response { + value: "Yes", + condition: None + }] + }], + }, + Scope::DependentBlock { + ordinal: "4", + description: vec![Paragraph(vec![Descriptive::Text( + "Is the pulse oximeter on the patient and functioning?" + )])], + subscopes: vec![Scope::ResponseBlock { + responses: vec![Response { + value: "Yes", + condition: None + }] + }], + }, + Scope::DependentBlock { + ordinal: "5", + description: vec![Paragraph(vec![Descriptive::Text( + "Does the patient have a:" + )])], + + subscopes: vec![ + Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text( + "Known allergy?" + )])], + subscopes: vec![Scope::ResponseBlock { + responses: vec![ + Response { + value: "No", + condition: None + }, + Response { + value: "Yes", + condition: None + } + ] + }], + }, + Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text( + "Difficult airway or aspiration risk?" + )])], + subscopes: vec![Scope::ResponseBlock { + responses: vec![ + Response { + value: "No", + condition: None + }, + Response { + value: "Yes", + condition: Some("and equipment/assistance available") + } + ] + }], + }, + Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text( + "Risk of blood loss > 500 mL?" + )])], + subscopes: vec![Scope::ResponseBlock { + responses: vec![ + Response { + value: "No", + condition: None + }, + Response { + value: "Yes", + condition: Some( + "and two IVs planned and fluids available" + ) + } + ] + }], + } + ] + } + ]) + ] + } + ); +} + +#[test] +fn realistic_procedure_part2() { + let mut input = Parser::new(); + input.initialize(trim( + r#" +label_the_specimens : + + 1. Specimen labelling + @nursing_team + - Label blood tests + - Label tissue samples + @admin_staff + a. Prepare the envelopes + "#, + )); + let procedure = input.read_procedure(); + + assert_eq!( + procedure, + Ok(Procedure { + name: Identifier("label_the_specimens"), + parameters: None, + signature: None, + elements: vec![Element::Steps(vec![Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text("Specimen labelling")])], + + subscopes: vec![ + Scope::AttributeBlock { + attributes: vec![Attribute::Role(Identifier("nursing_team"))], + subscopes: vec![ + Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text( + "Label blood tests" + )])], + + subscopes: vec![], + }, + Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text( + "Label tissue samples" + )])], + + subscopes: vec![], + } + ] + }, + Scope::AttributeBlock { + attributes: vec![Attribute::Role(Identifier("admin_staff"))], + subscopes: vec![Scope::DependentBlock { + ordinal: "a", + description: vec![Paragraph(vec![Descriptive::Text( + "Prepare the envelopes" + )])], + + subscopes: vec![], + }] + } + ], + }])], + }) + ); +} + +#[test] +fn realistic_procedure_part3() { + let mut input = Parser::new(); + input.initialize(trim( + r#" +before_leaving : + +# Before patient leaves operating room + + 1. Verbally confirm: + - The name of the surgical procedure(s). + - Completion of instrument, sponge, and needle counts. + - Specimen labelling + { foreach specimen in specimens } + @nursing_team + a. Read specimen labels aloud, including patient + name. + - Whether there are any equipment problems to be addressed. + 2. Post-operative care: + @surgeon + a. What are the key concerns for recovery and management + of this patient? + @anesthetist + b. What are the key concerns for recovery and management + of this patient? + @nursing_team + c. What are the key concerns for recovery and management + of this patient? + "#, + )); + let result = input.read_procedure(); + + let procedure = result.expect("a procedure"); + assert_eq!( + procedure, + Procedure { + name: Identifier("before_leaving"), + parameters: None, + signature: None, + elements: vec![ + Element::Title("Before patient leaves operating room"), + Element::Steps(vec![ + Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text("Verbally confirm:")])], + + subscopes: vec![ + Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text( + "The name of the surgical procedure(s)." + )])], + + subscopes: vec![], + }, + Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text( + "Completion of instrument, sponge, and needle counts." + )])], + + subscopes: vec![], + }, + Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text( + "Specimen labelling" + )])], + + subscopes: vec![Scope::CodeBlock { + expression: Expression::Foreach( + vec![Identifier("specimen")], + Box::new(Expression::Variable(Identifier("specimens"))) + ), + subscopes: vec![Scope::AttributeBlock { + attributes: vec![Attribute::Role(Identifier( + "nursing_team" + ))], + subscopes: vec![Scope::DependentBlock { + ordinal: "a", + description: vec![Paragraph(vec![ + Descriptive::Text( + "Read specimen labels aloud, including patient" + ), + Descriptive::Text("name.") + ])], + + subscopes: vec![] + }] + }] + }] + }, + Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text( + "Whether there are any equipment problems to be addressed." + )])], + + subscopes: vec![], + } + ] + }, + Scope::DependentBlock { + ordinal: "2", + description: vec![Paragraph(vec![Descriptive::Text( + "Post-operative care:" + )])], + + subscopes: vec![ + Scope::AttributeBlock { + attributes: vec![Attribute::Role(Identifier("surgeon"))], + subscopes: vec![Scope::DependentBlock { + ordinal: "a", + description: vec![Paragraph(vec![ + Descriptive::Text( + "What are the key concerns for recovery and management" + ), + Descriptive::Text("of this patient?") + ])], + + subscopes: vec![], + }] + }, + Scope::AttributeBlock { + attributes: vec![Attribute::Role(Identifier("anesthetist"))], + subscopes: vec![Scope::DependentBlock { + ordinal: "b", + description: vec![Paragraph(vec![ + Descriptive::Text( + "What are the key concerns for recovery and management" + ), + Descriptive::Text("of this patient?") + ])], + + subscopes: vec![], + }] + }, + Scope::AttributeBlock { + attributes: vec![Attribute::Role(Identifier("nursing_team"))], + subscopes: vec![Scope::DependentBlock { + ordinal: "c", + description: vec![Paragraph(vec![ + Descriptive::Text( + "What are the key concerns for recovery and management" + ), + Descriptive::Text("of this patient?") + ])], + + subscopes: vec![], + }] + } + ], + } + ]) + ], + } + ); +} + +#[test] +fn parallel_role_assignments() { + let mut input = Parser::new(); + + // Test a step that mirrors the surgical safety checklist pattern + input.initialize( + r#" +5. Review anticipated critical events. + @surgeon + a. What are the critical or non-routine steps? + b. How long will the case take? + c. What is the blood loss expected? + @anaesthetist + d. Are there any patient-specific concerns? + @nursing_team + e. Has sterility been confirmed? + f. Has the equipment issues been addressed? + "#, + ); + + let result = input.read_step_dependent(); + + match result { + Ok(Scope::DependentBlock { + ordinal, + description: content, + subscopes: scopes, + }) => { + assert_eq!(ordinal, "5"); + assert_eq!( + content, + vec![Paragraph(vec![Descriptive::Text( + "Review anticipated critical events." + )])] + ); + // Should have 3 scopes: one for each role with their substeps + assert_eq!(scopes.len(), 3); + + // Check that the first scope has surgeon role + if let Scope::AttributeBlock { + attributes, + subscopes: substeps, + } = &scopes[0] + { + assert_eq!(*attributes, vec![Attribute::Role(Identifier("surgeon"))]); + assert_eq!(substeps.len(), 3); // a, b, c + } else { + panic!("Expected AttributedBlock for surgeon"); + } + + // Check that the second scope has anaesthetist role + if let Scope::AttributeBlock { + attributes, + subscopes: substeps, + } = &scopes[1] + { + assert_eq!( + *attributes, + vec![Attribute::Role(Identifier("anaesthetist"))] + ); + assert_eq!(substeps.len(), 1); // d + } else { + panic!("Expected AttributedBlock for anaesthetist"); + } + + // Check that the third scope has nursing_team role + if let Scope::AttributeBlock { + attributes, + subscopes: substeps, + } = &scopes[2] + { + assert_eq!( + *attributes, + vec![Attribute::Role(Identifier("nursing_team"))] + ); + assert_eq!(substeps.len(), 2); // e, f + } else { + panic!("Expected AttributedBlock for nursing_team"); + } + } + _ => panic!("Expected dependent step with role assignment"), + } +} + +#[test] +fn multiple_roles_with_dependent_substeps() { + let mut input = Parser::new(); + + // Test multiple roles each with their own dependent substeps + input.initialize( + r#" +1. Review surgical procedure + @surgeon + a. Review patient chart + b. Verify surgical site + c. Confirm procedure type + @anaesthetist + a. Check patient allergies + b. Review medication history + @nursing_team + a. Prepare instruments + b. Verify sterility + c. Confirm patient positioning + "#, + ); + + let result = input.read_step_dependent(); + + match result { + Ok(Scope::DependentBlock { + ordinal, + description: content, + subscopes: scopes, + }) => { + assert_eq!(ordinal, "1"); + assert_eq!( + content, + vec![Paragraph(vec![Descriptive::Text( + "Review surgical procedure" + )])] + ); + assert_eq!(scopes.len(), 3); + + // Check surgeon scope (3 dependent substeps) + if let Scope::AttributeBlock { + attributes, + subscopes: substeps, + } = &scopes[0] + { + assert_eq!(*attributes, vec![Attribute::Role(Identifier("surgeon"))]); + assert_eq!(substeps.len(), 3); + } else { + panic!("Expected AttributedBlock for surgeon"); + } + + // Check anaesthetist scope (2 dependent substeps) + if let Scope::AttributeBlock { + attributes, + subscopes: substeps, + } = &scopes[1] + { + assert_eq!( + *attributes, + vec![Attribute::Role(Identifier("anaesthetist"))] + ); + assert_eq!(substeps.len(), 2); + } else { + panic!("Expected AttributedBlock for anaesthetist"); + } + + // Check nursing_team scope (3 dependent substeps) + if let Scope::AttributeBlock { + attributes, + subscopes: substeps, + } = &scopes[2] + { + assert_eq!( + *attributes, + vec![Attribute::Role(Identifier("nursing_team"))] + ); + assert_eq!(substeps.len(), 3); + } else { + panic!("Expected AttributedBlock for nursing_team"); + } + + // Verify all substeps are dependent (ordered) steps + for scope in &scopes { + match scope { + Scope::AttributeBlock { + subscopes: substeps, + .. + } => { + for substep in substeps { + assert!(matches!(substep, Scope::DependentBlock { .. })); + } + } + _ => panic!("Expected AttributedBlock scopes"), + } + } + } + _ => panic!("Expected dependent step with multiple role assignments"), + } +} + +#[test] +fn mixed_substeps_in_roles() { + let mut input = Parser::new(); + + input.initialize( + r#" +1. Emergency response + @team_lead + a. Assess situation + b. Coordinate response + - Monitor communications + - Track resources + c. File report + "#, + ); + let result = input.read_step_dependent(); + + let step = match result { + Ok(step) => step, + _ => panic!("Expected step with mixed substep types"), + }; + + assert_eq!( + step, + Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text("Emergency response")])], + + subscopes: vec![Scope::AttributeBlock { + attributes: vec![Attribute::Role(Identifier("team_lead"))], + subscopes: vec![ + Scope::DependentBlock { + ordinal: "a", + description: vec![Paragraph(vec![Descriptive::Text("Assess situation")])], + + subscopes: vec![] + }, + Scope::DependentBlock { + ordinal: "b", + description: vec![Paragraph(vec![Descriptive::Text( + "Coordinate response" + )])], + + subscopes: vec![ + Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text( + "Monitor communications" + )])], + + subscopes: vec![] + }, + Scope::ParallelBlock { + bullet: '-', + description: vec![Paragraph(vec![Descriptive::Text( + "Track resources" + )])], + + subscopes: vec![] + } + ] + }, + Scope::DependentBlock { + ordinal: "c", + description: vec![Paragraph(vec![Descriptive::Text("File report")])], + + subscopes: vec![] + } + ] + }] + } + ); +} + +#[test] +fn substeps_with_responses() { + let mut input = Parser::new(); + + input.initialize( + r#" +1. Main step + a. Substep with response + 'Yes' | 'No' + "#, + ); + let result = input.read_step_dependent(); + + assert_eq!( + result, + Ok(Scope::DependentBlock { + ordinal: "1", + description: vec![Paragraph(vec![Descriptive::Text("Main step")])], + + subscopes: vec![Scope::DependentBlock { + ordinal: "a", + description: vec![Paragraph(vec![Descriptive::Text("Substep with response")])], + subscopes: vec![Scope::ResponseBlock { + responses: vec![ + Response { + value: "Yes", + condition: None, + }, + Response { + value: "No", + condition: None, + }, + ] + }], + }], + }) + ); +} + +#[test] +fn naked_bindings() { + let mut input = Parser::new(); + + // Test simple naked binding: text ~ variable + input.initialize("What is the result? ~ answer"); + let descriptive = input.read_descriptive(); + assert_eq!( + descriptive, + Ok(vec![Paragraph(vec![Descriptive::Binding( + Box::new(Descriptive::Text("What is the result?")), + vec![Identifier("answer")] + )])]) + ); + + // Test naked binding followed by more text. This is probably not a + // valid usage, but it's good that it parses cleanly. + input.initialize("Enter your name ~ name\nContinue with next step"); + let descriptive = input.read_descriptive(); + assert_eq!( + descriptive, + Ok(vec![Paragraph(vec![ + Descriptive::Binding( + Box::new(Descriptive::Text("Enter your name")), + vec![Identifier("name")] + ), + Descriptive::Text("Continue with next step") + ])]) + ); + + // Test mixed content with function call binding and naked binding. + // This likewise may turn out to be something that fails compilation, + // but it's important that it parses right so that the users gets + // appropriate feedback. + input.initialize("First ~ result then describe the outcome ~ description"); + let descriptive = input.read_descriptive(); + assert_eq!( + descriptive, + Ok(vec![Paragraph(vec![ + Descriptive::Text("First"), + Descriptive::Binding( + Box::new(Descriptive::Application(Invocation { + target: Target::Local(Identifier("do_something")), + parameters: None, + })), + vec![Identifier("result")] + ), + Descriptive::Binding( + Box::new(Descriptive::Text("then describe the outcome")), + vec![Identifier("description")] + ) + ])]) + ); +} + +#[test] +fn section_parsing() { + let result = parse_with_recovery( + Path::new(""), + trim( + r#" +main_procedure : + +I. First Section + +first_section_first_procedure : + +# One dot One + +first_section_second_procedure : + +# One dot Two + +II. Second Section + +second_section_first_procedure : + +# Two dot One + +second_section_second_procedure : + +# Two dot Two + "#, + ), + ); + + let document = match result { + Ok(document) => document, + Err(e) => panic!("Parsing failed: {:?}", e), + }; + + // Verify complete structure + assert_eq!( + document, + Document { + header: None, + body: Some(Technique::Procedures(vec![Procedure { + name: Identifier("main_procedure"), + parameters: None, + signature: None, + elements: vec![Element::Steps(vec![ + Scope::SectionChunk { + numeral: "I", + title: Some(Paragraph(vec![Descriptive::Text("First Section")])), + body: Technique::Procedures(vec![ + Procedure { + name: Identifier("first_section_first_procedure"), + parameters: None, + signature: None, + elements: vec![Element::Title("One dot One")] + }, + Procedure { + name: Identifier("first_section_second_procedure"), + parameters: None, + signature: None, + elements: vec![Element::Title("One dot Two")] + } + ]), + }, + Scope::SectionChunk { + numeral: "II", + title: Some(Paragraph(vec![Descriptive::Text("Second Section")])), + body: Technique::Procedures(vec![ + Procedure { + name: Identifier("second_section_first_procedure"), + parameters: None, + signature: None, + elements: vec![Element::Title("Two dot One")] + }, + Procedure { + name: Identifier("second_section_second_procedure"), + parameters: None, + signature: None, + elements: vec![Element::Title("Two dot Two")] + } + ]), + }, + ])], + }])), + } + ); +} + +#[test] +fn section_with_procedures_only() { + let result = parse_with_recovery( + Path::new(""), + trim( + r#" +main_procedure : + +I. First Section + +procedure_one : Input -> Output + +procedure_two : Other -> Thing + +II. Second Section + +procedure_three : Concept -> Requirements + +procedure_four : Concept -> Architecture + "#, + ), + ); + + let document = match result { + Ok(document) => document, + Err(e) => panic!("Parsing failed: {:?}", e), + }; + + // Verify that both sections contain their respective procedures + if let Some(Technique::Procedures(procs)) = document.body { + let main_proc = &procs[0]; + if let Some(Element::Steps(steps)) = main_proc + .elements + .first() + { + // Should have 2 sections + assert_eq!(steps.len(), 2); + + // Check first section has 2 procedures + if let Scope::SectionChunk { + body: Technique::Procedures(section1_procs), + .. + } = &steps[0] + { + assert_eq!(section1_procs.len(), 2); + assert_eq!(section1_procs[0].name, Identifier("procedure_one")); + assert_eq!(section1_procs[1].name, Identifier("procedure_two")); + } else { + panic!("First section should contain procedures"); + } + + // Check second section has 2 procedures + if let Scope::SectionChunk { + body: Technique::Procedures(section2_procs), + .. + } = &steps[1] + { + assert_eq!(section2_procs.len(), 2); + assert_eq!(section2_procs[0].name, Identifier("procedure_three")); + assert_eq!(section2_procs[1].name, Identifier("procedure_four")); + } else { + panic!("Second section should contain procedures"); + } + } else { + panic!("Main procedure should have steps"); + } + } else { + panic!("Should have procedures"); + } +} + +#[test] +fn section_with_procedures() { + let result = parse_with_recovery( + Path::new(""), + trim( + r#" +main_procedure : + +I. Concept + +II. Requirements Definition and Architecture + +requirements_and_architecture : Concept -> Requirements, Architecture + + 2. Define Requirements (concept) + + 3. Determine Architecture (concept) + +define_requirements : Concept -> Requirements + +determine_architecture : Concept -> Architecture + +III. Implementation + "#, + ), + ); + + let document = match result { + Ok(document) => document, + Err(e) => panic!("Parsing failed: {:?}", e), + }; + + assert_eq!( + document, + Document { + header: None, + body: Some(Technique::Procedures(vec![Procedure { + name: Identifier("main_procedure"), + parameters: None, + signature: None, + elements: vec![Element::Steps(vec![ + Scope::SectionChunk { + numeral: "I", + title: Some(Paragraph(vec![Descriptive::Text("Concept")])), + body: Technique::Empty, + }, + Scope::SectionChunk { + numeral: "II", + title: Some(Paragraph(vec![Descriptive::Text( + "Requirements Definition and Architecture" + )])), + body: Technique::Procedures(vec![ + Procedure { + name: Identifier("requirements_and_architecture"), + parameters: None, + signature: Some(Signature { + domain: Genus::Single(Forma("Concept")), + range: Genus::Naked(vec![ + Forma("Requirements"), + Forma("Architecture") + ]), + }), + elements: vec![Element::Steps(vec![ + Scope::DependentBlock { + ordinal: "2", + description: vec![Paragraph(vec![ + Descriptive::Text("Define Requirements"), + Descriptive::Application(Invocation { + target: Target::Local(Identifier( + "define_requirements" + )), + parameters: Some(vec![Expression::Variable( + Identifier("concept") + )]), + }), + ])], + + subscopes: vec![], + }, + Scope::DependentBlock { + ordinal: "3", + description: vec![Paragraph(vec![ + Descriptive::Text("Determine Architecture"), + Descriptive::Application(Invocation { + target: Target::Local(Identifier( + "determine_architecture" + )), + parameters: Some(vec![Expression::Variable( + Identifier("concept") + )]), + }), + ])], + + subscopes: vec![], + }, + ])], + }, + Procedure { + name: Identifier("define_requirements"), + parameters: None, + signature: Some(Signature { + domain: Genus::Single(Forma("Concept")), + range: Genus::Single(Forma("Requirements")), + }), + elements: vec![], + }, + Procedure { + name: Identifier("determine_architecture"), + parameters: None, + signature: Some(Signature { + domain: Genus::Single(Forma("Concept")), + range: Genus::Single(Forma("Architecture")), + }), + elements: vec![], + }, + ]), + }, + Scope::SectionChunk { + numeral: "III", + title: Some(Paragraph(vec![Descriptive::Text("Implementation")])), + body: Technique::Empty, + }, + ])], + }])), + } + ) +} diff --git a/src/parsing/mod.rs b/src/parsing/mod.rs index d878d45..06e0588 100644 --- a/src/parsing/mod.rs +++ b/src/parsing/mod.rs @@ -4,11 +4,13 @@ use std::path::Path; use tracing::debug; use crate::language::{Document, LoadingError, Technique}; -use crate::parsing::parser::ParsingError; -pub mod parser; +mod parser; mod scope; +// Export the actual public API +pub use parser::{parse_with_recovery, Parser, ParsingError}; + /// Read a file and return an owned String. We pass that ownership back to the /// main function so that the Technique object created by parse() below can /// have the same lifetime. diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index 1212387..c263389 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -32,6 +32,7 @@ pub enum ParsingError { UnexpectedEndOfInput(usize), Expected(usize, &'static str), ExpectedMatchingChar(usize, &'static str, char, char), + MissingParenthesis(usize), // more specific errors InvalidCharacter(usize, char), InvalidHeader(usize), @@ -68,6 +69,7 @@ impl ParsingError { ParsingError::Unrecognized(offset) => *offset, ParsingError::Expected(offset, _) => *offset, ParsingError::ExpectedMatchingChar(offset, _, _, _) => *offset, + ParsingError::MissingParenthesis(offset) => *offset, ParsingError::UnclosedInterpolation(offset) => *offset, ParsingError::InvalidHeader(offset) => *offset, ParsingError::InvalidCharacter(offset, _) => *offset, @@ -136,7 +138,7 @@ pub struct Parser<'i> { } impl<'i> Parser<'i> { - pub fn new() -> Parser<'i> { + fn new() -> Parser<'i> { Parser { filename: Path::new("-"), original: "", @@ -146,10 +148,10 @@ impl<'i> Parser<'i> { } } - pub fn filename(&mut self, filename: &'i Path) { + fn filename(&mut self, filename: &'i Path) { self.filename = filename; } - pub fn initialize(&mut self, content: &'i str) { + fn initialize(&mut self, content: &'i str) { self.original = content; self.source = content; self.offset = 0; @@ -732,7 +734,7 @@ impl<'i> Parser<'i> { }) } - pub fn read_technique_header(&mut self) -> Result, ParsingError> { + fn read_technique_header(&mut self) -> Result, ParsingError> { // Process magic line let version = if is_magic_line(self.source) { let result = self.read_magic_line()?; @@ -907,7 +909,7 @@ impl<'i> Parser<'i> { } /// Parse a procedure with error recovery - collects multiple errors instead of stopping at the first one - pub fn read_procedure(&mut self) -> Result, ParsingError> { + fn read_procedure(&mut self) -> Result, ParsingError> { // Find the procedure block boundaries let mut i = 0; let mut begun = false; @@ -967,6 +969,12 @@ impl<'i> Parser<'i> { let mut elements = vec![]; while !parser.is_finished() { + parser.trim_whitespace(); + + if parser.is_finished() { + break; + } + let content = parser.source; if is_procedure_title(content) { @@ -1291,6 +1299,15 @@ impl<'i> Parser<'i> { if is_binding(content) { self.read_binding_expression() + } else if malformed_binding_pattern(content) { + if let Some(tilde_pos) = self + .source + .find('~') + { + self.advance(tilde_pos + 1); // Move past ~ + self.trim_whitespace(); + } + return Err(ParsingError::MissingParenthesis(self.offset)); } else if is_repeat_keyword(content) { self.read_repeat_expression() } else if is_foreach_keyword(content) { @@ -1821,7 +1838,7 @@ impl<'i> Parser<'i> { } /// Parse top-level ordered step - pub fn read_step_dependent(&mut self) -> Result, ParsingError> { + fn read_step_dependent(&mut self) -> Result, ParsingError> { self.take_block_lines(is_step_dependent, is_step_dependent, |outer| { outer.trim_whitespace(); @@ -1860,7 +1877,7 @@ impl<'i> Parser<'i> { } /// Parse a top-level concurrent step - pub fn read_step_parallel(&mut self) -> Result, ParsingError> { + fn read_step_parallel(&mut self) -> Result, ParsingError> { self.take_block_lines(is_step_parallel, is_step_parallel, |outer| { outer.trim_whitespace(); @@ -1961,7 +1978,7 @@ impl<'i> Parser<'i> { ) } - pub fn read_descriptive(&mut self) -> Result>, ParsingError> { + fn read_descriptive(&mut self) -> Result>, ParsingError> { self.take_block_lines( |_| true, |line| { @@ -2016,7 +2033,20 @@ impl<'i> Parser<'i> { if parser.peek_next_char() == Some('~') { parser.advance(1); parser.trim_whitespace(); + let start_pos = parser.offset; let variable = parser.read_identifier()?; + + // Check for malformed tuple binding (missing parentheses) + parser.trim_whitespace(); + if parser + .source + .starts_with(',') + { + return Err(ParsingError::MissingParenthesis( + start_pos, + )); + } + content.push(Descriptive::Binding( Box::new(Descriptive::Application(invocation)), vec![variable], @@ -2043,7 +2073,19 @@ impl<'i> Parser<'i> { } else if parser.peek_next_char() == Some('~') { parser.advance(1); parser.trim_whitespace(); + let start_pos = parser.offset; let variable = parser.read_identifier()?; + + parser.trim_whitespace(); + if parser + .source + .starts_with(',') + { + return Err(ParsingError::MissingParenthesis( + start_pos, + )); + } + content.push(Descriptive::Binding( Box::new(Descriptive::Text(text)), vec![variable], @@ -2237,8 +2279,13 @@ impl<'i> Parser<'i> { for part in parts { let trimmed = part.trim_ascii(); - // Check if it's a role '@' - if let Some(captures) = regex!(r"^@([a-z][a-z0-9_]*)$").captures(trimmed) { + // Check if it's the special @* "reset attribute" role + if trimmed == "@*" { + let identifier = Identifier("*"); + attributes.push(Attribute::Role(identifier)); + } + // Check if it's a regular role '@' + else if let Some(captures) = regex!(r"^@([a-z][a-z0-9_]*)$").captures(trimmed) { let role_name = captures .get(1) .ok_or(ParsingError::Expected(inner.offset, "role name after @"))? @@ -2699,6 +2746,12 @@ fn is_binding(content: &str) -> bool { re.is_match(content) } +fn malformed_binding_pattern(content: &str) -> bool { + // Detect ~ identifier, identifier (missing parentheses) + let re = regex!(r"~\s+[a-z][a-z0-9_]*\s*,\s*[a-z]"); + re.is_match(content) +} + fn is_step_dependent(content: &str) -> bool { let re = regex!(r"^\s*\d+\.\s+"); re.is_match(content) @@ -2782,2209 +2835,23 @@ fn is_string_literal(content: &str) -> bool { fn is_attribute_assignment(input: &str) -> bool { // Matches any combination of @ and ^ attributes separated by + - let re = regex!(r"^\s*[@^][a-z][a-z0-9_]*(\s*\+\s*[@^][a-z][a-z0-9_]*)*"); + // Also matches the special @* "reset to all" role + let re = regex!(r"^\s*(@\*|[@^][a-z][a-z0-9_]*)(\s*\+\s*[@^][a-z][a-z0-9_]*)*"); re.is_match(input) } -#[cfg(test)] -mod check { - use super::*; - - #[test] - fn magic_line() { - let mut input = Parser::new(); - input.initialize("% technique v1"); - assert!(is_magic_line(input.source)); - - let result = input.read_magic_line(); - assert_eq!(result, Ok(1)); - - input.initialize("%technique v1"); - assert!(is_magic_line(input.source)); - - let result = input.read_magic_line(); - assert_eq!(result, Ok(1)); - - input.initialize("%techniquev1"); - assert!(is_magic_line(input.source)); - - // this is rejected because the technique keyword isn't present. - let result = input.read_magic_line(); - assert!(result.is_err()); - } - - #[test] - fn magic_line_wrong_keyword_error_position() { - // Test that error position points to the first character of the wrong keyword - assert_eq!(analyze_magic_line("% tecnique v1"), 2); // Points to "t" in "tecnique" - assert_eq!(analyze_magic_line("% tecnique v1"), 3); // Points to "t" in "tecnique" with extra space - assert_eq!(analyze_magic_line("% \ttechniqe v1"), 3); // Points to "t" in "techniqe" with tab - assert_eq!(analyze_magic_line("% wrong v1"), 5); // Points to "w" in "wrong" with multiple spaces - assert_eq!(analyze_magic_line("% foo v1"), 2); // Points to "f" in "foo" - assert_eq!(analyze_magic_line("% TECHNIQUE v1"), 2); // Points to "T" in uppercase "TECHNIQUE" - - // Test missing keyword entirely - should point to position after % - assert_eq!(analyze_magic_line("% v1"), 2); // Points to "v" when keyword is missing - assert_eq!(analyze_magic_line("% v1"), 3); // Points to "v" when keyword is missing with space - } - - #[test] - fn magic_line_wrong_version_error_position() { - // Test that error position points to the version number after "v" in wrong version strings - assert_eq!(analyze_magic_line("% technique v0"), 13); // Points to "0" in "v0" - assert_eq!(analyze_magic_line("% technique v2"), 14); // Points to "2" in "v2" with extra space - assert_eq!(analyze_magic_line("% technique\tv0"), 13); // Points to "0" in "v0" with tab - assert_eq!(analyze_magic_line("% technique vX"), 15); // Points to "X" in "vX" with multiple spaces - assert_eq!(analyze_magic_line("% technique v99"), 13); // Points to "9" in "v99" - assert_eq!(analyze_magic_line("% technique v0.5"), 15); // Points to "0" in "v0.5" with multiple spaces - - // Test edge case where there's no "v" at all - should point to where version should start - assert_eq!(analyze_magic_line("% technique 1.0"), 12); // Points to "1" when there's no "v" - assert_eq!(analyze_magic_line("% technique v1.0"), 14); // Points to "." when there is a "v1" but it has minor version - assert_eq!(analyze_magic_line("% technique 2"), 13); // Points to "2" when there's no "v" with extra space - assert_eq!(analyze_magic_line("% technique beta"), 12); // Points to "b" in "beta" when there's no "v" - } - - #[test] - fn header_spdx() { - let mut input = Parser::new(); - input.initialize("! PD"); - assert!(is_spdx_line(input.source)); - - let result = input.read_spdx_line(); - assert_eq!(result, Ok((Some("PD"), None))); - - input.initialize("! MIT; (c) ACME, Inc."); - assert!(is_spdx_line(input.source)); - - let result = input.read_spdx_line(); - assert_eq!(result, Ok((Some("MIT"), Some("ACME, Inc.")))); - - input.initialize("! MIT; (C) 2024 ACME, Inc."); - assert!(is_spdx_line(input.source)); - - let result = input.read_spdx_line(); - assert_eq!(result, Ok((Some("MIT"), Some("2024 ACME, Inc.")))); - - input.initialize("! CC BY-SA 3.0 [IGO]; (c) 2024 ACME, Inc."); - assert!(is_spdx_line(input.source)); - - let result = input.read_spdx_line(); - assert_eq!( - result, - Ok((Some("CC BY-SA 3.0 [IGO]"), Some("2024 ACME, Inc."))) - ); - } - - #[test] - fn header_template() { - let mut input = Parser::new(); - input.initialize("& checklist"); - assert!(is_template_line(input.source)); - - let result = input.read_template_line(); - assert_eq!(result, Ok(Some("checklist"))); - - input.initialize("& nasa-flight-plan,v4.0"); - assert!(is_template_line(input.source)); - - let result = input.read_template_line(); - assert_eq!(result, Ok(Some("nasa-flight-plan,v4.0"))); - } - - // now we test incremental parsing - - #[test] - fn check_not_eof() { - let mut input = Parser::new(); - input.initialize("Hello World"); - assert!(!input.is_finished()); - - input.initialize(""); - assert!(input.is_finished()); - } - - #[test] - fn consume_whitespace() { - let mut input = Parser::new(); - input.initialize(" hello"); - input.trim_whitespace(); - assert_eq!(input.source, "hello"); - - input.initialize("\n \nthere"); - input.trim_whitespace(); - assert_eq!(input.source, "there"); - assert_eq!(input.offset, 3); - } - - // It is not clear that we will ever actually need parse_identifier(), - // parse_forma(), parse_genus(), or parse_signature() as they are not - // called directly, but even though they are not used in composition of - // the parse_procedure_declaration() parser, it is highly likely that - // someday we will need to be able to parse them individually, perhaps for - // a future language server or code highlighter. So we test them properly - // here; in any event it exercises the underlying validate_*() codepaths. - - #[test] - fn identifier_rules() { - let mut input = Parser::new(); - input.initialize("p"); - let result = input.read_identifier(); - assert_eq!(result, Ok(Identifier("p"))); - - input.initialize("cook_pizza"); - let result = input.read_identifier(); - assert_eq!(result, Ok(Identifier("cook_pizza"))); - - input.initialize("cook-pizza"); - let result = input.read_identifier(); - assert!(result.is_err()); - } - - #[test] - fn signatures() { - let mut input = Parser::new(); - input.initialize("A -> B"); - let result = input.read_signature(); - assert_eq!( - result, - Ok(Signature { - domain: Genus::Single(Forma("A")), - range: Genus::Single(Forma("B")) - }) - ); - - input.initialize("Beans -> Coffee"); - let result = input.read_signature(); - assert_eq!( - result, - Ok(Signature { - domain: Genus::Single(Forma("Beans")), - range: Genus::Single(Forma("Coffee")) - }) - ); - - input.initialize("[Bits] -> Bob"); - let result = input.read_signature(); - assert_eq!( - result, - Ok(Signature { - domain: Genus::List(Forma("Bits")), - range: Genus::Single(Forma("Bob")) - }) - ); - - input.initialize("Complex -> (Real, Imaginary)"); - let result = input.read_signature(); - assert_eq!( - result, - Ok(Signature { - domain: Genus::Single(Forma("Complex")), - range: Genus::Tuple(vec![Forma("Real"), Forma("Imaginary")]) - }) - ); - } - - #[test] - fn declaration_simple() { - let mut input = Parser::new(); - input.initialize("making_coffee :"); - - assert!(is_procedure_declaration(input.source)); - - let result = input.parse_procedure_declaration(); - assert_eq!(result, Ok((Identifier("making_coffee"), None, None))); - } - - #[test] - fn declaration_full() { - let mut input = Parser::new(); - input.initialize("f : A -> B"); - assert!(is_procedure_declaration(input.source)); - - let result = input.parse_procedure_declaration(); - assert_eq!( - result, - Ok(( - Identifier("f"), - None, - Some(Signature { - domain: Genus::Single(Forma("A")), - range: Genus::Single(Forma("B")) - }) - )) - ); - - input.initialize("making_coffee : (Beans, Milk) -> [Coffee]"); - assert!(is_procedure_declaration(input.source)); - - let result = input.parse_procedure_declaration(); - assert_eq!( - result, - Ok(( - Identifier("making_coffee"), - None, - Some(Signature { - domain: Genus::Tuple(vec![Forma("Beans"), Forma("Milk")]), - range: Genus::List(Forma("Coffee")) - }) - )) - ); - - let content = "f : B"; - // we still need to detect procedure declarations with malformed - // signatures; the user's intent will be to declare a procedure though - // it will fail validation in the parser shortly after. - assert!(is_procedure_declaration(content)); - - let content = r#" - connectivity_check(e,s) : LocalEnvironment, TargetService -> NetworkHealth - "#; - - assert!(is_procedure_declaration(content)); - } - - // At one point we had a bug where parsing was racing ahead and taking too - // much content, which was only uncovered when we expanded to be agnostic - // about whitespace in procedure declarations. - #[test] - fn multiline_declaration() { - let content = r#" - making_coffee (b, m) : - (Beans, Milk) - -> Coffee - - And now we will make coffee as follows... - - 1. Add the beans to the machine - 2. Pour in the milk - "#; - - assert!(is_procedure_declaration(content)); - } - - #[test] - fn multiline_signature_parsing() { - let mut input = Parser::new(); - let content = r#" -making_coffee : - Ingredients - -> Coffee - "# - .trim_ascii(); - - input.initialize(content); - let result = input.parse_procedure_declaration(); - - assert_eq!( - result, - Ok(( - Identifier("making_coffee"), - None, - Some(Signature { - domain: Genus::Single(Forma("Ingredients")), - range: Genus::Single(Forma("Coffee")) - }) - )) - ); - - // Test complex multiline signature with parameters and tuple - let content = r#" -making_coffee(b, m) : - (Beans, Milk) - -> Coffee - "# - .trim_ascii(); - - input.initialize(content); - let result = input.parse_procedure_declaration(); - - assert_eq!( - result, - Ok(( - Identifier("making_coffee"), - Some(vec![Identifier("b"), Identifier("m")]), - Some(Signature { - domain: Genus::Tuple(vec![Forma("Beans"), Forma("Milk")]), - range: Genus::Single(Forma("Coffee")) - }) - )) - ); - } - - #[test] - fn character_delimited_blocks() { - let mut input = Parser::new(); - input.initialize("{ todo() }"); - - let result = input.take_block_chars("inline code", '{', '}', true, |parser| { - let text = parser.source; - assert_eq!(text, " todo() "); - Ok(true) - }); - assert_eq!(result, Ok(true)); - - // this is somewhat contrived as we would not be using this to parse - // strings (We will need to preserve whitespace inside strings when - // we find ourselves parsing them, so subparser() won't work. - input.initialize("XhelloX world"); - - let result = input.take_block_chars("", 'X', 'X', false, |parser| { - let text = parser.source; - assert_eq!(text, "hello"); - Ok(true) - }); - assert_eq!(result, Ok(true)); - } - - #[test] - fn skip_string_content_flag() { - let mut input = Parser::new(); - - // Test skip_string_content: true - should ignore braces inside strings - input.initialize(r#"{ "string with { brace" }"#); - let result = input.take_block_chars("code block", '{', '}', true, |parser| { - let text = parser.source; - assert_eq!(text, r#" "string with { brace" "#); - Ok(true) - }); - assert_eq!(result, Ok(true)); - - // Test skip_string_content: false - should treat braces normally - input.initialize(r#""string with } brace""#); - let result = input.take_block_chars("string content", '"', '"', false, |parser| { - let text = parser.source; - assert_eq!(text, "string with } brace"); - Ok(true) - }); - assert_eq!(result, Ok(true)); - } - - #[test] - fn string_delimited_blocks() { - let mut input = Parser::new(); - input.initialize("```bash\nls -l\necho hello```"); - assert_eq!(input.offset, 0); - - let result = input.take_block_delimited("```", |parser| { - let text = parser.source; - assert_eq!(text, "bash\nls -l\necho hello"); - Ok(true) - }); - assert_eq!(result, Ok(true)); - assert_eq!(input.source, ""); - assert_eq!(input.offset, 27); - - // Test with different delimiter - input.initialize("---start\ncontent here\nmore content---end"); - - let result = input.take_block_delimited("---", |parser| { - let text = parser.source; - assert_eq!(text, "start\ncontent here\nmore content"); - Ok(true) - }); - assert_eq!(result, Ok(true)); - - // Test with whitespace around delimiters - input.initialize("``` hello world ``` and now goodbye"); - - let result = input.take_block_delimited("```", |parser| { - let text = parser.source; - assert_eq!(text, " hello world "); - Ok(true) - }); - assert_eq!(result, Ok(true)); - assert_eq!(input.source, " and now goodbye"); - assert_eq!(input.offset, 21); - } - - #[test] - fn taking_until() { - let mut input = Parser::new(); - - // Test take_until() with an identifier up to a limiting character - input.initialize("hello,world"); - let result = input.take_until(&[','], |inner| inner.read_identifier()); - assert_eq!(result, Ok(Identifier("hello"))); - assert_eq!(input.source, ",world"); - - // Test take_until() with whitespace delimiters - input.initialize("test \t\nmore"); - let result = input.take_until(&[' ', '\t', '\n'], |inner| inner.read_identifier()); - assert_eq!(result, Ok(Identifier("test"))); - assert_eq!(input.source, " \t\nmore"); - - // Test take_until() when no delimiter found (it should take everything) - input.initialize("onlytext"); - let result = input.take_until(&[',', ';'], |inner| inner.read_identifier()); - assert_eq!(result, Ok(Identifier("onlytext"))); - assert_eq!(input.source, ""); - } - - #[test] - fn reading_invocations() { - let mut input = Parser::new(); - - // Test simple invocation without parameters - input.initialize(""); - let result = input.read_invocation(); - assert_eq!( - result, - Ok(Invocation { - target: Target::Local(Identifier("hello")), - parameters: None - }) - ); - - // Test invocation with empty parameters - input.initialize("()"); - let result = input.read_invocation(); - assert_eq!( - result, - Ok(Invocation { - target: Target::Local(Identifier("hello_world")), - parameters: Some(vec![]) - }) - ); - - // Test invocation with multiple parameters - input.initialize("(name, title, occupation)"); - let result = input.read_invocation(); - assert_eq!( - result, - Ok(Invocation { - target: Target::Local(Identifier("greetings")), - parameters: Some(vec![ - Expression::Variable(Identifier("name")), - Expression::Variable(Identifier("title")), - Expression::Variable(Identifier("occupation")) - ]) - }) - ); - - // We don't have real support for this yet, but syntactically we will - // support the idea of invoking a procedure at an external URL, so we - // have this case as a placeholder. - input.initialize(""); - let result = input.read_invocation(); - assert_eq!( - result, - Ok(Invocation { - target: Target::Remote(External("https://example.com/proc")), - parameters: None - }) - ); - } - - #[test] - fn step_detection() { - // Test main dependent steps (whitespace agnostic) - assert!(is_step_dependent("1. First step")); - assert!(is_step_dependent(" 1. Indented step")); - assert!(is_step_dependent("10. Tenth step")); - assert!(!is_step_dependent("a. Letter step")); - assert!(!is_step_dependent("1.No space")); - - // Test dependent substeps (whitespace agnostic) - assert!(is_substep_dependent("a. Substep")); - assert!(is_substep_dependent(" a. Indented substep")); - assert!(!is_substep_dependent("2. Substep can't have number")); - assert!(!is_substep_dependent(" 1. Even if it is indented")); - - // Test parallel substeps (whitespace agnostic) - assert!(is_substep_parallel("- Parallel substep")); - assert!(is_substep_parallel(" - Indented parallel")); - assert!(is_substep_parallel(" - Deeper indented")); - assert!(!is_substep_parallel("-No space")); // it's possible we may allow this in the future - assert!(!is_substep_parallel("* Different bullet")); - - // Test top-level parallel steps - assert!(is_step_parallel("- Top level parallel")); - assert!(is_step_parallel(" - Indented parallel")); - assert!(is_step("- Top level parallel")); // general step detection - assert!(is_step("1. Numbered step")); - - // Test recognition of sub-sub-steps - assert!(is_subsubstep_dependent("i. One")); - assert!(is_subsubstep_dependent(" ii. Two")); - assert!(is_subsubstep_dependent("v. Five")); - assert!(is_subsubstep_dependent("vi. Six")); - assert!(is_subsubstep_dependent("ix. Nine")); - assert!(is_subsubstep_dependent("x. Ten")); - assert!(is_subsubstep_dependent("xi. Eleven")); - assert!(is_subsubstep_dependent("xxxix. Thirty-nine")); - - // Test attribute assignments - assert!(is_attribute_assignment("@surgeon")); - assert!(is_attribute_assignment(" @nursing_team")); - assert!(is_attribute_assignment("^kitchen")); - assert!(is_attribute_assignment(" ^garden ")); - assert!(is_attribute_assignment("@chef + ^kitchen")); - assert!(is_attribute_assignment("^room1 + @barista")); - assert!(!is_attribute_assignment("surgeon")); - assert!(!is_attribute_assignment("@123invalid")); - assert!(!is_attribute_assignment("^InvalidPlace")); - - // Test enum responses - assert!(is_enum_response("'Yes'")); - assert!(is_enum_response(" 'No'")); - assert!(is_enum_response("'Not Applicable'")); - assert!(!is_enum_response("Yes")); - assert!(!is_enum_response("'unclosed")); - } - - #[test] - fn read_toplevel_steps() { - let mut input = Parser::new(); - - // Test simple dependent step - input.initialize("1. First step"); - let result = input.read_step_dependent(); - assert_eq!( - result, - Ok(Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text("First step")])], - subscopes: vec![], - }) - ); - - // Test simple parallel step - input.initialize( - r#" - - a top-level task to be one in parallel with - - another top-level task - "#, - ); - let result = input.read_step_parallel(); - assert_eq!( - result, - Ok(Scope::ParallelBlock { - bullet: '-', - description: vec![Paragraph(vec![Descriptive::Text( - "a top-level task to be one in parallel with" - )]),], - subscopes: vec![], - }) - ); - let result = input.read_step_parallel(); - assert_eq!( - result, - Ok(Scope::ParallelBlock { - bullet: '-', - description: vec![Paragraph(vec![Descriptive::Text("another top-level task")]),], - subscopes: vec![], - }) - ); - - // Test multi-line dependent step - input.initialize( - r#" - 1. Have you done the first thing in the first one? - "#, - ); - let result = input.read_step_dependent(); - assert_eq!( - result, - Ok(Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text( - "Have you done the first thing in the first one?" - )])], - subscopes: vec![], - }) - ); - - // Test invalid step - input.initialize("Not a step"); - let result = input.read_step_dependent(); - assert_eq!(result, Err(ParsingError::InvalidStep(0))); - } - - #[test] - fn reading_substeps_basic() { - let mut input = Parser::new(); - - // Test simple dependent sub-step - input.initialize("a. First subordinate task"); - let result = input.read_substep_dependent(); - assert_eq!( - result, - Ok(Scope::DependentBlock { - ordinal: "a", - description: vec![Paragraph(vec![Descriptive::Text("First subordinate task")])], - subscopes: vec![], - }) - ); - - // Test simple parallel sub-step - input.initialize("- Parallel task"); - let result = input.read_substep_parallel(); - assert_eq!( - result, - Ok(Scope::ParallelBlock { - bullet: '-', - description: vec![Paragraph(vec![Descriptive::Text("Parallel task")])], - subscopes: vec![], - }) - ); - } - - #[test] - fn single_step_with_dependent_substeps() { - let mut input = Parser::new(); - - input.initialize( - r#" -1. Main step - a. First substep - b. Second substep - "#, - ); - let result = input.read_step_dependent(); - - assert_eq!( - result, - Ok(Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text("Main step")])], - subscopes: vec![ - Scope::DependentBlock { - ordinal: "a", - description: vec![Paragraph(vec![Descriptive::Text("First substep")])], - subscopes: vec![], - }, - Scope::DependentBlock { - ordinal: "b", - description: vec![Paragraph(vec![Descriptive::Text("Second substep")])], - subscopes: vec![], - }, - ], - }) - ); - } - - #[test] - fn single_step_with_parallel_substeps() { - let mut input = Parser::new(); - - input.initialize( - r#" -1. Main step - - First substep - - Second substep - "#, - ); - let result = input.read_step_dependent(); - - assert_eq!( - result, - Ok(Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text("Main step")])], - subscopes: vec![ - Scope::ParallelBlock { - bullet: '-', - description: vec![Paragraph(vec![Descriptive::Text("First substep")])], - subscopes: vec![], - }, - Scope::ParallelBlock { - bullet: '-', - description: vec![Paragraph(vec![Descriptive::Text("Second substep")])], - subscopes: vec![], - }, - ], - }) - ); - } - - #[test] - fn multiple_steps_with_substeps() { - let mut input = Parser::new(); - - input.initialize( - r#" -1. First step - a. Substep -2. Second step - "#, - ); - let first_result = input.read_step_dependent(); - let second_result = input.read_step_dependent(); - - assert_eq!( - first_result, - Ok(Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text("First step")])], - subscopes: vec![Scope::DependentBlock { - ordinal: "a", - description: vec![Paragraph(vec![Descriptive::Text("Substep")])], - subscopes: vec![], - }], - }) - ); - - assert_eq!( - second_result, - Ok(Scope::DependentBlock { - ordinal: "2", - description: vec![Paragraph(vec![Descriptive::Text("Second step")])], - subscopes: vec![], - }) - ); - } - - #[test] - fn is_step_with_failing_input() { - let test_input = "1. Have you done the first thing in the first one?\n a. Do the first thing. Then ask yourself if you are done:\n 'Yes' | 'No' but I have an excuse\n2. Do the second thing in the first one."; - - // Test each line that should be a step - assert!(is_step_dependent( - "1. Have you done the first thing in the first one?" - )); - assert!(is_step_dependent( - "2. Do the second thing in the first one." - )); - - // Test lines that should NOT be steps - assert!(!is_step_dependent( - " a. Do the first thing. Then ask yourself if you are done:" - )); - assert!(!is_step_dependent( - " 'Yes' | 'No' but I have an excuse" - )); - - // Finally, test content over multiple lines - assert!(is_step_dependent(test_input)); - } - - #[test] - fn read_step_with_content() { - let mut input = Parser::new(); - - input.initialize( - r#" -1. Have you done the first thing in the first one? - a. Do the first thing. Then ask yourself if you are done: - 'Yes' | 'No' but I have an excuse -2. Do the second thing in the first one. - "#, - ); - - let result = input.read_step_dependent(); - - // Should parse the complete first step with substeps - assert_eq!( - result, - Ok(Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text( - "Have you done the first thing in the first one?" - )])], - subscopes: vec![Scope::DependentBlock { - ordinal: "a", - description: vec![Paragraph(vec![Descriptive::Text( - "Do the first thing. Then ask yourself if you are done:" - )])], - subscopes: vec![Scope::ResponseBlock { - responses: vec![ - Response { - value: "Yes", - condition: None - }, - Response { - value: "No", - condition: Some("but I have an excuse") - } - ] - }] - }], - }) - ); - - assert_eq!( - input.source, - "2. Do the second thing in the first one.\n " - ); - } +// This is a rather monsterous test battery, so we move it into a separate +// file. We use the path directive to avoid the need to put it into a +// subdirectory with the same name as this module. - #[test] - fn read_procedure_step_isolation() { - let mut input = Parser::new(); - - input.initialize( - r#" -first : A -> B - -# The First - -This is the first one. - -1. Have you done the first thing in the first one? - a. Do the first thing. Then ask yourself if you are done: - 'Yes' | 'No' but I have an excuse -2. Do the second thing in the first one. - "#, - ); - - let result = input.read_procedure(); - - // This should pass if read_procedure correctly isolates step content - match result { - Ok(procedure) => { - let steps = procedure - .elements - .iter() - .find_map(|element| match element { - Element::Steps(steps) => Some(steps), - _ => None, - }); - assert_eq!( - steps - .unwrap() - .len(), - 2 - ); - } - Err(_e) => { - panic!("read_procedure failed"); - } - } - } - - #[test] - fn take_block_lines_with_is_step() { - let mut input = Parser::new(); - - input.initialize( - r#" -1. Have you done the first thing in the first one? - a. Do the first thing. Then ask yourself if you are done: - 'Yes' | 'No' but I have an excuse -2. Do the second thing in the first one. - "#, - ); - - let result = input.take_block_lines(is_step_dependent, is_step_dependent, |inner| { - Ok(inner.source) - }); - - match result { - Ok(content) => { - // Should isolate first step including substeps, stop at second step - assert!(content.contains("1. Have you done")); - assert!(content.contains("a. Do the first thing")); - assert!(!content.contains("2. Do the second thing")); - - // Remaining should be the second step - assert_eq!( - input.source, - "2. Do the second thing in the first one.\n " - ); - } - Err(_) => { - panic!("take_block_lines() failed"); - } - } - } - - #[test] - fn is_step_line_by_line() { - // Test is_step on each line of our test content - let lines = [ - "1. Have you done the first thing in the first one?", - " a. Do the first thing. Then ask yourself if you are done:", - " 'Yes' | 'No' but I have an excuse", - "2. Do the second thing in the first one.", - ]; - - for (i, line) in lines - .iter() - .enumerate() - { - let is_step_result = is_step_dependent(line); - - match i { - 0 => assert!(is_step_result, "First step line should match is_step"), - 1 | 2 => assert!( - !is_step_result, - "Substep/response lines should NOT match is_step" - ), - 3 => assert!(is_step_result, "Second step line should match is_step"), - _ => {} - } - } - } - - #[test] - fn take_block_lines_title_description_pattern() { - let mut input = Parser::new(); - - // Test the exact pattern used in read_procedure for title/description extraction - input.initialize( - r#" -# The First - -This is the first one. - -1. Have you done the first thing in the first one? - a. Do the first thing. Then ask yourself if you are done: - 'Yes' | 'No' but I have an excuse -2. Do the second thing in the first one. - "#, - ); - - let result = input.take_block_lines( - |_| true, // start predicate (always true) - |line| is_step_dependent(line), // end predicate (stop at first step) - |inner| Ok(inner.source), - ); - - match result { - Ok(content) => { - // The isolated content should be title + description, stopping at first step - assert!(content.contains("# The First")); - assert!(content.contains("This is the first one.")); - assert!(!content.contains("1. Have you done")); - - // The remaining content should include ALL steps and substeps - let remaining = input - .source - .trim_ascii_start(); - assert!(remaining.starts_with("1. Have you done")); - assert!(remaining.contains("a. Do the first thing")); - assert!(remaining.contains("2. Do the second thing")); - } - Err(_e) => { - panic!("take_block_lines failed"); - } - } - } - - #[test] - fn test_potential_procedure_declaration_is_superset() { - // All valid procedure declarations must be matched by potential_procedure_declaration - - // Valid simple declarations - assert!(is_procedure_declaration("foo : A -> B")); - assert!(potential_procedure_declaration("foo : A -> B")); - - assert!(is_procedure_declaration("my_proc :")); - assert!(potential_procedure_declaration("my_proc :")); - - assert!(is_procedure_declaration("step123 : Input -> Output")); - assert!(potential_procedure_declaration("step123 : Input -> Output")); - - // Valid with parameters - assert!(is_procedure_declaration("process(a, b) : X -> Y")); - assert!(potential_procedure_declaration("process(a, b) : X -> Y")); - - assert!(is_procedure_declaration("calc(x) :")); - assert!(potential_procedure_declaration("calc(x) :")); - - // Invalid that should only match potential_ - assert!(!is_procedure_declaration("MyProcedure :")); // Capital letter - assert!(potential_procedure_declaration("MyProcedure :")); - - assert!(!is_procedure_declaration("123foo :")); // Starts with digit - assert!(potential_procedure_declaration("123foo :")); - - // Neither should match sentences with spaces - assert!(!is_procedure_declaration("Ask these questions :")); - assert!(!potential_procedure_declaration("Ask these questions :")); - - // Edge cases with whitespace - assert!(!is_procedure_declaration(" :")); // No name - assert!(!potential_procedure_declaration(" :")); - - assert!(is_procedure_declaration(" foo : ")); // Whitespace around - assert!(potential_procedure_declaration(" foo : ")); - - // Verify the superset property systematically - let test_cases = vec![ - "a :", - "z :", - "abc :", - "test_123 :", - "foo_bar_baz :", - "x() :", - "func(a) :", - "proc(a, b, c) :", - "test(x,y,z) :", - "a_1 :", - "test_ :", - "_test :", // Underscores - ]; - - for case in test_cases { - if is_procedure_declaration(case) { - assert!( - potential_procedure_declaration(case), - "potential_procedure_declaration must match all valid declarations: {}", - case - ); - } - } - } - - #[test] - fn test_take_block_lines_procedure_wrapper() { - let mut input = Parser::new(); - - // Test the outer take_block_lines call that wraps read_procedure - input.initialize( - r#" -first : A -> B - -# The First - -This is the first one. - -1. Have you done the first thing in the first one? - a. Do the first thing. Then ask yourself if you are done: - 'Yes' | 'No' but I have an excuse -2. Do the second thing in the first one. - "#, - ); - - let result = input.take_block_lines( - is_procedure_declaration, - is_procedure_declaration, - |outer| Ok(outer.source), - ); - - match result { - Ok(isolated_content) => { - // Since there's only one procedure, the outer take_block_lines should capture everything - assert!(isolated_content.contains("first : A -> B")); - assert!(isolated_content.contains("# The First")); - assert!(isolated_content.contains("This is the first one.")); - assert!( - isolated_content.contains("1. Have you done the first thing in the first one?") - ); - assert!(isolated_content.contains("a. Do the first thing")); - assert!(isolated_content.contains("2. Do the second thing")); - } - Err(_e) => { - panic!("take_block_lines failed"); - } - } - } - - #[test] - fn code_blocks() { - let mut input = Parser::new(); - - // Test simple identifier in code block - input.initialize("{ count }"); - let result = input.read_code_block(); - assert_eq!(result, Ok(Expression::Variable(Identifier("count")))); - - // Test function with simple parameter - input.initialize("{ sum(count) }"); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Execution(Function { - target: Identifier("sum"), - parameters: vec![Expression::Variable(Identifier("count"))] - })) - ); - - // Test function with multiple parameters - input.initialize("{ consume(apple, banana, chocolate) }"); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Execution(Function { - target: Identifier("consume"), - parameters: vec![ - Expression::Variable(Identifier("apple")), - Expression::Variable(Identifier("banana")), - Expression::Variable(Identifier("chocolate")) - ] - })) - ); - - // Test function with text parameter - input.initialize("{ exec(\"Hello, World\") }"); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Execution(Function { - target: Identifier("exec"), - parameters: vec![Expression::String(vec![Piece::Text("Hello, World")])] - })) - ); - - // Test function with multiline string parameter - input.initialize( - r#"{ exec(```bash -ls -l -echo "Done"```) }"#, - ); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Execution(Function { - target: Identifier("exec"), - parameters: vec![Expression::Multiline( - Some("bash"), - vec!["ls -l", "echo \"Done\""] - )] - })) - ); - - // Test function with quantity parameter (like timer with duration) - input.initialize("{ timer(3 hr) }"); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Execution(Function { - target: Identifier("timer"), - parameters: vec![Expression::Number(Numeric::Scientific(Quantity { - mantissa: Decimal { - number: 3, - precision: 0 - }, - uncertainty: None, - magnitude: None, - symbol: "hr" - }))] - })) - ); - - // Test function with integer quantity parameter - input.initialize("{ measure(100) }"); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Execution(Function { - target: Identifier("measure"), - parameters: vec![Expression::Number(Numeric::Integral(100))] - })) - ); - - // Test function with decimal quantity parameter - input.initialize("{ wait(2.5 s, \"yes\") }"); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Execution(Function { - target: Identifier("wait"), - parameters: vec![ - Expression::Number(Numeric::Scientific(Quantity { - mantissa: Decimal { - number: 25, - precision: 1 - }, - uncertainty: None, - magnitude: None, - symbol: "s" - })), - Expression::String(vec![Piece::Text("yes")]) - ] - })) - ); - } - - #[test] - fn multiline() { - let mut input = Parser::new(); - - // Test multiline with consistent indentation that should be trimmed - input.initialize( - r#"{ exec(```bash - ./stuff - - if [ true ] - then - ./other args - fi```) }"#, - ); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Execution(Function { - target: Identifier("exec"), - parameters: vec![Expression::Multiline( - Some("bash"), - vec![ - "./stuff", - "", - "if [ true ]", - "then", - " ./other args", - "fi" - ] - )] - })) - ); - - // Test multiline without language tag - input.initialize( - r#"{ exec(``` -ls -l -echo "Done"```) }"#, - ); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Execution(Function { - target: Identifier("exec"), - parameters: vec![Expression::Multiline(None, vec!["ls -l", "echo \"Done\""])] - })) - ); - - // Test multiline with intentional empty lines in the middle - input.initialize( - r#"{ exec(```shell -echo "Starting" - -echo "Middle section" - - -echo "Ending"```) }"#, - ); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Execution(Function { - target: Identifier("exec"), - parameters: vec![Expression::Multiline( - Some("shell"), - vec![ - "echo \"Starting\"", - "", - "echo \"Middle section\"", - "", - "", - "echo \"Ending\"" - ] - )] - })) - ); - - // Test that internal indentation relative to the base is preserved, - // and also that nested parenthesis don't break the enclosing - // take_block_chars() used to capture the input to the function. - input.initialize( - r#"{ exec(```python - def hello(): - print("Hello") - if True: - print("World") - - hello()```) }"#, - ); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Execution(Function { - target: Identifier("exec"), - parameters: vec![Expression::Multiline( - Some("python"), - vec![ - "def hello():", - " print(\"Hello\")", - " if True:", - " print(\"World\")", - "", - "hello()" - ] - )] - })) - ); - - // Test that a trailing empty line from the closing delimiter is removed - input.initialize( - r#"{ exec(``` -echo test -```) }"#, - ); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Execution(Function { - target: Identifier("exec"), - parameters: vec![Expression::Multiline(None, vec!["echo test"])] - })) - ); - - // Test various indentation edge cases - input.initialize( - r#"{ exec(```yaml - name: test - items: - - item1 - - item2 - config: - enabled: true```) }"#, - ); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Execution(Function { - target: Identifier("exec"), - parameters: vec![Expression::Multiline( - Some("yaml"), - vec![ - "name: test", - "items:", - " - item1", - " - item2", - "config:", - " enabled: true" - ] - )] - })) - ); - } - - #[test] - fn tablets() { - let mut input = Parser::new(); - - // Test simple single-entry tablet - input.initialize(r#"{ ["name" = "Johannes Grammerly"] }"#); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Tablet(vec![Pair { - label: "name", - value: Expression::String(vec![Piece::Text("Johannes Grammerly")]) - }])) - ); - - // Test multiline tablet with string values - input.initialize( - r#"{ [ - "name" = "Alice of Chains" - "age" = "29" -] }"#, - ); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Tablet(vec![ - Pair { - label: "name", - value: Expression::String(vec![Piece::Text("Alice of Chains")]) - }, - Pair { - label: "age", - value: Expression::String(vec![Piece::Text("29")]) - } - ])) - ); - - // Test tablet with mixed value types - input.initialize( - r#"{ [ - "answer" = 42 - "message" = msg - "timestamp" = now() -] }"#, - ); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Tablet(vec![ - Pair { - label: "answer", - value: Expression::Number(Numeric::Integral(42)) - }, - Pair { - label: "message", - value: Expression::Variable(Identifier("msg")) - }, - Pair { - label: "timestamp", - value: Expression::Execution(Function { - target: Identifier("now"), - parameters: vec![] - }) - } - ])) - ); - - // Test empty tablet - input.initialize("{ [ ] }"); - let result = input.read_code_block(); - assert_eq!(result, Ok(Expression::Tablet(vec![]))); - - // Test tablet with interpolated string values - input.initialize( - r#"{ [ - "context" = "Details about the thing" - "status" = active -] }"#, - ); - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Tablet(vec![ - Pair { - label: "context", - value: Expression::String(vec![Piece::Text("Details about the thing")]) - }, - Pair { - label: "status", - value: Expression::Variable(Identifier("active")) - } - ])) - ); - } - - #[test] - fn numeric_literals() { - let mut input = Parser::new(); - - // Test simple integer - input.initialize("{ 42 }"); - let result = input.read_code_block(); - assert_eq!(result, Ok(Expression::Number(Numeric::Integral(42)))); - - // Test negative integer - input.initialize("{ -123 }"); - let result = input.read_code_block(); - assert_eq!(result, Ok(Expression::Number(Numeric::Integral(-123)))); - - // Test zero - input.initialize("{ 0 }"); - let result = input.read_code_block(); - assert_eq!(result, Ok(Expression::Number(Numeric::Integral(0)))); - } - - #[test] - fn reading_identifiers() { - let mut input = Parser::new(); - - // Parse a basic identifier - input.initialize("hello"); - let result = input.read_identifier(); - assert_eq!(result, Ok(Identifier("hello"))); - assert_eq!(input.source, ""); - - // Parse an identifier with trailing content - input.initialize("count more"); - let result = input.read_identifier(); - assert_eq!(result, Ok(Identifier("count"))); - assert_eq!(input.source, " more"); - - // Parse an identifier with leading whitespace and trailing content - input.initialize(" \t test_name after"); - let result = input.read_identifier(); - assert_eq!(result, Ok(Identifier("test_name"))); - assert_eq!(input.source, " after"); - - // Parse an identifier with various delimiters - input.initialize("name(param)"); - let result = input.read_identifier(); - assert_eq!(result, Ok(Identifier("name"))); - assert_eq!(input.source, "(param)"); - } - - #[test] - fn test_foreach_expression() { - let mut input = Parser::new(); - input.initialize("{ foreach item in items }"); - - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Foreach( - vec![Identifier("item")], - Box::new(Expression::Variable(Identifier("items"))) - )) - ); - } - - #[test] - fn foreach_tuple_pattern() { - let mut input = Parser::new(); - input.initialize("{ foreach (design, component) in zip(designs, components) }"); - - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Foreach( - vec![Identifier("design"), Identifier("component")], - Box::new(Expression::Execution(Function { - target: Identifier("zip"), - parameters: vec![ - Expression::Variable(Identifier("designs")), - Expression::Variable(Identifier("components")) - ] - })) - )) - ); - - input.initialize("{ foreach (a, b, c) in zip(list1, list2, list3) }"); - - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Foreach( - vec![Identifier("a"), Identifier("b"), Identifier("c")], - Box::new(Expression::Execution(Function { - target: Identifier("zip"), - parameters: vec![ - Expression::Variable(Identifier("list1")), - Expression::Variable(Identifier("list2")), - Expression::Variable(Identifier("list3")) - ] - })) - )) - ); - } - - #[test] - fn tuple_binding_expression() { - let mut input = Parser::new(); - input.initialize("{ () ~ (x, y) }"); - - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Binding( - Box::new(Expression::Application(Invocation { - target: Target::Local(Identifier("get_coordinates")), - parameters: Some(vec![]) - })), - vec![Identifier("x"), Identifier("y")] - )) - ); - } - - #[test] - fn test_repeat_expression() { - let mut input = Parser::new(); - input.initialize("{ repeat count }"); - - let result = input.read_code_block(); - assert_eq!( - result, - Ok(Expression::Repeat(Box::new(Expression::Variable( - Identifier("count") - )))) - ); - } - - #[test] - fn test_foreach_keyword_boundary() { - // Test that "foreach" must be a complete word - let mut input = Parser::new(); - input.initialize("{ foreachitem in items }"); - - let result = input.read_code_block(); - // Should parse as identifier, not foreach - assert_eq!(result, Ok(Expression::Variable(Identifier("foreachitem")))); - } - - #[test] - fn test_repeat_keyword_boundary() { - // Test that "repeat" must be a complete word - let mut input = Parser::new(); - input.initialize("{ repeater }"); - - let result = input.read_code_block(); - // Should parse as identifier, not repeat - assert_eq!(result, Ok(Expression::Variable(Identifier("repeater")))); - } - - #[test] - fn test_foreach_in_keyword_boundary() { - // Test that "in" must be a complete word in foreach - let mut input = Parser::new(); - input.initialize("{ foreach item instead items }"); - - let result = input.read_code_block(); - // Should fail because "instead" doesn't match "in" - assert!(result.is_err()); - } - - #[test] - fn splitting_by() { - let mut input = Parser::new(); - - // Test splitting simple comma-separated identifiers - input.initialize("apple, banana, cherry"); - let result = input.take_split_by(',', |inner| inner.read_identifier()); - assert_eq!( - result, - Ok(vec![ - Identifier("apple"), - Identifier("banana"), - Identifier("cherry") - ]) - ); - assert_eq!(input.source, ""); - - // Test splitting with extra whitespace - input.initialize(" un | deux | trois "); - let result = input.take_split_by('|', |inner| inner.read_identifier()); - assert_eq!( - result, - Ok(vec![ - Identifier("un"), - Identifier("deux"), - Identifier("trois") - ]) - ); - - // Ensure a single item (no delimiter present in input) works - input.initialize("seulement"); - let result = input.take_split_by(',', |inner| inner.read_identifier()); - assert_eq!(result, Ok(vec![Identifier("seulement")])); - - // an empty chunk causes an error - input.initialize("un,,trois"); - let result = input.take_split_by(',', |inner| inner.read_identifier()); - assert!(result.is_err()); - - // empty trailing chunk causes an error - input.initialize("un,deux,"); - let result = input.take_split_by(',', |inner| inner.read_identifier()); - assert!(result.is_err()); - - // different split character - input.initialize("'Yes'|'No'|'Maybe'"); - let result = input.take_split_by('|', |inner| { - validate_response(inner.source).ok_or(ParsingError::IllegalParserState(inner.offset)) - }); - assert_eq!( - result, - Ok(vec![ - Response { - value: "Yes", - condition: None - }, - Response { - value: "No", - condition: None - }, - Response { - value: "Maybe", - condition: None - } - ]) - ); - } - - #[test] - fn reading_responses() { - let mut input = Parser::new(); - - // Test single response - input.initialize("'Yes'"); - let result = input.read_responses(); - assert_eq!( - result, - Ok(vec![Response { - value: "Yes", - condition: None - }]) - ); - - // Test multiple responses - input.initialize("'Yes' | 'No'"); - let result = input.read_responses(); - assert_eq!( - result, - Ok(vec![ - Response { - value: "Yes", - condition: None - }, - Response { - value: "No", - condition: None - } - ]) - ); - - // Test three responses - input.initialize("'Yes' | 'No' | 'Not Applicable'"); - let result = input.read_responses(); - assert_eq!( - result, - Ok(vec![ - Response { - value: "Yes", - condition: None - }, - Response { - value: "No", - condition: None - }, - Response { - value: "Not Applicable", - condition: None - } - ]) - ); - - // Test response with condition - input.initialize("'Yes' and equipment available"); - let result = input.read_responses(); - assert_eq!( - result, - Ok(vec![Response { - value: "Yes", - condition: Some("and equipment available") - }]) - ); - - // Test responses with whitespace - input.initialize(" 'Option A' | 'Option B' "); - let result = input.read_responses(); - assert_eq!( - result, - Ok(vec![ - Response { - value: "Option A", - condition: None - }, - Response { - value: "Option B", - condition: None - } - ]) - ); - } - - #[test] - fn reading_attributes() { - let mut input = Parser::new(); - - // Test simple role - input.initialize("@chef"); - let result = input.read_attributes(); - assert_eq!(result, Ok(vec![Attribute::Role(Identifier("chef"))])); - - // Test simple place - input.initialize("^kitchen"); - let result = input.read_attributes(); - assert_eq!(result, Ok(vec![Attribute::Place(Identifier("kitchen"))])); - - // Test multiple roles - input.initialize("@master_chef + @barista"); - let result = input.read_attributes(); - assert_eq!( - result, - Ok(vec![ - Attribute::Role(Identifier("master_chef")), - Attribute::Role(Identifier("barista")) - ]) - ); - - // Test multiple places - input.initialize("^kitchen + ^bath_room"); - let result = input.read_attributes(); - assert_eq!( - result, - Ok(vec![ - Attribute::Place(Identifier("kitchen")), - Attribute::Place(Identifier("bath_room")) - ]) - ); - - // Test mixed roles and places - input.initialize("@chef + ^bathroom"); - let result = input.read_attributes(); - assert_eq!( - result, - Ok(vec![ - Attribute::Role(Identifier("chef")), - Attribute::Place(Identifier("bathroom")) - ]) - ); - - // Test mixed places and roles - input.initialize("^kitchen + @barista"); - let result = input.read_attributes(); - assert_eq!( - result, - Ok(vec![ - Attribute::Place(Identifier("kitchen")), - Attribute::Role(Identifier("barista")) - ]) - ); - - // Test complex mixed attributes - input.initialize("@chef + ^kitchen + @barista + ^dining_room"); - let result = input.read_attributes(); - assert_eq!( - result, - Ok(vec![ - Attribute::Role(Identifier("chef")), - Attribute::Place(Identifier("kitchen")), - Attribute::Role(Identifier("barista")), - Attribute::Place(Identifier("dining_room")) - ]) - ); - - // Test invalid - uppercase - input.initialize("^Kitchen"); - let result = input.read_attributes(); - assert!(result.is_err()); - - // Test invalid - no marker - input.initialize("kitchen"); - let result = input.read_attributes(); - assert!(result.is_err()); - } - - #[test] - fn step_with_role_assignment() { - let mut input = Parser::new(); - - // Test step with role assignment - input.initialize( - r#" -1. Check the patient's vital signs - @nurse - "#, - ); - let result = input.read_step_dependent(); - - let scope = result.expect("Expected dependent step with role assignment"); - - assert_eq!( - scope, - Scope::DependentBlock { - ordinal: "1", - - description: vec![Paragraph(vec![Descriptive::Text( - "Check the patient's vital signs" - )])], - subscopes: vec![Scope::AttributeBlock { - attributes: vec![Attribute::Role(Identifier("nurse"))], - subscopes: vec![], - }] - } - ); - } - - #[test] - fn substep_with_role_assignment() { - let mut input = Parser::new(); - - // Test step with role assignment and substep - input.initialize( - r#" -1. Verify patient identity - @surgeon - a. Check ID - "#, - ); - let result = input.read_step_dependent(); - - let scope = result.expect("Expected dependent step with role assignment"); - - assert_eq!( - scope, - Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text( - "Verify patient identity" - )])], - subscopes: vec![Scope::AttributeBlock { - attributes: vec![Attribute::Role(Identifier("surgeon"))], - subscopes: vec![Scope::DependentBlock { - ordinal: "a", - description: vec![Paragraph(vec![Descriptive::Text("Check ID")])], - subscopes: vec![] - }] - }] - } - ); - } - - #[test] - fn parallel_step_with_role_assignment() { - let mut input = Parser::new(); - - // Test step with role assignment and parallel substep - input.initialize( - r#" -1. Monitor patient vitals - @nursing_team - - Check readings - "#, - ); - let result = input.read_step_dependent(); - - let scope = result.expect("Expected dependent step with role assignment"); - - assert_eq!( - scope, - Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text("Monitor patient vitals")])], - subscopes: vec![Scope::AttributeBlock { - attributes: vec![Attribute::Role(Identifier("nursing_team"))], - subscopes: vec![Scope::ParallelBlock { - bullet: '-', - description: vec![Paragraph(vec![Descriptive::Text("Check readings")])], - subscopes: vec![] - }] - }] - } - ); - } - - #[test] - fn two_roles_with_substeps() { - let mut input = Parser::new(); - - // Test two roles each with one substep - input.initialize( - r#" -1. Review events. - @surgeon - a. What are the steps? - @nurse - b. What are the concerns? - "#, - ); - - let result = input.read_step_dependent(); - - let scope = result.expect("Failed to parse two roles with substeps"); - - assert_eq!( - scope, - Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text("Review events.")])], - subscopes: vec![ - Scope::AttributeBlock { - attributes: vec![Attribute::Role(Identifier("surgeon"))], - subscopes: vec![Scope::DependentBlock { - ordinal: "a", - description: vec![Paragraph(vec![Descriptive::Text( - "What are the steps?" - )])], - subscopes: vec![] - }] - }, - Scope::AttributeBlock { - attributes: vec![Attribute::Role(Identifier("nurse"))], - subscopes: vec![Scope::DependentBlock { - ordinal: "b", - description: vec![Paragraph(vec![Descriptive::Text( - "What are the concerns?" - )])], - subscopes: vec![] - }] - } - ] - } - ); - } - - #[test] - fn parse_collecting_errors_basic() { - let mut input = Parser::new(); - - // Test with valid content - should have no errors - input.initialize("% technique v1\nvalid_proc : A -> B\n# Title\nDescription"); - let result = input.parse_collecting_errors(); - match result { - Ok(document) => { - assert!(document - .header - .is_some()); - assert!(document - .body - .is_some()); - } - Err(_) => panic!("Expected successful parse for valid content"), - } - - // Test with invalid header - should collect header error - input.initialize("% wrong v1"); - let result = input.parse_collecting_errors(); - match result { - Ok(_) => panic!("Expected errors for invalid content"), - Err(errors) => { - assert!(errors.len() > 0); - assert!(errors - .iter() - .any(|e| matches!(e, ParsingError::InvalidHeader(_)))); - } - } - - // Test that the method returns Result instead of ParseResult - input.initialize("some content"); - let _result: Result> = input.parse_collecting_errors(); - // If this compiles, the method signature is correct - } - - #[test] - fn test_multiple_error_collection() { - use std::path::Path; - - // Create a string with 3 procedures: 2 with errors and 1 valid - let content = r#" -broken_proc1 : A -> - # This procedure has incomplete signature - - 1. Do something - -valid_proc : A -> B - # This is a valid procedure - - 1. Valid step - 2. Another valid step - -broken_proc2 : -> B - # This procedure has incomplete signature (missing domain) - - 1. Do something else - "#; - - let result = parse_with_recovery(Path::new("test.t"), content); - - // Assert that there are at least 2 errors (from the broken procedures) - match result { - Ok(_) => panic!("Result should have errors"), - Err(errors) => { - let l = errors.len(); - assert!(l >= 2, "Should have at least 2 errors, got {}", l) - } - }; - } - - #[test] - fn test_redundant_error_removal_needed() { - use std::path::Path; - - // Create a malformed procedure that could generate multiple errors at the same offset - let content = r#" -% technique v1 - -broken : - This is not a valid signature line - - 1. Step one - "#; - - let result = parse_with_recovery(Path::new("test.tq"), content); - - // Check that we get an error about the invalid signature - match result { - Err(errors) => { - // Debug: print what errors we actually get - eprintln!("Errors: {:?}", errors); - - // Verify no redundant errors at the same offset - let mut offsets = errors - .iter() - .map(|e| e.offset()) - .collect::>(); - offsets.sort(); - let original_len = offsets.len(); - offsets.dedup(); - assert_eq!( - offsets.len(), - original_len, - "Found redundant errors at same offset" - ); - } - Ok(_) => panic!("Expected errors for malformed content"), - } - } - - #[test] - fn test_redundant_error_removal_unclosed_interpolation() { - let mut input = Parser::new(); - - // Test that UnclosedInterpolation error takes precedence over generic - // ExpectedMatchingChar - input.initialize(r#"{ "string with {unclosed interpolation" }"#); - let result = input.read_code_block(); - - // Should get the specific UnclosedInterpolation error, not a generic - // one - match result { - Err(ParsingError::UnclosedInterpolation(_)) => { - // Good - we got the specific error - } - Err(other) => { - panic!("Expected UnclosedInterpolation error, got: {:?}", other); - } - Ok(_) => { - panic!("Expected error for unclosed interpolation, but parsing succeeded"); - } - } - } - - #[test] - fn multiline_code_inline() { - let mut input = Parser::new(); - - // Test multiline code inline in descriptive text - let source = r#" -This is { exec(a, - b, c) - } a valid inline. - "#; - - input.initialize(source); - let result = input.read_descriptive(); - - assert!( - result.is_ok(), - "Multiline code inline should parse successfully" - ); - - let paragraphs = result.unwrap(); - assert_eq!(paragraphs.len(), 1, "Should have exactly one paragraph"); - - let descriptives = ¶graphs[0].0; - assert_eq!( - descriptives.len(), - 3, - "Should have 3 descriptive elements: text, code inline, text" - ); - - // First element should be "This is" - match &descriptives[0] { - Descriptive::Text(text) => assert_eq!(*text, "This is"), - _ => panic!("First element should be text"), - } +#[cfg(test)] +#[path = "checks/parser.rs"] +mod check; - // Second element should be the multiline code inline - match &descriptives[1] { - Descriptive::CodeInline(Expression::Execution(func)) => { - assert_eq!( - func.target - .0, - "exec" - ); - assert_eq!( - func.parameters - .len(), - 3 - ); - // Check that all parameters were parsed correctly - if let Expression::Variable(Identifier(name)) = &func.parameters[0] { - assert_eq!(*name, "a"); - } else { - panic!("First parameter should be variable 'a'"); - } - } - _ => panic!("Second element should be code inline with function execution"), - } +#[cfg(test)] +#[path = "checks/verify.rs"] +mod verify; - // Third element should be "a valid inline." - match &descriptives[2] { - Descriptive::Text(text) => assert_eq!(*text, "a valid inline."), - _ => panic!("Third element should be text"), - } - } -} +#[cfg(test)] +#[path = "checks/errors.rs"] +mod errors; diff --git a/src/problem/format.rs b/src/problem/format.rs index 617fd18..92ea071 100644 --- a/src/problem/format.rs +++ b/src/problem/format.rs @@ -1,7 +1,7 @@ use super::messages::generate_error_message; use owo_colors::OwoColorize; use std::path::Path; -use technique::{formatting::Render, language::LoadingError, parsing::parser::ParsingError}; +use technique::{formatting::Render, language::LoadingError, parsing::ParsingError}; /// Format a parsing error with full details including source code context pub fn full_parsing_error<'i>( diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 5094588..f46226e 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1,5 +1,5 @@ use crate::problem::Present; -use technique::{formatting::Render, language::*, parsing::parser::ParsingError}; +use technique::{formatting::Render, language::*, parsing::ParsingError}; /// Generate problem and detail messages for parsing errors using AST construction pub fn generate_error_message<'i>(error: &ParsingError, renderer: &dyn Render) -> (String, String) { @@ -35,6 +35,30 @@ there was no more input remaining in the current scope. .trim_ascii() .to_string(), ), + ParsingError::MissingParenthesis(_) => { + let examples = vec![Descriptive::Binding( + Box::new(Descriptive::Application(Invocation { + target: Target::Local(Identifier("mix_pangalactic_gargle_blaster")), + parameters: None, + })), + vec![Identifier("zaphod"), Identifier("trillian")], + )]; + + ( + "Lists of binding variables must be enclosed in parentheses".to_string(), + format!( + r#" +If you bind the result of an invocation to more than one variable, you must +enclose those names in parenthesis. For example: + + {} + "#, + examples[0].present(renderer), + ) + .trim_ascii() + .to_string(), + ) + } ParsingError::UnclosedInterpolation(_) => ( "Unclosed string interpolation".to_string(), r#" diff --git a/src/problem/present.rs b/src/problem/present.rs index 06da32d..ddf7567 100644 --- a/src/problem/present.rs +++ b/src/problem/present.rs @@ -70,6 +70,12 @@ impl Present for Invocation<'_> { } } +impl Present for Descriptive<'_> { + fn present(&self, renderer: &dyn Render) -> String { + formatter::render_descriptive(self, renderer) + } +} + impl Present for Function<'_> { fn present(&self, renderer: &dyn Render) -> String { formatter::render_function(self, renderer) diff --git a/tests/broken/MissingParenthesis.tq b/tests/broken/MissingParenthesis.tq new file mode 100644 index 0000000..3f139d2 --- /dev/null +++ b/tests/broken/MissingParenthesis.tq @@ -0,0 +1,11 @@ +prepare_meal(i) : Ingredients -> Meal + +# Prepare Meal + +Coordinating the preparation of all meal components. + + @chef + 1. Roast the turkey { (i) ~ turkey } + 2. Roast potatoes { (i) ~ potatoes } + 3. Prepare vegetables { (i) ~ raw } + 4. Cook vegetables { (raw) ~ veggies, water } diff --git a/tests/formatting/golden.rs b/tests/formatting/golden.rs index 46361cb..c7da535 100644 --- a/tests/formatting/golden.rs +++ b/tests/formatting/golden.rs @@ -1,123 +1,123 @@ -#[cfg(test)] -mod examples { - use std::fs; - use std::path::Path; - - use technique::formatting::*; - use technique::parsing; - - /// Golden test for the format command - /// - /// This test: - /// 1. Reads all .t files from examples/golden/ - /// 2. Runs the equivalent of the `format` command on each file - /// 3. Compares the formatted output with the original input - /// 4. Shows clear diffs when differences are found - /// - /// The test expects files to be in their canonical formatted form. If - /// files fail this test, either the parser & formatter is wrong (a bug - /// that needs to be fixed!) or possibly the example file is wrong - /// (perhaps because of a deliberate style change, and they thus might - /// need reformatting) - - /// Simple diff function to show line-by-line differences - fn show_diff(original: &str, formatted: &str, file_path: &Path) { - let original_lines: Vec<&str> = original - .lines() - .collect(); - let formatted_lines: Vec<&str> = formatted - .lines() - .collect(); - - let max_lines = original_lines - .len() - .max(formatted_lines.len()); - let mut differences_found = false; - - println!("\nDifferences found in file: {:?}", file_path); - println!("--- Original"); - println!("+++ Formatted"); - - for i in 0..max_lines { - let orig_line = original_lines - .get(i) - .unwrap_or(&""); - let fmt_line = formatted_lines - .get(i) - .unwrap_or(&""); - - if orig_line != fmt_line { - if !differences_found { - differences_found = true; - } - println!("@@ Line {} @@", i + 1); - println!("- {}", orig_line); - println!("+ {}", fmt_line); +use std::fs; +use std::path::Path; + +use technique::formatting::*; +use technique::parsing; + +/// Golden test for the format command +/// +/// This test: +/// 1. Reads all .t files from examples/golden/ +/// 2. Runs the equivalent of the `format` command on each file +/// 3. Compares the formatted output with the original input +/// 4. Shows clear diffs when differences are found +/// +/// The test expects files to be in their canonical formatted form. If +/// files fail this test, either the parser & formatter is wrong (a bug +/// that needs to be fixed!) or possibly the example file is wrong +/// (perhaps because of a deliberate style change, and they thus might +/// need reformatting) + +/// Simple diff function to show line-by-line differences +fn show_diff(original: &str, formatted: &str, file_path: &Path) { + let original_lines: Vec<&str> = original + .lines() + .collect(); + let formatted_lines: Vec<&str> = formatted + .lines() + .collect(); + + let max_lines = original_lines + .len() + .max(formatted_lines.len()); + let mut differences_found = false; + + println!("\nDifferences found in file: {:?}", file_path); + println!("--- Original"); + println!("+++ Formatted"); + + for i in 0..max_lines { + let orig_line = original_lines + .get(i) + .unwrap_or(&""); + let fmt_line = formatted_lines + .get(i) + .unwrap_or(&""); + + if orig_line != fmt_line { + if !differences_found { + differences_found = true; } + println!("@@ Line {} @@", i + 1); + println!("- {}", orig_line); + println!("+ {}", fmt_line); } } +} - #[test] - fn ensure_identical_output() { - // Read all .tq files from examples/prototype/ - let dir = Path::new("tests/golden/"); +#[test] +fn ensure_identical_output() { + // Read all .tq files from examples/prototype/ + let dir = Path::new("tests/golden/"); - // Ensure the directory exists - assert!(dir.exists(), "examples directory missing"); + // Ensure the directory exists + assert!(dir.exists(), "examples directory missing"); - // Get all .tq files in the directory - let entries = fs::read_dir(dir).expect("Failed to read examples directory"); + // Get all .tq files in the directory + let entries = fs::read_dir(dir).expect("Failed to read examples directory"); - let mut files = Vec::new(); - for entry in entries { - let entry = entry.expect("Failed to read directory entry"); - let path = entry.path(); + let mut files = Vec::new(); + for entry in entries { + let entry = entry.expect("Failed to read directory entry"); + let path = entry.path(); - if path - .extension() - .and_then(|s| s.to_str()) - == Some("tq") - { - files.push(path); - } + if path + .extension() + .and_then(|s| s.to_str()) + == Some("tq") + { + files.push(path); } + } - // Ensure we found some test files - assert!(!files.is_empty(), "No .tq files found in examples directory"); + // Ensure we found some test files + assert!( + !files.is_empty(), + "No .tq files found in examples directory" + ); - let mut failures = Vec::new(); + let mut failures = Vec::new(); - // Test each file - for file in &files { - // Load the original content - let original = parsing::load(&file) - .unwrap_or_else(|e| panic!("Failed to load file {:?}: {:?}", file, e)); + // Test each file + for file in &files { + // Load the original content + let original = parsing::load(&file) + .unwrap_or_else(|e| panic!("Failed to load file {:?}: {:?}", file, e)); - // Parse the content into a Document - let document = parsing::parse(&file, &original) - .unwrap_or_else(|e| panic!("Failed to parse file {:?}: {:?}", file, e)); + // Parse the content into a Document + let document = parsing::parse(&file, &original) + .unwrap_or_else(|e| panic!("Failed to parse file {:?}: {:?}", file, e)); - // Format the document using the Identity renderer (no markup) - // Using width 78 to match the default - let result = render(&Identity, &document, 78); + // Format the document using the Identity renderer (no markup) + // Using width 78 to match the default + let result = render(&Identity, &document, 78); - // Compare the formatted output with the original input - // They should be identical for well-formed files - if result != original { - failures.push(file.clone()); - } + // Compare the formatted output with the original input + // They should be identical for well-formed files + if result != original { + failures.push(file.clone()); } + } - // If any files had differences, show detailed diffs and fail - if !failures.is_empty() { - for file_path in &failures { - let content = parsing::load(&file_path).unwrap(); - let document = parsing::parse(&file_path, &content).unwrap(); - let output = render(&Identity, &document, 78); - show_diff(&content, &output, &file_path); - } - - panic!("All examples must format unchanged"); + // If any files had differences, show detailed diffs and fail + if !failures.is_empty() { + for file_path in &failures { + let content = parsing::load(&file_path).unwrap(); + let document = parsing::parse(&file_path, &content).unwrap(); + let output = render(&Identity, &document, 78); + show_diff(&content, &output, &file_path); } + + panic!("All examples must format unchanged"); } } diff --git a/tests/parsing/broken.rs b/tests/parsing/broken.rs new file mode 100644 index 0000000..69b5dd1 --- /dev/null +++ b/tests/parsing/broken.rs @@ -0,0 +1,51 @@ +use std::fs; +use std::path::Path; + +use technique::parsing; + +#[test] +fn ensure_fail() { + let dir = Path::new("tests/broken/"); + + assert!(dir.exists(), "broken directory missing"); + + let entries = fs::read_dir(dir).expect("Failed to read broken directory"); + + let mut files = Vec::new(); + for entry in entries { + let entry = entry.expect("Failed to read directory entry"); + let path = entry.path(); + + if path + .extension() + .and_then(|s| s.to_str()) + == Some("tq") + { + files.push(path); + } + } + + assert!(!files.is_empty(), "No .tq files found in broken directory"); + + let mut unexpected_successes = Vec::new(); + + for file in &files { + let content = parsing::load(&file) + .unwrap_or_else(|e| panic!("Failed to load file {:?}: {:?}", file, e)); + + match parsing::parse(&file, &content) { + Ok(_) => { + println!("File {:?} unexpectedly parsed successfully", file); + unexpected_successes.push(file.clone()); + } + Err(_) => {} + } + } + + if !unexpected_successes.is_empty() { + panic!( + "Broken files should not to parse successfully, but {} files passed", + unexpected_successes.len() + ); + } +} diff --git a/tests/parsing/errors.rs b/tests/parsing/errors.rs deleted file mode 100644 index af73f94..0000000 --- a/tests/parsing/errors.rs +++ /dev/null @@ -1,272 +0,0 @@ -#[cfg(test)] -mod syntax { - use std::path::Path; - use technique::parsing::parser::{parse_with_recovery, ParsingError}; - - /// Helper function to check if parsing produces the expected error type - fn expect_error(content: &str, expected: ParsingError) { - let result = parse_with_recovery(Path::new("test.tq"), content); - match result { - Ok(_) => panic!( - "Expected parsing to fail, but it succeeded for input: {}", - content - ), - Err(errors) => { - // Check if any error matches the expected type - let found_expected = errors - .iter() - .any(|error| { - std::mem::discriminant(error) == std::mem::discriminant(&expected) - }); - - if !found_expected { - panic!( - "Expected error type like {:?} but got: {:?} for input '{}'", - expected, errors, content - ); - } - } - } - } - - #[test] - fn invalid_identifier_uppercase_start() { - expect_error( - r#" -Making_Coffee : Ingredients -> Coffee - "# - .trim_ascii(), - ParsingError::InvalidIdentifier(0, "".to_string()), - ); - } - - #[test] - fn invalid_identifier_mixed_case() { - expect_error( - r#" -makeCoffee : Ingredients -> Coffee - "# - .trim_ascii(), - ParsingError::InvalidIdentifier(0, "".to_string()), - ); - } - - #[test] - fn invalid_identifier_with_dashes() { - expect_error( - r#" -make-coffee : Ingredients -> Coffee - "# - .trim_ascii(), - ParsingError::InvalidIdentifier(0, "".to_string()), - ); - } - - #[test] - fn invalid_identifier_with_spaces() { - expect_error( - r#" -make coffee : Ingredients -> Coffee - "# - .trim_ascii(), - ParsingError::InvalidParameters(0), - ); - } - - #[test] - fn invalid_signature_wrong_arrow() { - expect_error( - r#" -making_coffee : Ingredients => Coffee - "# - .trim_ascii(), - ParsingError::InvalidSignature(0), - ); - } - - #[test] - fn invalid_genus_lowercase_forma() { - expect_error( - r#" -making_coffee : ingredients -> Coffee - "# - .trim_ascii(), - ParsingError::InvalidGenus(16), - ); - } - - #[test] - fn invalid_genus_both_lowercase() { - expect_error( - r#" -making_coffee : ingredients -> coffee - "# - .trim_ascii(), - ParsingError::InvalidGenus(16), - ); - } - - #[test] - fn invalid_signature_missing_arrow() { - expect_error( - r#" -making_coffee : Ingredients Coffee - "# - .trim_ascii(), - ParsingError::InvalidSignature(16), - ); - } - - #[test] - fn invalid_declaration_missing_colon() { - expect_error( - r#" -making_coffee Ingredients -> Coffee - "# - .trim_ascii(), - ParsingError::Unrecognized(0), - ); - } - - #[test] - fn invalid_identifier_in_parameters() { - expect_error( - r#" -making_coffee(BadParam) : Ingredients -> Coffee - "# - .trim_ascii(), - ParsingError::InvalidIdentifier(14, "".to_string()), - ); - } - - #[test] - fn invalid_identifier_empty() { - expect_error( - r#" - : Ingredients -> Coffee - "# - .trim_ascii(), - ParsingError::InvalidDeclaration(0), - ); - } - - #[test] - fn invalid_step_format() { - expect_error( - r#" -making_coffee : - - A. First step (should be lowercase 'a.') - "# - .trim_ascii(), - ParsingError::InvalidStep(21), - ); - } - - #[test] - fn invalid_response_wrong_quotes() { - expect_error( - r#" -making_coffee : - - 1. Do you want coffee? - "Yes" | "No" - "# - .trim_ascii(), - ParsingError::InvalidResponse(52), - ); - } - - #[test] - fn invalid_multiline_missing_closing() { - expect_error( - r#" -making_coffee : - - 1. Do something with ``` - This is missing closing backticks - "# - .trim_ascii(), - ParsingError::InvalidMultiline(41), - ); - } - - #[test] - fn invalid_code_block_missing_closing_brace() { - expect_error( - r#" -making_coffee : - - 1. Do something { exec("command" - "# - .trim_ascii(), - ParsingError::ExpectedMatchingChar(38, "a code block", '{', '}'), - ); - } - - #[test] - fn invalid_step_wrong_ordinal() { - expect_error( - r#" -making_coffee : - - i. Wrong case section - "# - .trim_ascii(), - ParsingError::InvalidStep(21), - ); - } - - #[test] - fn invalid_invocation_malformed() { - expect_error( - r#" -making_coffee : - - 1. Do '), - ); - } - - #[test] - fn invalid_execution_malformed() { - expect_error( - r#" -making_coffee : - - 1. Do something { exec("command" } - "# - .trim_ascii(), - ParsingError::ExpectedMatchingChar(43, "a function call", '(', ')'), - ); - } - - #[test] - fn invalid_invocation_in_repeat() { - expect_error( - r#" -making_coffee : - - 1. { repeat '), - ); - } - - #[test] - fn invalid_substep_uppercase() { - expect_error( - r#" -making_coffee : - - 1. First step - A. This should be lowercase - "# - .trim_ascii(), - ParsingError::InvalidSubstep(37), - ); - } -} diff --git a/tests/parsing/mod.rs b/tests/parsing/mod.rs index 1a832e4..25fc4d6 100644 --- a/tests/parsing/mod.rs +++ b/tests/parsing/mod.rs @@ -1,3 +1,2 @@ -mod errors; -mod parser; mod samples; +mod broken; diff --git a/tests/parsing/parser.rs b/tests/parsing/parser.rs deleted file mode 100644 index 90ee577..0000000 --- a/tests/parsing/parser.rs +++ /dev/null @@ -1,1376 +0,0 @@ -#[cfg(test)] -mod verify { - use std::path::Path; - use std::vec; - - use technique::language::*; - use technique::parsing::parser::Parser; - - fn trim(s: &str) -> &str { - s.strip_prefix('\n') - .unwrap_or(s) - } - - #[test] - fn technique_header() { - let mut input = Parser::new(); - input.initialize("% technique v1"); - - let metadata = input.read_technique_header(); - assert_eq!( - metadata, - Ok(Metadata { - version: 1, - license: None, - copyright: None, - template: None - }) - ); - - input.initialize(trim( - r#" -% technique v1 -! MIT; (c) ACME, Inc -& checklist - "#, - )); - - let metadata = input.read_technique_header(); - assert_eq!( - metadata, - Ok(Metadata { - version: 1, - license: Some("MIT"), - copyright: Some("ACME, Inc"), - template: Some("checklist") - }) - ); - } - - #[test] - fn procedure_declaration_one() { - let mut input = Parser::new(); - input.initialize(trim( - r#" -making_coffee : (Beans, Milk) -> Coffee - - "#, - )); - - let procedure = input.read_procedure(); - assert_eq!( - procedure, - Ok(Procedure { - name: Identifier("making_coffee"), - parameters: None, - signature: Some(Signature { - domain: Genus::Tuple(vec![Forma("Beans"), Forma("Milk")]), - range: Genus::Single(Forma("Coffee")) - }), - elements: vec![], - }) - ); - } - - #[test] - fn procedure_declaration_two() { - let mut input = Parser::new(); - input.initialize(trim( - r#" -first : A -> B - -second : C -> D - - "#, - )); - - let procedure = input.read_procedure(); - assert_eq!( - procedure, - Ok(Procedure { - name: Identifier("first"), - parameters: None, - signature: Some(Signature { - domain: Genus::Single(Forma("A")), - range: Genus::Single(Forma("B")) - }), - elements: vec![], - }) - ); - - let procedure = input.read_procedure(); - assert_eq!( - procedure, - Ok(Procedure { - name: Identifier("second"), - parameters: None, - signature: Some(Signature { - domain: Genus::Single(Forma("C")), - range: Genus::Single(Forma("D")) - }), - elements: vec![], - }) - ); - } - - #[test] - fn procedure_declaration_with_parameters() { - let mut input = Parser::new(); - input.initialize(trim( - r#" -making_coffee(e) : Ingredients -> Coffee - - "#, - )); - - let procedure = input.read_procedure(); - assert_eq!( - procedure, - Ok(Procedure { - name: Identifier("making_coffee"), - parameters: Some(vec![Identifier("e")]), - signature: Some(Signature { - domain: Genus::Single(Forma("Ingredients")), - range: Genus::Single(Forma("Coffee")) - }), - elements: vec![], - }) - ); - } - - #[test] - fn example_procedure() { - let mut input = Parser::new(); - input.initialize(trim( - r#" -first : A -> B - -# The First - -This is the first one. - -1. Do the first thing in the first one. -2. Do the second thing in the first one. - - "#, - )); - - let procedure = input.read_procedure(); - assert_eq!( - procedure, - Ok(Procedure { - name: Identifier("first"), - parameters: None, - signature: Some(Signature { - domain: Genus::Single(Forma("A")), - range: Genus::Single(Forma("B")) - }), - elements: vec![ - Element::Title("The First"), - Element::Description(vec![Paragraph(vec![Descriptive::Text( - "This is the first one." - )])]), - Element::Steps(vec![ - Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text( - "Do the first thing in the first one." - )])], - - subscopes: vec![] - }, - Scope::DependentBlock { - ordinal: "2", - description: vec![Paragraph(vec![Descriptive::Text( - "Do the second thing in the first one." - )])], - - subscopes: vec![] - } - ]) - ], - }) - ); - } - - #[test] - fn example_with_responses() { - let mut input = Parser::new(); - input.initialize(trim( - r#" -first : A -> B - -# The First - -This is the first one. - -1. Have you done the first thing in the first one? - 'Yes' | 'No' but I have an excuse -2. Do the second thing in the first one. - "#, - )); - - let procedure = input.read_procedure(); - assert_eq!( - procedure, - Ok(Procedure { - name: Identifier("first"), - parameters: None, - signature: Some(Signature { - domain: Genus::Single(Forma("A")), - range: Genus::Single(Forma("B")) - }), - elements: vec![ - Element::Title("The First"), - Element::Description(vec![Paragraph(vec![Descriptive::Text( - "This is the first one." - )])]), - Element::Steps(vec![ - Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text( - "Have you done the first thing in the first one?" - )])], - subscopes: vec![Scope::ResponseBlock { - responses: vec![ - Response { - value: "Yes", - condition: None - }, - Response { - value: "No", - condition: Some("but I have an excuse") - } - ] - }], - }, - Scope::DependentBlock { - ordinal: "2", - description: vec![Paragraph(vec![Descriptive::Text( - "Do the second thing in the first one." - )])], - - subscopes: vec![], - } - ]) - ], - }) - ); - } - - #[test] - fn example_with_substeps() { - let mut input = Parser::new(); - input.initialize(trim( - r#" -first : A -> B - -# The First - -This is the first one. - -1. Have you done the first thing in the first one? - a. Do the first thing. Then ask yourself if you are done: - 'Yes' | 'No' but I have an excuse -2. Do the second thing in the first one. - "#, - )); - - let procedure = input.read_procedure(); - assert_eq!( - procedure, - Ok(Procedure { - name: Identifier("first"), - parameters: None, - signature: Some(Signature { - domain: Genus::Single(Forma("A")), - range: Genus::Single(Forma("B")) - }), - elements: vec![ - Element::Title("The First"), - Element::Description(vec![Paragraph(vec![Descriptive::Text( - "This is the first one." - )])]), - Element::Steps(vec![ - Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text( - "Have you done the first thing in the first one?" - )])], - - subscopes: vec![Scope::DependentBlock { - ordinal: "a", - description: vec![Paragraph(vec![Descriptive::Text( - "Do the first thing. Then ask yourself if you are done:" - )])], - subscopes: vec![Scope::ResponseBlock { - responses: vec![ - Response { - value: "Yes", - condition: None - }, - Response { - value: "No", - condition: Some("but I have an excuse") - } - ] - }] - }] - }, - Scope::DependentBlock { - ordinal: "2", - description: vec![Paragraph(vec![Descriptive::Text( - "Do the second thing in the first one." - )])], - - subscopes: vec![], - } - ]) - ], - }) - ); - } - - #[test] - fn realistic_procedure() { - let mut input = Parser::new(); - input.initialize(trim( - r#" - before_anesthesia : - - # Before induction of anaesthesia - - 1. Has the patient confirmed his/her identity, site, procedure, - and consent? - 'Yes' - 2. Is the site marked? - 'Yes' | 'Not Applicable' - 3. Is the anaesthesia machine and medication check complete? - 'Yes' - 4. Is the pulse oximeter on the patient and functioning? - 'Yes' - 5. Does the patient have a: - - Known allergy? - 'No' | 'Yes' - - Difficult airway or aspiration risk? - 'No' | 'Yes' and equipment/assistance available - - Risk of blood loss > 500 mL? - 'No' | 'Yes' and two IVs planned and fluids available - "#, - )); - let result = input.read_procedure(); - let procedure = result.expect("a parsed Procedure"); - - assert_eq!( - procedure, - Procedure { - name: Identifier("before_anesthesia"), - parameters: None, - signature: None, - elements: vec![ - Element::Title("Before induction of anaesthesia"), - Element::Steps(vec![ - Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![ - Descriptive::Text( - "Has the patient confirmed his/her identity, site, procedure," - ), - Descriptive::Text("and consent?") - ])], - subscopes: vec![Scope::ResponseBlock { - responses: vec![Response { - value: "Yes", - condition: None - }] - }], - }, - Scope::DependentBlock { - ordinal: "2", - description: vec![Paragraph(vec![Descriptive::Text( - "Is the site marked?" - )])], - subscopes: vec![Scope::ResponseBlock { - responses: vec![ - Response { - value: "Yes", - condition: None - }, - Response { - value: "Not Applicable", - condition: None - } - ] - }], - }, - Scope::DependentBlock { - ordinal: "3", - description: vec![Paragraph(vec![Descriptive::Text( - "Is the anaesthesia machine and medication check complete?" - )])], - subscopes: vec![Scope::ResponseBlock { - responses: vec![Response { - value: "Yes", - condition: None - }] - }], - }, - Scope::DependentBlock { - ordinal: "4", - description: vec![Paragraph(vec![Descriptive::Text( - "Is the pulse oximeter on the patient and functioning?" - )])], - subscopes: vec![Scope::ResponseBlock { - responses: vec![Response { - value: "Yes", - condition: None - }] - }], - }, - Scope::DependentBlock { - ordinal: "5", - description: vec![Paragraph(vec![Descriptive::Text( - "Does the patient have a:" - )])], - - subscopes: vec![ - Scope::ParallelBlock { - bullet: '-', - description: vec![Paragraph(vec![Descriptive::Text( - "Known allergy?" - )])], - subscopes: vec![Scope::ResponseBlock { - responses: vec![ - Response { - value: "No", - condition: None - }, - Response { - value: "Yes", - condition: None - } - ] - }], - }, - Scope::ParallelBlock { - bullet: '-', - description: vec![Paragraph(vec![Descriptive::Text( - "Difficult airway or aspiration risk?" - )])], - subscopes: vec![Scope::ResponseBlock { - responses: vec![ - Response { - value: "No", - condition: None - }, - Response { - value: "Yes", - condition: Some( - "and equipment/assistance available" - ) - } - ] - }], - }, - Scope::ParallelBlock { - bullet: '-', - description: vec![Paragraph(vec![Descriptive::Text( - "Risk of blood loss > 500 mL?" - )])], - subscopes: vec![Scope::ResponseBlock { - responses: vec![ - Response { - value: "No", - condition: None - }, - Response { - value: "Yes", - condition: Some( - "and two IVs planned and fluids available" - ) - } - ] - }], - } - ] - } - ]) - ] - } - ); - } - - #[test] - fn realistic_procedure_part2() { - let mut input = Parser::new(); - input.initialize(trim( - r#" -label_the_specimens : - - 1. Specimen labelling - @nursing_team - - Label blood tests - - Label tissue samples - @admin_staff - a. Prepare the envelopes - "#, - )); - let procedure = input.read_procedure(); - - assert_eq!( - procedure, - Ok(Procedure { - name: Identifier("label_the_specimens"), - parameters: None, - signature: None, - elements: vec![Element::Steps(vec![Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text("Specimen labelling")])], - - subscopes: vec![ - Scope::AttributeBlock { - attributes: vec![Attribute::Role(Identifier("nursing_team"))], - subscopes: vec![ - Scope::ParallelBlock { - bullet: '-', - description: vec![Paragraph(vec![Descriptive::Text( - "Label blood tests" - )])], - - subscopes: vec![], - }, - Scope::ParallelBlock { - bullet: '-', - description: vec![Paragraph(vec![Descriptive::Text( - "Label tissue samples" - )])], - - subscopes: vec![], - } - ] - }, - Scope::AttributeBlock { - attributes: vec![Attribute::Role(Identifier("admin_staff"))], - subscopes: vec![Scope::DependentBlock { - ordinal: "a", - description: vec![Paragraph(vec![Descriptive::Text( - "Prepare the envelopes" - )])], - - subscopes: vec![], - }] - } - ], - }])], - }) - ); - } - - #[test] - fn realistic_procedure_part3() { - let mut input = Parser::new(); - input.initialize(trim( - r#" -before_leaving : - -# Before patient leaves operating room - - 1. Verbally confirm: - - The name of the surgical procedure(s). - - Completion of instrument, sponge, and needle counts. - - Specimen labelling - { foreach specimen in specimens } - @nursing_team - a. Read specimen labels aloud, including patient - name. - - Whether there are any equipment problems to be addressed. - 2. Post-operative care: - @surgeon - a. What are the key concerns for recovery and management - of this patient? - @anesthetist - b. What are the key concerns for recovery and management - of this patient? - @nursing_team - c. What are the key concerns for recovery and management - of this patient? - "#, - )); - let result = input.read_procedure(); - - let procedure = result.expect("a procedure"); - assert_eq!( - procedure, - Procedure { - name: Identifier("before_leaving"), - parameters: None, - signature: None, - elements: vec![ - Element::Title("Before patient leaves operating room"), - Element::Steps(vec![ - Scope::DependentBlock { - ordinal: "1", - description: vec![ - Paragraph(vec![Descriptive::Text("Verbally confirm:")]) - ], - - subscopes: vec![ - Scope::ParallelBlock { - bullet: '-', - description: vec![ - Paragraph(vec![Descriptive::Text("The name of the surgical procedure(s).")]) - ], - - subscopes: vec![], - }, - Scope::ParallelBlock { - bullet: '-', - description: vec![ - Paragraph(vec![Descriptive::Text("Completion of instrument, sponge, and needle counts.")]) - ], - - subscopes: vec![], - }, - Scope::ParallelBlock { - bullet: '-', - description: vec![Paragraph(vec![ - Descriptive::Text("Specimen labelling")])], - - subscopes: vec![ - Scope::CodeBlock { - expression: Expression::Foreach( - vec![Identifier("specimen")], - Box::new(Expression::Variable(Identifier("specimens"))) - ), - subscopes: vec![ - Scope::AttributeBlock { - attributes: vec![Attribute::Role(Identifier("nursing_team"))], - subscopes: vec![ - Scope::DependentBlock { - ordinal: "a", - description: vec![ - Paragraph(vec![ - Descriptive::Text("Read specimen labels aloud, including patient"), - Descriptive::Text("name.") - ]) - ], - - subscopes: vec![] - } - ] - } - ]} - ]}, - Scope::ParallelBlock { - bullet: '-', - description: vec![ - Paragraph(vec![Descriptive::Text("Whether there are any equipment problems to be addressed.")]) - ], - - subscopes: vec![], - } - ] - }, - Scope::DependentBlock { - ordinal: "2", - description: vec![ - Paragraph(vec![Descriptive::Text("Post-operative care:")]) - ], - - subscopes: vec![ - Scope::AttributeBlock { - attributes: vec![Attribute::Role(Identifier("surgeon"))], - subscopes: vec![ - Scope::DependentBlock { - ordinal: "a", - description: vec![ - Paragraph(vec![ - Descriptive::Text("What are the key concerns for recovery and management"), - Descriptive::Text("of this patient?") - ]) - ], - - subscopes: vec![], - } - ] - }, - Scope::AttributeBlock { - attributes: vec![Attribute::Role(Identifier("anesthetist"))], - subscopes: vec![ - Scope::DependentBlock { - ordinal: "b", - description: vec![ - Paragraph(vec![ - Descriptive::Text("What are the key concerns for recovery and management"), - Descriptive::Text("of this patient?") - ]) - ], - - subscopes: vec![], - } - ] - }, - Scope::AttributeBlock { - attributes: vec![Attribute::Role(Identifier("nursing_team"))], - subscopes: vec![ - Scope::DependentBlock { - ordinal: "c", - description: vec![ - Paragraph(vec![ - Descriptive::Text("What are the key concerns for recovery and management"), - Descriptive::Text("of this patient?") - ]) - ], - - subscopes: vec![], - } - ] - } - ], - } - ]) - ], - }); - } - - #[test] - fn parallel_role_assignments() { - let mut input = Parser::new(); - - // Test a step that mirrors the surgical safety checklist pattern - input.initialize( - r#" -5. Review anticipated critical events. - @surgeon - a. What are the critical or non-routine steps? - b. How long will the case take? - c. What is the blood loss expected? - @anaesthetist - d. Are there any patient-specific concerns? - @nursing_team - e. Has sterility been confirmed? - f. Has the equipment issues been addressed? - "#, - ); - - let result = input.read_step_dependent(); - - match result { - Ok(Scope::DependentBlock { - ordinal, - description: content, - subscopes: scopes, - }) => { - assert_eq!(ordinal, "5"); - assert_eq!( - content, - vec![Paragraph(vec![Descriptive::Text( - "Review anticipated critical events." - )])] - ); - // Should have 3 scopes: one for each role with their substeps - assert_eq!(scopes.len(), 3); - - // Check that the first scope has surgeon role - if let Scope::AttributeBlock { - attributes, - subscopes: substeps, - } = &scopes[0] - { - assert_eq!(*attributes, vec![Attribute::Role(Identifier("surgeon"))]); - assert_eq!(substeps.len(), 3); // a, b, c - } else { - panic!("Expected AttributedBlock for surgeon"); - } - - // Check that the second scope has anaesthetist role - if let Scope::AttributeBlock { - attributes, - subscopes: substeps, - } = &scopes[1] - { - assert_eq!( - *attributes, - vec![Attribute::Role(Identifier("anaesthetist"))] - ); - assert_eq!(substeps.len(), 1); // d - } else { - panic!("Expected AttributedBlock for anaesthetist"); - } - - // Check that the third scope has nursing_team role - if let Scope::AttributeBlock { - attributes, - subscopes: substeps, - } = &scopes[2] - { - assert_eq!( - *attributes, - vec![Attribute::Role(Identifier("nursing_team"))] - ); - assert_eq!(substeps.len(), 2); // e, f - } else { - panic!("Expected AttributedBlock for nursing_team"); - } - } - _ => panic!("Expected dependent step with role assignment"), - } - } - - #[test] - fn multiple_roles_with_dependent_substeps() { - let mut input = Parser::new(); - - // Test multiple roles each with their own dependent substeps - input.initialize( - r#" -1. Review surgical procedure - @surgeon - a. Review patient chart - b. Verify surgical site - c. Confirm procedure type - @anaesthetist - a. Check patient allergies - b. Review medication history - @nursing_team - a. Prepare instruments - b. Verify sterility - c. Confirm patient positioning - "#, - ); - - let result = input.read_step_dependent(); - - match result { - Ok(Scope::DependentBlock { - ordinal, - description: content, - subscopes: scopes, - }) => { - assert_eq!(ordinal, "1"); - assert_eq!( - content, - vec![Paragraph(vec![Descriptive::Text( - "Review surgical procedure" - )])] - ); - assert_eq!(scopes.len(), 3); - - // Check surgeon scope (3 dependent substeps) - if let Scope::AttributeBlock { - attributes, - subscopes: substeps, - } = &scopes[0] - { - assert_eq!(*attributes, vec![Attribute::Role(Identifier("surgeon"))]); - assert_eq!(substeps.len(), 3); - } else { - panic!("Expected AttributedBlock for surgeon"); - } - - // Check anaesthetist scope (2 dependent substeps) - if let Scope::AttributeBlock { - attributes, - subscopes: substeps, - } = &scopes[1] - { - assert_eq!( - *attributes, - vec![Attribute::Role(Identifier("anaesthetist"))] - ); - assert_eq!(substeps.len(), 2); - } else { - panic!("Expected AttributedBlock for anaesthetist"); - } - - // Check nursing_team scope (3 dependent substeps) - if let Scope::AttributeBlock { - attributes, - subscopes: substeps, - } = &scopes[2] - { - assert_eq!( - *attributes, - vec![Attribute::Role(Identifier("nursing_team"))] - ); - assert_eq!(substeps.len(), 3); - } else { - panic!("Expected AttributedBlock for nursing_team"); - } - - // Verify all substeps are dependent (ordered) steps - for scope in &scopes { - match scope { - Scope::AttributeBlock { - subscopes: substeps, - .. - } => { - for substep in substeps { - assert!(matches!(substep, Scope::DependentBlock { .. })); - } - } - _ => panic!("Expected AttributedBlock scopes"), - } - } - } - _ => panic!("Expected dependent step with multiple role assignments"), - } - } - - #[test] - fn mixed_substeps_in_roles() { - let mut input = Parser::new(); - - input.initialize( - r#" -1. Emergency response - @team_lead - a. Assess situation - b. Coordinate response - - Monitor communications - - Track resources - c. File report - "#, - ); - let result = input.read_step_dependent(); - - let step = match result { - Ok(step) => step, - _ => panic!("Expected step with mixed substep types"), - }; - - assert_eq!( - step, - Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text("Emergency response")])], - - subscopes: vec![Scope::AttributeBlock { - attributes: vec![Attribute::Role(Identifier("team_lead"))], - subscopes: vec![ - Scope::DependentBlock { - ordinal: "a", - description: vec![Paragraph(vec![Descriptive::Text( - "Assess situation" - )])], - - subscopes: vec![] - }, - Scope::DependentBlock { - ordinal: "b", - description: vec![Paragraph(vec![Descriptive::Text( - "Coordinate response" - )])], - - subscopes: vec![ - Scope::ParallelBlock { - bullet: '-', - description: vec![Paragraph(vec![Descriptive::Text( - "Monitor communications" - )])], - - subscopes: vec![] - }, - Scope::ParallelBlock { - bullet: '-', - description: vec![Paragraph(vec![Descriptive::Text( - "Track resources" - )])], - - subscopes: vec![] - } - ] - }, - Scope::DependentBlock { - ordinal: "c", - description: vec![Paragraph(vec![Descriptive::Text("File report")])], - - subscopes: vec![] - } - ] - }] - } - ); - } - - #[test] - fn substeps_with_responses() { - let mut input = Parser::new(); - - input.initialize( - r#" -1. Main step - a. Substep with response - 'Yes' | 'No' - "#, - ); - let result = input.read_step_dependent(); - - assert_eq!( - result, - Ok(Scope::DependentBlock { - ordinal: "1", - description: vec![Paragraph(vec![Descriptive::Text("Main step")])], - - subscopes: vec![Scope::DependentBlock { - ordinal: "a", - description: vec![Paragraph(vec![Descriptive::Text("Substep with response")])], - subscopes: vec![Scope::ResponseBlock { - responses: vec![ - Response { - value: "Yes", - condition: None, - }, - Response { - value: "No", - condition: None, - }, - ] - }], - }], - }) - ); - } - - #[test] - fn naked_bindings() { - let mut input = Parser::new(); - - // Test simple naked binding: text ~ variable - input.initialize("What is the result? ~ answer"); - let descriptive = input.read_descriptive(); - assert_eq!( - descriptive, - Ok(vec![Paragraph(vec![Descriptive::Binding( - Box::new(Descriptive::Text("What is the result?")), - vec![Identifier("answer")] - )])]) - ); - - // Test naked binding followed by more text. This is probably not a - // valid usage, but it's good that it parses cleanly. - input.initialize("Enter your name ~ name\nContinue with next step"); - let descriptive = input.read_descriptive(); - assert_eq!( - descriptive, - Ok(vec![Paragraph(vec![ - Descriptive::Binding( - Box::new(Descriptive::Text("Enter your name")), - vec![Identifier("name")] - ), - Descriptive::Text("Continue with next step") - ])]) - ); - - // Test mixed content with function call binding and naked binding. - // This likewise may turn out to be something that fails compilation, - // but it's important that it parses right so that the users gets - // appropriate feedback. - input.initialize("First ~ result then describe the outcome ~ description"); - let descriptive = input.read_descriptive(); - assert_eq!( - descriptive, - Ok(vec![Paragraph(vec![ - Descriptive::Text("First"), - Descriptive::Binding( - Box::new(Descriptive::Application(Invocation { - target: Target::Local(Identifier("do_something")), - parameters: None, - })), - vec![Identifier("result")] - ), - Descriptive::Binding( - Box::new(Descriptive::Text("then describe the outcome")), - vec![Identifier("description")] - ) - ])]) - ); - } - - #[test] - fn section_parsing() { - let result = technique::parsing::parser::parse_with_recovery( - Path::new(""), - trim( - r#" -main_procedure : - -I. First Section - -first_section_first_procedure : - -# One dot One - -first_section_second_procedure : - -# One dot Two - -II. Second Section - -second_section_first_procedure : - -# Two dot One - -second_section_second_procedure : - -# Two dot Two - "#, - ), - ); - - let document = match result { - Ok(document) => document, - Err(e) => panic!("Parsing failed: {:?}", e), - }; - - // Verify complete structure - assert_eq!( - document, - Document { - header: None, - body: Some(Technique::Procedures(vec![Procedure { - name: Identifier("main_procedure"), - parameters: None, - signature: None, - elements: vec![Element::Steps(vec![ - Scope::SectionChunk { - numeral: "I", - title: Some(Paragraph(vec![Descriptive::Text("First Section")])), - body: Technique::Procedures(vec![ - Procedure { - name: Identifier("first_section_first_procedure"), - parameters: None, - signature: None, - elements: vec![Element::Title("One dot One")] - }, - Procedure { - name: Identifier("first_section_second_procedure"), - parameters: None, - signature: None, - elements: vec![Element::Title("One dot Two")] - } - ]), - }, - Scope::SectionChunk { - numeral: "II", - title: Some(Paragraph(vec![Descriptive::Text("Second Section")])), - body: Technique::Procedures(vec![ - Procedure { - name: Identifier("second_section_first_procedure"), - parameters: None, - signature: None, - elements: vec![Element::Title("Two dot One")] - }, - Procedure { - name: Identifier("second_section_second_procedure"), - parameters: None, - signature: None, - elements: vec![Element::Title("Two dot Two")] - } - ]), - }, - ])], - }])), - } - ); - } - - #[test] - fn section_with_procedures_only() { - let result = technique::parsing::parser::parse_with_recovery( - Path::new(""), - trim( - r#" -main_procedure : - -I. First Section - -procedure_one : Input -> Output - -procedure_two : Other -> Thing - -II. Second Section - -procedure_three : Concept -> Requirements - -procedure_four : Concept -> Architecture - "#, - ), - ); - - let document = match result { - Ok(document) => document, - Err(e) => panic!("Parsing failed: {:?}", e), - }; - - // Verify that both sections contain their respective procedures - if let Some(Technique::Procedures(procs)) = document.body { - let main_proc = &procs[0]; - if let Some(Element::Steps(steps)) = main_proc - .elements - .first() - { - // Should have 2 sections - assert_eq!(steps.len(), 2); - - // Check first section has 2 procedures - if let Scope::SectionChunk { - body: Technique::Procedures(section1_procs), - .. - } = &steps[0] - { - assert_eq!(section1_procs.len(), 2); - assert_eq!(section1_procs[0].name, Identifier("procedure_one")); - assert_eq!(section1_procs[1].name, Identifier("procedure_two")); - } else { - panic!("First section should contain procedures"); - } - - // Check second section has 2 procedures - if let Scope::SectionChunk { - body: Technique::Procedures(section2_procs), - .. - } = &steps[1] - { - assert_eq!(section2_procs.len(), 2); - assert_eq!(section2_procs[0].name, Identifier("procedure_three")); - assert_eq!(section2_procs[1].name, Identifier("procedure_four")); - } else { - panic!("Second section should contain procedures"); - } - } else { - panic!("Main procedure should have steps"); - } - } else { - panic!("Should have procedures"); - } - } - - #[test] - fn section_with_procedures() { - let result = technique::parsing::parser::parse_with_recovery( - Path::new(""), - trim( - r#" -main_procedure : - -I. Concept - -II. Requirements Definition and Architecture - -requirements_and_architecture : Concept -> Requirements, Architecture - - 2. Define Requirements (concept) - - 3. Determine Architecture (concept) - -define_requirements : Concept -> Requirements - -determine_architecture : Concept -> Architecture - -III. Implementation - "#, - ), - ); - - let document = match result { - Ok(document) => document, - Err(e) => panic!("Parsing failed: {:?}", e), - }; - - assert_eq!( - document, - Document { - header: None, - body: Some(Technique::Procedures(vec![Procedure { - name: Identifier("main_procedure"), - parameters: None, - signature: None, - elements: vec![Element::Steps(vec![ - Scope::SectionChunk { - numeral: "I", - title: Some(Paragraph(vec![Descriptive::Text("Concept")])), - body: Technique::Empty, - }, - Scope::SectionChunk { - numeral: "II", - title: Some(Paragraph(vec![Descriptive::Text( - "Requirements Definition and Architecture" - )])), - body: Technique::Procedures(vec![ - Procedure { - name: Identifier("requirements_and_architecture"), - parameters: None, - signature: Some(Signature { - domain: Genus::Single(Forma("Concept")), - range: Genus::Naked(vec![ - Forma("Requirements"), - Forma("Architecture") - ]), - }), - elements: vec![Element::Steps(vec![ - Scope::DependentBlock { - ordinal: "2", - description: vec![Paragraph(vec![ - Descriptive::Text("Define Requirements"), - Descriptive::Application(Invocation { - target: Target::Local(Identifier( - "define_requirements" - )), - parameters: Some(vec![Expression::Variable( - Identifier("concept") - )]), - }), - ])], - - subscopes: vec![], - }, - Scope::DependentBlock { - ordinal: "3", - description: vec![Paragraph(vec![ - Descriptive::Text("Determine Architecture"), - Descriptive::Application(Invocation { - target: Target::Local(Identifier( - "determine_architecture" - )), - parameters: Some(vec![Expression::Variable( - Identifier("concept") - )]), - }), - ])], - - subscopes: vec![], - }, - ])], - }, - Procedure { - name: Identifier("define_requirements"), - parameters: None, - signature: Some(Signature { - domain: Genus::Single(Forma("Concept")), - range: Genus::Single(Forma("Requirements")), - }), - elements: vec![], - }, - Procedure { - name: Identifier("determine_architecture"), - parameters: None, - signature: Some(Signature { - domain: Genus::Single(Forma("Concept")), - range: Genus::Single(Forma("Architecture")), - }), - elements: vec![], - }, - ]), - }, - Scope::SectionChunk { - numeral: "III", - title: Some(Paragraph(vec![Descriptive::Text("Implementation")])), - body: Technique::Empty, - }, - ])], - }])), - } - ) - } -} diff --git a/tests/parsing/samples.rs b/tests/parsing/samples.rs index 7c286c1..85d0004 100644 --- a/tests/parsing/samples.rs +++ b/tests/parsing/samples.rs @@ -1,101 +1,51 @@ -#[cfg(test)] -mod samples { - use std::fs; - use std::path::Path; +use std::fs; +use std::path::Path; - use technique::parsing; +use technique::parsing; - #[test] - fn ensure_samples_parse() { - let dir = Path::new("tests/samples/"); +#[test] +fn ensure_parse() { + let dir = Path::new("tests/samples/"); - assert!(dir.exists(), "samples directory missing"); + assert!(dir.exists(), "samples directory missing"); - let entries = fs::read_dir(dir).expect("Failed to read samples directory"); + let entries = fs::read_dir(dir).expect("Failed to read samples directory"); - let mut files = Vec::new(); - for entry in entries { - let entry = entry.expect("Failed to read directory entry"); - let path = entry.path(); + let mut files = Vec::new(); + for entry in entries { + let entry = entry.expect("Failed to read directory entry"); + let path = entry.path(); - if path - .extension() - .and_then(|s| s.to_str()) - == Some("tq") - { - files.push(path); - } - } - - assert!(!files.is_empty(), "No .tq files found in samples directory"); - - let mut failures = Vec::new(); - - for file in &files { - let content = parsing::load(&file) - .unwrap_or_else(|e| panic!("Failed to load file {:?}: {:?}", file, e)); - - match parsing::parse(&file, &content) { - Ok(_) => {} - Err(e) => { - println!("File {:?} failed to parse: {:?}", file, e); - failures.push(file.clone()); - } - } - } - - if !failures.is_empty() { - panic!( - "Sample files should parse successfully, but {} files failed", - failures.len() - ); + if path + .extension() + .and_then(|s| s.to_str()) + == Some("tq") + { + files.push(path); } } - #[test] - fn ensure_broken_fail() { - let dir = Path::new("tests/broken/"); + assert!(!files.is_empty(), "No .tq files found in samples directory"); - assert!(dir.exists(), "broken directory missing"); + let mut failures = Vec::new(); - let entries = fs::read_dir(dir).expect("Failed to read broken directory"); + for file in &files { + let content = parsing::load(&file) + .unwrap_or_else(|e| panic!("Failed to load file {:?}: {:?}", file, e)); - let mut files = Vec::new(); - for entry in entries { - let entry = entry.expect("Failed to read directory entry"); - let path = entry.path(); - - if path - .extension() - .and_then(|s| s.to_str()) - == Some("tq") - { - files.push(path); - } - } - - assert!(!files.is_empty(), "No .tq files found in broken directory"); - - let mut unexpected_successes = Vec::new(); - - for file in &files { - let content = parsing::load(&file) - .unwrap_or_else(|e| panic!("Failed to load file {:?}: {:?}", file, e)); - - match parsing::parse(&file, &content) { - Ok(_) => { - println!("File {:?} unexpectedly parsed successfully", file); - unexpected_successes.push(file.clone()); - } - Err(_) => {} + match parsing::parse(&file, &content) { + Ok(_) => {} + Err(e) => { + println!("File {:?} failed to parse: {:?}", file, e); + failures.push(file.clone()); } } + } - if !unexpected_successes.is_empty() { - panic!( - "Broken files should not to parse successfully, but {} files passed", - unexpected_successes.len() - ); - } + if !failures.is_empty() { + panic!( + "Sample files should parse successfully, but {} files failed", + failures.len() + ); } } diff --git a/tests/samples/RoastTurkey.tq b/tests/samples/RoastTurkey.tq new file mode 100644 index 0000000..322064e --- /dev/null +++ b/tests/samples/RoastTurkey.tq @@ -0,0 +1,8 @@ +roast_turkey(i) : Ingredients -> Turkey + +# Roast Turkey + + @chef + 1. Set oven temperature { (180 °C) ~ temp } + 2. Place bacon strips onto bird + 3. Put bird into oven