├── .github ├── dependabot.yml ├── release.yml └── workflows │ ├── cd.yml │ ├── ci-action.yml │ ├── ci-pkg.yml │ ├── ci.yml │ ├── taplo.yml │ └── typos.yml ├── .gitignore ├── .node-version ├── .pre-commit-hooks.yaml ├── .ruby-version ├── .taplo.toml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── HomebrewFormula ├── LICENSE ├── README.md ├── action.yml ├── action └── entrypoint.sh ├── benches ├── hard_tab_search.rs ├── node_traverse.rs ├── output.rs └── trailing_space.rs ├── flake.lock ├── flake.nix ├── fuzz ├── .gitignore ├── Cargo.lock ├── Cargo.toml └── fuzz_targets │ └── linter.rs ├── justfile ├── mado.toml ├── pkg ├── homebrew │ └── mado.rb ├── json-schema │ └── mado.json ├── scoop │ └── mado.json └── winget │ └── mado.yml ├── rust-toolchain.toml ├── scripts ├── acceptance │ ├── .mdlrc │ ├── data │ │ └── .gitignore │ ├── setup.sh │ └── test.sh └── benchmarks │ ├── .gitignore │ ├── .markdownlint.jsonc │ ├── .mdlrc │ ├── comparison.sh │ ├── data │ └── .gitignore │ ├── mado.toml │ ├── package-lock.json │ ├── package.json │ └── setup.sh ├── src ├── cli.rs ├── collection.rs ├── command.rs ├── command │ ├── check.rs │ └── generate_shell_completion.rs ├── config.rs ├── config │ ├── lint.rs │ └── lint │ │ ├── md002.rs │ │ ├── md003.rs │ │ ├── md004.rs │ │ ├── md007.rs │ │ ├── md013.rs │ │ ├── md024.rs │ │ ├── md025.rs │ │ ├── md026.rs │ │ ├── md029.rs │ │ ├── md030.rs │ │ ├── md033.rs │ │ ├── md035.rs │ │ ├── md036.rs │ │ ├── md041.rs │ │ └── md046.rs ├── document.rs ├── lib.rs ├── main.rs ├── output.rs ├── output │ ├── concise.rs │ ├── markdownlint.rs │ └── mdl.rs ├── rule.rs ├── rule │ ├── helper.rs │ ├── md001.rs │ ├── md002.rs │ ├── md003.rs │ ├── md004.rs │ ├── md005.rs │ ├── md006.rs │ ├── md007.rs │ ├── md009.rs │ ├── md010.rs │ ├── md012.rs │ ├── md013.rs │ ├── md014.rs │ ├── md018.rs │ ├── md019.rs │ ├── md020.rs │ ├── md021.rs │ ├── md022.rs │ ├── md023.rs │ ├── md024.rs │ ├── md025.rs │ ├── md026.rs │ ├── md027.rs │ ├── md028.rs │ ├── md029.rs │ ├── md030.rs │ ├── md031.rs │ ├── md032.rs │ ├── md033.rs │ ├── md034.rs │ ├── md035.rs │ ├── md036.rs │ ├── md037.rs │ ├── md038.rs │ ├── md039.rs │ ├── md040.rs │ ├── md041.rs │ ├── md046.rs │ ├── md047.rs │ ├── metadata.rs │ └── tag.rs ├── service.rs ├── service │ ├── linter.rs │ ├── runner.rs │ ├── visitor.rs │ └── walker.rs └── violation.rs ├── tests ├── command_check.rs ├── command_generate_shell_completion.rs └── command_invalid.rs └── typos.toml /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: ⚠️ BREAKING CHANGES 4 | labels: 5 | - "breaking change" 6 | - title: Features 7 | labels: 8 | - enhancement 9 | - title: Bug Fixes 10 | labels: 11 | - bug 12 | - title: Other Changes 13 | labels: 14 | - "*" 15 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD # Continuous Deployment 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[v]?[0-9]+.[0-9]+.[0-9]+' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | name: Release - ${{ matrix.platform.os-name }} 14 | runs-on: ${{ matrix.platform.runs-on }} 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | platform: 20 | - os-name: Linux-x86_64 21 | runs-on: ubuntu-latest 22 | target: x86_64-unknown-linux-gnu 23 | - os-name: Linux-aarch64 24 | runs-on: ubuntu-latest 25 | target: aarch64-unknown-linux-gnu 26 | - os-name: macOS-x86_64 27 | runs-on: macOS-latest 28 | target: x86_64-apple-darwin 29 | - os-name: macOS-aarch64 30 | runs-on: macOS-latest 31 | target: aarch64-apple-darwin 32 | - os-name: Windows-x86_64 33 | runs-on: windows-latest 34 | target: x86_64-pc-windows-msvc 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v4 39 | - name: Build binary 40 | uses: houseabsolute/actions-rust-cross@v0 41 | with: 42 | command: build 43 | target: ${{ matrix.platform.target }} 44 | args: "--locked --release" 45 | strip: true 46 | - name: Publish artifacts and release 47 | uses: houseabsolute/actions-rust-release@v0 48 | with: 49 | executable-name: mado 50 | target: ${{ matrix.platform.target }} 51 | changes-file: CHANGELOG.md 52 | -------------------------------------------------------------------------------- /.github/workflows/ci-action.yml: -------------------------------------------------------------------------------- 1 | name: CI Action # Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | 11 | test: 12 | name: Test Action 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | - name: Use local action 18 | uses: ./ 19 | -------------------------------------------------------------------------------- /.github/workflows/ci-pkg.yml: -------------------------------------------------------------------------------- 1 | name: CI Package # Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | 11 | homebrew: 12 | name: Test Homebrew 13 | runs-on: macos-latest 14 | steps: 15 | - name: Set up Homebrew 16 | uses: Homebrew/actions/setup-homebrew@master 17 | with: 18 | test-bot: true 19 | stable: true 20 | env: 21 | HOMEBREW_TAP_REPOSITORY: /opt/homebrew/Library/Taps/akiomik/mado 22 | - name: Rename akiomik/mado to akiomik/homebrew-mado 23 | run: mv /opt/homebrew/Library/Taps/akiomik/mado /opt/homebrew/Library/Taps/akiomik/homebrew-mado 24 | - name: Checkout repository for updating HEAD 25 | uses: actions/checkout@v4 26 | - name: Show the latest commit 27 | run: git log -1 28 | - name: Run Homebrew info 29 | run: brew info mado 30 | - name: Run Homebrew Test Bot 31 | run: brew test-bot --only-formulae --skip-dependents --skip-online-checks mado 32 | - name: Revert renaming 33 | run: mv /opt/homebrew/Library/Taps/akiomik/homebrew-mado /opt/homebrew/Library/Taps/akiomik/mado 34 | 35 | scoop: 36 | name: Test Scoop 37 | runs-on: windows-latest 38 | steps: 39 | - name: Checkout repository for updating HEAD 40 | uses: actions/checkout@v4 41 | - name: Setup Scoop 42 | uses: MinoruSekine/setup-scoop@v4.0.1 43 | - name: Install Mado via Scoop 44 | run: scoop install pkg\scoop\mado.json 45 | 46 | winget: 47 | name: Test WinGet 48 | runs-on: windows-latest 49 | steps: 50 | - name: Checkout repository 51 | uses: actions/checkout@v4 52 | # NOTE: Cyberboss/install-winget@1 may reach rate limit and not install winget, 53 | # so use scoop instead 54 | - name: Setup Scoop 55 | uses: MinoruSekine/setup-scoop@v4.0.1 56 | - name: Install WinGet 57 | run: scoop install winget 58 | - name: Run winget validate 59 | run: winget validate pkg\winget\mado.yml 60 | - name: Enable LocalManifestFiles for WinGet 61 | run: winget settings --enable LocalManifestFiles 62 | - name: Install Mado via WinGet 63 | run: winget install -m pkg\winget\mado.yml 64 | 65 | nix: 66 | name: Test Nix 67 | runs-on: ubuntu-latest 68 | steps: 69 | - uses: actions/checkout@v4 70 | - uses: cachix/install-nix-action@v27 71 | - run: nix build 72 | - run: nix flake check 73 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI # Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | 11 | test: 12 | name: Test Suite 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | - name: Install Rust toolchain 18 | uses: dtolnay/rust-toolchain@1.84.0 19 | - uses: Swatinem/rust-cache@v2 20 | - name: Run tests 21 | run: cargo test --all-features --workspace 22 | env: 23 | CLICOLOR_FORCE: true 24 | TZ: 'Asia/Tokyo' 25 | 26 | rustfmt: 27 | name: Rustfmt 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v4 32 | - name: Install Rust toolchain 33 | uses: dtolnay/rust-toolchain@1.84.0 34 | with: 35 | components: rustfmt 36 | - uses: Swatinem/rust-cache@v2 37 | - name: Check formatting 38 | run: cargo fmt --all --check 39 | 40 | clippy: 41 | name: Clippy 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Checkout repository 45 | uses: actions/checkout@v4 46 | - name: Install Rust toolchain 47 | uses: dtolnay/rust-toolchain@1.84.0 48 | with: 49 | components: clippy 50 | - uses: Swatinem/rust-cache@v2 51 | - name: Clippy check 52 | run: cargo clippy --all-targets --all-features --workspace -- -D warnings 53 | 54 | docs: 55 | name: Docs 56 | runs-on: ubuntu-latest 57 | steps: 58 | - name: Checkout repository 59 | uses: actions/checkout@v4 60 | - name: Install Rust toolchain 61 | uses: dtolnay/rust-toolchain@1.84.0 62 | - uses: Swatinem/rust-cache@v2 63 | - name: Check documentation 64 | env: 65 | RUSTDOCFLAGS: -D warnings 66 | run: cargo doc --no-deps --document-private-items --all-features --workspace 67 | 68 | coverage: 69 | name: Code Coverage 70 | runs-on: ubuntu-latest 71 | env: 72 | CARGO_TERM_COLOR: always 73 | steps: 74 | - name: Checkout repository 75 | uses: actions/checkout@v4 76 | - name: Install Rust toolchain 77 | uses: dtolnay/rust-toolchain@1.84.0 78 | - name: Install cargo-llvm-cov 79 | uses: taiki-e/install-action@cargo-llvm-cov 80 | - uses: Swatinem/rust-cache@v2 81 | - name: Generate code coverage 82 | run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info 83 | env: 84 | CLICOLOR_FORCE: true 85 | TZ: 'Asia/Tokyo' 86 | - name: Upload coverage to Codecov 87 | uses: codecov/codecov-action@v5 88 | with: 89 | token: ${{ secrets.CODECOV_TOKEN }} 90 | files: lcov.info 91 | fail_ci_if_error: true 92 | -------------------------------------------------------------------------------- /.github/workflows/taplo.yml: -------------------------------------------------------------------------------- 1 | name: Taplo 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | 11 | taplo: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: uncenter/setup-taplo@v1 16 | with: 17 | version: "0.9.3" 18 | - name: Run Taplo 19 | run: taplo format --check 20 | -------------------------------------------------------------------------------- /.github/workflows/typos.yml: -------------------------------------------------------------------------------- 1 | name: Typos 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | 11 | typos: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: crate-ci/typos@v1.28.4 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | flamegraph.svg 3 | /tmp 4 | /result 5 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: mado 2 | name: mado 3 | description: A fast Markdown linter written in Rust. 4 | entry: mado check 5 | language: rust 6 | types: [markdown] 7 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3 2 | -------------------------------------------------------------------------------- /.taplo.toml: -------------------------------------------------------------------------------- 1 | exclude = [".git/**/*"] 2 | 3 | [[rule]] 4 | include = ["**/mado.toml"] 5 | 6 | [rule.schema] 7 | path = "./pkg/json-schema/mado.json" 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.3.0 (2025-04-12) 4 | 5 | ### ⚠️ BREAKING CHANGES 6 | 7 | * build(deps): bump comrak from 0.36.0 to 0.38.0 by @dependabot in #148 8 | 9 | ### Features 10 | 11 | * feat: tags by @akiomik in #92 12 | * feat: shell completion by @akiomik in #125 13 | 14 | ### Other Changes 15 | 16 | * chore: bump packages to v0.2.2 by @akiomik in #121 17 | * chore: update README.md by @akiomik in #122 18 | * refactor: add enum Tag by @akiomik in #123 19 | * chore: enable clippy::integer_division_remainder_used by @akiomik in #124 20 | * build(deps): bump tempfile from 3.16.0 to 3.17.0 by @dependabot in #127 21 | * build(deps): bump clap from 4.5.28 to 4.5.29 by @dependabot in #126 22 | * build(deps): bump serde from 1.0.217 to 1.0.219 by @dependabot in #135 23 | * build(deps): bump etcetera from 0.8.0 to 0.10.0 by @dependabot in #134 24 | * build(deps): bump tempfile from 3.17.0 to 3.18.0 by @dependabot in #133 25 | * build(deps): bump clap from 4.5.29 to 4.5.31 by @dependabot in #128 26 | * build(deps): bump globset from 0.4.15 to 0.4.16 by @dependabot in #131 27 | * build(deps): bump comrak from 0.35.0 to 0.36.0 by @dependabot in #139 28 | * build(deps): bump scraper from 0.22.0 to 0.23.1 by @dependabot in #138 29 | * build(deps): bump indoc from 2.0.5 to 2.0.6 by @dependabot in #137 30 | * build(deps): bump tempfile from 3.18.0 to 3.19.0 by @dependabot in #136 31 | * build(deps): bump mimalloc from 0.1.43 to 0.1.45 by @dependabot in #147 32 | * build(deps): bump clap from 4.5.32 to 4.5.35 by @dependabot in #146 33 | * build(deps): bump tempfile from 3.19.0 to 3.19.1 by @dependabot in #145 34 | * refactor: make subcommand required by @akiomik in #149 35 | * build(deps): bump comrak from 0.36.0 to 0.38.0 by @dependabot in #148 36 | 37 | ## 0.2.2 (2025-02-16) 38 | 39 | ### Features 40 | 41 | * feat: add sublist to style in [lint.md004] by @akiomik in #107 42 | * feat: add respect-gitignore to [lint] by @akiomik in #109 43 | * Feat exclude by @akiomik in #115 44 | 45 | ### Other Changes 46 | 47 | * chore: enable MD024 in mado.toml by @akiomik in #106 48 | * chore: fix `just cov` by @akiomik in #108 49 | * build(deps): bump clap from 4.5.27 to 4.5.28 by @dependabot in #112 50 | * build(deps): bump rustc-hash from 2.1.0 to 2.1.1 by @dependabot in #111 51 | * build(deps): bump toml from 0.8.19 to 0.8.20 by @dependabot in #110 52 | * perf: use ignore::Types by @akiomik in #113 53 | * refactor: add with_tmp_file helper by @akiomik in #114 54 | * test: use pretty_assertions by @akiomik in #116 55 | * test: disable allow-unwrap-in-tests by @akiomik in #117 56 | * Test indoc by @akiomik in #118 57 | * test: refactor for WalkParallelBuilder by @akiomik in #119 58 | 59 | ## 0.2.1 (2025-02-04) 60 | 61 | ### Features 62 | 63 | * feat: add allow-different-nesting to [lint.md024] by @akiomik in #104 64 | 65 | ### Other Changes 66 | 67 | * chore: bump packages to v0.2.0 by @akiomik in #100 68 | * use rustc-hash by @akiomik in #101 69 | * build(deps): bump miette from 7.4.0 to 7.5.0 by @dependabot in #102 70 | * build(deps): bump tempfile from 3.15.0 to 3.16.0 by @dependabot in #103 71 | 72 | ## 0.2.0 (2025-01-30) 73 | 74 | ### ⚠️ BREAKING CHANGES 75 | 76 | * fix!: rename config keys in [lint.md030] by @akiomik in #86 77 | * feat!: change style format for [lint.md035] by @akiomik in #91 78 | 79 | ### Features 80 | 81 | * feat: add stdin support to check by @akiomik in #89 82 | * feat: json schema support by @akiomik in #88 83 | 84 | ### Bug Fixes 85 | 86 | * fix: check command with empty stdin by @akiomik in #96 87 | 88 | ### Other Changes 89 | 90 | * chore: update packages to 0.1.5 by @akiomik in #85 91 | * chore: add update-winget to justfile by @akiomik in #84 92 | * chore: add breaking change to .github/release.yml by @akiomik in #87 93 | * Taplo ci by @akiomik in #90 94 | * build(deps): bump clap from 4.5.26 to 4.5.27 by @dependabot in #94 95 | * build(deps): bump comrak from 0.33.0 to 0.35.0 by @dependabot in #95 96 | * build(deps): bump rand from 0.8.5 to 0.9.0 by @dependabot in #93 97 | 98 | ## 0.1.5 (2025-01-22) 99 | 100 | ### Features 101 | 102 | * Winget by @akiomik in #74 103 | * feat: add --quiet flag by @hougesen in #78 104 | * feat: add Serialize for Config by @akiomik in #81 105 | 106 | ### Bug Fixes 107 | 108 | * fix: respect config with --quiet by @akiomik in #80 109 | 110 | ### Other Changes 111 | 112 | * Run justfile --fmt by @akiomik in #68 113 | * Update packages to v0.1.4 by @akiomik in #67 114 | * Remove .cargo/config.toml by @akiomik in #69 115 | * Use rust 1.84 by @akiomik in #70 116 | * Nursery by @akiomik in #71 117 | * Update README.md by @akiomik in #72 118 | * Fix use_self by @akiomik in #73 119 | * Add test for MarkdownLintVisitorFactory#build by @akiomik in #75 120 | * Add test for ParallelLintRunner#run by @akiomik in #76 121 | * ci: update .github/release.yml by @akiomik in #79 122 | 123 | ## 0.1.4 (2025-01-17) 124 | 125 | * Minor improvements (#41, #42, #45, #46, #49) 126 | * Bump colored from 2.2.0 to 3.0.0 (#43) 127 | * Bump clap from 4.5.23 to 4.5.26 (#44) 128 | * Add fuzz testing (#47) 129 | * Update README.md (#48, #50) 130 | * Add Homebrew support (#51, #52, #54, #55, #56, #57, #62) 131 | * Add Scoop support (#53, #58) 132 | * Add justfile (#59) 133 | * Add Nix support (#60, #61) 134 | * Add `.github/release.yml` (#63, #65) 135 | 136 | ## 0.1.3 (2025-01-13) 137 | 138 | * Update project `mado.toml` (#13) 139 | * Minor improvements (#19, #20, #23, #26, #29, #31, #32, #35, #39) 140 | * Add tests (#21, #22, #33, #34, #36, #37, #38) 141 | * Bump comrak from 0.32.0 to 0.33.0 (#24) 142 | * Fix benchmark results (#25) 143 | * Improve CI (#27, #30) 144 | * Update README.md (#28) 145 | 146 | ## 0.1.2 (2025-01-05) 147 | 148 | * Update `README.md` (#12, #17) 149 | * Fix MD013 (#14) 150 | * Fix `Cargo.toml` (#15) 151 | * Add `Document#lines` (#16) 152 | 153 | ## 0.1.1 (2025-01-05) 154 | 155 | * Add github action support (#7, #8) 156 | * Add `code_blocks` and `tables` options to MD013 (#9) 157 | * Fix global configuration loading (#10) 158 | 159 | ## 0.1.0 (2024-12-31) 160 | 161 | * Initial release! 162 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mado" 3 | description = "A fast Markdown linter." 4 | version = "0.3.0" 5 | edition = "2021" 6 | repository = "https://github.com/akiomik/mado" 7 | license = "Apache-2.0" 8 | authors = ["Akiomi Kamakura "] 9 | keywords = ["markdown", "lint"] 10 | categories = ["command-line-utilities", "development-tools"] 11 | readme = "README.md" 12 | exclude = [ 13 | "/action", 14 | "/scripts", 15 | "/tmp", 16 | ".node-version", 17 | ".ruby-version", 18 | "action.yml", 19 | "flamegraph.svg", 20 | ] 21 | 22 | [dependencies] 23 | clap = { version = "4.5.38", features = ["derive"] } 24 | clap_complete = "4.5.50" 25 | colored = "3.0.0" 26 | comrak = "0.39.0" 27 | etcetera = "0.10.0" 28 | globset = { version = "0.4.16", features = ["serde1"] } 29 | ignore = "0.4.23" 30 | linkify = "0.10.0" 31 | miette = { version = "7.6.0", features = ["fancy"] } 32 | regex = "1.11.1" 33 | rustc-hash = "2.1.1" 34 | scraper = "0.23.1" 35 | serde = { version = "1.0", features = ["derive"] } 36 | toml = "0.8.22" 37 | 38 | [target.'cfg(target_os = "windows")'.dependencies] 39 | mimalloc = "0.1.46" 40 | 41 | [target.'cfg(all(not(target_os = "windows"), not(target_os = "openbsd"), not(target_os = "aix"), any(target_arch = "x86_64", target_arch = "aarch64", target_arch = "powerpc64")))'.dependencies] 42 | tikv-jemallocator = "0.6.0" 43 | 44 | [dev-dependencies] 45 | assert_cmd = "2.0" 46 | criterion = "0.5" 47 | indoc = "2" 48 | pretty_assertions = "1" 49 | rand = "0.9" 50 | tempfile = "3.20" 51 | 52 | [profile.release] 53 | lto = "thin" 54 | 55 | [profile.bench] 56 | debug = true 57 | 58 | [lints.clippy] 59 | cargo = { level = "warn", priority = -1 } 60 | complexity = { level = "warn", priority = -1 } 61 | correctness = { level = "warn", priority = -1 } 62 | perf = { level = "warn", priority = -1 } 63 | suspicious = { level = "warn", priority = -1 } 64 | pedantic = { level = "warn", priority = -1 } 65 | nursery = { level = "warn", priority = -1 } 66 | # pedantic 67 | module_name_repetitions = "allow" 68 | # restrictions 69 | absolute_paths = "warn" 70 | blanket_clippy_restriction_lints = "warn" 71 | clone_on_ref_ptr = "warn" 72 | empty_structs_with_brackets = "warn" 73 | exhaustive_enums = "warn" 74 | exhaustive_structs = "warn" 75 | expect_used = "warn" 76 | integer_division_remainder_used = "warn" 77 | missing_inline_in_public_items = "warn" 78 | shadow_unrelated = "warn" 79 | std_instead_of_alloc = "warn" 80 | std_instead_of_core = "warn" 81 | str_to_string = "warn" 82 | unwrap_in_result = "warn" 83 | unused_trait_names = "warn" 84 | unwrap_used = "warn" 85 | # TODO: Enable following rules 86 | allow_attributes_without_reason = "allow" 87 | arithmetic_side_effects = "allow" 88 | missing_errors_doc = "allow" 89 | missing_panics_doc = "allow" 90 | missing_docs_in_private_items = "allow" 91 | multiple_crate_versions = "allow" 92 | 93 | [[bench]] 94 | name = "trailing_space" 95 | harness = false 96 | 97 | [[bench]] 98 | name = "node_traverse" 99 | harness = false 100 | 101 | [[bench]] 102 | name = "output" 103 | harness = false 104 | 105 | [[bench]] 106 | name = "hard_tab_search" 107 | harness = false 108 | -------------------------------------------------------------------------------- /HomebrewFormula: -------------------------------------------------------------------------------- 1 | pkg/homebrew -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "mado-action" 2 | description: "Run mado to check Markdown files" 3 | author: "Akiomi Kamakura" 4 | branding: 5 | icon: check 6 | color: white 7 | 8 | inputs: 9 | args: 10 | description: "Arguments passed to Mado. Defaults to `check .`." 11 | required: false 12 | default: "check ." 13 | 14 | runs: 15 | using: 'composite' 16 | steps: 17 | - id: mado 18 | shell: bash 19 | run: $GITHUB_ACTION_PATH/action/entrypoint.sh 20 | env: 21 | INSTALL_DIR: . 22 | INPUT_ARGS: ${{ inputs.args }} 23 | -------------------------------------------------------------------------------- /action/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | COMMAND="mado" 4 | VERSION="v0.3.0" 5 | INSTALL_DIR="$HOME/bin" 6 | COMMAND_PATH="$INSTALL_DIR/$COMMAND" 7 | 8 | if [[ ! -x "$COMMAND" ]]; then 9 | ARCH=$(uname -m) 10 | UNAME=$(uname -s) 11 | if [[ "$UNAME" == "Darwin" ]]; then 12 | DOWNLOAD_FILE="mado-macOS-$ARCH.tar.gz" 13 | elif [[ "$UNAME" == CYGWIN* || "$UNAME" == MINGW* || "$UNAME" == MSYS* ]]; then 14 | DOWNLOAD_FILE="mado-Windows-msvc-$ARCH.zip" 15 | else 16 | DOWNLOAD_FILE="mado-Linux-gnu-$ARCH.tar.gz" 17 | fi 18 | 19 | echo "Downloading '$COMMAND' $VERSION" 20 | wget --progress=dot:mega "https://github.com/akiomik/mado/releases/download/$VERSION/$DOWNLOAD_FILE" 21 | 22 | mkdir -p $INSTALL_DIR 23 | if [[ "$UNAME" == CYGWIN* || "$UNAME" == MINGW* || "$UNAME" == MSYS* ]] ; then 24 | unzip -o $DOWNLOAD_FILE -d $INSTALL_DIR "$COMMAND.exe" 25 | else 26 | tar -xvf $DOWNLOAD_FILE -C $INSTALL_DIR $COMMAND 27 | fi 28 | 29 | rm $DOWNLOAD_FILE 30 | fi 31 | 32 | echo "Run '$COMMAND_PATH $INPUT_ARGS'" 33 | $COMMAND_PATH $INPUT_ARGS 34 | -------------------------------------------------------------------------------- /benches/hard_tab_search.rs: -------------------------------------------------------------------------------- 1 | use core::hint::black_box; 2 | use std::sync::LazyLock; 3 | 4 | use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; 5 | use rand::distr::SampleString as _; 6 | use rand::distr::StandardUniform; 7 | use regex::Regex; 8 | 9 | static RE: LazyLock = LazyLock::new(|| { 10 | #[allow(clippy::unwrap_used)] 11 | #[allow(clippy::trivial_regex)] 12 | Regex::new("\t").unwrap() 13 | }); 14 | 15 | fn regex_captures(s: &str) -> Option<(usize, usize)> { 16 | let mut locs = RE.capture_locations(); 17 | RE.captures_read(&mut locs, s); 18 | if let Some((start_column, end_column)) = locs.get(0) { 19 | return Some((start_column + 1, end_column)); 20 | } 21 | 22 | None 23 | } 24 | 25 | fn regex_find(s: &str) -> Option<(usize, usize)> { 26 | if let Some(m) = RE.find(s) { 27 | return Some((m.start() + 1, m.end())); 28 | } 29 | 30 | None 31 | } 32 | 33 | fn string_find(s: &str) -> Option<(usize, usize)> { 34 | if let Some(index) = s.find('\t') { 35 | let column = index + 1; 36 | return Some((column, column)); 37 | } 38 | 39 | None 40 | } 41 | 42 | fn string_match_indices(s: &str) -> Option<(usize, usize)> { 43 | if let Some((index, _)) = s.match_indices('\t').next() { 44 | let column = index + 1; 45 | return Some((column, column)); 46 | } 47 | 48 | None 49 | } 50 | 51 | fn chars_position(s: &str) -> Option<(usize, usize)> { 52 | if let Some(index) = s.chars().position(|c| c == '\t') { 53 | let column = index + 1; 54 | return Some((column, column)); 55 | } 56 | 57 | None 58 | } 59 | 60 | fn criterion_benchmark(c: &mut Criterion) { 61 | let mut group = c.benchmark_group("hard_tab_search"); 62 | 63 | for i in 1..=3 { 64 | let string = StandardUniform.sample_string(&mut rand::rng(), (i as usize).pow(i) * 100); 65 | 66 | group.bench_with_input(BenchmarkId::new("regex_captures", i), &string, |b, s| { 67 | b.iter(|| regex_captures(black_box(s))); 68 | }); 69 | 70 | group.bench_with_input(BenchmarkId::new("regex_find", i), &string, |b, s| { 71 | b.iter(|| regex_find(black_box(s))); 72 | }); 73 | 74 | group.bench_with_input(BenchmarkId::new("string_find", i), &string, |b, s| { 75 | b.iter(|| string_find(black_box(s))); 76 | }); 77 | 78 | group.bench_with_input( 79 | BenchmarkId::new("string_match_indices", i), 80 | &string, 81 | |b, s| { 82 | b.iter(|| string_match_indices(black_box(s))); 83 | }, 84 | ); 85 | 86 | group.bench_with_input(BenchmarkId::new("chars_position", i), &string, |b, s| { 87 | b.iter(|| chars_position(black_box(s))); 88 | }); 89 | } 90 | group.finish(); 91 | } 92 | 93 | criterion_group!(benches, criterion_benchmark); 94 | criterion_main!(benches); 95 | -------------------------------------------------------------------------------- /benches/node_traverse.rs: -------------------------------------------------------------------------------- 1 | use core::hint::black_box; 2 | 3 | use comrak::nodes::AstNode; 4 | use comrak::nodes::NodeValue; 5 | use comrak::parse_document; 6 | use comrak::Arena; 7 | use comrak::Options; 8 | use criterion::{criterion_group, criterion_main, Criterion}; 9 | 10 | fn recursive<'a>(root: &'a AstNode<'a>, texts: &mut Vec) { 11 | for node in root.children() { 12 | if let NodeValue::Text(text) = &node.data.borrow().value { 13 | texts.push(text.clone()); 14 | } 15 | 16 | recursive(node, texts); 17 | } 18 | } 19 | 20 | fn descendants<'a>(root: &'a AstNode<'a>) -> Vec { 21 | let mut texts = vec![]; 22 | 23 | for node in root.descendants() { 24 | if let NodeValue::Text(text) = &node.data.borrow().value { 25 | texts.push(text.clone()); 26 | } 27 | } 28 | 29 | texts 30 | } 31 | 32 | fn criterion_benchmark(c: &mut Criterion) { 33 | let mut group = c.benchmark_group("node_traverse"); 34 | 35 | let markdown = include_str!("../README.md"); 36 | let arena = Arena::new(); 37 | let options = Options::default(); 38 | let doc = parse_document(&arena, markdown, &options); 39 | 40 | group.bench_function("recursive", |b| { 41 | b.iter(|| recursive(black_box(doc), black_box(&mut vec![]))); 42 | }); 43 | 44 | group.bench_function("descendants", |b| { 45 | b.iter(|| descendants(black_box(doc))); 46 | }); 47 | 48 | group.finish(); 49 | } 50 | 51 | criterion_group!(benches, criterion_benchmark); 52 | criterion_main!(benches); 53 | -------------------------------------------------------------------------------- /benches/output.rs: -------------------------------------------------------------------------------- 1 | use core::hint::black_box; 2 | use std::io::BufWriter; 3 | use std::io::Write as _; 4 | 5 | use criterion::{criterion_group, criterion_main, Criterion}; 6 | use rand::distr::SampleString as _; 7 | use rand::distr::StandardUniform; 8 | 9 | // TODO: Test `println!` and use actually stdout 10 | fn string_push_str_with_format(ss: &[String]) { 11 | #[allow(clippy::collection_is_never_read)] 12 | let mut buf = String::new(); 13 | 14 | for s in ss { 15 | buf.push_str(&format!("{s}\n")); 16 | } 17 | } 18 | 19 | fn string_push_str_without_format(ss: &[String]) { 20 | #[allow(clippy::collection_is_never_read)] 21 | let mut buf = String::new(); 22 | 23 | for s in ss { 24 | buf.push_str(s); 25 | buf.push('\n'); 26 | } 27 | } 28 | 29 | #[allow(clippy::unwrap_used)] 30 | fn buf_writer_writeln(ss: &[String]) { 31 | let buf = vec![]; 32 | let mut output = BufWriter::new(buf); 33 | 34 | for s in ss { 35 | writeln!(output, "{s}").unwrap(); 36 | } 37 | } 38 | 39 | #[allow(clippy::unwrap_used)] 40 | #[allow(clippy::write_with_newline)] 41 | fn buf_writer_write(ss: &[String]) { 42 | let buf = vec![]; 43 | let mut output = BufWriter::new(buf); 44 | 45 | for s in ss { 46 | write!(output, "{s}\n").unwrap(); 47 | } 48 | } 49 | 50 | #[allow(clippy::unwrap_used)] 51 | #[allow(clippy::unused_io_amount)] 52 | fn buf_writer_write_method_with_format(ss: &[String]) { 53 | let buf = vec![]; 54 | let mut output = BufWriter::new(buf); 55 | 56 | for s in ss { 57 | output.write(format!("{s}\n").as_bytes()).unwrap(); 58 | } 59 | } 60 | 61 | #[allow(clippy::unwrap_used)] 62 | #[allow(clippy::unused_io_amount)] 63 | fn buf_writer_write_method_without_format(ss: &[String]) { 64 | let buf = vec![]; 65 | let mut output = BufWriter::new(buf); 66 | 67 | for s in ss { 68 | output.write(s.as_bytes()).unwrap(); 69 | output.write(b"\n").unwrap(); 70 | } 71 | } 72 | 73 | #[allow(clippy::unwrap_used)] 74 | fn buf_writer_write_all_push_str(ss: &[String]) { 75 | let buf = vec![]; 76 | let mut output = BufWriter::new(buf); 77 | 78 | for chunk in ss.chunks(100) { 79 | let mut chunk_buf = String::new(); 80 | 81 | for s in chunk { 82 | chunk_buf.push_str(&format!("{s}\n")); 83 | } 84 | 85 | output.write_all(chunk_buf.as_bytes()).unwrap(); 86 | } 87 | } 88 | 89 | #[allow(clippy::unwrap_used)] 90 | fn buf_writer_write_all_append_with_format(ss: &[String]) { 91 | let buf = vec![]; 92 | let mut output = BufWriter::new(buf); 93 | 94 | for chunk in ss.chunks(100) { 95 | let mut chunk_buf = String::new(); 96 | 97 | for s in chunk { 98 | chunk_buf += &format!("{s}\n"); 99 | } 100 | 101 | output.write_all(chunk_buf.as_bytes()).unwrap(); 102 | } 103 | } 104 | 105 | #[allow(clippy::unwrap_used)] 106 | fn buf_writer_write_all_append_without_format(ss: &[String]) { 107 | let buf = vec![]; 108 | let mut output = BufWriter::new(buf); 109 | 110 | for chunk in ss.chunks(100) { 111 | let mut chunk_buf = String::new(); 112 | 113 | for s in chunk { 114 | chunk_buf += s; 115 | chunk_buf += "\n"; 116 | } 117 | 118 | output.write_all(chunk_buf.as_bytes()).unwrap(); 119 | } 120 | } 121 | 122 | fn criterion_benchmark(c: &mut Criterion) { 123 | let mut group = c.benchmark_group("output"); 124 | 125 | let mut ss = vec![]; 126 | for _ in 0..50000 { 127 | let s = StandardUniform.sample_string(&mut rand::rng(), 128); 128 | ss.push(s); 129 | } 130 | 131 | group.bench_function("string_push_str_with_format", |b| { 132 | b.iter(|| string_push_str_with_format(black_box(&ss.clone()))); 133 | }); 134 | 135 | group.bench_function("string_push_str_without_format", |b| { 136 | b.iter(|| string_push_str_without_format(black_box(&ss.clone()))); 137 | }); 138 | 139 | group.bench_function("buf_writer_writeln", |b| { 140 | b.iter(|| buf_writer_writeln(black_box(&ss.clone()))); 141 | }); 142 | 143 | group.bench_function("buf_writer_write", |b| { 144 | b.iter(|| buf_writer_write(black_box(&ss.clone()))); 145 | }); 146 | 147 | group.bench_function("buf_writer_write_method_with_format", |b| { 148 | b.iter(|| buf_writer_write_method_with_format(black_box(&ss.clone()))); 149 | }); 150 | 151 | group.bench_function("buf_writer_write_method_without_format", |b| { 152 | b.iter(|| buf_writer_write_method_without_format(black_box(&ss.clone()))); 153 | }); 154 | 155 | group.bench_function("buf_writer_write_all_push_str", |b| { 156 | b.iter(|| buf_writer_write_all_push_str(black_box(&ss.clone()))); 157 | }); 158 | 159 | group.bench_function("buf_writer_write_all_append_with_format", |b| { 160 | b.iter(|| buf_writer_write_all_append_with_format(black_box(&ss.clone()))); 161 | }); 162 | 163 | group.bench_function("buf_writer_write_all_append_without_format", |b| { 164 | b.iter(|| buf_writer_write_all_append_without_format(black_box(&ss.clone()))); 165 | }); 166 | 167 | group.finish(); 168 | } 169 | 170 | criterion_group!(benches, criterion_benchmark); 171 | criterion_main!(benches); 172 | -------------------------------------------------------------------------------- /benches/trailing_space.rs: -------------------------------------------------------------------------------- 1 | use core::hint::black_box; 2 | use std::sync::LazyLock; 3 | 4 | use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; 5 | use rand::distr::SampleString as _; 6 | use rand::distr::StandardUniform; 7 | use regex::Regex; 8 | 9 | static RE: LazyLock = LazyLock::new(|| { 10 | #[allow(clippy::unwrap_used)] 11 | Regex::new(" +$").unwrap() 12 | }); 13 | 14 | fn regex_captures(s: &str) -> Option<(usize, usize)> { 15 | let mut locs = RE.capture_locations(); 16 | RE.captures_read(&mut locs, s); 17 | if let Some((start_column, end_column)) = locs.get(0) { 18 | return Some((start_column + 1, end_column)); 19 | } 20 | 21 | None 22 | } 23 | 24 | fn string_trim_end_matches(s: &str) -> Option<(usize, usize)> { 25 | let trimmed = s.trim_end_matches(' '); 26 | if s != trimmed { 27 | return Some((trimmed.len() + 1, s.len())); 28 | } 29 | 30 | None 31 | } 32 | 33 | fn criterion_benchmark(c: &mut Criterion) { 34 | let mut group = c.benchmark_group("trailing_space_detection"); 35 | 36 | for i in 1..=3 { 37 | let string = StandardUniform.sample_string(&mut rand::rng(), (i as usize).pow(i) * 100); 38 | 39 | group.bench_with_input(BenchmarkId::new("regex_captures", i), &string, |b, s| { 40 | b.iter(|| regex_captures(black_box(s))); 41 | }); 42 | 43 | group.bench_with_input( 44 | BenchmarkId::new("string_trim_end_matches", i), 45 | &string, 46 | |b, s| b.iter(|| string_trim_end_matches(black_box(s))), 47 | ); 48 | } 49 | group.finish(); 50 | } 51 | 52 | criterion_group!(benches, criterion_benchmark); 53 | criterion_main!(benches); 54 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1736943799, 24 | "narHash": "sha256-BYsp8PA1j691FupfrLVOQzm4CaYaKtkh4U+KuGMnBWw=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "ae2fb9f1fb5fcf17fb59f25c2a881c170c501d6f", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixpkgs-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Flake for building Mado packages"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = 10 | { 11 | self, 12 | nixpkgs, 13 | flake-utils, 14 | ... 15 | }: 16 | flake-utils.lib.eachDefaultSystem ( 17 | system: 18 | let 19 | pkgs = nixpkgs.legacyPackages.${system}; 20 | os = if pkgs.stdenv.hostPlatform.isDarwin then "macOS" else "Linux-gnu"; 21 | arch = if pkgs.stdenv.hostPlatform.isAarch64 then "arm64" else "x86_64"; 22 | 23 | in 24 | { 25 | packages = { 26 | mado = pkgs.stdenv.mkDerivation rec { 27 | pname = "mado"; 28 | version = "0.3.0"; 29 | 30 | src = pkgs.fetchzip { 31 | stripRoot = false; 32 | url = "https://github.com/akiomik/mado/releases/download/v${version}/mado-${os}-${arch}.tar.gz"; 33 | sha256 = 34 | { 35 | x86_64-linux = "10x000gza9hac6qs4pfihfbsvk6fwbnjhy7vwh6zdmwwbvf9ikis"; 36 | aarch64-linux = "0qr12gib7j7h2dpxfbz02p2hfchdwkyb9xa5qlq9yyr4d3j4lvr8"; 37 | x86_64-darwin = "0q33bdz2c2mjl1dn1rdy859kkkamd7m6mabsswjz0rb5cy1cyyd4"; 38 | aarch64-darwin = "1cv6vqk2aq2rybhbl0ybpnrq3r2cxf03p4cd1572s8w3i4mq6rql"; 39 | } 40 | .${system} or (throw "unsupported system ${system}"); 41 | }; 42 | 43 | installPhase = '' 44 | mkdir -p $out/bin 45 | cp mado $out/bin/ 46 | ''; 47 | 48 | meta = with pkgs.lib; { 49 | homepage = "https://github.com/akiomik/mado"; 50 | description = "A fast Markdown linter written in Rust"; 51 | license = licenses.asl20; 52 | sourceProvenance = [ sourceTypes.binaryNativeCode ]; 53 | }; 54 | }; 55 | default = self.packages.${system}.mado; 56 | }; 57 | formatter = pkgs.nixfmt-rfc-style; 58 | } 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /fuzz/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | corpus 3 | artifacts 4 | coverage 5 | -------------------------------------------------------------------------------- /fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mado-fuzz" 3 | version = "0.0.0" 4 | publish = false 5 | edition = "2021" 6 | 7 | [package.metadata] 8 | cargo-fuzz = true 9 | 10 | [dependencies] 11 | libfuzzer-sys = "0.4" 12 | comrak = "0.33.0" 13 | 14 | [dependencies.mado] 15 | path = ".." 16 | 17 | [[bin]] 18 | name = "linter" 19 | path = "fuzz_targets/linter.rs" 20 | test = false 21 | doc = false 22 | bench = false 23 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/linter.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use std::path::Path; 4 | 5 | use comrak::Arena; 6 | 7 | use mado::service::Linter; 8 | use mado::{Config, Document, Rule}; 9 | 10 | use libfuzzer_sys::fuzz_target; 11 | 12 | fuzz_target!(|text: String| { 13 | let config = Config::default(); 14 | let rules = Vec::::from(&config.lint); 15 | let linter = Linter::new(rules); 16 | let arena = Arena::new(); 17 | let path = Path::new("test.md").to_path_buf(); 18 | let doc = Document::new(&arena, path, text).unwrap(); 19 | let _ = linter.check(&doc); 20 | }); 21 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | prev_version := "0.2.2" 2 | version := "0.3.0" 3 | tempdir := `mktemp -d` 4 | 5 | default: fmt test lint 6 | 7 | fmt: 8 | cargo fmt --all --check 9 | nix fmt flake.nix 10 | taplo format 11 | 12 | test: 13 | CLICOLOR_FORCE=true cargo test --all-features --workspace 14 | 15 | lint: 16 | cargo clippy --all-targets --all-features --workspace -- -D warnings 17 | taplo lint 18 | 19 | cov: 20 | CLICOLOR_FORCE=true cargo llvm-cov --open 21 | 22 | [linux] 23 | flamegraph target="scripts/benchmarks/data/gitlab": 24 | cargo flamegraph --profile bench --open -- check {{ target }} 25 | 26 | [macos] 27 | flamegraph target="scripts/benchmarks/data/gitlab": 28 | # See https://github.com/flamegraph-rs/flamegraph#dtrace-on-macos 29 | cargo flamegraph --root --profile bench --open -- check {{ target }} 30 | 31 | fuzz target="linter": 32 | cargo +nightly fuzz run {{ target }} 33 | 34 | [private] 35 | download-hash version target: 36 | @echo 'Downloading v{{ version }}/{{ target }}...' 37 | @wget -q -P {{ tempdir }} https://github.com/akiomik/mado/releases/download/v{{ version }}/{{ target }} 38 | @mv {{ tempdir }}/{{ target }} {{ tempdir }}/v{{ version }}-{{ target }} 39 | 40 | [private] 41 | update-homebrew-hash target: (download-hash prev_version target) (download-hash version target) 42 | @echo 'Updating pkg/homebrew/mado.rb for {{ target }}...' 43 | @prev_hash=`cut -d ' ' -f 1 {{ tempdir }}/v{{ prev_version }}-{{ target }}` \ 44 | && new_hash=`cut -d ' ' -f 1 {{ tempdir }}/v{{ version }}-{{ target }}` \ 45 | && sed -I '' "s/$prev_hash/$new_hash/" pkg/homebrew/mado.rb 46 | 47 | update-homebrew-hash-linux-arm64: (update-homebrew-hash "mado-Linux-gnu-arm64.tar.gz.sha256") 48 | 49 | update-homebrew-hash-linux-amd64: (update-homebrew-hash "mado-Linux-gnu-x86_64.tar.gz.sha256") 50 | 51 | update-homebrew-hash-macos-arm64: (update-homebrew-hash "mado-macOS-arm64.tar.gz.sha256") 52 | 53 | update-homebrew-hash-macos-amd64: (update-homebrew-hash "mado-macOS-x86_64.tar.gz.sha256") 54 | 55 | update-homebrew-hash-all: update-homebrew-hash-linux-arm64 update-homebrew-hash-linux-amd64 update-homebrew-hash-macos-arm64 update-homebrew-hash-macos-amd64 56 | 57 | update-homebrew: update-homebrew-hash-all 58 | @echo 'Updating pkg/homebrew/mado.rb for {{ version }}...' 59 | @sed -I '' "s/{{ prev_version }}/{{ version }}/" pkg/homebrew/mado.rb 60 | 61 | [private] 62 | update-scoop-hash target: (download-hash prev_version target) (download-hash version target) 63 | @echo 'Updating pkg/scoop/mado.json for {{ target }}...' 64 | @prev_hash=`cut -d ' ' -f 1 {{ tempdir }}/v{{ prev_version }}-{{ target }}` \ 65 | && new_hash=`cut -d ' ' -f 1 {{ tempdir }}/v{{ version }}-{{ target }}` \ 66 | && sed -I '' "s/$prev_hash/$new_hash/" pkg/scoop/mado.json 67 | 68 | update-scoop-hash-windows-amd64: (update-scoop-hash "mado-Windows-msvc-x86_64.zip.sha256") 69 | 70 | update-scoop-hash-all: update-scoop-hash-windows-amd64 71 | 72 | update-scoop: update-scoop-hash-all 73 | @echo 'Updating pkg/scoop/mado.json for {{ version }}...' 74 | @sed -I '' "s/{{ prev_version }}/{{ version }}/" pkg/scoop/mado.json 75 | 76 | [private] 77 | update-winget-hash target: (download-hash prev_version target) (download-hash version target) 78 | @echo 'Updating pkg/winget/mado.yml for {{ target }}...' 79 | @prev_hash=`cut -d ' ' -f 1 {{ tempdir }}/v{{ prev_version }}-{{ target }}` \ 80 | && new_hash=`cut -d ' ' -f 1 {{ tempdir }}/v{{ version }}-{{ target }}` \ 81 | && sed -I '' "s/$prev_hash/$new_hash/" pkg/winget/mado.yml 82 | 83 | update-winget-hash-windows-amd64: (update-winget-hash "mado-Windows-msvc-x86_64.zip.sha256") 84 | 85 | update-winget-hash-all: update-winget-hash-windows-amd64 86 | 87 | update-winget: update-winget-hash-all 88 | @echo 'Updating pkg/winget/mado.yml for {{ version }}...' 89 | @sed -I '' "s/{{ prev_version }}/{{ version }}/" pkg/winget/mado.yml 90 | 91 | [private] 92 | nix-hash version target: 93 | @echo 'Downloading v{{ version }}/{{ target }}...' 94 | @nix-prefetch-url --unpack https://github.com/akiomik/mado/releases/download/v{{ version }}/{{ target }} \ 95 | > {{ tempdir }}/v{{ version }}-{{ target }}.sha256 96 | 97 | [private] 98 | update-flake-hash target: (nix-hash prev_version target) (nix-hash version target) 99 | @echo 'Updating flake.nix for {{ target }}...' 100 | @prev_hash=`cat {{ tempdir }}/v{{ prev_version }}-{{ target }}.sha256` \ 101 | && new_hash=`cat {{ tempdir }}/v{{ version }}-{{ target }}.sha256` \ 102 | && sed -I '' "s/$prev_hash/$new_hash/" flake.nix 103 | 104 | update-flake-hash-linux-arm64: (update-flake-hash "mado-Linux-gnu-arm64.tar.gz") 105 | 106 | update-flake-hash-linux-amd64: (update-flake-hash "mado-Linux-gnu-x86_64.tar.gz") 107 | 108 | update-flake-hash-macos-arm64: (update-flake-hash "mado-macOS-arm64.tar.gz") 109 | 110 | update-flake-hash-macos-amd64: (update-flake-hash "mado-macOS-x86_64.tar.gz") 111 | 112 | update-flake-hash-all: update-flake-hash-linux-arm64 update-flake-hash-linux-amd64 update-flake-hash-macos-arm64 update-flake-hash-macos-amd64 113 | 114 | update-flake: update-flake-hash-all 115 | @echo 'Updating flake.nix for {{ version }}...' 116 | @sed -I '' "s/{{ prev_version }}/{{ version }}/" flake.nix 117 | -------------------------------------------------------------------------------- /mado.toml: -------------------------------------------------------------------------------- 1 | [lint] 2 | respect-ignore = true 3 | respect-gitignore = true 4 | output-format = "concise" 5 | quiet = false 6 | exclude = [] 7 | rules = [ 8 | "MD001", 9 | "MD002", 10 | "MD003", 11 | "MD004", 12 | "MD005", 13 | "MD006", 14 | "MD007", 15 | "MD009", 16 | "MD010", 17 | "MD012", 18 | "MD013", 19 | "MD014", 20 | "MD018", 21 | "MD019", 22 | "MD020", 23 | "MD021", 24 | "MD022", 25 | "MD023", 26 | "MD024", 27 | "MD025", 28 | "MD026", 29 | "MD027", 30 | "MD028", 31 | "MD029", 32 | "MD030", 33 | "MD031", 34 | "MD032", 35 | "MD033", 36 | "MD034", 37 | "MD035", 38 | "MD036", 39 | "MD037", 40 | "MD038", 41 | "MD039", 42 | "MD040", 43 | "MD041", 44 | "MD046", 45 | "MD047", 46 | ] 47 | 48 | [lint.md002] 49 | level = 1 50 | 51 | [lint.md003] 52 | style = "consistent" 53 | 54 | [lint.md004] 55 | style = "consistent" 56 | 57 | [lint.md007] 58 | indent = 4 59 | 60 | [lint.md013] 61 | line-length = 80 62 | code-blocks = false 63 | tables = false 64 | 65 | [lint.md024] 66 | allow-different-nesting = true 67 | 68 | [lint.md025] 69 | level = 1 70 | 71 | [lint.md026] 72 | punctuation = ".,;:!?" 73 | 74 | [lint.md029] 75 | style = "one" 76 | 77 | [lint.md030] 78 | ul-single = 1 79 | ol-single = 1 80 | ul-multi = 1 81 | ol-multi = 1 82 | 83 | [lint.md033] 84 | allowed-elements = [] 85 | 86 | [lint.md035] 87 | style = "consistent" 88 | 89 | [lint.md036] 90 | punctuation = ".,;:!?" 91 | 92 | [lint.md041] 93 | level = 1 94 | 95 | [lint.md046] 96 | style = "fenced" 97 | -------------------------------------------------------------------------------- /pkg/homebrew/mado.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Mado < Formula 4 | desc "Fast Markdown linter written in Rust" 5 | homepage "https://github.com/akiomik/mado" 6 | version "0.3.0" 7 | license "Apache-2.0" 8 | 9 | on_macos do 10 | on_arm do 11 | url "https://github.com/akiomik/mado/releases/download/v#{version}/mado-macOS-arm64.tar.gz" 12 | sha256 "4000955c41799c839dbf1b4c3011ff1688bd31d0af88be282eec622a07ad9743" 13 | end 14 | 15 | on_intel do 16 | url "https://github.com/akiomik/mado/releases/download/v#{version}/mado-macOS-x86_64.tar.gz" 17 | sha256 "b8a3fb5cf3e84747c12a848fb87ce08c1c09dc5a957c1f74733bea6cb7f9d560" 18 | end 19 | end 20 | 21 | on_linux do 22 | on_arm do 23 | url "https://github.com/akiomik/mado/releases/download/v#{version}/mado-Linux-gnu-arm64.tar.gz" 24 | sha256 "ffcdd4845329a69bc729c0242abb6163f23495c0b04dbbaf608cbed43a2f4976" 25 | end 26 | 27 | on_intel do 28 | url "https://github.com/akiomik/mado/releases/download/v#{version}/mado-Linux-gnu-x86_64.tar.gz" 29 | sha256 "aad845cd23c8c0417cdf87b8376b75e131c38e1cb890124790567735306de6d7" 30 | end 31 | end 32 | 33 | def install 34 | bin.install "mado" 35 | end 36 | 37 | test do 38 | assert_equal "mado #{version}", shell_output("#{bin}/mado --version").strip 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /pkg/scoop/mado.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.3.0", 3 | "description": "A fast Markdown linter written in Rust", 4 | "homepage": "https://github.com/akiomik/mado", 5 | "license": "Apache-2.0", 6 | "architecture": { 7 | "64bit": { 8 | "url": "https://github.com/akiomik/mado/releases/download/v0.3.0/mado-Windows-msvc-x86_64.zip", 9 | "hash": "f79025f931642ea942182aa2717a91af911df122555cd0501c24a1a9bf40e08a" 10 | } 11 | }, 12 | "bin": "mado.exe", 13 | "checkver": "github", 14 | "autoupdate": { 15 | "architecture": { 16 | "64bit": { 17 | "url": "https://github.com/akiomik/mado/releases/download/v$version/mado-Windows-msvc-x86_64.zip" 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pkg/winget/mado.yml: -------------------------------------------------------------------------------- 1 | PackageIdentifier: AkiomiKamakura.Mado 2 | PackageVersion: 0.3.0 3 | PackageLocale: en-US 4 | PackageName: Mado 5 | ShortDescription: A fast Markdown linter written in Rust. 6 | License: Apache-2.0 7 | Publisher: Akiomi Kamakura 8 | ReleaseNotesUrl: https://github.com/akiomik/mado/blob/main/CHANGELOG.md 9 | InstallerType: zip 10 | NestedInstallerType: portable 11 | NestedInstallerFiles: 12 | - RelativeFilePath: mado.exe 13 | InstallModes: 14 | - silent 15 | - silentWithProgress 16 | UpgradeBehavior: install 17 | ReleaseDate: 2025-01-17 18 | Installers: 19 | - Architecture: x64 20 | InstallerUrl: https://github.com/akiomik/mado/releases/download/v0.3.0/mado-Windows-msvc-x86_64.zip 21 | InstallerSha256: f79025f931642ea942182aa2717a91af911df122555cd0501c24a1a9bf40e08a 22 | ManifestType: singleton 23 | ManifestVersion: 1.9.0 24 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.84.0" 3 | profile = "default" 4 | -------------------------------------------------------------------------------- /scripts/acceptance/.mdlrc: -------------------------------------------------------------------------------- 1 | ../benchmarks/.mdlrc -------------------------------------------------------------------------------- /scripts/acceptance/data/.gitignore: -------------------------------------------------------------------------------- 1 | markdownlint 2 | -------------------------------------------------------------------------------- /scripts/acceptance/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | SCRIPT_DIR=$(cd $(dirname $0); pwd) 4 | DATA_ROOT=$SCRIPT_DIR/data 5 | 6 | cd $DATA_ROOT 7 | git clone --sparse --filter=blob:none https://github.com/markdownlint/markdownlint.git 8 | cd markdownlint 9 | git sparse-checkout set test/rule_tests 10 | git checkout 11 | 12 | # TODO: Support each styles 13 | style_files=$(find data/markdownlint/test/rule_tests -name '*_style.rb') 14 | for style_file in $style_files; do 15 | if [ $style_file -eq "data/markdownlint/test/rule_tests/default_test_style.rb" ]; then 16 | continue 17 | fi 18 | 19 | markdown_file=$(echo $style_file | sed 's/_style.rb$/.md/') 20 | mv $markdown_file $markdown_file.bak 21 | done 22 | -------------------------------------------------------------------------------- /scripts/acceptance/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | SCRIPT_DIR=$(cd $(dirname $0); pwd) 4 | PROJECT_ROOT=($SCRIPT_DIR/../../) 5 | DATA_ROOT=$SCRIPT_DIR/data 6 | DOC_PATH=$DATA_ROOT/markdownlint/test/rule_tests 7 | TEMP_PATH=$PROJECT_ROOT/tmp 8 | 9 | mdl --config $SCRIPT_DIR/.mdlrc $DOC_PATH > $TEMP_PATH/mdl.txt 10 | cargo run check --output-format=mdl $DOC_PATH > $TEMP_PATH/mado.txt 11 | 12 | # Truncate unnecessary texts 13 | sed -i '' -e "/^Further documentation is available for these failures:/d" $TEMP_PATH/mdl.txt > /dev/null 14 | sed -i '' -e "/^ - /d" $TEMP_PATH/mdl.txt > /dev/null 15 | sed -i '' -e "/^Found /d" $TEMP_PATH/mado.txt > /dev/null 16 | -------------------------------------------------------------------------------- /scripts/benchmarks/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /scripts/benchmarks/.markdownlint.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "default": false, 3 | "MD001": true, 4 | "MD002": true, 5 | "MD003": true, 6 | "MD004": true, 7 | "MD005": true, 8 | "MD006": true, 9 | "MD007": true, 10 | "MD009": true, 11 | "MD010": true, 12 | "MD012": true, 13 | "MD013": true, 14 | "MD014": true, 15 | "MD018": true, 16 | "MD019": true, 17 | "MD020": true, 18 | "MD021": true, 19 | "MD022": true, 20 | "MD023": true, 21 | "MD024": true, 22 | "MD025": true, 23 | "MD026": true, 24 | "MD027": true, 25 | "MD028": true, 26 | "MD029": true, 27 | "MD030": true, 28 | "MD031": true, 29 | "MD032": true, 30 | "MD033": true, 31 | "MD034": true, 32 | "MD035": true, 33 | "MD036": true, 34 | "MD037": true, 35 | "MD038": true, 36 | "MD039": true, 37 | "MD040": true, 38 | "MD041": true, 39 | "MD046": true 40 | } 41 | -------------------------------------------------------------------------------- /scripts/benchmarks/.mdlrc: -------------------------------------------------------------------------------- 1 | rules "MD001", "MD002", "MD003", "MD004", "MD005", "MD006", "MD007", "MD009", "MD010", "MD012", "MD013", "MD014", "MD018", "MD019", "MD020", "MD021", "MD022", "MD023", "MD024", "MD025", "MD026", "MD027", "MD028", "MD029", "MD030", "MD031", "MD032", "MD033", "MD034", "MD035", "MD036", "MD037", "MD038", "MD039", "MD040", "MD041", "MD046", "MD047" 2 | -------------------------------------------------------------------------------- /scripts/benchmarks/comparison.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | SCRIPT_DIR=$(cd $(dirname $0); pwd) 4 | PROJECT_ROOT=($SCRIPT_DIR/../..) 5 | DATA_ROOT=$SCRIPT_DIR/data 6 | DOC_PATH=$DATA_ROOT/gitlab/doc 7 | 8 | cargo build --release 9 | 10 | hyperfine --ignore-failure --warmup 10 \ 11 | "$PROJECT_ROOT/target/release/mado --config $SCRIPT_DIR/mado.toml check $DOC_PATH" \ 12 | "mdl --config $SCRIPT_DIR/.mdlrc $DOC_PATH" \ 13 | "$SCRIPT_DIR/node_modules/.bin/markdownlint --config $SCRIPT_DIR/.markdownlint.jsonc $DOC_PATH" \ 14 | "$SCRIPT_DIR/node_modules/.bin/markdownlint-cli2 --config $SCRIPT_DIR/.markdownlint.jsonc \"$DOC_PATH/**/*.md\"" 15 | -------------------------------------------------------------------------------- /scripts/benchmarks/data/.gitignore: -------------------------------------------------------------------------------- 1 | gitlab 2 | -------------------------------------------------------------------------------- /scripts/benchmarks/mado.toml: -------------------------------------------------------------------------------- 1 | [lint] 2 | output-format = "concise" 3 | rules = [ 4 | "MD001", 5 | "MD002", 6 | "MD003", 7 | "MD004", 8 | "MD005", 9 | "MD006", 10 | "MD007", 11 | "MD009", 12 | "MD010", 13 | "MD012", 14 | "MD013", 15 | "MD014", 16 | "MD018", 17 | "MD019", 18 | "MD020", 19 | "MD021", 20 | "MD022", 21 | "MD023", 22 | "MD024", 23 | "MD025", 24 | "MD026", 25 | "MD027", 26 | "MD028", 27 | "MD029", 28 | "MD030", 29 | "MD031", 30 | "MD032", 31 | "MD033", 32 | "MD034", 33 | "MD035", 34 | "MD036", 35 | "MD037", 36 | "MD038", 37 | "MD039", 38 | "MD040", 39 | "MD041", 40 | "MD046", 41 | "MD047", 42 | ] 43 | 44 | [lint.md002] 45 | level = 1 46 | 47 | [lint.md003] 48 | style = "consistent" 49 | 50 | [lint.md004] 51 | style = "consistent" 52 | 53 | [lint.md007] 54 | indent = 4 55 | 56 | [lint.md013] 57 | line-length = 80 58 | code-blocks = true 59 | tables = true 60 | 61 | [lint.md025] 62 | level = 1 63 | 64 | [lint.md026] 65 | punctuation = ".,;:!?" 66 | 67 | [lint.md029] 68 | style = "one" 69 | 70 | [lint.md030] 71 | ul-single = 1 72 | ol-single = 1 73 | ul-multi = 1 74 | ol-multi = 1 75 | 76 | [lint.md033] 77 | allowed-elements = [] 78 | 79 | [lint.md035] 80 | style = "consistent" 81 | 82 | [lint.md036] 83 | punctuation = ".,;:!?" 84 | 85 | [lint.md041] 86 | level = 1 87 | 88 | [lint.md046] 89 | style = "fenced" 90 | -------------------------------------------------------------------------------- /scripts/benchmarks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mado-benchmark", 3 | "private": true, 4 | "dependencies": { 5 | "markdownlint-cli": "^0.43.0", 6 | "markdownlint-cli2": "^0.16.0" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /scripts/benchmarks/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | SCRIPT_DIR=$(cd $(dirname $0); pwd) 4 | DATA_ROOT=$SCRIPT_DIR/data 5 | 6 | # NOTE: Use the same datasets as those used by vale for benchmarking 7 | # https://github.com/errata-ai/vale?tab=readme-ov-file#benchmarks 8 | cd $DATA_ROOT 9 | git clone --sparse --filter=blob:none https://gitlab.com/gitlab-org/gitlab.git 10 | cd gitlab 11 | git sparse-checkout set doc 12 | git reset --hard 7d6a4025a0346f1f50d2825c85742e5a27b39a8b 13 | git checkout 14 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::Parser; 4 | 5 | use crate::Command; 6 | 7 | #[derive(Parser)] 8 | #[command( 9 | name = "mado", 10 | bin_name = "mado", 11 | version, 12 | about, 13 | long_about = None, 14 | arg_required_else_help = true 15 | )] 16 | #[non_exhaustive] 17 | pub struct Cli { 18 | /// A path to a TOML configuration file overriding a specific configuration option 19 | #[arg(long, value_name = "FILE")] 20 | pub config: Option, 21 | 22 | #[command(subcommand)] 23 | pub command: Command, 24 | } 25 | 26 | #[cfg(test)] 27 | mod tests { 28 | use clap::CommandFactory as _; 29 | 30 | use super::*; 31 | 32 | #[test] 33 | fn command() { 34 | Cli::command().debug_assert(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/collection.rs: -------------------------------------------------------------------------------- 1 | use core::hash::Hash; 2 | use core::marker::PhantomData; 3 | use core::ops::RangeBounds; 4 | use std::collections::hash_set::Iter; 5 | 6 | use rustc_hash::FxHashSet; 7 | 8 | #[derive(Debug, Default, Clone)] 9 | pub struct RangeSet, Idx> { 10 | data: FxHashSet, 11 | phantom: PhantomData, 12 | } 13 | 14 | impl RangeSet 15 | where 16 | R: RangeBounds, 17 | { 18 | #[inline] 19 | #[must_use] 20 | pub fn new() -> Self { 21 | let data = FxHashSet::default(); 22 | let phantom = PhantomData; 23 | Self { data, phantom } 24 | } 25 | 26 | #[inline] 27 | #[must_use] 28 | pub fn iter(&self) -> Iter<'_, R> { 29 | self.data.iter() 30 | } 31 | 32 | #[inline] 33 | #[must_use] 34 | pub fn is_empty(&self) -> bool { 35 | self.data.is_empty() 36 | } 37 | 38 | #[inline] 39 | #[must_use] 40 | pub fn len(&self) -> usize { 41 | self.data.len() 42 | } 43 | } 44 | 45 | impl RangeSet 46 | where 47 | R: RangeBounds, 48 | Idx: PartialOrd, 49 | { 50 | #[inline] 51 | #[must_use] 52 | pub fn contains(&self, value: &Idx) -> bool { 53 | self.data.iter().any(|range| range.contains(value)) 54 | } 55 | } 56 | 57 | impl RangeSet 58 | where 59 | R: RangeBounds + Eq + Hash, 60 | { 61 | #[inline] 62 | pub fn insert(&mut self, value: R) { 63 | self.data.insert(value); 64 | } 65 | } 66 | 67 | impl PartialEq for RangeSet 68 | where 69 | R: RangeBounds + Eq + Hash, 70 | { 71 | #[inline] 72 | #[must_use] 73 | fn eq(&self, other: &Self) -> bool { 74 | self.data == other.data 75 | } 76 | } 77 | 78 | impl IntoIterator for RangeSet 79 | where 80 | R: RangeBounds, 81 | { 82 | type Item = R; 83 | type IntoIter = as IntoIterator>::IntoIter; 84 | 85 | #[inline] 86 | fn into_iter(self) -> Self::IntoIter { 87 | self.data.into_iter() 88 | } 89 | } 90 | 91 | impl<'a, R, Idx> IntoIterator for &'a RangeSet 92 | where 93 | R: RangeBounds, 94 | { 95 | type Item = &'a R; 96 | type IntoIter = Iter<'a, R>; 97 | 98 | #[inline] 99 | fn into_iter(self) -> Self::IntoIter { 100 | self.iter() 101 | } 102 | } 103 | 104 | impl From<[R; N]> for RangeSet 105 | where 106 | R: RangeBounds + Eq + Hash, 107 | { 108 | #[inline] 109 | fn from(value: [R; N]) -> Self { 110 | let data = FxHashSet::from_iter(value); 111 | let phantom = PhantomData; 112 | Self { data, phantom } 113 | } 114 | } 115 | 116 | #[cfg(test)] 117 | mod tests { 118 | use pretty_assertions::{assert_eq, assert_ne}; 119 | 120 | use super::*; 121 | 122 | #[test] 123 | fn insert() { 124 | let mut set = RangeSet::new(); 125 | assert!(!set.data.contains(&(0..10))); 126 | set.insert(0..10); 127 | assert!(set.data.contains(&(0..10))); 128 | } 129 | 130 | #[test] 131 | fn len() { 132 | let mut set = RangeSet::new(); 133 | assert_eq!(set.len(), 0); 134 | set.insert(0..10); 135 | assert_eq!(set.len(), 1); 136 | } 137 | 138 | #[test] 139 | fn iter() { 140 | let ranges = [0..10, 20..30, 25..35]; 141 | let set = RangeSet::from(ranges.clone()); 142 | let actual: FxHashSet<_> = set.iter().collect(); 143 | let expected: FxHashSet<_> = ranges.iter().collect(); 144 | assert_eq!(actual, expected); 145 | } 146 | 147 | #[test] 148 | fn into_iter() { 149 | let ranges = [0..10, 20..30, 25..35]; 150 | let set = RangeSet::from(ranges.clone()); 151 | let actual: FxHashSet<_> = set.into_iter().collect(); 152 | let expected: FxHashSet<_> = ranges.into_iter().collect(); 153 | assert_eq!(actual, expected); 154 | } 155 | 156 | #[test] 157 | #[allow(clippy::into_iter_on_ref)] 158 | fn into_iter_ref() { 159 | let ranges = &[0..10, 20..30, 25..35]; 160 | let set = &RangeSet::from(ranges.clone()); 161 | let actual: FxHashSet<_> = set.into_iter().collect(); 162 | let expected: FxHashSet<_> = ranges.into_iter().collect(); 163 | assert_eq!(actual, expected); 164 | } 165 | 166 | #[test] 167 | fn is_empty() { 168 | let mut set = RangeSet::new(); 169 | assert!(set.is_empty()); 170 | set.insert(0..10); 171 | assert!(!set.is_empty()); 172 | } 173 | 174 | #[test] 175 | fn contains_range() { 176 | let set = RangeSet::from([0..10, 20..30, 25..35]); 177 | assert!(set.contains(&0)); 178 | assert!(!set.contains(&10)); 179 | assert!(set.contains(&20)); 180 | assert!(set.contains(&30)); 181 | assert!(!set.contains(&35)); 182 | } 183 | 184 | #[test] 185 | fn contains_range_inclusive() { 186 | let set = RangeSet::from([0..=10, 20..=30, 25..=35]); 187 | assert!(set.contains(&0)); 188 | assert!(set.contains(&10)); 189 | assert!(set.contains(&20)); 190 | assert!(set.contains(&30)); 191 | assert!(set.contains(&35)); 192 | } 193 | 194 | #[test] 195 | fn partial_eq_true() { 196 | let ranges = [0..10, 20..30, 25..35]; 197 | let set0 = RangeSet::from(ranges.clone()); 198 | let set1 = RangeSet::from(ranges); 199 | assert_eq!(set0, set1); 200 | } 201 | 202 | #[test] 203 | fn partial_eq_false() { 204 | let ranges0 = [0..10, 20..30, 25..35]; 205 | let ranges1 = [0..10, 20..30, 35..45]; 206 | let set0 = RangeSet::from(ranges0); 207 | let set1 = RangeSet::from(ranges1); 208 | assert_ne!(set0, set1); 209 | } 210 | 211 | #[test] 212 | fn from_array() { 213 | let ranges = [0..10, 20..30, 25..35]; 214 | let set = RangeSet::from(ranges.clone()); 215 | let expected: FxHashSet<_> = ranges.iter().map(ToOwned::to_owned).collect(); 216 | assert_eq!(set.data, expected); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/command.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::{Subcommand, ValueHint}; 4 | use clap_complete::Shell; 5 | use globset::Glob; 6 | 7 | use crate::output::Format; 8 | 9 | pub mod check; 10 | pub mod generate_shell_completion; 11 | 12 | #[derive(Subcommand)] 13 | #[allow(clippy::exhaustive_enums)] 14 | pub enum Command { 15 | /// Check markdown on the given files or directories 16 | Check { 17 | /// List of files or directories to check 18 | #[arg(default_value = ".", value_hint = ValueHint::AnyPath)] 19 | files: Vec, 20 | 21 | /// Output format for violations. The default format is "concise" 22 | #[arg(value_enum, long = "output-format")] 23 | output_format: Option, 24 | 25 | /// Only log errors 26 | #[arg(long, default_value_t = false)] 27 | quiet: bool, 28 | 29 | /// List of file patterns to exclude from linting 30 | #[arg(long, value_delimiter = ',')] 31 | exclude: Option>, 32 | }, 33 | /// Generate shell completion 34 | GenerateShellCompletion { 35 | /// Shell to generate a completion script 36 | shell: Shell, 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /src/command/check.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read as _; 2 | use std::io::{self, BufWriter, IsTerminal as _, Write as _}; 3 | use std::path::PathBuf; 4 | use std::process::ExitCode; 5 | 6 | use globset::Glob; 7 | use miette::IntoDiagnostic as _; 8 | use miette::Result; 9 | 10 | use crate::output::{Concise, Format, Markdownlint, Mdl}; 11 | use crate::service::runner::{LintRunner, ParallelLintRunner, StringLintRunner}; 12 | use crate::Config; 13 | 14 | #[derive(Debug, Clone, PartialEq, Eq)] 15 | #[allow(clippy::exhaustive_structs)] 16 | pub struct Options { 17 | pub config_path: Option, 18 | pub output_format: Option, 19 | pub quiet: bool, 20 | pub exclude: Option>, 21 | } 22 | 23 | impl Options { 24 | #[inline] 25 | pub fn to_config(self) -> Result { 26 | let mut config = match self.config_path { 27 | Some(config_path) => Config::load(&config_path)?, 28 | None => Config::resolve()?, 29 | }; 30 | 31 | if let Some(format) = self.output_format { 32 | config.lint.output_format = format; 33 | } 34 | 35 | // Respect config 36 | config.lint.quiet |= self.quiet; 37 | 38 | if let Some(exclude) = self.exclude { 39 | config.lint.exclude = exclude; 40 | } 41 | 42 | Ok(config) 43 | } 44 | } 45 | 46 | pub struct Checker { 47 | runner: LintRunner, 48 | config: Config, 49 | } 50 | 51 | fn stdin_input() -> Option { 52 | let stdin = io::stdin(); 53 | if stdin.is_terminal() { 54 | return None; 55 | } 56 | 57 | let mut buffer = String::new(); 58 | io::stdin().lock().read_to_string(&mut buffer).ok()?; 59 | 60 | if buffer.is_empty() { 61 | None 62 | } else { 63 | Some(buffer) 64 | } 65 | } 66 | 67 | impl Checker { 68 | #[inline] 69 | pub fn new(patterns: &[PathBuf], config: Config) -> Result { 70 | let runner = match stdin_input() { 71 | Some(input) => { 72 | LintRunner::String(Box::new(StringLintRunner::new(input, config.clone()))) 73 | } 74 | None => LintRunner::Parallel(ParallelLintRunner::new(patterns, config.clone(), 100)?), 75 | }; 76 | 77 | Ok(Self { runner, config }) 78 | } 79 | 80 | #[inline] 81 | pub fn check(self) -> Result { 82 | let mut violations = self.runner.run()?; 83 | violations.sort_by(self.config.lint.output_format.sorter()); 84 | 85 | if violations.is_empty() { 86 | if !self.config.lint.quiet { 87 | println!("All checks passed!"); 88 | } 89 | 90 | return Ok(ExitCode::SUCCESS); 91 | } 92 | 93 | let mut output = BufWriter::new(io::stdout().lock()); 94 | let num_violations = violations.len(); 95 | for violation in violations { 96 | match self.config.lint.output_format { 97 | Format::Concise => { 98 | writeln!(output, "{}", Concise::new(&violation)).into_diagnostic()?; 99 | } 100 | Format::Mdl => writeln!(output, "{}", Mdl::new(&violation)).into_diagnostic()?, 101 | Format::Markdownlint => { 102 | writeln!(output, "{}", Markdownlint::new(&violation)).into_diagnostic()?; 103 | } 104 | } 105 | } 106 | 107 | if num_violations == 1 { 108 | writeln!(output, "\nFound 1 error.").into_diagnostic()?; 109 | } else { 110 | writeln!(output, "\nFound {num_violations} errors.").into_diagnostic()?; 111 | } 112 | 113 | Ok(ExitCode::FAILURE) 114 | } 115 | } 116 | 117 | #[cfg(test)] 118 | mod tests { 119 | use std::path::Path; 120 | 121 | use pretty_assertions::assert_eq; 122 | 123 | use super::*; 124 | 125 | #[test] 126 | fn options_to_config_none_none_false_none() -> Result<()> { 127 | let options = Options { 128 | config_path: None, 129 | output_format: None, 130 | quiet: false, 131 | exclude: None, 132 | }; 133 | let actual = options.to_config()?; 134 | let mut expected = Config::default(); 135 | expected.lint.md013.code_blocks = false; 136 | expected.lint.md013.tables = false; 137 | expected.lint.md024.allow_different_nesting = true; 138 | assert_eq!(actual, expected); 139 | Ok(()) 140 | } 141 | 142 | #[test] 143 | fn options_to_config_some_some_true_some() -> Result<()> { 144 | let exclude = vec![Glob::new("README.md").into_diagnostic()?]; 145 | let options = Options { 146 | config_path: Some(Path::new("mado.toml").to_path_buf()), 147 | output_format: Some(Format::Mdl), 148 | quiet: true, 149 | exclude: Some(exclude.clone()), 150 | }; 151 | let actual = options.to_config()?; 152 | let mut expected = Config::default(); 153 | expected.lint.output_format = Format::Mdl; 154 | expected.lint.quiet = true; 155 | expected.lint.exclude = exclude; 156 | expected.lint.md013.code_blocks = false; 157 | expected.lint.md013.tables = false; 158 | expected.lint.md024.allow_different_nesting = true; 159 | assert_eq!(actual, expected); 160 | Ok(()) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/command/generate_shell_completion.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use clap::Command; 4 | use clap_complete::{generate, Generator}; 5 | 6 | #[derive(Debug, Clone)] 7 | pub struct ShellCompletionGenerator { 8 | cmd: Command, 9 | } 10 | 11 | impl ShellCompletionGenerator { 12 | #[inline] 13 | #[must_use] 14 | pub const fn new(cmd: Command) -> Self { 15 | Self { cmd } 16 | } 17 | 18 | #[inline] 19 | pub fn generate(&mut self, gen: G) { 20 | let name = self.cmd.get_name().to_owned(); 21 | generate(gen, &mut self.cmd, name, &mut io::stdout()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::Path; 3 | 4 | use etcetera::choose_base_strategy; 5 | use etcetera::BaseStrategy as _; 6 | use miette::miette; 7 | use miette::IntoDiagnostic as _; 8 | use miette::Result; 9 | use serde::Deserialize; 10 | 11 | pub mod lint; 12 | 13 | pub use lint::Lint; 14 | use serde::Serialize; 15 | 16 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] 17 | #[serde(default)] 18 | #[allow(clippy::exhaustive_structs)] 19 | pub struct Config { 20 | pub lint: Lint, 21 | } 22 | 23 | impl Config { 24 | const FILE_NAME: &str = "mado.toml"; 25 | const HIDDEN_FILE_NAME: &str = ".mado.toml"; 26 | 27 | #[inline] 28 | pub fn load>(path: P) -> Result { 29 | let config_text = fs::read_to_string(path).into_diagnostic()?; 30 | toml::from_str(&config_text).map_err(|err| miette!(err)) 31 | } 32 | 33 | #[inline] 34 | pub fn resolve() -> Result { 35 | let local_path = Path::new(Self::FILE_NAME); 36 | let exists_local = fs::exists(local_path).into_diagnostic()?; 37 | if exists_local { 38 | return Self::load(local_path); 39 | } 40 | 41 | let hidden_local_path = Path::new(Self::HIDDEN_FILE_NAME); 42 | let exists_hidden_local = fs::exists(hidden_local_path).into_diagnostic()?; 43 | if exists_hidden_local { 44 | return Self::load(hidden_local_path); 45 | } 46 | 47 | let strategy = choose_base_strategy().into_diagnostic()?; 48 | let config_path = strategy.config_dir().join("mado").join(Self::FILE_NAME); 49 | let exists_config = fs::exists(&config_path).into_diagnostic()?; 50 | if exists_config { 51 | return Self::load(&config_path); 52 | } 53 | 54 | Ok(Self::default()) 55 | } 56 | } 57 | 58 | #[cfg(test)] 59 | mod tests { 60 | use super::*; 61 | 62 | use crate::output::Format; 63 | use indoc::indoc; 64 | use lint::{RuleSet, MD002}; 65 | use pretty_assertions::assert_eq; 66 | 67 | #[test] 68 | fn load() -> Result<()> { 69 | let path = Path::new("mado.toml"); 70 | let actual = Config::load(path)?; 71 | let mut expected = Config::default(); 72 | expected.lint.md013.code_blocks = false; 73 | expected.lint.md013.tables = false; 74 | expected.lint.md024.allow_different_nesting = true; 75 | assert_eq!(actual, expected); 76 | Ok(()) 77 | } 78 | 79 | #[test] 80 | fn resolve() -> Result<()> { 81 | let actual = Config::resolve()?; 82 | let path = Path::new("mado.toml"); 83 | let expected = Config::load(path)?; 84 | assert_eq!(actual, expected); 85 | Ok(()) 86 | } 87 | 88 | #[test] 89 | fn deserialize() -> Result<()> { 90 | let text = indoc! {r#" 91 | [lint] 92 | output-format = "mdl" 93 | rules = ["MD027"] 94 | 95 | [lint.md002] 96 | level = 2 97 | "#}; 98 | let actual: Config = toml::from_str(text).into_diagnostic()?; 99 | let mut expected = Config::default(); 100 | expected.lint.output_format = Format::Mdl; 101 | expected.lint.rules = vec![RuleSet::MD027]; 102 | expected.lint.md002 = MD002 { level: 2 }; 103 | assert_eq!(actual, expected); 104 | Ok(()) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/config/lint/md002.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | 5 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 6 | #[serde(default)] 7 | #[allow(clippy::exhaustive_structs)] 8 | pub struct MD002 { 9 | pub level: u8, 10 | } 11 | 12 | impl Default for MD002 { 13 | #[inline] 14 | fn default() -> Self { 15 | Self { 16 | level: rule::MD002::DEFAULT_LEVEL, 17 | } 18 | } 19 | } 20 | 21 | impl From<&MD002> for rule::MD002 { 22 | #[inline] 23 | fn from(config: &MD002) -> Self { 24 | Self::new(config.level) 25 | } 26 | } 27 | 28 | #[cfg(test)] 29 | mod tests { 30 | use pretty_assertions::assert_eq; 31 | 32 | use super::*; 33 | 34 | #[test] 35 | fn from_for_rule_md002() { 36 | let level = 3; 37 | let config = MD002 { level }; 38 | let expected = rule::MD002::new(level); 39 | assert_eq!(rule::MD002::from(&config), expected); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/config/lint/md003.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | use crate::rule::md003::HeadingStyle; 5 | 6 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 7 | #[serde(default)] 8 | #[allow(clippy::exhaustive_structs)] 9 | pub struct MD003 { 10 | pub style: HeadingStyle, 11 | } 12 | 13 | impl Default for MD003 { 14 | #[inline] 15 | fn default() -> Self { 16 | Self { 17 | style: rule::MD003::DEFAULT_HEADING_STYLE, 18 | } 19 | } 20 | } 21 | 22 | impl From<&MD003> for rule::MD003 { 23 | #[inline] 24 | fn from(config: &MD003) -> Self { 25 | Self::new(config.style.clone()) 26 | } 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use pretty_assertions::assert_eq; 32 | 33 | use super::*; 34 | 35 | #[test] 36 | fn from_for_rule_md003() { 37 | let style = HeadingStyle::SetextWithAtx; 38 | let config = MD003 { 39 | style: style.clone(), 40 | }; 41 | let expected = rule::MD003::new(style); 42 | assert_eq!(rule::MD003::from(&config), expected); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/config/lint/md004.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | use crate::rule::md004::ListStyle; 5 | 6 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 7 | #[serde(default)] 8 | #[allow(clippy::exhaustive_structs)] 9 | pub struct MD004 { 10 | pub style: ListStyle, 11 | } 12 | 13 | impl Default for MD004 { 14 | #[inline] 15 | fn default() -> Self { 16 | Self { 17 | style: rule::MD004::DEFAULT_LIST_STYLE, 18 | } 19 | } 20 | } 21 | 22 | impl From<&MD004> for rule::MD004 { 23 | #[inline] 24 | fn from(config: &MD004) -> Self { 25 | Self::new(config.style.clone()) 26 | } 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use pretty_assertions::assert_eq; 32 | 33 | use super::*; 34 | 35 | #[test] 36 | fn from_for_rule_md004() { 37 | let style = ListStyle::Asterisk; 38 | let config = MD004 { 39 | style: style.clone(), 40 | }; 41 | let expected = rule::MD004::new(style); 42 | assert_eq!(rule::MD004::from(&config), expected); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/config/lint/md007.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | 5 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 6 | #[serde(default)] 7 | #[allow(clippy::exhaustive_structs)] 8 | pub struct MD007 { 9 | pub indent: usize, 10 | } 11 | 12 | impl Default for MD007 { 13 | #[inline] 14 | fn default() -> Self { 15 | Self { 16 | indent: rule::MD007::DEFAULT_INDENT, 17 | } 18 | } 19 | } 20 | 21 | impl From<&MD007> for rule::MD007 { 22 | #[inline] 23 | fn from(config: &MD007) -> Self { 24 | Self::new(config.indent) 25 | } 26 | } 27 | 28 | #[cfg(test)] 29 | mod tests { 30 | use pretty_assertions::assert_eq; 31 | 32 | use super::*; 33 | 34 | #[test] 35 | fn from_for_rule_md007() { 36 | let indent = 5; 37 | let config = MD007 { indent }; 38 | let expected = rule::MD007::new(indent); 39 | assert_eq!(rule::MD007::from(&config), expected); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/config/lint/md013.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | 5 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 6 | #[serde(default, rename_all = "kebab-case")] 7 | #[allow(clippy::exhaustive_structs)] 8 | pub struct MD013 { 9 | pub line_length: usize, 10 | pub code_blocks: bool, 11 | pub tables: bool, 12 | } 13 | 14 | impl Default for MD013 { 15 | #[inline] 16 | fn default() -> Self { 17 | Self { 18 | line_length: rule::MD013::DEFAULT_LINE_LENGTH, 19 | code_blocks: rule::MD013::DEFAULT_CODE_BLOCKS, 20 | tables: rule::MD013::DEFAULT_TABLES, 21 | } 22 | } 23 | } 24 | 25 | impl From<&MD013> for rule::MD013 { 26 | #[inline] 27 | fn from(config: &MD013) -> Self { 28 | Self::new(config.line_length, config.code_blocks, config.tables) 29 | } 30 | } 31 | 32 | #[cfg(test)] 33 | mod tests { 34 | use pretty_assertions::assert_eq; 35 | 36 | use super::*; 37 | 38 | #[test] 39 | fn from_for_rule_md013() { 40 | let line_length = 33; 41 | let code_blocks = true; 42 | let tables = false; 43 | let config = MD013 { 44 | line_length, 45 | code_blocks, 46 | tables, 47 | }; 48 | let expected = rule::MD013::new(line_length, code_blocks, tables); 49 | assert_eq!(rule::MD013::from(&config), expected); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/config/lint/md024.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | 5 | #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] 6 | #[serde(default, rename_all = "kebab-case")] 7 | #[allow(clippy::exhaustive_structs)] 8 | pub struct MD024 { 9 | pub allow_different_nesting: bool, 10 | } 11 | 12 | impl From<&MD024> for rule::MD024 { 13 | #[inline] 14 | fn from(config: &MD024) -> Self { 15 | Self::new(config.allow_different_nesting) 16 | } 17 | } 18 | 19 | #[cfg(test)] 20 | mod tests { 21 | use pretty_assertions::assert_eq; 22 | 23 | use super::*; 24 | 25 | #[test] 26 | fn from_for_rule_md024() { 27 | let allow_different_nesting = true; 28 | let config = MD024 { 29 | allow_different_nesting, 30 | }; 31 | let expected = rule::MD024::new(allow_different_nesting); 32 | assert_eq!(rule::MD024::from(&config), expected); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/config/lint/md025.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | 5 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 6 | #[serde(default, rename_all = "kebab-case")] 7 | #[allow(clippy::exhaustive_structs)] 8 | pub struct MD025 { 9 | pub level: u8, 10 | } 11 | 12 | impl Default for MD025 { 13 | #[inline] 14 | fn default() -> Self { 15 | Self { 16 | level: rule::MD025::DEFAULT_LEVEL, 17 | } 18 | } 19 | } 20 | 21 | impl From<&MD025> for rule::MD025 { 22 | #[inline] 23 | fn from(config: &MD025) -> Self { 24 | Self::new(config.level) 25 | } 26 | } 27 | 28 | #[cfg(test)] 29 | mod tests { 30 | use pretty_assertions::assert_eq; 31 | 32 | use super::*; 33 | 34 | #[test] 35 | fn from_for_rule_md025() { 36 | let level = 3; 37 | let config = MD025 { level }; 38 | let expected = rule::MD025::new(level); 39 | assert_eq!(rule::MD025::from(&config), expected); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/config/lint/md026.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | 5 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 6 | #[serde(default, rename_all = "kebab-case")] 7 | #[allow(clippy::exhaustive_structs)] 8 | pub struct MD026 { 9 | pub punctuation: String, 10 | } 11 | 12 | impl Default for MD026 { 13 | #[inline] 14 | fn default() -> Self { 15 | Self { 16 | punctuation: rule::MD026::DEFAULT_PUNCTUATION.to_owned(), 17 | } 18 | } 19 | } 20 | 21 | impl From<&MD026> for rule::MD026 { 22 | #[inline] 23 | fn from(config: &MD026) -> Self { 24 | Self::new(config.punctuation.clone()) 25 | } 26 | } 27 | 28 | #[cfg(test)] 29 | mod tests { 30 | use pretty_assertions::assert_eq; 31 | 32 | use super::*; 33 | 34 | #[test] 35 | fn from_for_rule_md026() { 36 | let punctuation = "!?".to_owned(); 37 | let config = MD026 { 38 | punctuation: punctuation.clone(), 39 | }; 40 | let expected = rule::MD026::new(punctuation); 41 | assert_eq!(rule::MD026::from(&config), expected); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/config/lint/md029.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | use crate::rule::md029::OrderedListStyle; 5 | 6 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 7 | #[serde(default)] 8 | #[allow(clippy::exhaustive_structs)] 9 | pub struct MD029 { 10 | pub style: OrderedListStyle, 11 | } 12 | 13 | impl Default for MD029 { 14 | #[inline] 15 | fn default() -> Self { 16 | Self { 17 | style: rule::MD029::DEFAULT_STYLE, 18 | } 19 | } 20 | } 21 | 22 | impl From<&MD029> for rule::MD029 { 23 | #[inline] 24 | fn from(config: &MD029) -> Self { 25 | Self::new(config.style.clone()) 26 | } 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use pretty_assertions::assert_eq; 32 | 33 | use super::*; 34 | 35 | #[test] 36 | fn from_for_rule_md029() { 37 | let style = OrderedListStyle::Ordered; 38 | let config = MD029 { 39 | style: style.clone(), 40 | }; 41 | let expected = rule::MD029::new(style); 42 | assert_eq!(rule::MD029::from(&config), expected); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/config/lint/md030.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | 5 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 6 | #[serde(default, rename_all = "kebab-case")] 7 | #[allow(clippy::exhaustive_structs)] 8 | pub struct MD030 { 9 | pub ul_single: usize, 10 | pub ol_single: usize, 11 | pub ul_multi: usize, 12 | pub ol_multi: usize, 13 | } 14 | 15 | impl Default for MD030 { 16 | #[inline] 17 | fn default() -> Self { 18 | Self { 19 | ul_single: rule::MD030::DEFAULT_UL_SINGLE, 20 | ol_single: rule::MD030::DEFAULT_OL_SINGLE, 21 | ul_multi: rule::MD030::DEFAULT_UL_MULTI, 22 | ol_multi: rule::MD030::DEFAULT_OL_MULTI, 23 | } 24 | } 25 | } 26 | 27 | impl From<&MD030> for rule::MD030 { 28 | #[inline] 29 | fn from(config: &MD030) -> Self { 30 | Self::new( 31 | config.ul_single, 32 | config.ol_single, 33 | config.ul_multi, 34 | config.ol_multi, 35 | ) 36 | } 37 | } 38 | 39 | #[cfg(test)] 40 | mod tests { 41 | use pretty_assertions::assert_eq; 42 | 43 | use super::*; 44 | 45 | #[test] 46 | fn from_for_rule_md030() { 47 | let ul_single = 1; 48 | let ol_single = 2; 49 | let ul_multi = 3; 50 | let ol_multi = 4; 51 | let config = MD030 { 52 | ul_single, 53 | ol_single, 54 | ul_multi, 55 | ol_multi, 56 | }; 57 | let expected = rule::MD030::new(ul_single, ol_single, ul_multi, ol_multi); 58 | assert_eq!(rule::MD030::from(&config), expected); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/config/lint/md033.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | 5 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 6 | #[serde(default, rename_all = "kebab-case")] 7 | #[allow(clippy::exhaustive_structs)] 8 | pub struct MD033 { 9 | pub allowed_elements: Vec, 10 | } 11 | 12 | impl Default for MD033 { 13 | #[inline] 14 | fn default() -> Self { 15 | Self { 16 | allowed_elements: rule::MD033::DEFAULT_ALLOWED_ELEMENTS, 17 | } 18 | } 19 | } 20 | 21 | impl From<&MD033> for rule::MD033 { 22 | #[inline] 23 | fn from(config: &MD033) -> Self { 24 | Self::new(&config.allowed_elements) 25 | } 26 | } 27 | 28 | #[cfg(test)] 29 | mod tests { 30 | use pretty_assertions::assert_eq; 31 | 32 | use super::*; 33 | 34 | #[test] 35 | fn from_for_rule_md033() { 36 | let allowed_elements = vec!["br".to_owned()]; 37 | let config = MD033 { 38 | allowed_elements: allowed_elements.clone(), 39 | }; 40 | let expected = rule::MD033::new(&allowed_elements); 41 | assert_eq!(rule::MD033::from(&config), expected); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/config/lint/md035.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | use crate::rule::md035::HorizontalRuleStyle; 5 | 6 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 7 | #[serde(default)] 8 | #[allow(clippy::exhaustive_structs)] 9 | pub struct MD035 { 10 | pub style: HorizontalRuleStyle, 11 | } 12 | 13 | impl Default for MD035 { 14 | #[inline] 15 | fn default() -> Self { 16 | Self { 17 | style: rule::MD035::DEFAULT_STYLE, 18 | } 19 | } 20 | } 21 | 22 | impl From<&MD035> for rule::MD035 { 23 | #[inline] 24 | fn from(config: &MD035) -> Self { 25 | Self::new(config.style.clone()) 26 | } 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use miette::{IntoDiagnostic as _, Result}; 32 | use pretty_assertions::assert_eq; 33 | 34 | use super::*; 35 | 36 | #[test] 37 | fn deserialize_for_horizontal_rule_style_consistent() -> Result<()> { 38 | let text = r#"style = "consistent""#; 39 | let config: MD035 = toml::from_str(text).into_diagnostic()?; 40 | assert_eq!(config.style, HorizontalRuleStyle::Consistent); 41 | Ok(()) 42 | } 43 | 44 | #[test] 45 | fn deserialize_for_horizontal_rule_style_custom() -> Result<()> { 46 | let text = r#"style = "~~~""#; 47 | let config: MD035 = toml::from_str(text).into_diagnostic()?; 48 | assert_eq!(config.style, HorizontalRuleStyle::Custom("~~~".to_owned())); 49 | Ok(()) 50 | } 51 | 52 | #[test] 53 | fn from_for_rule_md035() { 54 | let style = HorizontalRuleStyle::Custom("~~~".to_owned()); 55 | let config = MD035 { 56 | style: style.clone(), 57 | }; 58 | let expected = rule::MD035::new(style); 59 | assert_eq!(rule::MD035::from(&config), expected); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/config/lint/md036.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | 5 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 6 | #[serde(default)] 7 | #[allow(clippy::exhaustive_structs)] 8 | pub struct MD036 { 9 | pub punctuation: String, 10 | } 11 | 12 | impl Default for MD036 { 13 | #[inline] 14 | fn default() -> Self { 15 | Self { 16 | punctuation: rule::MD036::DEFAULT_PUNCTUATION.to_owned(), 17 | } 18 | } 19 | } 20 | 21 | impl From<&MD036> for rule::MD036 { 22 | #[inline] 23 | fn from(config: &MD036) -> Self { 24 | Self::new(config.punctuation.clone()) 25 | } 26 | } 27 | 28 | #[cfg(test)] 29 | mod tests { 30 | use pretty_assertions::assert_eq; 31 | 32 | use super::*; 33 | 34 | #[test] 35 | fn from_for_rule_md036() { 36 | let punctuation = "!?".to_owned(); 37 | let config = MD036 { 38 | punctuation: punctuation.clone(), 39 | }; 40 | let expected = rule::MD036::new(punctuation); 41 | assert_eq!(rule::MD036::from(&config), expected); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/config/lint/md041.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | 5 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 6 | #[serde(default)] 7 | #[allow(clippy::exhaustive_structs)] 8 | pub struct MD041 { 9 | pub level: u8, 10 | } 11 | 12 | impl Default for MD041 { 13 | #[inline] 14 | fn default() -> Self { 15 | Self { 16 | level: rule::MD041::DEFAULT_LEVEL, 17 | } 18 | } 19 | } 20 | 21 | impl From<&MD041> for rule::MD041 { 22 | #[inline] 23 | fn from(config: &MD041) -> Self { 24 | Self::new(config.level) 25 | } 26 | } 27 | 28 | #[cfg(test)] 29 | mod tests { 30 | use pretty_assertions::assert_eq; 31 | 32 | use super::*; 33 | 34 | #[test] 35 | fn from_for_rule_md041() { 36 | let level = 3; 37 | let config = MD041 { level }; 38 | let expected = rule::MD041::new(level); 39 | assert_eq!(rule::MD041::from(&config), expected); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/config/lint/md046.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::rule; 4 | use crate::rule::md046::CodeBlockStyle; 5 | 6 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 7 | #[serde(default)] 8 | #[allow(clippy::exhaustive_structs)] 9 | pub struct MD046 { 10 | pub style: CodeBlockStyle, 11 | } 12 | 13 | impl Default for MD046 { 14 | #[inline] 15 | fn default() -> Self { 16 | Self { 17 | style: rule::MD046::DEFAULT_STYLE, 18 | } 19 | } 20 | } 21 | 22 | impl From<&MD046> for rule::MD046 { 23 | #[inline] 24 | fn from(config: &MD046) -> Self { 25 | Self::new(config.style.clone()) 26 | } 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use pretty_assertions::assert_eq; 32 | 33 | use super::*; 34 | use crate::rule::md046::CodeBlockStyle; 35 | 36 | #[test] 37 | fn from_for_rule_md046() { 38 | let style = CodeBlockStyle::Indented; 39 | let config = MD046 { 40 | style: style.clone(), 41 | }; 42 | let expected = rule::MD046::new(style); 43 | assert_eq!(rule::MD046::from(&config), expected); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/document.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::{Path, PathBuf}; 3 | 4 | use comrak::nodes::{AstNode, NodeValue}; 5 | use comrak::{parse_document, Arena, Options}; 6 | use miette::IntoDiagnostic as _; 7 | use miette::Result; 8 | 9 | #[derive(Debug, Clone)] 10 | #[non_exhaustive] 11 | pub struct Document<'a> { 12 | pub path: PathBuf, 13 | pub ast: &'a AstNode<'a>, 14 | pub text: String, 15 | pub lines: Vec, 16 | } 17 | 18 | impl<'a> Document<'a> { 19 | #[inline] 20 | pub fn new(arena: &'a Arena>, path: PathBuf, text: String) -> Result { 21 | let mut options = Options::default(); 22 | options.extension.front_matter_delimiter = Some("---".to_owned()); 23 | options.extension.table = true; 24 | let ast = parse_document(arena, &text, &options); 25 | let lines: Vec<_> = text.lines().map(ToOwned::to_owned).collect(); 26 | 27 | Ok(Self { 28 | path, 29 | ast, 30 | text, 31 | lines, 32 | }) 33 | } 34 | 35 | #[inline] 36 | pub fn open(arena: &'a Arena>, path: &Path) -> Result { 37 | let text = fs::read_to_string(path).into_diagnostic()?; 38 | Self::new(arena, path.to_path_buf(), text) 39 | } 40 | 41 | #[inline] 42 | #[must_use] 43 | pub fn front_matter(&self) -> Option { 44 | if let Some(node) = self.ast.first_child() { 45 | if let NodeValue::FrontMatter(front_matter) = &node.data.borrow().value { 46 | return Some(front_matter.clone()); 47 | } 48 | } 49 | 50 | None 51 | } 52 | } 53 | 54 | #[cfg(test)] 55 | mod tests { 56 | use indoc::indoc; 57 | use pretty_assertions::assert_eq; 58 | 59 | use super::*; 60 | 61 | #[test] 62 | fn open() { 63 | let arena = Arena::new(); 64 | let path = Path::new("README.md"); 65 | assert!(Document::open(&arena, path).is_ok()); 66 | } 67 | 68 | #[test] 69 | fn front_matter_some() -> Result<()> { 70 | let front_matter = indoc! {" 71 | --- 72 | foo: bar 73 | --- 74 | 75 | "} 76 | .to_owned(); 77 | let text = format!("{front_matter}text"); 78 | let arena = Arena::new(); 79 | let path = Path::new("test.md").to_path_buf(); 80 | let doc = Document::new(&arena, path, text)?; 81 | assert_eq!(doc.front_matter(), Some(front_matter)); 82 | Ok(()) 83 | } 84 | 85 | #[test] 86 | fn front_matter_none() -> Result<()> { 87 | let text = "text".to_owned(); 88 | let arena = Arena::new(); 89 | let path = Path::new("test.md").to_path_buf(); 90 | let doc = Document::new(&arena, path, text)?; 91 | assert_eq!(doc.front_matter(), None); 92 | Ok(()) 93 | } 94 | 95 | #[test] 96 | fn front_matter_empty() -> Result<()> { 97 | let text = String::new(); 98 | let arena = Arena::new(); 99 | let path = Path::new("test.md").to_path_buf(); 100 | let doc = Document::new(&arena, path, text)?; 101 | assert_eq!(doc.front_matter(), None); 102 | Ok(()) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | extern crate pretty_assertions; 3 | 4 | mod cli; 5 | pub mod collection; 6 | pub mod command; 7 | pub mod config; 8 | mod document; 9 | mod output; 10 | pub mod rule; 11 | pub mod service; 12 | mod violation; 13 | 14 | pub use cli::Cli; 15 | pub use command::Command; 16 | pub use config::Config; 17 | pub use document::Document; 18 | pub use rule::Rule; 19 | pub use violation::Violation; 20 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[cfg(all( 2 | not(target_os = "windows"), 3 | not(target_os = "openbsd"), 4 | not(target_os = "aix"), 5 | any( 6 | target_arch = "x86_64", 7 | target_arch = "aarch64", 8 | target_arch = "powerpc64" 9 | ) 10 | ))] 11 | #[global_allocator] 12 | static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; 13 | 14 | #[cfg(target_os = "windows")] 15 | #[global_allocator] 16 | static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; 17 | 18 | use std::process::ExitCode; 19 | 20 | use clap::CommandFactory as _; 21 | use clap::Parser as _; 22 | use mado::command::check::Options; 23 | use miette::Result; 24 | 25 | use mado::command::check::Checker; 26 | use mado::command::generate_shell_completion::ShellCompletionGenerator; 27 | use mado::Cli; 28 | use mado::Command; 29 | 30 | fn main() -> Result { 31 | let cli = Cli::parse(); 32 | 33 | match &cli.command { 34 | Command::Check { 35 | files, 36 | output_format, 37 | quiet, 38 | exclude, 39 | } => { 40 | let options = Options { 41 | output_format: output_format.clone(), 42 | config_path: cli.config, 43 | quiet: *quiet, 44 | exclude: exclude.clone(), 45 | }; 46 | let config = options.to_config()?; 47 | let checker = Checker::new(files, config)?; 48 | checker.check() 49 | } 50 | Command::GenerateShellCompletion { shell } => { 51 | let cmd = Cli::command(); 52 | let mut generator = ShellCompletionGenerator::new(cmd); 53 | generator.generate(*shell); 54 | Ok(ExitCode::SUCCESS) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/output.rs: -------------------------------------------------------------------------------- 1 | use core::cmp::Ordering; 2 | 3 | use clap::ValueEnum; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | mod concise; 7 | mod markdownlint; 8 | mod mdl; 9 | 10 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] 11 | #[serde(rename_all = "lowercase")] 12 | pub enum Format { 13 | Concise, 14 | Mdl, 15 | Markdownlint, 16 | } 17 | 18 | impl Format { 19 | #[inline] 20 | #[must_use] 21 | pub fn sorter(&self) -> fn(a: &Violation, b: &Violation) -> Ordering { 22 | match self { 23 | Self::Concise => |a, b| Concise::new(a).cmp(&Concise::new(b)), 24 | Self::Mdl => |a, b| Mdl::new(a).cmp(&Mdl::new(b)), 25 | Self::Markdownlint => |a, b| Markdownlint::new(a).cmp(&Markdownlint::new(b)), 26 | } 27 | } 28 | } 29 | 30 | pub use concise::Concise; 31 | pub use markdownlint::Markdownlint; 32 | pub use mdl::Mdl; 33 | 34 | use crate::Violation; 35 | 36 | #[cfg(test)] 37 | mod tests { 38 | use std::path::Path; 39 | 40 | use comrak::nodes::Sourcepos; 41 | use pretty_assertions::assert_eq; 42 | 43 | use crate::rule::RuleLike as _; 44 | use crate::rule::{MD001, MD010}; 45 | 46 | use super::*; 47 | 48 | #[allow(clippy::similar_names)] 49 | fn violations() -> Vec { 50 | let path1 = Path::new("foo.md").to_path_buf(); 51 | let path2 = Path::new("bar.md").to_path_buf(); 52 | let md001 = MD001::new(); 53 | let md010 = MD010::new(); 54 | let position0 = Sourcepos::from((1, 1, 1, 1)); 55 | let position1 = Sourcepos::from((1, 2, 1, 2)); 56 | let position2 = Sourcepos::from((2, 1, 2, 1)); 57 | let violation1 = md001.to_violation(path1.clone(), position0); 58 | let violation2 = md001.to_violation(path1.clone(), position1); 59 | let violation3 = md001.to_violation(path1.clone(), position2); 60 | let violation4 = md001.to_violation(path2.clone(), position0); 61 | let violation5 = md001.to_violation(path2.clone(), position1); 62 | let violation6 = md001.to_violation(path2.clone(), position2); 63 | let violation7 = md010.to_violation(path1.clone(), position0); 64 | let violation8 = md010.to_violation(path1.clone(), position1); 65 | let violation9 = md010.to_violation(path1, position2); 66 | let violation10 = md010.to_violation(path2.clone(), position0); 67 | let violation11 = md010.to_violation(path2.clone(), position1); 68 | let violation12 = md010.to_violation(path2, position2); 69 | vec![ 70 | violation1, 71 | violation2, 72 | violation3, 73 | violation4, 74 | violation5, 75 | violation6, 76 | violation7, 77 | violation8, 78 | violation9, 79 | violation10, 80 | violation11, 81 | violation12, 82 | ] 83 | } 84 | 85 | #[test] 86 | fn sorter_concise() { 87 | let violations = violations(); 88 | let mut actual = violations.clone(); 89 | actual.sort_by(Format::Concise.sorter()); 90 | let mut outputs: Vec<_> = violations.iter().map(Concise::new).collect(); 91 | outputs.sort(); 92 | let expected: Vec<_> = outputs.iter().map(|o| o.violation().clone()).collect(); 93 | assert_eq!(actual, expected); 94 | } 95 | 96 | #[test] 97 | fn sorter_mdl() { 98 | let violations = violations(); 99 | let mut actual = violations.clone(); 100 | actual.sort_by(Format::Mdl.sorter()); 101 | let mut outputs: Vec<_> = violations.iter().map(Mdl::new).collect(); 102 | outputs.sort(); 103 | let expected: Vec<_> = outputs.iter().map(|o| o.violation().clone()).collect(); 104 | assert_eq!(actual, expected); 105 | } 106 | 107 | #[test] 108 | fn sorter_markdownlint() { 109 | let violations = violations(); 110 | let mut actual = violations.clone(); 111 | actual.sort_by(Format::Markdownlint.sorter()); 112 | let mut outputs: Vec<_> = violations.iter().map(Markdownlint::new).collect(); 113 | outputs.sort(); 114 | let expected: Vec<_> = outputs.iter().map(|o| o.violation().clone()).collect(); 115 | assert_eq!(actual, expected); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/output/concise.rs: -------------------------------------------------------------------------------- 1 | use core::cmp::Ordering; 2 | use core::fmt::{Display, Error, Formatter, Result}; 3 | 4 | use colored::Colorize as _; 5 | 6 | use crate::Violation; 7 | 8 | #[derive(Debug, Clone, PartialEq, Eq)] 9 | pub struct Concise<'a> { 10 | violation: &'a Violation, 11 | } 12 | 13 | impl<'a> Concise<'a> { 14 | pub const fn new(violation: &'a Violation) -> Self { 15 | Self { violation } 16 | } 17 | 18 | #[cfg(test)] 19 | pub const fn violation(&self) -> &'a Violation { 20 | self.violation 21 | } 22 | } 23 | 24 | impl Display for Concise<'_> { 25 | #[inline] 26 | fn fmt(&self, f: &mut Formatter<'_>) -> Result { 27 | let path = self.violation.path().to_str().ok_or(Error)?; 28 | write!( 29 | f, 30 | "{}{}{}{}{}{} {} {}", 31 | path.bold(), 32 | ":".blue(), 33 | self.violation.position().start.line, 34 | ":".blue(), 35 | self.violation.position().start.column, 36 | ":".blue(), 37 | self.violation.name().red().bold(), 38 | self.violation.description() 39 | ) 40 | } 41 | } 42 | 43 | impl PartialOrd for Concise<'_> { 44 | #[inline] 45 | fn partial_cmp(&self, other: &Self) -> Option { 46 | Some(self.cmp(other)) 47 | } 48 | } 49 | 50 | impl Ord for Concise<'_> { 51 | #[inline] 52 | fn cmp(&self, other: &Self) -> Ordering { 53 | self.violation.cmp(other.violation) 54 | } 55 | } 56 | 57 | #[cfg(test)] 58 | mod tests { 59 | use std::path::Path; 60 | 61 | use comrak::nodes::Sourcepos; 62 | use pretty_assertions::assert_eq; 63 | 64 | use crate::rule::{Metadata, Tag}; 65 | 66 | use super::*; 67 | 68 | const METADATA: Metadata = Metadata { 69 | name: "name", 70 | description: "description", 71 | aliases: &["alias"], 72 | tags: &[Tag::Atx], 73 | }; 74 | 75 | #[test] 76 | fn display_fmt() { 77 | let path = Path::new("file.md").to_path_buf(); 78 | let position = Sourcepos::from((0, 1, 3, 5)); 79 | let violation = Violation::new(path, &METADATA, position); 80 | let actual = Concise::new(&violation).to_string(); 81 | let expected = "\u{1b}[1mfile.md\u{1b}[0m\u{1b}[34m:\u{1b}[0m0\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m \u{1b}[1;31mname\u{1b}[0m description"; 82 | assert_eq!(actual, expected); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/output/markdownlint.rs: -------------------------------------------------------------------------------- 1 | use core::cmp::Ordering; 2 | use core::fmt::{Display, Error, Formatter, Result}; 3 | 4 | use colored::Colorize as _; 5 | 6 | use crate::Violation; 7 | 8 | #[derive(Debug, Clone, PartialEq, Eq)] 9 | pub struct Markdownlint<'a> { 10 | violation: &'a Violation, 11 | } 12 | 13 | impl<'a> Markdownlint<'a> { 14 | pub const fn new(violation: &'a Violation) -> Self { 15 | Self { violation } 16 | } 17 | 18 | #[cfg(test)] 19 | pub const fn violation(&self) -> &'a Violation { 20 | self.violation 21 | } 22 | } 23 | 24 | impl Display for Markdownlint<'_> { 25 | // TODO: Add `expected` and `actual` 26 | #[inline] 27 | fn fmt(&self, f: &mut Formatter<'_>) -> Result { 28 | let path = self.violation.path().to_str().ok_or(Error)?; 29 | write!( 30 | f, 31 | "{}", 32 | format!( 33 | "{}:{}:{} {}/{} {}", 34 | path, 35 | self.violation.position().start.line, 36 | self.violation.position().start.column, 37 | self.violation.name(), 38 | self.violation.alias(), 39 | self.violation.description() 40 | ) 41 | .red() 42 | ) 43 | } 44 | } 45 | 46 | impl PartialOrd for Markdownlint<'_> { 47 | #[inline] 48 | fn partial_cmp(&self, other: &Self) -> Option { 49 | Some(self.cmp(other)) 50 | } 51 | } 52 | 53 | impl Ord for Markdownlint<'_> { 54 | #[inline] 55 | fn cmp(&self, other: &Self) -> Ordering { 56 | self.violation.cmp(other.violation) 57 | } 58 | } 59 | 60 | #[cfg(test)] 61 | mod tests { 62 | use std::path::Path; 63 | 64 | use comrak::nodes::Sourcepos; 65 | use pretty_assertions::assert_eq; 66 | 67 | use crate::rule::{Metadata, Tag}; 68 | 69 | use super::*; 70 | 71 | const METADATA: Metadata = Metadata { 72 | name: "name", 73 | description: "description", 74 | aliases: &["alias"], 75 | tags: &[Tag::Atx], 76 | }; 77 | 78 | #[test] 79 | fn display_fmt() { 80 | let path = Path::new("file.md").to_path_buf(); 81 | let position = Sourcepos::from((0, 1, 3, 5)); 82 | let violation = Violation::new(path, &METADATA, position); 83 | let actual = Markdownlint::new(&violation).to_string(); 84 | let expected = "\u{1b}[31mfile.md:0:1 name/alias description\u{1b}[0m"; 85 | assert_eq!(actual, expected); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/output/mdl.rs: -------------------------------------------------------------------------------- 1 | use core::cmp::Ordering; 2 | use core::fmt::{Display, Error, Formatter, Result}; 3 | 4 | use colored::Colorize as _; 5 | 6 | use crate::Violation; 7 | 8 | #[derive(Debug, Clone, PartialEq, Eq)] 9 | pub struct Mdl<'a> { 10 | violation: &'a Violation, 11 | } 12 | 13 | impl<'a> Mdl<'a> { 14 | pub const fn new(violation: &'a Violation) -> Self { 15 | Self { violation } 16 | } 17 | 18 | #[cfg(test)] 19 | pub const fn violation(&self) -> &'a Violation { 20 | self.violation 21 | } 22 | } 23 | 24 | impl Display for Mdl<'_> { 25 | #[inline] 26 | fn fmt(&self, f: &mut Formatter<'_>) -> Result { 27 | let path = self.violation.path().to_str().ok_or(Error)?; 28 | write!( 29 | f, 30 | "{}{}{}{} {} {}", 31 | path.bold(), 32 | ":".blue(), 33 | self.violation.position().start.line, 34 | ":".blue(), 35 | self.violation.name().red().bold(), 36 | self.violation.description() 37 | ) 38 | } 39 | } 40 | 41 | impl PartialOrd for Mdl<'_> { 42 | #[inline] 43 | fn partial_cmp(&self, other: &Self) -> Option { 44 | Some(self.cmp(other)) 45 | } 46 | } 47 | 48 | impl Ord for Mdl<'_> { 49 | #[inline] 50 | fn cmp(&self, other: &Self) -> Ordering { 51 | let path_cmp = self.violation.path().cmp(other.violation.path()); 52 | if path_cmp != Ordering::Equal { 53 | return path_cmp; 54 | } 55 | 56 | let name_cmp = self.violation.name().cmp(other.violation.name()); 57 | if name_cmp != Ordering::Equal { 58 | return name_cmp; 59 | } 60 | 61 | self.violation 62 | .position() 63 | .start 64 | .cmp(&other.violation.position().start) 65 | } 66 | } 67 | 68 | #[cfg(test)] 69 | mod tests { 70 | use std::path::Path; 71 | 72 | use comrak::nodes::Sourcepos; 73 | use pretty_assertions::assert_eq; 74 | 75 | use crate::rule::{Metadata, Tag}; 76 | 77 | use super::*; 78 | 79 | const METADATA: Metadata = Metadata { 80 | name: "name", 81 | description: "description", 82 | aliases: &["alias"], 83 | tags: &[Tag::Atx], 84 | }; 85 | 86 | #[test] 87 | fn display_fmt() { 88 | let path = Path::new("file.md").to_path_buf(); 89 | let position = Sourcepos::from((0, 1, 3, 5)); 90 | let violation = Violation::new(path, &METADATA, position); 91 | let actual = Mdl::new(&violation).to_string(); 92 | let expected = "\u{1b}[1mfile.md\u{1b}[0m\u{1b}[34m:\u{1b}[0m0\u{1b}[34m:\u{1b}[0m \u{1b}[1;31mname\u{1b}[0m description"; 93 | assert_eq!(actual, expected); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/rule/helper.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::{AstNode, NodeValue}; 2 | 3 | pub fn inline_text_of<'a>(root: &'a AstNode<'a>) -> String { 4 | let texts: Vec = root 5 | .descendants() 6 | .filter_map(|node| match node.data.borrow().value.clone() { 7 | NodeValue::Text(text) => Some(text), 8 | NodeValue::Code(code) => Some(format!("`{}`", code.literal)), 9 | _ => None, 10 | }) 11 | .collect(); 12 | 13 | texts.join("") 14 | } 15 | 16 | #[cfg(test)] 17 | mod tests { 18 | use comrak::{parse_document, Arena, Options}; 19 | use miette::{Context as _, Result}; 20 | use pretty_assertions::assert_eq; 21 | 22 | use super::*; 23 | 24 | // TODO: Test more inline nodes 25 | #[test] 26 | fn test_inline_text_of() -> Result<()> { 27 | let text = "# Heading with `code`, [link](http://example.com) and **bold**"; 28 | let arena = Arena::new(); 29 | let ast = parse_document(&arena, text, &Options::default()); 30 | let heading = ast 31 | .first_child() 32 | .wrap_err("failed to get the first child")?; 33 | let actual = inline_text_of(heading); 34 | let expected = "Heading with `code`, link and bold"; 35 | assert_eq!(actual, expected); 36 | Ok(()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/rule/md001.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::NodeValue; 2 | use miette::Result; 3 | 4 | use crate::{violation::Violation, Document}; 5 | 6 | use super::{Metadata, RuleLike, Tag}; 7 | 8 | #[derive(Default, Debug, Clone, PartialEq, Eq)] 9 | #[non_exhaustive] 10 | pub struct MD001; 11 | 12 | impl MD001 { 13 | const METADATA: Metadata = Metadata { 14 | name: "MD001", 15 | description: "Header levels should only increment by one level at a time", 16 | tags: &[Tag::Headers], 17 | aliases: &["header-increment"], 18 | }; 19 | 20 | #[inline] 21 | #[must_use] 22 | pub const fn new() -> Self { 23 | Self {} 24 | } 25 | } 26 | 27 | impl RuleLike for MD001 { 28 | #[inline] 29 | fn metadata(&self) -> &'static Metadata { 30 | &Self::METADATA 31 | } 32 | 33 | #[inline] 34 | fn check(&self, doc: &Document) -> Result> { 35 | let mut violations = vec![]; 36 | let mut maybe_prev_level = None; 37 | 38 | for node in doc.ast.children() { 39 | if let NodeValue::Heading(heading) = node.data.borrow().value { 40 | if let Some(prev_level) = maybe_prev_level { 41 | if heading.level > prev_level + 1 { 42 | let position = node.data.borrow().sourcepos; 43 | let violation = self.to_violation(doc.path.clone(), position); 44 | violations.push(violation); 45 | } 46 | } 47 | 48 | maybe_prev_level = Some(heading.level); 49 | } 50 | } 51 | 52 | Ok(violations) 53 | } 54 | } 55 | 56 | #[cfg(test)] 57 | mod tests { 58 | use std::path::Path; 59 | 60 | use comrak::{nodes::Sourcepos, Arena}; 61 | use indoc::indoc; 62 | use pretty_assertions::assert_eq; 63 | 64 | use super::*; 65 | 66 | #[test] 67 | fn check_errors() -> Result<()> { 68 | let text = indoc! {" 69 | # Header 1 70 | 71 | ### Header 3 72 | 73 | ## Another Header 2 74 | 75 | #### Header 4 76 | "} 77 | .to_owned(); 78 | let path = Path::new("test.md").to_path_buf(); 79 | let arena = Arena::new(); 80 | let doc = Document::new(&arena, path.clone(), text)?; 81 | let rule = MD001::new(); 82 | let actual = rule.check(&doc)?; 83 | let expected = vec![ 84 | rule.to_violation(path.clone(), Sourcepos::from((3, 1, 3, 12))), 85 | rule.to_violation(path, Sourcepos::from((7, 1, 7, 13))), 86 | ]; 87 | assert_eq!(actual, expected); 88 | Ok(()) 89 | } 90 | 91 | #[test] 92 | fn check_no_errors() -> Result<()> { 93 | let text = indoc! {" 94 | # Header 1 95 | 96 | ## Header 2 97 | 98 | ### Header 3 99 | 100 | #### Header 4 101 | 102 | ## Another Header 2 103 | 104 | ### Another Header 3 105 | "} 106 | .to_owned(); 107 | let path = Path::new("test.md").to_path_buf(); 108 | let arena = Arena::new(); 109 | let doc = Document::new(&arena, path, text)?; 110 | let rule = MD001::new(); 111 | let actual = rule.check(&doc)?; 112 | let expected = vec![]; 113 | assert_eq!(actual, expected); 114 | Ok(()) 115 | } 116 | 117 | #[test] 118 | fn check_no_errors_no_top_level() -> Result<()> { 119 | let text = "## This isn't a H1 header".to_owned(); 120 | let path = Path::new("test.md").to_path_buf(); 121 | let arena = Arena::new(); 122 | let doc = Document::new(&arena, path, text)?; 123 | let rule = MD001::new(); 124 | let actual = rule.check(&doc)?; 125 | let expected = vec![]; 126 | assert_eq!(actual, expected); 127 | Ok(()) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/rule/md002.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::NodeValue; 2 | use miette::Result; 3 | 4 | use crate::violation::Violation; 5 | use crate::Document; 6 | 7 | use super::{Metadata, RuleLike, Tag}; 8 | 9 | #[derive(Debug, Clone, PartialEq, Eq)] 10 | #[non_exhaustive] 11 | pub struct MD002 { 12 | pub level: u8, 13 | } 14 | 15 | impl MD002 { 16 | const METADATA: Metadata = Metadata { 17 | name: "MD002", 18 | description: "First header should be a top level header", 19 | tags: &[Tag::Headers], 20 | aliases: &["first-header-h1"], 21 | }; 22 | 23 | pub const DEFAULT_LEVEL: u8 = 1; 24 | 25 | #[inline] 26 | #[must_use] 27 | pub const fn new(level: u8) -> Self { 28 | Self { level } 29 | } 30 | } 31 | 32 | impl Default for MD002 { 33 | #[inline] 34 | fn default() -> Self { 35 | Self { 36 | level: Self::DEFAULT_LEVEL, 37 | } 38 | } 39 | } 40 | 41 | impl RuleLike for MD002 { 42 | #[inline] 43 | fn metadata(&self) -> &'static Metadata { 44 | &Self::METADATA 45 | } 46 | 47 | #[inline] 48 | fn check(&self, doc: &Document) -> Result> { 49 | for node in doc.ast.children() { 50 | if let NodeValue::Heading(heading) = node.data.borrow().value { 51 | if heading.level != self.level { 52 | let position = node.data.borrow().sourcepos; 53 | let violation = self.to_violation(doc.path.clone(), position); 54 | 55 | return Ok(vec![violation]); 56 | } 57 | 58 | break; 59 | } 60 | } 61 | 62 | Ok(vec![]) 63 | } 64 | } 65 | 66 | #[cfg(test)] 67 | mod tests { 68 | use std::path::Path; 69 | 70 | use comrak::{nodes::Sourcepos, Arena}; 71 | use indoc::indoc; 72 | use pretty_assertions::assert_eq; 73 | 74 | use super::*; 75 | 76 | #[test] 77 | fn check_errors() -> Result<()> { 78 | let text = indoc! {" 79 | ## This isn't a H1 header 80 | 81 | ### Another header 82 | "} 83 | .to_owned(); 84 | let path = Path::new("test.md").to_path_buf(); 85 | let arena = Arena::new(); 86 | let doc = Document::new(&arena, path.clone(), text)?; 87 | let rule = MD002::default(); 88 | let actual = rule.check(&doc)?; 89 | let expected = vec![rule.to_violation(path, Sourcepos::from((1, 1, 1, 25)))]; 90 | assert_eq!(actual, expected); 91 | Ok(()) 92 | } 93 | 94 | #[test] 95 | fn check_errors_with_level() -> Result<()> { 96 | let text = indoc! {" 97 | # Start with a H1 header 98 | 99 | ## Then use a H2 for subsections 100 | "} 101 | .to_owned(); 102 | let path = Path::new("test.md").to_path_buf(); 103 | let arena = Arena::new(); 104 | let doc = Document::new(&arena, path.clone(), text)?; 105 | let rule = MD002::new(2); 106 | let actual = rule.check(&doc)?; 107 | let expected = vec![rule.to_violation(path, Sourcepos::from((1, 1, 1, 24)))]; 108 | assert_eq!(actual, expected); 109 | Ok(()) 110 | } 111 | 112 | #[test] 113 | fn check_no_errors() -> Result<()> { 114 | let text = indoc! {" 115 | # Start with a H1 header 116 | 117 | ## Then use a H2 for subsections 118 | "} 119 | .to_owned(); 120 | let path = Path::new("test.md").to_path_buf(); 121 | let arena = Arena::new(); 122 | let doc = Document::new(&arena, path, text)?; 123 | let rule = MD002::default(); 124 | let actual = rule.check(&doc)?; 125 | let expected = vec![]; 126 | assert_eq!(actual, expected); 127 | Ok(()) 128 | } 129 | 130 | #[test] 131 | fn check_no_errors_with_level() -> Result<()> { 132 | let text = indoc! {" 133 | ## This isn't a H1 header 134 | 135 | ### Another header 136 | "} 137 | .to_owned(); 138 | let path = Path::new("test.md").to_path_buf(); 139 | let arena = Arena::new(); 140 | let doc = Document::new(&arena, path, text)?; 141 | let rule = MD002::new(2); 142 | let actual = rule.check(&doc)?; 143 | let expected = vec![]; 144 | assert_eq!(actual, expected); 145 | Ok(()) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/rule/md006.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::{ListType, NodeList, NodeValue}; 2 | use miette::Result; 3 | 4 | use crate::{violation::Violation, Document}; 5 | 6 | use super::{Metadata, RuleLike, Tag}; 7 | 8 | #[derive(Default, Debug, Clone, PartialEq, Eq)] 9 | #[non_exhaustive] 10 | pub struct MD006; 11 | 12 | impl MD006 { 13 | const METADATA: Metadata = Metadata { 14 | name: "MD006", 15 | description: "Consider starting bulleted lists at the beginning of the line", 16 | tags: &[Tag::Bullet, Tag::Ul, Tag::Indentation], 17 | aliases: &["ul-start-left"], 18 | }; 19 | 20 | #[inline] 21 | #[must_use] 22 | pub const fn new() -> Self { 23 | Self {} 24 | } 25 | } 26 | 27 | impl RuleLike for MD006 { 28 | #[inline] 29 | fn metadata(&self) -> &'static Metadata { 30 | &Self::METADATA 31 | } 32 | 33 | #[inline] 34 | fn check(&self, doc: &Document) -> Result> { 35 | let mut violations = vec![]; 36 | 37 | for node in doc.ast.children() { 38 | if let NodeValue::List(NodeList { 39 | list_type: ListType::Bullet, 40 | .. 41 | }) = node.data.borrow().value 42 | { 43 | for item_node in node.children() { 44 | if let NodeValue::Item(item) = item_node.data.borrow().value { 45 | if item.marker_offset > 0 { 46 | let position = item_node.data.borrow().sourcepos; 47 | let violation = self.to_violation(doc.path.clone(), position); 48 | violations.push(violation); 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | Ok(violations) 56 | } 57 | } 58 | 59 | #[cfg(test)] 60 | mod tests { 61 | use std::path::Path; 62 | 63 | use comrak::{nodes::Sourcepos, Arena}; 64 | use indoc::indoc; 65 | use pretty_assertions::assert_eq; 66 | 67 | use super::*; 68 | 69 | #[test] 70 | fn check_errors() -> Result<()> { 71 | let text = indoc! {" 72 | Some text 73 | 74 | * List item 75 | * List item 76 | "} 77 | .to_owned(); 78 | let path = Path::new("test.md").to_path_buf(); 79 | let arena = Arena::new(); 80 | let doc = Document::new(&arena, path.clone(), text)?; 81 | let rule = MD006::new(); 82 | let actual = rule.check(&doc)?; 83 | let expected = vec![ 84 | rule.to_violation(path.clone(), Sourcepos::from((3, 3, 3, 13))), 85 | rule.to_violation(path, Sourcepos::from((4, 3, 4, 13))), 86 | ]; 87 | assert_eq!(actual, expected); 88 | Ok(()) 89 | } 90 | 91 | #[test] 92 | fn check_no_errors() -> Result<()> { 93 | let text = indoc! {" 94 | Some test 95 | 96 | * List item 97 | * List item 98 | "} 99 | .to_owned(); 100 | let path = Path::new("test.md").to_path_buf(); 101 | let arena = Arena::new(); 102 | let doc = Document::new(&arena, path, text)?; 103 | let rule = MD006::new(); 104 | let actual = rule.check(&doc)?; 105 | let expected = vec![]; 106 | assert_eq!(actual, expected); 107 | Ok(()) 108 | } 109 | 110 | #[test] 111 | fn check_no_errors_with_ordered_list() -> Result<()> { 112 | let text = indoc! {" 113 | Some test 114 | 115 | 1. Ordered list item 116 | 2. Ordered list item 117 | "} 118 | .to_owned(); 119 | let path = Path::new("test.md").to_path_buf(); 120 | let arena = Arena::new(); 121 | let doc = Document::new(&arena, path, text)?; 122 | let rule = MD006::new(); 123 | let actual = rule.check(&doc)?; 124 | let expected = vec![]; 125 | assert_eq!(actual, expected); 126 | Ok(()) 127 | } 128 | 129 | #[test] 130 | fn check_no_errors_with_nested_list() -> Result<()> { 131 | let text = indoc! { " 132 | * List 133 | * List item 134 | * List item 135 | "} 136 | .to_owned(); 137 | let path = Path::new("test.md").to_path_buf(); 138 | let arena = Arena::new(); 139 | let doc = Document::new(&arena, path, text)?; 140 | let rule = MD006::new(); 141 | let actual = rule.check(&doc)?; 142 | let expected = vec![]; 143 | assert_eq!(actual, expected); 144 | Ok(()) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/rule/md009.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::Sourcepos; 2 | use miette::Result; 3 | 4 | use crate::{violation::Violation, Document}; 5 | 6 | use super::{Metadata, RuleLike, Tag}; 7 | 8 | #[derive(Debug, Clone, Default, PartialEq, Eq)] 9 | #[non_exhaustive] 10 | pub struct MD009; 11 | 12 | impl MD009 { 13 | const METADATA: Metadata = Metadata { 14 | name: "MD009", 15 | description: "Trailing spaces", 16 | tags: &[Tag::Whitespace], 17 | aliases: &["no-trailing-spaces"], 18 | }; 19 | 20 | #[inline] 21 | #[must_use] 22 | pub const fn new() -> Self { 23 | Self {} 24 | } 25 | } 26 | 27 | impl RuleLike for MD009 { 28 | #[inline] 29 | fn metadata(&self) -> &'static Metadata { 30 | &Self::METADATA 31 | } 32 | 33 | #[inline] 34 | fn check(&self, doc: &Document) -> Result> { 35 | let mut violations = vec![]; 36 | for (i, line) in doc.lines.iter().enumerate() { 37 | let trimmed_line = line.trim_end_matches(' '); 38 | if trimmed_line != line { 39 | let lineno = i + 1; 40 | let position = 41 | Sourcepos::from((lineno, trimmed_line.len() + 1, lineno, line.len())); 42 | let violation = self.to_violation(doc.path.clone(), position); 43 | violations.push(violation); 44 | } 45 | } 46 | 47 | Ok(violations) 48 | } 49 | } 50 | 51 | #[cfg(test)] 52 | mod tests { 53 | use std::path::Path; 54 | 55 | use comrak::Arena; 56 | use indoc::indoc; 57 | use pretty_assertions::assert_eq; 58 | 59 | use super::*; 60 | 61 | #[test] 62 | fn check_errors() -> Result<()> { 63 | let text = indoc! {" 64 | Text with a trailing space 65 | And text with some trailing spaces 66 | "} 67 | .to_owned(); 68 | let path = Path::new("test.md").to_path_buf(); 69 | let arena = Arena::new(); 70 | let doc = Document::new(&arena, path.clone(), text)?; 71 | let rule = MD009::new(); 72 | let actual = rule.check(&doc)?; 73 | let expected = vec![ 74 | rule.to_violation(path.clone(), Sourcepos::from((1, 27, 1, 27))), 75 | rule.to_violation(path, Sourcepos::from((2, 35, 2, 37))), 76 | ]; 77 | assert_eq!(actual, expected); 78 | Ok(()) 79 | } 80 | 81 | #[test] 82 | fn check_no_errors() -> Result<()> { 83 | let text = "Text with no trailing spaces".to_owned(); 84 | let path = Path::new("test.md").to_path_buf(); 85 | let arena = Arena::new(); 86 | let doc = Document::new(&arena, path, text)?; 87 | let rule = MD009::new(); 88 | let actual = rule.check(&doc)?; 89 | let expected = vec![]; 90 | assert_eq!(actual, expected); 91 | Ok(()) 92 | } 93 | 94 | #[test] 95 | fn check_no_errors_full_with_space() -> Result<()> { 96 | let text = "Text with no trailing spaces ".to_owned(); 97 | let path = Path::new("test.md").to_path_buf(); 98 | let arena = Arena::new(); 99 | let doc = Document::new(&arena, path, text)?; 100 | let rule = MD009::new(); 101 | let actual = rule.check(&doc)?; 102 | let expected = vec![]; 103 | assert_eq!(actual, expected); 104 | Ok(()) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/rule/md010.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::Sourcepos; 2 | use miette::Result; 3 | 4 | use crate::violation::Violation; 5 | use crate::Document; 6 | 7 | use super::{Metadata, RuleLike, Tag}; 8 | 9 | #[derive(Default, Debug, Clone, PartialEq, Eq)] 10 | #[non_exhaustive] 11 | pub struct MD010; 12 | 13 | impl MD010 { 14 | const METADATA: Metadata = Metadata { 15 | name: "MD010", 16 | description: "Hard tabs", 17 | tags: &[Tag::Whitespace, Tag::HardTab], 18 | aliases: &["no-hard-tabs"], 19 | }; 20 | 21 | #[inline] 22 | #[must_use] 23 | pub const fn new() -> Self { 24 | Self {} 25 | } 26 | } 27 | 28 | impl RuleLike for MD010 { 29 | #[inline] 30 | fn metadata(&self) -> &'static Metadata { 31 | &Self::METADATA 32 | } 33 | 34 | #[inline] 35 | fn check(&self, doc: &Document) -> Result> { 36 | let mut violations = vec![]; 37 | for (i, line) in doc.lines.iter().enumerate() { 38 | let lineno = i + 1; 39 | if let Some(idx) = line.find('\t') { 40 | let position = Sourcepos::from((lineno, idx + 1, lineno, idx + 1)); 41 | let violation = self.to_violation(doc.path.clone(), position); 42 | violations.push(violation); 43 | } 44 | } 45 | 46 | Ok(violations) 47 | } 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use std::path::Path; 53 | 54 | use comrak::Arena; 55 | use indoc::indoc; 56 | use pretty_assertions::assert_eq; 57 | 58 | use super::*; 59 | 60 | #[test] 61 | fn check_errors() -> Result<()> { 62 | let text = indoc! {" 63 | Some text 64 | 65 | * hard tab character used to indent the list item 66 | "} 67 | .to_owned(); 68 | let path = Path::new("test.md").to_path_buf(); 69 | let arena = Arena::new(); 70 | let doc = Document::new(&arena, path.clone(), text)?; 71 | let rule = MD010::new(); 72 | let actual = rule.check(&doc)?; 73 | let expected = vec![rule.to_violation(path, Sourcepos::from((3, 1, 3, 1)))]; 74 | assert_eq!(actual, expected); 75 | Ok(()) 76 | } 77 | 78 | #[test] 79 | fn check_no_errors() -> Result<()> { 80 | let text = indoc! {" 81 | Some text 82 | 83 | * Spaces used to indent the list item instead 84 | "} 85 | .to_owned(); 86 | let path = Path::new("test.md").to_path_buf(); 87 | let arena = Arena::new(); 88 | let doc = Document::new(&arena, path, text)?; 89 | let rule = MD010::new(); 90 | let actual = rule.check(&doc)?; 91 | let expected = vec![]; 92 | assert_eq!(actual, expected); 93 | Ok(()) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/rule/md012.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::{NodeValue, Sourcepos}; 2 | use miette::Result; 3 | 4 | use crate::{collection::RangeSet, violation::Violation, Document}; 5 | 6 | use super::{Metadata, RuleLike, Tag}; 7 | 8 | #[derive(Default, Debug, Clone, PartialEq, Eq)] 9 | #[non_exhaustive] 10 | pub struct MD012; 11 | 12 | impl MD012 { 13 | const METADATA: Metadata = Metadata { 14 | name: "MD012", 15 | description: "Multiple consecutive blank lines", 16 | tags: &[Tag::Whitespace, Tag::BlankLines], 17 | aliases: &["no-multiple-blanks"], 18 | }; 19 | 20 | #[inline] 21 | #[must_use] 22 | pub const fn new() -> Self { 23 | Self {} 24 | } 25 | } 26 | 27 | impl RuleLike for MD012 { 28 | #[inline] 29 | fn metadata(&self) -> &'static Metadata { 30 | &Self::METADATA 31 | } 32 | 33 | // TODO: Improve codes 34 | #[inline] 35 | fn check(&self, doc: &Document) -> Result> { 36 | let mut violations = vec![]; 37 | let mut maybe_prev_line: Option<&str> = None; 38 | let mut code_block_ranges = RangeSet::new(); 39 | 40 | for node in doc.ast.descendants() { 41 | if let NodeValue::CodeBlock(_) = node.data.borrow().value { 42 | let position = node.data.borrow().sourcepos; 43 | let range = position.start.line..=position.end.line; 44 | code_block_ranges.insert(range); 45 | } 46 | } 47 | 48 | for (i, line) in doc.lines.iter().enumerate() { 49 | let lineno = i + 1; 50 | 51 | if let Some(prev_line) = maybe_prev_line { 52 | if prev_line.is_empty() && line.is_empty() && !code_block_ranges.contains(&lineno) { 53 | let position = Sourcepos::from((lineno, 1, lineno, 1)); 54 | let violation = self.to_violation(doc.path.clone(), position); 55 | violations.push(violation); 56 | } 57 | } 58 | 59 | maybe_prev_line = Some(line); 60 | } 61 | 62 | Ok(violations) 63 | } 64 | } 65 | 66 | #[cfg(test)] 67 | mod tests { 68 | use std::path::Path; 69 | 70 | use comrak::Arena; 71 | use indoc::indoc; 72 | use pretty_assertions::assert_eq; 73 | 74 | use super::*; 75 | 76 | #[test] 77 | fn check_errors() -> Result<()> { 78 | let text = indoc! {" 79 | Some text here 80 | 81 | 82 | Some more text here 83 | "} 84 | .to_owned(); 85 | let path = Path::new("test.md").to_path_buf(); 86 | let arena = Arena::new(); 87 | let doc = Document::new(&arena, path.clone(), text)?; 88 | let rule = MD012::new(); 89 | let actual = rule.check(&doc)?; 90 | let expected = vec![rule.to_violation(path, Sourcepos::from((3, 1, 3, 1)))]; 91 | assert_eq!(actual, expected); 92 | Ok(()) 93 | } 94 | 95 | #[test] 96 | fn check_errors_with_front_matter() -> Result<()> { 97 | let text = indoc! {" 98 | --- 99 | foo: 100 | --- 101 | 102 | 103 | Some text 104 | "} 105 | .to_owned(); 106 | let path = Path::new("test.md").to_path_buf(); 107 | let arena = Arena::new(); 108 | let doc = Document::new(&arena, path.clone(), text)?; 109 | let rule = MD012::new(); 110 | let actual = rule.check(&doc)?; 111 | let expected = vec![rule.to_violation(path, Sourcepos::from((5, 1, 5, 1)))]; 112 | assert_eq!(actual, expected); 113 | Ok(()) 114 | } 115 | 116 | #[test] 117 | fn check_no_errors() -> Result<()> { 118 | let text = indoc! {" 119 | Some text here 120 | 121 | Some more text here 122 | "} 123 | .to_owned(); 124 | let path = Path::new("test.md").to_path_buf(); 125 | let arena = Arena::new(); 126 | let doc = Document::new(&arena, path, text)?; 127 | let rule = MD012::new(); 128 | let actual = rule.check(&doc)?; 129 | let expected = vec![]; 130 | assert_eq!(actual, expected); 131 | Ok(()) 132 | } 133 | 134 | #[test] 135 | fn check_no_errors_with_code_block() -> Result<()> { 136 | let text = indoc! {" 137 | Some text here 138 | 139 | ``` 140 | This is a code block 141 | 142 | 143 | Some code here 144 | ``` 145 | 146 | Some more text here 147 | "} 148 | .to_owned(); 149 | let path = Path::new("test.md").to_path_buf(); 150 | let arena = Arena::new(); 151 | let doc = Document::new(&arena, path, text)?; 152 | let rule = MD012::new(); 153 | let actual = rule.check(&doc)?; 154 | let expected = vec![]; 155 | assert_eq!(actual, expected); 156 | Ok(()) 157 | } 158 | 159 | #[test] 160 | fn check_no_errors_with_nested_code_block() -> Result<()> { 161 | let text = indoc! {" 162 | * List 163 | 164 | ``` 165 | This is a code block 166 | 167 | 168 | Some code here 169 | ``` 170 | "} 171 | .to_owned(); 172 | let path = Path::new("test.md").to_path_buf(); 173 | let arena = Arena::new(); 174 | let doc = Document::new(&arena, path, text)?; 175 | let rule = MD012::new(); 176 | let actual = rule.check(&doc)?; 177 | let expected = vec![]; 178 | assert_eq!(actual, expected); 179 | Ok(()) 180 | } 181 | 182 | #[test] 183 | fn check_no_errors_with_front_matter_and_code_block() -> Result<()> { 184 | let text = indoc! {" 185 | --- 186 | foo: 187 | bar: 188 | baz: 189 | qux: 190 | --- 191 | 192 | Some text here 193 | 194 | ``` 195 | This is a code block 196 | 197 | 198 | Some code here 199 | ``` 200 | 201 | Some more text here 202 | "} 203 | .to_owned(); 204 | let path = Path::new("test.md").to_path_buf(); 205 | let arena = Arena::new(); 206 | let doc = Document::new(&arena, path, text)?; 207 | let rule = MD012::new(); 208 | let actual = rule.check(&doc)?; 209 | let expected = vec![]; 210 | assert_eq!(actual, expected); 211 | Ok(()) 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/rule/md018.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::NodeValue; 2 | use miette::Result; 3 | 4 | use crate::violation::Violation; 5 | use crate::Document; 6 | 7 | use super::{Metadata, RuleLike, Tag}; 8 | 9 | #[derive(Default, Debug, Clone, PartialEq, Eq)] 10 | #[non_exhaustive] 11 | pub struct MD018; 12 | 13 | impl MD018 { 14 | const METADATA: Metadata = Metadata { 15 | name: "MD018", 16 | description: "No space after hash on atx style header", 17 | tags: &[Tag::Headers, Tag::Atx, Tag::Spaces], 18 | aliases: &["no-missing-space-atx"], 19 | }; 20 | 21 | #[inline] 22 | #[must_use] 23 | pub const fn new() -> Self { 24 | Self {} 25 | } 26 | } 27 | 28 | impl RuleLike for MD018 { 29 | #[inline] 30 | fn metadata(&self) -> &'static Metadata { 31 | &Self::METADATA 32 | } 33 | 34 | #[inline] 35 | fn check(&self, doc: &Document) -> Result> { 36 | let mut violations = vec![]; 37 | 38 | for node in doc.ast.children() { 39 | if node.data.borrow().value == NodeValue::Paragraph { 40 | for child_node in node.children() { 41 | if let NodeValue::Text(text) = &child_node.data.borrow().value { 42 | let position = node.data.borrow().sourcepos; 43 | if position.start.column == 1 44 | && text.starts_with('#') 45 | && !text.ends_with('#') 46 | { 47 | let violation = self.to_violation(doc.path.clone(), position); 48 | violations.push(violation); 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | Ok(violations) 56 | } 57 | } 58 | 59 | #[cfg(test)] 60 | mod tests { 61 | use std::path::Path; 62 | 63 | use comrak::{nodes::Sourcepos, Arena}; 64 | use indoc::indoc; 65 | use pretty_assertions::assert_eq; 66 | 67 | use super::*; 68 | 69 | #[test] 70 | fn check_errors() -> Result<()> { 71 | let text = indoc! {" 72 | #Header 1 73 | 74 | ##Header 2 75 | "} 76 | .to_owned(); 77 | let path = Path::new("test.md").to_path_buf(); 78 | let arena = Arena::new(); 79 | let doc = Document::new(&arena, path.clone(), text)?; 80 | let rule = MD018::default(); 81 | let actual = rule.check(&doc)?; 82 | let expected = vec![ 83 | rule.to_violation(path.clone(), Sourcepos::from((1, 1, 1, 9))), 84 | rule.to_violation(path, Sourcepos::from((3, 1, 3, 10))), 85 | ]; 86 | assert_eq!(actual, expected); 87 | Ok(()) 88 | } 89 | 90 | #[test] 91 | fn check_no_errors() -> Result<()> { 92 | let text = indoc! {" 93 | # Header 1 94 | 95 | ## Header 2 96 | "} 97 | .to_owned(); 98 | let path = Path::new("test.md").to_path_buf(); 99 | let arena = Arena::new(); 100 | let doc = Document::new(&arena, path, text)?; 101 | let rule = MD018::default(); 102 | let actual = rule.check(&doc)?; 103 | let expected = vec![]; 104 | assert_eq!(actual, expected); 105 | Ok(()) 106 | } 107 | 108 | #[test] 109 | fn check_no_errors_with_atx_closed() -> Result<()> { 110 | let text = indoc! {" 111 | #Header 1# 112 | 113 | ## Header 2## 114 | 115 | ##Header 3 ## 116 | "} 117 | .to_owned(); 118 | let path = Path::new("test.md").to_path_buf(); 119 | let arena = Arena::new(); 120 | let doc = Document::new(&arena, path, text)?; 121 | let rule = MD018::default(); 122 | let actual = rule.check(&doc)?; 123 | let expected = vec![]; 124 | assert_eq!(actual, expected); 125 | Ok(()) 126 | } 127 | 128 | #[test] 129 | fn check_no_errors_with_issue_number() -> Result<()> { 130 | let text = indoc! {" 131 | # Header 1 132 | 133 | See [#4649](https://example.com) for details. 134 | "} 135 | .to_owned(); 136 | let path = Path::new("test.md").to_path_buf(); 137 | let arena = Arena::new(); 138 | let doc = Document::new(&arena, path, text)?; 139 | let rule = MD018::default(); 140 | let actual = rule.check(&doc)?; 141 | let expected = vec![]; 142 | assert_eq!(actual, expected); 143 | Ok(()) 144 | } 145 | 146 | #[test] 147 | fn check_no_errors_with_list() -> Result<()> { 148 | let text = "* #Header 1".to_owned(); 149 | let path = Path::new("test.md").to_path_buf(); 150 | let arena = Arena::new(); 151 | let doc = Document::new(&arena, path, text)?; 152 | let rule = MD018::default(); 153 | let actual = rule.check(&doc)?; 154 | let expected = vec![]; 155 | assert_eq!(actual, expected); 156 | Ok(()) 157 | } 158 | 159 | #[test] 160 | fn check_no_errors_with_code_block_comment() -> Result<()> { 161 | let text = indoc! {" 162 | ``` 163 | #Header 164 | ``` 165 | "} 166 | .to_owned(); 167 | let path = Path::new("test.md").to_path_buf(); 168 | let arena = Arena::new(); 169 | let doc = Document::new(&arena, path, text)?; 170 | let rule = MD018::new(); 171 | let actual = rule.check(&doc)?; 172 | let expected = vec![]; 173 | assert_eq!(actual, expected); 174 | Ok(()) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/rule/md019.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::{NodeHeading, NodeValue}; 2 | use miette::Result; 3 | 4 | use crate::violation::Violation; 5 | use crate::Document; 6 | 7 | use super::{Metadata, RuleLike, Tag}; 8 | 9 | #[derive(Default, Debug, Clone, PartialEq, Eq)] 10 | #[non_exhaustive] 11 | pub struct MD019; 12 | 13 | impl MD019 { 14 | const METADATA: Metadata = Metadata { 15 | name: "MD019", 16 | description: "Multiple spaces after hash on atx style header", 17 | tags: &[Tag::Headers, Tag::Atx, Tag::Spaces], 18 | aliases: &["no-multiple-space-atx"], 19 | }; 20 | 21 | #[inline] 22 | #[must_use] 23 | pub const fn new() -> Self { 24 | Self {} 25 | } 26 | } 27 | 28 | impl RuleLike for MD019 { 29 | #[inline] 30 | fn metadata(&self) -> &'static Metadata { 31 | &Self::METADATA 32 | } 33 | 34 | #[inline] 35 | fn check(&self, doc: &Document) -> Result> { 36 | let mut violations = vec![]; 37 | 38 | for node in doc.ast.children() { 39 | if let NodeValue::Heading(NodeHeading { 40 | setext: false, 41 | level, 42 | .. 43 | }) = &node.data.borrow().value 44 | { 45 | if let (Some(first_node), Some(last_node)) = (node.first_child(), node.last_child()) 46 | { 47 | let heading_position = node.data.borrow().sourcepos; 48 | let first_position = first_node.data.borrow().sourcepos; 49 | let last_position = last_node.data.borrow().sourcepos; 50 | let is_atx = heading_position.end.column == last_position.end.column; 51 | 52 | let expected_offset = (*level as usize) + 1; 53 | if is_atx 54 | && ((heading_position.start.column 55 | < first_position.start.column - expected_offset) 56 | || (heading_position.end.column 57 | > last_position.end.column + expected_offset)) 58 | { 59 | let violation = self.to_violation(doc.path.clone(), heading_position); 60 | violations.push(violation); 61 | } 62 | } 63 | } 64 | } 65 | 66 | Ok(violations) 67 | } 68 | } 69 | 70 | #[cfg(test)] 71 | mod tests { 72 | use std::path::Path; 73 | 74 | use comrak::{nodes::Sourcepos, Arena}; 75 | use indoc::indoc; 76 | use pretty_assertions::assert_eq; 77 | 78 | use super::*; 79 | 80 | #[test] 81 | fn check_errors() -> Result<()> { 82 | let text = indoc! {" 83 | # Header 1 84 | 85 | ## Header 2 86 | "} 87 | .to_owned(); 88 | let path = Path::new("test.md").to_path_buf(); 89 | let arena = Arena::new(); 90 | let doc = Document::new(&arena, path.clone(), text)?; 91 | let rule = MD019::new(); 92 | let actual = rule.check(&doc)?; 93 | let expected = vec![ 94 | rule.to_violation(path.clone(), Sourcepos::from((1, 1, 1, 11))), 95 | rule.to_violation(path, Sourcepos::from((3, 1, 3, 12))), 96 | ]; 97 | assert_eq!(actual, expected); 98 | Ok(()) 99 | } 100 | 101 | #[test] 102 | fn check_no_errors() -> Result<()> { 103 | let text = indoc! {" 104 | # Header 1 105 | 106 | ## Header 2 107 | "} 108 | .to_owned(); 109 | let path = Path::new("test.md").to_path_buf(); 110 | let arena = Arena::new(); 111 | let doc = Document::new(&arena, path, text)?; 112 | let rule = MD019::new(); 113 | let actual = rule.check(&doc)?; 114 | let expected = vec![]; 115 | assert_eq!(actual, expected); 116 | Ok(()) 117 | } 118 | 119 | #[test] 120 | fn check_no_errors_with_atx_closed() -> Result<()> { 121 | let text = indoc! {" 122 | # Header 1 # 123 | 124 | ## Header 2 ## 125 | 126 | ## Header 3 ## 127 | "} 128 | .to_owned(); 129 | let path = Path::new("test.md").to_path_buf(); 130 | let arena = Arena::new(); 131 | let doc = Document::new(&arena, path, text)?; 132 | let rule = MD019::new(); 133 | let actual = rule.check(&doc)?; 134 | let expected = vec![]; 135 | assert_eq!(actual, expected); 136 | Ok(()) 137 | } 138 | 139 | #[test] 140 | fn check_no_errors_with_setext() -> Result<()> { 141 | let text = indoc! {" 142 | Header 1 143 | ========== 144 | "} 145 | .to_owned(); 146 | let path = Path::new("test.md").to_path_buf(); 147 | let arena = Arena::new(); 148 | let doc = Document::new(&arena, path, text)?; 149 | let rule = MD019::new(); 150 | let actual = rule.check(&doc)?; 151 | let expected = vec![]; 152 | assert_eq!(actual, expected); 153 | Ok(()) 154 | } 155 | 156 | #[test] 157 | fn check_no_errors_for_multiple_children() -> Result<()> { 158 | let text = "# Header with `code` and text".to_owned(); 159 | let path = Path::new("test.md").to_path_buf(); 160 | let arena = Arena::new(); 161 | let doc = Document::new(&arena, path, text)?; 162 | let rule = MD019::new(); 163 | let actual = rule.check(&doc)?; 164 | let expected = vec![]; 165 | assert_eq!(actual, expected); 166 | Ok(()) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/rule/md021.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::{NodeHeading, NodeValue}; 2 | use miette::Result; 3 | 4 | use crate::violation::Violation; 5 | use crate::Document; 6 | 7 | use super::{Metadata, RuleLike, Tag}; 8 | 9 | #[derive(Default, Debug, Clone, PartialEq, Eq)] 10 | #[non_exhaustive] 11 | pub struct MD021; 12 | 13 | impl MD021 { 14 | const METADATA: Metadata = Metadata { 15 | name: "MD021", 16 | description: "Multiple spaces inside hashes on closed atx style header", 17 | tags: &[Tag::Headers, Tag::AtxClosed, Tag::Spaces], 18 | aliases: &["no-multiple-space-closed-atx"], 19 | }; 20 | 21 | #[inline] 22 | #[must_use] 23 | pub const fn new() -> Self { 24 | Self {} 25 | } 26 | } 27 | 28 | impl RuleLike for MD021 { 29 | #[inline] 30 | fn metadata(&self) -> &'static Metadata { 31 | &Self::METADATA 32 | } 33 | 34 | #[inline] 35 | fn check(&self, doc: &Document) -> Result> { 36 | let mut violations = vec![]; 37 | 38 | for node in doc.ast.children() { 39 | if let NodeValue::Heading(NodeHeading { 40 | setext: false, 41 | level, 42 | .. 43 | }) = &node.data.borrow().value 44 | { 45 | if let (Some(first_node), Some(last_node)) = (node.first_child(), node.last_child()) 46 | { 47 | let heading_position = node.data.borrow().sourcepos; 48 | let first_position = first_node.data.borrow().sourcepos; 49 | let last_position = last_node.data.borrow().sourcepos; 50 | let is_atx_closed = heading_position.end.column > last_position.end.column; 51 | 52 | let expected_offset = (*level as usize) + 1; 53 | if is_atx_closed 54 | && ((heading_position.start.column 55 | < first_position.start.column - expected_offset) 56 | || (heading_position.end.column 57 | > last_position.end.column + expected_offset)) 58 | { 59 | let violation = self.to_violation(doc.path.clone(), heading_position); 60 | violations.push(violation); 61 | } 62 | } 63 | } 64 | } 65 | 66 | Ok(violations) 67 | } 68 | } 69 | 70 | #[cfg(test)] 71 | mod tests { 72 | use std::path::Path; 73 | 74 | use comrak::{nodes::Sourcepos, Arena}; 75 | use indoc::indoc; 76 | use pretty_assertions::assert_eq; 77 | 78 | use super::*; 79 | 80 | #[test] 81 | fn check_errors() -> Result<()> { 82 | let text = indoc! {" 83 | # Header 1 # 84 | 85 | ## Header 2 ## 86 | 87 | ## Header 3 ## 88 | "} 89 | .to_owned(); 90 | let path = Path::new("test.md").to_path_buf(); 91 | let arena = Arena::new(); 92 | let doc = Document::new(&arena, path.clone(), text)?; 93 | let rule = MD021::new(); 94 | let actual = rule.check(&doc)?; 95 | let expected = vec![ 96 | rule.to_violation(path.clone(), Sourcepos::from((1, 1, 1, 14))), 97 | rule.to_violation(path.clone(), Sourcepos::from((3, 1, 3, 15))), 98 | rule.to_violation(path, Sourcepos::from((5, 1, 5, 15))), 99 | ]; 100 | assert_eq!(actual, expected); 101 | Ok(()) 102 | } 103 | 104 | #[test] 105 | fn check_no_errors() -> Result<()> { 106 | let text = indoc! {" 107 | # Header 1 # 108 | 109 | ## Header 2 ## 110 | "} 111 | .to_owned(); 112 | let path = Path::new("test.md").to_path_buf(); 113 | let arena = Arena::new(); 114 | let doc = Document::new(&arena, path, text)?; 115 | let rule = MD021::new(); 116 | let actual = rule.check(&doc)?; 117 | let expected = vec![]; 118 | assert_eq!(actual, expected); 119 | Ok(()) 120 | } 121 | 122 | #[test] 123 | fn check_no_errors_with_escaped_hash() -> Result<()> { 124 | let text = indoc! {" 125 | # Header 1 \\# 126 | 127 | \\## Header 2 ## 128 | 129 | ## Header 3 \\## 130 | 131 | \\## Header 4 ## 132 | 133 | ## Header 5 \\## 134 | 135 | \\## Header 6 ## 136 | "} 137 | .to_owned(); 138 | let path = Path::new("test.md").to_path_buf(); 139 | let arena = Arena::new(); 140 | let doc = Document::new(&arena, path, text)?; 141 | let rule = MD021::default(); 142 | let actual = rule.check(&doc)?; 143 | let expected = vec![]; 144 | assert_eq!(actual, expected); 145 | Ok(()) 146 | } 147 | 148 | #[test] 149 | fn check_no_errors_with_atx() -> Result<()> { 150 | let text = indoc! {" 151 | # Header 1 152 | 153 | ## Header 2 154 | "} 155 | .to_owned(); 156 | let path = Path::new("test.md").to_path_buf(); 157 | let arena = Arena::new(); 158 | let doc = Document::new(&arena, path, text)?; 159 | let rule = MD021::new(); 160 | let actual = rule.check(&doc)?; 161 | let expected = vec![]; 162 | assert_eq!(actual, expected); 163 | Ok(()) 164 | } 165 | 166 | #[test] 167 | fn check_no_errors_with_setext() -> Result<()> { 168 | let text = indoc! {" 169 | Header 1 170 | ========== 171 | "} 172 | .to_owned(); 173 | let path = Path::new("test.md").to_path_buf(); 174 | let arena = Arena::new(); 175 | let doc = Document::new(&arena, path, text)?; 176 | let rule = MD021::new(); 177 | let actual = rule.check(&doc)?; 178 | let expected = vec![]; 179 | assert_eq!(actual, expected); 180 | Ok(()) 181 | } 182 | 183 | #[test] 184 | fn check_no_errors_for_multiple_children() -> Result<()> { 185 | let text = "# Header with `code` and text #".to_owned(); 186 | let path = Path::new("test.md").to_path_buf(); 187 | let arena = Arena::new(); 188 | let doc = Document::new(&arena, path, text)?; 189 | let rule = MD021::new(); 190 | let actual = rule.check(&doc)?; 191 | let expected = vec![]; 192 | assert_eq!(actual, expected); 193 | Ok(()) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/rule/md022.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::NodeValue; 2 | use miette::Result; 3 | 4 | use crate::{violation::Violation, Document}; 5 | 6 | use super::{Metadata, RuleLike, Tag}; 7 | 8 | #[derive(Default, Debug, Clone, PartialEq, Eq)] 9 | #[non_exhaustive] 10 | pub struct MD022; 11 | 12 | impl MD022 { 13 | const METADATA: Metadata = Metadata { 14 | name: "MD022", 15 | description: "Headers should be surrounded by blank lines", 16 | tags: &[Tag::Headers, Tag::BlankLines], 17 | aliases: &["blanks-around-headers"], 18 | }; 19 | 20 | #[inline] 21 | #[must_use] 22 | pub const fn new() -> Self { 23 | Self {} 24 | } 25 | } 26 | 27 | impl RuleLike for MD022 { 28 | #[inline] 29 | fn metadata(&self) -> &'static Metadata { 30 | &Self::METADATA 31 | } 32 | 33 | #[inline] 34 | fn check(&self, doc: &Document) -> Result> { 35 | let mut violations = vec![]; 36 | 37 | for node in doc.ast.children() { 38 | if let Some(prev_node) = node.previous_sibling() { 39 | let prev_position = prev_node.data.borrow().sourcepos; 40 | let position = node.data.borrow().sourcepos; 41 | 42 | match (&prev_node.data.borrow().value, &node.data.borrow().value) { 43 | (NodeValue::Heading(_), _) => { 44 | if position.start.line == prev_position.end.line + 1 { 45 | let violation = self.to_violation(doc.path.clone(), prev_position); 46 | violations.push(violation); 47 | } 48 | } 49 | (_, NodeValue::Heading(_)) => { 50 | // NOTE: Ignore column 0, as the List may end on the next line 51 | if position.start.line == prev_position.end.line + 1 52 | && prev_position.end.column != 0 53 | { 54 | let violation = self.to_violation(doc.path.clone(), position); 55 | violations.push(violation); 56 | } 57 | } 58 | _ => {} 59 | } 60 | } 61 | } 62 | 63 | Ok(violations) 64 | } 65 | } 66 | 67 | #[cfg(test)] 68 | mod tests { 69 | use std::path::Path; 70 | 71 | use comrak::{nodes::Sourcepos, Arena}; 72 | use indoc::indoc; 73 | use pretty_assertions::assert_eq; 74 | 75 | use super::*; 76 | 77 | #[test] 78 | fn check_errors_for_atx() -> Result<()> { 79 | let text = indoc! {" 80 | # Header 1 81 | Some text 82 | 83 | Some more text 84 | ## Header 2 85 | "} 86 | .to_owned(); 87 | let path = Path::new("test.md").to_path_buf(); 88 | let arena = Arena::new(); 89 | let doc = Document::new(&arena, path.clone(), text)?; 90 | let rule = MD022::new(); 91 | let actual = rule.check(&doc)?; 92 | let expected = vec![ 93 | rule.to_violation(path.clone(), Sourcepos::from((1, 1, 1, 10))), 94 | rule.to_violation(path, Sourcepos::from((5, 1, 5, 11))), 95 | ]; 96 | assert_eq!(actual, expected); 97 | Ok(()) 98 | } 99 | 100 | #[test] 101 | fn check_errors_for_setext() -> Result<()> { 102 | let text = indoc! {" 103 | Setext style H1 104 | =============== 105 | Some text 106 | 107 | ``` 108 | Some code block 109 | ``` 110 | Setext style H2 111 | --------------- 112 | "} 113 | .to_owned(); 114 | let path = Path::new("test.md").to_path_buf(); 115 | let arena = Arena::new(); 116 | let doc = Document::new(&arena, path.clone(), text)?; 117 | let rule = MD022::new(); 118 | let actual = rule.check(&doc)?; 119 | let expected = vec![ 120 | rule.to_violation(path.clone(), Sourcepos::from((1, 1, 2, 15))), 121 | rule.to_violation(path, Sourcepos::from((8, 1, 9, 15))), 122 | ]; 123 | assert_eq!(actual, expected); 124 | Ok(()) 125 | } 126 | 127 | #[test] 128 | fn check_no_errors() -> Result<()> { 129 | let text = indoc! {" 130 | # Header 1 131 | 132 | Some text 133 | 134 | Some more text 135 | 136 | ## Header 2 137 | "} 138 | .to_owned(); 139 | let path = Path::new("test.md").to_path_buf(); 140 | let arena = Arena::new(); 141 | let doc = Document::new(&arena, path, text)?; 142 | let rule = MD022::new(); 143 | let actual = rule.check(&doc)?; 144 | let expected = vec![]; 145 | assert_eq!(actual, expected); 146 | Ok(()) 147 | } 148 | 149 | #[test] 150 | fn check_no_errors_for_setext() -> Result<()> { 151 | let text = indoc! {" 152 | Setext style H1 153 | =============== 154 | 155 | Some text 156 | 157 | ``` 158 | Some code block 159 | ``` 160 | 161 | Setext style H2 162 | --------------- 163 | "} 164 | .to_owned(); 165 | let path = Path::new("test.md").to_path_buf(); 166 | let arena = Arena::new(); 167 | let doc = Document::new(&arena, path, text)?; 168 | let rule = MD022::new(); 169 | let actual = rule.check(&doc)?; 170 | let expected = vec![]; 171 | assert_eq!(actual, expected); 172 | Ok(()) 173 | } 174 | 175 | #[test] 176 | fn check_no_errors_for_list() -> Result<()> { 177 | let text = indoc! {" 178 | # Header 1 179 | 180 | - Some list item 181 | - Some more list item 182 | 183 | ## Header 2 184 | "} 185 | .to_owned(); 186 | let path = Path::new("test.md").to_path_buf(); 187 | let arena = Arena::new(); 188 | let doc = Document::new(&arena, path, text)?; 189 | let rule = MD022::new(); 190 | let actual = rule.check(&doc)?; 191 | let expected = vec![]; 192 | assert_eq!(actual, expected); 193 | Ok(()) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/rule/md023.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::NodeValue; 2 | use miette::Result; 3 | 4 | use crate::{violation::Violation, Document}; 5 | 6 | use super::{Metadata, RuleLike, Tag}; 7 | 8 | #[derive(Default, Debug, Clone, PartialEq, Eq)] 9 | #[non_exhaustive] 10 | pub struct MD023; 11 | 12 | impl MD023 { 13 | const METADATA: Metadata = Metadata { 14 | name: "MD023", 15 | description: "Headers must start at the beginning of the line", 16 | tags: &[Tag::Headers, Tag::Spaces], 17 | aliases: &["header-start-left"], 18 | }; 19 | 20 | #[inline] 21 | #[must_use] 22 | pub const fn new() -> Self { 23 | Self {} 24 | } 25 | } 26 | 27 | impl RuleLike for MD023 { 28 | #[inline] 29 | fn metadata(&self) -> &'static Metadata { 30 | &Self::METADATA 31 | } 32 | 33 | #[inline] 34 | fn check(&self, doc: &Document) -> Result> { 35 | let mut violations = vec![]; 36 | 37 | for node in doc.ast.children() { 38 | if let NodeValue::Heading(_) = node.data.borrow().value { 39 | let position = node.data.borrow().sourcepos; 40 | if position.start.column > 1 { 41 | let violation = self.to_violation(doc.path.clone(), position); 42 | violations.push(violation); 43 | } 44 | } 45 | } 46 | 47 | Ok(violations) 48 | } 49 | } 50 | 51 | #[cfg(test)] 52 | mod tests { 53 | use std::path::Path; 54 | 55 | use comrak::{nodes::Sourcepos, Arena}; 56 | use indoc::indoc; 57 | use pretty_assertions::assert_eq; 58 | 59 | use super::*; 60 | 61 | #[test] 62 | fn check_errors() -> Result<()> { 63 | let text = indoc! {" 64 | Some text 65 | 66 | # Indented header 67 | "} 68 | .to_owned(); 69 | let path = Path::new("test.md").to_path_buf(); 70 | let arena = Arena::new(); 71 | let doc = Document::new(&arena, path.clone(), text)?; 72 | let rule = MD023::new(); 73 | let actual = rule.check(&doc)?; 74 | let expected = vec![rule.to_violation(path, Sourcepos::from((3, 3, 3, 19)))]; 75 | assert_eq!(actual, expected); 76 | Ok(()) 77 | } 78 | 79 | #[test] 80 | fn check_no_errors() -> Result<()> { 81 | let text = indoc! {" 82 | Some text 83 | 84 | # Header 85 | "} 86 | .to_owned(); 87 | let path = Path::new("test.md").to_path_buf(); 88 | let arena = Arena::new(); 89 | let doc = Document::new(&arena, path, text)?; 90 | let rule = MD023::new(); 91 | let actual = rule.check(&doc)?; 92 | let expected = vec![]; 93 | assert_eq!(actual, expected); 94 | Ok(()) 95 | } 96 | 97 | #[test] 98 | fn check_no_errors_with_indented_code_block_comment() -> Result<()> { 99 | let text = indoc! {" 100 | Some text 101 | 102 | ``` 103 | # Header 104 | ``` 105 | "} 106 | .to_owned(); 107 | let path = Path::new("test.md").to_path_buf(); 108 | let arena = Arena::new(); 109 | let doc = Document::new(&arena, path, text)?; 110 | let rule = MD023::new(); 111 | let actual = rule.check(&doc)?; 112 | let expected = vec![]; 113 | assert_eq!(actual, expected); 114 | Ok(()) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/rule/md024.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::NodeValue; 2 | use miette::Result; 3 | use rustc_hash::FxHashMap; 4 | 5 | use crate::{violation::Violation, Document}; 6 | 7 | use super::{helper::inline_text_of, Metadata, RuleLike, Tag}; 8 | 9 | #[derive(Default, Debug, Clone, PartialEq, Eq)] 10 | #[non_exhaustive] 11 | pub struct MD024 { 12 | allow_different_nesting: bool, 13 | } 14 | 15 | impl MD024 { 16 | const METADATA: Metadata = Metadata { 17 | name: "MD024", 18 | description: "Multiple headers with the same content", 19 | tags: &[Tag::Headers], 20 | aliases: &["no-duplicate-header"], 21 | }; 22 | 23 | #[inline] 24 | #[must_use] 25 | pub const fn new(allow_different_nesting: bool) -> Self { 26 | Self { 27 | allow_different_nesting, 28 | } 29 | } 30 | } 31 | 32 | impl RuleLike for MD024 { 33 | #[inline] 34 | fn metadata(&self) -> &'static Metadata { 35 | &Self::METADATA 36 | } 37 | 38 | #[inline] 39 | fn check(&self, doc: &Document) -> Result> { 40 | let mut violations = vec![]; 41 | let mut contents: FxHashMap> = FxHashMap::default(); 42 | 43 | for node in doc.ast.children() { 44 | if let NodeValue::Heading(heading) = &node.data.borrow().value { 45 | let text = inline_text_of(node); 46 | if let Some(levels) = contents.get_mut(&text) { 47 | let is_different_nesting = levels.len() == 1 && levels.contains(&heading.level); 48 | if !self.allow_different_nesting || !is_different_nesting { 49 | let position = node.data.borrow().sourcepos; 50 | let violation = self.to_violation(doc.path.clone(), position); 51 | violations.push(violation); 52 | } 53 | 54 | if !levels.contains(&heading.level) { 55 | levels.push(heading.level); 56 | } 57 | } else { 58 | contents.insert(text.clone(), vec![heading.level]); 59 | } 60 | } 61 | } 62 | 63 | Ok(violations) 64 | } 65 | } 66 | 67 | #[cfg(test)] 68 | mod tests { 69 | use std::path::Path; 70 | 71 | use comrak::{nodes::Sourcepos, Arena}; 72 | use indoc::indoc; 73 | use pretty_assertions::assert_eq; 74 | 75 | use super::*; 76 | 77 | #[test] 78 | fn check_errors_false() -> Result<()> { 79 | let text = indoc! {" 80 | # A 81 | 82 | ## A 83 | 84 | ## B 85 | 86 | ### C 87 | 88 | ## D 89 | 90 | ### C 91 | 92 | ## E 93 | 94 | #### C 95 | "} 96 | .to_owned(); 97 | let path = Path::new("test.md").to_path_buf(); 98 | let arena = Arena::new(); 99 | let doc = Document::new(&arena, path.clone(), text)?; 100 | let rule = MD024::default(); 101 | let actual = rule.check(&doc)?; 102 | let expected = vec![ 103 | rule.to_violation(path.clone(), Sourcepos::from((3, 1, 3, 4))), 104 | rule.to_violation(path.clone(), Sourcepos::from((11, 1, 11, 5))), 105 | rule.to_violation(path, Sourcepos::from((15, 1, 15, 6))), 106 | ]; 107 | assert_eq!(actual, expected); 108 | Ok(()) 109 | } 110 | 111 | #[test] 112 | fn check_errors_true() -> Result<()> { 113 | let text = indoc! {" 114 | # A 115 | 116 | ## A 117 | 118 | ## B 119 | 120 | ### C 121 | 122 | ## D 123 | 124 | ### C 125 | 126 | ## E 127 | 128 | #### C 129 | "} 130 | .to_owned(); 131 | let path = Path::new("test.md").to_path_buf(); 132 | let arena = Arena::new(); 133 | let doc = Document::new(&arena, path.clone(), text)?; 134 | let rule = MD024::new(true); 135 | let actual = rule.check(&doc)?; 136 | let expected = vec![ 137 | rule.to_violation(path.clone(), Sourcepos::from((3, 1, 3, 4))), 138 | rule.to_violation(path, Sourcepos::from((15, 1, 15, 6))), 139 | ]; 140 | assert_eq!(actual, expected); 141 | Ok(()) 142 | } 143 | 144 | #[test] 145 | fn check_no_errors_false() -> Result<()> { 146 | let text = indoc! {" 147 | # A 148 | 149 | ## B 150 | 151 | ## C 152 | 153 | ### D 154 | 155 | ## E 156 | 157 | ### F 158 | 159 | ## G 160 | 161 | #### H 162 | "} 163 | .to_owned(); 164 | let path = Path::new("test.md").to_path_buf(); 165 | let arena = Arena::new(); 166 | let doc = Document::new(&arena, path, text)?; 167 | let rule = MD024::default(); 168 | let actual = rule.check(&doc)?; 169 | let expected = vec![]; 170 | assert_eq!(actual, expected); 171 | Ok(()) 172 | } 173 | 174 | #[test] 175 | fn check_no_errors_true() -> Result<()> { 176 | let text = indoc! {" 177 | # A 178 | 179 | ## B 180 | 181 | ## C 182 | 183 | ### D 184 | 185 | ## E 186 | 187 | ### D 188 | 189 | ## F 190 | 191 | #### G 192 | "} 193 | .to_owned(); 194 | let path = Path::new("test.md").to_path_buf(); 195 | let arena = Arena::new(); 196 | let doc = Document::new(&arena, path, text)?; 197 | let rule = MD024::new(true); 198 | let actual = rule.check(&doc)?; 199 | let expected = vec![]; 200 | assert_eq!(actual, expected); 201 | Ok(()) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/rule/md025.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::NodeValue; 2 | use miette::Result; 3 | 4 | use crate::{violation::Violation, Document}; 5 | 6 | use super::{Metadata, RuleLike, Tag}; 7 | 8 | #[derive(Debug, Clone, PartialEq, Eq)] 9 | #[non_exhaustive] 10 | pub struct MD025 { 11 | level: u8, 12 | } 13 | 14 | impl MD025 { 15 | const METADATA: Metadata = Metadata { 16 | name: "MD025", 17 | description: "Multiple top level headers in the same document", 18 | tags: &[Tag::Headers], 19 | aliases: &["single-h1"], 20 | }; 21 | 22 | pub const DEFAULT_LEVEL: u8 = 1; 23 | 24 | #[inline] 25 | #[must_use] 26 | pub const fn new(level: u8) -> Self { 27 | Self { level } 28 | } 29 | } 30 | 31 | impl Default for MD025 { 32 | #[inline] 33 | fn default() -> Self { 34 | Self { 35 | level: Self::DEFAULT_LEVEL, 36 | } 37 | } 38 | } 39 | 40 | impl RuleLike for MD025 { 41 | #[inline] 42 | fn metadata(&self) -> &'static Metadata { 43 | &Self::METADATA 44 | } 45 | 46 | #[inline] 47 | fn check(&self, doc: &Document) -> Result> { 48 | let mut violations = vec![]; 49 | let mut seen_top_level_header = false; 50 | 51 | for node in doc.ast.children() { 52 | if let NodeValue::Heading(heading) = node.data.borrow().value { 53 | if heading.level == self.level { 54 | if seen_top_level_header { 55 | let position = node.data.borrow().sourcepos; 56 | let violation = self.to_violation(doc.path.clone(), position); 57 | violations.push(violation); 58 | } else { 59 | seen_top_level_header = true; 60 | } 61 | } 62 | } 63 | } 64 | 65 | Ok(violations) 66 | } 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use std::path::Path; 72 | 73 | use comrak::{nodes::Sourcepos, Arena}; 74 | use indoc::indoc; 75 | use pretty_assertions::assert_eq; 76 | 77 | use super::*; 78 | 79 | #[test] 80 | fn check_errors() -> Result<()> { 81 | let text = indoc! {" 82 | # Top level header 83 | 84 | # Another top level header 85 | "} 86 | .to_owned(); 87 | let path = Path::new("test.md").to_path_buf(); 88 | let arena = Arena::new(); 89 | let doc = Document::new(&arena, path.clone(), text)?; 90 | let rule = MD025::default(); 91 | let actual = rule.check(&doc)?; 92 | let expected = vec![rule.to_violation(path, Sourcepos::from((3, 1, 3, 26)))]; 93 | assert_eq!(actual, expected); 94 | Ok(()) 95 | } 96 | 97 | #[test] 98 | fn check_errors_with_level() -> Result<()> { 99 | let text = indoc! {" 100 | ## Top level header 101 | 102 | ## Another top level header 103 | "} 104 | .to_owned(); 105 | let path = Path::new("test.md").to_path_buf(); 106 | let arena = Arena::new(); 107 | let doc = Document::new(&arena, path.clone(), text)?; 108 | let rule = MD025::new(2); 109 | let actual = rule.check(&doc)?; 110 | let expected = vec![rule.to_violation(path, Sourcepos::from((3, 1, 3, 27)))]; 111 | assert_eq!(actual, expected); 112 | Ok(()) 113 | } 114 | 115 | #[test] 116 | fn check_no_errors() -> Result<()> { 117 | let text = indoc! {" 118 | # Title 119 | 120 | ## Header 121 | 122 | ## Another header 123 | "} 124 | .to_owned(); 125 | let path = Path::new("test.md").to_path_buf(); 126 | let arena = Arena::new(); 127 | let doc = Document::new(&arena, path, text)?; 128 | let rule = MD025::default(); 129 | let actual = rule.check(&doc)?; 130 | let expected = vec![]; 131 | assert_eq!(actual, expected); 132 | Ok(()) 133 | } 134 | 135 | #[test] 136 | fn check_no_errors_with_level() -> Result<()> { 137 | let text = indoc! {" 138 | ## Title 139 | 140 | ### Header 141 | 142 | ### Another header 143 | "} 144 | .to_owned(); 145 | let path = Path::new("test.md").to_path_buf(); 146 | let arena = Arena::new(); 147 | let doc = Document::new(&arena, path, text)?; 148 | let rule = MD025::new(2); 149 | let actual = rule.check(&doc)?; 150 | let expected = vec![]; 151 | assert_eq!(actual, expected); 152 | Ok(()) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/rule/md026.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::NodeValue; 2 | use miette::Result; 3 | 4 | use crate::{violation::Violation, Document}; 5 | 6 | use super::{Metadata, RuleLike, Tag}; 7 | 8 | #[derive(Debug, Clone, PartialEq, Eq)] 9 | #[non_exhaustive] 10 | pub struct MD026 { 11 | punctuation: String, 12 | } 13 | 14 | impl MD026 { 15 | const METADATA: Metadata = Metadata { 16 | name: "MD026", 17 | description: "Trailing punctuation in header", 18 | tags: &[Tag::Headers], 19 | aliases: &["no-trailing-punctuation"], 20 | }; 21 | 22 | pub const DEFAULT_PUNCTUATION: &str = ".,;:!?"; 23 | 24 | #[inline] 25 | #[must_use] 26 | pub const fn new(punctuation: String) -> Self { 27 | Self { punctuation } 28 | } 29 | } 30 | 31 | impl Default for MD026 { 32 | #[inline] 33 | fn default() -> Self { 34 | Self { 35 | punctuation: Self::DEFAULT_PUNCTUATION.to_owned(), 36 | } 37 | } 38 | } 39 | 40 | impl RuleLike for MD026 { 41 | #[inline] 42 | fn metadata(&self) -> &'static Metadata { 43 | &Self::METADATA 44 | } 45 | 46 | #[inline] 47 | fn check(&self, doc: &Document) -> Result> { 48 | let mut violations = vec![]; 49 | 50 | for node in doc.ast.children() { 51 | if let NodeValue::Heading(_) = node.data.borrow().value { 52 | if let Some(child) = node.last_child() { 53 | if let NodeValue::Text(text) = &child.data.borrow().value { 54 | if let Some(last_char) = text.chars().last() { 55 | if self.punctuation.contains(last_char) { 56 | let position = node.data.borrow().sourcepos; 57 | let violation = self.to_violation(doc.path.clone(), position); 58 | violations.push(violation); 59 | } 60 | } 61 | } 62 | } 63 | } 64 | } 65 | 66 | Ok(violations) 67 | } 68 | } 69 | 70 | #[cfg(test)] 71 | mod tests { 72 | use std::path::Path; 73 | 74 | use comrak::{nodes::Sourcepos, Arena}; 75 | use pretty_assertions::assert_eq; 76 | 77 | use super::*; 78 | 79 | #[test] 80 | fn check_errors() -> Result<()> { 81 | let text = "# This is a header.".to_owned(); 82 | let path = Path::new("test.md").to_path_buf(); 83 | let arena = Arena::new(); 84 | let doc = Document::new(&arena, path.clone(), text)?; 85 | let rule = MD026::default(); 86 | let actual = rule.check(&doc)?; 87 | let expected = vec![rule.to_violation(path, Sourcepos::from((1, 1, 1, 19)))]; 88 | assert_eq!(actual, expected); 89 | Ok(()) 90 | } 91 | 92 | #[test] 93 | fn check_errors_with_link() -> Result<()> { 94 | let text = "# [This is a header](http://example.com).".to_owned(); 95 | let path = Path::new("test.md").to_path_buf(); 96 | let arena = Arena::new(); 97 | let doc = Document::new(&arena, path.clone(), text)?; 98 | let rule = MD026::default(); 99 | let actual = rule.check(&doc)?; 100 | let expected = vec![rule.to_violation(path, Sourcepos::from((1, 1, 1, 41)))]; 101 | assert_eq!(actual, expected); 102 | Ok(()) 103 | } 104 | 105 | #[test] 106 | fn check_errors_with_code() -> Result<()> { 107 | let text = "# `This is a header`.".to_owned(); 108 | let path = Path::new("test.md").to_path_buf(); 109 | let arena = Arena::new(); 110 | let doc = Document::new(&arena, path.clone(), text)?; 111 | let rule = MD026::default(); 112 | let actual = rule.check(&doc)?; 113 | let expected = vec![rule.to_violation(path, Sourcepos::from((1, 1, 1, 21)))]; 114 | assert_eq!(actual, expected); 115 | Ok(()) 116 | } 117 | 118 | #[test] 119 | fn check_errors_with_emph() -> Result<()> { 120 | let text = "# *This is a header*.".to_owned(); 121 | let path = Path::new("test.md").to_path_buf(); 122 | let arena = Arena::new(); 123 | let doc = Document::new(&arena, path.clone(), text)?; 124 | let rule = MD026::default(); 125 | let actual = rule.check(&doc)?; 126 | let expected = vec![rule.to_violation(path, Sourcepos::from((1, 1, 1, 21)))]; 127 | assert_eq!(actual, expected); 128 | Ok(()) 129 | } 130 | 131 | #[test] 132 | fn check_no_errors() -> Result<()> { 133 | let text = "# This is a header".to_owned(); 134 | let path = Path::new("test.md").to_path_buf(); 135 | let arena = Arena::new(); 136 | let doc = Document::new(&arena, path, text)?; 137 | let rule = MD026::default(); 138 | let actual = rule.check(&doc)?; 139 | let expected = vec![]; 140 | assert_eq!(actual, expected); 141 | Ok(()) 142 | } 143 | 144 | #[test] 145 | fn check_no_errors_with_link() -> Result<()> { 146 | let text = "# [This is a header.](http://example.com)".to_owned(); 147 | let path = Path::new("test.md").to_path_buf(); 148 | let arena = Arena::new(); 149 | let doc = Document::new(&arena, path, text)?; 150 | let rule = MD026::default(); 151 | let actual = rule.check(&doc)?; 152 | let expected = vec![]; 153 | assert_eq!(actual, expected); 154 | Ok(()) 155 | } 156 | 157 | #[test] 158 | fn check_no_errors_with_code() -> Result<()> { 159 | let text = "# `This is a header.`".to_owned(); 160 | let path = Path::new("test.md").to_path_buf(); 161 | let arena = Arena::new(); 162 | let doc = Document::new(&arena, path, text)?; 163 | let rule = MD026::default(); 164 | let actual = rule.check(&doc)?; 165 | let expected = vec![]; 166 | assert_eq!(actual, expected); 167 | Ok(()) 168 | } 169 | 170 | #[test] 171 | fn check_no_errors_with_emph() -> Result<()> { 172 | let text = "# *This is a header.*".to_owned(); 173 | let path = Path::new("test.md").to_path_buf(); 174 | let arena = Arena::new(); 175 | let doc = Document::new(&arena, path, text)?; 176 | let rule = MD026::default(); 177 | let actual = rule.check(&doc)?; 178 | let expected = vec![]; 179 | assert_eq!(actual, expected); 180 | Ok(()) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/rule/md034.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::NodeValue; 2 | use linkify::LinkFinder; 3 | use miette::Result; 4 | 5 | use crate::{violation::Violation, Document}; 6 | 7 | use super::{Metadata, RuleLike, Tag}; 8 | 9 | #[derive(Debug, Default, Clone, PartialEq, Eq)] 10 | #[non_exhaustive] 11 | pub struct MD034; 12 | 13 | impl MD034 { 14 | const METADATA: Metadata = Metadata { 15 | name: "MD034", 16 | description: "Bare URL used", 17 | tags: &[Tag::Links, Tag::Url], 18 | aliases: &["no-bare-urls"], 19 | }; 20 | 21 | #[inline] 22 | #[must_use] 23 | pub const fn new() -> Self { 24 | Self {} 25 | } 26 | } 27 | 28 | impl RuleLike for MD034 { 29 | #[inline] 30 | fn metadata(&self) -> &'static Metadata { 31 | &Self::METADATA 32 | } 33 | 34 | // TODO: Use safe casting 35 | #[inline] 36 | #[allow(clippy::cast_possible_wrap)] 37 | fn check(&self, doc: &Document) -> Result> { 38 | let mut violations = vec![]; 39 | let finder = LinkFinder::new(); 40 | 41 | for node in doc.ast.descendants() { 42 | if let NodeValue::Text(text) = &node.data.borrow().value { 43 | for link in finder.links(text) { 44 | if let Some(parent) = node.parent() { 45 | if let NodeValue::Link(_) = parent.data.borrow().value { 46 | continue; 47 | } 48 | } 49 | 50 | // NOTE: link.start and link.end start from 0 51 | let mut position = node.data.borrow().sourcepos; 52 | position.end = position.start.column_add(link.end() as isize); 53 | position.start = position.start.column_add(link.start() as isize); 54 | 55 | let violation = self.to_violation(doc.path.clone(), position); 56 | violations.push(violation); 57 | } 58 | } 59 | } 60 | 61 | Ok(violations) 62 | } 63 | } 64 | 65 | #[cfg(test)] 66 | mod tests { 67 | use std::path::Path; 68 | 69 | use comrak::{nodes::Sourcepos, Arena}; 70 | use pretty_assertions::assert_eq; 71 | 72 | use super::*; 73 | 74 | #[test] 75 | fn check_errors() -> Result<()> { 76 | let text = "For more information, see http://www.example.com/.".to_owned(); 77 | let path = Path::new("test.md").to_path_buf(); 78 | let arena = Arena::new(); 79 | let doc = Document::new(&arena, path.clone(), text)?; 80 | let rule = MD034::default(); 81 | let actual = rule.check(&doc)?; 82 | let expected = vec![rule.to_violation(path, Sourcepos::from((1, 27, 1, 50)))]; 83 | assert_eq!(actual, expected); 84 | Ok(()) 85 | } 86 | 87 | #[test] 88 | fn check_no_errors_with_brackets() -> Result<()> { 89 | let text = "For more information, see .".to_owned(); 90 | let path = Path::new("test.md").to_path_buf(); 91 | let arena = Arena::new(); 92 | let doc = Document::new(&arena, path, text)?; 93 | let rule = MD034::default(); 94 | let actual = rule.check(&doc)?; 95 | let expected = vec![]; 96 | assert_eq!(actual, expected); 97 | Ok(()) 98 | } 99 | 100 | #[test] 101 | fn check_no_errors_with_link() -> Result<()> { 102 | let text = "For more information, see [http://www.example.com/](http://www.example.com/)." 103 | .to_owned(); 104 | let path = Path::new("test.md").to_path_buf(); 105 | let arena = Arena::new(); 106 | let doc = Document::new(&arena, path, text)?; 107 | let rule = MD034::default(); 108 | let actual = rule.check(&doc)?; 109 | let expected = vec![]; 110 | assert_eq!(actual, expected); 111 | Ok(()) 112 | } 113 | 114 | #[test] 115 | fn check_no_errors_with_code() -> Result<()> { 116 | let text = "For more information, see `http://www.example.com/`.".to_owned(); 117 | let path = Path::new("test.md").to_path_buf(); 118 | let arena = Arena::new(); 119 | let doc = Document::new(&arena, path, text)?; 120 | let rule = MD034::default(); 121 | let actual = rule.check(&doc)?; 122 | let expected = vec![]; 123 | assert_eq!(actual, expected); 124 | Ok(()) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/rule/md038.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::NodeValue; 2 | use miette::Result; 3 | 4 | use crate::{violation::Violation, Document}; 5 | 6 | use super::{Metadata, RuleLike, Tag}; 7 | 8 | #[derive(Debug, Default, Clone, PartialEq, Eq)] 9 | #[non_exhaustive] 10 | pub struct MD038; 11 | 12 | impl MD038 { 13 | const METADATA: Metadata = Metadata { 14 | name: "MD038", 15 | description: "Spaces inside code span elements", 16 | tags: &[Tag::Whitespace, Tag::Code], 17 | aliases: &["no-space-in-code"], 18 | }; 19 | 20 | #[inline] 21 | #[must_use] 22 | pub const fn new() -> Self { 23 | Self {} 24 | } 25 | } 26 | 27 | impl RuleLike for MD038 { 28 | #[inline] 29 | fn metadata(&self) -> &'static Metadata { 30 | &Self::METADATA 31 | } 32 | 33 | #[inline] 34 | fn check(&self, doc: &Document) -> Result> { 35 | let mut violations = vec![]; 36 | 37 | for node in doc.ast.descendants() { 38 | if let NodeValue::Code(code) = &node.data.borrow().value { 39 | let position = node.data.borrow().sourcepos; 40 | let content_len = position.end.column - position.start.column - 1; 41 | if code.literal.trim() != code.literal || code.literal.len() != content_len { 42 | let violation = self.to_violation(doc.path.clone(), position); 43 | violations.push(violation); 44 | } 45 | } 46 | } 47 | 48 | Ok(violations) 49 | } 50 | } 51 | 52 | #[cfg(test)] 53 | mod tests { 54 | use std::path::Path; 55 | 56 | use comrak::{nodes::Sourcepos, Arena}; 57 | use indoc::indoc; 58 | use pretty_assertions::assert_eq; 59 | 60 | use super::*; 61 | 62 | #[test] 63 | fn check_errors() -> Result<()> { 64 | let text = indoc! {" 65 | ` some text ` 66 | 67 | `some text ` 68 | 69 | ` some text` 70 | "} 71 | .to_owned(); 72 | let path = Path::new("test.md").to_path_buf(); 73 | let arena = Arena::new(); 74 | let doc = Document::new(&arena, path.clone(), text)?; 75 | let rule = MD038::new(); 76 | let actual = rule.check(&doc)?; 77 | let expected = vec![ 78 | rule.to_violation(path.clone(), Sourcepos::from((1, 1, 1, 13))), 79 | rule.to_violation(path.clone(), Sourcepos::from((3, 1, 3, 12))), 80 | rule.to_violation(path, Sourcepos::from((5, 1, 5, 12))), 81 | ]; 82 | assert_eq!(actual, expected); 83 | Ok(()) 84 | } 85 | 86 | #[test] 87 | fn check_no_errors() -> Result<()> { 88 | let text = "`some text`".to_owned(); 89 | let path = Path::new("test.md").to_path_buf(); 90 | let arena = Arena::new(); 91 | let doc = Document::new(&arena, path, text)?; 92 | let rule = MD038::new(); 93 | let actual = rule.check(&doc)?; 94 | let expected = vec![]; 95 | assert_eq!(actual, expected); 96 | Ok(()) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/rule/md040.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::NodeValue; 2 | use miette::Result; 3 | 4 | use crate::{violation::Violation, Document}; 5 | 6 | use super::{Metadata, RuleLike, Tag}; 7 | 8 | #[derive(Debug, Default, Clone, PartialEq, Eq)] 9 | #[non_exhaustive] 10 | pub struct MD040; 11 | 12 | impl MD040 { 13 | const METADATA: Metadata = Metadata { 14 | name: "MD040", 15 | description: "Fenced code blocks should have a language specified", 16 | tags: &[Tag::Code, Tag::Language], 17 | aliases: &["fenced-code-language"], 18 | }; 19 | 20 | #[inline] 21 | #[must_use] 22 | pub const fn new() -> Self { 23 | Self {} 24 | } 25 | } 26 | 27 | impl RuleLike for MD040 { 28 | #[inline] 29 | fn metadata(&self) -> &'static Metadata { 30 | &Self::METADATA 31 | } 32 | 33 | #[inline] 34 | fn check(&self, doc: &Document) -> Result> { 35 | let mut violations = vec![]; 36 | 37 | for node in doc.ast.descendants() { 38 | if let NodeValue::CodeBlock(code) = &node.data.borrow().value { 39 | if code.fenced && code.info.is_empty() { 40 | let position = node.data.borrow().sourcepos; 41 | let violation = self.to_violation(doc.path.clone(), position); 42 | violations.push(violation); 43 | } 44 | } 45 | } 46 | 47 | Ok(violations) 48 | } 49 | } 50 | 51 | #[cfg(test)] 52 | mod tests { 53 | use std::path::Path; 54 | 55 | use comrak::{nodes::Sourcepos, Arena}; 56 | use indoc::indoc; 57 | use pretty_assertions::assert_eq; 58 | 59 | use super::*; 60 | 61 | #[test] 62 | fn check_errors() -> Result<()> { 63 | let text = indoc! {" 64 | ``` 65 | #!/bin/bash 66 | echo Hello world 67 | ``` 68 | "} 69 | .to_owned(); 70 | let path = Path::new("test.md").to_path_buf(); 71 | let arena = Arena::new(); 72 | let doc = Document::new(&arena, path.clone(), text)?; 73 | let rule = MD040::new(); 74 | let actual = rule.check(&doc)?; 75 | let expected = vec![rule.to_violation(path, Sourcepos::from((1, 1, 4, 3)))]; 76 | assert_eq!(actual, expected); 77 | Ok(()) 78 | } 79 | 80 | #[test] 81 | fn check_no_errors() -> Result<()> { 82 | let text = indoc! {" 83 | ```bash 84 | #!/bin/bash 85 | echo Hello world 86 | ``` 87 | "} 88 | .to_owned(); 89 | let path = Path::new("test.md").to_path_buf(); 90 | let arena = Arena::new(); 91 | let doc = Document::new(&arena, path, text)?; 92 | let rule = MD040::new(); 93 | let actual = rule.check(&doc)?; 94 | let expected = vec![]; 95 | assert_eq!(actual, expected); 96 | Ok(()) 97 | } 98 | 99 | #[test] 100 | fn check_no_errors_with_indented() -> Result<()> { 101 | let text = indoc! {" 102 | Some text 103 | 104 | Code block 105 | 106 | Some more text 107 | "} 108 | .to_owned(); 109 | let path = Path::new("test.md").to_path_buf(); 110 | let arena = Arena::new(); 111 | let doc = Document::new(&arena, path, text)?; 112 | let rule = MD040::new(); 113 | let actual = rule.check(&doc)?; 114 | let expected = vec![]; 115 | assert_eq!(actual, expected); 116 | Ok(()) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/rule/md041.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::{NodeHeading, NodeValue}; 2 | use miette::Result; 3 | 4 | use crate::{violation::Violation, Document}; 5 | 6 | use super::{Metadata, RuleLike, Tag}; 7 | 8 | #[derive(Debug, Clone, PartialEq, Eq)] 9 | #[non_exhaustive] 10 | pub struct MD041 { 11 | level: u8, 12 | } 13 | 14 | impl MD041 { 15 | const METADATA: Metadata = Metadata { 16 | name: "MD041", 17 | description: "First line in file should be a top level header", 18 | tags: &[Tag::Headers], 19 | aliases: &["first-line-h1"], 20 | }; 21 | 22 | pub const DEFAULT_LEVEL: u8 = 1; 23 | 24 | #[inline] 25 | #[must_use] 26 | pub const fn new(level: u8) -> Self { 27 | Self { level } 28 | } 29 | } 30 | 31 | impl Default for MD041 { 32 | #[inline] 33 | fn default() -> Self { 34 | Self { 35 | level: Self::DEFAULT_LEVEL, 36 | } 37 | } 38 | } 39 | 40 | impl RuleLike for MD041 { 41 | #[inline] 42 | fn metadata(&self) -> &'static Metadata { 43 | &Self::METADATA 44 | } 45 | 46 | #[inline] 47 | fn check(&self, doc: &Document) -> Result> { 48 | let mut violations = vec![]; 49 | 50 | if let Some(node) = doc.ast.first_child() { 51 | match node.data.borrow().value { 52 | NodeValue::Heading(NodeHeading { level, .. }) if level == self.level => {} 53 | _ => { 54 | let position = node.data.borrow().sourcepos; 55 | let violation = self.to_violation(doc.path.clone(), position); 56 | violations.push(violation); 57 | } 58 | } 59 | } 60 | 61 | Ok(violations) 62 | } 63 | } 64 | 65 | #[cfg(test)] 66 | mod tests { 67 | use std::path::Path; 68 | 69 | use comrak::{nodes::Sourcepos, Arena}; 70 | use indoc::indoc; 71 | use pretty_assertions::assert_eq; 72 | 73 | use super::*; 74 | 75 | #[test] 76 | fn check_errors() -> Result<()> { 77 | let text = "This is a file without a header".to_owned(); 78 | let path = Path::new("test.md").to_path_buf(); 79 | let arena = Arena::new(); 80 | let doc = Document::new(&arena, path.clone(), text)?; 81 | let rule = MD041::default(); 82 | let actual = rule.check(&doc)?; 83 | let expected = vec![rule.to_violation(path, Sourcepos::from((1, 1, 1, 31)))]; 84 | assert_eq!(actual, expected); 85 | Ok(()) 86 | } 87 | 88 | #[test] 89 | fn check_no_errors() -> Result<()> { 90 | let text = indoc! {" 91 | # File with header 92 | 93 | This is a file with a top level header 94 | "} 95 | .to_owned(); 96 | let path = Path::new("test.md").to_path_buf(); 97 | let arena = Arena::new(); 98 | let doc = Document::new(&arena, path, text)?; 99 | let rule = MD041::default(); 100 | let actual = rule.check(&doc)?; 101 | let expected = vec![]; 102 | assert_eq!(actual, expected); 103 | Ok(()) 104 | } 105 | 106 | #[test] 107 | fn check_no_errors_with_empty_text() -> Result<()> { 108 | let text = String::new(); 109 | let path = Path::new("test.md").to_path_buf(); 110 | let arena = Arena::new(); 111 | let doc = Document::new(&arena, path, text)?; 112 | let rule = MD041::default(); 113 | let actual = rule.check(&doc)?; 114 | let expected = vec![]; 115 | assert_eq!(actual, expected); 116 | Ok(()) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/rule/md047.rs: -------------------------------------------------------------------------------- 1 | use comrak::nodes::Sourcepos; 2 | use miette::Result; 3 | 4 | use crate::{violation::Violation, Document}; 5 | 6 | use super::{Metadata, RuleLike, Tag}; 7 | 8 | #[derive(Debug, Default, Clone, PartialEq, Eq)] 9 | #[non_exhaustive] 10 | pub struct MD047; 11 | 12 | impl MD047 { 13 | const METADATA: Metadata = Metadata { 14 | name: "MD047", 15 | description: "File should end with a single newline character", 16 | tags: &[Tag::BlankLines], 17 | aliases: &["single-trailing-newline"], 18 | }; 19 | 20 | #[inline] 21 | #[must_use] 22 | pub const fn new() -> Self { 23 | Self {} 24 | } 25 | } 26 | 27 | impl RuleLike for MD047 { 28 | #[inline] 29 | fn metadata(&self) -> &'static Metadata { 30 | &Self::METADATA 31 | } 32 | 33 | #[inline] 34 | fn check(&self, doc: &Document) -> Result> { 35 | if doc.text.is_empty() || doc.text.ends_with('\n') { 36 | return Ok(vec![]); 37 | } 38 | 39 | let lineno = doc.lines.len(); 40 | let end_column = doc.lines.last().unwrap_or(&String::new()).len() + 1; 41 | let position = Sourcepos::from((lineno, 1, lineno, end_column)); 42 | let violation = self.to_violation(doc.path.clone(), position); 43 | 44 | Ok(vec![violation]) 45 | } 46 | } 47 | 48 | #[cfg(test)] 49 | mod tests { 50 | use std::path::Path; 51 | 52 | use comrak::{nodes::Sourcepos, Arena}; 53 | use indoc::indoc; 54 | use pretty_assertions::assert_eq; 55 | 56 | use super::*; 57 | 58 | #[test] 59 | fn check_errors() -> Result<()> { 60 | let text = "Some text".to_owned(); 61 | let path = Path::new("test.md").to_path_buf(); 62 | let arena = Arena::new(); 63 | let doc = Document::new(&arena, path.clone(), text)?; 64 | let rule = MD047::new(); 65 | let actual = rule.check(&doc)?; 66 | let expected = vec![rule.to_violation(path, Sourcepos::from((1, 1, 1, 10)))]; 67 | assert_eq!(actual, expected); 68 | Ok(()) 69 | } 70 | 71 | #[test] 72 | fn check_no_errors() -> Result<()> { 73 | let text = indoc! {" 74 | Some text 75 | 76 | "} 77 | .to_owned(); 78 | let path = Path::new("test.md").to_path_buf(); 79 | let arena = Arena::new(); 80 | let doc = Document::new(&arena, path, text)?; 81 | let rule = MD047::new(); 82 | let actual = rule.check(&doc)?; 83 | let expected = vec![]; 84 | assert_eq!(actual, expected); 85 | Ok(()) 86 | } 87 | 88 | #[test] 89 | fn check_errors_with_empty_string() -> Result<()> { 90 | let text = String::new(); 91 | let path = Path::new("test.md").to_path_buf(); 92 | let arena = Arena::new(); 93 | let doc = Document::new(&arena, path, text)?; 94 | let rule = MD047::new(); 95 | let actual = rule.check(&doc)?; 96 | let expected = vec![]; 97 | assert_eq!(actual, expected); 98 | Ok(()) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/rule/metadata.rs: -------------------------------------------------------------------------------- 1 | use super::Tag; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq)] 4 | #[non_exhaustive] 5 | pub struct Metadata { 6 | pub name: &'static str, 7 | pub description: &'static str, 8 | pub tags: &'static [Tag], 9 | pub aliases: &'static [&'static str], 10 | } 11 | -------------------------------------------------------------------------------- /src/rule/tag.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 4 | #[serde(rename_all = "kebab-case")] 5 | #[non_exhaustive] 6 | pub enum Tag { 7 | Atx, 8 | AtxClosed, 9 | BlankLines, 10 | Blockquote, 11 | Bullet, 12 | Code, 13 | Emphasis, 14 | HardTab, 15 | Headers, 16 | Hr, 17 | Html, 18 | Indentation, 19 | Language, 20 | LineLength, 21 | Links, 22 | Ol, 23 | Spaces, 24 | Ul, 25 | Url, 26 | Whitespace, 27 | } 28 | -------------------------------------------------------------------------------- /src/service.rs: -------------------------------------------------------------------------------- 1 | mod linter; 2 | pub mod runner; 3 | pub mod visitor; 4 | pub mod walker; 5 | 6 | pub use linter::Linter; 7 | -------------------------------------------------------------------------------- /src/service/linter.rs: -------------------------------------------------------------------------------- 1 | use miette::Result; 2 | 3 | use crate::config::Config; 4 | use crate::violation::Violation; 5 | use crate::Document; 6 | use crate::Rule; 7 | 8 | #[derive(Default)] 9 | pub struct Linter { 10 | rules: Vec, 11 | } 12 | 13 | impl Linter { 14 | #[inline] 15 | #[must_use] 16 | pub const fn new(rules: Vec) -> Self { 17 | Self { rules } 18 | } 19 | 20 | #[inline] 21 | pub fn check(&self, doc: &Document) -> Result> { 22 | // Iterate rules while unrolling Vec>> to Result> 23 | self.rules.iter().try_fold(vec![], |mut unrolled, rule| { 24 | let result = rule.check(doc); 25 | unrolled.extend(result?); 26 | Ok(unrolled) 27 | }) 28 | } 29 | } 30 | 31 | impl From<&Config> for Linter { 32 | #[inline] 33 | #[must_use] 34 | fn from(config: &Config) -> Self { 35 | let rules = Vec::from(&config.lint); 36 | 37 | Self { rules } 38 | } 39 | } 40 | 41 | #[cfg(test)] 42 | mod tests { 43 | use std::path::Path; 44 | 45 | use comrak::{nodes::Sourcepos, Arena}; 46 | use indoc::indoc; 47 | use pretty_assertions::assert_eq; 48 | 49 | use crate::config::lint::RuleSet; 50 | use crate::rule::RuleLike as _; 51 | use crate::rule::MD026; 52 | 53 | use super::*; 54 | 55 | #[test] 56 | fn check() -> Result<()> { 57 | let text = indoc! {" 58 | --- 59 | comments: false 60 | description: Some text 61 | --- 62 | 63 | # This is a header. 64 | "} 65 | .to_owned(); 66 | let path = Path::new("test.md").to_path_buf(); 67 | let arena = Arena::new(); 68 | let doc = Document::new(&arena, path.clone(), text)?; 69 | let md026 = MD026::default(); 70 | let rules = vec![Rule::MD026(md026.clone())]; 71 | let linter = Linter::new(rules); 72 | let actual = linter.check(&doc)?; 73 | let expected = vec![md026.to_violation(path, Sourcepos::from((6, 1, 6, 19)))]; 74 | assert_eq!(actual, expected); 75 | Ok(()) 76 | } 77 | 78 | #[test] 79 | fn from_config() { 80 | let md026 = MD026::default(); 81 | let rules = vec![RuleSet::MD026]; 82 | let mut config = Config::default(); 83 | config.lint.rules = rules; 84 | let linter = Linter::from(&config); 85 | let expected = vec![Rule::MD026(md026)]; 86 | assert_eq!(linter.rules, expected); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/service/runner.rs: -------------------------------------------------------------------------------- 1 | extern crate alloc; 2 | 3 | use alloc::sync::Arc; 4 | use comrak::Arena; 5 | use std::path::{Path, PathBuf}; 6 | use std::sync::{mpsc, Mutex}; 7 | use std::thread; 8 | 9 | use ignore::WalkParallel; 10 | use miette::miette; 11 | use miette::{IntoDiagnostic as _, Result}; 12 | 13 | use super::visitor::MarkdownLintVisitorFactory; 14 | use super::walker::WalkParallelBuilder; 15 | use super::Linter; 16 | use crate::config::Config; 17 | use crate::{Document, Violation}; 18 | 19 | #[non_exhaustive] 20 | pub enum LintRunner { 21 | Parallel(ParallelLintRunner), 22 | String(Box), 23 | } 24 | 25 | impl LintRunner { 26 | #[inline] 27 | pub fn run(self) -> Result> { 28 | match self { 29 | Self::Parallel(runner) => runner.run(), 30 | Self::String(runner) => runner.run(), 31 | } 32 | } 33 | } 34 | 35 | pub struct ParallelLintRunner { 36 | walker: WalkParallel, 37 | config: Config, 38 | capacity: usize, 39 | } 40 | 41 | impl ParallelLintRunner { 42 | #[inline] 43 | pub fn new(patterns: &[PathBuf], config: Config, capacity: usize) -> Result { 44 | let walker = WalkParallelBuilder::build( 45 | patterns, 46 | config.lint.respect_ignore, 47 | config.lint.respect_gitignore, 48 | )?; 49 | 50 | Ok(Self { 51 | walker, 52 | config, 53 | capacity, 54 | }) 55 | } 56 | 57 | #[inline] 58 | // TODO: Don't use expect 59 | #[expect(clippy::expect_used)] 60 | #[expect(clippy::unwrap_in_result)] 61 | pub fn run(self) -> Result> { 62 | let mutex_violations: Arc>> = Arc::new(Mutex::new(vec![])); 63 | let (tx, rx) = mpsc::sync_channel::>(self.capacity); 64 | 65 | let local_mutex_violations = Arc::clone(&mutex_violations); 66 | let thread = thread::spawn(move || { 67 | for violations in rx { 68 | let mut acquired_violations = local_mutex_violations 69 | .lock() 70 | .expect("lock must be acquired"); 71 | acquired_violations.extend(violations); 72 | } 73 | }); 74 | 75 | let mut builder = MarkdownLintVisitorFactory::new(self.config, tx)?; 76 | self.walker.visit(&mut builder); 77 | 78 | // Wait for the completion 79 | drop(builder); 80 | thread 81 | .join() 82 | .map_err(|err| miette!("Failed to join thread. {:?}", err))?; 83 | 84 | // Take ownership of violations 85 | let lock = 86 | Arc::into_inner(mutex_violations).ok_or_else(|| miette!("Failed to unwrap Arc"))?; 87 | lock.into_inner().into_diagnostic() 88 | } 89 | } 90 | 91 | #[derive(Debug, Clone, PartialEq, Eq)] 92 | pub struct StringLintRunner { 93 | string: String, 94 | config: Config, 95 | } 96 | 97 | impl StringLintRunner { 98 | #[inline] 99 | #[must_use] 100 | pub const fn new(string: String, config: Config) -> Self { 101 | Self { string, config } 102 | } 103 | 104 | #[inline] 105 | pub fn run(self) -> Result> { 106 | let arena = Arena::new(); 107 | let path = Path::new("(stdin)").to_path_buf(); 108 | let doc = Document::new(&arena, path, self.string)?; 109 | let linter = Linter::from(&self.config); 110 | linter.check(&doc) 111 | } 112 | } 113 | 114 | #[cfg(test)] 115 | mod tests { 116 | use std::path::Path; 117 | 118 | use pretty_assertions::assert_eq; 119 | 120 | use super::*; 121 | 122 | #[test] 123 | fn parallel_lint_runner_run() -> Result<()> { 124 | let mut config = Config::default(); 125 | config.lint.rules = vec![]; 126 | 127 | let patterns = [Path::new(".").to_path_buf()]; 128 | let runner = ParallelLintRunner::new(&patterns, config, 0)?; 129 | let actual = runner.run()?; 130 | assert_eq!(actual, vec![]); 131 | Ok(()) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/service/visitor.rs: -------------------------------------------------------------------------------- 1 | use core::result::Result; 2 | use std::sync::mpsc::SyncSender; 3 | 4 | use comrak::Arena; 5 | use globset::GlobSet; 6 | use ignore::{DirEntry, Error, ParallelVisitor, ParallelVisitorBuilder, WalkState}; 7 | use miette::IntoDiagnostic as _; 8 | 9 | use super::Linter; 10 | use crate::{config::Config, Document, Violation}; 11 | 12 | pub struct MarkdownLintVisitor { 13 | linter: Linter, 14 | exclusion: GlobSet, 15 | tx: SyncSender>, 16 | } 17 | 18 | impl MarkdownLintVisitor { 19 | #[inline] 20 | #[must_use] 21 | pub const fn new(linter: Linter, exclusion: GlobSet, tx: SyncSender>) -> Self { 22 | Self { 23 | linter, 24 | exclusion, 25 | tx, 26 | } 27 | } 28 | 29 | fn visit_inner(&self, either_entry: Result) -> miette::Result<()> { 30 | let entry = either_entry.into_diagnostic()?; 31 | let path = entry.path(); 32 | if path.is_file() 33 | && path.extension() == Some("md".as_ref()) 34 | && !self.exclusion.is_match(path) 35 | { 36 | let arena = Arena::new(); 37 | let doc = Document::open(&arena, path)?; 38 | let violations = self.linter.check(&doc)?; 39 | if !violations.is_empty() { 40 | self.tx.send(violations).into_diagnostic()?; 41 | } 42 | } 43 | 44 | Ok(()) 45 | } 46 | } 47 | 48 | impl ParallelVisitor for MarkdownLintVisitor { 49 | #[inline] 50 | fn visit(&mut self, either_entry: Result) -> WalkState { 51 | if let Err(err) = self.visit_inner(either_entry) { 52 | // TODO: Handle errors 53 | println!("{err}"); 54 | } 55 | WalkState::Continue 56 | } 57 | } 58 | 59 | pub struct MarkdownLintVisitorFactory { 60 | config: Config, 61 | exclusion: GlobSet, 62 | tx: SyncSender>, 63 | } 64 | 65 | impl MarkdownLintVisitorFactory { 66 | #[inline] 67 | pub fn new(config: Config, tx: SyncSender>) -> miette::Result { 68 | let exclusion = config.lint.exclude_set()?; 69 | Ok(Self { 70 | config, 71 | exclusion, 72 | tx, 73 | }) 74 | } 75 | } 76 | 77 | impl<'s> ParallelVisitorBuilder<'s> for MarkdownLintVisitorFactory { 78 | #[inline] 79 | fn build(&mut self) -> Box { 80 | let linter = Linter::from(&self.config); 81 | Box::new(MarkdownLintVisitor::new( 82 | linter, 83 | self.exclusion.clone(), 84 | self.tx.clone(), 85 | )) 86 | } 87 | } 88 | 89 | #[cfg(test)] 90 | mod tests { 91 | use std::sync::mpsc; 92 | 93 | use ignore::Walk; 94 | 95 | use super::*; 96 | 97 | #[test] 98 | fn markdown_lint_visitor_visit_inner() -> miette::Result<()> { 99 | let (tx, rx) = mpsc::sync_channel::>(0); 100 | let linter = Linter::new(vec![]); 101 | let exclusion = GlobSet::empty(); 102 | let visitor = MarkdownLintVisitor::new(linter, exclusion, tx); 103 | 104 | for entry in Walk::new(".") { 105 | visitor.visit_inner(entry)?; 106 | } 107 | 108 | drop(visitor); 109 | assert!(rx.recv().is_err()); // Because rx has not received any messages 110 | Ok(()) 111 | } 112 | 113 | #[test] 114 | fn markdown_lint_visitor_factory_build() -> miette::Result<()> { 115 | let mut config = Config::default(); 116 | config.lint.rules = vec![]; 117 | 118 | let (tx, rx) = mpsc::sync_channel::>(0); 119 | let mut factory = MarkdownLintVisitorFactory::new(config, tx)?; 120 | let mut visitor = factory.build(); 121 | 122 | for entry in Walk::new(".") { 123 | visitor.visit(entry); 124 | } 125 | 126 | drop(visitor); 127 | drop(factory); 128 | assert!(rx.recv().is_err()); // Because rx has not received any messages 129 | Ok(()) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/service/walker.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use ignore::types::TypesBuilder; 4 | use ignore::WalkBuilder; 5 | use ignore::WalkParallel; 6 | use miette::miette; 7 | use miette::IntoDiagnostic as _; 8 | use miette::Result; 9 | 10 | #[non_exhaustive] 11 | pub struct WalkParallelBuilder; 12 | 13 | impl WalkParallelBuilder { 14 | #[inline] 15 | pub fn build( 16 | patterns: &[PathBuf], 17 | respect_ignore: bool, 18 | respect_gitignore: bool, 19 | ) -> Result { 20 | let (head_pattern, tail_patterns) = patterns 21 | .split_first() 22 | .ok_or_else(|| miette!("files must be non-empty"))?; 23 | let mut builder = WalkBuilder::new(head_pattern); 24 | for pattern in tail_patterns { 25 | builder.add(pattern); 26 | } 27 | 28 | builder.ignore(respect_ignore); 29 | builder.git_ignore(respect_gitignore); 30 | 31 | // NOTE: Expect performance improvements with pre-filtering 32 | let types = TypesBuilder::new() 33 | .add_defaults() 34 | .select("markdown") 35 | .build() 36 | .into_diagnostic()?; 37 | builder.types(types); 38 | 39 | Ok(builder.build_parallel()) 40 | } 41 | } 42 | 43 | #[cfg(test)] 44 | mod tests { 45 | extern crate alloc; 46 | 47 | use alloc::sync::Arc; 48 | use miette::{Context as _, IntoDiagnostic as _}; 49 | use std::{ 50 | path::{Path, PathBuf}, 51 | sync::Mutex, 52 | }; 53 | 54 | use ignore::{DirEntry, WalkState}; 55 | use pretty_assertions::assert_eq; 56 | 57 | use super::WalkParallelBuilder; 58 | 59 | struct PathCollector { 60 | paths: Arc>>, 61 | } 62 | 63 | impl PathCollector { 64 | fn new() -> Self { 65 | Self { 66 | paths: Arc::new(Mutex::new(vec![])), 67 | } 68 | } 69 | 70 | fn gen_visitor(&self) -> impl Fn(Result) -> WalkState { 71 | let paths = Arc::clone(&self.paths); 72 | 73 | move |either_entry: Result| { 74 | if let Ok(entry) = either_entry { 75 | if let Ok(mut paths) = paths.lock() { 76 | paths.push(entry.into_path()); 77 | } 78 | } 79 | 80 | WalkState::Continue 81 | } 82 | } 83 | 84 | fn paths(self) -> miette::Result> { 85 | let mutex = Arc::into_inner(self.paths).wrap_err("failed to get inner ownership")?; 86 | mutex.into_inner().into_diagnostic() 87 | } 88 | } 89 | 90 | #[test] 91 | fn build_and_run() -> miette::Result<()> { 92 | let paths = vec![ 93 | Path::new("action").to_path_buf(), 94 | Path::new("mado.toml").to_path_buf(), 95 | Path::new("README.md").to_path_buf(), 96 | ]; 97 | let builder = WalkParallelBuilder::build(&paths, true, true)?; 98 | let collector = PathCollector::new(); 99 | 100 | builder.run(|| Box::new(collector.gen_visitor())); 101 | 102 | let mut actual = collector.paths()?; 103 | actual.sort(); 104 | 105 | let expected = vec![ 106 | Path::new("README.md").to_path_buf(), 107 | Path::new("action").to_path_buf(), 108 | Path::new("mado.toml").to_path_buf(), 109 | ]; 110 | assert_eq!(actual, expected); 111 | Ok(()) 112 | } 113 | 114 | #[test] 115 | fn build_empty_patterns() { 116 | let result = WalkParallelBuilder::build(&[], true, true); 117 | assert!(result.is_err()); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/violation.rs: -------------------------------------------------------------------------------- 1 | use core::cmp::Ordering; 2 | use std::path::PathBuf; 3 | 4 | use comrak::nodes::Sourcepos; 5 | 6 | use crate::rule::Metadata; 7 | 8 | #[derive(Debug, Clone, PartialEq, Eq)] 9 | pub struct Violation { 10 | path: PathBuf, 11 | metadata: &'static Metadata, 12 | position: Sourcepos, 13 | } 14 | 15 | impl Violation { 16 | #[inline] 17 | #[must_use] 18 | pub const fn new(path: PathBuf, metadata: &'static Metadata, position: Sourcepos) -> Self { 19 | Self { 20 | path, 21 | metadata, 22 | position, 23 | } 24 | } 25 | 26 | #[inline] 27 | #[must_use] 28 | pub const fn path(&self) -> &PathBuf { 29 | &self.path 30 | } 31 | 32 | #[inline] 33 | #[must_use] 34 | pub const fn name(&self) -> &str { 35 | self.metadata.name 36 | } 37 | 38 | #[inline] 39 | #[must_use] 40 | pub const fn alias(&self) -> &str { 41 | self.metadata.aliases[0] 42 | } 43 | 44 | #[inline] 45 | #[must_use] 46 | pub const fn description(&self) -> &str { 47 | self.metadata.description 48 | } 49 | 50 | #[inline] 51 | #[must_use] 52 | pub const fn position(&self) -> &Sourcepos { 53 | &self.position 54 | } 55 | } 56 | 57 | impl PartialOrd for Violation { 58 | #[inline] 59 | fn partial_cmp(&self, other: &Self) -> Option { 60 | Some(self.cmp(other)) 61 | } 62 | } 63 | 64 | impl Ord for Violation { 65 | #[inline] 66 | fn cmp(&self, other: &Self) -> Ordering { 67 | let path_cmp = self.path.cmp(&other.path); 68 | if path_cmp != Ordering::Equal { 69 | return path_cmp; 70 | } 71 | 72 | let position_cmp = self.position.start.cmp(&other.position().start); 73 | if position_cmp != Ordering::Equal { 74 | return position_cmp; 75 | } 76 | 77 | self.metadata.name.cmp(other.metadata.name) 78 | } 79 | } 80 | 81 | #[cfg(test)] 82 | mod tests { 83 | use std::path::Path; 84 | 85 | use comrak::nodes::Sourcepos; 86 | use pretty_assertions::assert_eq; 87 | 88 | use crate::rule::RuleLike as _; 89 | use crate::rule::{MD001, MD010}; 90 | 91 | #[test] 92 | #[allow(clippy::similar_names)] 93 | fn cmp() { 94 | let path1 = Path::new("foo.md").to_path_buf(); 95 | let path2 = Path::new("bar.md").to_path_buf(); 96 | let md001 = MD001::new(); 97 | let md010 = MD010::new(); 98 | let position0 = Sourcepos::from((1, 1, 1, 1)); 99 | let position1 = Sourcepos::from((1, 2, 1, 2)); 100 | let position2 = Sourcepos::from((2, 1, 2, 1)); 101 | let violation1 = md001.to_violation(path1.clone(), position0); 102 | let violation2 = md001.to_violation(path1.clone(), position1); 103 | let violation3 = md001.to_violation(path1.clone(), position2); 104 | let violation4 = md001.to_violation(path2.clone(), position0); 105 | let violation5 = md001.to_violation(path2.clone(), position1); 106 | let violation6 = md001.to_violation(path2.clone(), position2); 107 | let violation7 = md010.to_violation(path1.clone(), position0); 108 | let violation8 = md010.to_violation(path1.clone(), position1); 109 | let violation9 = md010.to_violation(path1, position2); 110 | let violation10 = md010.to_violation(path2.clone(), position0); 111 | let violation11 = md010.to_violation(path2.clone(), position1); 112 | let violation12 = md010.to_violation(path2, position2); 113 | let mut actual = vec![ 114 | violation1.clone(), 115 | violation2.clone(), 116 | violation3.clone(), 117 | violation4.clone(), 118 | violation5.clone(), 119 | violation6.clone(), 120 | violation7.clone(), 121 | violation8.clone(), 122 | violation9.clone(), 123 | violation10.clone(), 124 | violation11.clone(), 125 | violation12.clone(), 126 | ]; 127 | actual.sort(); 128 | let expected = vec![ 129 | violation4, 130 | violation10, 131 | violation5, 132 | violation11, 133 | violation6, 134 | violation12, 135 | violation1, 136 | violation7, 137 | violation2, 138 | violation8, 139 | violation3, 140 | violation9, 141 | ]; 142 | assert_eq!(actual, expected); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /tests/command_check.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::Write as _; 3 | use std::path::PathBuf; 4 | 5 | use assert_cmd::Command; 6 | use indoc::formatdoc; 7 | use indoc::indoc; 8 | use mado::Config; 9 | use miette::Context as _; 10 | use miette::IntoDiagnostic as _; 11 | use miette::Result; 12 | use tempfile::tempdir; 13 | 14 | fn with_tmp_file(name: &str, content: &str, f: F) -> Result<()> 15 | where 16 | F: FnOnce(PathBuf) -> Result<()>, 17 | { 18 | let tmp_dir = tempdir().into_diagnostic()?; 19 | let path = tmp_dir.path().join(name); 20 | let mut tmp_file = File::create(path.clone()).into_diagnostic()?; 21 | write!(tmp_file, "{content}").into_diagnostic()?; 22 | 23 | f(path)?; 24 | 25 | tmp_dir.close().into_diagnostic() 26 | } 27 | 28 | #[test] 29 | fn check() -> Result<()> { 30 | let mut cmd = Command::cargo_bin("mado").into_diagnostic()?; 31 | let assert = cmd.args(["check", "."]).assert(); 32 | assert.success().stdout("All checks passed!\n"); 33 | Ok(()) 34 | } 35 | 36 | #[test] 37 | fn check_quiet() -> Result<()> { 38 | let mut cmd = Command::cargo_bin("mado").into_diagnostic()?; 39 | let assert = cmd.args(["check", "--quiet", "."]).assert(); 40 | assert.success().stdout(""); 41 | Ok(()) 42 | } 43 | 44 | #[test] 45 | fn check_quiet_with_config() -> Result<()> { 46 | let mut config = Config::default(); 47 | config.lint.quiet = true; 48 | config.lint.md013.tables = false; 49 | config.lint.md013.code_blocks = false; 50 | config.lint.md024.allow_different_nesting = true; 51 | let content = toml::to_string(&config).into_diagnostic()?; 52 | 53 | with_tmp_file("mado.toml", &content, |path| { 54 | let mut cmd = Command::cargo_bin("mado").into_diagnostic()?; 55 | let path_str = path.to_str().wrap_err("failed to convert string")?; 56 | let assert = cmd.args(["--config", path_str, "check", "."]).assert(); 57 | assert.success().stdout(""); 58 | Ok(()) 59 | }) 60 | } 61 | 62 | #[test] 63 | fn check_stdin() -> Result<()> { 64 | let mut cmd = Command::cargo_bin("mado").into_diagnostic()?; 65 | let assert = cmd.write_stdin("#Hello.").args(["check"]).assert(); 66 | assert.failure().stdout( 67 | indoc! {" 68 | \u{1b}[1m(stdin)\u{1b}[0m\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m \u{1b}[1;31mMD018\u{1b}[0m No space after hash on atx style header 69 | \u{1b}[1m(stdin)\u{1b}[0m\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m \u{1b}[1;31mMD041\u{1b}[0m First line in file should be a top level header 70 | \u{1b}[1m(stdin)\u{1b}[0m\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m \u{1b}[1;31mMD047\u{1b}[0m File should end with a single newline character 71 | 72 | Found 3 errors. 73 | "} 74 | ); 75 | Ok(()) 76 | } 77 | 78 | #[test] 79 | fn check_empty_stdin() -> Result<()> { 80 | let mut cmd = Command::cargo_bin("mado").into_diagnostic()?; 81 | let assert = cmd.write_stdin("").args(["check"]).assert(); 82 | assert.success().stdout("All checks passed!\n"); 83 | Ok(()) 84 | } 85 | 86 | #[test] 87 | fn check_empty_stdin_with_file() -> Result<()> { 88 | with_tmp_file("test.md", "#Hello.", |path| { 89 | let mut cmd = Command::cargo_bin("mado").into_diagnostic()?; 90 | let path_str = path.to_str().wrap_err("failed to convert string")?; 91 | let assert = cmd.write_stdin("").args(["check", path_str]).assert(); 92 | assert.failure().stdout( 93 | formatdoc! {" 94 | \u{1b}[1m{path_str}\u{1b}[0m\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m \u{1b}[1;31mMD018\u{1b}[0m No space after hash on atx style header 95 | \u{1b}[1m{path_str}\u{1b}[0m\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m \u{1b}[1;31mMD041\u{1b}[0m First line in file should be a top level header 96 | \u{1b}[1m{path_str}\u{1b}[0m\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m \u{1b}[1;31mMD047\u{1b}[0m File should end with a single newline character 97 | 98 | Found 3 errors. 99 | "} 100 | ); 101 | Ok(()) 102 | }) 103 | } 104 | 105 | #[test] 106 | fn check_stdin_with_file() -> Result<()> { 107 | with_tmp_file("test.md", "#Hello.", |path| { 108 | let mut cmd = Command::cargo_bin("mado").into_diagnostic()?; 109 | let path_str = path.to_str().wrap_err("failed to convert string")?; 110 | let assert = cmd 111 | .write_stdin("#Hello.") 112 | .args(["check", path_str]) 113 | .assert(); 114 | assert.failure().stdout( 115 | indoc! {" 116 | \u{1b}[1m(stdin)\u{1b}[0m\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m \u{1b}[1;31mMD018\u{1b}[0m No space after hash on atx style header 117 | \u{1b}[1m(stdin)\u{1b}[0m\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m \u{1b}[1;31mMD041\u{1b}[0m First line in file should be a top level header 118 | \u{1b}[1m(stdin)\u{1b}[0m\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m1\u{1b}[34m:\u{1b}[0m \u{1b}[1;31mMD047\u{1b}[0m File should end with a single newline character 119 | 120 | Found 3 errors. 121 | "} 122 | ); 123 | Ok(()) 124 | }) 125 | } 126 | 127 | #[test] 128 | fn check_exclusion() -> Result<()> { 129 | with_tmp_file("test.md", "#Hello.", |path| { 130 | let mut cmd = Command::cargo_bin("mado").into_diagnostic()?; 131 | let path_str = path.to_str().wrap_err("failed to convert string")?; 132 | let assert = cmd.args(["check", path_str, "--exclude", "*.md"]).assert(); 133 | assert.success().stdout("All checks passed!\n"); 134 | Ok(()) 135 | }) 136 | } 137 | -------------------------------------------------------------------------------- /tests/command_generate_shell_completion.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::Command; 2 | use indoc::indoc; 3 | use miette::{IntoDiagnostic as _, Result}; 4 | 5 | #[test] 6 | fn generate_shell_completion_zsh() -> Result<()> { 7 | let mut cmd = Command::cargo_bin("mado").into_diagnostic()?; 8 | let assert = cmd.args(["generate-shell-completion", "zsh"]).assert(); 9 | assert.success(); 10 | Ok(()) 11 | } 12 | 13 | #[test] 14 | fn generate_shell_completion_invalid() -> Result<()> { 15 | let mut cmd = Command::cargo_bin("mado").into_diagnostic()?; 16 | let assert = cmd.args(["generate-shell-completion", "foo"]).assert(); 17 | assert 18 | .failure() 19 | .stderr(indoc! {" 20 | \u{1b}[1m\u{1b}[31merror:\u{1b}[0m invalid value \'\u{1b}[33mfoo\u{1b}[0m\' for \'\u{1b}[1m\u{1b}[0m\' 21 | [possible values: \u{1b}[32mbash\u{1b}[0m, \u{1b}[32melvish\u{1b}[0m, \u{1b}[32mfish\u{1b}[0m, \u{1b}[32mpowershell\u{1b}[0m, \u{1b}[32mzsh\u{1b}[0m] 22 | 23 | For more information, try \'\u{1b}[1m--help\u{1b}[0m\'. 24 | "}); 25 | Ok(()) 26 | } 27 | -------------------------------------------------------------------------------- /tests/command_invalid.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::Command; 2 | use indoc::formatdoc; 3 | use miette::IntoDiagnostic as _; 4 | use miette::Result; 5 | 6 | #[test] 7 | fn no_command() -> Result<()> { 8 | let mut cmd = Command::cargo_bin("mado").into_diagnostic()?; 9 | let assert = cmd.assert(); 10 | assert.failure(); 11 | Ok(()) 12 | } 13 | 14 | #[test] 15 | fn unknown_command() -> Result<()> { 16 | let mut cmd = Command::cargo_bin("mado").into_diagnostic()?; 17 | let assert = cmd.args(["foobar"]).assert(); 18 | assert.failure().stderr(formatdoc! {" 19 | \u{1b}[1m\u{1b}[31merror:\u{1b}[0m unrecognized subcommand \'\u{1b}[33mfoobar\u{1b}[0m\' 20 | 21 | \u{1b}[1m\u{1b}[4mUsage:\u{1b}[0m \u{1b}[1mmado\u{1b}[0m [OPTIONS] 22 | 23 | For more information, try \'\u{1b}[1m--help\u{1b}[0m\'. 24 | "}); 25 | Ok(()) 26 | } 27 | -------------------------------------------------------------------------------- /typos.toml: -------------------------------------------------------------------------------- 1 | [files] 2 | extend-exclude = [ 3 | # NOTE: Due to false positives caused by hash strings 4 | "flake.nix", 5 | "pkg/homebrew/mado.rb", 6 | "pkg/scoop/mado.json", 7 | ] 8 | --------------------------------------------------------------------------------