├── .clippy.toml ├── .github ├── renovate.json5 ├── settings.yml └── workflows │ ├── audit.yml │ ├── ci.yml │ ├── committed.yml │ ├── pre-commit.yml │ ├── rust-next.yml │ └── spelling.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── committed.toml ├── deny.toml ├── release.toml └── src ├── commit.rs ├── error.rs ├── lib.rs ├── lines.rs └── parser.rs /.clippy.toml: -------------------------------------------------------------------------------- 1 | allow-print-in-tests = true 2 | allow-expect-in-tests = true 3 | allow-unwrap-in-tests = true 4 | allow-dbg-in-tests = true 5 | disallowed-methods = [ 6 | { path = "std::option::Option::map_or", reason = "prefer `map(..).unwrap_or(..)` for legibility" }, 7 | { path = "std::option::Option::map_or_else", reason = "prefer `map(..).unwrap_or_else(..)` for legibility" }, 8 | { path = "std::result::Result::map_or", reason = "prefer `map(..).unwrap_or(..)` for legibility" }, 9 | { path = "std::result::Result::map_or_else", reason = "prefer `map(..).unwrap_or_else(..)` for legibility" }, 10 | { path = "std::iter::Iterator::for_each", reason = "prefer `for` for side-effects" }, 11 | { path = "std::iter::Iterator::try_for_each", reason = "prefer `for` for side-effects" }, 12 | ] 13 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | schedule: [ 3 | 'before 5am on the first day of the month', 4 | ], 5 | semanticCommits: 'enabled', 6 | commitMessageLowerCase: 'never', 7 | configMigration: true, 8 | dependencyDashboard: true, 9 | customManagers: [ 10 | { 11 | customType: 'regex', 12 | fileMatch: [ 13 | '^rust-toolchain\\.toml$', 14 | 'Cargo.toml$', 15 | 'clippy.toml$', 16 | '\\.clippy.toml$', 17 | '^\\.github/workflows/ci.yml$', 18 | '^\\.github/workflows/rust-next.yml$', 19 | ], 20 | matchStrings: [ 21 | 'STABLE.*?(?\\d+\\.\\d+(\\.\\d+)?)', 22 | '(?\\d+\\.\\d+(\\.\\d+)?).*?STABLE', 23 | ], 24 | depNameTemplate: 'STABLE', 25 | packageNameTemplate: 'rust-lang/rust', 26 | datasourceTemplate: 'github-releases', 27 | }, 28 | ], 29 | packageRules: [ 30 | { 31 | commitMessageTopic: 'Rust Stable', 32 | matchManagers: [ 33 | 'custom.regex', 34 | ], 35 | matchDepNames: [ 36 | 'STABLE', 37 | ], 38 | extractVersion: '^(?\\d+\\.\\d+)', // Drop the patch version 39 | schedule: [ 40 | '* * * * *', 41 | ], 42 | automerge: true, 43 | }, 44 | // Goals: 45 | // - Keep version reqs low, ignoring compatible normal/build dependencies 46 | // - Take advantage of latest dev-dependencies 47 | // - Rollup safe upgrades to reduce CI runner load 48 | // - Help keep number of versions down by always using latest breaking change 49 | // - Have lockfile and manifest in-sync 50 | { 51 | matchManagers: [ 52 | 'cargo', 53 | ], 54 | matchDepTypes: [ 55 | 'build-dependencies', 56 | 'dependencies', 57 | ], 58 | matchCurrentVersion: '>=0.1.0', 59 | matchUpdateTypes: [ 60 | 'patch', 61 | ], 62 | enabled: false, 63 | }, 64 | { 65 | matchManagers: [ 66 | 'cargo', 67 | ], 68 | matchDepTypes: [ 69 | 'build-dependencies', 70 | 'dependencies', 71 | ], 72 | matchCurrentVersion: '>=1.0.0', 73 | matchUpdateTypes: [ 74 | 'minor', 75 | 'patch', 76 | ], 77 | enabled: false, 78 | }, 79 | { 80 | matchManagers: [ 81 | 'cargo', 82 | ], 83 | matchDepTypes: [ 84 | 'dev-dependencies', 85 | ], 86 | matchCurrentVersion: '>=0.1.0', 87 | matchUpdateTypes: [ 88 | 'patch', 89 | ], 90 | automerge: true, 91 | groupName: 'compatible (dev)', 92 | }, 93 | { 94 | matchManagers: [ 95 | 'cargo', 96 | ], 97 | matchDepTypes: [ 98 | 'dev-dependencies', 99 | ], 100 | matchCurrentVersion: '>=1.0.0', 101 | matchUpdateTypes: [ 102 | 'minor', 103 | 'patch', 104 | ], 105 | automerge: true, 106 | groupName: 'compatible (dev)', 107 | }, 108 | ], 109 | } 110 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | # These settings are synced to GitHub by https://probot.github.io/apps/settings/ 2 | 3 | repository: 4 | description: "Conventional Commit API" 5 | homepage: "docs.rs/git-conventional" 6 | topics: "rust git conventional" 7 | has_issues: true 8 | has_projects: false 9 | has_wiki: false 10 | has_downloads: true 11 | default_branch: master 12 | 13 | # Preference: people do clean commits 14 | allow_merge_commit: true 15 | # Backup in case we need to clean up commits 16 | allow_squash_merge: true 17 | # Not really needed 18 | allow_rebase_merge: false 19 | 20 | allow_auto_merge: true 21 | delete_branch_on_merge: true 22 | 23 | squash_merge_commit_title: "PR_TITLE" 24 | squash_merge_commit_message: "PR_BODY" 25 | merge_commit_message: "PR_BODY" 26 | 27 | labels: 28 | # Type 29 | - name: bug 30 | color: '#b60205' 31 | description: "Not as expected" 32 | - name: enhancement 33 | color: '#1d76db' 34 | description: "Improve the expected" 35 | # Flavor 36 | - name: question 37 | color: "#cc317c" 38 | description: "Uncertainty is involved" 39 | - name: breaking-change 40 | color: "#e99695" 41 | - name: good first issue 42 | color: '#c2e0c6' 43 | description: "Help wanted!" 44 | 45 | # This serves more as documentation. 46 | # Branch protection API was replaced by rulesets but settings isn't updated. 47 | # See https://github.com/repository-settings/app/issues/825 48 | # 49 | # branches: 50 | # - name: master 51 | # protection: 52 | # required_pull_request_reviews: null 53 | # required_conversation_resolution: true 54 | # required_status_checks: 55 | # # Required. Require branches to be up to date before merging. 56 | # strict: false 57 | # contexts: ["CI", "Spell Check with Typos"] 58 | # enforce_admins: false 59 | # restrictions: null 60 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | pull_request: 8 | paths: 9 | - '**/Cargo.toml' 10 | - '**/Cargo.lock' 11 | push: 12 | branches: 13 | - master 14 | 15 | env: 16 | RUST_BACKTRACE: 1 17 | CARGO_TERM_COLOR: always 18 | CLICOLOR: 1 19 | 20 | concurrency: 21 | group: "${{ github.workflow }}-${{ github.ref }}" 22 | cancel-in-progress: true 23 | 24 | jobs: 25 | security_audit: 26 | permissions: 27 | issues: write # to create issues (actions-rs/audit-check) 28 | checks: write # to create check (actions-rs/audit-check) 29 | runs-on: ubuntu-latest 30 | # Prevent sudden announcement of a new advisory from failing ci: 31 | continue-on-error: true 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v4 35 | - uses: actions-rs/audit-check@v1 36 | with: 37 | token: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | cargo_deny: 40 | permissions: 41 | issues: write # to create issues (actions-rs/audit-check) 42 | checks: write # to create check (actions-rs/audit-check) 43 | runs-on: ubuntu-latest 44 | strategy: 45 | matrix: 46 | checks: 47 | - bans licenses sources 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: EmbarkStudios/cargo-deny-action@v2 51 | with: 52 | command: check ${{ matrix.checks }} 53 | rust-version: stable 54 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | pull_request: 8 | push: 9 | branches: 10 | - master 11 | 12 | env: 13 | RUST_BACKTRACE: 1 14 | CARGO_TERM_COLOR: always 15 | CLICOLOR: 1 16 | 17 | concurrency: 18 | group: "${{ github.workflow }}-${{ github.ref }}" 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | ci: 23 | permissions: 24 | contents: none 25 | name: CI 26 | needs: [test, msrv, lockfile, docs, rustfmt, clippy, minimal-versions] 27 | runs-on: ubuntu-latest 28 | if: "always()" 29 | steps: 30 | - name: Failed 31 | run: exit 1 32 | if: "contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')" 33 | test: 34 | name: Test 35 | strategy: 36 | matrix: 37 | os: ["ubuntu-latest", "windows-latest", "macos-latest"] 38 | rust: ["stable"] 39 | continue-on-error: ${{ matrix.rust != 'stable' }} 40 | runs-on: ${{ matrix.os }} 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v4 44 | - name: Install Rust 45 | uses: dtolnay/rust-toolchain@stable 46 | with: 47 | toolchain: ${{ matrix.rust }} 48 | - uses: Swatinem/rust-cache@v2 49 | - uses: taiki-e/install-action@cargo-hack 50 | - name: Build 51 | run: cargo test --workspace --no-run 52 | - name: Test 53 | run: cargo hack test --feature-powerset --workspace 54 | msrv: 55 | name: "Check MSRV" 56 | runs-on: ubuntu-latest 57 | steps: 58 | - name: Checkout repository 59 | uses: actions/checkout@v4 60 | - name: Install Rust 61 | uses: dtolnay/rust-toolchain@stable 62 | with: 63 | toolchain: stable 64 | - uses: Swatinem/rust-cache@v2 65 | - uses: taiki-e/install-action@cargo-hack 66 | - name: Default features 67 | run: cargo hack check --feature-powerset --locked --rust-version --ignore-private --workspace --all-targets 68 | minimal-versions: 69 | name: Minimal versions 70 | runs-on: ubuntu-latest 71 | steps: 72 | - name: Checkout repository 73 | uses: actions/checkout@v4 74 | - name: Install stable Rust 75 | uses: dtolnay/rust-toolchain@stable 76 | with: 77 | toolchain: stable 78 | - name: Install nightly Rust 79 | uses: dtolnay/rust-toolchain@stable 80 | with: 81 | toolchain: nightly 82 | - name: Downgrade dependencies to minimal versions 83 | run: cargo +nightly generate-lockfile -Z minimal-versions 84 | - name: Compile with minimal versions 85 | run: cargo +stable check --workspace --all-features --locked 86 | lockfile: 87 | runs-on: ubuntu-latest 88 | steps: 89 | - name: Checkout repository 90 | uses: actions/checkout@v4 91 | - name: Install Rust 92 | uses: dtolnay/rust-toolchain@stable 93 | with: 94 | toolchain: stable 95 | - uses: Swatinem/rust-cache@v2 96 | - name: "Is lockfile updated?" 97 | run: cargo update --workspace --locked 98 | docs: 99 | name: Docs 100 | runs-on: ubuntu-latest 101 | steps: 102 | - name: Checkout repository 103 | uses: actions/checkout@v4 104 | - name: Install Rust 105 | uses: dtolnay/rust-toolchain@stable 106 | with: 107 | toolchain: "1.87" # STABLE 108 | - uses: Swatinem/rust-cache@v2 109 | - name: Check documentation 110 | env: 111 | RUSTDOCFLAGS: -D warnings 112 | run: cargo doc --workspace --all-features --no-deps --document-private-items 113 | rustfmt: 114 | name: rustfmt 115 | runs-on: ubuntu-latest 116 | steps: 117 | - name: Checkout repository 118 | uses: actions/checkout@v4 119 | - name: Install Rust 120 | uses: dtolnay/rust-toolchain@stable 121 | with: 122 | toolchain: "1.87" # STABLE 123 | components: rustfmt 124 | - uses: Swatinem/rust-cache@v2 125 | - name: Check formatting 126 | run: cargo fmt --all -- --check 127 | clippy: 128 | name: clippy 129 | runs-on: ubuntu-latest 130 | permissions: 131 | security-events: write # to upload sarif results 132 | steps: 133 | - name: Checkout repository 134 | uses: actions/checkout@v4 135 | - name: Install Rust 136 | uses: dtolnay/rust-toolchain@stable 137 | with: 138 | toolchain: "1.87" # STABLE 139 | components: clippy 140 | - uses: Swatinem/rust-cache@v2 141 | - name: Install SARIF tools 142 | run: cargo install clippy-sarif --locked 143 | - name: Install SARIF tools 144 | run: cargo install sarif-fmt --locked 145 | - name: Check 146 | run: > 147 | cargo clippy --workspace --all-features --all-targets --message-format=json 148 | | clippy-sarif 149 | | tee clippy-results.sarif 150 | | sarif-fmt 151 | continue-on-error: true 152 | - name: Upload 153 | uses: github/codeql-action/upload-sarif@v3 154 | with: 155 | sarif_file: clippy-results.sarif 156 | wait-for-processing: true 157 | - name: Report status 158 | run: cargo clippy --workspace --all-features --all-targets -- -D warnings --allow deprecated 159 | coverage: 160 | name: Coverage 161 | runs-on: ubuntu-latest 162 | steps: 163 | - name: Checkout repository 164 | uses: actions/checkout@v4 165 | - name: Install Rust 166 | uses: dtolnay/rust-toolchain@stable 167 | with: 168 | toolchain: stable 169 | - uses: Swatinem/rust-cache@v2 170 | - name: Install cargo-tarpaulin 171 | run: cargo install cargo-tarpaulin 172 | - name: Gather coverage 173 | run: cargo tarpaulin --output-dir coverage --out lcov 174 | - name: Publish to Coveralls 175 | uses: coverallsapp/github-action@master 176 | with: 177 | github-token: ${{ secrets.GITHUB_TOKEN }} 178 | -------------------------------------------------------------------------------- /.github/workflows/committed.yml: -------------------------------------------------------------------------------- 1 | # Not run as part of pre-commit checks because they don't handle sending the correct commit 2 | # range to `committed` 3 | name: Lint Commits 4 | on: [pull_request] 5 | 6 | permissions: 7 | contents: read 8 | 9 | env: 10 | RUST_BACKTRACE: 1 11 | CARGO_TERM_COLOR: always 12 | CLICOLOR: 1 13 | 14 | concurrency: 15 | group: "${{ github.workflow }}-${{ github.ref }}" 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | committed: 20 | name: Lint Commits 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout Actions Repository 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | - name: Lint Commits 28 | uses: crate-ci/committed@master 29 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | permissions: {} # none 4 | 5 | on: 6 | pull_request: 7 | push: 8 | branches: [master] 9 | 10 | env: 11 | RUST_BACKTRACE: 1 12 | CARGO_TERM_COLOR: always 13 | CLICOLOR: 1 14 | 15 | concurrency: 16 | group: "${{ github.workflow }}-${{ github.ref }}" 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | pre-commit: 21 | permissions: 22 | contents: read 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions/setup-python@v5 27 | with: 28 | python-version: '3.x' 29 | - uses: pre-commit/action@v3.0.1 30 | -------------------------------------------------------------------------------- /.github/workflows/rust-next.yml: -------------------------------------------------------------------------------- 1 | name: rust-next 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | schedule: 8 | - cron: '5 5 5 * *' 9 | 10 | env: 11 | RUST_BACKTRACE: 1 12 | CARGO_TERM_COLOR: always 13 | CLICOLOR: 1 14 | 15 | concurrency: 16 | group: "${{ github.workflow }}-${{ github.ref }}" 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | test: 21 | name: Test 22 | strategy: 23 | matrix: 24 | os: ["ubuntu-latest", "windows-latest", "macos-latest"] 25 | rust: ["stable", "beta"] 26 | include: 27 | - os: ubuntu-latest 28 | rust: "nightly" 29 | continue-on-error: ${{ matrix.rust != 'stable' }} 30 | runs-on: ${{ matrix.os }} 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v4 34 | - name: Install Rust 35 | uses: dtolnay/rust-toolchain@stable 36 | with: 37 | toolchain: ${{ matrix.rust }} 38 | - uses: Swatinem/rust-cache@v2 39 | - uses: taiki-e/install-action@cargo-hack 40 | - name: Build 41 | run: cargo test --workspace --no-run 42 | - name: Test 43 | run: cargo hack test --feature-powerset --workspace 44 | latest: 45 | name: "Check latest dependencies" 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Checkout repository 49 | uses: actions/checkout@v4 50 | - name: Install Rust 51 | uses: dtolnay/rust-toolchain@stable 52 | with: 53 | toolchain: stable 54 | - uses: Swatinem/rust-cache@v2 55 | - uses: taiki-e/install-action@cargo-hack 56 | - name: Update dependencies 57 | run: cargo update 58 | - name: Build 59 | run: cargo test --workspace --no-run 60 | - name: Test 61 | run: cargo hack test --feature-powerset --workspace 62 | -------------------------------------------------------------------------------- /.github/workflows/spelling.yml: -------------------------------------------------------------------------------- 1 | name: Spelling 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: [pull_request] 7 | 8 | env: 9 | RUST_BACKTRACE: 1 10 | CARGO_TERM_COLOR: always 11 | CLICOLOR: 1 12 | 13 | concurrency: 14 | group: "${{ github.workflow }}-${{ github.ref }}" 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | spelling: 19 | name: Spell Check with Typos 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout Actions Repository 23 | uses: actions/checkout@v4 24 | - name: Spell Check Repo 25 | uses: crate-ci/typos@master 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: check-yaml 6 | stages: [commit] 7 | - id: check-json 8 | stages: [commit] 9 | - id: check-toml 10 | stages: [commit] 11 | - id: check-merge-conflict 12 | stages: [commit] 13 | - id: check-case-conflict 14 | stages: [commit] 15 | - id: detect-private-key 16 | stages: [commit] 17 | - repo: https://github.com/crate-ci/typos 18 | rev: v1.16.20 19 | hooks: 20 | - id: typos 21 | stages: [commit] 22 | - repo: https://github.com/crate-ci/committed 23 | rev: v1.0.20 24 | hooks: 25 | - id: committed 26 | stages: [commit-msg] 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | 8 | ## [Unreleased] - ReleaseDate 9 | 10 | ## [0.12.9] - 2025-01-30 11 | 12 | ### Internal 13 | 14 | - Update dependencies 15 | 16 | ## [0.12.8] - 2025-01-24 17 | 18 | ### Internal 19 | 20 | - Removed a dependency 21 | 22 | ## [0.12.7] - 2024-07-25 23 | 24 | ### Compatibility 25 | 26 | - Update MSRV to 1.74 27 | 28 | ## [0.12.6] - 2024-02-16 29 | 30 | ### Features 31 | 32 | - Implement `Display` for `Footer` 33 | 34 | ## [0.12.5] - 2024-02-13 35 | 36 | ### Internal 37 | 38 | - Update dependencies 39 | 40 | ## [0.12.4] - 2023-07-14 41 | 42 | ### Internal 43 | 44 | - Update dependencies 45 | 46 | ## [0.12.3] - 2023-03-18 47 | 48 | ### Internal 49 | 50 | - Update dependencies 51 | 52 | ## [0.12.2] - 2023-02-22 53 | 54 | ### Internal 55 | 56 | - Update dependencies 57 | 58 | ## [0.12.1] - 2022-12-29 59 | 60 | ### Fixes 61 | 62 | - Ensure footers value isn't confused with another footer 63 | 64 | ## [0.12.0] - 2022-07-18 65 | 66 | ### Fixes 67 | 68 | - Error when a newline doesn't separate summary from description 69 | 70 | ## [0.11.3] - 2022-04-14 71 | 72 | ### Fixes 73 | 74 | - Don't treat body text as a footer but require a newline 75 | 76 | ## [0.11.2] - 2022-01-18 77 | 78 | ### Fixes 79 | 80 | - When a body and footer have extra newlines between them, don't put them at the end of the body 81 | - Handle windows newlines (was missing footers with them) 82 | 83 | ## [0.11.1] - 2021-12-14 84 | 85 | ### Fixes 86 | 87 | - Clarify error messages 88 | 89 | ## [0.11.0] - 2021-10-19 90 | 91 | ### Breaking Changes 92 | 93 | - Some grammar changes *might* have made us more restrictive, but more likely they have made parsing more loose 94 | - `FooterSeparator` variants have been renamed 95 | 96 | ### Fixes 97 | 98 | - Parser is now closer to [the proposed grammar](https://github.com/conventional-commits/parser) 99 | 100 | ## [0.10.3] - 2021-09-18 101 | 102 | ### Fixes 103 | 104 | - Relaxed some lifetimes, associating them with the message, rather than `Commit`. 105 | 106 | ## [0.10.2] - 2021-09-06 107 | 108 | ### Fixes 109 | 110 | - Support scopes with numbers in them, like `x86` 111 | 112 | ## [0.10.1] - 2021-09-03 113 | 114 | ### Fixes 115 | 116 | - Allow trailing newlines when there is no body 117 | 118 | ## [0.10.0] - 2021-08-18 119 | 120 | ### Features 121 | 122 | - `Commit::breaking_description` to handle the two potential sources for you 123 | 124 | ### Breaking Changes 125 | 126 | - Moved `` consts to `Type::`. 127 | 128 | ## [0.9.2] - 2021-05-24 129 | 130 | ### Features 131 | 132 | - `serde` feature for serializing `Commit`. 133 | 134 | ## [0.9.1] - 2021-01-30 135 | 136 | ## [0.9.0] - 2020-05-06 137 | 138 | ### Breaking Changes 139 | 140 | - Made error type work as `Send` / `Sync`. 141 | 142 | ## [0.8.0] - 2020-05-06 143 | 144 | ### Breaking Changes 145 | 146 | - `::new` has been renamed to `::new_unchecked` to signify that it bypasses validity checks. 147 | 148 | ## [0.7.0] - 2020-05-05 149 | 150 | ### Breaking Changes 151 | 152 | - Forked `conventional` as `git-conventional` 153 | - Merged `Simple` and `Typed` APIs (identifiers are typed, otherwise `str`s), removing the need to pull in a trait. 154 | 155 | ### Features 156 | 157 | - Add typed identifier equality with `str`. 158 | - Added constants for common `type_`s. 159 | - Made it easier to find the footer that describes a breaking change. 160 | - Expose means to convert `str` into typed identifier with validation. 161 | 162 | ### Fixes 163 | 164 | 165 | [Unreleased]: https://github.com/crate-ci/git-conventional/compare/v0.12.9...HEAD 166 | [0.12.9]: https://github.com/crate-ci/git-conventional/compare/v0.12.8...v0.12.9 167 | [0.12.8]: https://github.com/crate-ci/git-conventional/compare/v0.12.7...v0.12.8 168 | [0.12.7]: https://github.com/crate-ci/git-conventional/compare/v0.12.6...v0.12.7 169 | [0.12.6]: https://github.com/crate-ci/git-conventional/compare/v0.12.5...v0.12.6 170 | [0.12.5]: https://github.com/crate-ci/git-conventional/compare/v0.12.4...v0.12.5 171 | [0.12.4]: https://github.com/crate-ci/git-conventional/compare/v0.12.3...v0.12.4 172 | [0.12.3]: https://github.com/crate-ci/git-conventional/compare/v0.12.2...v0.12.3 173 | [0.12.2]: https://github.com/crate-ci/git-conventional/compare/v0.12.1...v0.12.2 174 | [0.12.1]: https://github.com/crate-ci/git-conventional/compare/v0.12.0...v0.12.1 175 | [0.12.0]: https://github.com/crate-ci/git-conventional/compare/v0.11.3...v0.12.0 176 | [0.11.3]: https://github.com/crate-ci/git-conventional/compare/v0.11.2...v0.11.3 177 | [0.11.2]: https://github.com/crate-ci/git-conventional/compare/v0.11.1...v0.11.2 178 | [0.11.1]: https://github.com/crate-ci/git-conventional/compare/v0.11.0...v0.11.1 179 | [0.11.0]: https://github.com/crate-ci/git-conventional/compare/v0.10.3...v0.11.0 180 | [0.10.3]: https://github.com/crate-ci/git-conventional/compare/v0.10.2...v0.10.3 181 | [0.10.2]: https://github.com/crate-ci/git-conventional/compare/v0.10.1...v0.10.2 182 | [0.10.1]: https://github.com/crate-ci/git-conventional/compare/v0.10.0...v0.10.1 183 | [0.10.0]: https://github.com/crate-ci/git-conventional/compare/v0.9.2...v0.10.0 184 | [0.9.2]: https://github.com/crate-ci/git-conventional/compare/v0.9.1...v0.9.2 185 | [0.9.1]: https://github.com/crate-ci/git-conventional/compare/v0.9.0...v0.9.1 186 | [0.9.0]: https://github.com/crate-ci/git-conventional/compare/v0.8.0...v0.9.0 187 | [0.8.0]: https://github.com/crate-ci/git-conventional/compare/v0.7.0...v0.8.0 188 | [0.7.0]: https://github.com/crate-ci/git-conventional/compare/ccaed9b35854a3536c4a2c89b89e33fbc5b6b4e4...v0.7.0 189 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to git-conventional 2 | 3 | Thanks for wanting to contribute! There are many ways to contribute and we 4 | appreciate any level you're willing to do. 5 | 6 | ## Feature Requests 7 | 8 | Need some new functionality to help? You can let us know by opening an 9 | [issue][new issue]. It's helpful to look through [all issues][all issues] in 10 | case its already being talked about. 11 | 12 | ## Bug Reports 13 | 14 | Please let us know about what problems you run into, whether in behavior or 15 | ergonomics of API. You can do this by opening an [issue][new issue]. It's 16 | helpful to look through [all issues][all issues] in case its already being 17 | talked about. 18 | 19 | ## Pull Requests 20 | 21 | Looking for an idea? Check our [issues][issues]. If it's look more open ended, 22 | it is probably best to post on the issue how you are thinking of resolving the 23 | issue so you can get feedback early in the process. We want you to be 24 | successful and it can be discouraging to find out a lot of re-work is needed. 25 | 26 | Already have an idea? It might be good to first [create an issue][new issue] 27 | to propose it so we can make sure we are aligned and lower the risk of having 28 | to re-work some of it and the discouragement that goes along with that. 29 | 30 | ### Process 31 | 32 | As a heads up, we'll be running your PR through the following gauntlet: 33 | - warnings turned to compile errors 34 | - `cargo test` 35 | - `rustfmt` 36 | - `clippy` 37 | - `rustdoc` 38 | - [`committed`](https://github.com/crate-ci/committed) as we use [Conventional](https://www.conventionalcommits.org) commit style 39 | - [`typos`](https://github.com/crate-ci/typos) to check spelling 40 | 41 | Not everything can be checked automatically though. 42 | 43 | We request that the commit history gets cleaned up. 44 | We ask that commits are atomic, meaning they are complete and have a single responsibility. 45 | PRs should tell a cohesive story, with test and refactor commits that keep the 46 | fix or feature commits simple and clear. 47 | 48 | Specifically, we would encourage 49 | - File renames be isolated into their own commit 50 | - Add tests in a commit before their feature or fix, showing the current behavior. 51 | The diff for the feature/fix commit will then show how the behavior changed, 52 | making it clearer to reviewers and the community and showing people that the 53 | test is verifying the expected state. 54 | - e.g. [clap#5520](https://github.com/clap-rs/clap/pull/5520) 55 | 56 | Note that we are talking about ideals. 57 | We understand having a clean history requires more advanced git skills; 58 | feel free to ask us for help! 59 | We might even suggest where it would work to be lax. 60 | We also understand that editing some early commits may cause a lot of churn 61 | with merge conflicts which can make it not worth editing all of the history. 62 | 63 | For code organization, we recommend 64 | - Grouping `impl` blocks next to their type (or trait) 65 | - Grouping private items after the `pub` item that uses them. 66 | - The intent is to help people quickly find the "relevant" details, allowing them to "dig deeper" as needed. Or put another way, the `pub` items serve as a table-of-contents. 67 | - The exact order is fuzzy; do what makes sense 68 | 69 | ## Releasing 70 | 71 | Pre-requisites 72 | - Running `cargo login` 73 | - A member of `ORG:Maintainers` 74 | - Push permission to the repo 75 | - [`cargo-release`](https://github.com/crate-ci/cargo-release/) 76 | 77 | When we're ready to release, a project owner should do the following 78 | 1. Update the changelog (see `cargo release changes` for ideas) 79 | 2. Determine what the next version is, according to semver 80 | 3. Run [`cargo release -x `](https://github.com/crate-ci/cargo-release) 81 | 82 | [issues]: https://github.com/crate-ci/git-conventional/issues 83 | [new issue]: https://github.com/crate-ci/git-conventional/issues/new 84 | [all issues]: https://github.com/crate-ci/git-conventional/issues?utf8=%E2%9C%93&q=is%3Aissue 85 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "git-conventional" 7 | version = "0.12.9" 8 | dependencies = [ 9 | "indoc", 10 | "serde", 11 | "serde_test", 12 | "unicase", 13 | "winnow", 14 | ] 15 | 16 | [[package]] 17 | name = "indoc" 18 | version = "2.0.6" 19 | source = "registry+https://github.com/rust-lang/crates.io-index" 20 | checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" 21 | 22 | [[package]] 23 | name = "memchr" 24 | version = "2.5.0" 25 | source = "registry+https://github.com/rust-lang/crates.io-index" 26 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 27 | 28 | [[package]] 29 | name = "proc-macro2" 30 | version = "1.0.78" 31 | source = "registry+https://github.com/rust-lang/crates.io-index" 32 | checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" 33 | dependencies = [ 34 | "unicode-ident", 35 | ] 36 | 37 | [[package]] 38 | name = "quote" 39 | version = "1.0.27" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "8f4f29d145265ec1c483c7c654450edde0bfe043d3938d6972630663356d9500" 42 | dependencies = [ 43 | "proc-macro2", 44 | ] 45 | 46 | [[package]] 47 | name = "serde" 48 | version = "1.0.163" 49 | source = "registry+https://github.com/rust-lang/crates.io-index" 50 | checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2" 51 | dependencies = [ 52 | "serde_derive", 53 | ] 54 | 55 | [[package]] 56 | name = "serde_derive" 57 | version = "1.0.163" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "8c805777e3930c8883389c602315a24224bcc738b63905ef87cd1420353ea93e" 60 | dependencies = [ 61 | "proc-macro2", 62 | "quote", 63 | "syn", 64 | ] 65 | 66 | [[package]] 67 | name = "serde_test" 68 | version = "1.0.177" 69 | source = "registry+https://github.com/rust-lang/crates.io-index" 70 | checksum = "7f901ee573cab6b3060453d2d5f0bae4e6d628c23c0a962ff9b5f1d7c8d4f1ed" 71 | dependencies = [ 72 | "serde", 73 | ] 74 | 75 | [[package]] 76 | name = "syn" 77 | version = "2.0.16" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "a6f671d4b5ffdb8eadec19c0ae67fe2639df8684bd7bc4b83d986b8db549cf01" 80 | dependencies = [ 81 | "proc-macro2", 82 | "quote", 83 | "unicode-ident", 84 | ] 85 | 86 | [[package]] 87 | name = "unicase" 88 | version = "2.6.0" 89 | source = "registry+https://github.com/rust-lang/crates.io-index" 90 | checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" 91 | dependencies = [ 92 | "version_check", 93 | ] 94 | 95 | [[package]] 96 | name = "unicode-ident" 97 | version = "1.0.8" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" 100 | 101 | [[package]] 102 | name = "version_check" 103 | version = "0.9.4" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 106 | 107 | [[package]] 108 | name = "winnow" 109 | version = "0.7.0" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "7e49d2d35d3fad69b39b94139037ecfb4f359f08958b9c11e7315ce770462419" 112 | dependencies = [ 113 | "memchr", 114 | ] 115 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | 4 | [workspace.package] 5 | repository = "https://github.com/crate-ci/git-conventional" 6 | license = "MIT OR Apache-2.0" 7 | edition = "2021" 8 | rust-version = "1.74" # MSRV 9 | include = [ 10 | "build.rs", 11 | "src/**/*", 12 | "Cargo.toml", 13 | "Cargo.lock", 14 | "LICENSE*", 15 | "README.md", 16 | "benches/**/*", 17 | "examples/**/*" 18 | ] 19 | 20 | [workspace.lints.rust] 21 | rust_2018_idioms = { level = "warn", priority = -1 } 22 | unreachable_pub = "warn" 23 | unsafe_op_in_unsafe_fn = "warn" 24 | unused_lifetimes = "warn" 25 | unused_macro_rules = "warn" 26 | unused_qualifications = "warn" 27 | 28 | [workspace.lints.clippy] 29 | bool_assert_comparison = "allow" 30 | branches_sharing_code = "allow" 31 | checked_conversions = "warn" 32 | collapsible_else_if = "allow" 33 | create_dir = "warn" 34 | dbg_macro = "warn" 35 | debug_assert_with_mut_call = "warn" 36 | doc_markdown = "warn" 37 | empty_enum = "warn" 38 | enum_glob_use = "warn" 39 | expl_impl_clone_on_copy = "warn" 40 | explicit_deref_methods = "warn" 41 | explicit_into_iter_loop = "warn" 42 | fallible_impl_from = "warn" 43 | filter_map_next = "warn" 44 | flat_map_option = "warn" 45 | float_cmp_const = "warn" 46 | fn_params_excessive_bools = "warn" 47 | from_iter_instead_of_collect = "warn" 48 | if_same_then_else = "allow" 49 | implicit_clone = "warn" 50 | imprecise_flops = "warn" 51 | inconsistent_struct_constructor = "warn" 52 | inefficient_to_string = "warn" 53 | infinite_loop = "warn" 54 | invalid_upcast_comparisons = "warn" 55 | large_digit_groups = "warn" 56 | large_stack_arrays = "warn" 57 | large_types_passed_by_value = "warn" 58 | let_and_return = "allow" # sometimes good to name what you are returning 59 | linkedlist = "warn" 60 | lossy_float_literal = "warn" 61 | macro_use_imports = "warn" 62 | mem_forget = "warn" 63 | mutex_integer = "warn" 64 | needless_continue = "warn" 65 | needless_for_each = "warn" 66 | negative_feature_names = "warn" 67 | path_buf_push_overwrite = "warn" 68 | ptr_as_ptr = "warn" 69 | rc_mutex = "warn" 70 | redundant_feature_names = "warn" 71 | ref_option_ref = "warn" 72 | rest_pat_in_fully_bound_structs = "warn" 73 | result_large_err = "allow" 74 | same_functions_in_if_condition = "warn" 75 | self_named_module_files = "warn" 76 | semicolon_if_nothing_returned = "warn" 77 | str_to_string = "warn" 78 | string_add = "warn" 79 | string_add_assign = "warn" 80 | string_lit_as_bytes = "warn" 81 | string_to_string = "warn" 82 | todo = "warn" 83 | trait_duplication_in_bounds = "warn" 84 | uninlined_format_args = "warn" 85 | verbose_file_reads = "warn" 86 | wildcard_imports = "warn" 87 | zero_sized_map_values = "warn" 88 | 89 | [package] 90 | name = "git-conventional" 91 | version = "0.12.9" 92 | description = "A parser library for the Conventional Commit specification." 93 | authors = ["Ed Page ", "Jean Mertz "] 94 | homepage = "https://github.com/crate-ci/git-conventional" 95 | documentation = "http://docs.rs/git-conventional/" 96 | readme = "README.md" 97 | categories = ["parser-implementations"] 98 | keywords = ["parser", "git", "conventional-commit", "commit", "conventional"] 99 | repository.workspace = true 100 | license.workspace = true 101 | edition.workspace = true 102 | rust-version.workspace = true 103 | include.workspace = true 104 | 105 | [package.metadata.docs.rs] 106 | all-features = true 107 | rustdoc-args = ["--cfg", "docsrs", "--generate-link-to-definition"] 108 | 109 | [package.metadata.release] 110 | pre-release-replacements = [ 111 | {file="CHANGELOG.md", search="Unreleased", replace="{{version}}", min=1}, 112 | {file="CHANGELOG.md", search="\\.\\.\\.HEAD", replace="...{{tag_name}}", exactly=1}, 113 | {file="CHANGELOG.md", search="ReleaseDate", replace="{{date}}", min=1}, 114 | {file="CHANGELOG.md", search="", replace="\n## [Unreleased] - ReleaseDate\n", exactly=1}, 115 | {file="CHANGELOG.md", search="", replace="\n[Unreleased]: https://github.com/crate-ci/git-conventional/compare/{{tag_name}}...HEAD", exactly=1}, 116 | ] 117 | 118 | [features] 119 | 120 | [dependencies] 121 | winnow = "0.7.0" 122 | unicase = "2.5" 123 | serde = { version = "1.0.163", optional = true, features = ["derive"] } 124 | 125 | [dev-dependencies] 126 | indoc = "2.0" 127 | serde_test = "1.0" 128 | 129 | [lints] 130 | workspace = true 131 | -------------------------------------------------------------------------------- /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 | 203 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) Individual contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `code>conventional::Commit` 2 | 3 | [![codecov](https://codecov.io/gh/crate-ci/git-conventional/branch/master/graph/badge.svg)](https://codecov.io/gh/crate-ci/git-conventional) 4 | [![Documentation](https://img.shields.io/badge/docs-master-blue.svg)][Documentation] 5 | ![License](https://img.shields.io/crates/l/git-conventional.svg) 6 | [![Crates Status](https://img.shields.io/crates/v/git-conventional.svg)][Crates.io] 7 | 8 | > A Rust parser library for the [Conventional Commit](https://www.conventionalcommits.org) spec. 9 | 10 | ## Quick Start 11 | 12 | 1. Add the crate to your `Cargo.toml`: 13 | 14 | ```console 15 | $ cargo add git_conventional 16 | ``` 17 | 18 | 2. Parse a commit and lookup what you need 19 | 20 | ```rust 21 | let commit = git_conventional::Commit::parse("feat(conventional commit): this is it!").unwrap(); 22 | 23 | assert_eq!(commit.type_(), git_conventional::Type::FEAT); 24 | assert_eq!(commit.scope().unwrap(), "conventional commit"); 25 | assert_eq!(commit.description(), "this is it!"); 26 | assert_eq!(commit.body(), None); 27 | ``` 28 | 29 | ## License 30 | 31 | Licensed under either of 32 | 33 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or ) 34 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or ) 35 | 36 | at your option. 37 | 38 | ### Contribution 39 | 40 | Unless you explicitly state otherwise, any contribution intentionally 41 | submitted for inclusion in the work by you, as defined in the Apache-2.0 42 | license, shall be dual licensed as above, without any additional terms or 43 | conditions. 44 | 45 | [Crates.io]: https://crates.io/crates/git-conventional 46 | [Documentation]: https://docs.rs/git-conventional 47 | -------------------------------------------------------------------------------- /committed.toml: -------------------------------------------------------------------------------- 1 | style="conventional" 2 | ignore_author_re="(dependabot|renovate)" 3 | merge_commit = false 4 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # Note that all fields that take a lint level have these possible values: 2 | # * deny - An error will be produced and the check will fail 3 | # * warn - A warning will be produced, but the check will not fail 4 | # * allow - No warning or error will be produced, though in some cases a note 5 | # will be 6 | 7 | # Root options 8 | 9 | # The graph table configures how the dependency graph is constructed and thus 10 | # which crates the checks are performed against 11 | [graph] 12 | # If 1 or more target triples (and optionally, target_features) are specified, 13 | # only the specified targets will be checked when running `cargo deny check`. 14 | # This means, if a particular package is only ever used as a target specific 15 | # dependency, such as, for example, the `nix` crate only being used via the 16 | # `target_family = "unix"` configuration, that only having windows targets in 17 | # this list would mean the nix crate, as well as any of its exclusive 18 | # dependencies not shared by any other crates, would be ignored, as the target 19 | # list here is effectively saying which targets you are building for. 20 | targets = [ 21 | # The triple can be any string, but only the target triples built in to 22 | # rustc (as of 1.40) can be checked against actual config expressions 23 | #"x86_64-unknown-linux-musl", 24 | # You can also specify which target_features you promise are enabled for a 25 | # particular target. target_features are currently not validated against 26 | # the actual valid features supported by the target architecture. 27 | #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, 28 | ] 29 | # When creating the dependency graph used as the source of truth when checks are 30 | # executed, this field can be used to prune crates from the graph, removing them 31 | # from the view of cargo-deny. This is an extremely heavy hammer, as if a crate 32 | # is pruned from the graph, all of its dependencies will also be pruned unless 33 | # they are connected to another crate in the graph that hasn't been pruned, 34 | # so it should be used with care. The identifiers are [Package ID Specifications] 35 | # (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) 36 | #exclude = [] 37 | # If true, metadata will be collected with `--all-features`. Note that this can't 38 | # be toggled off if true, if you want to conditionally enable `--all-features` it 39 | # is recommended to pass `--all-features` on the cmd line instead 40 | all-features = false 41 | # If true, metadata will be collected with `--no-default-features`. The same 42 | # caveat with `all-features` applies 43 | no-default-features = false 44 | # If set, these feature will be enabled when collecting metadata. If `--features` 45 | # is specified on the cmd line they will take precedence over this option. 46 | #features = [] 47 | 48 | # The output table provides options for how/if diagnostics are outputted 49 | [output] 50 | # When outputting inclusion graphs in diagnostics that include features, this 51 | # option can be used to specify the depth at which feature edges will be added. 52 | # This option is included since the graphs can be quite large and the addition 53 | # of features from the crate(s) to all of the graph roots can be far too verbose. 54 | # This option can be overridden via `--feature-depth` on the cmd line 55 | feature-depth = 1 56 | 57 | # This section is considered when running `cargo deny check advisories` 58 | # More documentation for the advisories section can be found here: 59 | # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html 60 | [advisories] 61 | # The path where the advisory databases are cloned/fetched into 62 | #db-path = "$CARGO_HOME/advisory-dbs" 63 | # The url(s) of the advisory databases to use 64 | #db-urls = ["https://github.com/rustsec/advisory-db"] 65 | # A list of advisory IDs to ignore. Note that ignored advisories will still 66 | # output a note when they are encountered. 67 | ignore = [ 68 | #"RUSTSEC-0000-0000", 69 | #{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" }, 70 | #"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish 71 | #{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, 72 | ] 73 | # If this is true, then cargo deny will use the git executable to fetch advisory database. 74 | # If this is false, then it uses a built-in git library. 75 | # Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. 76 | # See Git Authentication for more information about setting up git authentication. 77 | #git-fetch-with-cli = true 78 | 79 | # This section is considered when running `cargo deny check licenses` 80 | # More documentation for the licenses section can be found here: 81 | # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html 82 | [licenses] 83 | # List of explicitly allowed licenses 84 | # See https://spdx.org/licenses/ for list of possible licenses 85 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 86 | allow = [ 87 | "MIT", 88 | "MIT-0", 89 | "Apache-2.0", 90 | "BSD-3-Clause", 91 | "MPL-2.0", 92 | "Unicode-DFS-2016", 93 | "CC0-1.0", 94 | "ISC", 95 | "OpenSSL", 96 | ] 97 | # The confidence threshold for detecting a license from license text. 98 | # The higher the value, the more closely the license text must be to the 99 | # canonical license text of a valid SPDX license file. 100 | # [possible values: any between 0.0 and 1.0]. 101 | confidence-threshold = 0.8 102 | # Allow 1 or more licenses on a per-crate basis, so that particular licenses 103 | # aren't accepted for every possible crate as with the normal allow list 104 | exceptions = [ 105 | # Each entry is the crate and version constraint, and its specific allow 106 | # list 107 | #{ allow = ["Zlib"], crate = "adler32" }, 108 | ] 109 | 110 | # Some crates don't have (easily) machine readable licensing information, 111 | # adding a clarification entry for it allows you to manually specify the 112 | # licensing information 113 | [[licenses.clarify]] 114 | # The package spec the clarification applies to 115 | crate = "ring" 116 | # The SPDX expression for the license requirements of the crate 117 | expression = "MIT AND ISC AND OpenSSL" 118 | # One or more files in the crate's source used as the "source of truth" for 119 | # the license expression. If the contents match, the clarification will be used 120 | # when running the license check, otherwise the clarification will be ignored 121 | # and the crate will be checked normally, which may produce warnings or errors 122 | # depending on the rest of your configuration 123 | license-files = [ 124 | # Each entry is a crate relative path, and the (opaque) hash of its contents 125 | { path = "LICENSE", hash = 0xbd0eed23 } 126 | ] 127 | 128 | [licenses.private] 129 | # If true, ignores workspace crates that aren't published, or are only 130 | # published to private registries. 131 | # To see how to mark a crate as unpublished (to the official registry), 132 | # visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. 133 | ignore = true 134 | # One or more private registries that you might publish crates to, if a crate 135 | # is only published to private registries, and ignore is true, the crate will 136 | # not have its license(s) checked 137 | registries = [ 138 | #"https://sekretz.com/registry 139 | ] 140 | 141 | # This section is considered when running `cargo deny check bans`. 142 | # More documentation about the 'bans' section can be found here: 143 | # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html 144 | [bans] 145 | # Lint level for when multiple versions of the same crate are detected 146 | multiple-versions = "warn" 147 | # Lint level for when a crate version requirement is `*` 148 | wildcards = "allow" 149 | # The graph highlighting used when creating dotgraphs for crates 150 | # with multiple versions 151 | # * lowest-version - The path to the lowest versioned duplicate is highlighted 152 | # * simplest-path - The path to the version with the fewest edges is highlighted 153 | # * all - Both lowest-version and simplest-path are used 154 | highlight = "all" 155 | # The default lint level for `default` features for crates that are members of 156 | # the workspace that is being checked. This can be overridden by allowing/denying 157 | # `default` on a crate-by-crate basis if desired. 158 | workspace-default-features = "allow" 159 | # The default lint level for `default` features for external crates that are not 160 | # members of the workspace. This can be overridden by allowing/denying `default` 161 | # on a crate-by-crate basis if desired. 162 | external-default-features = "allow" 163 | # List of crates that are allowed. Use with care! 164 | allow = [ 165 | #"ansi_term@0.11.0", 166 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" }, 167 | ] 168 | # List of crates to deny 169 | deny = [ 170 | #"ansi_term@0.11.0", 171 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" }, 172 | # Wrapper crates can optionally be specified to allow the crate when it 173 | # is a direct dependency of the otherwise banned crate 174 | #{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] }, 175 | ] 176 | 177 | # List of features to allow/deny 178 | # Each entry the name of a crate and a version range. If version is 179 | # not specified, all versions will be matched. 180 | #[[bans.features]] 181 | #crate = "reqwest" 182 | # Features to not allow 183 | #deny = ["json"] 184 | # Features to allow 185 | #allow = [ 186 | # "rustls", 187 | # "__rustls", 188 | # "__tls", 189 | # "hyper-rustls", 190 | # "rustls", 191 | # "rustls-pemfile", 192 | # "rustls-tls-webpki-roots", 193 | # "tokio-rustls", 194 | # "webpki-roots", 195 | #] 196 | # If true, the allowed features must exactly match the enabled feature set. If 197 | # this is set there is no point setting `deny` 198 | #exact = true 199 | 200 | # Certain crates/versions that will be skipped when doing duplicate detection. 201 | skip = [ 202 | #"ansi_term@0.11.0", 203 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" }, 204 | ] 205 | # Similarly to `skip` allows you to skip certain crates during duplicate 206 | # detection. Unlike skip, it also includes the entire tree of transitive 207 | # dependencies starting at the specified crate, up to a certain depth, which is 208 | # by default infinite. 209 | skip-tree = [ 210 | #"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies 211 | #{ crate = "ansi_term@0.11.0", depth = 20 }, 212 | ] 213 | 214 | # This section is considered when running `cargo deny check sources`. 215 | # More documentation about the 'sources' section can be found here: 216 | # https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html 217 | [sources] 218 | # Lint level for what to happen when a crate from a crate registry that is not 219 | # in the allow list is encountered 220 | unknown-registry = "deny" 221 | # Lint level for what to happen when a crate from a git repository that is not 222 | # in the allow list is encountered 223 | unknown-git = "deny" 224 | # List of URLs for allowed crate registries. Defaults to the crates.io index 225 | # if not specified. If it is specified but empty, no registries are allowed. 226 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 227 | # List of URLs for allowed Git repositories 228 | allow-git = [] 229 | 230 | [sources.allow-org] 231 | # 1 or more github.com organizations to allow git sources for 232 | github = [] 233 | # 1 or more gitlab.com organizations to allow git sources for 234 | gitlab = [] 235 | # 1 or more bitbucket.org organizations to allow git sources for 236 | bitbucket = [] 237 | -------------------------------------------------------------------------------- /release.toml: -------------------------------------------------------------------------------- 1 | allow-branch = ["master"] 2 | -------------------------------------------------------------------------------- /src/commit.rs: -------------------------------------------------------------------------------- 1 | //! The conventional commit type and its simple, and typed implementations. 2 | 3 | use std::fmt; 4 | use std::ops::Deref; 5 | use std::str::FromStr; 6 | 7 | use winnow::error::ContextError; 8 | use winnow::Parser; 9 | 10 | use crate::parser::parse; 11 | use crate::{Error, ErrorKind}; 12 | 13 | const BREAKING_PHRASE: &str = "BREAKING CHANGE"; 14 | const BREAKING_ARROW: &str = "BREAKING-CHANGE"; 15 | 16 | /// A conventional commit. 17 | #[cfg_attr(feature = "serde", derive(serde::Serialize))] 18 | #[derive(Clone, Debug, PartialEq, Eq)] 19 | pub struct Commit<'a> { 20 | ty: Type<'a>, 21 | scope: Option>, 22 | description: &'a str, 23 | body: Option<&'a str>, 24 | breaking: bool, 25 | #[cfg_attr(feature = "serde", serde(skip))] 26 | breaking_description: Option<&'a str>, 27 | footers: Vec>, 28 | } 29 | 30 | impl<'a> Commit<'a> { 31 | /// Create a new Conventional Commit based on the provided commit message 32 | /// string. 33 | /// 34 | /// # Errors 35 | /// 36 | /// This function returns an error if the commit does not conform to the 37 | /// Conventional Commit specification. 38 | pub fn parse(string: &'a str) -> Result { 39 | let (ty, scope, breaking, description, body, footers) = parse:: 40 | .parse(string) 41 | .map_err(|err| Error::with_nom(string, err))?; 42 | 43 | let breaking_description = footers 44 | .iter() 45 | .find_map(|(k, _, v)| (k == &BREAKING_PHRASE || k == &BREAKING_ARROW).then_some(*v)) 46 | .or_else(|| breaking.then_some(description)); 47 | let breaking = breaking_description.is_some(); 48 | let footers: Result, Error> = footers 49 | .into_iter() 50 | .map(|(k, s, v)| Ok(Footer::new(FooterToken::new_unchecked(k), s.parse()?, v))) 51 | .collect(); 52 | let footers = footers?; 53 | 54 | Ok(Self { 55 | ty: Type::new_unchecked(ty), 56 | scope: scope.map(Scope::new_unchecked), 57 | description, 58 | body, 59 | breaking, 60 | breaking_description, 61 | footers, 62 | }) 63 | } 64 | 65 | /// The type of the commit. 66 | pub fn type_(&self) -> Type<'a> { 67 | self.ty 68 | } 69 | 70 | /// The optional scope of the commit. 71 | pub fn scope(&self) -> Option> { 72 | self.scope 73 | } 74 | 75 | /// The commit description. 76 | pub fn description(&self) -> &'a str { 77 | self.description 78 | } 79 | 80 | /// The commit body, containing a more detailed explanation of the commit 81 | /// changes. 82 | pub fn body(&self) -> Option<&'a str> { 83 | self.body 84 | } 85 | 86 | /// A flag to signal that the commit contains breaking changes. 87 | /// 88 | /// This flag is set either when the commit has an exclamation mark after 89 | /// the message type and scope, e.g.: 90 | /// ```text 91 | /// feat(scope)!: this is a breaking change 92 | /// ``` 93 | /// 94 | /// Or when the `BREAKING CHANGE: ` footer is defined: 95 | /// ```text 96 | /// feat: my commit description 97 | /// 98 | /// BREAKING CHANGE: this is a breaking change 99 | /// ``` 100 | pub fn breaking(&self) -> bool { 101 | self.breaking 102 | } 103 | 104 | /// Explanation for the breaking change. 105 | /// 106 | /// Note: if no `BREAKING CHANGE` footer is provided, the `description` is expected to describe 107 | /// the breaking change. 108 | pub fn breaking_description(&self) -> Option<&'a str> { 109 | self.breaking_description 110 | } 111 | 112 | /// Any footer. 113 | /// 114 | /// A footer is similar to a Git trailer, with the exception of not 115 | /// requiring whitespace before newlines. 116 | /// 117 | /// See: 118 | pub fn footers(&self) -> &[Footer<'a>] { 119 | &self.footers 120 | } 121 | } 122 | 123 | impl fmt::Display for Commit<'_> { 124 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 125 | f.write_str(self.type_().as_str())?; 126 | 127 | if let Some(scope) = &self.scope() { 128 | f.write_fmt(format_args!("({scope})"))?; 129 | } 130 | 131 | f.write_fmt(format_args!(": {}", &self.description()))?; 132 | 133 | if let Some(body) = &self.body() { 134 | f.write_fmt(format_args!("\n\n{body}"))?; 135 | } 136 | 137 | for footer in self.footers() { 138 | write!(f, "\n\n{footer}")?; 139 | } 140 | 141 | Ok(()) 142 | } 143 | } 144 | 145 | /// A single footer. 146 | /// 147 | /// A footer is similar to a Git trailer, with the exception of not requiring 148 | /// whitespace before newlines. 149 | /// 150 | /// See: 151 | #[cfg_attr(feature = "serde", derive(serde::Serialize))] 152 | #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] 153 | pub struct Footer<'a> { 154 | token: FooterToken<'a>, 155 | sep: FooterSeparator, 156 | value: &'a str, 157 | } 158 | 159 | impl<'a> Footer<'a> { 160 | /// Piece together a footer. 161 | pub const fn new(token: FooterToken<'a>, sep: FooterSeparator, value: &'a str) -> Self { 162 | Self { token, sep, value } 163 | } 164 | 165 | /// The token of the footer. 166 | pub const fn token(&self) -> FooterToken<'a> { 167 | self.token 168 | } 169 | 170 | /// The separator between the footer token and its value. 171 | pub const fn separator(&self) -> FooterSeparator { 172 | self.sep 173 | } 174 | 175 | /// The value of the footer. 176 | pub const fn value(&self) -> &'a str { 177 | self.value 178 | } 179 | 180 | /// A flag to signal that the footer describes a breaking change. 181 | pub fn breaking(&self) -> bool { 182 | self.token.breaking() 183 | } 184 | } 185 | 186 | impl fmt::Display for Footer<'_> { 187 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 188 | let Self { token, sep, value } = self; 189 | write!(f, "{token}{sep}{value}") 190 | } 191 | } 192 | 193 | /// The type of separator between the footer token and value. 194 | #[cfg_attr(feature = "serde", derive(serde::Serialize))] 195 | #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] 196 | #[non_exhaustive] 197 | pub enum FooterSeparator { 198 | /// ":" 199 | Value, 200 | 201 | /// " #" 202 | Ref, 203 | } 204 | 205 | impl FooterSeparator { 206 | /// Access `str` representation of `FooterSeparator` 207 | pub fn as_str(self) -> &'static str { 208 | match self { 209 | FooterSeparator::Value => ":", 210 | FooterSeparator::Ref => " #", 211 | } 212 | } 213 | } 214 | 215 | impl Deref for FooterSeparator { 216 | type Target = str; 217 | 218 | fn deref(&self) -> &Self::Target { 219 | self.as_str() 220 | } 221 | } 222 | 223 | impl PartialEq<&'_ str> for FooterSeparator { 224 | fn eq(&self, other: &&str) -> bool { 225 | self.as_str() == *other 226 | } 227 | } 228 | 229 | impl fmt::Display for FooterSeparator { 230 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 231 | f.write_str(self) 232 | } 233 | } 234 | 235 | impl FromStr for FooterSeparator { 236 | type Err = Error; 237 | 238 | fn from_str(sep: &str) -> Result { 239 | match sep { 240 | ":" => Ok(FooterSeparator::Value), 241 | " #" => Ok(FooterSeparator::Ref), 242 | _ => { 243 | Err(Error::new(ErrorKind::InvalidFooter).set_context(Box::new(format!("{sep:?}")))) 244 | } 245 | } 246 | } 247 | } 248 | 249 | macro_rules! unicase_components { 250 | ($($ty:ident),+) => ( 251 | $( 252 | /// A component of the conventional commit. 253 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] 254 | pub struct $ty<'a>(unicase::UniCase<&'a str>); 255 | 256 | impl<'a> $ty<'a> { 257 | /// See `parse` for ensuring the data is valid. 258 | pub const fn new_unchecked(value: &'a str) -> Self { 259 | $ty(unicase::UniCase::unicode(value)) 260 | } 261 | 262 | /// Access `str` representation 263 | pub fn as_str(&self) -> &'a str { 264 | &self.0.into_inner() 265 | } 266 | } 267 | 268 | impl Deref for $ty<'_> { 269 | type Target = str; 270 | 271 | fn deref(&self) -> &Self::Target { 272 | self.as_str() 273 | } 274 | } 275 | 276 | impl PartialEq<&'_ str> for $ty<'_> { 277 | fn eq(&self, other: &&str) -> bool { 278 | *self == $ty::new_unchecked(*other) 279 | } 280 | } 281 | 282 | impl fmt::Display for $ty<'_> { 283 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 284 | self.0.fmt(f) 285 | } 286 | } 287 | 288 | #[cfg(feature = "serde")] 289 | impl serde::Serialize for $ty<'_> { 290 | fn serialize(&self, serializer: S) -> Result 291 | where 292 | S: serde::Serializer, 293 | { 294 | serializer.serialize_str(self) 295 | } 296 | } 297 | )+ 298 | ) 299 | } 300 | 301 | unicase_components![Type, Scope, FooterToken]; 302 | 303 | impl<'a> Type<'a> { 304 | /// Parse a `str` into a `Type`. 305 | pub fn parse(sep: &'a str) -> Result { 306 | let t = crate::parser::type_:: 307 | .parse(sep) 308 | .map_err(|err| Error::with_nom(sep, err))?; 309 | Ok(Type::new_unchecked(t)) 310 | } 311 | } 312 | 313 | /// Common commit types 314 | impl Type<'static> { 315 | /// Commit type when introducing new features (correlates with `minor` in semver) 316 | pub const FEAT: Type<'static> = Type::new_unchecked("feat"); 317 | /// Commit type when patching a bug (correlates with `patch` in semver) 318 | pub const FIX: Type<'static> = Type::new_unchecked("fix"); 319 | /// Possible commit type when reverting changes. 320 | pub const REVERT: Type<'static> = Type::new_unchecked("revert"); 321 | /// Possible commit type for changing documentation. 322 | pub const DOCS: Type<'static> = Type::new_unchecked("docs"); 323 | /// Possible commit type for changing code style. 324 | pub const STYLE: Type<'static> = Type::new_unchecked("style"); 325 | /// Possible commit type for refactoring code structure. 326 | pub const REFACTOR: Type<'static> = Type::new_unchecked("refactor"); 327 | /// Possible commit type for performance optimizations. 328 | pub const PERF: Type<'static> = Type::new_unchecked("perf"); 329 | /// Possible commit type for addressing tests. 330 | pub const TEST: Type<'static> = Type::new_unchecked("test"); 331 | /// Possible commit type for other things. 332 | pub const CHORE: Type<'static> = Type::new_unchecked("chore"); 333 | } 334 | 335 | impl<'a> Scope<'a> { 336 | /// Parse a `str` into a `Scope`. 337 | pub fn parse(sep: &'a str) -> Result { 338 | let t = crate::parser::scope:: 339 | .parse(sep) 340 | .map_err(|err| Error::with_nom(sep, err))?; 341 | Ok(Scope::new_unchecked(t)) 342 | } 343 | } 344 | 345 | impl<'a> FooterToken<'a> { 346 | /// Parse a `str` into a `FooterToken`. 347 | pub fn parse(sep: &'a str) -> Result { 348 | let t = crate::parser::token:: 349 | .parse(sep) 350 | .map_err(|err| Error::with_nom(sep, err))?; 351 | Ok(FooterToken::new_unchecked(t)) 352 | } 353 | 354 | /// A flag to signal that the footer describes a breaking change. 355 | pub fn breaking(&self) -> bool { 356 | self == &BREAKING_PHRASE || self == &BREAKING_ARROW 357 | } 358 | } 359 | 360 | #[cfg(test)] 361 | mod test { 362 | use super::*; 363 | use crate::ErrorKind; 364 | use indoc::indoc; 365 | #[cfg(feature = "serde")] 366 | use serde_test::Token; 367 | 368 | #[test] 369 | fn test_valid_simple_commit() { 370 | let commit = Commit::parse("type(my scope): hello world").unwrap(); 371 | 372 | assert_eq!(commit.type_(), "type"); 373 | assert_eq!(commit.scope().unwrap(), "my scope"); 374 | assert_eq!(commit.description(), "hello world"); 375 | } 376 | 377 | #[test] 378 | fn test_trailing_whitespace_without_body() { 379 | let commit = Commit::parse("type(my scope): hello world\n\n\n").unwrap(); 380 | 381 | assert_eq!(commit.type_(), "type"); 382 | assert_eq!(commit.scope().unwrap(), "my scope"); 383 | assert_eq!(commit.description(), "hello world"); 384 | } 385 | 386 | #[test] 387 | fn test_trailing_1_nl() { 388 | let commit = Commit::parse("type: hello world\n").unwrap(); 389 | 390 | assert_eq!(commit.type_(), "type"); 391 | assert_eq!(commit.scope(), None); 392 | assert_eq!(commit.description(), "hello world"); 393 | } 394 | 395 | #[test] 396 | fn test_trailing_2_nl() { 397 | let commit = Commit::parse("type: hello world\n\n").unwrap(); 398 | 399 | assert_eq!(commit.type_(), "type"); 400 | assert_eq!(commit.scope(), None); 401 | assert_eq!(commit.description(), "hello world"); 402 | } 403 | 404 | #[test] 405 | fn test_trailing_3_nl() { 406 | let commit = Commit::parse("type: hello world\n\n\n").unwrap(); 407 | 408 | assert_eq!(commit.type_(), "type"); 409 | assert_eq!(commit.scope(), None); 410 | assert_eq!(commit.description(), "hello world"); 411 | } 412 | 413 | #[test] 414 | fn test_parenthetical_statement() { 415 | let commit = Commit::parse("type: hello world (#1)").unwrap(); 416 | 417 | assert_eq!(commit.type_(), "type"); 418 | assert_eq!(commit.scope(), None); 419 | assert_eq!(commit.description(), "hello world (#1)"); 420 | } 421 | 422 | #[test] 423 | fn test_multiline_description() { 424 | let err = Commit::parse( 425 | "chore: Automate fastlane when a file in the fastlane directory is\nchanged (hopefully)", 426 | ).unwrap_err(); 427 | 428 | assert_eq!(ErrorKind::InvalidBody, err.kind()); 429 | } 430 | 431 | #[test] 432 | fn test_issue_12_case_1() { 433 | // Looks like it was test_trailing_2_nl that triggered this to fail originally 434 | let commit = Commit::parse("chore: add .hello.txt (#1)\n\n").unwrap(); 435 | 436 | assert_eq!(commit.type_(), "chore"); 437 | assert_eq!(commit.scope(), None); 438 | assert_eq!(commit.description(), "add .hello.txt (#1)"); 439 | } 440 | 441 | #[test] 442 | fn test_issue_12_case_2() { 443 | // Looks like it was test_trailing_2_nl that triggered this to fail originally 444 | let commit = Commit::parse("refactor: use fewer lines (#3)\n\n").unwrap(); 445 | 446 | assert_eq!(commit.type_(), "refactor"); 447 | assert_eq!(commit.scope(), None); 448 | assert_eq!(commit.description(), "use fewer lines (#3)"); 449 | } 450 | 451 | #[test] 452 | fn test_breaking_change() { 453 | let commit = Commit::parse("feat!: this is a breaking change").unwrap(); 454 | assert_eq!(Type::FEAT, commit.type_()); 455 | assert!(commit.breaking()); 456 | assert_eq!( 457 | commit.breaking_description(), 458 | Some("this is a breaking change") 459 | ); 460 | 461 | let commit = Commit::parse(indoc!( 462 | "feat: message 463 | 464 | BREAKING CHANGE: breaking change" 465 | )) 466 | .unwrap(); 467 | assert_eq!(Type::FEAT, commit.type_()); 468 | assert_eq!("breaking change", commit.footers().first().unwrap().value()); 469 | assert!(commit.breaking()); 470 | assert_eq!(commit.breaking_description(), Some("breaking change")); 471 | 472 | let commit = Commit::parse(indoc!( 473 | "fix: message 474 | 475 | BREAKING-CHANGE: it's broken" 476 | )) 477 | .unwrap(); 478 | assert_eq!(Type::FIX, commit.type_()); 479 | assert_eq!("it's broken", commit.footers().first().unwrap().value()); 480 | assert!(commit.breaking()); 481 | assert_eq!(commit.breaking_description(), Some("it's broken")); 482 | } 483 | 484 | #[test] 485 | fn test_conjoined_footer() { 486 | let commit = Commit::parse( 487 | "fix(example): fix keepachangelog config example 488 | 489 | Fixes: #123, #124, #125", 490 | ) 491 | .unwrap(); 492 | assert_eq!(Type::FIX, commit.type_()); 493 | assert_eq!(commit.body(), None); 494 | assert_eq!( 495 | commit.footers(), 496 | [Footer::new( 497 | FooterToken("Fixes".into()), 498 | FooterSeparator::Value, 499 | "#123, #124, #125" 500 | ),] 501 | ); 502 | } 503 | 504 | #[test] 505 | fn test_windows_line_endings() { 506 | let commit = 507 | Commit::parse("feat: thing\r\n\r\nbody\r\n\r\ncloses #1234\r\n\r\n\r\nBREAKING CHANGE: something broke\r\n\r\n") 508 | .unwrap(); 509 | assert_eq!(commit.body(), Some("body")); 510 | assert_eq!( 511 | commit.footers(), 512 | [ 513 | Footer::new(FooterToken("closes".into()), FooterSeparator::Ref, "1234"), 514 | Footer::new( 515 | FooterToken("BREAKING CHANGE".into()), 516 | FooterSeparator::Value, 517 | "something broke" 518 | ), 519 | ] 520 | ); 521 | assert_eq!(commit.breaking_description(), Some("something broke")); 522 | } 523 | 524 | #[test] 525 | fn test_extra_line_endings() { 526 | let commit = 527 | Commit::parse("feat: thing\n\n\n\n\nbody\n\n\n\n\ncloses #1234\n\n\n\n\n\nBREAKING CHANGE: something broke\n\n\n\n") 528 | .unwrap(); 529 | assert_eq!(commit.body(), Some("body")); 530 | assert_eq!( 531 | commit.footers(), 532 | [ 533 | Footer::new(FooterToken("closes".into()), FooterSeparator::Ref, "1234"), 534 | Footer::new( 535 | FooterToken("BREAKING CHANGE".into()), 536 | FooterSeparator::Value, 537 | "something broke" 538 | ), 539 | ] 540 | ); 541 | assert_eq!(commit.breaking_description(), Some("something broke")); 542 | } 543 | 544 | #[test] 545 | fn test_fake_footer() { 546 | let commit = indoc! {" 547 | fix: something something 548 | 549 | First line of the body 550 | IMPORTANT: Please see something else for details. 551 | Another line here. 552 | "}; 553 | 554 | let commit = Commit::parse(commit).unwrap(); 555 | 556 | assert_eq!(Type::FIX, commit.type_()); 557 | assert_eq!(None, commit.scope()); 558 | assert_eq!("something something", commit.description()); 559 | assert_eq!( 560 | Some(indoc!( 561 | " 562 | First line of the body 563 | IMPORTANT: Please see something else for details. 564 | Another line here." 565 | )), 566 | commit.body() 567 | ); 568 | let empty_footer: &[Footer<'_>] = &[]; 569 | assert_eq!(empty_footer, commit.footers()); 570 | } 571 | 572 | #[test] 573 | fn test_valid_complex_commit() { 574 | let commit = indoc! {" 575 | chore: improve changelog readability 576 | 577 | Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a tiny bit 578 | easier to parse while reading. 579 | 580 | BREAKING CHANGE: Just kidding! 581 | "}; 582 | 583 | let commit = Commit::parse(commit).unwrap(); 584 | 585 | assert_eq!(Type::CHORE, commit.type_()); 586 | assert_eq!(None, commit.scope()); 587 | assert_eq!("improve changelog readability", commit.description()); 588 | assert_eq!( 589 | Some(indoc!( 590 | "Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a tiny bit 591 | easier to parse while reading." 592 | )), 593 | commit.body() 594 | ); 595 | assert_eq!("Just kidding!", commit.footers().first().unwrap().value()); 596 | } 597 | 598 | #[test] 599 | fn test_missing_type() { 600 | let err = Commit::parse("").unwrap_err(); 601 | 602 | assert_eq!(ErrorKind::MissingType, err.kind()); 603 | } 604 | 605 | #[cfg(feature = "serde")] 606 | #[test] 607 | fn test_commit_serialize() { 608 | let commit = Commit::parse("type(my scope): hello world").unwrap(); 609 | serde_test::assert_ser_tokens( 610 | &commit, 611 | &[ 612 | Token::Struct { 613 | name: "Commit", 614 | len: 6, 615 | }, 616 | Token::Str("ty"), 617 | Token::Str("type"), 618 | Token::Str("scope"), 619 | Token::Some, 620 | Token::Str("my scope"), 621 | Token::Str("description"), 622 | Token::Str("hello world"), 623 | Token::Str("body"), 624 | Token::None, 625 | Token::Str("breaking"), 626 | Token::Bool(false), 627 | Token::Str("footers"), 628 | Token::Seq { len: Some(0) }, 629 | Token::SeqEnd, 630 | Token::StructEnd, 631 | ], 632 | ); 633 | } 634 | } 635 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //! All errors related to Conventional Commits. 2 | 3 | use std::fmt; 4 | 5 | /// The error returned when parsing a commit fails. 6 | pub struct Error { 7 | kind: ErrorKind, 8 | 9 | context: Option>, 10 | commit: Option, 11 | } 12 | 13 | impl Error { 14 | /// Create a new error from a `ErrorKind`. 15 | pub(crate) fn new(kind: ErrorKind) -> Self { 16 | Self { 17 | kind, 18 | context: None, 19 | commit: None, 20 | } 21 | } 22 | 23 | pub(crate) fn with_nom( 24 | commit: &str, 25 | err: winnow::error::ParseError<&str, winnow::error::ContextError>, 26 | ) -> Self { 27 | use winnow::error::StrContext; 28 | use ErrorKind::{ 29 | InvalidBody, InvalidFormat, InvalidScope, MissingDescription, MissingType, 30 | }; 31 | 32 | let mut kind = InvalidFormat; 33 | for context in err.inner().context() { 34 | kind = match context { 35 | StrContext::Label(string) => match *string { 36 | crate::parser::SUMMARY => MissingType, 37 | crate::parser::TYPE => MissingType, 38 | crate::parser::SCOPE => InvalidScope, 39 | crate::parser::DESCRIPTION => MissingDescription, 40 | crate::parser::BODY => InvalidBody, 41 | _ => kind, 42 | }, 43 | _ => kind, 44 | }; 45 | } 46 | 47 | Self { 48 | kind, 49 | context: None, 50 | commit: Some(commit.to_owned()), 51 | } 52 | } 53 | 54 | pub(crate) fn set_context(mut self, context: Box) -> Self { 55 | self.context = Some(context); 56 | self 57 | } 58 | 59 | /// The kind of error. 60 | pub fn kind(&self) -> ErrorKind { 61 | self.kind 62 | } 63 | } 64 | 65 | impl fmt::Debug for Error { 66 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 67 | f.debug_struct("Error") 68 | .field("kind", &self.kind) 69 | .field("context", &self.context.as_ref().map(|s| s.to_string())) 70 | .field("commit", &self.commit) 71 | .finish() 72 | } 73 | } 74 | 75 | impl fmt::Display for Error { 76 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 77 | if let Some(context) = self.context.as_ref() { 78 | write!(f, "{}: {}", self.kind, context) 79 | } else { 80 | write!(f, "{}", self.kind) 81 | } 82 | } 83 | } 84 | 85 | impl std::error::Error for Error { 86 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 87 | None 88 | } 89 | } 90 | 91 | /// All possible error kinds returned when parsing a conventional commit. 92 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 93 | #[non_exhaustive] 94 | pub enum ErrorKind { 95 | /// The commit type is missing from the commit message. 96 | MissingType, 97 | 98 | /// The scope has an invalid format. 99 | InvalidScope, 100 | 101 | /// The description of the commit is missing. 102 | MissingDescription, 103 | 104 | /// The body of the commit has an invalid format. 105 | InvalidBody, 106 | 107 | /// The footer of the commit has an invalid format. 108 | InvalidFooter, 109 | 110 | /// Any other part of the commit does not conform to the conventional commit 111 | /// spec. 112 | InvalidFormat, 113 | } 114 | 115 | impl fmt::Display for ErrorKind { 116 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 117 | let s = match self { 118 | ErrorKind::MissingType => { 119 | "Missing type in the commit summary, expected `type: description`" 120 | } 121 | ErrorKind::InvalidScope => { 122 | "Incorrect scope syntax in commit summary, expected `type(scope): description`" 123 | } 124 | ErrorKind::MissingDescription => { 125 | "Missing description in commit summary, expected `type: description`" 126 | } 127 | ErrorKind::InvalidBody => "Incorrect body syntax", 128 | ErrorKind::InvalidFooter => "Incorrect footer syntax", 129 | ErrorKind::InvalidFormat => "Incorrect conventional commit format", 130 | }; 131 | f.write_str(s) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A parser library for the [Conventional Commit] specification. 2 | //! 3 | //! [conventional commit]: https://www.conventionalcommits.org 4 | //! 5 | //! # Example 6 | //! 7 | //! ```rust 8 | //! use indoc::indoc; 9 | //! 10 | //! let message = indoc!(" 11 | //! docs(example)!: add tested usage example 12 | //! 13 | //! This example is tested using Rust's doctest capabilities. Having this 14 | //! example helps people understand how to use the parser. 15 | //! 16 | //! BREAKING CHANGE: Going from nothing to something, meaning anyone doing 17 | //! nothing before suddenly has something to do. That sounds like a change 18 | //! in your break. 19 | //! 20 | //! Co-Authored-By: Lisa Simpson 21 | //! Closes #12 22 | //! "); 23 | //! 24 | //! let commit = git_conventional::Commit::parse(message).unwrap(); 25 | //! 26 | //! // You can access all components of the subject. 27 | //! assert_eq!(commit.type_(), git_conventional::Type::DOCS); 28 | //! assert_eq!(commit.scope().unwrap(), "example"); 29 | //! assert_eq!(commit.description(), "add tested usage example"); 30 | //! 31 | //! // And the free-form commit body. 32 | //! assert!(commit.body().unwrap().contains("helps people understand")); 33 | //! 34 | //! // If a commit is marked with a bang (`!`) OR has a footer with the key 35 | //! // "BREAKING CHANGE", it is considered a "breaking" commit. 36 | //! assert!(commit.breaking()); 37 | //! 38 | //! // You can access each footer individually. 39 | //! assert!(commit.footers()[0].value().contains("That sounds like a change")); 40 | //! 41 | //! // Footers provide access to their token and value. 42 | //! assert_eq!(commit.footers()[1].token(), "Co-Authored-By"); 43 | //! assert_eq!(commit.footers()[1].value(), "Lisa Simpson "); 44 | //! 45 | //! // Two types of separators are supported, regular ": ", and " #": 46 | //! assert_eq!(commit.footers()[2].separator(), " #"); 47 | //! assert_eq!(commit.footers()[2].value(), "12"); 48 | //! ``` 49 | 50 | #![cfg_attr(docsrs, feature(doc_auto_cfg))] 51 | #![warn(missing_docs)] 52 | #![warn(clippy::print_stderr)] 53 | #![warn(clippy::print_stdout)] 54 | 55 | mod commit; 56 | mod error; 57 | mod lines; 58 | mod parser; 59 | 60 | pub use commit::{Commit, Footer, FooterSeparator, FooterToken, Scope, Type}; 61 | pub use error::{Error, ErrorKind}; 62 | 63 | #[doc = include_str!("../README.md")] 64 | #[cfg(doctest)] 65 | pub struct ReadmeDoctests; 66 | -------------------------------------------------------------------------------- /src/lines.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Debug)] 2 | pub(crate) struct LinesWithTerminator<'a> { 3 | data: &'a str, 4 | } 5 | 6 | impl<'a> LinesWithTerminator<'a> { 7 | pub(crate) fn new(data: &'a str) -> LinesWithTerminator<'a> { 8 | LinesWithTerminator { data } 9 | } 10 | } 11 | 12 | impl<'a> Iterator for LinesWithTerminator<'a> { 13 | type Item = &'a str; 14 | 15 | #[inline] 16 | fn next(&mut self) -> Option<&'a str> { 17 | match self.data.find('\n') { 18 | None if self.data.is_empty() => None, 19 | None => { 20 | let line = self.data; 21 | self.data = ""; 22 | Some(line) 23 | } 24 | Some(end) => { 25 | let line = &self.data[..end + 1]; 26 | self.data = &self.data[end + 1..]; 27 | Some(line) 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/parser.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::let_unit_value)] // for clarify and to ensure the right type is selected 2 | 3 | use std::str; 4 | 5 | use winnow::ascii::line_ending; 6 | use winnow::combinator::alt; 7 | use winnow::combinator::repeat; 8 | use winnow::combinator::trace; 9 | use winnow::combinator::{cut_err, eof, fail, opt, peek}; 10 | use winnow::combinator::{delimited, preceded, terminated}; 11 | use winnow::error::{AddContext, ErrMode, ParserError, StrContext}; 12 | use winnow::prelude::*; 13 | use winnow::token::{take, take_till, take_while}; 14 | 15 | type CommitDetails<'a> = ( 16 | &'a str, 17 | Option<&'a str>, 18 | bool, 19 | &'a str, 20 | Option<&'a str>, 21 | Vec<(&'a str, &'a str, &'a str)>, 22 | ); 23 | 24 | pub(crate) fn parse< 25 | 'a, 26 | E: ParserError<&'a str> + AddContext<&'a str, StrContext> + std::fmt::Debug, 27 | >( 28 | i: &mut &'a str, 29 | ) -> ModalResult, E> { 30 | message.parse_next(i) 31 | } 32 | 33 | // ::= "0x000D" 34 | // ::= "0x000A" 35 | // ::= [], 36 | fn is_line_ending(c: char) -> bool { 37 | c == '\n' || c == '\r' 38 | } 39 | 40 | // ::= "(" | ")" 41 | fn is_parens(c: char) -> bool { 42 | c == '(' || c == ')' 43 | } 44 | 45 | // ::= "U+FEFF" 46 | // ::= "U+0009" 47 | // ::= "U+000B" 48 | // ::= "U+000C" 49 | // ::= "U+0020" 50 | // ::= "U+00A0" 51 | // /* See: https://www.ecma-international.org/ecma-262/11.0/index.html#sec-white-space */ 52 | // ::= "Any other Unicode 'Space_Separator' code point" 53 | // /* Any non-newline whitespace: */ 54 | // ::= | | | | | | 55 | fn is_whitespace(c: char) -> bool { 56 | c.is_whitespace() 57 | } 58 | 59 | fn whitespace<'a, E: ParserError<&'a str> + AddContext<&'a str, StrContext> + std::fmt::Debug>( 60 | i: &mut &'a str, 61 | ) -> ModalResult<&'a str, E> { 62 | take_while(0.., is_whitespace).parse_next(i) 63 | } 64 | 65 | // ::= , +, , (+,