A Rust implementation of markdownlint-cli2 v0.22.1 (markdownlint v0.40.0). It is meant to be a drop-in replacement: the same command line, the same .markdownlint-cli2.{jsonc,yaml} and .markdownlint.{jsonc,json,yaml,yml} configuration files, the same inline comments (<!-- markdownlint-disable --> and friends), and byte-identical output.
- All 53 rules of markdownlint v0.40.0 are implemented. Linting the original
test/*.mdcorpus (388 files) with the default configuration produces 3218 errors that match the original byte for byte. A real-world repository with 20966 markdown files (includingnode_modules) produces 264114 identical errors. - Files are linted in parallel. 3x to 12x faster than markdownlint-cli2 depending on the corpus and the machine (see Performance).
- A single static binary. No Node.js required.
With npm (the package is a thin wrapper that runs a prebuilt binary from a platform package installed as an optional dependency: darwin-arm64, darwin-x64, linux-x64, linux-arm64, win32-x64; no postinstall script, no download at install time):
npm i -D @yceffort/rust-markdownlint
npx rust-markdownlint "**/*.md" "#node_modules"Or download a binary for your platform from Releases: macOS (arm64, x86_64), Linux (x86_64, arm64, statically linked with musl), Windows (x86_64). Each archive comes with a .sha256 file.
curl -LO https://github.com/yceffort/rust-markdownlint/releases/latest/download/rust-markdownlint-v0.1.2-aarch64-apple-darwin.tar.gz
tar xzf rust-markdownlint-v0.1.2-aarch64-apple-darwin.tar.gz
./rust-markdownlint --help
rust-markdownlint completions zsh > ~/.zsh/completions/_rust-markdownlint # shell completionTo build from source you need Rust 1.88 or later:
cargo install --git https://github.com/yceffort/rust-markdownlint rust-markdownlint-cliEither way you get a rust-markdownlint binary.
repos:
- repo: https://github.com/yceffort/rust-markdownlint
rev: v0.1.2
hooks:
- id: rust-markdownlint # or rust-markdownlint-fix to apply fixesrust-markdownlint and rust-markdownlint-fix download the release binary for rev the first time they run (verified against the .sha256 file) and need neither cargo nor Node.js, only a POSIX shell (macOS, Linux, Git Bash on Windows). rust-markdownlint-node installs the npm package instead and works wherever pre-commit's node language works; pin the package version with additional_dependencies: ["@yceffort/rust-markdownlint@<version>"] if it should differ from rev. All three lint the staged Markdown files that pre-commit passes in, with the configuration files of your repository.
steps:
- uses: actions/checkout@v4
- uses: yceffort/rust-markdownlint@v0
with:
globs: | # default: **/*.md
docs/**/*.md
"#node_modules"
# config: .markdownlint-cli2.jsonc # passed as --config
# fix: true # leaves fixed files modified, does not commit
# version: v0.1.2 # default: the release matching the action refThe action downloads the release binary (with .sha256 verification), registers a problem matcher so every result becomes an annotation on the pull request, and fails when errors remain (warnings do not fail it, same as the exit code). @v0 and @v0.1 follow the newest release with that prefix; @v0.1.2 pins one.
The command line is the same as markdownlint-cli2. Replace the executable name in your existing commands and scripts.
rust-markdownlint "**/*.md" "#node_modules"
rust-markdownlint --fix "docs/**/*.md"
rust-markdownlint --diff "docs/**/*.md" # print what --fix would change, without writing
rust-markdownlint --config .markdownlint-cli2.jsonc "*.md"
rust-markdownlint --config .markdownlint.yaml --configPointer /config "*.md"
rust-markdownlint --no-globs "README.md"
cat README.md | rust-markdownlint - # lint stdin
cat README.md | rust-markdownlint --format # fix stdin and print the result to stdout
cat docs/x.md | rust-markdownlint --stdin-filename docs/x.md - # lint stdin with docs/ configuration
rust-markdownlint --help
rust-markdownlint completions zsh > ~/.zsh/completions/_rust-markdownlint # shell completion| Argument | Description |
|---|---|
glob0 [glob1] ... |
globby-style globs. A leading ! or # excludes, a leading : is a literal path, everything after -- is a glob |
- |
Lint stdin as a file named stdin |
--config <file> |
Top-level configuration file. The name must be a supported one (.markdownlint-cli2.jsonc etc.) or end with .jsonc, .json, .toml, .yaml, .yml |
--configPointer <pointer> |
JSON Pointer into the --config file |
--fix |
Write fixable errors back to the files |
--diff |
Not in markdownlint-cli2. Print what --fix would write as a unified diff on stdout (git apply takes it as is) and leave the files alone. Wins over --fix and over fix in the configuration, but a configured fix: false still turns the diff off. Exit code 1 when there is something to change |
--format |
Fix stdin and print it to stdout (no banner, progress, or results) |
--no-globs |
Ignore globs from configuration files and use only the command line globs |
--stdin-filename <path> |
Not in markdownlint-cli2. Report stdin as <path> and apply the configuration files of that directory (.markdownlint-cli2.*, .markdownlint.*, ignores), so editors can lint an unsaved buffer with the right settings. The file itself is neither read nor written, and if a glob also matches it only stdin is linted |
--help |
Show help |
server |
Not in markdownlint-cli2. Run a Language Server Protocol server on stdio (see docs/lsp.md) |
completions <shell> |
Not in markdownlint-cli2. Write the bash, zsh, or fish completion script to stdout. The scripts also ship in the release archives under completions/ |
- Configuration cascades per directory exactly like the original:
.markdownlint-cli2.{jsonc,yaml}merges with the parent options,.markdownlint.{jsonc,json,yaml,yml}replaces the parent rule configuration. - Output is byte-identical to markdownlint-cli2 except for the banner line. Results go to stderr, progress (
Finding:,Linting:,Summary:) goes to stdout. - Exit codes: 0 (no errors, or warnings only), 1 (errors), 2 (help, invalid configuration, exception).
rust-markdownlint server runs a Language Server Protocol server on stdio, so Neovim, Helix, and other LSP clients get diagnostics and quick fixes without Node. Diagnostic positions and the quick fix results are the same as the CLI output and --fix.
rust-markdownlint server # JSON-RPC over stdin/stdout, started by the editordocs/lsp.md has Neovim (vim.lsp.config and nvim-lspconfig), Helix, and Zed configuration, the supported requests, and a manual verification checklist.
Options in .markdownlint-cli2.{jsonc,yaml}:
| Option | Supported | Notes |
|---|---|---|
config |
Yes | Rule configuration, including extends |
fix |
Yes | Same as --fix. false in a configuration file overrides the flag |
frontMatter |
Yes | Front matter regular expression (JavaScript syntax) |
gitignore |
Yes | true or a gitignore-style string |
globs |
Yes | |
ignores |
Yes | |
noBanner |
Yes | |
noInlineConfig |
Yes | |
noProgress |
Yes | |
showFound |
Yes | |
customRules |
No | A one-line warning on stderr, then ignored |
markdownItPlugins |
No | A one-line warning on stderr, then ignored |
outputFormatters |
Yes | Built-in ports of the original formatter packages, selected by package name: markdownlint-cli2-formatter-default, -json (name, spaces), -junit (name), -sarif (name), -codequality (name, severity, severityError, severityWarning), -summarize (byFile, byRule, byFileByRule, byRuleByFile), -pretty (appendLink), -template (template). Output files and text are byte-identical to the originals. Any other module id (a custom .cjs/.mjs formatter, an unknown package) is Unable to import module '<id>'. with exit 2, like the original when the module cannot be loaded |
modulePaths |
No | A one-line warning on stderr, then ignored |
Rule configuration supports all 53 rules of markdownlint v0.40.0 (MD001 through MD060, excluding the deprecated ones) with their parameters, aliases, and tags.
- The banner reads
rust-markdownlint v0.1.2 (markdownlint-cli2 v0.22.1 / markdownlint v0.40.0 compatible). Turn onnoBannerif something parses it. - Anything that requires loading JavaScript modules is not supported.
.markdownlint-cli2.{cjs,mjs}and.markdownlint.{cjs,mjs}configuration files are an error (exit 2), andcustomRules,markdownItPlugins,modulePathsare ignored as listed above.outputFormattersworks with the built-in formatters listed above (the original npm packages are not loaded, so-prettydecides on colors and hyperlinks fromFORCE_COLOR,NO_COLOR,FORCE_HYPERLINK, and the terminal like the original does, but with a shorter list of recognized terminals). Use the original if you need custom rules, markdown-it plugins, or a custom formatter module. - Configuration files are parsed with Rust parsers (jsonc-parser, toml, serde-saphyr). Error messages for invalid files keep the original wording where the original tests rely on it (
Unable to parse JSONC content,Invalid TOML document,duplicated mapping key) but the details differ. YAML flow collections are additionally checked with the js-yaml rules (an implicit key must have its:on the line where the key starts, and a multi-line plain scalar must stay indented past the enclosing block), so a JSONC document saved under a.yamlname fails withmissed comma between flow collection entrieslike the original. - File names in the results are sorted with an approximation of ICU
localeComparethat is exact for ASCII. Non-ASCII file names sort by code point. - MD060 measures character width with
unicode-widthinstead ofstring-width. A handful of characters (for example half-width katakana voiced marks) may differ. - The markdown parser is a modified markdown-rs rather than micromark. 12 of the 388 original fixtures have slightly different token structure (lazy continuation lines after fenced code inside lists, for example); rule results are unaffected. Text directives (
:name[label], from micromark-extension-directive) are not recognized; in practice this only showed up when linting binary files, where_inside such a label paired with one outside and produced extra MD049 errors.
hyperfine --warmup 3, mean ± σ in milliseconds, ratio is markdownlint-cli2 / rust-markdownlint. The results of both tools are identical (no diff) in every row.
| Corpus | Machine | markdownlint-cli2 | rust-markdownlint | Ratio |
|---|---|---|---|---|
markdownlint test/*.md, 388 files, all rules |
Apple M-series, 10 cores | 366.2 ± 2.8 | 55.1 ± 1.1 | 6.6x |
markdownlint test/*.md, 388 files, all rules |
GitHub Actions ubuntu-latest | 1267.3 ± 90.4 | 178.4 ± 1.3 | 7.1x |
| Same corpus copied 10 times, 3880 files | Apple M-series, 10 cores | 2956.7 ± 199.6 | 682.6 ± 10.0 | 4.3x |
yceffort/blog, apps/blog/posts/**/*.md, 441 posts (7.2 MB), project config |
Apple M-series, 10 cores | 1411.2 ± 17.5 | 105.0 ± 2.3 | 13.4x |
The same repository, **/*.md including node_modules, 20966 files (single run) |
Apple M-series, 10 cores | 48306 | 14419 | 3.4x |
The 388-file corpus is small enough that process startup dominates both tools. Parallel linting alone made the Rust binary 2.8x faster than its own sequential version on that corpus (159.8 ms to 57.5 ms) and 2.9x on the 10x corpus (1516 ms to 524 ms). Per-rule results and the parallelization comparison are in bench/RESULTS.md.
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspaceTo compare one rule against the original markdownlint expectations, filter the snapshot test by rule name: cargo test -p rust-markdownlint --test rules_snapshot -- MD047. Regenerate the expectations with node scripts/dump-expected.mjs bench/node_modules/markdownlint bench/node_modules/markdownlint-cli2.
The command line behavior is checked against the markdownlint-cli2 test scenarios and their snapshots: cargo test -p rust-markdownlint-cli --test cli2_scenarios (one scenario: CLI2_SCENARIO=<name>). scripts/compare-fix.sh runs --fix with both tools on the 388 fixtures and diffs the results. Scenario list, exclusions, and results are in docs/cli2-scenarios.md.
bench/run.sh runs both tools on the same corpus, diffs the results, and times them with hyperfine (needs node and hyperfine).
bench/run.sh MD047 # one rule
bench/run.sh all # default configuration (all rules, inline config honored)
SCALE=10 bench/run.sh all # corpus copied 10 timesResults are recorded in bench/RESULTS.md. On pull requests, CI benchmarks the changed rules and posts the numbers as a comment.
Bump version in crates/cli/Cargo.toml and push a matching v* tag. The release workflow builds the five platform binaries and uploads them to a GitHub Release. It fails if the tag and the crate version differ.
The same tag also publishes six npm packages: @yceffort/rust-markdownlint-{darwin-arm64,darwin-x64,linux-x64,linux-arm64,win32-x64} (one binary each, built from the same artifacts as the GitHub Release) and then @yceffort/rust-markdownlint (the wrapper, with the platform packages as optionalDependencies). Bump version in npm/rust-markdownlint/package.json (including its optionalDependencies) and in the five npm/platforms/*/package.json too; the workflow fails before building if any of them differs from the tag. The publish-npm job runs after the GitHub Release is created, so a failed npm publish leaves the release in place. It authenticates with the NPM_TOKEN repository secret (an npm granular access token with publish permission for the @yceffort scope) and publishes with --provenance. To switch to npm trusted publishing instead, add a trusted publisher on npmjs.com for each of the six packages (organization or user yceffort, repository rust-markdownlint, workflow release.yml), remove the NODE_AUTH_TOKEN line from the workflow, and make sure the job runs npm 11.5.1 or later (npm install -g npm@latest after setup-node), which is what trusted publishing requires; the id-token: write permission is already there.