├── .clippy.toml ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── audit.yml │ └── ci.yml ├── .gitignore ├── .rustfmt.toml ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── Makefile ├── README.md ├── SECURITY.md ├── fuzz ├── .gitignore ├── Cargo.toml └── fuzz_targets │ ├── alternation.rs │ ├── expression.rs │ ├── into_regex.rs │ ├── optional.rs │ └── parameter.rs └── src ├── ast.rs ├── combinator.rs ├── expand ├── mod.rs └── parameters.rs ├── lib.rs └── parse.rs /.clippy.toml: -------------------------------------------------------------------------------- 1 | # Project configuration for Clippy Rust code linter. 2 | # See full lints list at: 3 | # https://rust-lang.github.io/rust-clippy/master/index.html 4 | 5 | allow-expect-in-tests = true 6 | 7 | standard-macro-braces = [ 8 | { name = "assert", brace = "(" }, 9 | { name = "assert_eq", brace = "(" }, 10 | { name = "assert_ne", brace = "(" }, 11 | { name = "debug_assert", brace = "(" }, 12 | { name = "debug_assert_eq", brace = "(" }, 13 | { name = "debug_assert_ne", brace = "(" }, 14 | { name = "format", brace = "(" }, 15 | { name = "format_args", brace = "(" }, 16 | { name = "matches", brace = "(" }, 17 | { name = "panic", brace = "(" }, 18 | { name = "print", brace = "(" }, 19 | { name = "println", brace = "(" }, 20 | { name = "vec", brace = "[" }, 21 | { name = "write", brace = "(" }, 22 | { name = "writeln", brace = "(" }, 23 | ] 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | max_line_length = 80 9 | 10 | [*.md] 11 | indent_style = space 12 | indent_size = 4 13 | max_line_length = off 14 | 15 | [*.rs] 16 | indent_style = space 17 | indent_size = 4 18 | 19 | [*.toml] 20 | indent_style = space 21 | indent_size = 4 22 | 23 | [*.{yaml,yml}] 24 | indent_style = space 25 | indent_size = 2 26 | 27 | [Makefile] 28 | indent_style = tab 29 | indent_size = 4 30 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: / 5 | labels: ["enhancement", "rust", "k::dependencies"] 6 | schedule: 7 | interval: daily 8 | 9 | - package-ecosystem: github-actions 10 | directory: / 11 | labels: ["enhancement", "github_actions", "k::dependencies"] 12 | schedule: 13 | interval: daily 14 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | tags: ["v*"] 7 | paths: ["**/Cargo.toml"] 8 | pull_request: 9 | branches: ["main"] 10 | paths: ["**/Cargo.toml"] 11 | schedule: 12 | - cron: "7 7 * * *" 13 | 14 | jobs: 15 | cargo-audit: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: dtolnay/rust-toolchain@v1 20 | with: 21 | toolchain: stable 22 | 23 | - uses: actions-rs/audit-check@v1 24 | with: 25 | token: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | tags: ["v*"] 7 | pull_request: 8 | branches: ["main"] 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | env: 15 | RUST_BACKTRACE: 1 16 | 17 | jobs: 18 | 19 | ########################## 20 | # Linting and formatting # 21 | ########################## 22 | 23 | clippy: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: dtolnay/rust-toolchain@v1 28 | with: 29 | toolchain: stable 30 | components: clippy 31 | 32 | - run: make cargo.lint 33 | 34 | rustfmt: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: dtolnay/rust-toolchain@v1 39 | with: 40 | toolchain: nightly 41 | components: rustfmt 42 | 43 | - run: make cargo.fmt check=yes 44 | 45 | 46 | 47 | 48 | ########### 49 | # Testing # 50 | ########### 51 | 52 | feature: 53 | strategy: 54 | fail-fast: false 55 | matrix: 56 | feature: 57 | - 58 | - into-regex 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v4 62 | - uses: dtolnay/rust-toolchain@v1 63 | with: 64 | toolchain: nightly 65 | - uses: dtolnay/rust-toolchain@v1 66 | with: 67 | toolchain: stable 68 | 69 | - run: cargo +nightly update -Z minimal-versions 70 | 71 | - run: cargo check -p cucumber-expressions --no-default-features 72 | ${{ (matrix.feature != '' 73 | && format('--features {0}', matrix.feature)) 74 | || '' }} 75 | env: 76 | RUSTFLAGS: -D warnings 77 | 78 | fuzz: 79 | strategy: 80 | fail-fast: false 81 | matrix: 82 | target: 83 | - alternation 84 | - expression 85 | - into-regex 86 | - optional 87 | - parameter 88 | runs-on: ubuntu-latest 89 | steps: 90 | - uses: actions/checkout@v4 91 | - uses: dtolnay/rust-toolchain@v1 92 | with: 93 | toolchain: nightly 94 | 95 | - run: cargo install cargo-fuzz 96 | 97 | - run: make cargo.fuzz target=${{ matrix.target }} time=60 98 | 99 | msrv: 100 | name: MSRV 101 | strategy: 102 | fail-fast: false 103 | matrix: 104 | msrv: ["1.85.0"] 105 | os: ["ubuntu", "macOS", "windows"] 106 | runs-on: ${{ matrix.os }}-latest 107 | steps: 108 | - uses: actions/checkout@v4 109 | - uses: dtolnay/rust-toolchain@v1 110 | with: 111 | toolchain: nightly 112 | - uses: dtolnay/rust-toolchain@v1 113 | with: 114 | toolchain: ${{ matrix.msrv }} 115 | 116 | - run: cargo +nightly update -Z minimal-versions 117 | 118 | - run: make test.cargo 119 | 120 | test: 121 | strategy: 122 | fail-fast: false 123 | matrix: 124 | toolchain: ["stable", "beta", "nightly"] 125 | os: ["ubuntu", "macOS", "windows"] 126 | runs-on: ${{ matrix.os }}-latest 127 | steps: 128 | - uses: actions/checkout@v4 129 | - uses: dtolnay/rust-toolchain@v1 130 | with: 131 | toolchain: ${{ matrix.toolchain }} 132 | components: rust-src 133 | 134 | - run: cargo install cargo-careful 135 | if: ${{ matrix.toolchain == 'nightly' }} 136 | 137 | - run: make test.cargo 138 | careful=${{ (matrix.toolchain == 'nightly' && 'yes') 139 | || 'no' }} 140 | 141 | 142 | 143 | 144 | ################# 145 | # Documentation # 146 | ################# 147 | 148 | rustdoc: 149 | runs-on: ubuntu-latest 150 | steps: 151 | - uses: actions/checkout@v4 152 | - uses: dtolnay/rust-toolchain@v1 153 | with: 154 | toolchain: nightly 155 | 156 | - run: make cargo.doc private=yes docsrs=yes open=no 157 | env: 158 | RUSTFLAGS: -D warnings 159 | 160 | 161 | 162 | 163 | ############# 164 | # Releasing # 165 | ############# 166 | 167 | publish: 168 | name: publish (crates.io) 169 | if: ${{ startsWith(github.ref, 'refs/tags/v') }} 170 | needs: ["release-github"] 171 | runs-on: ubuntu-latest 172 | steps: 173 | - uses: actions/checkout@v4 174 | - uses: dtolnay/rust-toolchain@v1 175 | with: 176 | toolchain: stable 177 | 178 | - run: cargo publish -p cucumber-expressions 179 | env: 180 | CARGO_REGISTRY_TOKEN: ${{ secrets.CRATESIO_TOKEN }} 181 | 182 | release-github: 183 | name: release (GitHub) 184 | if: ${{ startsWith(github.ref, 'refs/tags/v') }} 185 | needs: 186 | - clippy 187 | - feature 188 | - fuzz 189 | - msrv 190 | - rustdoc 191 | - rustfmt 192 | - test 193 | runs-on: ubuntu-latest 194 | steps: 195 | - uses: actions/checkout@v4 196 | 197 | - name: Parse release version 198 | id: release 199 | run: echo "version=${GITHUB_REF#refs/tags/v}" 200 | >> $GITHUB_OUTPUT 201 | - name: Verify release version matches Cargo manifest 202 | run: | 203 | test "${{ steps.release.outputs.version }}" \ 204 | == "$(grep -m1 'version = "' Cargo.toml | cut -d '"' -f2)" 205 | 206 | - name: Parse CHANGELOG link 207 | id: changelog 208 | run: echo "link=${{ github.server_url }}/${{ github.repository }}/blob/v${{ steps.release.outputs.version }}/CHANGELOG.md#$(sed -n '/^## \[${{ steps.release.outputs.version }}\]/{s/^## \[\(.*\)\][^0-9]*\([0-9].*\)/\1--\2/;s/[^0-9a-z-]*//g;p;}' CHANGELOG.md)" 209 | >> $GITHUB_OUTPUT 210 | 211 | - name: Create GitHub release 212 | uses: softprops/action-gh-release@v2 213 | with: 214 | name: ${{ steps.release.outputs.version }} 215 | body: | 216 | [API docs](https://docs.rs/cucumber-expressions/${{ steps.release.outputs.version }}) 217 | [Changelog](${{ steps.changelog.outputs.link }}) 218 | prerelease: ${{ contains(steps.release.outputs.version, '-') }} 219 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /.vscode/ 3 | /*.iml 4 | .DS_Store 5 | 6 | /target/ 7 | /Cargo.lock 8 | **/*.rs.bk 9 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Project configuration for rustfmt Rust code formatter. 2 | # See full list of configurations at: 3 | # https://github.com/rust-lang/rustfmt/blob/master/Configurations.md 4 | 5 | max_width = 80 6 | use_small_heuristics = "Max" 7 | 8 | group_imports = "StdExternalCrate" 9 | imports_granularity = "Crate" 10 | 11 | format_strings = false 12 | format_code_in_doc_comments = true 13 | format_macro_matchers = true 14 | use_try_shorthand = true 15 | 16 | error_on_line_overflow = true 17 | error_on_unformatted = true 18 | 19 | unstable_features = true 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | `cucumber-expressions` changelog 2 | ================================ 3 | 4 | All user visible changes to `cucumber-expressions` crate will be documented in this file. This project uses [Semantic Versioning 2.0.0]. 5 | 6 | 7 | 8 | 9 | ## main 10 | 11 | [Diff](https://github.com/cucumber-rs/cucumber-expressions/compare/v0.4.0...main) 12 | 13 | ### BC Breaks 14 | 15 | - Bumped up [MSRV] to 1.85 because of migration to 2024 edition. ([cd6a7180]) 16 | 17 | [cd6a7180]: https://github.com/cucumber-rs/cucumber-expressions/commit/cd6a71801b6f50d088c47ad30ea87bddda977f94 18 | 19 | 20 | 21 | 22 | ## [0.4.0] · 2025-02-03 23 | [0.4.0]: https://github.com/cucumber-rs/cucumber-expressions/tree/v0.4.0 24 | 25 | [Diff](https://github.com/cucumber-rs/cucumber-expressions/compare/v0.3.0...v0.4.0) 26 | 27 | ### BC Breaks 28 | 29 | - Bumped up [MSRV] to 1.81 because for `#[expect]` attribute usage. ([e1bb9266]) 30 | - Upgraded [`nom`] to 8.0 version and [`nom_locate`] to 5.0 version. ([#14], [356024ed]) 31 | 32 | ### Updated 33 | 34 | - [`derive_more`] to 2.0 version. ([#13]) 35 | 36 | [#13]: https://github.com/cucumber-rs/cucumber-expressions/pull/13 37 | [#14]: https://github.com/cucumber-rs/cucumber-expressions/pull/14 38 | [356024ed]: https://github.com/cucumber-rs/cucumber-expressions/commit/356024eddd10e3bcaa16c7b453a88ce2deb9cfc8 39 | [e1bb9266]: https://github.com/cucumber-rs/cucumber-expressions/commit/e1bb92668617432948ab0faa32232b67d6c530e7 40 | 41 | 42 | 43 | 44 | ## [0.3.0] · 2023-04-24 45 | [0.3.0]: https://github.com/cucumber-rs/cucumber-expressions/tree/v0.3.0 46 | 47 | [Diff](https://github.com/cucumber-rs/cucumber-expressions/compare/v0.2.1...v0.3.0) 48 | 49 | ### BC Breaks 50 | 51 | - Bumped up [MSRV] to 1.62 for more clever support of [Cargo feature]s. 52 | - `Box`ed `ParameterError::RenameRegexGroup::err` field due to `clippy::result_large_err` lint suggestion. 53 | - Upgraded [`regex-syntax`] to 0.7 version (changed parametrization of [`Regex`] with custom capturing groups). ([cd28fecc]) 54 | 55 | [cd28fecc]: https://github.com/cucumber-rs/cucumber-expressions/commit/cd28fecc62f5ee1942601053e5290968efa8244b 56 | 57 | 58 | 59 | 60 | ## [0.2.1] · 2022-03-09 61 | [0.2.1]: https://github.com/cucumber-rs/cucumber-expressions/tree/v0.2.1 62 | 63 | [Diff](https://github.com/cucumber-rs/cucumber-expressions/compare/v0.2.0...v0.2.1) 64 | 65 | ### Security updated 66 | 67 | - [`regex`] crate to 1.5.5 version to fix [CVE-2022-24713]. 68 | 69 | [CVE-2022-24713]: https://blog.rust-lang.org/2022/03/08/cve-2022-24713.html 70 | 71 | 72 | 73 | 74 | ## [0.2.0] · 2022-02-10 75 | [0.2.0]: https://github.com/cucumber-rs/cucumber-expressions/tree/v0.2.0 76 | 77 | [Diff](https://github.com/cucumber-rs/cucumber-expressions/compare/v0.1.2...v0.2.0) | [Milestone](https://github.com/cucumber-rs/cucumber-expressions/milestone/4) 78 | 79 | ### BC Breaks 80 | 81 | - Added `id` field to `Parameter` AST struct. ([#8], [#7]) 82 | 83 | ### Added 84 | 85 | - Support of capturing groups inside `Parameter` regex. ([#8], [#7]) 86 | 87 | [#7]: https://github.com/cucumber-rs/cucumber-expressions/issues/7 88 | [#8]: https://github.com/cucumber-rs/cucumber-expressions/pull/8 89 | 90 | 91 | 92 | 93 | ## [0.1.2] · 2022-01-11 94 | [0.1.2]: https://github.com/cucumber-rs/cucumber-expressions/tree/v0.1.2 95 | 96 | [Diff](https://github.com/cucumber-rs/cucumber-expressions/compare/v0.1.1...v0.1.2) | [Milestone](https://github.com/cucumber-rs/cucumber-expressions/milestone/3) 97 | 98 | ### Fixed 99 | 100 | - Unsupported lookaheads in `float` parameter's [`Regex`]. ([#6], [cucumber-rs/cucumber#197]) 101 | 102 | [#6]: https://github.com/cucumber-rs/cucumber-expressions/pull/6 103 | [cucumber-rs/cucumber#197]: https://github.com/cucumber-rs/cucumber/issues/197 104 | 105 | 106 | 107 | 108 | ## [0.1.1] · 2021-11-29 109 | [0.1.1]: https://github.com/cucumber-rs/cucumber-expressions/tree/v0.1.1 110 | 111 | [Diff](https://github.com/cucumber-rs/cucumber-expressions/compare/v0.1.0...v0.1.1) | [Milestone](https://github.com/cucumber-rs/cucumber-expressions/milestone/2) 112 | 113 | ### Updated 114 | 115 | - [`derive_more`] minimal version to `0.99.17`. ([#5]) 116 | 117 | [#5]: https://github.com/cucumber-rs/cucumber-expressions/pull/5 118 | 119 | 120 | 121 | 122 | ## [0.1.0] · 2021-11-22 123 | [0.1.0]: https://github.com/cucumber-rs/cucumber-expressions/tree/v0.1.0 124 | 125 | [Milestone](https://github.com/cucumber-rs/cucumber-expressions/milestone/1) 126 | 127 | ### Added 128 | 129 | - [Cucumber Expressions] AST and parser. ([#1]) 130 | - Expansion of [Cucumber Expressions] AST into [`Regex`] behind `into-regex` feature flag. ([#2]) 131 | - Fuzzing. ([#3]) 132 | 133 | [#1]: https://github.com/cucumber-rs/cucumber-expressions/pull/1 134 | [#2]: https://github.com/cucumber-rs/cucumber-expressions/pull/2 135 | [#3]: https://github.com/cucumber-rs/cucumber-expressions/pull/3 136 | 137 | 138 | 139 | 140 | [`derive_more`]: https://docs.rs/derive_more 141 | [`nom`]: https://docs.rs/nom 142 | [`nom_locate`]: https://docs.rs/nom_locate 143 | [`regex`]: https://docs.rs/regex 144 | [`Regex`]: https://docs.rs/regex 145 | [`regex-syntax`]: https://docs.rs/regex-syntax 146 | 147 | [Cargo feature]: https://doc.rust-lang.org/cargo/reference/features.html 148 | [Cucumber Expressions]: https://github.com/cucumber/cucumber-expressions#readme 149 | [MSRV]: https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field 150 | [Semantic Versioning 2.0.0]: https://semver.org 151 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cucumber-expressions" 3 | version = "0.4.0" 4 | edition = "2024" 5 | rust-version = "1.85" 6 | description = "Cucumber Expressions AST and parser." 7 | license = "MIT OR Apache-2.0" 8 | authors = [ 9 | "Ilya Solovyiov ", 10 | "Kai Ren ", 11 | ] 12 | documentation = "https://docs.rs/cucumber-expressions" 13 | homepage = "https://github.com/cucumber-rs/cucumber-expressions" 14 | repository = "https://github.com/cucumber-rs/cucumber-expressions" 15 | readme = "README.md" 16 | categories = ["compilers", "parser-implementations"] 17 | keywords = ["cucumber", "expression", "expressions", "cucumber-expressions"] 18 | include = ["/src/", "/LICENSE-*", "/README.md", "/CHANGELOG.md"] 19 | 20 | [package.metadata.docs.rs] 21 | all-features = true 22 | rustdoc-args = ["--cfg", "docsrs"] 23 | 24 | [features] 25 | # Enables ability to expand AST into regex. 26 | into-regex = ["dep:either", "dep:regex", "dep:regex-syntax"] 27 | 28 | [dependencies] 29 | derive_more = { version = "2.0", features = ["as_ref", "debug", "deref", "deref_mut", "display", "error", "from"] } 30 | nom = "8.0" 31 | nom_locate = "5.0" 32 | 33 | # "into-regex" feature dependencies 34 | either = { version = "1.6", optional = true } 35 | regex = { version = "1.8.1", optional = true } 36 | regex-syntax = { version = "0.8", optional = true } 37 | 38 | [workspace] 39 | members = ["fuzz"] 40 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021-2025 Brendan Molloy , 2 | Ilya Solovyiov , 3 | Kai Ren 4 | 5 | Permission is hereby granted, free of charge, to any 6 | person obtaining a copy of this software and associated 7 | documentation files (the "Software"), to deal in the 8 | Software without restriction, including without 9 | limitation the rights to use, copy, modify, merge, 10 | publish, distribute, sublicense, and/or sell copies of 11 | the Software, and to permit persons to whom the Software 12 | is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice 16 | shall be included in all copies or substantial portions 17 | of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 20 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 21 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 22 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 23 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 24 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 25 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 26 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 27 | DEALINGS IN THE SOFTWARE. 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ############################### 2 | # Common defaults/definitions # 3 | ############################### 4 | 5 | comma := , 6 | 7 | # Checks two given strings for equality. 8 | eq = $(if $(or $(1),$(2)),$(and $(findstring $(1),$(2)),\ 9 | $(findstring $(2),$(1))),1) 10 | 11 | 12 | 13 | 14 | ########### 15 | # Aliases # 16 | ########### 17 | 18 | 19 | docs: cargo.doc 20 | 21 | 22 | fmt: cargo.fmt 23 | 24 | 25 | fuzz: cargo.fuzz 26 | 27 | 28 | lint: cargo.lint 29 | 30 | 31 | test: test.cargo 32 | 33 | 34 | 35 | 36 | ################## 37 | # Cargo commands # 38 | ################## 39 | 40 | # Generate crate documentation from Rust sources. 41 | # 42 | # Usage: 43 | # make cargo.doc [private=(yes|no)] [docsrs=(no|yes)] 44 | # [open=(yes|no)] [clean=(no|yes)] 45 | 46 | cargo.doc: 47 | ifeq ($(clean),yes) 48 | @rm -rf target/doc/ 49 | endif 50 | $(if $(call eq,$(docsrs),yes),RUSTDOCFLAGS='--cfg docsrs',) \ 51 | cargo $(if $(call eq,$(docsrs),yes),+nightly,) doc -p cucumber-expressions \ 52 | --all-features \ 53 | $(if $(call eq,$(private),no),,--document-private-items) \ 54 | $(if $(call eq,$(open),no),,--open) 55 | 56 | 57 | # Format Rust sources with rustfmt. 58 | # 59 | # Usage: 60 | # make cargo.fmt [check=(no|yes)] 61 | 62 | cargo.fmt: 63 | cargo +nightly fmt --all $(if $(call eq,$(check),yes),-- --check,) 64 | 65 | 66 | # Fuzz Rust sources with cargo-fuzz. 67 | # 68 | # Usage: 69 | # make cargo.fuzz target= [time=] 70 | 71 | cargo.fuzz: 72 | cargo +nightly fuzz run $(target) \ 73 | $(if $(call eq,$(time),),,-- -max_total_time=$(time)) 74 | 75 | 76 | # Lint Rust sources with Clippy. 77 | # 78 | # Usage: 79 | # make cargo.lint 80 | 81 | cargo.lint: 82 | cargo clippy -p cucumber-expressions --all-features -- -D warnings 83 | 84 | 85 | cargo.test: test.cargo 86 | 87 | 88 | 89 | 90 | #################### 91 | # Testing commands # 92 | #################### 93 | 94 | # Run Rust tests of project crate. 95 | # 96 | # Usage: 97 | # make test.cargo [careful=(no|yes)] 98 | 99 | test.cargo: 100 | ifeq ($(careful),yes) 101 | ifeq ($(shell cargo install --list | grep cargo-careful),) 102 | cargo install cargo-careful 103 | endif 104 | ifeq ($(shell rustup component list --toolchain=nightly \ 105 | | grep 'rust-src (installed)'),) 106 | rustup component add --toolchain=nightly rust-src 107 | endif 108 | endif 109 | cargo $(if $(call eq,$(careful),yes),+nightly careful,) test \ 110 | -p cucumber-expressions \ 111 | --all-features 112 | 113 | 114 | 115 | 116 | ################## 117 | # .PHONY section # 118 | ################## 119 | 120 | .PHONY: docs fmt fuzz lint test \ 121 | cargo.doc cargo.fmt cargo.fuzz cargo.lint cargo.test \ 122 | test.cargo 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [Cucumber Expressions] for Rust 2 | =============================== 3 | 4 | [![crates.io](https://img.shields.io/crates/v/cucumber-expressions.svg?maxAge=2592000 "crates.io")](https://crates.io/crates/cucumber-expressions) 5 | [![Rust 1.85+](https://img.shields.io/badge/rustc-1.85+-lightgray.svg "Rust 1.85+")](https://blog.rust-lang.org/2025/02/20/Rust-1.85.0.html) 6 | [![Unsafe Forbidden](https://img.shields.io/badge/unsafe-forbidden-success.svg "Unsafe forbidden")](https://github.com/rust-secure-code/safety-dance)\ 7 | [![CI](https://github.com/cucumber-rs/cucumber-expressions/actions/workflows/ci.yml/badge.svg?branch=main "CI")](https://github.com/cucumber-rs/cucumber-expressions/actions?query=workflow%3ACI+branch%3Amain) 8 | [![Rust docs](https://docs.rs/cucumber-expressions/badge.svg "Rust docs")](https://docs.rs/cucumber-expressions) 9 | 10 | - [Changelog](https://github.com/cucumber-rs/cucumber-expressions/blob/v0.4.0/CHANGELOG.md) 11 | 12 | Rust implementation of [Cucumber Expressions]. 13 | 14 | This crate provides [AST] parser, and [`Regex`] expansion of [Cucumber Expressions]. 15 | 16 | ```rust 17 | # // `Regex` expansion requires `into-regex` feature. 18 | # #[cfg(feature = "into-regex")] { 19 | use cucumber_expressions::Expression; 20 | 21 | let re = Expression::regex("I have {int} cucumbers in my belly").unwrap(); 22 | let caps = re.captures("I have 42 cucumbers in my belly").unwrap(); 23 | 24 | assert_eq!(&caps[0], "I have 42 cucumbers in my belly"); 25 | assert_eq!(&caps[1], "42"); 26 | # } 27 | ``` 28 | 29 | 30 | 31 | 32 | ## Cargo features 33 | 34 | - `into-regex`: Enables expansion into [`Regex`]. 35 | 36 | 37 | 38 | 39 | ## Grammar 40 | 41 | This implementation follows a context-free grammar, [which isn't yet merged][1]. Original grammar is impossible to follow while creating a performant parser, as it consists errors and describes not an exact [Cucumber Expressions] language, but rather some superset language, while being also context-sensitive. In case you've found some inconsistencies between this implementation and the ones in other languages, please file an issue! 42 | 43 | [EBNF] spec of the current context-free grammar implemented by this crate: 44 | ```ebnf 45 | expression = single-expression* 46 | 47 | single-expression = alternation 48 | | optional 49 | | parameter 50 | | text-without-whitespace+ 51 | | whitespace+ 52 | text-without-whitespace = (- (text-to-escape | whitespace)) 53 | | ('\', text-to-escape) 54 | text-to-escape = '(' | '{' | '/' | '\' 55 | 56 | alternation = single-alternation, (`/`, single-alternation)+ 57 | single-alternation = ((text-in-alternative+, optional*) 58 | | (optional+, text-in-alternative+))+ 59 | text-in-alternative = (- alternative-to-escape) 60 | | ('\', alternative-to-escape) 61 | alternative-to-escape = whitespace | '(' | '{' | '/' | '\' 62 | whitespace = ' ' 63 | 64 | optional = '(' text-in-optional+ ')' 65 | text-in-optional = (- optional-to-escape) | ('\', optional-to-escape) 66 | optional-to-escape = '(' | ')' | '{' | '/' | '\' 67 | 68 | parameter = '{', name*, '}' 69 | name = (- name-to-escape) | ('\', name-to-escape) 70 | name-to-escape = '{' | '}' | '(' | '/' | '\' 71 | ``` 72 | 73 | 74 | 75 | 76 | ## [`Regex`] Production Rules 77 | 78 | Follows original [production rules][2]. 79 | 80 | 81 | 82 | 83 | ## License 84 | 85 | This project is licensed under either of 86 | 87 | * Apache License, Version 2.0 ([LICENSE-APACHE](https://github.com/cucumber-rs/cucumber-expressions/blob/v0.4.0/LICENSE-APACHE) or ) 88 | * MIT license ([LICENSE-MIT](https://github.com/cucumber-rs/cucumber-expressions/blob/v0.4.0/LICENSE-MIT) or ) 89 | 90 | at your option. 91 | 92 | 93 | 94 | 95 | [`Regex`]: https://docs.rs/regex 96 | 97 | [AST]: https://en.wikipedia.org/wiki/Abstract_syntax_tree 98 | [Cucumber Expressions]: https://github.com/cucumber/cucumber-expressions#readme 99 | [EBNF]: https://en.wikipedia.org/wiki/Extended_Backus–Naur_form 100 | 101 | [1]: https://github.com/cucumber/cucumber-expressions/issues/41 102 | [2]: https://github.com/cucumber/cucumber-expressions/blob/main/ARCHITECTURE.md#production-rules 103 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Security Policy 2 | =============== 3 | 4 | Security policy of [`cucumber-expressions`] crate. 5 | 6 | 7 | 8 | 9 | ## Supported versions 10 | 11 | Before going `1.0`, the [`cucumber-expressions`] crate maintains only the most recent minor release. 12 | 13 | 14 | 15 | 16 | ## Reporting a vulnerability 17 | 18 | Security is of the highest importance and all security vulnerabilities or suspected security vulnerabilities should be reported to this project privately, to minimize attacks against current users of [`cucumber-expressions`] crate before they are fixed. Vulnerabilities will be investigated and patched on the next patch (or minor) release as soon as possible. This information could be kept entirely internal to the project. 19 | 20 | 21 | ### Private disclosure process 22 | 23 | > **WARNING:** Do not file public issues on GitHub for security vulnerabilities. 24 | 25 | To report a vulnerability or a security-related issue, please use [GitHub private vulnerability reporting][11] on the [Security Advisories page][1] and fill the vulnerability details. It will be addressed within a week, including a detailed plan to investigate the issue and any potential workarounds to perform in the meantime. Do not report non-security-impacting bugs through this channel, use [GitHub issues][2] instead. 26 | 27 | 28 | ### Public disclosure process 29 | 30 | Project maintainers publish a [public advisory][1] to the community [via GitHub][12]. 31 | 32 | 33 | 34 | 35 | [`cucumber-expressions`]: https://docs.rs/cucumber-expressions 36 | 37 | [1]: /../../security/advisories 38 | [2]: /../../issues 39 | [11]: https://docs.github.com/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability 40 | [12]: https://docs.github.com/code-security/security-advisories/repository-security-advisories/publishing-a-repository-security-advisory#about-publishing-a-security-advisory 41 | -------------------------------------------------------------------------------- /fuzz/.gitignore: -------------------------------------------------------------------------------- 1 | /artifacts/ 2 | /corpus/ 3 | -------------------------------------------------------------------------------- /fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cucumber-expressions-fuzz" 3 | version = "0.0.0" 4 | edition = "2024" 5 | rust-version = "1.85" 6 | description = "Fuzz testing for `cucumber-expressions` crate." 7 | license = "MIT OR Apache-2.0" 8 | authors = [ 9 | "Ilya Solovyiov ", 10 | "Kai Ren ", 11 | ] 12 | publish = false 13 | 14 | [package.metadata] 15 | cargo-fuzz = true 16 | 17 | [dependencies] 18 | cucumber-expressions = { path = "..", features = ["into-regex"] } 19 | libfuzzer-sys = "0.4" 20 | 21 | [[bin]] 22 | name = "expression" 23 | path = "fuzz_targets/expression.rs" 24 | test = false 25 | doc = false 26 | 27 | [[bin]] 28 | name = "parameter" 29 | path = "fuzz_targets/parameter.rs" 30 | test = false 31 | doc = false 32 | 33 | [[bin]] 34 | name = "optional" 35 | path = "fuzz_targets/optional.rs" 36 | test = false 37 | doc = false 38 | 39 | [[bin]] 40 | name = "alternation" 41 | path = "fuzz_targets/alternation.rs" 42 | test = false 43 | doc = false 44 | 45 | [[bin]] 46 | name = "into-regex" 47 | path = "fuzz_targets/into_regex.rs" 48 | test = false 49 | doc = false 50 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/alternation.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use cucumber_expressions::parse; 4 | use libfuzzer_sys::fuzz_target; 5 | 6 | fuzz_target!(|data: &str| { 7 | let _ = parse::alternation(data); 8 | }); 9 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/expression.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use cucumber_expressions::parse; 4 | use libfuzzer_sys::fuzz_target; 5 | 6 | fuzz_target!(|data: &str| { 7 | let _ = parse::expression(data); 8 | }); 9 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/into_regex.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use cucumber_expressions::Expression; 4 | use libfuzzer_sys::fuzz_target; 5 | 6 | fuzz_target!(|data: &str| { 7 | let _ = Expression::regex(data); 8 | }); 9 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/optional.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use cucumber_expressions::parse; 4 | use libfuzzer_sys::fuzz_target; 5 | 6 | fuzz_target!(|data: &str| { 7 | let _ = parse::optional(data); 8 | }); 9 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/parameter.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use cucumber_expressions::parse; 4 | use libfuzzer_sys::fuzz_target; 5 | 6 | fuzz_target!(|data: &str| { 7 | let _ = parse::parameter(data, &mut 0); 8 | }); 9 | -------------------------------------------------------------------------------- /src/ast.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021-2025 Brendan Molloy , 2 | // Ilya Solovyiov , 3 | // Kai Ren 4 | // 5 | // Licensed under the Apache License, Version 2.0 or the MIT license 7 | // , at your 8 | // option. This file may not be copied, modified, or distributed 9 | // except according to those terms. 10 | 11 | //! [Cucumber Expressions][1] [AST]. 12 | //! 13 | //! See details in the [grammar spec][0]. 14 | //! 15 | //! [0]: crate#grammar 16 | //! [1]: https://github.com/cucumber/cucumber-expressions#readme 17 | //! [AST]: https://en.wikipedia.org/wiki/Abstract_syntax_tree 18 | 19 | use derive_more::with_trait::{AsRef, Deref, DerefMut}; 20 | use nom::{Err, Input, error::ErrorKind}; 21 | use nom_locate::LocatedSpan; 22 | 23 | use crate::parse; 24 | 25 | /// [`str`] along with its location information in the original input. 26 | pub type Spanned<'s> = LocatedSpan<&'s str>; 27 | 28 | /// Top-level `expression` defined in the [grammar spec][0]. 29 | /// 30 | /// See [`parse::expression()`] for the detailed grammar and examples. 31 | /// 32 | /// [0]: crate#grammar 33 | #[derive(AsRef, Clone, Debug, Deref, DerefMut, Eq, PartialEq)] 34 | pub struct Expression(pub Vec>); 35 | 36 | impl<'s> TryFrom<&'s str> for Expression> { 37 | type Error = parse::Error>; 38 | 39 | fn try_from(value: &'s str) -> Result { 40 | let (rest, parsed) = 41 | parse::expression(Spanned::new(value)).map_err(|e| match e { 42 | Err::Error(e) | Err::Failure(e) => e, 43 | Err::Incomplete(n) => parse::Error::Needed(n), 44 | })?; 45 | rest.is_empty() 46 | .then_some(parsed) 47 | .ok_or(parse::Error::Other(rest, ErrorKind::Verify)) 48 | } 49 | } 50 | 51 | impl<'s> Expression> { 52 | /// Parses the given `input` as an [`Expression`]. 53 | /// 54 | /// # Errors 55 | /// 56 | /// See [`parse::Error`] for details. 57 | pub fn parse + ?Sized>( 58 | input: &'s I, 59 | ) -> Result>> { 60 | Self::try_from(input.as_ref()) 61 | } 62 | } 63 | 64 | /// `single-expression` defined in the [grammar spec][0], representing a single 65 | /// entry of an [`Expression`]. 66 | /// 67 | /// See [`parse::single_expression()`] for the detailed grammar and examples. 68 | /// 69 | /// [0]: crate#grammar 70 | #[derive(Clone, Debug, Eq, PartialEq)] 71 | pub enum SingleExpression { 72 | /// [`alternation`][0] expression. 73 | /// 74 | /// [0]: crate#grammar 75 | Alternation(Alternation), 76 | 77 | /// [`optional`][0] expression. 78 | /// 79 | /// [0]: crate#grammar 80 | Optional(Optional), 81 | 82 | /// [`parameter`][0] expression. 83 | /// 84 | /// [0]: crate#grammar 85 | Parameter(Parameter), 86 | 87 | /// [`text-without-whitespace+`][0] expression. 88 | /// 89 | /// [0]: crate#grammar 90 | Text(Input), 91 | 92 | /// [`whitespace+`][0] expression. 93 | /// 94 | /// [0]: crate#grammar 95 | Whitespaces(Input), 96 | } 97 | 98 | /// `single-alternation` defined in the [grammar spec][0], representing a 99 | /// building block of an [`Alternation`]. 100 | /// 101 | /// [0]: crate#grammar 102 | pub type SingleAlternation = Vec>; 103 | 104 | /// `alternation` defined in the [grammar spec][0], allowing to match one of 105 | /// [`SingleAlternation`]s. 106 | /// 107 | /// See [`parse::alternation()`] for the detailed grammar and examples. 108 | /// 109 | /// [0]: crate#grammar 110 | #[derive(AsRef, Clone, Debug, Deref, DerefMut, Eq, PartialEq)] 111 | pub struct Alternation(pub Vec>); 112 | 113 | impl Alternation { 114 | /// Returns length of this [`Alternation`]'s span in the `Input`. 115 | pub(crate) fn span_len(&self) -> usize { 116 | self.0 117 | .iter() 118 | .flatten() 119 | .map(|alt| match alt { 120 | Alternative::Text(t) => t.input_len(), 121 | Alternative::Optional(opt) => opt.input_len() + 2, 122 | }) 123 | .sum::() 124 | + self.len() 125 | - 1 126 | } 127 | 128 | /// Indicates whether any of [`SingleAlternation`]s consists only from 129 | /// [`Optional`]s. 130 | pub(crate) fn contains_only_optional(&self) -> bool { 131 | (**self).iter().any(|single_alt| { 132 | single_alt.iter().all(|alt| matches!(alt, Alternative::Optional(_))) 133 | }) 134 | } 135 | } 136 | 137 | /// `alternative` defined in the [grammar spec][0]. 138 | /// 139 | /// See [`parse::alternative()`] for the detailed grammar and examples. 140 | /// 141 | /// [0]: crate#grammar 142 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 143 | pub enum Alternative { 144 | /// [`optional`][1] expression. 145 | /// 146 | /// [1]: crate#grammar 147 | Optional(Optional), 148 | 149 | /// Text. 150 | Text(Input), 151 | } 152 | 153 | /// `optional` defined in the [grammar spec][0], allowing to match an optional 154 | /// `Input`. 155 | /// 156 | /// See [`parse::optional()`] for the detailed grammar and examples. 157 | /// 158 | /// [0]: crate#grammar 159 | #[derive(AsRef, Clone, Copy, Debug, Deref, DerefMut, Eq, PartialEq)] 160 | pub struct Optional(pub Input); 161 | 162 | /// `parameter` defined in the [grammar spec][0], allowing to match some special 163 | /// `Input` described by a [`Parameter`] name. 164 | /// 165 | /// See [`parse::parameter()`] for the detailed grammar and examples. 166 | /// 167 | /// [0]: crate#grammar 168 | #[derive(AsRef, Clone, Copy, Debug, Deref, DerefMut, Eq, PartialEq)] 169 | pub struct Parameter { 170 | /// Inner `Input`. 171 | #[deref] 172 | #[deref_mut] 173 | pub input: Input, 174 | 175 | /// Unique ID of this [`Parameter`] in the parsed [`Expression`]. 176 | #[as_ref(ignore)] 177 | pub id: usize, 178 | } 179 | -------------------------------------------------------------------------------- /src/combinator.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021-2025 Brendan Molloy , 2 | // Ilya Solovyiov , 3 | // Kai Ren 4 | // 5 | // Licensed under the Apache License, Version 2.0 or the MIT license 7 | // , at your 8 | // option. This file may not be copied, modified, or distributed 9 | // except according to those terms. 10 | 11 | //! Helper parser combinators. 12 | 13 | use nom::{ 14 | AsChar, Err, IResult, Input, Offset, Parser, 15 | error::{ErrorKind, ParseError}, 16 | }; 17 | 18 | /// Applies the given `map` function to the `parser`'s [`IResult`] in case it 19 | /// represents an error. 20 | /// 21 | /// Can be used to harden an [`Error`] into a [`Failure`]. 22 | /// 23 | /// [`Error`]: nom::Err::Error 24 | /// [`Failure`]: nom::Err::Failure 25 | /// [`verify()`]: nom::combinator::verify() 26 | pub(crate) fn map_err( 27 | mut parser: F, 28 | mut map: G, 29 | ) -> impl FnMut(I) -> IResult 30 | where 31 | F: Parser, 32 | G: FnMut(Err) -> Err, 33 | { 34 | move |input: I| parser.parse(input).map_err(&mut map) 35 | } 36 | 37 | /// Matches a byte string with escaped characters. 38 | /// 39 | /// Differences from [`escaped()`]: 40 | /// 1. If `normal` matched empty sequence, tries to match escaped; 41 | /// 2. If `normal` matched empty sequence and then `escapable` didn't match 42 | /// anything, returns an empty sequence; 43 | /// 3. Errors with [`ErrorKind::Escaped`] if a `control_char` was followed by a 44 | /// non-`escapable` `Input` or end of line. 45 | /// 46 | /// [`escaped()`]: nom::bytes::complete::escaped() 47 | pub(crate) fn escaped0( 48 | mut normal: F, 49 | control_char: char, 50 | mut escapable: G, 51 | ) -> impl FnMut(I) -> IResult 52 | where 53 | I: Clone + Offset + Input, 54 | ::Item: AsChar, 55 | F: Parser, 56 | G: Parser, 57 | Error: ParseError, 58 | { 59 | move |input: I| { 60 | let mut i = input.clone(); 61 | let mut consumed_nothing = false; 62 | 63 | while i.input_len() > 0 { 64 | let current_len = i.input_len(); 65 | 66 | match (normal.parse(i.clone()), consumed_nothing) { 67 | (Ok((i2, _)), false) => { 68 | if i2.input_len() == 0 { 69 | return Ok((input.take_from(input.input_len()), input)); 70 | } 71 | if i2.input_len() == current_len { 72 | consumed_nothing = true; 73 | } 74 | i = i2; 75 | } 76 | (Ok(..), true) | (Err(Err::Error(_)), _) => { 77 | let next_char = i 78 | .iter_elements() 79 | .next() 80 | .ok_or_else(|| { 81 | Err::Error(Error::from_error_kind( 82 | i.clone(), 83 | ErrorKind::Escaped, 84 | )) 85 | })? 86 | .as_char(); 87 | if next_char == control_char { 88 | let next = control_char.len_utf8(); 89 | if next >= i.input_len() { 90 | return Err(Err::Error(Error::from_error_kind( 91 | input, 92 | ErrorKind::Escaped, 93 | ))); 94 | } 95 | match escapable.parse(i.take_from(next)) { 96 | Ok((i2, _)) => { 97 | if i2.input_len() == 0 { 98 | return Ok(( 99 | input.take_from(input.input_len()), 100 | input, 101 | )); 102 | } 103 | consumed_nothing = false; 104 | i = i2; 105 | } 106 | Err(_) => { 107 | return Err(Err::Error( 108 | Error::from_error_kind( 109 | i, 110 | ErrorKind::Escaped, 111 | ), 112 | )); 113 | } 114 | } 115 | } else { 116 | let index = input.offset(&i); 117 | return Ok(input.take_split(index)); 118 | } 119 | } 120 | (Err(e), _) => { 121 | return Err(e); 122 | } 123 | } 124 | } 125 | 126 | Ok((input.take_from(input.input_len()), input)) 127 | } 128 | } 129 | 130 | #[cfg(test)] 131 | mod escaped0_spec { 132 | use nom::{ 133 | Err, IResult, 134 | bytes::complete::escaped, 135 | character::complete::{digit0, digit1, one_of}, 136 | error::{Error, ErrorKind}, 137 | }; 138 | 139 | use super::escaped0; 140 | 141 | /// Type used to compare behaviour of [`escaped`] and [`escaped0`]. 142 | /// 143 | /// Tuple is constructed from the following parsers results: 144 | /// - [`escaped0`]`(`[`digit0`]`, '\\', `[`one_of`]`(r#""n\"#))` 145 | /// - [`escaped0`]`(`[`digit1`]`, '\\', `[`one_of`]`(r#""n\"#))` 146 | /// - [`escaped`]`(`[`digit0`]`, '\\', `[`one_of`]`(r#""n\"#))` 147 | /// - [`escaped`]`(`[`digit1`]`, '\\', `[`one_of`]`(r#""n\"#))` 148 | type TestResult<'s> = ( 149 | IResult<&'s str, &'s str>, 150 | IResult<&'s str, &'s str>, 151 | IResult<&'s str, &'s str>, 152 | IResult<&'s str, &'s str>, 153 | ); 154 | 155 | /// Produces a [`TestResult`] from the given `input`. 156 | fn get_result(input: &str) -> TestResult<'_> { 157 | ( 158 | escaped0(digit0, '\\', one_of(r#""n\"#))(input), 159 | escaped0(digit1, '\\', one_of(r#""n\"#))(input), 160 | escaped(digit0, '\\', one_of(r#""n\"#))(input), 161 | escaped(digit1, '\\', one_of(r#""n\"#))(input), 162 | ) 163 | } 164 | 165 | #[test] 166 | fn matches_empty() { 167 | assert_eq!( 168 | get_result(""), 169 | (Ok(("", "")), Ok(("", "")), Ok(("", "")), Ok(("", ""))), 170 | ); 171 | } 172 | 173 | #[test] 174 | fn matches_normal() { 175 | assert_eq!( 176 | get_result("123;"), 177 | ( 178 | Ok((";", "123")), 179 | Ok((";", "123")), 180 | Ok((";", "123")), 181 | Ok((";", "123")) 182 | ), 183 | ); 184 | } 185 | 186 | #[test] 187 | fn matches_only_escaped() { 188 | assert_eq!( 189 | get_result(r#"\n\";"#), 190 | ( 191 | Ok((";", r#"\n\""#)), 192 | Ok((";", r#"\n\""#)), 193 | Ok((r#"\n\";"#, "")), 194 | Ok((";", r#"\n\""#)), 195 | ), 196 | ); 197 | } 198 | 199 | #[test] 200 | fn matches_escaped_followed_by_normal() { 201 | assert_eq!( 202 | get_result(r#"\n\"123;"#), 203 | ( 204 | Ok((";", r#"\n\"123"#)), 205 | Ok((";", r#"\n\"123"#)), 206 | Ok((r#"\n\"123;"#, "")), 207 | Ok((";", r#"\n\"123"#)), 208 | ), 209 | ); 210 | } 211 | 212 | #[test] 213 | fn matches_normal_followed_by_escaped() { 214 | assert_eq!( 215 | get_result(r#"123\n\";"#), 216 | ( 217 | Ok((";", r#"123\n\""#)), 218 | Ok((";", r#"123\n\""#)), 219 | Ok((r#"\n\";"#, "123")), 220 | Ok((";", r#"123\n\""#)), 221 | ), 222 | ); 223 | } 224 | 225 | #[test] 226 | fn matches_escaped_followed_by_normal_then_escaped() { 227 | assert_eq!( 228 | get_result(r#"\n\"123\n;"#), 229 | ( 230 | Ok((";", r#"\n\"123\n"#)), 231 | Ok((";", r#"\n\"123\n"#)), 232 | Ok((r#"\n\"123\n;"#, "")), 233 | Ok((";", r#"\n\"123\n"#)), 234 | ), 235 | ); 236 | } 237 | 238 | #[test] 239 | fn matches_normal_followed_by_escaped_then_normal() { 240 | assert_eq!( 241 | get_result(r#"123\n\"567;"#), 242 | ( 243 | Ok((";", r#"123\n\"567"#)), 244 | Ok((";", r#"123\n\"567"#)), 245 | Ok((r#"\n\"567;"#, "123")), 246 | Ok((";", r#"123\n\"567"#)), 247 | ), 248 | ); 249 | } 250 | 251 | #[test] 252 | fn errors_on_escaped_non_reserved() { 253 | assert_eq!( 254 | get_result(r#"\n\r"#), 255 | ( 256 | Err(Err::Error(Error { 257 | input: r#"\r"#, 258 | code: ErrorKind::Escaped, 259 | })), 260 | Err(Err::Error(Error { 261 | input: r#"\r"#, 262 | code: ErrorKind::Escaped, 263 | })), 264 | Ok((r#"\n\r"#, "")), 265 | Err(Err::Error(Error { 266 | input: r#"r"#, 267 | code: ErrorKind::OneOf, 268 | })), 269 | ), 270 | ); 271 | } 272 | 273 | #[test] 274 | fn errors_on_ending_with_control_char() { 275 | assert_eq!( 276 | get_result("\\"), 277 | ( 278 | Err(Err::Error(Error { 279 | input: "\\", 280 | code: ErrorKind::Escaped, 281 | })), 282 | Err(Err::Error(Error { 283 | input: "\\", 284 | code: ErrorKind::Escaped, 285 | })), 286 | Ok(("\\", "")), 287 | Err(Err::Error(Error { 288 | input: "\\", 289 | code: ErrorKind::Escaped, 290 | })), 291 | ), 292 | ); 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /src/expand/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021-2025 Brendan Molloy , 2 | // Ilya Solovyiov , 3 | // Kai Ren 4 | // 5 | // Licensed under the Apache License, Version 2.0 or the MIT license 7 | // , at your 8 | // option. This file may not be copied, modified, or distributed 9 | // except according to those terms. 10 | 11 | //! [Cucumber Expressions][0] [AST] into [`Regex`] expansion. 12 | //! 13 | //! Follows original [production rules][1]. 14 | //! 15 | //! [`Regex`]: regex::Regex 16 | //! [0]: https://github.com/cucumber/cucumber-expressions#readme 17 | //! [1]: https://git.io/J159T 18 | //! [AST]: https://en.wikipedia.org/wiki/Abstract_syntax_tree 19 | 20 | pub mod parameters; 21 | 22 | use std::{iter, str, vec}; 23 | 24 | use derive_more::with_trait::{Debug, Display, Error as StdError, From}; 25 | use either::Either; 26 | use nom::{AsChar, Input}; 27 | use regex::Regex; 28 | 29 | pub use self::parameters::{ 30 | Provider as ParametersProvider, WithCustom as WithCustomParameters, 31 | }; 32 | use crate::{ 33 | Alternation, Alternative, Expression, Optional, Parameter, 34 | SingleAlternation, SingleExpression, Spanned, parse, 35 | }; 36 | 37 | impl<'s> Expression> { 38 | /// Parses the given `input` as an [`Expression`], and immediately expands 39 | /// it into the appropriate [`Regex`]. 40 | /// 41 | /// # Parameter types 42 | /// 43 | /// Text between curly braces references a *parameter type*. 44 | /// [Cucumber Expressions][0] come with the following 45 | /// [built-in parameter types][1]: 46 | /// 47 | /// | Parameter Type | Description | 48 | /// | --------------- | ---------------------------------------------- | 49 | /// | `{int}` | Matches integers | 50 | /// | `{float}` | Matches floats | 51 | /// | `{word}` | Matches words without whitespace | 52 | /// | `{string}` | Matches single-quoted or double-quoted strings | 53 | /// | `{}` anonymous | Matches anything (`/.*/`) | 54 | /// 55 | /// To expand an [`Expression`] with custom parameter types in addition to 56 | /// the built-in ones, use [`Expression::regex_with_parameters()`]. 57 | /// 58 | /// # Errors 59 | /// 60 | /// See [`Error`] for more details. 61 | /// 62 | /// [`Error`]: enum@Error 63 | /// [0]: https://github.com/cucumber/cucumber-expressions#readme 64 | /// [1]: https://github.com/cucumber/cucumber-expressions#parameter-types 65 | pub fn regex + ?Sized>( 66 | input: &'s I, 67 | ) -> Result>> { 68 | let re_str = Expression::parse(input)? 69 | .into_regex_char_iter() 70 | .collect::>()?; 71 | Regex::new(&re_str).map_err(Into::into) 72 | } 73 | 74 | /// Parses the given `input` as an [`Expression`], and immediately expands 75 | /// it into the appropriate [`Regex`], considering the custom defined 76 | /// `parameters` in addition to [default ones][1]. 77 | /// 78 | /// # Errors 79 | /// 80 | /// See [`Error`] for more details. 81 | /// 82 | /// # Example 83 | /// 84 | /// ```rust 85 | /// # use std::collections::HashMap; 86 | /// # 87 | /// # use cucumber_expressions::Expression; 88 | /// # 89 | /// let parameters = HashMap::from([("color", "[Rr]ed|[Gg]reen|[Bb]lue")]); 90 | /// let re = Expression::regex_with_parameters( 91 | /// "{word} has {color} eyes", 92 | /// ¶meters, 93 | /// ) 94 | /// .unwrap(); 95 | /// let re = re.as_str(); 96 | /// 97 | /// assert_eq!(re, "^([^\\s]+) has ([Rr]ed|[Gg]reen|[Bb]lue) eyes$"); 98 | /// ``` 99 | /// 100 | /// [`Error`]: enum@Error 101 | /// [1]: https://github.com/cucumber/cucumber-expressions#parameter-types 102 | pub fn regex_with_parameters( 103 | input: &'s I, 104 | parameters: Parameters, 105 | ) -> Result>> 106 | where 107 | I: AsRef + ?Sized, 108 | Parameters: Clone + ParametersProvider>, 109 | Parameters::Value: Input, 110 | ::Item: AsChar, 111 | { 112 | let re_str = Expression::parse(input)? 113 | .with_parameters(parameters) 114 | .into_regex_char_iter() 115 | .collect::>()?; 116 | Regex::new(&re_str).map_err(Into::into) 117 | } 118 | 119 | /// Creates a parser, parsing [`Expression`]s and immediately expanding them 120 | /// into appropriate [`Regex`]es, considering the custom defined 121 | /// `parameters` in addition to [default ones][1]. 122 | /// 123 | /// [1]: https://github.com/cucumber/cucumber-expressions#parameter-types 124 | pub const fn with_parameters>>( 125 | self, 126 | parameters: P, 127 | ) -> WithCustomParameters { 128 | WithCustomParameters { element: self, parameters } 129 | } 130 | } 131 | 132 | /// Possible errors while parsing `Input` representing a 133 | /// [Cucumber Expression][0] and expanding it into a [`Regex`]. 134 | /// 135 | /// [0]: https://github.com/cucumber/cucumber-expressions#readme 136 | #[derive(Clone, Debug, Display, From, StdError)] 137 | pub enum Error { 138 | /// Parsing error. 139 | #[display("Parsing failed: {_0}")] 140 | Parsing(parse::Error), 141 | 142 | /// Expansion error. 143 | #[display("Failed to expand regex: {_0}")] 144 | Expansion(ParameterError), 145 | 146 | /// [`Regex`] creation error. 147 | #[display("Regex creation failed: {_0}")] 148 | Regex(regex::Error), 149 | } 150 | 151 | /// Possible [`Parameter`] errors being used in an [`Expression`]. 152 | #[derive(Clone, Debug, Display, StdError)] 153 | pub enum ParameterError { 154 | /// [`Parameter`] not found. 155 | #[display("Parameter `{_0}` not found")] 156 | NotFound(Input), 157 | 158 | /// Failed to rename [`Regex`] capturing group. 159 | #[display( 160 | "Failed to rename capturing groups in regex `{re}` of \ 161 | parameter `{parameter}`: {err}" 162 | )] 163 | RenameRegexGroup { 164 | /// [`Parameter`] name. 165 | parameter: Input, 166 | 167 | /// [`Regex`] of the [`Parameter`]. 168 | re: String, 169 | 170 | /// [`Error`] of parsing the [`Regex`] with renamed capturing groups. 171 | /// 172 | /// [`Error`]: regex_syntax::Error 173 | err: Box, 174 | }, 175 | } 176 | 177 | /// Expansion of a [Cucumber Expressions][0] [AST] element into a [`Regex`] by 178 | /// producing a [`char`]s [`Iterator`] following original [production rules][1]. 179 | /// 180 | /// [0]: https://github.com/cucumber/cucumber-expressions#readme 181 | /// [1]: https://git.io/J159T 182 | /// [AST]: https://en.wikipedia.org/wiki/Abstract_syntax_tree 183 | pub trait IntoRegexCharIter { 184 | /// Type of [`Iterator`] performing the expansion. 185 | type Iter: Iterator>>; 186 | 187 | /// Consumes this [AST] element returning an [`Iterator`] over [`char`]s 188 | /// transformable into a [`Regex`]. 189 | /// 190 | /// [AST]: https://github.com/cucumber/cucumber-expressions#readme 191 | fn into_regex_char_iter(self) -> Self::Iter; 192 | } 193 | 194 | impl IntoRegexCharIter for Expression 195 | where 196 | I: Clone + Display + Input, 197 | ::Item: AsChar, 198 | { 199 | type Iter = ExpressionIter; 200 | 201 | fn into_regex_char_iter(self) -> Self::Iter { 202 | let into_regex_char_iter: fn(_) -> _ = 203 | IntoRegexCharIter::into_regex_char_iter; 204 | 205 | iter::once(Ok('^')) 206 | .chain(self.0.into_iter().flat_map(into_regex_char_iter)) 207 | .chain(iter::once(Ok('$'))) 208 | } 209 | } 210 | 211 | // TODO: Replace with TAIT, once stabilized: 212 | // https://github.com/rust-lang/rust/issues/63063 213 | /// [`IntoRegexCharIter::Iter`] for an [`Expression`]. 214 | type ExpressionIter = iter::Chain< 215 | iter::Chain< 216 | iter::Once>>, 217 | iter::FlatMap< 218 | vec::IntoIter>, 219 | as IntoRegexCharIter>::Iter, 220 | fn( 221 | SingleExpression, 222 | ) 223 | -> as IntoRegexCharIter>::Iter, 224 | >, 225 | >, 226 | iter::Once>>, 227 | >; 228 | 229 | impl IntoRegexCharIter for SingleExpression 230 | where 231 | I: Clone + Display + Input, 232 | ::Item: AsChar, 233 | { 234 | type Iter = SingleExpressionIter; 235 | 236 | fn into_regex_char_iter(self) -> Self::Iter { 237 | use Either::{Left, Right}; 238 | 239 | let ok: fn(_) -> _ = Ok; 240 | let as_char: fn(_) -> _ = AsChar::as_char; 241 | 242 | match self { 243 | Self::Alternation(alt) => Left(alt.into_regex_char_iter()), 244 | Self::Optional(opt) => Right(Left(opt.into_regex_char_iter())), 245 | Self::Parameter(p) => Right(Right(Left(p.into_regex_char_iter()))), 246 | Self::Text(t) | Self::Whitespaces(t) => Right(Right(Right( 247 | EscapeForRegex::new(t.iter_elements().map(as_char)).map(ok), 248 | ))), 249 | } 250 | } 251 | } 252 | 253 | // TODO: Replace with TAIT, once stabilized: 254 | // https://github.com/rust-lang/rust/issues/63063 255 | /// [`IntoRegexCharIter::Iter`] for a [`SingleExpression`]. 256 | type SingleExpressionIter = Either< 257 | as IntoRegexCharIter>::Iter, 258 | Either< 259 | as IntoRegexCharIter>::Iter, 260 | Either< 261 | as IntoRegexCharIter>::Iter, 262 | iter::Map< 263 | EscapeForRegex< 264 | iter::Map< 265 | ::Iter, 266 | fn(::Item) -> char, 267 | >, 268 | >, 269 | MapOkChar, 270 | >, 271 | >, 272 | >, 273 | >; 274 | 275 | impl IntoRegexCharIter for Alternation 276 | where 277 | I: Display + Input, 278 | ::Item: AsChar, 279 | { 280 | type Iter = AlternationIter; 281 | 282 | fn into_regex_char_iter(self) -> Self::Iter { 283 | let ok: fn(_) -> _ = Ok; 284 | let single_alt: fn(SingleAlternation) -> _ = |alt| { 285 | let into_regex_char_iter: fn(_) -> _ = 286 | IntoRegexCharIter::into_regex_char_iter; 287 | 288 | alt.into_iter() 289 | .flat_map(into_regex_char_iter) 290 | .chain(iter::once(Ok('|'))) 291 | }; 292 | 293 | "(?:" 294 | .chars() 295 | .map(ok) 296 | .chain(SkipLast::new(self.0.into_iter().flat_map(single_alt))) 297 | .chain(iter::once(Ok(')'))) 298 | } 299 | } 300 | 301 | // TODO: Replace with TAIT, once stabilized: 302 | // https://github.com/rust-lang/rust/issues/63063 303 | /// [`IntoRegexCharIter::Iter`] for an [`Alternation`]. 304 | type AlternationIter = iter::Chain< 305 | iter::Chain< 306 | iter::Map, MapOkChar>, 307 | SkipLast< 308 | iter::FlatMap< 309 | vec::IntoIter>, 310 | AlternationIterInner, 311 | fn(SingleAlternation) -> AlternationIterInner, 312 | >, 313 | >, 314 | >, 315 | iter::Once>>, 316 | >; 317 | 318 | // TODO: Replace with TAIT, once stabilized: 319 | // https://github.com/rust-lang/rust/issues/63063 320 | /// Inner type of [`AlternationIter`]. 321 | type AlternationIterInner = iter::Chain< 322 | iter::FlatMap< 323 | vec::IntoIter>, 324 | as IntoRegexCharIter>::Iter, 325 | fn(Alternative) -> as IntoRegexCharIter>::Iter, 326 | >, 327 | iter::Once>>, 328 | >; 329 | 330 | impl IntoRegexCharIter for Alternative 331 | where 332 | I: Display + Input, 333 | ::Item: AsChar, 334 | { 335 | type Iter = AlternativeIter; 336 | 337 | fn into_regex_char_iter(self) -> Self::Iter { 338 | use Either::{Left, Right}; 339 | 340 | let as_char: fn(::Item) -> char = AsChar::as_char; 341 | 342 | match self { 343 | Self::Optional(opt) => Left(opt.into_regex_char_iter()), 344 | Self::Text(text) => Right( 345 | EscapeForRegex::new(text.iter_elements().map(as_char)).map(Ok), 346 | ), 347 | } 348 | } 349 | } 350 | 351 | // TODO: Replace with TAIT, once stabilized: 352 | // https://github.com/rust-lang/rust/issues/63063 353 | /// [`IntoRegexCharIter::Iter`] for an [`Alternative`]. 354 | type AlternativeIter = Either< 355 | as IntoRegexCharIter>::Iter, 356 | iter::Map< 357 | EscapeForRegex< 358 | iter::Map<::Iter, fn(::Item) -> char>, 359 | >, 360 | MapOkChar, 361 | >, 362 | >; 363 | 364 | impl IntoRegexCharIter for Optional 365 | where 366 | I: Display + Input, 367 | ::Item: AsChar, 368 | { 369 | type Iter = OptionalIter; 370 | 371 | fn into_regex_char_iter(self) -> Self::Iter { 372 | let as_char: fn(::Item) -> char = AsChar::as_char; 373 | 374 | "(?:" 375 | .chars() 376 | .chain(EscapeForRegex::new(self.0.iter_elements().map(as_char))) 377 | .chain(")?".chars()) 378 | .map(Ok) 379 | } 380 | } 381 | 382 | // TODO: Replace with TAIT, once stabilized: 383 | // https://github.com/rust-lang/rust/issues/63063 384 | /// [`IntoRegexCharIter::Iter`] for an [`Optional`]. 385 | type OptionalIter = iter::Map< 386 | iter::Chain< 387 | iter::Chain< 388 | str::Chars<'static>, 389 | EscapeForRegex< 390 | iter::Map<::Iter, fn(::Item) -> char>, 391 | >, 392 | >, 393 | str::Chars<'static>, 394 | >, 395 | MapOkChar, 396 | >; 397 | 398 | /// Function pointer describing [`Ok`]. 399 | type MapOkChar = fn(char) -> Result>; 400 | 401 | impl IntoRegexCharIter for Parameter 402 | where 403 | I: Clone + Display + Input, 404 | ::Item: AsChar, 405 | { 406 | type Iter = ParameterIter; 407 | 408 | fn into_regex_char_iter(self) -> Self::Iter { 409 | use Either::{Left, Right}; 410 | 411 | let eq = |i: &I, str: &str| { 412 | i.iter_elements().map(AsChar::as_char).eq(str.chars()) 413 | }; 414 | 415 | if eq(&self.input, "int") { 416 | Left(Left(r"((?:-?\d+)|(?:\d+))".chars().map(Ok))) 417 | } else if eq(&self.input, "float") { 418 | // Regex in other implementations has lookaheads. As `regex` crate 419 | // doesn't support them, we use `f32`/`f64` grammar instead: 420 | // https://doc.rust-lang.org/stable/std/primitive.f64.html#grammar 421 | // Provided grammar is a superset of the original one: 422 | // - supports `e` as exponent in addition to `E` 423 | // - supports trailing comma: `1.` 424 | // - supports `inf` and `NaN` 425 | Left(Left( 426 | "([+-]?(?:inf\ 427 | |NaN\ 428 | |(?:\\d+|\\d+\\.\\d*|\\d*\\.\\d+)(?:[eE][+-]?\\d+)?\ 429 | ))" 430 | .chars() 431 | .map(Ok), 432 | )) 433 | } else if eq(&self.input, "word") { 434 | Left(Left(r"([^\s]+)".chars().map(Ok))) 435 | } else if eq(&self.input, "string") { 436 | Left(Right( 437 | OwnedChars::new(format!( 438 | "(?:\ 439 | \"(?P<__{id}_0>[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"\ 440 | |'(?P<__{id}_1>[^'\\\\]*(?:\\\\.[^'\\\\]*)*)'\ 441 | )", 442 | id = self.id, 443 | )) 444 | .map(Ok), 445 | )) 446 | } else if eq(&self.input, "") { 447 | Left(Left("(.*)".chars().map(Ok))) 448 | } else { 449 | Right(iter::once(Err(ParameterError::NotFound(self.input)))) 450 | } 451 | } 452 | } 453 | 454 | // TODO: Replace with TAIT, once stabilized: 455 | // https://github.com/rust-lang/rust/issues/63063 456 | /// [`IntoRegexCharIter::Iter`] for a [`Parameter`]. 457 | type ParameterIter = Either< 458 | Either< 459 | iter::Map< 460 | str::Chars<'static>, 461 | fn(char) -> Result>, 462 | >, 463 | iter::Map Result>>, 464 | >, 465 | iter::Once>>, 466 | >; 467 | 468 | /// [`Iterator`] for skipping a last [`Item`]. 469 | /// 470 | /// [`Item`]: Iterator::Item 471 | #[derive(Debug)] 472 | pub struct SkipLast { 473 | /// Inner [`Iterator`] to skip the last [`Item`] from. 474 | /// 475 | /// [`Item`]: Iterator::Item 476 | iter: iter::Peekable, 477 | } 478 | 479 | impl Clone for SkipLast 480 | where 481 | Iter: Clone + Iterator, 482 | Iter::Item: Clone, 483 | { 484 | fn clone(&self) -> Self { 485 | Self { iter: self.iter.clone() } 486 | } 487 | } 488 | 489 | impl SkipLast { 490 | /// Creates a new [`SkipLast`] [`Iterator`]. 491 | pub fn new(iter: Iter) -> Self { 492 | Self { iter: iter.peekable() } 493 | } 494 | } 495 | 496 | impl Iterator for SkipLast 497 | where 498 | Iter: Iterator, 499 | { 500 | type Item = Iter::Item; 501 | 502 | fn next(&mut self) -> Option { 503 | let next = self.iter.next(); 504 | (self.iter.peek().is_some()).then_some(next).flatten() 505 | } 506 | } 507 | 508 | // TODO: Make private, once TAIT stabilized: 509 | // https://github.com/rust-lang/rust/issues/63063 510 | /// Like [`str::Chars`] [`Iterator`], but owns its [`String`]. 511 | #[derive(Clone, Debug)] 512 | pub struct OwnedChars { 513 | /// Iterated [`String`]. 514 | str: String, 515 | 516 | /// Current char number. 517 | cur: usize, 518 | } 519 | 520 | impl OwnedChars { 521 | /// Creates a new [`OwnedChars`] [`Iterator`]. 522 | #[must_use] 523 | pub const fn new(str: String) -> Self { 524 | Self { str, cur: 0 } 525 | } 526 | } 527 | 528 | impl Iterator for OwnedChars { 529 | type Item = char; 530 | 531 | fn next(&mut self) -> Option { 532 | let char = self.str.chars().nth(self.cur)?; 533 | self.cur += 1; 534 | Some(char) 535 | } 536 | } 537 | 538 | /// [`Iterator`] for escaping `^`, `$`, `[`, `]`, `(`, `)`, `{`, `}`, `.`, `|`, 539 | /// `?`, `*`, `+` with `\`, and removing it for other [`char`]s. 540 | /// 541 | /// # Example 542 | /// 543 | /// ```rust 544 | /// # use cucumber_expressions::expand::EscapeForRegex; 545 | /// # 546 | /// assert_eq!( 547 | /// EscapeForRegex::new("\\\\text\\ (\\)\\".chars()).collect::(), 548 | /// "\\\\text \\(\\)", 549 | /// ); 550 | /// ``` 551 | #[derive(Clone, Debug)] 552 | pub struct EscapeForRegex { 553 | /// Inner [`Iterator`] for escaping. 554 | iter: iter::Peekable, 555 | 556 | /// [`Item`] that was escaped. 557 | /// 558 | /// [`Item`]: Iterator::Item 559 | was_escaped: Option, 560 | } 561 | 562 | impl EscapeForRegex { 563 | /// Creates a new [`EscapeForRegex`] [`Iterator`]. 564 | pub fn new(iter: Iter) -> Self { 565 | Self { iter: iter.peekable(), was_escaped: None } 566 | } 567 | } 568 | 569 | impl Iterator for EscapeForRegex 570 | where 571 | Iter: Iterator, 572 | { 573 | type Item = char; 574 | 575 | fn next(&mut self) -> Option { 576 | let should_be_escaped = |c| "^$[]()\\{}.|?*+".contains(c); 577 | 578 | if self.was_escaped.is_some() { 579 | return self.was_escaped.take(); 580 | } 581 | 582 | loop { 583 | return match self.iter.next() { 584 | Some('\\') => { 585 | let c = *self.iter.peek()?; 586 | if should_be_escaped(c) { 587 | self.was_escaped = self.iter.next(); 588 | Some('\\') 589 | } else { 590 | continue; 591 | } 592 | } 593 | Some(c) if should_be_escaped(c) => { 594 | self.was_escaped = Some(c); 595 | Some('\\') 596 | } 597 | Some(c) => Some(c), 598 | None => None, 599 | }; 600 | } 601 | } 602 | } 603 | 604 | // All test examples from: 605 | // Naming of test cases is preserved. 606 | #[cfg(test)] 607 | mod spec { 608 | use super::{Error, Expression, ParameterError}; 609 | 610 | #[test] 611 | fn alternation_with_optional() { 612 | let expr = Expression::regex("a/b(c)") 613 | .unwrap_or_else(|e| panic!("failed: {e}")); 614 | 615 | assert_eq!(expr.as_str(), "^(?:a|b(?:c)?)$"); 616 | } 617 | 618 | #[test] 619 | fn alternation() { 620 | let expr = Expression::regex("a/b c/d/e") 621 | .unwrap_or_else(|e| panic!("failed: {e}")); 622 | 623 | assert_eq!(expr.as_str(), "^(?:a|b) (?:c|d|e)$"); 624 | assert!(expr.is_match("a c")); 625 | assert!(expr.is_match("b e")); 626 | assert!(!expr.is_match("c e")); 627 | assert!(!expr.is_match("a")); 628 | assert!(!expr.is_match("a ")); 629 | } 630 | 631 | #[test] 632 | fn empty() { 633 | let expr = 634 | Expression::regex("").unwrap_or_else(|e| panic!("failed: {e}")); 635 | 636 | assert_eq!(expr.as_str(), "^$"); 637 | assert!(expr.is_match("")); 638 | assert!(!expr.is_match("a")); 639 | } 640 | 641 | #[test] 642 | fn escape_regex_characters() { 643 | let expr = Expression::regex(r"^$[]\()\{}\\.|?*+") 644 | .unwrap_or_else(|e| panic!("failed: {e}")); 645 | 646 | assert_eq!(expr.as_str(), r"^\^\$\[\]\(\)\{\}\\\.\|\?\*\+$"); 647 | assert!(expr.is_match("^$[](){}\\.|?*+")); 648 | } 649 | 650 | #[test] 651 | fn optional() { 652 | let expr = 653 | Expression::regex("(a)").unwrap_or_else(|e| panic!("failed: {e}")); 654 | 655 | assert_eq!(expr.as_str(), "^(?:a)?$"); 656 | assert!(expr.is_match("")); 657 | assert!(expr.is_match("a")); 658 | assert!(!expr.is_match("b")); 659 | } 660 | 661 | #[test] 662 | fn parameter_int() { 663 | let expr = Expression::regex("{int}") 664 | .unwrap_or_else(|e| panic!("failed: {e}")); 665 | 666 | assert_eq!(expr.as_str(), "^((?:-?\\d+)|(?:\\d+))$"); 667 | assert!(expr.is_match("123")); 668 | assert!(expr.is_match("-123")); 669 | assert!(!expr.is_match("+123")); 670 | assert!(!expr.is_match("123.")); 671 | } 672 | 673 | #[test] 674 | fn parameter_float() { 675 | let expr = Expression::regex("{float}") 676 | .unwrap_or_else(|e| panic!("failed: {e}")); 677 | 678 | assert_eq!( 679 | expr.as_str(), 680 | "^([+-]?(?:inf\ 681 | |NaN\ 682 | |(?:\\d+|\\d+\\.\\d*|\\d*\\.\\d+)(?:[eE][+-]?\\d+)?\ 683 | ))$", 684 | ); 685 | assert!(expr.is_match("+1")); 686 | assert!(expr.is_match(".1")); 687 | assert!(expr.is_match("-.1")); 688 | assert!(expr.is_match("-1.")); 689 | assert!(expr.is_match("-1.1E+1")); 690 | assert!(expr.is_match("-inf")); 691 | assert!(expr.is_match("NaN")); 692 | } 693 | 694 | #[test] 695 | fn parameter_word() { 696 | let expr = Expression::regex("{word}") 697 | .unwrap_or_else(|e| panic!("failed: {e}")); 698 | 699 | assert_eq!(expr.as_str(), "^([^\\s]+)$"); 700 | assert!(expr.is_match("test")); 701 | assert!(expr.is_match("\"test\"")); 702 | assert!(!expr.is_match("with space")); 703 | } 704 | 705 | #[test] 706 | fn parameter_string() { 707 | let expr = Expression::regex("{string}") 708 | .unwrap_or_else(|e| panic!("failed: {e}")); 709 | 710 | assert_eq!( 711 | expr.as_str(), 712 | "^(?:\ 713 | \"(?P<__0_0>[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"\ 714 | |'(?P<__0_1>[^'\\\\]*(?:\\\\.[^'\\\\]*)*)'\ 715 | )$", 716 | ); 717 | assert!(expr.is_match("\"\"")); 718 | assert!(expr.is_match("''")); 719 | assert!(expr.is_match("'with \"'")); 720 | assert!(expr.is_match("\"with '\"")); 721 | assert!(expr.is_match("\"with \\\" escaped\"")); 722 | assert!(expr.is_match("'with \\' escaped'")); 723 | assert!(!expr.is_match("word")); 724 | } 725 | 726 | #[test] 727 | fn multiple_string_parameters() { 728 | let expr = Expression::regex("{string} {string}") 729 | .unwrap_or_else(|e| panic!("failed: {e}")); 730 | 731 | assert_eq!( 732 | expr.as_str(), 733 | "^(?:\ 734 | \"(?P<__0_0>[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"\ 735 | |'(?P<__0_1>[^'\\\\]*(?:\\\\.[^'\\\\]*)*)'\ 736 | ) (?:\ 737 | \"(?P<__1_0>[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"\ 738 | |'(?P<__1_1>[^'\\\\]*(?:\\\\.[^'\\\\]*)*)'\ 739 | )$", 740 | ); 741 | assert!(expr.is_match("\"\" ''")); 742 | assert!(expr.is_match("'' \"\"")); 743 | assert!(expr.is_match("'with \"' \"\"")); 744 | assert!(expr.is_match("\"with '\" '\"'")); 745 | assert!(expr.is_match("\"with \\\" escaped\" 'with \\' escaped'")); 746 | assert!(expr.is_match("'with \\' escaped' \"with \\\" escaped\"")); 747 | } 748 | 749 | #[test] 750 | fn parameter_all() { 751 | let expr = 752 | Expression::regex("{}").unwrap_or_else(|e| panic!("failed: {e}")); 753 | 754 | assert_eq!(expr.as_str(), "^(.*)$"); 755 | assert!(expr.is_match("anything matches")); 756 | } 757 | 758 | #[test] 759 | fn text() { 760 | let expr = 761 | Expression::regex("a").unwrap_or_else(|e| panic!("failed: {e}")); 762 | 763 | assert_eq!(expr.as_str(), "^a$"); 764 | assert!(expr.is_match("a")); 765 | assert!(!expr.is_match("b")); 766 | assert!(!expr.is_match("ab")); 767 | } 768 | 769 | #[test] 770 | fn unicode() { 771 | let expr = Expression::regex("Привет, Мир(ы)!") 772 | .unwrap_or_else(|e| panic!("failed: {e}")); 773 | 774 | assert_eq!(expr.as_str(), "^Привет, Мир(?:ы)?!$"); 775 | assert!(expr.is_match("Привет, Мир!")); 776 | assert!(expr.is_match("Привет, Миры!")); 777 | assert!(!expr.is_match("Hello world")); 778 | } 779 | 780 | #[test] 781 | fn unknown_parameter() { 782 | match Expression::regex("{custom}").unwrap_err() { 783 | Error::Expansion(ParameterError::NotFound(not_found)) => { 784 | assert_eq!(*not_found, "custom"); 785 | } 786 | e @ (Error::Parsing(_) | Error::Regex(_) | Error::Expansion(_)) => { 787 | panic!("wrong err: {e}"); 788 | } 789 | } 790 | } 791 | } 792 | -------------------------------------------------------------------------------- /src/expand/parameters.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021-2025 Brendan Molloy , 2 | // Ilya Solovyiov , 3 | // Kai Ren 4 | // 5 | // Licensed under the Apache License, Version 2.0 or the MIT license 7 | // , at your 8 | // option. This file may not be copied, modified, or distributed 9 | // except according to those terms. 10 | 11 | //! Support for [custom][1] [`Parameter`]s. 12 | //! 13 | //! [1]: https://github.com/cucumber/cucumber-expressions#custom-parameter-types 14 | 15 | use std::{collections::HashMap, fmt::Display, iter, str, vec}; 16 | 17 | use either::Either; 18 | use nom::{AsChar, Input}; 19 | 20 | use super::{ 21 | Expression, IntoRegexCharIter, ParameterError, ParameterIter, 22 | SingleExpressionIter, 23 | }; 24 | use crate::{Parameter, SingleExpression, expand::OwnedChars}; 25 | 26 | /// Parser of a [Cucumber Expressions][0] [AST] `Element` with [custom][1] 27 | /// `Parameters` in mind. 28 | /// 29 | /// Usually, a [`Parameter`] is represented by a single [`Regex`] capturing 30 | /// group. In case there are multiple capturing groups, they will be named like 31 | /// `__{parameter_id}_{group_id}`. This is done to identify multiple capturing 32 | /// groups related to a single [`Parameter`]. 33 | /// 34 | /// [`Regex`]: regex::Regex 35 | /// [0]: https://github.com/cucumber/cucumber-expressions#readme 36 | /// [1]: https://github.com/cucumber/cucumber-expressions#custom-parameter-types 37 | /// [AST]: https://en.wikipedia.org/wiki/Abstract_syntax_tree 38 | #[derive(Clone, Copy, Debug)] 39 | pub struct WithCustom { 40 | /// Parsed element of a [Cucumber Expressions][0] [AST]. 41 | /// 42 | /// [0]: https://github.com/cucumber/cucumber-expressions#readme 43 | /// [AST]: https://en.wikipedia.org/wiki/Abstract_syntax_tree 44 | pub element: Element, 45 | 46 | /// Custom `Parameters` (in addition to [default ones][1]) to be used for 47 | /// expanding the `Element` into a [`Regex`]. 48 | /// 49 | /// [`Regex`]: regex::Regex 50 | /// [1]: https://github.com/cucumber/cucumber-expressions#parameter-types 51 | pub parameters: Parameters, 52 | } 53 | 54 | /// Provider of custom [`Parameter`]s. 55 | pub trait Provider { 56 | /// `<`[`Value`]` as `[`Input`]`>::`[`Item`]. 57 | /// 58 | /// [`Item`]: Input::Item 59 | /// [`Value`]: Self::Value 60 | type Item: AsChar; 61 | 62 | /// Value matcher to be used in a [`Regex`]. 63 | /// 64 | /// Usually, a [`Parameter`] is represented by a single [`Regex`] capturing 65 | /// group. In case there are multiple capturing groups, they will be named 66 | /// like `__{parameter_id}_{group_id}`. This is done to identify multiple 67 | /// capturing groups related to a single [`Parameter`]. 68 | /// 69 | /// [`Regex`]: regex::Regex 70 | type Value: Input; 71 | 72 | /// Returns a [`Value`] matcher corresponding to the given `input`, if any. 73 | /// 74 | /// [`Value`]: Self::Value 75 | fn get(&self, input: &I) -> Option; 76 | } 77 | 78 | impl<'p, I, Key, Value, S> Provider for &'p HashMap 79 | where 80 | I: Input, 81 | ::Item: AsChar, 82 | Key: AsRef, 83 | Value: AsRef, 84 | { 85 | type Item = char; 86 | type Value = &'p str; 87 | 88 | fn get(&self, input: &I) -> Option { 89 | self.iter().find_map(|(k, v)| { 90 | k.as_ref() 91 | .chars() 92 | .eq(input.iter_elements().map(AsChar::as_char)) 93 | .then(|| v.as_ref()) 94 | }) 95 | } 96 | } 97 | 98 | impl IntoRegexCharIter for WithCustom, Pars> 99 | where 100 | I: Clone + Display + Input, 101 | ::Item: AsChar, 102 | Pars: Clone + Provider, 103 | >::Value: Input, 104 | { 105 | type Iter = ExpressionWithParsIter; 106 | 107 | fn into_regex_char_iter(self) -> Self::Iter { 108 | let add_pars: fn(_) -> _ = 109 | |(item, parameters)| WithCustom { element: item, parameters }; 110 | let into_regex_char_iter: fn(_) -> _ = 111 | IntoRegexCharIter::into_regex_char_iter; 112 | iter::once(Ok('^')) 113 | .chain( 114 | self.element 115 | .0 116 | .into_iter() 117 | .zip(iter::repeat(self.parameters)) 118 | .map(add_pars) 119 | .flat_map(into_regex_char_iter), 120 | ) 121 | .chain(iter::once(Ok('$'))) 122 | } 123 | } 124 | 125 | // TODO: Replace with TAIT, once stabilized: 126 | // https://github.com/rust-lang/rust/issues/63063 127 | /// [`IntoRegexCharIter::Iter`] for [`WithCustom`]`<`[`Expression`]`>`. 128 | type ExpressionWithParsIter = iter::Chain< 129 | iter::Chain< 130 | iter::Once>>, 131 | iter::FlatMap< 132 | iter::Map< 133 | iter::Zip>, iter::Repeat

>, 134 | fn( 135 | (SingleExpression, P), 136 | ) -> WithCustom, P>, 137 | >, 138 | SingleExprWithParsIter, 139 | fn( 140 | WithCustom, P>, 141 | ) -> SingleExprWithParsIter, 142 | >, 143 | >, 144 | iter::Once>>, 145 | >; 146 | 147 | impl IntoRegexCharIter for WithCustom, Pars> 148 | where 149 | I: Clone + Display + Input, 150 | ::Item: AsChar, 151 | Pars: Provider, 152 | >::Value: Input, 153 | { 154 | type Iter = SingleExprWithParsIter; 155 | 156 | fn into_regex_char_iter(self) -> Self::Iter { 157 | use Either::{Left, Right}; 158 | 159 | if let SingleExpression::Parameter(item) = self.element { 160 | Left( 161 | WithCustom { element: item, parameters: self.parameters } 162 | .into_regex_char_iter(), 163 | ) 164 | } else { 165 | Right(self.element.into_regex_char_iter()) 166 | } 167 | } 168 | } 169 | 170 | // TODO: Replace with TAIT, once stabilized: 171 | // https://github.com/rust-lang/rust/issues/63063 172 | /// [`IntoRegexCharIter::Iter`] for 173 | /// [`WithCustom`]`<`[`SingleExpression`]`>`. 174 | type SingleExprWithParsIter = Either< 175 | , P> as IntoRegexCharIter>::Iter, 176 | SingleExpressionIter, 177 | >; 178 | 179 | impl IntoRegexCharIter for WithCustom, P> 180 | where 181 | I: Clone + Display + Input, 182 | ::Item: AsChar, 183 | P: Provider, 184 |

>::Value: Input, 185 | { 186 | type Iter = WithParsIter; 187 | 188 | fn into_regex_char_iter(self) -> Self::Iter { 189 | use Either::{Left, Right}; 190 | 191 | let id = self.element.id; 192 | 193 | match self.parameters.get(&self.element) { 194 | None => Right(Left(self.element.into_regex_char_iter())), 195 | Some(v) => { 196 | // We try to find '(' inside regex. If unsuccessfully, we can be 197 | // sure that the regex has no groups, so we can skip parsing. 198 | let parsed = v 199 | .iter_elements() 200 | .any(|c| c.as_char() == '(') 201 | .then(|| { 202 | let re = v 203 | .iter_elements() 204 | .map(AsChar::as_char) 205 | .collect::(); 206 | let hir = regex_syntax::Parser::new() 207 | .parse(&re) 208 | .map_err(|err| (self.element.input, re, err))?; 209 | Ok(regex_hir::has_capture_groups(&hir).then_some(hir)) 210 | }) 211 | .transpose(); 212 | let parsed = match parsed { 213 | Ok(hir) => hir.flatten(), 214 | Err((parameter, re, err)) => { 215 | return Left(iter::once(Err( 216 | ParameterError::RenameRegexGroup { 217 | parameter, 218 | re, 219 | err: Box::new(err), 220 | }, 221 | ))); 222 | } 223 | }; 224 | 225 | parsed.map_or_else( 226 | || { 227 | let ok: fn(_) -> _ = 228 | |c: ::Item| Ok(c.as_char()); 229 | Right(Right(Right( 230 | iter::once(Ok('(')) 231 | .chain(v.iter_elements().map(ok)) 232 | .chain(iter::once(Ok(')'))), 233 | ))) 234 | }, 235 | |cur_hir| { 236 | let ok: fn(_) -> _ = Ok; 237 | let new_hir = 238 | regex_hir::rename_capture_groups(cur_hir, id); 239 | Right(Right(Left( 240 | "(?:" 241 | .chars() 242 | .map(ok) 243 | .chain( 244 | OwnedChars::new(new_hir.to_string()) 245 | .map(ok), 246 | ) 247 | .chain(iter::once(Ok(')'))), 248 | ))) 249 | }, 250 | ) 251 | } 252 | } 253 | } 254 | } 255 | 256 | // TODO: Replace with TAIT, once stabilized: 257 | // https://github.com/rust-lang/rust/issues/63063 258 | /// [`IntoRegexCharIter::Iter`] for [`WithCustom`]`<`[`Parameter`]`>`. 259 | type WithParsIter = Either< 260 | iter::Once>>, 261 | Either< 262 | ParameterIter, 263 | Either< 264 | iter::Chain< 265 | iter::Chain< 266 | iter::Map< 267 | str::Chars<'static>, 268 | fn(char) -> Result>, 269 | >, 270 | iter::Map< 271 | OwnedChars, 272 | fn(char) -> Result>, 273 | >, 274 | >, 275 | iter::Once>>, 276 | >, 277 | iter::Chain< 278 | iter::Chain< 279 | iter::Once>>, 280 | iter::Map< 281 | <

>::Value as Input>::Iter, 282 | fn( 283 | <

>::Value as Input>::Item, 284 | ) 285 | -> Result>, 286 | >, 287 | >, 288 | iter::Once>>, 289 | >, 290 | >, 291 | >, 292 | >; 293 | 294 | /// Helpers to work with [`Regex`]es [`Hir`]. 295 | /// 296 | /// [`Hir`]: regex_syntax::hir::Hir 297 | /// [`Regex`]: regex::Regex 298 | mod regex_hir { 299 | use std::mem; 300 | 301 | use regex_syntax::hir::{Hir, HirKind}; 302 | 303 | /// Checks whether the given [`Regex`] [`Hir`] contains any capturing 304 | /// groups. 305 | /// 306 | /// [`Regex`]: regex::Regex 307 | pub(super) fn has_capture_groups(hir: &Hir) -> bool { 308 | match hir.kind() { 309 | HirKind::Empty 310 | | HirKind::Literal(_) 311 | | HirKind::Class(_) 312 | | HirKind::Look(_) 313 | | HirKind::Repetition(_) => false, 314 | HirKind::Capture(_) => true, 315 | HirKind::Concat(inner) | HirKind::Alternation(inner) => { 316 | inner.iter().any(has_capture_groups) 317 | } 318 | } 319 | } 320 | 321 | /// Renames capturing groups in the given [`Hir`] via 322 | /// `__{parameter_id}_{group_id}` naming scheme. 323 | pub(super) fn rename_capture_groups(hir: Hir, parameter_id: usize) -> Hir { 324 | rename_groups_inner(hir, parameter_id, &mut 0) 325 | } 326 | 327 | /// Renames capturing groups in the given [`Hir`] via 328 | /// `__{parameter_id}_{group_id}` naming scheme, using the provided 329 | /// `group_id_indexer`. 330 | fn rename_groups_inner( 331 | hir: Hir, 332 | parameter_id: usize, 333 | group_id_indexer: &mut usize, 334 | ) -> Hir { 335 | match hir.into_kind() { 336 | HirKind::Empty => Hir::empty(), 337 | HirKind::Literal(lit) => Hir::literal(lit.0), 338 | HirKind::Class(cl) => Hir::class(cl), 339 | HirKind::Look(l) => Hir::look(l), 340 | HirKind::Repetition(rep) => Hir::repetition(rep), 341 | HirKind::Capture(mut capture) => { 342 | capture.name = 343 | Some(format!("__{parameter_id}_{group_id_indexer}").into()); 344 | *group_id_indexer += 1; 345 | 346 | let inner_hir = 347 | mem::replace(capture.sub.as_mut(), Hir::empty()); 348 | drop(mem::replace( 349 | capture.sub.as_mut(), 350 | rename_groups_inner( 351 | inner_hir, 352 | parameter_id, 353 | group_id_indexer, 354 | ), 355 | )); 356 | 357 | Hir::capture(capture) 358 | } 359 | HirKind::Concat(concat) => Hir::concat( 360 | concat 361 | .into_iter() 362 | .map(|h| { 363 | rename_groups_inner(h, parameter_id, group_id_indexer) 364 | }) 365 | .collect(), 366 | ), 367 | HirKind::Alternation(alt) => Hir::alternation( 368 | alt.into_iter() 369 | .map(|h| { 370 | rename_groups_inner(h, parameter_id, group_id_indexer) 371 | }) 372 | .collect(), 373 | ), 374 | } 375 | } 376 | } 377 | 378 | #[cfg(test)] 379 | mod spec { 380 | use super::{Expression, HashMap, ParameterError}; 381 | use crate::expand::Error; 382 | 383 | #[test] 384 | fn custom_parameter() { 385 | let pars = HashMap::from([("custom", "custom")]); 386 | let expr = Expression::regex_with_parameters("{custom}", &pars) 387 | .unwrap_or_else(|e| panic!("failed: {e}")); 388 | 389 | assert_eq!(expr.as_str(), "^(custom)$"); 390 | } 391 | 392 | #[test] 393 | fn custom_parameter_with_groups() { 394 | let pars = HashMap::from([("custom", "\"(custom)\"|'(custom)'")]); 395 | let expr = 396 | Expression::regex_with_parameters("{custom} {custom}", &pars) 397 | .unwrap_or_else(|e| panic!("failed: {e}")); 398 | 399 | assert_eq!( 400 | expr.as_str(), 401 | "^(?:(?:(?:\"(?P<__0_0>(?:custom))\")\ 402 | |(?:'(?P<__0_1>(?:custom))'))) \ 403 | (?:(?:(?:\"(?P<__1_0>(?:custom))\")\ 404 | |(?:'(?P<__1_1>(?:custom))')))$", 405 | ); 406 | } 407 | 408 | #[test] 409 | fn default_parameter() { 410 | let pars = HashMap::from([("custom", "custom")]); 411 | let expr = Expression::regex_with_parameters("{}", &pars) 412 | .unwrap_or_else(|e| panic!("failed: {e}")); 413 | 414 | assert_eq!(expr.as_str(), "^(.*)$"); 415 | } 416 | 417 | #[test] 418 | fn unknown_parameter() { 419 | let pars = HashMap::::new(); 420 | 421 | match Expression::regex_with_parameters("{custom}", &pars).unwrap_err() 422 | { 423 | Error::Expansion(ParameterError::NotFound(not_found)) => { 424 | assert_eq!(*not_found, "custom"); 425 | } 426 | e @ (Error::Regex(_) | Error::Parsing(_) | Error::Expansion(_)) => { 427 | panic!("wrong err: {e}") 428 | } 429 | } 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021-2025 Brendan Molloy , 2 | // Ilya Solovyiov , 3 | // Kai Ren 4 | // 5 | // Licensed under the Apache License, Version 2.0 or the MIT license 7 | // , at your 8 | // option. This file may not be copied, modified, or distributed 9 | // except according to those terms. 10 | 11 | #![doc( 12 | html_logo_url = "https://avatars.githubusercontent.com/u/91469139?s=128", 13 | html_favicon_url = "https://avatars.githubusercontent.com/u/91469139?s=256" 14 | )] 15 | #![cfg_attr(docsrs, feature(doc_auto_cfg))] 16 | #![cfg_attr(any(doc, test), doc = include_str!("../README.md"))] 17 | #![cfg_attr(not(any(doc, test)), doc = env!("CARGO_PKG_NAME"))] 18 | #![deny(nonstandard_style, rustdoc::all, trivial_casts, trivial_numeric_casts)] 19 | #![forbid(non_ascii_idents, unsafe_code)] 20 | #![warn( 21 | clippy::absolute_paths, 22 | clippy::allow_attributes, 23 | clippy::allow_attributes_without_reason, 24 | clippy::as_conversions, 25 | clippy::as_pointer_underscore, 26 | clippy::as_ptr_cast_mut, 27 | clippy::assertions_on_result_states, 28 | clippy::branches_sharing_code, 29 | clippy::cfg_not_test, 30 | clippy::clear_with_drain, 31 | clippy::clone_on_ref_ptr, 32 | clippy::collection_is_never_read, 33 | clippy::create_dir, 34 | clippy::dbg_macro, 35 | clippy::debug_assert_with_mut_call, 36 | clippy::decimal_literal_representation, 37 | clippy::default_union_representation, 38 | clippy::derive_partial_eq_without_eq, 39 | clippy::doc_include_without_cfg, 40 | clippy::empty_drop, 41 | clippy::empty_structs_with_brackets, 42 | clippy::equatable_if_let, 43 | clippy::empty_enum_variants_with_brackets, 44 | clippy::exit, 45 | clippy::expect_used, 46 | clippy::fallible_impl_from, 47 | clippy::filetype_is_file, 48 | clippy::float_cmp_const, 49 | clippy::fn_to_numeric_cast_any, 50 | clippy::get_unwrap, 51 | clippy::if_then_some_else_none, 52 | clippy::imprecise_flops, 53 | clippy::infinite_loop, 54 | clippy::iter_on_empty_collections, 55 | clippy::iter_on_single_items, 56 | clippy::iter_over_hash_type, 57 | clippy::iter_with_drain, 58 | clippy::large_include_file, 59 | clippy::large_stack_frames, 60 | clippy::let_underscore_untyped, 61 | clippy::literal_string_with_formatting_args, 62 | clippy::lossy_float_literal, 63 | clippy::map_err_ignore, 64 | clippy::map_with_unused_argument_over_ranges, 65 | clippy::mem_forget, 66 | clippy::missing_assert_message, 67 | clippy::missing_asserts_for_indexing, 68 | clippy::missing_const_for_fn, 69 | clippy::missing_docs_in_private_items, 70 | clippy::module_name_repetitions, 71 | clippy::multiple_inherent_impl, 72 | clippy::multiple_unsafe_ops_per_block, 73 | clippy::mutex_atomic, 74 | clippy::mutex_integer, 75 | clippy::needless_collect, 76 | clippy::needless_pass_by_ref_mut, 77 | clippy::needless_raw_strings, 78 | clippy::non_zero_suggestions, 79 | clippy::nonstandard_macro_braces, 80 | clippy::option_if_let_else, 81 | clippy::or_fun_call, 82 | clippy::panic_in_result_fn, 83 | clippy::partial_pub_fields, 84 | clippy::pathbuf_init_then_push, 85 | clippy::pedantic, 86 | clippy::precedence_bits, 87 | clippy::print_stderr, 88 | clippy::print_stdout, 89 | clippy::pub_without_shorthand, 90 | clippy::rc_buffer, 91 | clippy::rc_mutex, 92 | clippy::read_zero_byte_vec, 93 | clippy::redundant_clone, 94 | clippy::redundant_type_annotations, 95 | clippy::renamed_function_params, 96 | clippy::ref_patterns, 97 | clippy::rest_pat_in_fully_bound_structs, 98 | clippy::return_and_then, 99 | clippy::same_name_method, 100 | clippy::semicolon_inside_block, 101 | clippy::set_contains_or_insert, 102 | clippy::shadow_unrelated, 103 | clippy::significant_drop_in_scrutinee, 104 | clippy::significant_drop_tightening, 105 | clippy::single_option_map, 106 | clippy::str_to_string, 107 | clippy::string_add, 108 | clippy::string_lit_as_bytes, 109 | clippy::string_lit_chars_any, 110 | clippy::string_slice, 111 | clippy::string_to_string, 112 | clippy::suboptimal_flops, 113 | clippy::suspicious_operation_groupings, 114 | clippy::suspicious_xor_used_as_pow, 115 | clippy::tests_outside_test_module, 116 | clippy::todo, 117 | clippy::too_long_first_doc_paragraph, 118 | clippy::trailing_empty_array, 119 | clippy::transmute_undefined_repr, 120 | clippy::trivial_regex, 121 | clippy::try_err, 122 | clippy::undocumented_unsafe_blocks, 123 | clippy::unimplemented, 124 | clippy::uninhabited_references, 125 | clippy::unnecessary_safety_comment, 126 | clippy::unnecessary_safety_doc, 127 | clippy::unnecessary_self_imports, 128 | clippy::unnecessary_struct_initialization, 129 | clippy::unused_peekable, 130 | clippy::unused_result_ok, 131 | clippy::unused_trait_names, 132 | clippy::unwrap_in_result, 133 | clippy::unwrap_used, 134 | clippy::use_debug, 135 | clippy::use_self, 136 | clippy::useless_let_if_seq, 137 | clippy::verbose_file_reads, 138 | clippy::while_float, 139 | clippy::wildcard_enum_match_arm, 140 | ambiguous_negative_literals, 141 | closure_returning_async_block, 142 | future_incompatible, 143 | impl_trait_redundant_captures, 144 | let_underscore_drop, 145 | macro_use_extern_crate, 146 | meta_variable_misuse, 147 | missing_copy_implementations, 148 | missing_debug_implementations, 149 | missing_docs, 150 | redundant_lifetimes, 151 | rust_2018_idioms, 152 | single_use_lifetimes, 153 | unit_bindings, 154 | unnameable_types, 155 | unreachable_pub, 156 | unstable_features, 157 | unused, 158 | variant_size_differences 159 | )] 160 | 161 | pub mod ast; 162 | mod combinator; 163 | #[cfg(feature = "into-regex")] 164 | pub mod expand; 165 | pub mod parse; 166 | 167 | #[doc(inline)] 168 | pub use self::ast::{ 169 | Alternation, Alternative, Expression, Optional, Parameter, 170 | SingleAlternation, SingleExpression, Spanned, 171 | }; 172 | -------------------------------------------------------------------------------- /src/parse.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021-2025 Brendan Molloy , 2 | // Ilya Solovyiov , 3 | // Kai Ren 4 | // 5 | // Licensed under the Apache License, Version 2.0 or the MIT license 7 | // , at your 8 | // option. This file may not be copied, modified, or distributed 9 | // except according to those terms. 10 | 11 | //! [Cucumber Expressions][1] [AST] parser. 12 | //! 13 | //! See details in the [grammar spec][0]. 14 | //! 15 | //! [0]: crate#grammar 16 | //! [1]: https://github.com/cucumber/cucumber-expressions#readme 17 | //! [AST]: https://en.wikipedia.org/wiki/Abstract_syntax_tree 18 | 19 | use derive_more::with_trait::{Display, Error as StdError}; 20 | use nom::{ 21 | AsChar, Compare, Err, FindToken, IResult, Input, Needed, Offset, Parser, 22 | branch::alt, 23 | bytes::complete::{tag, take_while, take_while1}, 24 | character::complete::one_of, 25 | combinator::{map, peek, verify}, 26 | error::{ErrorKind, ParseError}, 27 | multi::{many0, many1, separated_list1}, 28 | }; 29 | 30 | use crate::{ 31 | ast::{ 32 | Alternation, Alternative, Expression, Optional, Parameter, 33 | SingleExpression, 34 | }, 35 | combinator, 36 | }; 37 | 38 | /// Reserved characters requiring a special handling. 39 | pub const RESERVED_CHARS: &str = r"{}()\/ "; 40 | 41 | /// Matches `normal` and [`RESERVED_CHARS`] escaped with `\`. 42 | /// 43 | /// Uses [`combinator::escaped0`] under the hood. 44 | /// 45 | /// # Errors 46 | /// 47 | /// ## Recoverable [`Error`] 48 | /// 49 | /// - If `normal` parser errors. 50 | /// 51 | /// ## Irrecoverable [`Failure`] 52 | /// 53 | /// - If `normal` parser fails. 54 | /// - [`EscapedEndOfLine`]. 55 | /// - [`EscapedNonReservedCharacter`]. 56 | /// 57 | /// [`Error`]: Err::Error 58 | /// [`EscapedEndOfLine`]: Error::EscapedEndOfLine 59 | /// [`EscapedNonReservedCharacter`]: Error::EscapedNonReservedCharacter 60 | /// [`Failure`]: Err::Failure 61 | fn escaped_reserved_chars0( 62 | normal: F, 63 | ) -> impl FnMut(I) -> IResult> 64 | where 65 | I: Clone + Display + Offset + Input, 66 | ::Item: AsChar + Copy, 67 | F: Parser>, 68 | Error: ParseError, 69 | for<'s> &'s str: FindToken<::Item>, 70 | { 71 | combinator::map_err( 72 | combinator::escaped0(normal, '\\', one_of(RESERVED_CHARS)), 73 | |e| { 74 | if let Err::Error(Error::Other(span, ErrorKind::Escaped)) = e { 75 | match span.input_len() { 76 | 1 => Error::EscapedEndOfLine(span), 77 | n if n > 1 => Error::EscapedNonReservedCharacter( 78 | span.take(span.slice_index(2).unwrap_or_default()), 79 | ), 80 | _ => Error::EscapedNonReservedCharacter(span), 81 | } 82 | .failure() 83 | } else { 84 | e 85 | } 86 | }, 87 | ) 88 | } 89 | 90 | /// Parses a `parameter` as defined in the [grammar spec][0]. 91 | /// 92 | /// # Grammar 93 | /// 94 | /// ```ebnf 95 | /// parameter = '{', name*, '}' 96 | /// name = (- name-to-escape) | ('\', name-to-escape) 97 | /// name-to-escape = '{' | '}' | '(' | '/' | '\' 98 | /// ``` 99 | /// 100 | /// # Example 101 | /// 102 | /// ```text 103 | /// {} 104 | /// {name} 105 | /// {with spaces} 106 | /// {escaped \/\{\(} 107 | /// {no need to escape )} 108 | /// {🦀} 109 | /// ``` 110 | /// 111 | /// # Errors 112 | /// 113 | /// ## Recoverable [`Error`] 114 | /// 115 | /// - If `input` doesn't start with `{`. 116 | /// 117 | /// ## Irrecoverable [`Failure`]. 118 | /// 119 | /// - [`EscapedNonReservedCharacter`]. 120 | /// - [`NestedParameter`]. 121 | /// - [`OptionalInParameter`]. 122 | /// - [`UnescapedReservedCharacter`]. 123 | /// - [`UnfinishedParameter`]. 124 | /// 125 | /// # Indexing 126 | /// 127 | /// The given `indexer` is incremented only if the parsed [`Parameter`] is 128 | /// returned. 129 | /// 130 | /// [`Error`]: Err::Error 131 | /// [`Failure`]: Err::Failure 132 | /// [`EscapedNonReservedCharacter`]: Error::EscapedNonReservedCharacter 133 | /// [`NestedParameter`]: Error::NestedParameter 134 | /// [`OptionalInParameter`]: Error::OptionalInParameter 135 | /// [`UnescapedReservedCharacter`]: Error::UnescapedReservedCharacter 136 | /// [`UnfinishedParameter`]: Error::UnfinishedParameter 137 | /// [0]: crate#grammar 138 | pub fn parameter( 139 | input: I, 140 | indexer: &mut usize, 141 | ) -> IResult, Error> 142 | where 143 | I: Clone + Display + Offset + Input + for<'s> Compare<&'s str>, 144 | ::Item: AsChar + Copy, 145 | Error: ParseError, 146 | for<'s> &'s str: FindToken<::Item>, 147 | { 148 | fn is_name(c: impl AsChar) -> bool { 149 | !"{}(\\/".contains(c.as_char()) 150 | } 151 | 152 | let fail = |inp: I, opening_brace| { 153 | match inp.iter_elements().next().map(AsChar::as_char) { 154 | Some('{') => { 155 | if let Ok((_, (par, ..))) = peek(( 156 | // We don't use `indexer` here, because we do this parsing 157 | // for error reporting only. 158 | |i| parameter(i, &mut 0), 159 | escaped_reserved_chars0(take_while(is_name)), 160 | tag("}"), 161 | )) 162 | .parse(inp.clone()) 163 | { 164 | return Error::NestedParameter( 165 | inp.take(par.input.input_len() + 2), 166 | ) 167 | .failure(); 168 | } 169 | return Error::UnescapedReservedCharacter(inp.take(1)) 170 | .failure(); 171 | } 172 | Some('(') => { 173 | if let Ok((_, opt)) = peek(optional).parse(inp.clone()) { 174 | return Error::OptionalInParameter( 175 | inp.take(opt.0.input_len() + 2), 176 | ) 177 | .failure(); 178 | } 179 | return Error::UnescapedReservedCharacter(inp.take(1)) 180 | .failure(); 181 | } 182 | Some(c) if RESERVED_CHARS.contains(c) => { 183 | return Error::UnescapedReservedCharacter(inp.take(1)) 184 | .failure(); 185 | } 186 | _ => {} 187 | } 188 | Error::UnfinishedParameter(opening_brace).failure() 189 | }; 190 | 191 | let (input, opening_brace) = tag("{")(input)?; 192 | let (input, par_name) = 193 | escaped_reserved_chars0(take_while(is_name))(input)?; 194 | let (input, _) = combinator::map_err(tag("}"), |_| { 195 | fail(input.clone(), opening_brace.clone()) 196 | })(input.clone())?; 197 | 198 | *indexer += 1; 199 | Ok((input, Parameter { input: par_name, id: *indexer - 1 })) 200 | } 201 | 202 | /// Parses an `optional` as defined in the [grammar spec][0]. 203 | /// 204 | /// # Grammar 205 | /// 206 | /// ```ebnf 207 | /// optional = '(' text-in-optional+ ')' 208 | /// text-in-optional = (- optional-to-escape) | ('\', optional-to-escape) 209 | /// optional-to-escape = '(' | ')' | '{' | '/' | '\' 210 | /// ``` 211 | /// 212 | /// # Example 213 | /// 214 | /// ```text 215 | /// (name) 216 | /// (with spaces) 217 | /// (escaped \/\{\() 218 | /// (no need to escape }) 219 | /// (🦀) 220 | /// ``` 221 | /// 222 | /// # Errors 223 | /// 224 | /// ## Recoverable [`Error`] 225 | /// 226 | /// - If `input` doesn't start with `(`. 227 | /// 228 | /// ## Irrecoverable [`Failure`] 229 | /// 230 | /// - [`AlternationInOptional`]. 231 | /// - [`EmptyOptional`]. 232 | /// - [`EscapedEndOfLine`]. 233 | /// - [`EscapedNonReservedCharacter`]. 234 | /// - [`NestedOptional`]. 235 | /// - [`ParameterInOptional`]. 236 | /// - [`UnescapedReservedCharacter`]. 237 | /// - [`UnfinishedOptional`]. 238 | /// 239 | /// [`Error`]: Err::Error 240 | /// [`Failure`]: Err::Failure 241 | /// [`AlternationInOptional`]: Error::AlternationInOptional 242 | /// [`EmptyOptional`]: Error::EmptyOptional 243 | /// [`EscapedEndOfLine`]: Error::EscapedEndOfLine 244 | /// [`EscapedNonReservedCharacter`]: Error::EscapedNonReservedCharacter 245 | /// [`NestedOptional`]: Error::NestedOptional 246 | /// [`ParameterInOptional`]: Error::ParameterInOptional 247 | /// [`UnescapedReservedCharacter`]: Error::UnescapedReservedCharacter 248 | /// [`UnfinishedOptional`]: Error::UnfinishedOptional 249 | /// [0]: crate#grammar 250 | pub fn optional(input: I) -> IResult, Error> 251 | where 252 | I: Clone + Display + Offset + Input + for<'s> Compare<&'s str>, 253 | ::Item: AsChar + Copy, 254 | Error: ParseError, 255 | for<'s> &'s str: FindToken<::Item>, 256 | { 257 | fn is_in_optional(c: impl AsChar) -> bool { 258 | !"(){\\/".contains(c.as_char()) 259 | } 260 | 261 | let fail = |inp: I, opening_brace| { 262 | match inp.iter_elements().next().map(AsChar::as_char) { 263 | Some('(') => { 264 | if let Ok((_, (opt, ..))) = peek(( 265 | optional, 266 | escaped_reserved_chars0(take_while(is_in_optional)), 267 | tag(")"), 268 | )) 269 | .parse(inp.clone()) 270 | { 271 | return Error::NestedOptional( 272 | inp.take(opt.0.input_len() + 2), 273 | ) 274 | .failure(); 275 | } 276 | return Error::UnescapedReservedCharacter(inp.take(1)) 277 | .failure(); 278 | } 279 | Some('{') => { 280 | // We use just `0` as `indexer` here, because we do this parsing 281 | // for error reporting only. 282 | if let Ok((_, par)) = 283 | peek(|i| parameter(i, &mut 0)).parse(inp.clone()) 284 | { 285 | return Error::ParameterInOptional( 286 | inp.take(par.input.input_len() + 2), 287 | ) 288 | .failure(); 289 | } 290 | return Error::UnescapedReservedCharacter(inp.take(1)) 291 | .failure(); 292 | } 293 | Some('/') => { 294 | return Error::AlternationInOptional(inp.take(1)).failure(); 295 | } 296 | Some(c) if RESERVED_CHARS.contains(c) => { 297 | return Error::UnescapedReservedCharacter(inp.take(1)) 298 | .failure(); 299 | } 300 | _ => {} 301 | } 302 | Error::UnfinishedOptional(opening_brace).failure() 303 | }; 304 | 305 | let original_input = input.clone(); 306 | let (input, opening_paren) = tag("(")(input)?; 307 | let (input, opt) = 308 | escaped_reserved_chars0(take_while(is_in_optional))(input)?; 309 | let (input, _) = combinator::map_err(tag(")"), |_| { 310 | fail(input.clone(), opening_paren.clone()) 311 | })(input.clone())?; 312 | 313 | if opt.input_len() == 0 { 314 | return Err(Err::Failure(Error::EmptyOptional(original_input.take(2)))); 315 | } 316 | 317 | Ok((input, Optional(opt))) 318 | } 319 | 320 | /// Parses an `alternative` as defined in the [grammar spec][0]. 321 | /// 322 | /// # Grammar 323 | /// 324 | /// ```ebnf 325 | /// alternative = optional | (text-in-alternative+) 326 | /// text-in-alternative = (- alternative-to-escape) 327 | /// | ('\', alternative-to-escape) 328 | /// alternative-to-escape = ' ' | '(' | '{' | '/' | '\' 329 | /// ``` 330 | /// 331 | /// # Example 332 | /// 333 | /// ```text 334 | /// text 335 | /// escaped\ whitespace 336 | /// no-need-to-escape)} 337 | /// 🦀 338 | /// (optional) 339 | /// ``` 340 | /// 341 | /// # Errors 342 | /// 343 | /// ## Irrecoverable [`Failure`] 344 | /// 345 | /// Any [`Failure`] of [`optional()`]. 346 | /// 347 | /// [`Failure`]: Err::Failure 348 | /// [0]: crate#grammar 349 | pub fn alternative(input: I) -> IResult, Error> 350 | where 351 | I: Clone + Display + Offset + Input + for<'s> Compare<&'s str>, 352 | ::Item: AsChar + Copy, 353 | Error: ParseError, 354 | for<'s> &'s str: FindToken<::Item>, 355 | { 356 | fn is_without_whitespace(c: impl AsChar) -> bool { 357 | !" ({\\/".contains(c.as_char()) 358 | } 359 | 360 | alt(( 361 | map(optional, Alternative::Optional), 362 | map( 363 | verify( 364 | escaped_reserved_chars0(take_while(is_without_whitespace)), 365 | |p| p.input_len() > 0, 366 | ), 367 | Alternative::Text, 368 | ), 369 | )) 370 | .parse(input) 371 | } 372 | 373 | /// Parses an `alternation` as defined in the [grammar spec][0]. 374 | /// 375 | /// # Grammar 376 | /// 377 | /// ```ebnf 378 | /// alternation = single-alternation, (`/`, single-alternation)+ 379 | /// single-alternation = ((text-in-alternative+, optional*) 380 | /// | (optional+, text-in-alternative+))+ 381 | /// ``` 382 | /// 383 | /// # Example 384 | /// 385 | /// ```text 386 | /// left/right 387 | /// left(opt)/(opt)right 388 | /// escaped\ /text 389 | /// no-need-to-escape)}/text 390 | /// 🦀/⚙️ 391 | /// ``` 392 | /// 393 | /// # Errors 394 | /// 395 | /// ## Recoverable [`Error`] 396 | /// 397 | /// - If `input` doesn't have `/`. 398 | /// 399 | /// ## Irrecoverable [`Failure`] 400 | /// 401 | /// - Any [`Failure`] of [`optional()`]. 402 | /// - [`EmptyAlternation`]. 403 | /// - [`OnlyOptionalInAlternation`]. 404 | /// 405 | /// [`Error`]: Err::Error 406 | /// [`Failure`]: Err::Failure 407 | /// [`EmptyAlternation`]: Error::EmptyAlternation 408 | /// [`OnlyOptionalInAlternation`]: Error::OnlyOptionalInAlternation 409 | /// [0]: crate#grammar 410 | pub fn alternation(input: I) -> IResult, Error> 411 | where 412 | I: Clone + Display + Offset + Input + for<'s> Compare<&'s str>, 413 | ::Item: AsChar + Copy, 414 | Error: ParseError, 415 | for<'s> &'s str: FindToken<::Item>, 416 | { 417 | let original_input = input.clone(); 418 | let (rest, alt) = match separated_list1(tag("/"), many1(alternative)) 419 | .parse(input) 420 | { 421 | Ok((rest, alt)) => { 422 | if let Ok((_, slash)) = 423 | peek(tag::<_, _, Error>("/")).parse(rest.clone()) 424 | { 425 | Err(Error::EmptyAlternation(slash).failure()) 426 | } else if alt.len() == 1 { 427 | Err(Err::Error(Error::Other(rest, ErrorKind::Tag))) 428 | } else { 429 | Ok((rest, Alternation(alt))) 430 | } 431 | } 432 | Err(Err::Error(Error::Other(sp, ErrorKind::Many1))) 433 | if peek(tag::<_, _, Error>("/")).parse(sp.clone()).is_ok() => 434 | { 435 | Err(Error::EmptyAlternation(sp.take(1)).failure()) 436 | } 437 | Err(e) => Err(e), 438 | }?; 439 | 440 | if alt.contains_only_optional() { 441 | Err(Error::OnlyOptionalInAlternation( 442 | original_input.take(alt.span_len()), 443 | ) 444 | .failure()) 445 | } else { 446 | Ok((rest, alt)) 447 | } 448 | } 449 | 450 | /// Parses a `single-expression` as defined in the [grammar spec][0]. 451 | /// 452 | /// # Grammar 453 | /// 454 | /// ```ebnf 455 | /// single-expression = alternation 456 | /// | optional 457 | /// | parameter 458 | /// | text-without-whitespace+ 459 | /// | whitespace+ 460 | /// text-without-whitespace = (- (text-to-escape | whitespace)) 461 | /// | ('\', text-to-escape) 462 | /// text-to-escape = '(' | '{' | '/' | '\' 463 | /// ``` 464 | /// 465 | /// # Example 466 | /// 467 | /// ```text 468 | /// text(opt)/text 469 | /// (opt) 470 | /// {string} 471 | /// text 472 | /// ``` 473 | /// 474 | /// # Errors 475 | /// 476 | /// ## Irrecoverable [`Failure`] 477 | /// 478 | /// Any [`Failure`] of [`alternation()`], [`optional()`] or [`parameter()`]. 479 | /// 480 | /// # Indexing 481 | /// 482 | /// The given `indexer` is incremented only if the parsed [`SingleExpression`] 483 | /// is returned and it represents a [`Parameter`]. 484 | /// 485 | /// [`Failure`]: Err::Failure 486 | /// [0]: crate#grammar 487 | pub fn single_expression( 488 | input: I, 489 | indexer: &mut usize, 490 | ) -> IResult, Error> 491 | where 492 | I: Clone + Display + Offset + Input + for<'s> Compare<&'s str>, 493 | ::Item: AsChar + Copy, 494 | Error: ParseError, 495 | for<'s> &'s str: FindToken<::Item>, 496 | { 497 | fn is_without_whitespace(c: impl AsChar) -> bool { 498 | !" ({\\/".contains(c.as_char()) 499 | } 500 | fn is_whitespace(c: impl AsChar) -> bool { 501 | c.as_char() == ' ' 502 | } 503 | 504 | alt(( 505 | map(alternation, SingleExpression::Alternation), 506 | map(optional, SingleExpression::Optional), 507 | map(|i| parameter(i, indexer), SingleExpression::Parameter), 508 | map( 509 | verify( 510 | escaped_reserved_chars0(take_while(is_without_whitespace)), 511 | |s| s.input_len() > 0, 512 | ), 513 | SingleExpression::Text, 514 | ), 515 | map(take_while1(is_whitespace), SingleExpression::Whitespaces), 516 | )) 517 | .parse(input) 518 | } 519 | 520 | /// Parses an `expression` as defined in the [grammar spec][0]. 521 | /// 522 | /// # Grammar 523 | /// 524 | /// ```ebnf 525 | /// expression = single-expression* 526 | /// ``` 527 | /// 528 | /// # Example 529 | /// 530 | /// ```text 531 | /// text(opt)/text 532 | /// (opt) 533 | /// {string} 534 | /// text 535 | /// ``` 536 | /// 537 | /// > **NOTE:** Empty string is matched too. 538 | /// 539 | /// # Errors 540 | /// 541 | /// ## Irrecoverable [`Failure`] 542 | /// 543 | /// Any [`Failure`] of [`alternation()`], [`optional()`] or [`parameter()`]. 544 | /// 545 | /// [`Failure`]: Err::Failure 546 | /// [0]: crate#grammar 547 | pub fn expression(input: I) -> IResult, Error> 548 | where 549 | I: Clone + Display + Offset + Input + for<'s> Compare<&'s str>, 550 | ::Item: AsChar + Copy, 551 | Error: ParseError, 552 | for<'s> &'s str: FindToken<::Item>, 553 | { 554 | let mut indexer = 0; 555 | map(many0(move |i| single_expression(i, &mut indexer)), Expression) 556 | .parse(input) 557 | } 558 | 559 | /// Possible parsing errors. 560 | #[derive(Clone, Copy, Debug, Display, Eq, PartialEq, StdError)] 561 | pub enum Error { 562 | /// Nested [`Parameter`]s. 563 | #[display( 564 | "{_0}\n\ 565 | A parameter may not contain an other parameter.\n\ 566 | If you did not mean to use an optional type you can use '\\{{' to \ 567 | escape the '{{'. For more complicated expressions consider using a \ 568 | regular expression instead." 569 | )] 570 | NestedParameter(#[error(not(source))] Input), 571 | 572 | /// [`Optional`] inside a [`Parameter`]. 573 | #[display( 574 | "{_0}\n\ 575 | A parameter may not contain an optional.\n\ 576 | If you did not mean to use an parameter type you can use '\\(' to \ 577 | escape the '('." 578 | )] 579 | OptionalInParameter(#[error(not(source))] Input), 580 | 581 | /// Unfinished [`Parameter`]. 582 | #[display( 583 | "{_0}\n\ 584 | The '{{' does not have a matching '}}'.\n\ 585 | If you did not intend to use a parameter you can use '\\{{' to escape \ 586 | the '{{'." 587 | )] 588 | UnfinishedParameter(#[error(not(source))] Input), 589 | 590 | /// Nested [`Optional`]. 591 | #[display( 592 | "{_0}\n\ 593 | An optional may not contain an other optional.\n\ 594 | If you did not mean to use an optional type you can use '\\(' to \ 595 | escape the '('. For more complicated expressions consider using a \ 596 | regular expression instead." 597 | )] 598 | NestedOptional(#[error(not(source))] Input), 599 | 600 | /// [`Parameter`] inside an [`Optional`]. 601 | #[display( 602 | "{_0}\n\ 603 | An optional may not contain a parameter.\n\ 604 | If you did not mean to use an parameter type you can use '\\{{' to \ 605 | escape the '{{'." 606 | )] 607 | ParameterInOptional(#[error(not(source))] Input), 608 | 609 | /// Empty [`Optional`]. 610 | #[display( 611 | "{_0}\n\ 612 | An optional must contain some text.\n\ 613 | If you did not mean to use an optional you can use '\\(' to escape \ 614 | the '('." 615 | )] 616 | EmptyOptional(#[error(not(source))] Input), 617 | 618 | /// [`Alternation`] inside an [`Optional`]. 619 | #[display( 620 | "{_0}\n\ 621 | An alternation can not be used inside an optional.\n\ 622 | You can use '\\/' to escape the '/'." 623 | )] 624 | AlternationInOptional(#[error(not(source))] Input), 625 | 626 | /// Unfinished [`Optional`]. 627 | #[display( 628 | "{_0}\n\ 629 | The '(' does not have a matching ')'.\n\ 630 | If you did not intend to use an optional you can use '\\(' to escape \ 631 | the '('." 632 | )] 633 | UnfinishedOptional(#[error(not(source))] Input), 634 | 635 | /// Empty [`Alternation`]. 636 | #[display( 637 | "{_0}\n\ 638 | An alternation can not be empty.\n\ 639 | If you did not mean to use an alternative you can use '\\/' to \ 640 | escape the '/'." 641 | )] 642 | EmptyAlternation(#[error(not(source))] Input), 643 | 644 | /// Only [`Optional`] inside [`Alternation`]. 645 | #[display( 646 | "{_0}\n\ 647 | An alternation may not exclusively contain optionals.\n\ 648 | If you did not mean to use an optional you can use '\\(' to escape \ 649 | the '('." 650 | )] 651 | OnlyOptionalInAlternation(#[error(not(source))] Input), 652 | 653 | /// Unescaped [`RESERVED_CHARS`]. 654 | #[display( 655 | "{_0}\n\ 656 | Unescaped reserved character.\n\ 657 | You can use an '\\' to escape it." 658 | )] 659 | UnescapedReservedCharacter(#[error(not(source))] Input), 660 | 661 | /// Escaped non-[`RESERVED_CHARS`]. 662 | #[display( 663 | "{_0}\n\ 664 | Only the characters '{{', '}}', '(', ')', '\\', '/' and whitespace \ 665 | can be escaped.\n\ 666 | If you did mean to use an '\\' you can use '\\\\' to escape it." 667 | )] 668 | EscapedNonReservedCharacter(#[error(not(source))] Input), 669 | 670 | /// Escaped EOL. 671 | #[display( 672 | "{_0}\n\ 673 | The end of line can not be escaped.\n\ 674 | You can use '\\' to escape the the '\'." 675 | )] 676 | EscapedEndOfLine(#[error(not(source))] Input), 677 | 678 | /// Unknown error. 679 | #[display( 680 | "{_0}\n\ 681 | Unknown parsing error." 682 | )] 683 | Other(#[error(not(source))] Input, ErrorKind), 684 | 685 | /// Parsing requires more data. 686 | #[display( 687 | "{}", match _0 { 688 | Needed::Size(n) => { 689 | write!(__derive_more_f, "Parsing requires {n} bytes/chars") 690 | } 691 | Needed::Unknown => { 692 | write!(__derive_more_f, "Parsing requires more data") 693 | } 694 | }.map(|()| "")? 695 | )] 696 | Needed(#[error(not(source))] Needed), 697 | } 698 | 699 | impl Error { 700 | /// Converts this [`Error`] into a [`Failure`]. 701 | /// 702 | /// [`Error`]: enum@Error 703 | /// [`Failure`]: Err::Failure 704 | const fn failure(self) -> Err { 705 | Err::Failure(self) 706 | } 707 | } 708 | 709 | impl ParseError for Error { 710 | fn from_error_kind(input: Input, kind: ErrorKind) -> Self { 711 | Self::Other(input, kind) 712 | } 713 | 714 | fn append(input: Input, kind: ErrorKind, other: Self) -> Self { 715 | if let Self::Other(..) = other { 716 | Self::from_error_kind(input, kind) 717 | } else { 718 | other 719 | } 720 | } 721 | } 722 | 723 | #[cfg(test)] 724 | mod spec { 725 | use std::fmt; 726 | 727 | use nom::{Err, IResult, error::ErrorKind}; 728 | 729 | use crate::{ 730 | Alternative, Spanned, 731 | parse::{ 732 | Error, alternation, alternative, expression, optional, parameter, 733 | }, 734 | }; 735 | 736 | /// Asserts two given text representations of [AST] to be equal. 737 | /// 738 | /// [AST]: https://en.wikipedia.org/wiki/Abstract_syntax_tree 739 | fn assert_ast_eq(actual: impl fmt::Debug, expected: impl AsRef) { 740 | assert_eq!( 741 | format!("{actual:#?}") 742 | .lines() 743 | .map(|line| line.trim_start().trim_end_matches('\n')) 744 | .collect::(), 745 | expected 746 | .as_ref() 747 | .lines() 748 | .map(|line| line.trim_end_matches('\n').trim()) 749 | .collect::(), 750 | ); 751 | } 752 | 753 | /// Unwraps the given `parser` result asserting it has finished and succeed. 754 | fn unwrap_parser<'s, T>( 755 | parser: IResult, T, Error>>, 756 | ) -> T { 757 | let (rest, par) = parser.unwrap_or_else(|e| match &e { 758 | Err::Error(e) | Err::Failure(e) => { 759 | panic!("expected `Ok`, found `Err`: {e}") 760 | } 761 | Err::Incomplete(_) => { 762 | panic!("expected `Ok`, but `Err::Incomplete`: {e}") 763 | } 764 | }); 765 | assert_eq!(*rest, ""); 766 | par 767 | } 768 | 769 | mod parameter { 770 | use super::{Err, Error, ErrorKind, Spanned, parameter, unwrap_parser}; 771 | 772 | #[test] 773 | fn empty() { 774 | assert_eq!( 775 | **unwrap_parser(parameter(Spanned::new("{}"), &mut 0)), 776 | "", 777 | ); 778 | } 779 | 780 | #[test] 781 | fn named() { 782 | assert_eq!( 783 | **unwrap_parser(parameter(Spanned::new("{string}"), &mut 0)), 784 | "string", 785 | ); 786 | } 787 | 788 | #[test] 789 | fn named_with_spaces() { 790 | assert_eq!( 791 | **unwrap_parser(parameter( 792 | Spanned::new("{with space}"), 793 | &mut 0, 794 | )), 795 | "with space", 796 | ); 797 | } 798 | 799 | #[test] 800 | fn named_with_escaped() { 801 | assert_eq!( 802 | **unwrap_parser(parameter(Spanned::new("{with \\{}"), &mut 0)), 803 | "with \\{", 804 | ); 805 | } 806 | 807 | #[test] 808 | fn named_with_closing_paren() { 809 | assert_eq!( 810 | **unwrap_parser(parameter(Spanned::new("{with )}"), &mut 0)), 811 | "with )", 812 | ); 813 | } 814 | 815 | #[test] 816 | fn named_with_emoji() { 817 | assert_eq!( 818 | **unwrap_parser(parameter(Spanned::new("{🦀}"), &mut 0)), 819 | "🦀", 820 | ); 821 | } 822 | 823 | #[test] 824 | fn errors_on_empty() { 825 | let span = Spanned::new(""); 826 | 827 | assert_eq!( 828 | parameter(span, &mut 0), 829 | Err(Err::Error(Error::Other(span, ErrorKind::Tag))), 830 | ); 831 | } 832 | 833 | #[test] 834 | fn fails_on_escaped_non_reserved() { 835 | let err = parameter(Spanned::new("{\\r}"), &mut 0).unwrap_err(); 836 | 837 | match err { 838 | Err::Failure(Error::EscapedNonReservedCharacter(e)) => { 839 | assert_eq!(*e, "\\r"); 840 | } 841 | Err::Incomplete(_) | Err::Error(_) | Err::Failure(_) => { 842 | panic!("wrong error: {err:?}"); 843 | } 844 | } 845 | } 846 | 847 | #[test] 848 | fn fails_on_nested() { 849 | for input in [ 850 | "{{nest}}", 851 | "{before{nest}}", 852 | "{{nest}after}", 853 | "{bef{nest}aft}", 854 | ] { 855 | match parameter(Spanned::new(input), &mut 0).expect_err("error") 856 | { 857 | Err::Failure(Error::NestedParameter(e)) => { 858 | assert_eq!(*e, "{nest}", "on input: {input}"); 859 | } 860 | e => panic!("wrong error: {e:?}"), 861 | } 862 | } 863 | } 864 | 865 | #[test] 866 | fn fails_on_optional() { 867 | for input in [ 868 | "{(nest)}", 869 | "{before(nest)}", 870 | "{(nest)after}", 871 | "{bef(nest)aft}", 872 | ] { 873 | match parameter(Spanned::new(input), &mut 0).expect_err("error") 874 | { 875 | Err::Failure(Error::OptionalInParameter(e)) => { 876 | assert_eq!(*e, "(nest)", "on input: {input}"); 877 | } 878 | e => panic!("wrong error: {e:?}"), 879 | } 880 | } 881 | } 882 | 883 | #[test] 884 | fn fails_on_unescaped_reserved_char() { 885 | for (input, expected) in [ 886 | ("{(opt}", "("), 887 | ("{(n(e)st)}", "("), 888 | ("{{nest}", "{"), 889 | ("{l/r}", "/"), 890 | ] { 891 | match parameter(Spanned::new(input), &mut 0).expect_err("error") 892 | { 893 | Err::Failure(Error::UnescapedReservedCharacter(e)) => { 894 | assert_eq!(*e, expected, "on input: {input}"); 895 | } 896 | e => panic!("wrong error: {e:?}"), 897 | } 898 | } 899 | } 900 | 901 | #[test] 902 | fn fails_on_unfinished() { 903 | for input in ["{", "{name "] { 904 | match parameter(Spanned::new(input), &mut 0).expect_err("error") 905 | { 906 | Err::Failure(Error::UnfinishedParameter(e)) => { 907 | assert_eq!(*e, "{", "on input: {input}"); 908 | } 909 | e => panic!("wrong error: {e:?}"), 910 | } 911 | } 912 | } 913 | } 914 | 915 | mod optional { 916 | use super::{Err, Error, ErrorKind, Spanned, optional, unwrap_parser}; 917 | 918 | #[test] 919 | fn basic() { 920 | assert_eq!( 921 | **unwrap_parser(optional(Spanned::new("(string)"))), 922 | "string", 923 | ); 924 | } 925 | 926 | #[test] 927 | fn with_spaces() { 928 | assert_eq!( 929 | **unwrap_parser(optional(Spanned::new("(with space)"))), 930 | "with space", 931 | ); 932 | } 933 | 934 | #[test] 935 | fn with_escaped() { 936 | assert_eq!( 937 | **unwrap_parser(optional(Spanned::new("(with \\{)"))), 938 | "with \\{", 939 | ); 940 | } 941 | 942 | #[test] 943 | fn with_closing_brace() { 944 | assert_eq!( 945 | **unwrap_parser(optional(Spanned::new("(with })"))), 946 | "with }", 947 | ); 948 | } 949 | 950 | #[test] 951 | fn with_emoji() { 952 | assert_eq!(**unwrap_parser(optional(Spanned::new("(🦀)"))), "🦀"); 953 | } 954 | 955 | #[test] 956 | fn errors_on_empty() { 957 | let span = Spanned::new(""); 958 | 959 | assert_eq!( 960 | optional(span), 961 | Err(Err::Error(Error::Other(span, ErrorKind::Tag))), 962 | ); 963 | } 964 | 965 | #[test] 966 | fn fails_on_empty() { 967 | let err = optional(Spanned::new("()")).unwrap_err(); 968 | 969 | match err { 970 | Err::Failure(Error::EmptyOptional(e)) => { 971 | assert_eq!(*e, "()"); 972 | } 973 | Err::Incomplete(_) | Err::Error(_) | Err::Failure(_) => { 974 | panic!("wrong error: {err:?}") 975 | } 976 | } 977 | } 978 | 979 | #[test] 980 | fn fails_on_escaped_non_reserved() { 981 | let err = optional(Spanned::new("(\\r)")).unwrap_err(); 982 | 983 | match err { 984 | Err::Failure(Error::EscapedNonReservedCharacter(e)) => { 985 | assert_eq!(*e, "\\r"); 986 | } 987 | Err::Incomplete(_) | Err::Error(_) | Err::Failure(_) => { 988 | panic!("wrong error: {err:?}") 989 | } 990 | } 991 | } 992 | 993 | #[test] 994 | fn fails_on_nested() { 995 | for input in [ 996 | "((nest))", 997 | "(before(nest))", 998 | "((nest)after)", 999 | "(bef(nest)aft)", 1000 | ] { 1001 | match optional(Spanned::new(input)).expect_err("error") { 1002 | Err::Failure(Error::NestedOptional(e)) => { 1003 | assert_eq!(*e, "(nest)", "on input: {input}"); 1004 | } 1005 | e => panic!("wrong error: {e:?}"), 1006 | } 1007 | } 1008 | } 1009 | 1010 | #[test] 1011 | fn fails_on_parameter() { 1012 | for input in [ 1013 | "({nest})", 1014 | "(before{nest})", 1015 | "({nest}after)", 1016 | "(bef{nest}aft)", 1017 | ] { 1018 | match optional(Spanned::new(input)).expect_err("error") { 1019 | Err::Failure(Error::ParameterInOptional(e)) => { 1020 | assert_eq!(*e, "{nest}", "on input: {input}"); 1021 | } 1022 | e => panic!("wrong error: {e:?}"), 1023 | } 1024 | } 1025 | } 1026 | 1027 | #[test] 1028 | fn fails_on_alternation() { 1029 | for input in ["(/)", "(bef/)", "(/aft)", "(bef/aft)"] { 1030 | match optional(Spanned::new(input)).expect_err("error") { 1031 | Err::Failure(Error::AlternationInOptional(e)) => { 1032 | assert_eq!(*e, "/", "on input: {input}"); 1033 | } 1034 | e => panic!("wrong error: {e:?}"), 1035 | } 1036 | } 1037 | } 1038 | 1039 | #[test] 1040 | fn fails_on_unescaped_reserved_char() { 1041 | for (input, expected) in 1042 | [("({opt)", "{"), ("({n{e}st})", "{"), ("((nest)", "(")] 1043 | { 1044 | match optional(Spanned::new(input)).expect_err("error") { 1045 | Err::Failure(Error::UnescapedReservedCharacter(e)) => { 1046 | assert_eq!(*e, expected, "on input: {input}"); 1047 | } 1048 | e => panic!("wrong error: {e:?}"), 1049 | } 1050 | } 1051 | } 1052 | 1053 | #[test] 1054 | fn fails_on_unfinished() { 1055 | for input in ["(", "(name "] { 1056 | match optional(Spanned::new(input)).expect_err("error") { 1057 | Err::Failure(Error::UnfinishedOptional(e)) => { 1058 | assert_eq!(*e, "(", "on input: {input}"); 1059 | } 1060 | e => panic!("wrong error: {e:?}"), 1061 | } 1062 | } 1063 | } 1064 | } 1065 | 1066 | mod alternative { 1067 | use super::{ 1068 | Alternative, Err, Error, ErrorKind, Spanned, alternative, 1069 | unwrap_parser, 1070 | }; 1071 | 1072 | #[test] 1073 | fn text() { 1074 | for input in ["string", "🦀"] { 1075 | match unwrap_parser(alternative(Spanned::new(input))) { 1076 | Alternative::Text(t) => { 1077 | assert_eq!(*t, input, "on input: {input}"); 1078 | } 1079 | _ => panic!("expected `Alternative::Text`"), 1080 | } 1081 | } 1082 | } 1083 | 1084 | #[test] 1085 | fn escaped_spaces() { 1086 | for input in ["bef\\ ", "\\ aft", "bef\\ aft"] { 1087 | match unwrap_parser(alternative(Spanned::new(input))) { 1088 | Alternative::Text(t) => { 1089 | assert_eq!(*t, input, "on input: {input}"); 1090 | } 1091 | _ => panic!("expected `Alternative::Text`"), 1092 | } 1093 | } 1094 | } 1095 | 1096 | #[test] 1097 | fn optional() { 1098 | match unwrap_parser(alternative(Spanned::new("(opt)"))) { 1099 | Alternative::Optional(t) => { 1100 | assert_eq!(**t, "opt"); 1101 | } 1102 | Alternative::Text(_) => { 1103 | panic!("expected `Alternative::Optional`"); 1104 | } 1105 | } 1106 | } 1107 | 1108 | #[test] 1109 | fn not_captures_unescaped_whitespace() { 1110 | match alternative(Spanned::new("text ")) { 1111 | Ok((rest, matched)) => { 1112 | assert_eq!(*rest, " "); 1113 | 1114 | match matched { 1115 | Alternative::Text(t) => assert_eq!(*t, "text"), 1116 | Alternative::Optional(_) => { 1117 | panic!("expected `Alternative::Text`"); 1118 | } 1119 | } 1120 | } 1121 | Err(..) => panic!("expected ok"), 1122 | } 1123 | } 1124 | 1125 | #[test] 1126 | fn errors_on_empty() { 1127 | match alternative(Spanned::new("")).unwrap_err() { 1128 | Err::Error(Error::Other(_, ErrorKind::Alt)) => {} 1129 | e @ (Err::Incomplete(_) | Err::Error(_) | Err::Failure(_)) => { 1130 | panic!("wrong error: {e:?}"); 1131 | } 1132 | } 1133 | } 1134 | 1135 | #[test] 1136 | fn fails_on_unfinished_optional() { 1137 | for input in ["(", "(opt"] { 1138 | match alternative(Spanned::new(input)).unwrap_err() { 1139 | Err::Failure(Error::UnfinishedOptional(e)) => { 1140 | assert_eq!(*e, "(", "on input: {input}"); 1141 | } 1142 | e => panic!("wrong error: {e:?}"), 1143 | } 1144 | } 1145 | } 1146 | 1147 | #[test] 1148 | fn fails_on_escaped_non_reserved() { 1149 | for input in ["(\\r)", "\\r"] { 1150 | match alternative(Spanned::new(input)).unwrap_err() { 1151 | Err::Failure(Error::EscapedNonReservedCharacter(e)) => { 1152 | assert_eq!(*e, "\\r", "on input: {input}"); 1153 | } 1154 | e => panic!("wrong error: {e:?}"), 1155 | } 1156 | } 1157 | } 1158 | } 1159 | 1160 | mod alternation { 1161 | use super::{ 1162 | Err, Error, ErrorKind, Spanned, alternation, assert_ast_eq, 1163 | unwrap_parser, 1164 | }; 1165 | 1166 | #[test] 1167 | fn basic() { 1168 | assert_ast_eq( 1169 | unwrap_parser(alternation(Spanned::new("l/🦀"))), 1170 | r#"Alternation( 1171 | [ 1172 | [ 1173 | Text( 1174 | LocatedSpan { 1175 | offset: 0, 1176 | line: 1, 1177 | fragment: "l", 1178 | extra: (), 1179 | }, 1180 | ), 1181 | ], 1182 | [ 1183 | Text( 1184 | LocatedSpan { 1185 | offset: 2, 1186 | line: 1, 1187 | fragment: "🦀", 1188 | extra: (), 1189 | }, 1190 | ), 1191 | ], 1192 | ], 1193 | )"#, 1194 | ); 1195 | } 1196 | 1197 | #[test] 1198 | fn with_optionals() { 1199 | assert_ast_eq( 1200 | unwrap_parser(alternation(Spanned::new( 1201 | "l(opt)/(opt)r/l(opt)r", 1202 | ))), 1203 | r#"Alternation( 1204 | [ 1205 | [ 1206 | Text( 1207 | LocatedSpan { 1208 | offset: 0, 1209 | line: 1, 1210 | fragment: "l", 1211 | extra: (), 1212 | }, 1213 | ), 1214 | Optional( 1215 | Optional( 1216 | LocatedSpan { 1217 | offset: 2, 1218 | line: 1, 1219 | fragment: "opt", 1220 | extra: (), 1221 | }, 1222 | ), 1223 | ), 1224 | ], 1225 | [ 1226 | Optional( 1227 | Optional( 1228 | LocatedSpan { 1229 | offset: 8, 1230 | line: 1, 1231 | fragment: "opt", 1232 | extra: (), 1233 | }, 1234 | ), 1235 | ), 1236 | Text( 1237 | LocatedSpan { 1238 | offset: 12, 1239 | line: 1, 1240 | fragment: "r", 1241 | extra: (), 1242 | }, 1243 | ), 1244 | ], 1245 | [ 1246 | Text( 1247 | LocatedSpan { 1248 | offset: 14, 1249 | line: 1, 1250 | fragment: "l", 1251 | extra: (), 1252 | }, 1253 | ), 1254 | Optional( 1255 | Optional( 1256 | LocatedSpan { 1257 | offset: 16, 1258 | line: 1, 1259 | fragment: "opt", 1260 | extra: (), 1261 | }, 1262 | ), 1263 | ), 1264 | Text( 1265 | LocatedSpan { 1266 | offset: 20, 1267 | line: 1, 1268 | fragment: "r", 1269 | extra: (), 1270 | }, 1271 | ), 1272 | ], 1273 | ], 1274 | )"#, 1275 | ); 1276 | } 1277 | 1278 | #[test] 1279 | fn with_more_optionals() { 1280 | assert_ast_eq( 1281 | unwrap_parser(alternation(Spanned::new( 1282 | "l(opt)(opt)/(opt)(opt)r/(opt)m(opt)", 1283 | ))), 1284 | r#"Alternation( 1285 | [ 1286 | [ 1287 | Text( 1288 | LocatedSpan { 1289 | offset: 0, 1290 | line: 1, 1291 | fragment: "l", 1292 | extra: (), 1293 | }, 1294 | ), 1295 | Optional( 1296 | Optional( 1297 | LocatedSpan { 1298 | offset: 2, 1299 | line: 1, 1300 | fragment: "opt", 1301 | extra: (), 1302 | }, 1303 | ), 1304 | ), 1305 | Optional( 1306 | Optional( 1307 | LocatedSpan { 1308 | offset: 7, 1309 | line: 1, 1310 | fragment: "opt", 1311 | extra: (), 1312 | }, 1313 | ), 1314 | ), 1315 | ], 1316 | [ 1317 | Optional( 1318 | Optional( 1319 | LocatedSpan { 1320 | offset: 13, 1321 | line: 1, 1322 | fragment: "opt", 1323 | extra: (), 1324 | }, 1325 | ), 1326 | ), 1327 | Optional( 1328 | Optional( 1329 | LocatedSpan { 1330 | offset: 18, 1331 | line: 1, 1332 | fragment: "opt", 1333 | extra: (), 1334 | }, 1335 | ), 1336 | ), 1337 | Text( 1338 | LocatedSpan { 1339 | offset: 22, 1340 | line: 1, 1341 | fragment: "r", 1342 | extra: (), 1343 | }, 1344 | ), 1345 | ], 1346 | [ 1347 | Optional( 1348 | Optional( 1349 | LocatedSpan { 1350 | offset: 25, 1351 | line: 1, 1352 | fragment: "opt", 1353 | extra: (), 1354 | }, 1355 | ), 1356 | ), 1357 | Text( 1358 | LocatedSpan { 1359 | offset: 29, 1360 | line: 1, 1361 | fragment: "m", 1362 | extra: (), 1363 | }, 1364 | ), 1365 | Optional( 1366 | Optional( 1367 | LocatedSpan { 1368 | offset: 31, 1369 | line: 1, 1370 | fragment: "opt", 1371 | extra: (), 1372 | }, 1373 | ), 1374 | ), 1375 | ], 1376 | ], 1377 | )"#, 1378 | ); 1379 | } 1380 | 1381 | #[test] 1382 | fn errors_without_slash() { 1383 | for (input, expected) in [ 1384 | ("", ErrorKind::Many1), 1385 | ("{par}", ErrorKind::Many1), 1386 | ("text", ErrorKind::Tag), 1387 | ("(opt)", ErrorKind::Tag), 1388 | ] { 1389 | match alternation(Spanned::new(input)).unwrap_err() { 1390 | Err::Error(Error::Other(_, kind)) => { 1391 | assert_eq!(kind, expected, "on input: {input}"); 1392 | } 1393 | e => panic!("wrong error: {e:?}"), 1394 | } 1395 | } 1396 | } 1397 | 1398 | #[test] 1399 | fn fails_on_empty_alternation() { 1400 | for input in ["/", "l/", "/r", "l/m/", "l//r", "/m/r"] { 1401 | match alternation(Spanned::new(input)).unwrap_err() { 1402 | Err::Failure(Error::EmptyAlternation(e)) => { 1403 | assert_eq!(*e, "/", "on input: {input}"); 1404 | } 1405 | e => panic!("wrong error: {e:?}"), 1406 | } 1407 | } 1408 | } 1409 | 1410 | #[test] 1411 | fn fails_on_only_optional() { 1412 | for input in 1413 | ["text/(opt)", "text/(opt)(opt)", "(opt)/text", "(opt)/(opt)"] 1414 | { 1415 | match alternation(Spanned::new(input)).unwrap_err() { 1416 | Err::Failure(Error::OnlyOptionalInAlternation(e)) => { 1417 | assert_eq!(*e, input, "on input: {input}"); 1418 | } 1419 | e => panic!("wrong error: {e:?}"), 1420 | } 1421 | } 1422 | } 1423 | } 1424 | 1425 | // All test examples from: 1426 | // Naming of test cases is preserved. 1427 | mod expression { 1428 | use super::{ 1429 | Err, Error, Spanned, assert_ast_eq, expression, unwrap_parser, 1430 | }; 1431 | 1432 | #[test] 1433 | fn parameters_ids() { 1434 | assert_ast_eq( 1435 | unwrap_parser(expression(Spanned::new("{string} {string}"))), 1436 | r#"Expression( 1437 | [ 1438 | Parameter( 1439 | Parameter { 1440 | input: LocatedSpan { 1441 | offset: 1, 1442 | line: 1, 1443 | fragment: "string", 1444 | extra: (), 1445 | }, 1446 | id: 0, 1447 | }, 1448 | ), 1449 | Whitespaces( 1450 | LocatedSpan { 1451 | offset: 8, 1452 | line: 1, 1453 | fragment: " ", 1454 | extra: (), 1455 | }, 1456 | ), 1457 | Parameter( 1458 | Parameter { 1459 | input: LocatedSpan { 1460 | offset: 10, 1461 | line: 1, 1462 | fragment: "string", 1463 | extra: (), 1464 | }, 1465 | id: 1, 1466 | }, 1467 | ), 1468 | ], 1469 | )"#, 1470 | ) 1471 | } 1472 | 1473 | #[test] 1474 | fn allows_escaped_optional_parameter_types() { 1475 | assert_ast_eq( 1476 | unwrap_parser(expression(Spanned::new("\\({int})"))), 1477 | r#"Expression( 1478 | [ 1479 | Text( 1480 | LocatedSpan { 1481 | offset: 0, 1482 | line: 1, 1483 | fragment: "\\(", 1484 | extra: (), 1485 | }, 1486 | ), 1487 | Parameter( 1488 | Parameter { 1489 | input: LocatedSpan { 1490 | offset: 3, 1491 | line: 1, 1492 | fragment: "int", 1493 | extra: (), 1494 | }, 1495 | id: 0, 1496 | }, 1497 | ), 1498 | Text( 1499 | LocatedSpan { 1500 | offset: 7, 1501 | line: 1, 1502 | fragment: ")", 1503 | extra: (), 1504 | }, 1505 | ), 1506 | ], 1507 | )"#, 1508 | ); 1509 | } 1510 | 1511 | #[test] 1512 | fn allows_parameter_type_in_alternation() { 1513 | assert_ast_eq( 1514 | unwrap_parser(expression(Spanned::new("a/i{int}n/y"))), 1515 | r#"Expression( 1516 | [ 1517 | Alternation( 1518 | Alternation( 1519 | [ 1520 | [ 1521 | Text( 1522 | LocatedSpan { 1523 | offset: 0, 1524 | line: 1, 1525 | fragment: "a", 1526 | extra: (), 1527 | }, 1528 | ), 1529 | ], 1530 | [ 1531 | Text( 1532 | LocatedSpan { 1533 | offset: 2, 1534 | line: 1, 1535 | fragment: "i", 1536 | extra: (), 1537 | }, 1538 | ), 1539 | ], 1540 | ], 1541 | ), 1542 | ), 1543 | Parameter( 1544 | Parameter { 1545 | input: LocatedSpan { 1546 | offset: 4, 1547 | line: 1, 1548 | fragment: "int", 1549 | extra: (), 1550 | }, 1551 | id: 0, 1552 | }, 1553 | ), 1554 | Alternation( 1555 | Alternation( 1556 | [ 1557 | [ 1558 | Text( 1559 | LocatedSpan { 1560 | offset: 8, 1561 | line: 1, 1562 | fragment: "n", 1563 | extra: (), 1564 | }, 1565 | ), 1566 | ], 1567 | [ 1568 | Text( 1569 | LocatedSpan { 1570 | offset: 10, 1571 | line: 1, 1572 | fragment: "y", 1573 | extra: (), 1574 | }, 1575 | ), 1576 | ], 1577 | ], 1578 | ), 1579 | ), 1580 | ], 1581 | )"#, 1582 | ); 1583 | } 1584 | 1585 | #[test] 1586 | fn does_allow_parameter_adjacent_to_alternation() { 1587 | assert_ast_eq( 1588 | unwrap_parser(expression(Spanned::new("{int}st/nd/rd/th"))), 1589 | r#"Expression( 1590 | [ 1591 | Parameter( 1592 | Parameter { 1593 | input: LocatedSpan { 1594 | offset: 1, 1595 | line: 1, 1596 | fragment: "int", 1597 | extra: (), 1598 | }, 1599 | id: 0, 1600 | }, 1601 | ), 1602 | Alternation( 1603 | Alternation( 1604 | [ 1605 | [ 1606 | Text( 1607 | LocatedSpan { 1608 | offset: 5, 1609 | line: 1, 1610 | fragment: "st", 1611 | extra: (), 1612 | }, 1613 | ), 1614 | ], 1615 | [ 1616 | Text( 1617 | LocatedSpan { 1618 | offset: 8, 1619 | line: 1, 1620 | fragment: "nd", 1621 | extra: (), 1622 | }, 1623 | ), 1624 | ], 1625 | [ 1626 | Text( 1627 | LocatedSpan { 1628 | offset: 11, 1629 | line: 1, 1630 | fragment: "rd", 1631 | extra: (), 1632 | }, 1633 | ), 1634 | ], 1635 | [ 1636 | Text( 1637 | LocatedSpan { 1638 | offset: 14, 1639 | line: 1, 1640 | fragment: "th", 1641 | extra: (), 1642 | }, 1643 | ), 1644 | ], 1645 | ], 1646 | ), 1647 | ), 1648 | ], 1649 | )"#, 1650 | ); 1651 | } 1652 | 1653 | #[test] 1654 | fn does_not_allow_alternation_in_optional() { 1655 | match expression(Spanned::new("three( brown/black) mice")) 1656 | .unwrap_err() 1657 | { 1658 | Err::Failure(Error::AlternationInOptional(s)) => { 1659 | assert_eq!(*s, "/"); 1660 | } 1661 | e @ (Err::Incomplete(_) | Err::Error(_) | Err::Failure(_)) => { 1662 | panic!("wrong error: {e:?}"); 1663 | } 1664 | } 1665 | } 1666 | 1667 | #[rustfmt::skip] 1668 | #[test] 1669 | fn does_not_allow_alternation_with_empty_alternative_by_adjacent_left_parameter( 1670 | ) { 1671 | match expression(Spanned::new("{int}/x")).unwrap_err() { 1672 | Err::Failure(Error::EmptyAlternation(s)) => { 1673 | assert_eq!(*s, "/"); 1674 | } 1675 | e @ (Err::Incomplete(_) | Err::Error(_) | Err::Failure(_)) => { 1676 | panic!("wrong error: {e:?}"); 1677 | } 1678 | } 1679 | } 1680 | 1681 | #[rustfmt::skip] 1682 | #[test] 1683 | fn does_not_allow_alternation_with_empty_alternative_by_adjacent_optional( 1684 | ) { 1685 | match expression(Spanned::new("three (brown)/black mice")) 1686 | .unwrap_err() 1687 | { 1688 | Err::Failure(Error::OnlyOptionalInAlternation(s)) => { 1689 | assert_eq!(*s, "(brown)/black"); 1690 | } 1691 | e @ (Err::Incomplete(_) | Err::Error(_) | Err::Failure(_)) => { 1692 | panic!("wrong error: {e:?}"); 1693 | } 1694 | } 1695 | } 1696 | 1697 | #[rustfmt::skip] 1698 | #[test] 1699 | fn does_not_allow_alternation_with_empty_alternative_by_adjacent_right_parameter( 1700 | ) { 1701 | match expression(Spanned::new("x/{int}")).unwrap_err() { 1702 | Err::Failure(Error::EmptyAlternation(s)) => { 1703 | assert_eq!(*s, "/"); 1704 | } 1705 | e @ (Err::Incomplete(_) | Err::Error(_) | Err::Failure(_)) => { 1706 | panic!("wrong error: {e:?}"); 1707 | } 1708 | } 1709 | } 1710 | 1711 | #[test] 1712 | fn does_not_allow_alternation_with_empty_alternative() { 1713 | match expression(Spanned::new("three brown//black mice")) 1714 | .unwrap_err() 1715 | { 1716 | Err::Failure(Error::EmptyAlternation(s)) => { 1717 | assert_eq!(*s, "/"); 1718 | } 1719 | e @ (Err::Incomplete(_) | Err::Error(_) | Err::Failure(_)) => { 1720 | panic!("wrong error: {e:?}"); 1721 | } 1722 | } 1723 | } 1724 | 1725 | #[test] 1726 | fn does_not_allow_empty_optional() { 1727 | match expression(Spanned::new("three () mice")).unwrap_err() { 1728 | Err::Failure(Error::EmptyOptional(s)) => { 1729 | assert_eq!(*s, "()"); 1730 | } 1731 | e @ (Err::Incomplete(_) | Err::Error(_) | Err::Failure(_)) => { 1732 | panic!("wrong error: {e:?}"); 1733 | } 1734 | } 1735 | } 1736 | 1737 | #[test] 1738 | fn does_not_allow_nested_optional() { 1739 | match expression(Spanned::new("(a(b))")).unwrap_err() { 1740 | Err::Failure(Error::NestedOptional(s)) => { 1741 | assert_eq!(*s, "(b)"); 1742 | } 1743 | e @ (Err::Incomplete(_) | Err::Error(_) | Err::Failure(_)) => { 1744 | panic!("wrong error: {e:?}"); 1745 | } 1746 | } 1747 | } 1748 | 1749 | #[test] 1750 | fn does_not_allow_optional_parameter_types() { 1751 | match expression(Spanned::new("({int})")).unwrap_err() { 1752 | Err::Failure(Error::ParameterInOptional(s)) => { 1753 | assert_eq!(*s, "{int}"); 1754 | } 1755 | e @ (Err::Incomplete(_) | Err::Error(_) | Err::Failure(_)) => { 1756 | panic!("wrong error: {e:?}"); 1757 | } 1758 | } 1759 | } 1760 | 1761 | #[test] 1762 | fn does_not_allow_parameter_name_with_reserved_characters() { 1763 | match expression(Spanned::new("{(string)}")).unwrap_err() { 1764 | Err::Failure(Error::OptionalInParameter(s)) => { 1765 | assert_eq!(*s, "(string)"); 1766 | } 1767 | e @ (Err::Incomplete(_) | Err::Error(_) | Err::Failure(_)) => { 1768 | panic!("wrong error: {e:?}"); 1769 | } 1770 | } 1771 | } 1772 | 1773 | #[test] 1774 | fn does_not_allow_unfinished_parenthesis_1() { 1775 | match expression(Spanned::new( 1776 | "three (exceptionally\\) {string\\} mice", 1777 | )) 1778 | .unwrap_err() 1779 | { 1780 | Err::Failure(Error::UnescapedReservedCharacter(s)) => { 1781 | assert_eq!(*s, "{"); 1782 | } 1783 | e @ (Err::Incomplete(_) | Err::Error(_) | Err::Failure(_)) => { 1784 | panic!("wrong error: {e:?}"); 1785 | } 1786 | } 1787 | } 1788 | 1789 | #[test] 1790 | fn does_not_allow_unfinished_parenthesis_2() { 1791 | match expression(Spanned::new( 1792 | "three (exceptionally\\) {string} mice", 1793 | )) 1794 | .unwrap_err() 1795 | { 1796 | Err::Failure(Error::ParameterInOptional(s)) => { 1797 | assert_eq!(*s, "{string}"); 1798 | } 1799 | e @ (Err::Incomplete(_) | Err::Error(_) | Err::Failure(_)) => { 1800 | panic!("wrong error: {e:?}"); 1801 | } 1802 | } 1803 | } 1804 | 1805 | #[test] 1806 | fn does_not_allow_unfinished_parenthesis_3() { 1807 | match expression(Spanned::new( 1808 | "three ((exceptionally\\) strong) mice", 1809 | )) 1810 | .unwrap_err() 1811 | { 1812 | Err::Failure(Error::UnescapedReservedCharacter(s)) => { 1813 | assert_eq!(*s, "("); 1814 | } 1815 | e @ (Err::Incomplete(_) | Err::Error(_) | Err::Failure(_)) => { 1816 | panic!("wrong error: {e:?}"); 1817 | } 1818 | } 1819 | } 1820 | 1821 | #[test] 1822 | fn matches_alternation() { 1823 | assert_ast_eq( 1824 | unwrap_parser(expression(Spanned::new( 1825 | "mice/rats and rats\\/mice", 1826 | ))), 1827 | r#"Expression( 1828 | [ 1829 | Alternation( 1830 | Alternation( 1831 | [ 1832 | [ 1833 | Text( 1834 | LocatedSpan { 1835 | offset: 0, 1836 | line: 1, 1837 | fragment: "mice", 1838 | extra: (), 1839 | }, 1840 | ), 1841 | ], 1842 | [ 1843 | Text( 1844 | LocatedSpan { 1845 | offset: 5, 1846 | line: 1, 1847 | fragment: "rats", 1848 | extra: (), 1849 | }, 1850 | ), 1851 | ], 1852 | ], 1853 | ), 1854 | ), 1855 | Whitespaces( 1856 | LocatedSpan { 1857 | offset: 9, 1858 | line: 1, 1859 | fragment: " ", 1860 | extra: (), 1861 | }, 1862 | ), 1863 | Text( 1864 | LocatedSpan { 1865 | offset: 10, 1866 | line: 1, 1867 | fragment: "and", 1868 | extra: (), 1869 | }, 1870 | ), 1871 | Whitespaces( 1872 | LocatedSpan { 1873 | offset: 13, 1874 | line: 1, 1875 | fragment: " ", 1876 | extra: (), 1877 | }, 1878 | ), 1879 | Text( 1880 | LocatedSpan { 1881 | offset: 14, 1882 | line: 1, 1883 | fragment: "rats\\/mice", 1884 | extra: (), 1885 | }, 1886 | ), 1887 | ], 1888 | )"#, 1889 | ); 1890 | } 1891 | 1892 | #[test] 1893 | fn matches_anonymous_parameter_type() { 1894 | assert_ast_eq( 1895 | unwrap_parser(expression(Spanned::new("{}"))), 1896 | r#"Expression( 1897 | [ 1898 | Parameter( 1899 | Parameter { 1900 | input: LocatedSpan { 1901 | offset: 1, 1902 | line: 1, 1903 | fragment: "", 1904 | extra: (), 1905 | }, 1906 | id: 0, 1907 | }, 1908 | ), 1909 | ], 1910 | )"#, 1911 | ); 1912 | } 1913 | 1914 | #[test] 1915 | fn matches_doubly_escaped_parenthesis() { 1916 | assert_ast_eq( 1917 | unwrap_parser(expression(Spanned::new( 1918 | "three \\(exceptionally) \\{string} mice", 1919 | ))), 1920 | r#"Expression( 1921 | [ 1922 | Text( 1923 | LocatedSpan { 1924 | offset: 0, 1925 | line: 1, 1926 | fragment: "three", 1927 | extra: (), 1928 | }, 1929 | ), 1930 | Whitespaces( 1931 | LocatedSpan { 1932 | offset: 5, 1933 | line: 1, 1934 | fragment: " ", 1935 | extra: (), 1936 | }, 1937 | ), 1938 | Text( 1939 | LocatedSpan { 1940 | offset: 6, 1941 | line: 1, 1942 | fragment: "\\(exceptionally)", 1943 | extra: (), 1944 | }, 1945 | ), 1946 | Whitespaces( 1947 | LocatedSpan { 1948 | offset: 22, 1949 | line: 1, 1950 | fragment: " ", 1951 | extra: (), 1952 | }, 1953 | ), 1954 | Text( 1955 | LocatedSpan { 1956 | offset: 23, 1957 | line: 1, 1958 | fragment: "\\{string}", 1959 | extra: (), 1960 | }, 1961 | ), 1962 | Whitespaces( 1963 | LocatedSpan { 1964 | offset: 32, 1965 | line: 1, 1966 | fragment: " ", 1967 | extra: (), 1968 | }, 1969 | ), 1970 | Text( 1971 | LocatedSpan { 1972 | offset: 33, 1973 | line: 1, 1974 | fragment: "mice", 1975 | extra: (), 1976 | }, 1977 | ), 1978 | ], 1979 | )"#, 1980 | ); 1981 | } 1982 | 1983 | #[test] 1984 | fn matches_doubly_escaped_slash() { 1985 | assert_ast_eq( 1986 | unwrap_parser(expression(Spanned::new("12\\\\/2020"))), 1987 | r#"Expression( 1988 | [ 1989 | Alternation( 1990 | Alternation( 1991 | [ 1992 | [ 1993 | Text( 1994 | LocatedSpan { 1995 | offset: 0, 1996 | line: 1, 1997 | fragment: "12\\\\", 1998 | extra: (), 1999 | }, 2000 | ), 2001 | ], 2002 | [ 2003 | Text( 2004 | LocatedSpan { 2005 | offset: 5, 2006 | line: 1, 2007 | fragment: "2020", 2008 | extra: (), 2009 | }, 2010 | ), 2011 | ], 2012 | ], 2013 | ), 2014 | ), 2015 | ], 2016 | )"#, 2017 | ); 2018 | } 2019 | 2020 | #[test] 2021 | fn matches_optional_before_alternation() { 2022 | assert_ast_eq( 2023 | unwrap_parser(expression(Spanned::new( 2024 | "three (brown )mice/rats", 2025 | ))), 2026 | r#"Expression( 2027 | [ 2028 | Text( 2029 | LocatedSpan { 2030 | offset: 0, 2031 | line: 1, 2032 | fragment: "three", 2033 | extra: (), 2034 | }, 2035 | ), 2036 | Whitespaces( 2037 | LocatedSpan { 2038 | offset: 5, 2039 | line: 1, 2040 | fragment: " ", 2041 | extra: (), 2042 | }, 2043 | ), 2044 | Alternation( 2045 | Alternation( 2046 | [ 2047 | [ 2048 | Optional( 2049 | Optional( 2050 | LocatedSpan { 2051 | offset: 7, 2052 | line: 1, 2053 | fragment: "brown ", 2054 | extra: (), 2055 | }, 2056 | ), 2057 | ), 2058 | Text( 2059 | LocatedSpan { 2060 | offset: 14, 2061 | line: 1, 2062 | fragment: "mice", 2063 | extra: (), 2064 | }, 2065 | ), 2066 | ], 2067 | [ 2068 | Text( 2069 | LocatedSpan { 2070 | offset: 19, 2071 | line: 1, 2072 | fragment: "rats", 2073 | extra: (), 2074 | }, 2075 | ), 2076 | ], 2077 | ], 2078 | ), 2079 | ), 2080 | ], 2081 | )"#, 2082 | ); 2083 | } 2084 | 2085 | #[test] 2086 | fn matches_optional_in_alternation() { 2087 | assert_ast_eq( 2088 | unwrap_parser(expression(Spanned::new( 2089 | "{int} rat(s)/mouse/mice", 2090 | ))), 2091 | r#"Expression( 2092 | [ 2093 | Parameter( 2094 | Parameter { 2095 | input: LocatedSpan { 2096 | offset: 1, 2097 | line: 1, 2098 | fragment: "int", 2099 | extra: (), 2100 | }, 2101 | id: 0, 2102 | }, 2103 | ), 2104 | Whitespaces( 2105 | LocatedSpan { 2106 | offset: 5, 2107 | line: 1, 2108 | fragment: " ", 2109 | extra: (), 2110 | }, 2111 | ), 2112 | Alternation( 2113 | Alternation( 2114 | [ 2115 | [ 2116 | Text( 2117 | LocatedSpan { 2118 | offset: 6, 2119 | line: 1, 2120 | fragment: "rat", 2121 | extra: (), 2122 | }, 2123 | ), 2124 | Optional( 2125 | Optional( 2126 | LocatedSpan { 2127 | offset: 10, 2128 | line: 1, 2129 | fragment: "s", 2130 | extra: (), 2131 | }, 2132 | ), 2133 | ), 2134 | ], 2135 | [ 2136 | Text( 2137 | LocatedSpan { 2138 | offset: 13, 2139 | line: 1, 2140 | fragment: "mouse", 2141 | extra: (), 2142 | }, 2143 | ), 2144 | ], 2145 | [ 2146 | Text( 2147 | LocatedSpan { 2148 | offset: 19, 2149 | line: 1, 2150 | fragment: "mice", 2151 | extra: (), 2152 | }, 2153 | ), 2154 | ], 2155 | ], 2156 | ), 2157 | ), 2158 | ], 2159 | )"#, 2160 | ); 2161 | } 2162 | 2163 | #[test] 2164 | fn err_on_escaped_end_of_line() { 2165 | match expression(Spanned::new("\\")).unwrap_err() { 2166 | Err::Failure(Error::EscapedEndOfLine(_)) => {} 2167 | e @ (Err::Incomplete(_) | Err::Error(_) | Err::Failure(_)) => { 2168 | panic!("wrong err: {e}"); 2169 | } 2170 | } 2171 | } 2172 | 2173 | #[test] 2174 | fn empty() { 2175 | assert_ast_eq( 2176 | unwrap_parser(expression(Spanned::new(""))), 2177 | r#"Expression([],)"#, 2178 | ); 2179 | } 2180 | } 2181 | } 2182 | --------------------------------------------------------------------------------