Skip to content

Port the doc-style checker to Rust - #8

Closed
MirzaMerdovic wants to merge 25 commits into
mainfrom
rust-port
Closed

MirzaMerdovic wants to merge 25 commits into
mainfrom
rust-port

Conversation

@MirzaMerdovic

@MirzaMerdovic MirzaMerdovic commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This change adds a Rust port of skills/doc-style/scripts/check. The crate is
crates/check, and the binary is doc-style-check. The plugin still runs the bash
checker. The bash checker, the bats suite, and tests/mutate are unchanged.

The crate is a library. main.rs only passes the arguments to run. The crate depends on
the regex crate only, and its tests also use the tempfile crate. Each file holds one
part of the bash checker:

Module Content
lib.rs Command line, file loop, findings, output order, exit status.
strip.rs Markdown mode and comments mode.
patterns.rs The seven pattern rules, copied from PATTERNS without change.
length.rs Sentence splitting and word counts.
directive.rs Inline disable and enable directives.
config.rs .doc-style search, rules, allow lines, and glob matching.

Each rule has its own commit. Some comments compare a Rust idiom with Go.

Behavior

The binary writes the same stdout and stderr and exits with the same status as the bash
checker. Verification:

  • tests/run passes 73 of 73 tests with the bash checker and with
    CHECK="$PWD/target/release/doc-style-check".
  • A differential test compared both checkers on 5186 local files and on 9000 generated
    cases. The only difference is the gawk warning in the list below. The differential test
    scripts are not part of this change.
  • 68 Rust integration tests in crates/check/tests port the checker tests of the bats
    suite. They run against the binary, and against the bash checker when CHECK names it.
  • 34 unit tests cover Markdown stripping, comment parsing, sentence splitting, .doc-style
    glob matching, and the behavior that no bats test covers. Each expected value comes from
    bash.

In a UTF-8 locale, GNU grep, gawk, sed, and bash read [[:space:]] with the glibc class.
That class includes Unicode spaces such as U+3000. The Rust [[:space:]] class is ASCII
only, and the Rust \s class also matches no-break spaces such as U+00A0. The crate
therefore replaces both with the glibc class before it compiles an expression.

Known differences from the bash checker:

  • An unreadable file: the binary prints cannot read FILE and exits 2. The bash checker
    prints the shell and awk errors and exits 0.
  • Invalid UTF-8: gawk prints warning: Invalid multibyte data detected on stderr. The
    binary prints nothing. The findings are the same.
  • .doc-style patterns: the binary supports *, ?, [...], and \. Bash also accepts
    extended globs and character classes such as [[:digit:]] in a [[ == ]] pattern.
  • .doc-style patterns with a backslash: bash and the binary give different results in 47
    of 139,745 generated pairs of pattern and path. In 41 pairs, the pattern holds a * and
    ends with an unpaired \. In 6 pairs, a \ is inside a bracket expression, where bash
    reads it as an escape.
  • Locale: the bash output depends on the locale. With LC_ALL=C, the length snippet is
    cut at 56 bytes instead of 56 characters. The binary output does not depend on the
    locale. It matches the bash output in a UTF-8 locale.

Measurements

BENCH.md records the baseline and the Rust measurements:

Measurement Bash Rust Ratio
Checker on one file 38.2 ms 5.4 ms 7.1
tests/run 3.903 s 2.227 s 1.75

The ratio increases with the number of files in one call. BENCH.md does not hold these
numbers, because the file sets are local Markdown files:

Files Bash Rust Ratio
100 3.052 s 0.032 s 94
1000 33.7 s 0.269 s 125

The bash checker takes about 34 ms per file. The binary takes about 4 ms to compile the
expressions, and then about 0.26 ms per file.

CI

.github/workflows/rust.yml runs on a pull request and on a push to main. It runs only
when the crate, the Cargo files, the toolchain file, the cargo-mutants configuration, or
the workflow changes. The steps are cargo fmt --check, cargo clippy -- -D warnings, a
release build, tests/run against the binary, nproc, and cargo mutants.

  • A pull request tests only the mutants in the lines that it changes, with --in-diff. A
    push to main tests every mutant.
  • cargo test runs the unit tests and the Rust integration tests, and not the bats suite.
    The bats suite runs in its own step against the release binary.
  • On 4 processors, the CI run at commit 36b9666 tested all 194 mutants in 228 s: 185
    caught, 9 unviable, no missed mutants, and no timeouts.

tests/mutate has 76 behavior mutants, and 75 of them have a Rust equivalent. The other
one changes the cut marker of the sentence splitter, and the Rust splitter uses no cut
marker. The bats suite detects each of the 75 equivalents.

Not in this change

  • macOS support. The bash checker needs Bash 4 and GNU grep, so it does not run on a
    default macOS install. The crate passes cargo check --target aarch64-apple-darwin. It
    was not built or run on macOS.
  • A switch of the plugin from the bash checker to the binary.

The baseline holds the mean time of the checker on one fixture, the
mean time of the test suite, and the processor count. The Rust port
measurements go next to it.
The crate ports the shared parts of the bash checker to Rust: the
command line, the exit status, Markdown and comment stripping, inline
directives, the .doc-style file, and the finding format. The rule table
is empty. Each rule follows in its own commit.

A file that exists and cannot be read is an error with exit status 2.
The bash checker prints the read error and exits 0 for that file.
The expression is the idiom entry of PATTERNS in the bash checker,
unchanged.

The fixtures under tests/ need three more rules, because the repository
.doc-style file names them. A copy of tests/fail/idiom.md outside the
repository matches idiom.expected.
The expression is the qualifier entry of PATTERNS in the bash checker,
unchanged. A copy of tests/fail/qualifier.md outside the repository
matches qualifier.expected.
The expression is the filler entry of PATTERNS in the bash checker,
unchanged. A copy of tests/fail/filler.md outside the repository
matches filler.expected.
The expression is the anthropomorphism entry of PATTERNS in the bash
checker, unchanged. A copy of tests/fail/anthropomorphism.md outside the
repository matches anthropomorphism.expected.
The expression is the hype entry of PATTERNS in the bash checker,
unchanged. With this rule, the repository .doc-style file names only
known rules, and the fixture tests under tests/ run. The pass fixtures
and the hype fixture pass.
The expression is the tone entry of PATTERNS in the bash checker,
unchanged. The source code exemptions for a task marker line and a
trailing ? are part of the crate since its first commit.
The expression is the chain entry of PATTERNS in the bash checker,
unchanged. Every pattern rule is now ported.
The lines of a paragraph are joined and cut at each sentence terminator,
as in the long_sentences awk program of the bash checker. A finding
reports the line where the sentence starts. Every bats test passes
against the release binary.
GNU grep, gawk, sed, and bash read [[:space:]] with the glibc class of
the UTF-8 locale. That class holds the Unicode spaces U+1680, U+2000 to
U+2006, U+2008 to U+200A, U+2028, U+2029, U+205F, and U+3000. It does
not hold the no-break spaces. The Rust [[:space:]] class is ASCII only,
and the Rust \s class holds the no-break spaces.

Every expression now compiles with the glibc class, and is_space uses
it. A snippet then drops leading Unicode spaces, and a Unicode space no
longer counts as a word.
The tests call markdown() and sentences() directly. They cover front
matter, fences, indented code, inline code, HTML comments, directives,
sentence terminators, wrapped lines, list markers, and the lines that
end a paragraph. The bats fixtures stay the conformance tests.
The conformance test runs tests/run with CHECK set to the binary of the
crate. cargo test then runs the bats suite, and cargo mutants tests each
mutant against the fixtures. The test needs bats-core on PATH.

.cargo/mutants.toml excludes main.rs. It copies the .git entry into the
build directory, because the bats suite runs git grep.
Paragraph::add appended start lines in a while loop. The cargo-mutants
mutant that replaces <= with > in the loop condition appends without
end. A unit test then grew to 20 GB before the mutant timeout, and the
kernel stopped the session. resize has no mutant that grows the list
without bound.
The workflow runs on a pull request that changes the crate or one of its
build files: crates/**, the workspace manifest, the lock file, the
toolchain file, .cargo/, and the workflow. It runs cargo fmt, cargo
clippy, and a release build, then tests/run against the release binary,
then cargo mutants with one job per processor. nproc has its own step,
and the mutation step prints its duration.

dtolnay/rust-toolchain installs the toolchain, because mise.toml does
not hold Rust. taiki-e/install-action installs a pinned cargo-mutants
binary. mise-action installs bats-core.
The Rust port section holds the three measurements of the baseline,
taken with the release binary, and the ratio of each. The mutation
testing section holds the cargo-mutants totals and the wall-clock time.
c_block_start and trailing iterate over str::match_indices, and find iterates over
Path::ancestors. Before this change, a cargo-mutants mutant of the index arithmetic in
these loops made a loop run without end, and cargo-mutants reported a timeout.

The block comment search and the trailing comment search now give one list of comment
starts, and min_by_key selects the first. The output does not change. The bats suite
passes, and the previous binary and this binary give the same output on 8000 local files.
glob_match and bracket match slice patterns and recurse on the rest of the pattern, and
in_set reads the members of a bracket expression. No loop depends on index arithmetic, so
no cargo-mutants mutant makes the match run without end.

The new matcher and the previous matcher give the same result on 139,745 generated pairs
of pattern and path. Unit tests cover *, ?, bracket expressions, negation, an
unterminated bracket, and the backslash. Each expected result comes from bash.
The tests cover these cases:

- The -h option.
- The tone exemption in source code.
- The compound join.
- An empty list item.
- A whitespace line in an indented block.
- The order of comment starts on a line.
- The single-quote count before a trailing comment.

Each expected value comes from the bash checker or from its strip_code and sed steps.

cargo mutants now reports 194 mutants: 185 caught, 9 unviable, no missed mutants, and no
timeouts.
The table now holds the run at commit b6dcc29, and each section names the commit that
it measured.
fail.rs, pass.rs, and fixtures.rs hold the tests of fail.bats, pass.bats, and
fixtures.bats. Each test runs the checker as a separate process and reads the existing
fixtures and .expected files. The CHECK environment variable selects the checker, as in
the bats suite, so the same tests also run the bash checker.

A fail test compares stdout with the .expected file byte for byte and requires an empty
stderr. The bats suite still runs, and the conformance test still runs it.
cli.rs, config.rs, and suppress.rs hold the tests of cli.bats, config.bats, and
suppress.bats. A test that needs a git repository uses Repo, a temporary directory from
the tempfile crate with a .git directory. Each test runs against the Rust binary, and
against the bash checker when CHECK names it.
The Rust integration tests now cover the checker tests of the bats suite, so cargo test
and cargo mutants no longer run bats. cargo mutants reports no missed mutants without it.
A mutant now takes about 0.7 s to test instead of about 11 s.

The bats suite still runs in CI against the release binary. The copy of the tree for
cargo mutants no longer needs the .git entry, because no test runs git.
A pull request runs cargo mutants with --in-diff, on the diff between its merge commit and
the base branch. A push to main runs every mutant. The checkout fetches two commits, so
that the first parent of the merge commit is available.

A pull request that changes only tests or the workflow tests no mutants. The full run on
main is then the first run that detects a test change that removes coverage.
Port the checker tests from bats to Rust
@MirzaMerdovic

Copy link
Copy Markdown
Contributor Author

Superseded by #10, which removes the checker instead of porting it.

@MirzaMerdovic
MirzaMerdovic deleted the rust-port branch October 9, 2026 20:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant