---
title: 'The Real Cost of <em>Drop-in Compatibility</em>: What I Threw Away Porting markdownlint-cli2 to Rust'
tags:
  - rust
  - markdown
  - ai
  - code-generation
  - testing
  - compatibility
published: true
date: 2026-09-08 21:00:00
description: 'I ported markdownlint-cli2 to Rust and matched 264,114 diagnostics on this blog repository byte for byte. The commits that ported 51 rules landed in a single day, but turning that into a tool you could actually swap in took two weeks. A record of giving up a parser 37 times faster, correcting two performance misreadings, and finding 11 more compatibility differences in an adversarial review.'
thumbnail: /thumbnails/2026/09/porting-markdownlint-cli2-to-rust.png
art:
  undraw: algorithm-execution
---

## Table of Contents

## Could I Do That Too

In May I wrote a post after seeing [the news that Bun had moved its codebase from Zig to Rust](/2026/05/bun-rust-rewrite-real-story). That post was about governance, but one thing stayed with me after I finished it. Jarred Sumner was a frontend developer like me, and he started Bun from the same frustration with the speed and weight of JavaScript tooling. I had wrestled with the borrow checker (the mechanism that enforces ownership and lifetimes of references at compile time) while writing a [Rust for JavaScript developers](/2022/02/rust-for-javascript-developer-chapter1) series in 2022, and I had touched wasm a few times, but I had never taken Rust into real work. If someone from the same background could attach agents and switch languages wholesale, maybe I could move one much smaller thing. That curiosity was the start.

I picked the target from what I use every day: [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) v0.22.1, the tool that lints this blog's markdown. The new name is [rust-markdownlint](https://github.com/yceffort/rust-markdownlint), and there was one goal. The command line, the configuration files, the inline comments, the output, the exit code, and the `--fix` results had to be byte-identical to the original. Today, running both tools over this blog's 20,966 markdown files (node_modules included) produces 264,114 errors without a single character of difference. Where the time went before I could write that sentence, though, was not what I expected.

The repository's first commit is August 24 and the last is September 7. That is fifteen days on the calendar and ten days with commits. The commits that ported 51 of the 53 rules are packed between 12:06 and 17:52 on August 26. Before and after that, I was building the parser and CLI skeleton, comparing output against the original, and polishing distribution and performance. On the 27th I compared nine real-world repositories and the original's command-line scenarios and fixed JavaScript semantics differences. On the 28th and 29th I added an LSP server (a Language Server Protocol implementation that feeds diagnostics to editors) and `--diff`, and discovered the speed gap with rumdl along the way. After a five-day break, on September 6 I fixed the 11 issues the adversarial review found and shipped v0.1.3, and on the 7th I measured the remaining speed gap and reworked the output path. That was the end.

Commit dates do not tell you the ratio of working time. They do show that after most rules were ported, plenty of work remained before the tool could replace the original. This post is a record of the decisions made along the way.

What I took from Bun was the method. The Bun team ran Claude agents in parallel to move 535,496 lines of Zig to Rust in 11 days and verified the new implementation with the existing TypeScript test suite. I followed the same recipe at a much smaller scale. The original program and its test material existed, but the system to connect the Rust implementation to them and compare had to be built by hand.

> Every number in this post comes from documents and bench records inside the repository. The compatibility target is markdownlint-cli2 v0.22.1 (markdownlint v0.40.0); the three-tool performance comparison on September 7 used cli2 v0.23.2. The parser is a modified markdown-rs 1.0.0, and rumdl, the comparison tool, was 0.2.61 in the August measurements and 0.2.67 on September 7. Speed was measured with `hyperfine --warmup 3` on a 10-core Apple M-series machine, except the three-tool comparison on September 7, which ran on a GitHub Codespaces 4 vCPU instance (AMD EPYC 7763, Ubuntu 24.04). Detailed conditions are in each section. Coding agents were used for the rule port and the adversarial review (Claude Code, and Codex for the review), and the text distinguishes which judgments were human and which were the agent's.

## Making the Original the Verification Standard

markdownlint-cli2 is a Node.js tool. It gathers markdown files with a glob (a wildcard path pattern like `**/*.md`), runs the 53 markdownlint rules, and prints results in the form `file:line:column rule description`. My reasons for wanting it in Rust were the usual ones: a single binary without Node, and per-file parallelism. If the goal were "a fast markdown linter", though, [rumdl](https://github.com/rvben/rumdl) already exists. My goal was different. The existing `.markdownlint-cli2.jsonc` and `.markdownlint.jsonc` had to work without changing a single character, with only the executable name swapped, and the results had to be the same.

I wrote this definition as four lines in the first section of the design document. Read the same configuration files unmodified; produce the same positions, rules, messages, and exit code for the same input; write the same files under `--fix`; pass the original repository's test fixtures (input files with predetermined expected results). Almost every later decision derives from those four lines. What not to build was settled here as much as what to build: JavaScript plugins (`customRules`, `markdownItPlugins`) and `.cjs`/`.mjs` configuration files were out of scope from the start, and instead of silently ignoring such keys the tool prints a warning.

Where this differed from Bun was in how existing tests could be connected to the new implementation. Bun's TypeScript tests run regardless of the runtime's implementation language. markdownlint's tests are bound to the JavaScript API and cannot run against a Rust implementation as they are, but the input fixtures and expected output could be taken over. On top of that I made **the original program itself** the reference, building a harness that feeds the same input to both programs and diffs the output. Passing a test is evidence of sameness on that input. To know how far the sameness extends, the inputs and the scope of comparison had to be widened, and that work turned out larger than expected.

## Choosing the Parser Half-Decided Everything

markdownlint rules run on micromark's token tree. micromark is a CommonMark (the markdown standard specification) parser, but instead of the usual AST (mdast, a tree organized into semantic units like paragraphs and headings) it produces the concrete token tree underneath (a tree that keeps every byte of the source as a token, down to whitespace, line endings, and individual symbols). Every byte of the file is explained by tokens like `atxHeadingSequence`, `whitespace`, `lineEnding`, and `listItemPrefix`. The rules walk these tokens and ask "is there a whitespace token before the heading" or "how long is the list item prefix token". The rule definitions are bound to micromark's token structure.

No Rust crate exposes these tokens as a public API. pulldown-cmark's events are too coarse and it has no autolink literal (the GFM syntax where a bare `https://…` without angle brackets becomes a link on its own). comrak has no byte offsets (where in the source a token starts and ends). Re-implementing the rules by rescanning the source on top of mdast was an option, but then half the rules become reinterpretations and the basis for verifying compatibility disappears. What remained was [markdown-rs](https://github.com/wooorm/markdown-rs). It is a 1:1 port by micromark's own author, so the same token events exist inside. Its public API is only mdast and HTML, however, so I vendored the whole 1.0.0 crate into the repository (43,802 lines) and patched it to open the internal modules with `pub`. On top of that sits an adapter that builds a tree shaped like the original `MicromarkToken`.

The patches started at 3 and now number 15. The first ones were structural, like exposing modules and recording failed reference links. The later ones are different in kind: they revert places where markdown-rs behaves subtly differently from micromark back to micromark's behavior. A line starting with `2.` in a paragraph that begins right after a list closes being cut into a new list, the inner `#` of `## # a` not landing in the body text, an autolink literal starting inside an open `[` and swallowing the `]` into the URL. None of these were CommonMark interpretation questions. They were accidental differences between two implementations, and those differences showed up as rule result differences. This story grows later.

## Porting 51 Rules in a Single Day

While writing the design document I created 71 GitHub issues up front: 14 for the skeleton, 53 for one rule each, 4 for wrap-up. Each rule issue carried the original source path (`lib/md0XX.mjs`), the documentation path, the test fixture path, and a parameter table in its body, because one issue was the unit of work handed to an agent.

The skeleton took two days, August 24 and 25. Vendor markdown-rs and open its event API, build the micromark-shaped token tree adapter, compare it against a token oracle (oracle: a reference implementation that produces the correct answer) made by dumping the 388 original fixtures through JS micromark, then the rule trait and registry, configuration loading and `extends`, front matter (the metadata block wrapped in `---` at the top of a document) and inline comments (configuration comments inside the body like `<!-- markdownlint-disable -->`), `--fix` application, and the CLI's argument parsing and glob enumeration. Only two rules, MD047 and MD018, were ported at this stage as samples. On the morning of the 26th the per-directory configuration cascade, output formatting and exit codes, and the per-rule snapshot (a testing method that saves a once-verified output to a file and compares later runs against it) and bench harnesses went in, and the first rule commit landed at 12:06. The rule port ran in batches. Each batch picked around 10 rules, and one sub-agent per rule was launched in parallel, isolated in its own git worktree. Each agent could touch four files: one rule file, one line in `mod.rs`, the registry, and the snapshot test list. Port the original rule function by function, 1:1, and when that rule's fixture snapshot matches the original's expected value, it is done. When the agents finished I cherry-picked in completion order into a stacked PR (each PR built on the previous PR's branch), and ran the bench once per rule at the end of the stack in a separate commit. CI on every PR benchmarked the changed rules and left a comment.

The procedure has the same structure as Bun's. Split the work by file, run agents in parallel, and have a human resolve conflicts by deciding the order. Only the scale differs: Bun had up to 64 agents and 6,502 commits, here it was 10 per batch and, up to v0.1.3, 279 commits across 116 PRs. The commit timestamps show five batches landing at 12:00, 14:30, 15:30, 16:00, and 17:30 with 13, 11, 10, 10, and 7 rules each (MD019 and MD021 share one file, as in the original). And most rules passed their fixtures on the first try. All 10 in batch 3 matched at once. What failed was the parser, not the rules. When MD029 and MD027 failed, the cause was not the rule code but markdown-rs's container-exit handling and the adapter's whitespace assignment, so parser-fix PRs had to be inserted in the middle of the stack.

That was three days. Because the two sample rules and the skeleton came first, the port of the remaining 51 rules could be gathered into a single day. By 18:30 on August 26, the 53 rules produced 3,218 errors on the 388 original fixtures byte-identical to the original, per-file parallelism with rayon was in, and a workflow that cuts a release when a tag is pushed was attached. At that point I thought it was nearly done.

## Is It the Same Outside the Fixtures

The 388 fixtures are files the original repository made to test its rules. They are dense with the patterns the rules should catch, but low in diversity. No Korean, no emoji, no pnpm symlinks. So that evening, thirty minutes after attaching the release workflow, I fed this blog's entire repository to both tools as `**/*.md`. The results differed. Six bugs came out, and three of them were in places that had nothing to do with markdown parsing.

| Finding                                              | Cause                                                                                            |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Panic when an HTML comment contains Korean           | Comment body replaced with `.`, then indices computed against the original length                |
| Panic in range validation on a column after an emoji | Parser columns are UTF-16 code units (JS `.length`) but validation counted code points           |
| Files under pnpm's node_modules not enumerated       | fast-glob follows symlinks (`followSymbolicLinks: true`); the `ignore` crate does not by default |
| MD003 false positive on `## # a`                     | markdown-rs does not put the inner `#` into the heading text                                     |
| MD052 missed on `[a][https://x]y`                    | An autolink literal starts inside the open `[`                                                   |
| `apps/blog/posts/**/*.md` walks all of node_modules  | Filtering after enumeration; fast-glob prunes during traversal                                   |

These six changed the direction. Once it was confirmed that fixtures alone could not support "the same", evidence for that claim had to be stacked one layer at a time. What got stacked is, in the end, this table. Two of the six, though, existed before the rules. The token oracle was built on August 25 to verify the adapter when no rule existed yet, and the rule snapshot harness was built on the morning of the 26th, right before launching the first rule batch. The other four appeared between the night of the 26th and the 27th.

| Comparison         | Target                                                                  | Result                        | What only this comparison caught                                                                          |
| ------------------ | ----------------------------------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------- |
| Token oracle       | 388 original fixtures dumped through JS micromark, token trees compared | 376/388 match                 | Structural differences in parser and adapter, including ones invisible in rule results                    |
| Rule snapshots     | 388 fixtures, default config, 3,218 errors                              | Byte match                    | Rule porting errors                                                                                       |
| cli2 scenarios     | 216 original CLI tests minus 57 that need JS loading                    | 159 pass, 0 known differences | Glob negation order, `./` and `../`, config file error wording, js-yaml's flow collection rejection rules |
| `--fix` comparison | 174 of 388 fixtures change                                              | 0 file diff                   | Fix application order and line-ending majority vote                                                       |
| 9 real-world repos | airflow, electron, eslint, mocha and more: 1,534 files, 1,825 errors    | 6 diff lines, 1 cause         | MD013 `Actual` off by 1 on lines with emoji; `line.length` is UTF-16                                      |
| Blog repository    | 20,966 files, 264,114 errors                                            | 0 diff                        | The 6 items in the table above                                                                            |

Each comparison exposed a different kind of difference. The token oracle revealed 12 files whose token structure differed even though rule results matched (things like lazy continuation, a following line continued without indentation, after fenced code, a code block wrapped in three backticks, inside a list). The cli2 scenarios caught command-line and configuration behavior. The real-world repositories revealed that the UTF-16 length problem existed in 17 more places using `chars().count()` beyond column computation. Adding input files alone cannot confirm differences in command-line behavior or in the files `--fix` writes. What to feed in and what to compare had to be widened together.

## Fixing to the Spec Breaks Compatibility

Gathering the differences from the six comparisons shows a common thread. Almost none are differences in interpreting markdown. Most are differences in **how JavaScript does the job**.

`line.length` is a count of UTF-16 code units, so an emoji counts as 2. `fs.readFile(file, "utf8")` replaces invalid bytes with U+FFFD and keeps reading. So when a png slipped into the glob, the original linted the png to the end and reported 2,605 errors, while rust-markdownlint, using `read_to_string`, died with exit 2. micromark replaces NUL with U+FFFD in preprocessing before tokenizing, but `token.text` is a slice of the original so the NUL remains; markdown-rs only replaces it in HTML output, so a NUL next to `_` was classified differently in emphasis detection and MD049 results diverged. I matched it by parsing the substituted text and mapping event byte indices back to the original.

The differences from YAML configuration files are the extreme of this type. A JSONC document saved under a `.yaml` name (with `// Comment` lines) is rejected by the original's js-yaml with `missed comma between flow collection entries`. The new implementation's serde-saphyr follows the YAML spec and parses it fine as `{ "// Comment \"config\"": ... }`. Judged by the spec alone, the new implementation is right. Judged by compatibility, it is wrong. In the end I added code that scans the scanner tokens before parsing YAML and reproduces js-yaml's rules (inside a flow collection, the `{}` or `[]` notation, an implicit key's `:` must be on the line where the key started, and the continuation lines of a multi-line plain scalar, an unquoted string, must be indented at least one column more than the enclosing block). A library that behaves to spec, wrapped to behave against it.

The same thing happened in the parser. In `**a [b] c**`, micromark leaves the unmatched `[` as a separate top-level token, while markdown-rs merges it into a single data token, and MD036 produced a false positive. The cause is resolver order (a resolver is the post-processing step that walks the event list again after tokenization to pair and merge things). micromark runs data merging before the label (link brackets) and attention (`*` and `_` emphasis markers) resolvers; markdown-rs runs them the other way round. markdown-rs's order looks tidier, but the results differ, so it had to be reverted. Changing the order broke other code that uses event indices, so I added a resolver at the very end that merges everything, records the boundaries, and restores only what micromark would have left.

The command line was no different. I did not use clap as the argument parser. The original does not support the `--flag=value` form and treats an unknown `--xyz` as a glob pattern; clap cannot reproduce either behavior. I wrote a single-pass parser matching the original. Glob enumeration could not simply use globset either. globby (the Node glob library the original uses, built on fast-glob) applies a negative pattern only to the positive patterns that came before it, prunes whole directories during traversal for patterns like `#**/node_modules`, and follows symlinks. I matched all three while reading the fast-glob source.

At this point it became clear what compatibility was actually with. Not markdownlint's documented rules. It was **JavaScript's string length unit, Node's file reading, js-yaml's parser bug, micromark's resolver order, and globby's pattern interpretation order**. None of these are written down as a spec anywhere, and the original author most likely did not build them intentionally. But users' configuration files and documents have been running on them for six years, and the promise of drop-in includes that. Among the rules themselves, MD027, MD037, MD038, and MD051 through MD053 are defined by micromark's token quirks (behavior that was not designed but has solidified), so fixing the parser to be "more correct" makes the rules wrong.

## Why I Did Not Use a Parser 37 Times Faster

With compatibility in hand I looked at speed. On the 388 fixtures, dominated by process startup, it was 6.6x (366.2ms vs 55.1ms); on this blog's 441 posts (7.2MB) it was 13.4x (1,411.2ms vs 105.0ms). Divide by cores and the story changes. Removing the 10-core parallelism, single-threaded it was a little over 3x, and more than half of that was parsing. Profiling with samply (a sampling profiler) put the markdown-rs tokenizer at 55%, the adapter that turns events into a token tree at 15%, and the 53 rules at 25%.

So I considered replacing the parser. pulldown-cmark parses the same 441 files in 9.8ms. markdown-rs took 365.6ms, and with the adapter 524.7ms. A 37x difference. On those numbers alone there seems no reason not to switch.

| Stage                                        | Time (441 posts, single thread, best of 10) |
| -------------------------------------------- | ------------------------------------------- |
| pulldown-cmark 0.13.4, consuming events only | 9.8ms                                       |
| markdown-rs `parser::parse`, events only     | 365.6ms                                     |
| markdown-rs + adapter, 582,321 tokens        | 524.7ms                                     |
| Full `lint_content`, 53 rules                | 643.9ms                                     |

The rule cost tells a different story. With parsing and the adapter removed, the rules and everything else (inline configuration, HTML comment substitution, line splitting) cost 119ms. Even at 0ms for parser and adapter, 119ms remains, and since the original cli2 takes 1,475ms on the same corpus, the per-core ceiling is 12.4x. The target written in the issue was 20x per core, which requires under 74ms. That is less than the rule cost. No parser could reach that number without touching the rules.

Besides, pulldown-cmark's 9.8ms only consumes events. It excludes the cost of building the 580,000 tokens the rules use and linking them into a tree. The rules and helpers reference 89 kinds of micromark tokens by string, and pulldown-cmark events cover about 20 of them with ranges. The rest, like `linePrefix`, `listItemPrefix`, `codeFencedFenceInfo`, and `undefinedReference`, would have to be re-cut from the source, and that reconstruction code is an estimated 2,000 to 3,000 lines. Comparing block structure, pulldown-cmark and markdown-rs agreed 99%, and every disagreement was a semantic difference in the `$` math extension. Matching that means vendoring and patching pulldown-cmark too, which repeats what was done in markdown-rs in a codebase further from micromark's design.

The conclusion was not to switch, and I documented the reasoning. Instead I cut inside the existing parser. Replacing the adapter's per-token `String` allocation with static kinds and source ranges took 159ms to 45ms; replacing the markdown-rs tokenizer's EditMap (an internal structure collecting insertions and deletions to apply to the event list) with a BTreeMap and skipping the table head scan with a pre-check took 320ms to 203ms; moving the 53 rules' per-file regex compilation into LazyLock and removing the per-token `children.clone()` took 125ms to 44ms.

And here I measured wrong twice. The first was the comparison target. Comparing against rumdl, both tools came out the same between 90ms and 105ms, and I wrote down "tied". Measuring again on August 29, rumdl was 2.3x faster (234.5ms vs 101.9ms, 443 posts, hyperfine (a command-line benchmarking tool), 50 runs). The first comparison had used this blog's `.markdownlint.json` as is, and that file sets `default: false` to turn most rules off. With the rules off, both tools were bound by glob and file IO and looked the same.

The second was the cause. Turning rules on one at a time and timing the CLI, I attributed the 56ms added going from 0 rules to 1 to parsing, the remaining 145ms to the rule bodies, and 47ms of that to MD013. I wrote it in the issue title. Digging again on September 6, most of that 145ms was not the rules. The CLI time difference between rules on and off includes, besides the rule body, the cost of collecting, sorting, and printing diagnostics, and MD013 alone produces 15,138 diagnostics on this corpus. And the sort comparator `locale_compare` was building a new `Vec` of sort keys for both filenames on every comparison, and two more case-fold keys when the primary keys were equal. Comparing diagnostics within the same file was no exception. Returning immediately for identical strings and comparing the rest through iterators took sorting 16,764 diagnostics from 128.0ms to 23.2ms and the whole CLI from 343.1ms to 179.0ms (Apple M1, 445 posts, 20-run mean). In the same spot rumdl 0.2.61 was 175.9ms. Timing only `rule.check` after parsing and line splitting are done, MD013 is about 13ms. It was never a 47ms rule.

After fixing the sort, I looked at the remaining gap again on September 7 in GitHub Codespaces (4 vCPU, Ubuntu 24.04). An A/B of the allocator and link-time optimization in the same session showed jemalloc (a memory allocator) cutting 7.3% and Thin LTO (an option that optimizes across module boundaries at link time) cutting 3.6% on the blog corpus, and the mean gap to rumdl in that session shrank from 43.3ms to 10.8ms. With both made release-build defaults (jemalloc for the Linux CLI only) and the three tools measured with the static musl (a C library for static linking) build that releases ship, the numbers look like this. Each tool ran 24 times after 3 warm-ups, cycling through six execution orders, measured from process start to exit.

| Corpus                                    | rust-markdownlint | rumdl 0.2.67 | markdownlint-cli2 0.23.2 |
| ----------------------------------------- | ----------------: | -----------: | -----------------------: |
| Blog posts, 445 files (7.50MB)            |      434.4 ± 16.1 |  380.1 ± 5.3 |          4,792.9 ± 104.4 |
| markdownlint fixtures, 388 files (0.25MB) |        83.8 ± 2.4 |   89.8 ± 1.2 |           1,192.4 ± 23.6 |
| Fixtures copied 10x, 3,880 files (2.45MB) |      762.9 ± 25.9 | 748.3 ± 14.6 |          6,501.8 ± 157.1 |

rumdl has a different rule set and reports 17,527 diagnostics on the blog corpus versus 16,764 for rust-markdownlint and cli2, so these are not timings of the same work. With that in mind, rust-markdownlint is 14% slower than rumdl on the blog corpus, faster on the fixtures, and within 2% on the 10x corpus. Single-threaded stage instrumentation the same day, after the adapter and rule cuts, put the markdown-rs parser body at 74% of core time, the adapter at 10%, and the 53 rules at 12%, with MD013 at 27.3ms, 2.5% of the whole. To cut internal execution time further, the next place to look is the parser body I decided not to replace. That share alone does not explain the whole time difference with rumdl, though. On these corpora the tool was about 8.5x to 14x faster than cli2, and traded places with rumdl.

That afternoon I cut one more place. The default formatter was building an intermediate string per diagnostic and writing each one straight to stderr. Buffering in 64KiB chunks and writing the fields directly took write calls on the blog corpus from 33,531 to 32. A paired measurement of musl builds in the same session gave 84.1ms to 78.3ms on the fixtures (6.9%), 434.9ms to 408.5ms on the blog (6.1%), and 771.5ms to 710.0ms on the 10x corpus (8.0%). The three-tool table above was measured before this change. The same experiment also tried a candidate that moves ownership of the parser's event vectors, but its improvement stayed within measurement noise and it was not adopted. After sorting, output too was cut not in the rules or the parser but on the path that emits diagnostics.

## I Handed What Six Comparisons Missed to a Reviewer

The most striking part of the methodology in the Bun post was the adversarial reviewer. Two or more review agents were attached to each implementation agent, and the reviewers were given only the diff without the original Zig code and told to "assume this code is wrong". Those reviewers caught, before merge, one use-after-free, one `trunc` that should have been `floor`, and one `unwrap_or` that panicked before checking its condition.

After releasing v0.1.2 I tried the same thing, but changed what the reviewer was given. Not a diff but an oracle. I gave Codex the repository and the original cli2 0.22.1 in `bench/node_modules`, with these instructions. Reading code and saying it looks risky is not a finding. Only actually running both tools and getting different output is a finding. For each finding, fill in the input file, the configuration, the original output, the target output, and the suspected cause. Do not redo the six comparisons already verified; probe where they cannot see. Non-default combinations of rule parameters, inline comment variants, configuration file edges, input bytes, command-line edges, determinism of parallel execution, built-in formatters, and inputs outside the conditions under which the 15 markdown-rs patches claim "the results are the same".

It ran about 1,600 times. 323 rule option combinations, a 1,113-file parser boundary corpus aimed at the 15 patches, 29 `--fix` cases run twice each, 26 formatter cases, 26 command-line and configuration cases, 82 inline comment and byte boundary cases. There were 11 findings and 1 auxiliary one.

The most serious was MD044. With `names: ["K", "S"]` in the configuration, running `--fix` on `AKB AſB` (Kelvin sign U+212A and long s U+017F) leaves the file untouched in the original but rust-markdownlint rewrites it to `AKB ASB`. Both exit 0 with no output. Only the file differs. The cause is that Rust `regex`'s `(?i)` does Unicode case folding (folding characters to a canonical form to ignore case), while the original's `new RegExp(..., "gi")` has no `u` flag and folds ASCII only. Low probability, but the worst failure this project defined. The file silently changes.

The most realistic one for Korean users was front matter. Given a custom `frontMatter` pattern of `^\w+\n`, when the first line is `한글` the original treats it as body text and reports MD041, while the new implementation swallows it as front matter and exits 0. JavaScript's `\w` is ASCII even with the `u` flag; Rust's `\w` is Unicode. MD051's `ignored_pattern` and MD025 and MD041's `front_matter_title` diverged for the same reason.

Some came from neither rules nor parser. With a symlink cycle like `sub/loop -> ..` and a finite glob like `sub/*/sub/a.md`, fast-glob follows the path and finds the file, while the `ignore` crate treats a symlink pointing back to an ancestor as an error and stops. `flatten()` discarded that error, so the file silently dropped out and the exit code was 0.

Grouped by cause, the 11 were only three kinds.

| Cause                    | Count       | Content                                                                                                                                            |
| ------------------------ | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| JavaScript regex dialect | 7           | The range of `\w`, `[^]` (any character in JS), `[]]`, and invalid regexes the original reports as rule exceptions but the target silently ignores |
| JavaScript type coercion | 2 (9 rules) | The original does `Number("3")`, `String(7)`, and throws calling `.map` on a non-array. The target substituted defaults                            |
| Runtime libraries        | 2           | fast-glob's symlink cycle handling, Node error object output format                                                                                |

The findings clustered in the interpretation of configuration values and the behavior of runtime libraries. The six existing comparisons had configuration and error scenarios, but did not sufficiently touch regex dialects, values of unexpected types, or symlink cycles. The 15 markdown-rs patches I expected to be weakest did not diverge once across 1,113 files. Those patches, though, are places where a difference had already been found, fixed, and verified. What this result says is that the places already suspected and checked held, and more differences came from the places where I had assumed the two languages behave the same.

The Bun post's regressions showed a similar boundary. Zig's `assert()` moved to Rust's `debug_assert!`, so the check vanished in release; Zig's `reinterpretSlice()`, which ignored odd bytes, moved to `bytemuck::cast_slice`, which panics. It is not a law explaining every error in both ports, but it is reason enough, in the next review, to start where the language and its libraries decide behavior: string length, regexes, number conversion, file reading.

## Where Does Compatibility End

Fixing the 11 touched 21 source files, adding 1,528 lines and removing 236. How they were fixed is closer to this post's subject than the findings themselves.

I wrote a new translator from JavaScript regexes to Rust regexes. `\w`, `\d`, and `\b` become ASCII regardless of the `u` flag; `.` excludes not only LF but CR, U+2028, and U+2029; `[^]` becomes any character; `[]` becomes a class matching nothing; `[`, `&`, and `~` inside a class are escaped because they are metacharacters in Rust; undefined escapes like `\z` become the literal character. When compilation fails, the tool reproduces V8's `Invalid regular expression: /src/flags: reason` wording verbatim as the rule failure message. To match `Number("3")`, I implemented ECMAScript's StringToNumber in full: the `0x`, `0o`, `0b` prefixes, `Infinity`, the decimal literal grammar, and the reverse direction `Number.prototype.toString` (exponent notation at 1e21 and above, `-0` as `0`).

The furthest I went was MD044. When numbers are mixed into `names`, the original throws `a.localeCompare is not a function`, and which element the message comes from depends on the order in which V8 calls the comparator while sorting the array. V8's TimSort (its array sorting algorithm) does binary insertion in a single run for fewer than 64 elements, and I added a function that walks that exact call order to find the first TypeError. I also reproduced the semantics of `handleRuleFailures`: when a rule throws, emit one error on line 1 and drop all further errors from that rule. Cases where `padEnd` exceeds its limit or `repeat(Infinity)` occurs come out as rule failures too.

There is a place where I stopped. When the file `extends` points to does not exist or is unreadable, the original prints Node's Error object to stderr in full, stack trace and `cause` included. The Rust implementation prints one line: `Error: Unable to use configuration file '...'; No such file or directory (os error 2)`. Both exit 2. I did not match this. The banner string likewise prints this tool's name instead of the original's.

Here is where the line was drawn. **Chase to the end anything that makes the result silently differ; when the fact of failure is the same, leave the form of the failure alone.** Following V8's sort order was worth it because it makes the difference between exit 1 and a line on stderr; not following Node's stack trace was fine because it is a difference in form inside exit 2. With a criterion, "how far did you go" becomes a judgment rather than a boast, I think.

Running the same 1,600 cases after the fixes leaves three differences. The banner, the Node error format, and the case where `extends` is circular: the original does not finish within 10 seconds and the new implementation died of a stack overflow. The last had no reference for "the same" because the original never produces a normal exit, but dying had to be fixed regardless of compatibility. It now detects the cycle and exits 2 with an error that the configuration file cannot be used, and the README records this together with the observation that the original did not terminate within 10 seconds. After the banner and the error format, it is the third item judged to sit outside the compatibility line.

## What to Do First in the Next Port

The way of working taken from Bun was usable here too. Divide into issues, run agents isolated in worktrees in parallel, and confirm completion against the original's expected output. That 51 rules could be ported in one day owed much, I think, to clearly defined units of work and to having the parser adapter, token oracle, and rule snapshot harness ready beforehand.

Next time I intend to prepare the comparisons against real-world input and configuration edges earlier too. Feed documents from external repositories and non-default configurations into the harness that runs the original with the same arguments, and compare as each rule is implemented. This time, many comparisons were added only after the rule port. Preparing them up front will not make all the later work disappear, but it should reduce revisiting the same assumption across many already-ported rules.

I intend to have reviewers check language boundaries and the values users can put in configuration first. The bar for a finding stays as it was this time: a reproducible difference. Measurement follows the same principle. Before reading the time difference between rules on and off as rule cost, measure parsing, rule execution, diagnostic sorting, and output directly.

The faster rules can be ported, the more important it becomes to decide what counts as the same and to confirm it. In this project that criterion shaped feature selection too. `--flavor` and a custom formatter were excluded, and no cache was added. Instead I attached `--diff`, shell completions, an LSP server, and pre-commit hooks. And confirming that results match on supported inputs had to go hand in hand with recording the behaviors that are unsupported or handled differently.

## Where Things Stand

The compatibility range confirmed as of September 7 is as follows. The reference version is markdownlint-cli2 v0.22.1.

| Comparison                             | Result                                                                                                                                                         |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 53 rules, 388 original fixtures        | 3,218 errors byte-identical. The 174 files changed by `--fix` also 0 diff                                                                                      |
| 216 original CLI scenarios             | All 159 match their snapshots, excluding 57 that need JavaScript loading                                                                                       |
| 9 real-world repositories, 1,534 files | 1,825 errors match                                                                                                                                             |
| This blog, 20,966 files                | 264,114 errors match                                                                                                                                           |
| About 1,600 adversarial cases          | 3 remaining differences: banner text, Node error object output format, circular `extends` (original does not terminate within 10s, new implementation exits 2) |

What is unsupported is everything that requires loading JavaScript. `customRules` and `markdownItPlugins` are ignored after a warning, and `.cjs` and `.mjs` configuration files exit 2. Text directives (the `:name[label]` extension syntax) and some characters that `unicode-width` measures differently also differ from the original.

The cli2 used for the performance comparison is v0.23.2, which differs from the compatibility reference version. On the three corpora above the tool was about 8.5x to 14x faster than that version, and traded places with rumdl depending on the corpus. Output buffering afterwards cut a further 6% to 8% against a same-session baseline. I do not treat this benchmark as compatibility verification against v0.23.2 as a whole.

The code and all comparison records are at [github.com/yceffort/rust-markdownlint](https://github.com/yceffort/rust-markdownlint). Installing from npm pulls a platform binary as an optional dependency, and existing markdownlint-cli2 configuration files are read as they are.

```bash
npm i -D @yceffort/rust-markdownlint
npx rust-markdownlint "**/*.md" "#node_modules"
```

## To the Original Author

I want to end this post by thanking [David Anson](https://dlaa.me/). Almost everything that made this work possible was material he had already built. The 388 fixtures attached to the rules, the 216 command-line scenarios with expected output preserved as snapshots, the tests that compare nine real-world repositories at pinned commits, the rule documentation with parameters laid out in tables. Because of this material, accumulated while he refined markdownlint and markdownlint-cli2 since 2015, the rules could be ported quickly and the results compared. What I did is closer to wiring that material up so it could run again in another language.

The quirks I described as "fixing to the spec breaks it" are, given the documents and configuration files already running on them, behaviors that are hard to change lightly. The compatibility the original has maintained over the years became the standard I had to follow in this port. If this tool sits in a different place from rumdl, the person who made that place first is the one who built markdownlint before me. My thanks go here, in writing.

## References

- [rust-markdownlint](https://github.com/yceffort/rust-markdownlint): the repository. Parser replacement review (`docs/parser-replacement.md`), cli2 scenario results (`docs/cli2-scenarios.md`), real-world repository comparison (`docs/test-repos.md`), bench records (`bench/RESULTS.md`), the A/B and stage instrumentation of the remaining performance gap (`bench/remaining-gap-2026-09-07.md`), the output buffering experiment (`bench/optimization-plan-2026-09-07.md`), markdown-rs patch list (`crates/markdown-rs/PATCHES.md`)
- [Rewriting Bun in Rust](https://bun.com/blog/bun-in-rust): the Bun team's porting record. Agent setup, adversarial review, 19 regressions
- [markdownlint-cli2 v0.22.1](https://github.com/DavidAnson/markdownlint-cli2), [markdownlint v0.40.0](https://github.com/DavidAnson/markdownlint): the originals
- [markdown-rs](https://github.com/wooorm/markdown-rs): the Rust port of micromark
