├── Cargo.toml ├── pest-test-gen ├── tests │ ├── csv.pest │ ├── csv │ │ └── whitespace.txt │ ├── pest │ │ ├── skip.txt │ │ └── example.txt │ ├── example.pest │ └── tests.rs ├── Cargo.toml └── src │ └── lib.rs ├── .gitignore ├── pest-test ├── Cargo.toml └── src │ ├── test.pest │ ├── lib.rs │ ├── parser.rs │ ├── model.rs │ └── diff.rs ├── .github └── workflows │ └── test.yml ├── LICENSE └── README.md /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = ["pest-test", "pest-test-gen"] 4 | -------------------------------------------------------------------------------- /pest-test-gen/tests/csv.pest: -------------------------------------------------------------------------------- 1 | field = { (ASCII_DIGIT | "." | "-")+ } 2 | record = { field ~ ("," ~ field)* } 3 | file = { SOI ~ NEWLINE? ~ (record ~ NEWLINE)* ~ EOI } 4 | -------------------------------------------------------------------------------- /pest-test-gen/tests/csv/whitespace.txt: -------------------------------------------------------------------------------- 1 | Test Preserves Trailing Whitespace 2 | 3 | +++++++++ 4 | 11,22 5 | 33,44 6 | 7 | +++++++++ 8 | 9 | (file 10 | (record 11 | (field: "11") 12 | (field: "22") 13 | ) 14 | (record 15 | (field: "33") 16 | (field: "44") 17 | ) 18 | ) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | **/target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | -------------------------------------------------------------------------------- /pest-test-gen/tests/pest/skip.txt: -------------------------------------------------------------------------------- 1 | Node Skipping 2 | ============= 3 | 4 | fn x() int { 5 | return 1; 6 | } 7 | 8 | ============= 9 | 10 | (source_file 11 | (function_definition 12 | (identifier: "x") 13 | (parameter_list) 14 | (primitive_type: "int") 15 | (block 16 | #[skip(depth = 1)] 17 | (return_statement 18 | (number: "1") 19 | ) 20 | ) 21 | ) 22 | ) -------------------------------------------------------------------------------- /pest-test-gen/tests/pest/example.txt: -------------------------------------------------------------------------------- 1 | My Test 2 | 3 | ======= 4 | 5 | fn x() int { 6 | return 1; 7 | } 8 | 9 | ======= 10 | 11 | (source_file 12 | (function_definition 13 | (identifier: "x") 14 | (parameter_list) 15 | (primitive_type: "int") 16 | (block 17 | (inner_block 18 | (return_statement 19 | (number: "1") 20 | ) 21 | ) 22 | ) 23 | ) 24 | ) -------------------------------------------------------------------------------- /pest-test/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pest-test" 3 | description = "Testing framework for pest parsers" 4 | authors = ["John Didion "] 5 | version = "0.1.6" 6 | keywords = ["parsing", "pest"] 7 | categories = ["parsing"] 8 | repository = "https://github.com/jdidion/pest-test" 9 | edition = "2021" 10 | license = "MIT" 11 | readme = "../README.md" 12 | 13 | [dependencies] 14 | colored = "2.0.4" 15 | pest = "2.5.2" 16 | pest_derive = "2.5.2" 17 | snailquote = "0.3.1" 18 | thiserror = "1.0.38" 19 | 20 | [dev-dependencies] 21 | indoc = "2.0.0" 22 | -------------------------------------------------------------------------------- /pest-test-gen/tests/example.pest: -------------------------------------------------------------------------------- 1 | source_file = { SOI ~ function_definition ~ EOI } 2 | function_definition = { fn_kw ~ identifier ~ lparen ~ parameter_list ~ rparen ~ primitive_type ~ block } 3 | fn_kw = _{ "fn" } 4 | identifier = @{ ASCII_ALPHA ~ ( ASCII_ALPHANUMERIC | "_" )* } 5 | lparen = _{ "(" } 6 | rparen = _{ ")" } 7 | parameter_list = { ( number ~ comma )* ~ number? } 8 | comma = _{ "," } 9 | primitive_type = { "int" } 10 | block = { lbrace ~ inner_block ~ rbrace } 11 | inner_block = { return_statement } 12 | lbrace = _{ "{" } 13 | rbrace = _{ "}" } 14 | return_statement = { return_kw ~ number ~ semi } 15 | return_kw = _{ "return" } 16 | number = @{ ASCII_DIGIT+ } 17 | semi = _{ ";" } 18 | WHITESPACE = _{ ( " " | "\t" | NEWLINE )+ } 19 | -------------------------------------------------------------------------------- /pest-test-gen/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pest-test-gen" 3 | description = "Macro for generating tests from pest-test test cases" 4 | authors = ["John Didion "] 5 | version = "0.1.7" 6 | keywords = ["parsing", "pest", "macro"] 7 | categories = ["parsing"] 8 | repository = "https://github.com/jdidion/pest-test" 9 | edition = "2021" 10 | license = "MIT" 11 | 12 | [lib] 13 | proc-macro = true 14 | 15 | [dependencies] 16 | pest-test = { version = "0.1.5", path = "../pest-test" } 17 | proc-macro2 = "1.0.51" 18 | syn = { version = "1.0.107", features = ["full"] } 19 | quote = "1.0.23" 20 | proc-macro-error = "1.0.4" 21 | walkdir = "2.3.2" 22 | 23 | [dev-dependencies] 24 | indoc = "2.0.0" 25 | lazy_static = "1.4.0" 26 | pest = "2.5.2" 27 | pest_derive = "2.5.2" -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: build and test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | types: [opened, reopened] 7 | 8 | env: 9 | CLICOLOR_FORCE: true 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout sources 17 | uses: actions/checkout@v4 18 | - uses: actions-rs/toolchain@v1 19 | with: 20 | toolchain: stable 21 | profile: minimal 22 | - uses: actions-rs/cargo@v1 23 | with: 24 | command: build 25 | - uses: actions-rs/cargo@v1 26 | with: 27 | command: test 28 | - uses: actions-rs/cargo@v1 29 | with: 30 | command: fmt 31 | args: --all -- --check 32 | - uses: actions-rs/cargo@v1 33 | with: 34 | command: clippy 35 | args: -- -D warnings 36 | 37 | -------------------------------------------------------------------------------- /pest-test/src/test.pest: -------------------------------------------------------------------------------- 1 | test_case = { SOI ~ test_name ~ code_block ~ expression ~ EOI } 2 | test_name = @{ ( !NEWLINE ~ ANY )+ } 3 | code_block = ${ PUSH(div) ~ code ~ POP } 4 | div = @{ ( !WHITESPACE ~ ANY ){3,} } 5 | code = @{ ( !PEEK ~ ANY )* } 6 | expression = { skip? ~ lparen ~ identifier ~ ( sub_expressions | ( colon ~ string ) )? ~ rparen } 7 | skip = { lmeta ~ skip_kw ~ lparen ~ depth_kw ~ assign ~ int ~ rparen ~ rmeta } 8 | skip_kw = _{ "skip" } 9 | lmeta = _{ "#[" } 10 | rmeta = _{ "]" } 11 | depth_kw = _{ "depth" } 12 | assign = _{ "=" } 13 | sub_expressions = { expression+ } 14 | lparen = _{ "(" } 15 | rparen = _{ ")" } 16 | identifier = @{ ASCII_ALPHA ~ ( ASCII_ALPHANUMERIC | "_" )* } 17 | colon = _{ ":" } 18 | string = ${ dquote ~ string_value ~ dquote } 19 | dquote = _{ "\"" } 20 | string_value = @{ ( "\\\"" | ( !"\"" ~ ANY ) )* } 21 | int = @{ ASCII_DIGIT+ } 22 | WHITESPACE = _{ ( " " | "\t" | NEWLINE )+ } 23 | -------------------------------------------------------------------------------- /pest-test-gen/tests/tests.rs: -------------------------------------------------------------------------------- 1 | use pest_test_gen::pest_tests; 2 | 3 | mod example { 4 | use pest_derive; 5 | 6 | #[derive(pest_derive::Parser)] 7 | #[grammar = "tests/example.pest"] 8 | pub struct ExampleParser; 9 | } 10 | 11 | #[pest_tests(super::example::ExampleParser, super::example::Rule, "source_file")] 12 | #[cfg(test)] 13 | mod example_test_cases {} 14 | 15 | #[pest_tests( 16 | super::example::ExampleParser, 17 | super::example::Rule, 18 | "source_file", 19 | lazy_static = true 20 | )] 21 | #[cfg(test)] 22 | mod example_test_cases_lazy_static {} 23 | 24 | mod csv { 25 | use pest_derive; 26 | 27 | #[derive(pest_derive::Parser)] 28 | #[grammar = "tests/csv.pest"] 29 | pub struct CsvParser; 30 | } 31 | 32 | #[pest_tests(super::csv::CsvParser, super::csv::Rule, "file", dir = "tests/csv")] 33 | #[cfg(test)] 34 | mod csv_test_cases {} 35 | 36 | #[pest_tests( 37 | super::csv::CsvParser, 38 | super::csv::Rule, 39 | "file", 40 | lazy_static = true, 41 | dir = "tests/csv" 42 | )] 43 | #[cfg(test)] 44 | mod csv_test_cases_lazy_static {} 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 John Didion 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pest-test/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod diff; 2 | pub mod model; 3 | mod parser; 4 | 5 | use crate::{ 6 | diff::ExpressionDiff, 7 | model::{Expression, ModelError, TestCase}, 8 | parser::{ParserError, Rule, TestParser}, 9 | }; 10 | use pest::{error::Error as PestError, Parser, RuleType}; 11 | use std::{ 12 | collections::HashSet, fs::read_to_string, io::Error as IOError, marker::PhantomData, 13 | path::PathBuf, 14 | }; 15 | use thiserror::Error; 16 | 17 | pub fn cargo_manifest_dir() -> PathBuf { 18 | PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap().as_str()) 19 | } 20 | 21 | pub fn default_test_dir() -> PathBuf { 22 | cargo_manifest_dir().join("tests").join("pest") 23 | } 24 | 25 | #[derive(Error, Debug)] 26 | pub enum TestError { 27 | #[error("Error reading test case from file")] 28 | IO { source: IOError }, 29 | #[error("Error parsing test case")] 30 | Parser { source: ParserError }, 31 | #[error("Error building model from test case parse tree")] 32 | Model { source: ModelError }, 33 | #[error("Error parsing code with target parser")] 34 | Target { source: Box> }, 35 | #[error("Expected and actual parse trees are different:\n{diff}")] 36 | Diff { diff: ExpressionDiff }, 37 | } 38 | 39 | pub struct PestTester> { 40 | test_dir: PathBuf, 41 | test_ext: String, 42 | rule: R, 43 | skip_rules: HashSet, 44 | parser: PhantomData

, 45 | } 46 | 47 | impl> PestTester { 48 | /// Creates a new `PestTester` that looks for tests in `test_dir` and having file extension 49 | /// `test_ext`. Code is parsed beinning at `rule` and the rules in `skip_rule` are ignored 50 | /// when comparing to the expected expression. 51 | pub fn new, S: AsRef>( 52 | test_dir: D, 53 | test_ext: S, 54 | rule: R, 55 | skip_rules: HashSet, 56 | ) -> Self { 57 | Self { 58 | test_dir: test_dir.into(), 59 | test_ext: test_ext.as_ref().to_owned(), 60 | rule, 61 | skip_rules, 62 | parser: PhantomData::

, 63 | } 64 | } 65 | 66 | /// Creates a new `PestTester` that looks for tests in `/tests/pest` and having 67 | /// file extension ".txt". Code is parsed beinning at `rule` and the rules in `skip_rule` are 68 | /// ignored when comparing to the expected expression. 69 | pub fn from_defaults(rule: R, skip_rules: HashSet) -> Self { 70 | Self::new(default_test_dir(), ".txt", rule, skip_rules) 71 | } 72 | 73 | /// Evaluates the test with the given name. If `ignore_missing_expected_values` is true, then 74 | /// the test is not required to specify values for non-terminal nodes. 75 | pub fn evaluate>( 76 | &self, 77 | name: N, 78 | ignore_missing_expected_values: bool, 79 | ) -> Result<(), TestError> { 80 | let path = self 81 | .test_dir 82 | .join(format!("{}.{}", name.as_ref(), self.test_ext)); 83 | let text = read_to_string(path).map_err(|source| TestError::IO { source })?; 84 | let pair = 85 | TestParser::parse(text.as_ref()).map_err(|source| TestError::Parser { source })?; 86 | let test_case = 87 | TestCase::try_from_pair(pair).map_err(|source| TestError::Model { source })?; 88 | let code_pair = 89 | parser::parse(test_case.code.as_ref(), self.rule, self.parser).map_err(|source| { 90 | match source { 91 | ParserError::Empty => TestError::Parser { 92 | source: ParserError::Empty, 93 | }, 94 | ParserError::Pest { source } => TestError::Target { source }, 95 | } 96 | })?; 97 | let code_expr = Expression::try_from_code(code_pair, &self.skip_rules) 98 | .map_err(|source| TestError::Model { source })?; 99 | match ExpressionDiff::from_expressions( 100 | &test_case.expression, 101 | &code_expr, 102 | ignore_missing_expected_values, 103 | ) { 104 | ExpressionDiff::Equal(_) => Ok(()), 105 | diff => Err(TestError::Diff { diff }), 106 | } 107 | } 108 | 109 | /// Equivalent to `self.evaluate(name, true) 110 | pub fn evaluate_strict>(&self, name: N) -> Result<(), TestError> { 111 | self.evaluate(name, false) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pest-test 2 | 3 | Testing framework for [pest parser](https://pest.rs) (similar to `tree-sitter test`). 4 | 5 | ## Test cases 6 | 7 | A test case is a text file with three sections: 8 | 9 | * The test name must be on the first line of the file. It may contain any characters except newline. 10 | * The source code block is delimited by any sequence of three or more non-whitespace characters. The same sequence of characters must preceed and follow the code block, and that sequence of characters may not appear anywhere within the code block. 11 | * The code block must be both preceeded and followed by a line separator. The outer-most line separators are trimmed off; any remaining line separators are left in tact. This means that if your parser is sensitive to leading/trailing whitespace, you must make sure to put the correct number of empty lines before/after the code block. 12 | * The expected output syntax tree written as an [S-expression](https://en.wikipedia.org/wiki/S-expression). Optionally, a terminal node may be followed by its expected string value. Expected string values may contain escape characters - they are unescaped prior to comparison to the actual values. 13 | 14 | Here is an example test. Note that the code block delimiter is exactly 7 `=` characters. In this case, the parser ignores implicit whitespace, so the numbers of blank lines before/after the code are arbitrary. 15 | 16 | ``` 17 | My Test 18 | ======= 19 | 20 | fn x() int { 21 | return 1; 22 | } 23 | 24 | ======= 25 | 26 | (source_file 27 | (function_definition 28 | (identifier: "x") 29 | (parameter_list) 30 | (primitive_type: "int") 31 | (block 32 | (return_statement 33 | (number: "1") 34 | ) 35 | ) 36 | ) 37 | ) 38 | 39 | ``` 40 | 41 | ### Attributes 42 | 43 | Nodes in the expected output S-expression may be annotated with attributes of the form `#[name(args)]`. The currently recognized attributes are: 44 | 45 | * `skip`: For some grammars, there are multiple levels of nesting that are only necessary to work around the limitations of PEG parsers, e.g., mathematical expressions with left-recursion. To simplify test cases involving such grammars, the `skip` attribute can be used to ignore a specified number of levels of nesting in the actual parse tree. 46 | ``` 47 | (expression 48 | #[skip(depth = 3)] 49 | (sum 50 | (number: 1) 51 | (number: 2) 52 | ) 53 | ) 54 | ``` 55 | 56 | ## Usage 57 | 58 | The main interface to the test framework is `pest_test::PestTester`. By default, tests are assumed to be in the `tests/pest` directory of your crate and have a `.txt` file extension. The example below shows using the `lazy_static` macro to create a single `PestTester` instance and then using it to evaluate any number of tests. 59 | 60 | ```rust 61 | #[cfg(test)] 62 | mod tests { 63 | use mycrate::parser::{MyParser, Rule}; 64 | use lazy_static::lazy_static; 65 | use pest_test::{Error, PestTester}; 66 | 67 | lazy_static! { 68 | static ref TESTER: PestTester = 69 | PestTester::from_defaults(Rule::root_rule); 70 | } 71 | 72 | // Loads test from `tests/pest/mytest.txt` and evaluates it. Returns an `Err` 73 | // if there was an error evaluating the test, or if the expected and actual values do not match. 74 | fn test_my_parser -> Result<(), Error> { 75 | (*TESTER).evaluate_strict("mytest") 76 | } 77 | } 78 | ``` 79 | 80 | If you add `pest-test-gen` as a dev dependency, then you can use the `pest_tests` attribute macro to generate tests for all your test cases: 81 | 82 | ```rust 83 | // Generate tests for all test cases in tests/pest/foo/ and all subdirectories. Since 84 | // `lazy_static = true`, a single `PestTester` is created and used by all tests; otherwise a new 85 | // `PestTester` would be created for each test. 86 | #[pest_tests( 87 | mycrate::parser::MyParser, 88 | mycrate::parser::Rule, 89 | "root_rule", 90 | subdir = "foo", 91 | recursive = true, 92 | lazy_static = true, 93 | )] 94 | #[cfg(test)] 95 | mod foo_tests {} 96 | ``` 97 | 98 | To disable colorization of the diff output, run cargo with `CARGO_TERM_COLOR=never`. 99 | 100 | Note that a test module is only recompiled when its code changes. Thus, if you add or rename a test case in `tests/pest` without changing the test module, the test module might not get updated to include the new/renamed tests, so you may need to delete the `target` folder to force your tests to be recompiled. 101 | 102 | ## Details 103 | 104 | Test files are parsed using pest. The source code is parsed using your pest grammar, and the resulting tree is iterated exhaustively to build up a nested data structure, which is then matched to the same structure built from the expected output. If they don't match, the tree is printed with the differences in-line. 105 | -------------------------------------------------------------------------------- /pest-test/src/parser.rs: -------------------------------------------------------------------------------- 1 | use pest::{error::Error as PestError, iterators::Pair, Parser, RuleType}; 2 | use std::marker::PhantomData; 3 | use thiserror::Error; 4 | 5 | #[derive(Error, Debug)] 6 | pub enum ParserError { 7 | #[error("Error parsing source text")] 8 | Pest { source: Box> }, 9 | #[error("Empty parse tree")] 10 | Empty, 11 | } 12 | 13 | pub fn parse>( 14 | text: &str, 15 | rule: R, 16 | _: PhantomData

, 17 | ) -> Result, ParserError> { 18 | P::parse(rule, text) 19 | .map_err(|source| ParserError::Pest { 20 | source: Box::new(source), 21 | }) 22 | .and_then(|mut code_pairs| code_pairs.next().ok_or(ParserError::Empty)) 23 | } 24 | 25 | #[derive(pest_derive::Parser)] 26 | #[grammar = "test.pest"] 27 | pub struct TestParser; 28 | 29 | impl TestParser { 30 | pub fn parse(text: &str) -> Result, ParserError> { 31 | parse(text, Rule::test_case, PhantomData::) 32 | } 33 | } 34 | 35 | #[cfg(test)] 36 | mod tests { 37 | use super::{ParserError, Rule, TestParser}; 38 | use indoc::indoc; 39 | use pest::iterators::Pairs; 40 | 41 | fn assert_nonterminal<'a>(pairs: &mut Pairs<'a, Rule>, expected_name: &str) -> Pairs<'a, Rule> { 42 | let expression = pairs.next().expect("Missing expression"); 43 | assert_eq!(expression.as_rule(), Rule::expression); 44 | let mut pairs = expression.into_inner(); 45 | let rule_name = pairs.next().expect("Missing identifier"); 46 | assert_eq!(rule_name.as_rule(), Rule::identifier); 47 | assert_eq!(rule_name.as_str(), expected_name); 48 | let subexpressions = pairs.next().expect("Missing subexpressions"); 49 | assert_eq!(subexpressions.as_rule(), Rule::sub_expressions); 50 | subexpressions.into_inner() 51 | } 52 | 53 | fn assert_terminal<'a>( 54 | pairs: &mut Pairs<'a, Rule>, 55 | expected_name: &str, 56 | expected_value: Option<&str>, 57 | ) { 58 | let expression = pairs.next().expect("Missing expression"); 59 | assert_eq!(expression.as_rule(), Rule::expression); 60 | let mut pairs = expression.into_inner(); 61 | let rule_name = pairs.next().expect("Missing identifier"); 62 | assert_eq!(rule_name.as_rule(), Rule::identifier); 63 | assert_eq!(rule_name.as_str(), expected_name); 64 | match (pairs.next(), expected_value) { 65 | (Some(value_str), Some(expected)) => { 66 | assert_eq!(value_str.as_rule(), Rule::string); 67 | assert_eq!(value_str.as_rule(), Rule::string); 68 | let mut pairs = value_str.into_inner(); 69 | let value = pairs.next().expect("Missing value"); 70 | assert_eq!(value.as_rule(), Rule::string_value); 71 | assert_eq!(value.as_str(), expected); 72 | } 73 | (Some(value_str), None) => panic!( 74 | "Terminal node has value {:?} but there is no expected value", 75 | value_str 76 | ), 77 | (None, Some(expected)) => { 78 | panic!("Terminal node has no value but expected {expected}") 79 | } 80 | _ => (), 81 | } 82 | } 83 | 84 | #[test] 85 | fn test_parse() -> Result<(), ParserError> { 86 | let text = indoc! {r#" 87 | My Test 88 | 89 | ======= 90 | 91 | fn x() int { 92 | return 1; 93 | } 94 | 95 | ======= 96 | 97 | (source_file 98 | (function_definition 99 | (identifier: "x") 100 | (parameter_list) 101 | (primitive_type: "int") 102 | (block 103 | (return_statement 104 | (number: "1") 105 | ) 106 | ) 107 | ) 108 | ) 109 | "#}; 110 | 111 | let root = TestParser::parse(text)?; 112 | let mut root_pairs = root.into_inner(); 113 | let test_name = root_pairs.next().expect("Missing test name"); 114 | assert_eq!(test_name.as_rule(), Rule::test_name); 115 | assert_eq!(test_name.as_str().trim(), "My Test"); 116 | let code_block = root_pairs.next().expect("Missing code"); 117 | assert_eq!(code_block.as_rule(), Rule::code_block); 118 | let mut pairs = code_block.into_inner(); 119 | let div = pairs.next().expect("Missing div"); 120 | assert_eq!(div.as_rule(), Rule::div); 121 | assert_eq!(div.as_str().trim(), "======="); 122 | let code = pairs.next().expect("Missing code"); 123 | assert_eq!(code.as_rule(), Rule::code); 124 | assert_eq!(code.as_str().trim(), "fn x() int {\n return 1;\n}"); 125 | let mut pairs = assert_nonterminal(&mut root_pairs, "source_file"); 126 | let mut pairs = assert_nonterminal(&mut pairs, "function_definition"); 127 | assert_terminal(&mut pairs, "identifier", Some("x")); 128 | assert_terminal(&mut pairs, "parameter_list", None); 129 | assert_terminal(&mut pairs, "primitive_type", Some("int")); 130 | let mut pairs = assert_nonterminal(&mut pairs, "block"); 131 | let mut pairs = assert_nonterminal(&mut pairs, "return_statement"); 132 | assert_terminal(&mut pairs, "number", Some("1")); 133 | Ok(()) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /pest-test-gen/src/lib.rs: -------------------------------------------------------------------------------- 1 | use proc_macro::TokenStream; 2 | use proc_macro2::Span; 3 | use proc_macro_error::{abort, abort_call_site, proc_macro_error}; 4 | use quote::{format_ident, quote, ToTokens}; 5 | use std::{borrow::Cow, path::PathBuf}; 6 | use syn::{ 7 | parse_macro_input, AttributeArgs, Ident, Item, ItemMod, Lit, Meta, MetaList, MetaNameValue, 8 | NestedMeta, Path, PathArguments, PathSegment, 9 | }; 10 | use walkdir::WalkDir; 11 | 12 | struct Args { 13 | parser_path: Path, 14 | rule_path: Path, 15 | rule_ident: Ident, 16 | skip_rules: Vec, 17 | dir: PathBuf, 18 | subdir: Option, 19 | ext: String, 20 | recursive: bool, 21 | strict: bool, 22 | no_eoi: bool, 23 | lazy_static: bool, 24 | } 25 | 26 | impl Args { 27 | fn from(attr_args: Vec) -> Self { 28 | let mut attr_args_iter = attr_args.into_iter(); 29 | 30 | // process required attrs 31 | let parser_path = match attr_args_iter.next() { 32 | Some(NestedMeta::Meta(Meta::Path(path))) => path, 33 | Some(other) => abort!(other, "Invalid parser type"), 34 | None => abort_call_site!("Missing required argument "), 35 | }; 36 | let rule_path = match attr_args_iter.next() { 37 | Some(NestedMeta::Meta(Meta::Path(path))) => path, 38 | Some(other) => abort!(other, "Invalid rule"), 39 | None => abort_call_site!("Missing required argument "), 40 | }; 41 | let rule_ident = match attr_args_iter.next() { 42 | Some(NestedMeta::Lit(Lit::Str(s))) => Ident::new(s.value().as_ref(), Span::call_site()), 43 | Some(other) => abort!(other, "Invalid rule name"), 44 | None => abort_call_site!("Missing required argument "), 45 | }; 46 | 47 | let mut args = Args { 48 | parser_path, 49 | rule_path, 50 | rule_ident, 51 | skip_rules: Vec::new(), 52 | dir: pest_test::default_test_dir(), 53 | subdir: None, 54 | ext: String::from("txt"), 55 | recursive: false, 56 | strict: true, 57 | no_eoi: false, 58 | lazy_static: false, 59 | }; 60 | 61 | // process optional attrs 62 | for arg in attr_args_iter { 63 | match arg { 64 | NestedMeta::Meta(Meta::NameValue(MetaNameValue { 65 | path, 66 | eq_token: _, 67 | lit, 68 | })) => { 69 | let attr_name = path 70 | .get_ident() 71 | .unwrap_or_else(|| abort!(path, "Invalid argument to pest_test_gen macro")) 72 | .to_string(); 73 | match attr_name.as_str() { 74 | "dir" => { 75 | let mut path = match lit { 76 | Lit::Str(s) => PathBuf::from(s.value()), 77 | _ => abort!(lit, "Invalid argument to 'dir' attribute"), 78 | }; 79 | if path.is_relative() { 80 | path = pest_test::cargo_manifest_dir().join(path) 81 | } 82 | args.dir = path 83 | } 84 | "subdir" => { 85 | args.subdir = match lit { 86 | Lit::Str(s) => Some(PathBuf::from(s.value())), 87 | _ => abort!(lit, "Invalid argument to 'subdir' attribute"), 88 | } 89 | } 90 | "ext" => { 91 | args.ext = match lit { 92 | Lit::Str(s) => s.value(), 93 | _ => abort!(lit, "Invalid argument to 'ext' attribute"), 94 | } 95 | } 96 | "recursive" => { 97 | args.recursive = match lit { 98 | Lit::Bool(b) => b.value, 99 | _ => abort!(lit, "Invalid argument to 'recursive' attribute"), 100 | } 101 | } 102 | "strict" => { 103 | args.strict = match lit { 104 | Lit::Bool(b) => b.value, 105 | _ => abort!(lit, "Invalid argument to 'strict' attribute"), 106 | } 107 | } 108 | "no_eoi" => { 109 | args.no_eoi = match lit { 110 | Lit::Bool(b) => b.value, 111 | _ => abort!(lit, "Invalid argument to 'no_eoi' attribute"), 112 | } 113 | } 114 | "lazy_static" => { 115 | args.lazy_static = match lit { 116 | Lit::Bool(b) => b.value, 117 | _ => abort!(lit, "Invalid argument to 'lazy_static' attribute"), 118 | } 119 | } 120 | _ => abort!(path, "Invalid argument to pest_test_gen macro"), 121 | } 122 | } 123 | NestedMeta::Meta(Meta::List(MetaList { 124 | path, 125 | paren_token: _, 126 | nested, 127 | })) => { 128 | let attr_name = path 129 | .get_ident() 130 | .unwrap_or_else(|| abort!(path, "Invalid argument to pest_test_gen macro")) 131 | .to_string(); 132 | if attr_name == "skip_rule" { 133 | for rule_meta in nested { 134 | match rule_meta { 135 | NestedMeta::Lit(Lit::Str(s)) => { 136 | let rule_name = s.value(); 137 | args.skip_rules 138 | .push(Ident::new(rule_name.as_ref(), Span::call_site())); 139 | // if EOI is added manually, don't add it again automatically 140 | if rule_name == "EOI" { 141 | args.no_eoi = true; 142 | } 143 | } 144 | _ => abort!(rule_meta, "Invalid skip_rule item"), 145 | } 146 | } 147 | } else { 148 | abort!(path, "Invalid argument to pest_test_gen macro"); 149 | } 150 | } 151 | _ => abort!(arg, "Invalid argument to pest_test_gen macro"), 152 | } 153 | } 154 | 155 | args 156 | } 157 | 158 | fn iter_tests(&self) -> impl Iterator + '_ { 159 | let dir = self 160 | .subdir 161 | .as_ref() 162 | .map(|subdir| Cow::Owned(self.dir.join(subdir))) 163 | .unwrap_or(Cow::Borrowed(&self.dir)); 164 | let mut walker = WalkDir::new(dir.as_ref()); 165 | if !self.recursive { 166 | walker = walker.max_depth(1); 167 | } 168 | walker 169 | .into_iter() 170 | .filter_map(|entry| entry.ok()) 171 | .filter(|entry| { 172 | let path = entry.path(); 173 | if path.is_dir() { 174 | false 175 | } else if self.ext.is_empty() { 176 | path.extension().is_none() 177 | } else { 178 | entry.path().extension() == Some(self.ext.as_ref()) 179 | } 180 | }) 181 | .map(move |entry| { 182 | entry 183 | .path() 184 | .strip_prefix(dir.as_ref()) 185 | .expect("Error getting relative path of {:?}") 186 | .with_extension("") 187 | .as_os_str() 188 | .to_str() 189 | .unwrap() 190 | .to_owned() 191 | }) 192 | } 193 | } 194 | 195 | fn rule_variant(rule_path: &Path, variant_ident: Ident) -> Path { 196 | let mut path = rule_path.clone(); 197 | path.segments.push(PathSegment { 198 | ident: variant_ident, 199 | arguments: PathArguments::None, 200 | }); 201 | path 202 | } 203 | 204 | fn add_tests(module: &mut ItemMod, args: &Args) { 205 | let (_, content) = module.content.get_or_insert_with(Default::default); 206 | 207 | let test_dir = args.dir.as_os_str().to_str().unwrap().to_owned(); 208 | let test_ext = args.ext.clone(); 209 | let parser_path = &args.parser_path; 210 | let rule_path = &args.rule_path; 211 | let rule_ident = &args.rule_ident; 212 | let mut skip_rules: Vec = args 213 | .skip_rules 214 | .iter() 215 | .map(|ident| rule_variant(rule_path, ident.clone())) 216 | .collect(); 217 | if !args.no_eoi { 218 | skip_rules.push(rule_variant( 219 | rule_path, 220 | Ident::new("EOI", Span::call_site()), 221 | )); 222 | } 223 | 224 | if args.lazy_static { 225 | let lazy_static_tokens = quote! { 226 | lazy_static::lazy_static! { 227 | static ref COLORIZE: bool = { 228 | option_env!("CARGO_TERM_COLOR").unwrap_or("always") != "never" 229 | }; 230 | static ref TESTER: pest_test::PestTester<#rule_path, #parser_path> = 231 | pest_test::PestTester::new( 232 | #test_dir, 233 | #test_ext, 234 | #rule_path::#rule_ident, 235 | std::collections::HashSet::from([#(#skip_rules),*]) 236 | ); 237 | } 238 | }; 239 | let item: Item = match syn::parse2(lazy_static_tokens) { 240 | Ok(item) => item, 241 | Err(err) => abort_call_site!(format!("Error generating lazy_static block: {:?}", err)), 242 | }; 243 | content.push(item); 244 | } 245 | 246 | for test_name in args.iter_tests() { 247 | let fn_name = format_ident!("test_{}", test_name); 248 | let fn_tokens = if args.lazy_static { 249 | quote! { 250 | #[test] 251 | fn #fn_name() -> Result<(), pest_test::TestError<#rule_path>> { 252 | let res = (*TESTER).evaluate_strict(#test_name); 253 | if let Err(pest_test::TestError::Diff { ref diff }) = res { 254 | diff.print_test_result(*COLORIZE).unwrap(); 255 | } 256 | res 257 | } 258 | } 259 | } else { 260 | quote! { 261 | #[test] 262 | fn #fn_name() -> Result<(), pest_test::TestError<#rule_path>> { 263 | let tester: pest_test::PestTester<#rule_path, #parser_path> = pest_test::PestTester::new( 264 | #test_dir, 265 | #test_ext, 266 | #rule_path::#rule_ident, 267 | std::collections::HashSet::from([#(#skip_rules),*]) 268 | ); 269 | let res = tester.evaluate_strict(#test_name); 270 | if let Err(pest_test::TestError::Diff { ref diff }) = res { 271 | let colorize = option_env!("CARGO_TERM_COLOR").unwrap_or("always") != "never"; 272 | diff.print_test_result(colorize).unwrap(); 273 | } 274 | res 275 | } 276 | } 277 | }; 278 | let item: Item = match syn::parse2(fn_tokens) { 279 | Ok(item) => item, 280 | Err(err) => { 281 | abort_call_site!(format!("Error generating test fn {}: {:?}", test_name, err)) 282 | } 283 | }; 284 | content.push(item); 285 | } 286 | } 287 | 288 | /// When added to a test module, adds test functions for pest-test test cases. Must come before 289 | /// the `#[cfg(test)]` attribute. If you specify `lazy_static = true` then a singleton `PestTester` 290 | /// is created and used by all the generated test functions (dependency on `lazy_static` is 291 | /// required), otherwise a separate instance is created for each test. 292 | /// 293 | /// # Arguments: 294 | /// * **parser_type**: (required) the full path to the struct you defined that derives `pest::Parser`, 295 | /// e.g. `mycrate::parser::MyParser`. 296 | /// * **rule_type**: (required) the full path to the `Rule` enum, e.g. `mycrate::parser::Rule`. 297 | /// * **rule_name**: (required) the name of the `Rule` variant from which to start parsing. 298 | /// * skip_rules: (optional) a list of names of rules to skip when parsing; by default `Rule::EOI` is 299 | /// skipped unless `no_eoi = true`. 300 | /// * no_eoi: (optional) there is no `Rule::EOI` - don't automatically add it to `skip_rules`. 301 | /// * dir: (optional) the root directory where pest test cases are found; defaults to 'tests/pest'. 302 | /// * subdir: (optional) the subdirectory under `tests/pest` in which to look for test cases; 303 | /// defaults to "". 304 | /// * ext: (optional) the file extension of pest test cases; defaults to "txt". 305 | /// * recursive: (optional) whether to search for tests cases recursively under `{dir}/{subdir}`; 306 | /// defaults to `false`. 307 | /// * strict: (optional) whether to enforce that terminal node values must match between the 308 | /// expected and actual parse trees; defaults to `true`. 309 | /// * lazy_static: (optional) whether to create a singleton `PestTester` - requires dependency on 310 | /// `lazy_static`; defaults to `false`. 311 | /// 312 | /// # Example: 313 | /// ``` 314 | /// 315 | /// use pest_test_gen; 316 | /// 317 | /// #[pest_tests( 318 | /// mycrate::parser::MyParser, 319 | /// mycrate::parser::Rule, 320 | /// "root_rule", 321 | /// skip_rules("comment"), 322 | /// subdir = "foo", 323 | /// recursive = true, 324 | /// lazy_static = true 325 | /// )] 326 | /// #[cfg(test)] 327 | /// mod parser_tests {} 328 | /// 329 | /// ``` 330 | 331 | #[proc_macro_attribute] 332 | #[proc_macro_error] 333 | pub fn pest_tests(attr: TokenStream, item: TokenStream) -> TokenStream { 334 | let args = Args::from(parse_macro_input!(attr as AttributeArgs)); 335 | let mut module = match parse_macro_input!(item as Item) { 336 | Item::Mod(module) => module, 337 | other => abort!( 338 | other, 339 | "The pest_test_gen macro may only be used as an attribute on a module" 340 | ), 341 | }; 342 | add_tests(&mut module, &args); 343 | module.to_token_stream().into() 344 | } 345 | -------------------------------------------------------------------------------- /pest-test/src/model.rs: -------------------------------------------------------------------------------- 1 | use crate::parser::Rule; 2 | use colored::{Color, Colorize}; 3 | use pest::{iterators::Pair, RuleType}; 4 | use snailquote::unescape; 5 | use std::{ 6 | collections::HashSet, 7 | fmt::{Display, Result as FmtResult, Write}, 8 | }; 9 | use thiserror::Error; 10 | 11 | #[derive(Error, Debug)] 12 | #[error("Error creating model element from parser pair")] 13 | pub struct ModelError(String); 14 | 15 | impl ModelError { 16 | fn from_str(msg: &str) -> Self { 17 | Self(msg.to_owned()) 18 | } 19 | } 20 | 21 | fn assert_rule(pair: Pair<'_, Rule>, rule: Rule) -> Result, ModelError> { 22 | if pair.as_rule() == rule { 23 | Ok(pair) 24 | } else { 25 | Err(ModelError(format!( 26 | "Expected pair {:?} rule to be {:?}", 27 | pair, rule 28 | ))) 29 | } 30 | } 31 | 32 | #[derive(Clone, Debug)] 33 | pub enum Expression { 34 | Terminal { 35 | name: String, 36 | value: Option, 37 | }, 38 | NonTerminal { 39 | name: String, 40 | children: Vec, 41 | }, 42 | Skip { 43 | depth: usize, 44 | next: Box, 45 | }, 46 | } 47 | 48 | impl Expression { 49 | pub fn try_from_sexpr(pair: Pair<'_, Rule>) -> Result { 50 | let mut inner = pair.into_inner(); 51 | let skip_depth: usize = if inner.peek().map(|pair| pair.as_rule()) == Some(Rule::skip) { 52 | let depth_pair = inner 53 | .next() 54 | .unwrap() 55 | .into_inner() 56 | .next() 57 | .ok_or_else(|| ModelError::from_str("Missing skip depth")) 58 | .and_then(|pair| assert_rule(pair, Rule::int))?; 59 | depth_pair 60 | .as_str() 61 | .parse() 62 | .map_err(|err| ModelError(format!("Error parsing skip depth: {:?}", err)))? 63 | } else { 64 | 0 65 | }; 66 | let name = inner 67 | .next() 68 | .ok_or_else(|| ModelError::from_str("Missing rule name")) 69 | .and_then(|pair| assert_rule(pair, Rule::identifier)) 70 | .map(|pair| pair.as_str().to_owned())?; 71 | let expr = match inner.next() { 72 | None => Self::Terminal { name, value: None }, 73 | Some(pair) => match pair.as_rule() { 74 | Rule::sub_expressions => { 75 | let children: Result, ModelError> = 76 | pair.into_inner().map(Self::try_from_sexpr).collect(); 77 | Self::NonTerminal { 78 | name, 79 | children: children?, 80 | } 81 | } 82 | Rule::string => { 83 | let s = pair.as_str().trim(); 84 | let value = Some(unescape(s).map_err(|err| { 85 | ModelError(format!("Error unescaping string value {}: {:?}", s, err)) 86 | })?); 87 | Self::Terminal { name, value } 88 | } 89 | other => return Err(ModelError(format!("Unexpected rule {:?}", other))), 90 | }, 91 | }; 92 | if skip_depth == 0 { 93 | Ok(expr) 94 | } else { 95 | Ok(Self::Skip { 96 | depth: skip_depth, 97 | next: Box::new(expr), 98 | }) 99 | } 100 | } 101 | 102 | pub fn try_from_code( 103 | pair: Pair<'_, R>, 104 | skip_rules: &HashSet, 105 | ) -> Result { 106 | let name = format!("{:?}", pair.as_rule()); 107 | let value = pair.as_str(); 108 | let children: Result, ModelError> = pair 109 | .into_inner() 110 | .filter(|pair| !skip_rules.contains(&pair.as_rule())) 111 | .map(|pair| Self::try_from_code(pair, skip_rules)) 112 | .collect(); 113 | match children { 114 | Ok(children) if children.is_empty() => Ok(Self::Terminal { 115 | name, 116 | value: Some(value.to_owned()), 117 | }), 118 | Ok(children) => Ok(Self::NonTerminal { name, children }), 119 | Err(e) => Err(e), 120 | } 121 | } 122 | 123 | pub fn name(&self) -> &String { 124 | match self { 125 | Self::Terminal { name, value: _ } => name, 126 | Self::NonTerminal { name, children: _ } => name, 127 | Self::Skip { depth: _, next } => next.name(), 128 | } 129 | } 130 | 131 | pub fn skip_depth(&self) -> usize { 132 | match self { 133 | Expression::Skip { depth, next: _ } => *depth, 134 | _ => 0, 135 | } 136 | } 137 | 138 | /// Returns the `Nth` descendant of this expression, where `N = depth`. For a 139 | /// `NonTerminal` expression, the descendant is its first child. For a `Terminal` node, there 140 | /// is no descendant. 141 | pub fn get_descendant(&self, depth: usize) -> Option<&Expression> { 142 | if depth > 0 { 143 | match self { 144 | Self::NonTerminal { name: _, children } if !children.is_empty() => { 145 | children.first().unwrap().get_descendant(depth - 1) 146 | } 147 | Self::Skip { 148 | depth: skip_depth, 149 | next, 150 | } if *skip_depth <= depth => next.as_ref().get_descendant(depth - skip_depth), 151 | _ => None, 152 | } 153 | } else { 154 | Some(self) 155 | } 156 | } 157 | } 158 | 159 | pub struct ExpressionFormatter<'a> { 160 | writer: &'a mut dyn Write, 161 | indent: &'a str, 162 | pub(crate) level: usize, 163 | pub(crate) color: Option, 164 | buffering: bool, 165 | } 166 | 167 | impl<'a> ExpressionFormatter<'a> { 168 | pub fn from_defaults(writer: &'a mut dyn Write) -> Self { 169 | Self { 170 | writer, 171 | indent: " ", 172 | level: 0, 173 | color: None, 174 | buffering: true, 175 | } 176 | } 177 | 178 | pub(crate) fn write_indent(&mut self) -> FmtResult { 179 | for _ in 0..self.level { 180 | self.writer.write_str(self.indent)?; 181 | } 182 | Ok(()) 183 | } 184 | 185 | pub(crate) fn write_newline(&mut self) -> FmtResult { 186 | self.writer.write_char('\n') 187 | } 188 | 189 | pub(crate) fn write_char(&mut self, c: char) -> FmtResult { 190 | match self.color { 191 | Some(color) => self 192 | .writer 193 | .write_str(format!("{}", c.to_string().color(color)).as_ref()), 194 | None => self.writer.write_char(c), 195 | } 196 | } 197 | 198 | pub(crate) fn write_str(&mut self, s: &str) -> FmtResult { 199 | match self.color { 200 | Some(color) => self 201 | .writer 202 | .write_str(format!("{}", s.color(color)).as_ref()), 203 | None => self.writer.write_str(s), 204 | } 205 | } 206 | 207 | fn fmt_buffered(&mut self, expression: &Expression) -> FmtResult { 208 | let mut buf = String::with_capacity(1024); 209 | let mut string_formatter = ExpressionFormatter { 210 | writer: &mut buf, 211 | indent: self.indent, 212 | level: self.level, 213 | color: None, 214 | buffering: false, 215 | }; 216 | string_formatter.fmt(expression)?; 217 | self.write_str(buf.as_ref())?; 218 | Ok(()) 219 | } 220 | 221 | fn fmt_unbuffered(&mut self, expression: &Expression) -> FmtResult { 222 | self.write_indent()?; 223 | match expression { 224 | Expression::Terminal { name, value } => { 225 | self.write_char('(')?; 226 | self.write_str(name)?; 227 | if let Some(value) = value { 228 | self.write_str(": \"")?; 229 | self.write_str(&value.escape_default().to_string())?; 230 | self.write_char('"')?; 231 | } 232 | self.write_char(')')?; 233 | } 234 | Expression::NonTerminal { name, children } if children.is_empty() => { 235 | self.write_char('(')?; 236 | self.write_str(name)?; 237 | self.write_char(')')?; 238 | } 239 | Expression::NonTerminal { name, children } => { 240 | self.write_char('(')?; 241 | self.write_str(name)?; 242 | self.write_newline()?; 243 | self.level += 1; 244 | for child in children { 245 | self.fmt(child)?; 246 | self.write_newline()?; 247 | } 248 | self.level -= 1; 249 | self.write_indent()?; 250 | self.write_char(')')?; 251 | } 252 | Expression::Skip { depth, next } => { 253 | self.write_str(format!("#[skip(depth = {})]", depth).as_ref())?; 254 | self.write_newline()?; 255 | self.fmt_unbuffered(next.as_ref())?; 256 | } 257 | } 258 | Ok(()) 259 | } 260 | 261 | pub fn fmt(&mut self, expression: &Expression) -> FmtResult { 262 | if self.buffering { 263 | self.fmt_buffered(expression) 264 | } else { 265 | self.fmt_unbuffered(expression) 266 | } 267 | } 268 | } 269 | 270 | impl Display for Expression { 271 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> FmtResult { 272 | ExpressionFormatter::from_defaults(f).fmt(self) 273 | } 274 | } 275 | 276 | #[derive(Clone, Debug)] 277 | pub struct TestCase { 278 | pub name: String, 279 | pub code: String, 280 | pub expression: Expression, 281 | } 282 | 283 | impl TestCase { 284 | pub fn try_from_pair(pair: Pair<'_, Rule>) -> Result { 285 | let mut inner = pair.into_inner(); 286 | let name = inner 287 | .next() 288 | .ok_or_else(|| ModelError::from_str("Missing test name")) 289 | .and_then(|pair| assert_rule(pair, Rule::test_name)) 290 | .map(|pair| pair.as_str().trim().to_owned())?; 291 | let mut code_block = inner 292 | .next() 293 | .ok_or_else(|| ModelError::from_str("Missing code block")) 294 | .and_then(|pair| assert_rule(pair, Rule::code_block)) 295 | .map(|pair| pair.into_inner())?; 296 | code_block 297 | .next() 298 | .ok_or_else(|| ModelError::from_str("Missing div")) 299 | .and_then(|pair| assert_rule(pair, Rule::div))?; 300 | let code_untrimmed = code_block 301 | .next() 302 | .ok_or_else(|| ModelError::from_str("Missing code")) 303 | .and_then(|pair| assert_rule(pair, Rule::code)) 304 | .map(|pair| pair.as_str())?; 305 | // The code must start and end with at least one line separator - remove first and last 306 | let code_len = code_untrimmed.len(); 307 | assert!(code_len >= 2); 308 | let mut code_chars = code_untrimmed.chars(); 309 | let code_start: usize = match code_chars.next() { 310 | Some('\n') => 1, 311 | Some('\r') => match code_chars.next() { 312 | Some('\n') if code_len > 2 => 2, 313 | _ => 1, 314 | }, 315 | _ => { 316 | return Err(ModelError::from_str( 317 | "Code block must be preceeded by at least one line separator", 318 | )) 319 | } 320 | }; 321 | let mut code_chars = code_untrimmed.chars().rev(); 322 | let code_end: usize = code_len 323 | - match code_chars.next() { 324 | Some('\r') => 1, 325 | Some('\n') => match code_chars.next() { 326 | Some('\r') if code_len - code_start > 2 => 2, 327 | _ => 1, 328 | }, 329 | _ => { 330 | return Err(ModelError::from_str( 331 | "Code block must be followed by at least one line separator", 332 | )) 333 | } 334 | }; 335 | let code = code_untrimmed[code_start..code_end].to_owned(); 336 | let expression = inner 337 | .next() 338 | .ok_or_else(|| ModelError::from_str("Missing expression")) 339 | .and_then(|pair| assert_rule(pair, Rule::expression))?; 340 | Ok(TestCase { 341 | name, 342 | code, 343 | expression: Expression::try_from_sexpr(expression)?, 344 | }) 345 | } 346 | } 347 | 348 | #[cfg(test)] 349 | mod tests { 350 | use super::{Expression, ExpressionFormatter, TestCase}; 351 | use crate::{ 352 | parser::{Rule, TestParser}, 353 | TestError, 354 | }; 355 | use indoc::indoc; 356 | use std::collections::HashSet; 357 | 358 | fn assert_nonterminal<'a>( 359 | expression: &'a Expression, 360 | expected_name: &str, 361 | ) -> &'a Vec { 362 | match expression { 363 | Expression::NonTerminal { name, children } => { 364 | assert_eq!(name, expected_name); 365 | children 366 | } 367 | _ => panic!("Expected non-terminal expression but found {expression:?}"), 368 | } 369 | } 370 | 371 | fn assert_skip<'a>(expression: &'a Expression, expected_depth: usize) -> &'a Box { 372 | match expression { 373 | Expression::Skip { depth, next } => { 374 | assert_eq!(expected_depth, *depth); 375 | next 376 | } 377 | _ => panic!("Expected skip expression but found {expression:?}"), 378 | } 379 | } 380 | 381 | fn assert_terminal(expression: &Expression, expected_name: &str, expected_value: Option<&str>) { 382 | match expression { 383 | Expression::Terminal { name, value } => { 384 | assert_eq!(name, expected_name); 385 | match (value, expected_value) { 386 | (Some(actual), Some(expected)) => assert_eq!(actual.trim(), expected), 387 | (Some(actual), None) => { 388 | panic!("Terminal node has value {actual} but there is no expected value") 389 | } 390 | (None, Some(expected)) => { 391 | panic!("Terminal node has no value but expected {expected}") 392 | } 393 | _ => (), 394 | } 395 | } 396 | _ => panic!("Expected terminal expression but found {expression:?}"), 397 | } 398 | } 399 | 400 | fn assert_nonterminal_sexpr<'a>( 401 | expression: &'a Expression, 402 | expected_name: &str, 403 | ) -> &'a Vec { 404 | let children = assert_nonterminal(expression, "expression"); 405 | assert_eq!(children.len(), 2); 406 | assert_terminal(&children[0], "identifier", Some(expected_name)); 407 | assert_nonterminal(&children[1], "sub_expressions") 408 | } 409 | 410 | fn assert_terminal_sexpr( 411 | expression: &Expression, 412 | expected_name: &str, 413 | expected_value: Option<&str>, 414 | ) { 415 | let children = assert_nonterminal(expression, "expression"); 416 | assert!(children.len() >= 1); 417 | assert_terminal(&children[0], "identifier", Some(expected_name)); 418 | if expected_value.is_some() { 419 | assert_eq!(children.len(), 2); 420 | let value = assert_nonterminal(&children[1], "string"); 421 | assert_eq!(value.len(), 1); 422 | assert_terminal(&value[0], "string_value", expected_value); 423 | } 424 | } 425 | 426 | const WITH_QUOTE: &str = indoc! {r#" 427 | Quoted 428 | ====== 429 | 430 | x = "hi" 431 | 432 | ====== 433 | 434 | (source_file 435 | (declaration 436 | (identifier: "x") 437 | (value: "\"hi\"") 438 | ) 439 | ) 440 | "#}; 441 | 442 | #[test] 443 | fn test_quoted_value() -> Result<(), TestError> { 444 | let test_case: TestCase = TestParser::parse(WITH_QUOTE) 445 | .map_err(|source| TestError::Parser { source }) 446 | .and_then(|pair| { 447 | TestCase::try_from_pair(pair).map_err(|source| TestError::Model { source }) 448 | })?; 449 | let expression = test_case.expression; 450 | let children = assert_nonterminal(&expression, "source_file"); 451 | assert_eq!(children.len(), 1); 452 | let children = assert_nonterminal(&children[0], "declaration"); 453 | assert_eq!(children.len(), 2); 454 | assert_terminal(&children[0], "identifier", Some("x")); 455 | assert_terminal(&children[1], "value", Some("\"hi\"")); 456 | Ok(()) 457 | } 458 | 459 | const BLANK_LINES: &str = indoc! {r#" 460 | 461 | 462 | "#}; 463 | 464 | #[test] 465 | fn test_escape_whitespace() -> Result<(), TestError> { 466 | let mut writer = String::new(); 467 | let mut formatter = ExpressionFormatter::from_defaults(&mut writer); 468 | let expression = Expression::Terminal { 469 | name: "blank_lines".to_string(), 470 | value: Some(BLANK_LINES.to_string()), 471 | }; 472 | formatter 473 | .fmt(&expression) 474 | .expect("Error formatting expression"); 475 | let expected = r#"(blank_lines: "\n\n")"#; 476 | assert_eq!(writer, expected); 477 | Ok(()) 478 | } 479 | 480 | const TEXT: &str = indoc! {r#" 481 | My Test 482 | 483 | ======= 484 | 485 | fn x() int { 486 | return 1; 487 | } 488 | 489 | ======= 490 | 491 | (source_file 492 | (function_definition 493 | (identifier: "x") 494 | (parameter_list) 495 | (primitive_type: "int") 496 | (block 497 | (return_statement 498 | (number: "1") 499 | ) 500 | ) 501 | ) 502 | ) 503 | "#}; 504 | 505 | #[test] 506 | fn test_parse_from_code() -> Result<(), TestError> { 507 | let test_pair = TestParser::parse(TEXT).map_err(|source| TestError::Parser { source })?; 508 | let skip_rules = HashSet::from([Rule::EOI]); 509 | let code_expression = Expression::try_from_code(test_pair, &skip_rules) 510 | .map_err(|source| TestError::Model { source })?; 511 | let children = assert_nonterminal(&code_expression, "test_case"); 512 | assert_eq!(children.len(), 3); 513 | assert_terminal(&children[0], "test_name", Some("My Test")); 514 | let code_block = assert_nonterminal(&children[1], "code_block"); 515 | assert_eq!(code_block.len(), 2); 516 | assert_terminal(&code_block[0], "div", Some("=======")); 517 | assert_terminal(&code_block[1], "code", Some("fn x() int {\n return 1;\n}")); 518 | let s_expression = assert_nonterminal_sexpr(&children[2], "source_file"); 519 | assert_eq!(s_expression.len(), 1); 520 | let s_expression = assert_nonterminal_sexpr(&s_expression[0], "function_definition"); 521 | assert_eq!(s_expression.len(), 4); 522 | assert_terminal_sexpr(&s_expression[0], "identifier", Some("x")); 523 | assert_terminal_sexpr(&s_expression[1], "parameter_list", None); 524 | assert_terminal_sexpr(&s_expression[2], "primitive_type", Some("int")); 525 | let s_expression = assert_nonterminal_sexpr(&s_expression[3], "block"); 526 | assert_eq!(s_expression.len(), 1); 527 | let s_expression = assert_nonterminal_sexpr(&s_expression[0], "return_statement"); 528 | assert_eq!(s_expression.len(), 1); 529 | assert_terminal_sexpr(&s_expression[0], "number", Some("1")); 530 | Ok(()) 531 | } 532 | 533 | const TEXT_WITH_SKIP: &str = indoc! {r#" 534 | My Test 535 | 536 | ======= 537 | 538 | fn x() int { 539 | return 1; 540 | } 541 | 542 | ======= 543 | 544 | (source_file 545 | (function_definition 546 | (identifier: "x") 547 | (parameter_list) 548 | (primitive_type: "int") 549 | (block 550 | #[skip(depth = 1)] 551 | (return_statement 552 | (number: "1") 553 | ) 554 | ) 555 | ) 556 | ) 557 | "#}; 558 | 559 | #[test] 560 | fn test_parse() -> Result<(), TestError> { 561 | let test_case: TestCase = TestParser::parse(TEXT_WITH_SKIP) 562 | .map_err(|source| TestError::Parser { source }) 563 | .and_then(|pair| { 564 | TestCase::try_from_pair(pair).map_err(|source| TestError::Model { source }) 565 | })?; 566 | assert_eq!(test_case.name, "My Test"); 567 | assert_eq!(test_case.code, "\nfn x() int {\n return 1;\n}\n"); 568 | let expression = test_case.expression; 569 | let children = assert_nonterminal(&expression, "source_file"); 570 | assert_eq!(children.len(), 1); 571 | let children = assert_nonterminal(&children[0], "function_definition"); 572 | assert_eq!(children.len(), 4); 573 | assert_terminal(&children[0], "identifier", Some("x")); 574 | assert_terminal(&children[1], "parameter_list", None); 575 | assert_terminal(&children[2], "primitive_type", Some("int")); 576 | let children = assert_nonterminal(&children[3], "block"); 577 | assert_eq!(children.len(), 1); 578 | let next = assert_skip(&children[0], 1); 579 | let children = assert_nonterminal(&next, "return_statement"); 580 | assert_eq!(children.len(), 1); 581 | assert_terminal(&children[0], "number", Some("1")); 582 | Ok(()) 583 | } 584 | 585 | #[test] 586 | fn test_format() -> Result<(), TestError> { 587 | let mut writer = String::new(); 588 | let mut formatter = ExpressionFormatter::from_defaults(&mut writer); 589 | let test_case: TestCase = TestParser::parse(TEXT_WITH_SKIP) 590 | .map_err(|source| TestError::Parser { source }) 591 | .and_then(|pair| { 592 | TestCase::try_from_pair(pair).map_err(|source| TestError::Model { source }) 593 | })?; 594 | formatter 595 | .fmt(&test_case.expression) 596 | .expect("Error formatting expression"); 597 | let expected = indoc! {r#" 598 | (source_file 599 | (function_definition 600 | (identifier: "x") 601 | (parameter_list) 602 | (primitive_type: "int") 603 | (block 604 | #[skip(depth = 1)] 605 | (return_statement 606 | (number: "1") 607 | ) 608 | ) 609 | ) 610 | )"#}; 611 | assert_eq!(writer, expected); 612 | Ok(()) 613 | } 614 | } 615 | -------------------------------------------------------------------------------- /pest-test/src/diff.rs: -------------------------------------------------------------------------------- 1 | use crate::model::{Expression, ExpressionFormatter}; 2 | use colored::Color; 3 | use std::{ 4 | collections::HashSet, 5 | fmt::{Display, Result as FmtResult}, 6 | }; 7 | 8 | #[derive(Debug)] 9 | pub enum ExpressionDiff { 10 | Equal(Expression), 11 | NotEqual { 12 | expected: Expression, 13 | actual: Expression, 14 | }, 15 | Missing(Expression), 16 | Extra(Expression), 17 | Partial { 18 | name: String, 19 | children: Vec, 20 | }, 21 | } 22 | 23 | impl ExpressionDiff { 24 | pub fn from_expressions( 25 | expected: &Expression, 26 | actual: &Expression, 27 | ignore_missing_expected_values: bool, 28 | ) -> ExpressionDiff { 29 | match (expected, actual) { 30 | ( 31 | Expression::Terminal { 32 | name: expected_name, 33 | value: expected_value, 34 | }, 35 | Expression::Terminal { 36 | name: actual_name, 37 | value: actual_value, 38 | }, 39 | ) if expected_name == actual_name && expected_value == actual_value => { 40 | ExpressionDiff::Equal(actual.clone()) 41 | } 42 | ( 43 | Expression::Terminal { 44 | name: expected_name, 45 | value: None, 46 | }, 47 | Expression::Terminal { 48 | name: actual_name, 49 | value: Some(actual_value), 50 | }, 51 | ) if expected_name == actual_name 52 | && (ignore_missing_expected_values || actual_value.is_empty()) => 53 | { 54 | ExpressionDiff::Equal(actual.clone()) 55 | } 56 | ( 57 | Expression::NonTerminal { 58 | name: expected_name, 59 | children: expected_children, 60 | }, 61 | Expression::NonTerminal { 62 | name: actual_name, 63 | children: actual_children, 64 | }, 65 | ) if expected_name == actual_name => { 66 | let expected_names: HashSet<&String> = 67 | expected_children.iter().map(|expr| expr.name()).collect(); 68 | let mut expected_iter = expected_children.iter().peekable(); 69 | let mut actual_iter = actual_children.iter(); 70 | let mut children = Vec::new(); 71 | loop { 72 | if let Some(expected_child) = expected_iter.next() { 73 | match actual_iter.next() { 74 | Some(actual_child) 75 | if Some(expected_child.name()) 76 | == actual_child 77 | .get_descendant(expected_child.skip_depth()) 78 | .map(|e| e.name()) => 79 | { 80 | children.push(Self::from_expressions( 81 | expected_child, 82 | actual_child, 83 | ignore_missing_expected_values, 84 | )); 85 | } 86 | Some(actual_child) => { 87 | children.push(ExpressionDiff::Missing(expected_child.clone())); 88 | if expected_names.contains(actual_child.name()) { 89 | while let Some(next) = expected_iter.peek() { 90 | if next.name() == actual_child.name() { 91 | break; 92 | } else { 93 | children.push(ExpressionDiff::Missing( 94 | expected_iter.next().unwrap().clone(), 95 | )); 96 | } 97 | } 98 | } else { 99 | children.push(ExpressionDiff::Extra(actual_child.clone())) 100 | } 101 | } 102 | None => children.push(ExpressionDiff::Missing(expected_child.clone())), 103 | } 104 | } else { 105 | children.extend( 106 | actual_iter 107 | .map(|actual_child| ExpressionDiff::Extra(actual_child.clone())), 108 | ); 109 | break; 110 | } 111 | } 112 | let partial = children 113 | .iter() 114 | .filter(|child| !matches!(child, ExpressionDiff::Equal(_))) 115 | .count() 116 | > 0; 117 | if partial { 118 | ExpressionDiff::Partial { 119 | name: expected_name.clone(), 120 | children, 121 | } 122 | } else { 123 | ExpressionDiff::Equal(Expression::NonTerminal { 124 | name: expected_name.clone(), 125 | children: children 126 | .into_iter() 127 | .map(|child| match child { 128 | ExpressionDiff::Equal(expression) => expression, 129 | _ => panic!("Unexpected non-equal value"), 130 | }) 131 | .collect(), 132 | }) 133 | } 134 | } 135 | (Expression::Skip { depth, next }, actual) => match actual.get_descendant(*depth) { 136 | Some(descendant) => Self::from_expressions( 137 | next.as_ref(), 138 | descendant, 139 | ignore_missing_expected_values, 140 | ), 141 | None => ExpressionDiff::NotEqual { 142 | expected: expected.clone(), 143 | actual: actual.clone(), 144 | }, 145 | }, 146 | _ => ExpressionDiff::NotEqual { 147 | expected: expected.clone(), 148 | actual: actual.clone(), 149 | }, 150 | } 151 | } 152 | 153 | pub fn name(&self) -> String { 154 | match self { 155 | ExpressionDiff::Equal(exp) => exp.name().clone(), 156 | ExpressionDiff::NotEqual { expected, actual } if expected.name() == actual.name() => { 157 | expected.name().to_owned() 158 | } 159 | ExpressionDiff::NotEqual { expected, actual } => { 160 | format!("{}/{}", expected.name(), actual.name()) 161 | } 162 | ExpressionDiff::Missing(exp) => exp.name().to_owned(), 163 | ExpressionDiff::Extra(exp) => exp.name().to_owned(), 164 | ExpressionDiff::Partial { name, children: _ } => name.to_owned(), 165 | } 166 | } 167 | 168 | /// Print this diff to stderr. Intended to be used in a unit test to print the diff when the 169 | /// evaluation result is a `TestError::Diff`. This is necessary because, by default, an Err 170 | /// result is displayed using its `Debug` value. 171 | /// 172 | /// Example: 173 | /// fn test () -> Result<(), TestError> { 174 | /// let tester: PestTester = PestTester::from_defaults(Rule::root_rule); 175 | /// let res = tester.evaluate_strict("mytest"); 176 | /// if let Err(TestError::Diff { diff }) = res { 177 | /// diff.print_test_result(); 178 | /// } 179 | /// res 180 | /// } 181 | pub fn print_test_result(&self, colorize: bool) -> FmtResult { 182 | let mut writer = String::new(); 183 | let (expected_color, actual_color) = if colorize { 184 | (Some(Color::Green), Some(Color::Red)) 185 | } else { 186 | (None, None) 187 | }; 188 | let mut formatter = ExpressionFormatter::from_defaults(&mut writer); 189 | formatter.write_str("========================================================\n")?; 190 | formatter.write_str("Parse tree differs between ")?; 191 | formatter.color = expected_color; 192 | formatter.write_str("expected")?; 193 | formatter.color = None; 194 | formatter.write_str(" and ")?; 195 | formatter.color = actual_color; 196 | formatter.write_str("actual")?; 197 | formatter.color = None; 198 | formatter.write_str(" results:")?; 199 | formatter.write_newline()?; 200 | formatter.write_str("========================================================")?; 201 | formatter.write_newline()?; 202 | formatter.fmt_diff(self, expected_color, actual_color)?; 203 | formatter.write_newline()?; 204 | formatter.write_str("========================================================")?; 205 | formatter.write_newline()?; 206 | eprintln!("{}", writer); 207 | Ok(()) 208 | } 209 | } 210 | 211 | pub trait ExpressionDiffFormatterExt { 212 | fn fmt_diff( 213 | &mut self, 214 | diff: &ExpressionDiff, 215 | expected_color: Option, 216 | actual_color: Option, 217 | ) -> FmtResult; 218 | } 219 | 220 | impl<'a> ExpressionDiffFormatterExt for ExpressionFormatter<'a> { 221 | fn fmt_diff( 222 | &mut self, 223 | diff: &ExpressionDiff, 224 | expected_color: Option, 225 | actual_color: Option, 226 | ) -> FmtResult { 227 | match diff { 228 | ExpressionDiff::Equal(expression) => self.fmt(expression)?, 229 | ExpressionDiff::NotEqual { expected, actual } => { 230 | self.color = expected_color; 231 | self.fmt(expected)?; 232 | self.write_newline()?; 233 | self.color = actual_color; 234 | self.fmt(actual)?; 235 | self.color = None; 236 | } 237 | ExpressionDiff::Missing(expression) => { 238 | self.color = expected_color; 239 | self.fmt(expression)?; 240 | self.color = None; 241 | } 242 | ExpressionDiff::Extra(expression) => { 243 | self.color = actual_color; 244 | self.fmt(expression)?; 245 | self.color = None; 246 | } 247 | ExpressionDiff::Partial { name, children } => { 248 | self.write_indent()?; 249 | self.write_char('(')?; 250 | self.write_str(name)?; 251 | self.write_newline()?; 252 | self.level += 1; 253 | for child in children { 254 | self.fmt_diff(child, expected_color, actual_color)?; 255 | self.write_newline()?; 256 | } 257 | self.level -= 1; 258 | self.write_indent()?; 259 | self.write_char(')')?; 260 | } 261 | } 262 | Ok(()) 263 | } 264 | } 265 | 266 | impl Display for ExpressionDiff { 267 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 268 | ExpressionFormatter::from_defaults(f).fmt_diff(self, Some(Color::Green), Some(Color::Red)) 269 | } 270 | } 271 | 272 | #[cfg(test)] 273 | mod tests { 274 | use super::{ExpressionDiff, ExpressionDiffFormatterExt}; 275 | use crate::{ 276 | model::{Expression, ExpressionFormatter, TestCase}, 277 | parser::Rule, 278 | TestError, TestParser, 279 | }; 280 | use colored::Color; 281 | use indoc::indoc; 282 | 283 | const TEXT: &str = indoc! {r#" 284 | My Test 285 | ======= 286 | 287 | fn x() int { 288 | return 1; 289 | } 290 | 291 | ======= 292 | 293 | (source_file 294 | (function_definition 295 | (identifier: "x") 296 | (parameter_list) 297 | (primitive_type: "int") 298 | (block 299 | (return_statement 300 | (number: "1") 301 | ) 302 | ) 303 | ) 304 | ) 305 | "#}; 306 | 307 | fn make_expected_sexpression(with_skip: bool) -> Expression { 308 | let block_children = if with_skip { 309 | Vec::from([Expression::Skip { 310 | depth: 1, 311 | next: Box::new(Expression::Terminal { 312 | name: String::from("number"), 313 | value: Some(String::from("1")), 314 | }), 315 | }]) 316 | } else { 317 | Vec::from([Expression::Terminal { 318 | name: String::from("return_statement"), 319 | value: None, 320 | }]) 321 | }; 322 | Expression::NonTerminal { 323 | name: String::from("source_file"), 324 | children: Vec::from([Expression::NonTerminal { 325 | name: String::from("function_definition"), 326 | children: Vec::from([ 327 | Expression::Terminal { 328 | name: String::from("identifier"), 329 | value: Some(String::from("y")), 330 | }, 331 | Expression::NonTerminal { 332 | name: String::from("missing"), 333 | children: Vec::from([Expression::Terminal { 334 | name: String::from("foo"), 335 | value: None, 336 | }]), 337 | }, 338 | Expression::Terminal { 339 | name: String::from("primitive_type"), 340 | value: None, 341 | }, 342 | Expression::NonTerminal { 343 | name: String::from("block"), 344 | children: block_children, 345 | }, 346 | ]), 347 | }]), 348 | } 349 | } 350 | 351 | fn assert_equal<'a>(diff: &'a ExpressionDiff, expected_name: &'a str) -> &'a Expression { 352 | match diff { 353 | ExpressionDiff::Equal(expr) => { 354 | assert_eq!(expr.name(), expected_name); 355 | expr 356 | } 357 | _ => panic!("Expected diff to be equal but was {}", diff), 358 | } 359 | } 360 | 361 | fn assert_partial<'a>( 362 | diff: &'a ExpressionDiff, 363 | expected_name: &'a str, 364 | ) -> &'a Vec { 365 | match diff { 366 | ExpressionDiff::Partial { name, children } => { 367 | assert_eq!(expected_name, name); 368 | children 369 | } 370 | _ => panic!("Expected diff to be partial but was {}", diff), 371 | } 372 | } 373 | 374 | fn assert_value_equal( 375 | diff: &ExpressionDiff, 376 | expected_name: &str, 377 | expected_value: Option<&str>, 378 | ) { 379 | match diff { 380 | ExpressionDiff::Equal(Expression::Terminal { name, value }) => { 381 | assert_eq!(expected_name, name); 382 | match (expected_value, value) { 383 | (Some(expected), Some(actual)) => assert_eq!(expected, actual), 384 | _ => (), 385 | } 386 | } 387 | _ => panic!("Expectedc diff to be equal but was {}", diff), 388 | } 389 | } 390 | 391 | fn assert_value_nonequal( 392 | diff: &ExpressionDiff, 393 | name: &str, 394 | expected_expected_value: Option<&str>, 395 | expected_actual_value: Option<&str>, 396 | ) { 397 | match diff { 398 | ExpressionDiff::NotEqual { 399 | expected: 400 | Expression::Terminal { 401 | name: expected_name, 402 | value: expected_value, 403 | }, 404 | actual: 405 | Expression::Terminal { 406 | name: actual_name, 407 | value: actual_value, 408 | }, 409 | } => { 410 | assert_eq!(expected_name, name); 411 | assert_eq!(actual_name, name); 412 | assert_eq!( 413 | expected_expected_value.map(|s| s.to_owned()), 414 | *expected_value 415 | ); 416 | assert_eq!(expected_actual_value.map(|s| s.to_owned()), *actual_value); 417 | } 418 | _ => panic!("Expected diff to be non-equal but was {}", diff), 419 | } 420 | } 421 | 422 | fn assert_missing(diff: &ExpressionDiff, expected_name: &str) { 423 | match diff { 424 | ExpressionDiff::Missing(expr) => assert_eq!(expr.name(), expected_name), 425 | _ => panic!("Expected diff to be missing but was {}", diff), 426 | } 427 | } 428 | 429 | fn assert_extra(diff: &ExpressionDiff, expected_name: &str) { 430 | match diff { 431 | ExpressionDiff::Extra(expr) => assert_eq!(expr.name(), expected_name), 432 | _ => panic!("Expected diff to be extra but was {}", diff), 433 | } 434 | } 435 | 436 | fn assert_nonequal_type(diff: &ExpressionDiff, expected_name: &str) { 437 | match diff { 438 | ExpressionDiff::NotEqual { 439 | expected: 440 | Expression::Terminal { 441 | name: terminal_name, 442 | value: _, 443 | }, 444 | actual: 445 | Expression::NonTerminal { 446 | name: nonterminal_name, 447 | children: _, 448 | }, 449 | } => { 450 | assert_eq!(expected_name, nonterminal_name); 451 | assert_eq!(expected_name, terminal_name); 452 | } 453 | ExpressionDiff::NotEqual { 454 | expected: 455 | Expression::NonTerminal { 456 | name: nonterminal_name, 457 | children: _, 458 | }, 459 | actual: 460 | Expression::Terminal { 461 | name: terminal_name, 462 | value: _, 463 | }, 464 | } => { 465 | assert_eq!(expected_name, nonterminal_name); 466 | assert_eq!(expected_name, terminal_name); 467 | } 468 | _ => panic!("Expected diff to be non-equal but was {}", diff), 469 | } 470 | } 471 | 472 | #[test] 473 | fn test_diff_strict() -> Result<(), TestError> { 474 | let test_case: TestCase = TestParser::parse(TEXT) 475 | .map_err(|source| TestError::Parser { source }) 476 | .and_then(|pair| { 477 | TestCase::try_from_pair(pair).map_err(|source| TestError::Model { source }) 478 | })?; 479 | let expected_sexpr = make_expected_sexpression(false); 480 | let diff_strict = 481 | ExpressionDiff::from_expressions(&expected_sexpr, &test_case.expression, false); 482 | let children = assert_partial(&diff_strict, "source_file"); 483 | assert_eq!(children.len(), 1); 484 | let children = assert_partial(&children[0], "function_definition"); 485 | assert_eq!(children.len(), 5); 486 | assert_value_nonequal(&children[0], "identifier", Some("y"), Some("x")); 487 | assert_missing(&children[1], "missing"); 488 | assert_extra(&children[2], "parameter_list"); 489 | assert_value_nonequal(&children[3], "primitive_type", None, Some("int")); 490 | let children = assert_partial(&children[4], "block"); 491 | assert_eq!(children.len(), 1); 492 | assert_nonequal_type(&children[0], "return_statement"); 493 | Ok(()) 494 | } 495 | 496 | #[test] 497 | fn test_diff_lenient() -> Result<(), TestError> { 498 | let test_case: TestCase = TestParser::parse(TEXT) 499 | .map_err(|source| TestError::Parser { source }) 500 | .and_then(|pair| { 501 | TestCase::try_from_pair(pair).map_err(|source| TestError::Model { source }) 502 | })?; 503 | let expected_sexpr = make_expected_sexpression(false); 504 | let diff_lenient = 505 | ExpressionDiff::from_expressions(&expected_sexpr, &test_case.expression, true); 506 | let children = assert_partial(&diff_lenient, "source_file"); 507 | let children = assert_partial(&children[0], "function_definition"); 508 | assert_value_equal(&children[3], "primitive_type", Some("int")); 509 | Ok(()) 510 | } 511 | 512 | #[test] 513 | fn test_diff_with_skip() -> Result<(), TestError> { 514 | let test_case: TestCase = TestParser::parse(TEXT) 515 | .map_err(|source| TestError::Parser { source }) 516 | .and_then(|pair| { 517 | TestCase::try_from_pair(pair).map_err(|source| TestError::Model { source }) 518 | })?; 519 | let expected_sexpr = make_expected_sexpression(true); 520 | let diff_lenient = 521 | ExpressionDiff::from_expressions(&expected_sexpr, &test_case.expression, true); 522 | let children = assert_partial(&diff_lenient, "source_file"); 523 | let children = assert_partial(&children[0], "function_definition"); 524 | assert_value_equal(&children[3], "primitive_type", Some("int")); 525 | assert_equal(&children[4], "block"); 526 | Ok(()) 527 | } 528 | 529 | #[test] 530 | fn test_format_nocolor() -> Result<(), TestError> { 531 | let test_case: TestCase = TestParser::parse(TEXT) 532 | .map_err(|source| TestError::Parser { source }) 533 | .and_then(|pair| { 534 | TestCase::try_from_pair(pair).map_err(|source| TestError::Model { source }) 535 | })?; 536 | let expected_sexpr = make_expected_sexpression(false); 537 | let diff = ExpressionDiff::from_expressions(&expected_sexpr, &test_case.expression, false); 538 | let mut writer = String::new(); 539 | let mut formatter = ExpressionFormatter::from_defaults(&mut writer); 540 | formatter.fmt_diff(&diff, None, None).ok(); 541 | let expected = indoc! {r#" 542 | (source_file 543 | (function_definition 544 | (identifier: "y") 545 | (identifier: "x") 546 | (missing 547 | (foo) 548 | ) 549 | (parameter_list) 550 | (primitive_type) 551 | (primitive_type: "int") 552 | (block 553 | (return_statement) 554 | (return_statement 555 | (number: "1") 556 | ) 557 | ) 558 | ) 559 | )"#}; 560 | assert_eq!(writer, expected); 561 | Ok(()) 562 | } 563 | 564 | #[test] 565 | fn test_format_color() -> Result<(), TestError> { 566 | let test_case: TestCase = TestParser::parse(TEXT) 567 | .map_err(|source| TestError::Parser { source }) 568 | .and_then(|pair| { 569 | TestCase::try_from_pair(pair).map_err(|source| TestError::Model { source }) 570 | })?; 571 | let expected_sexpr = make_expected_sexpression(false); 572 | let diff = ExpressionDiff::from_expressions(&expected_sexpr, &test_case.expression, false); 573 | let mut writer = String::new(); 574 | let mut formatter = ExpressionFormatter::from_defaults(&mut writer); 575 | formatter 576 | .fmt_diff(&diff, Some(Color::Green), Some(Color::Red)) 577 | .ok(); 578 | let expected = format!( 579 | indoc! {r#" 580 | (source_file 581 | (function_definition 582 | {green_start} (identifier: "y"){end} 583 | {red_start} (identifier: "x"){end} 584 | {green_start} (missing 585 | (foo) 586 | ){end} 587 | {red_start} (parameter_list){end} 588 | {green_start} (primitive_type){end} 589 | {red_start} (primitive_type: "int"){end} 590 | (block 591 | {green_start} (return_statement){end} 592 | {red_start} (return_statement 593 | (number: "1") 594 | ){end} 595 | ) 596 | ) 597 | )"#}, 598 | green_start = "\u{1b}[32m", 599 | red_start = "\u{1b}[31m", 600 | end = "\u{1b}[0m", 601 | ); 602 | assert_eq!(writer, expected); 603 | Ok(()) 604 | } 605 | } 606 | --------------------------------------------------------------------------------