From 7717813ee7abe2dc08377d9b5721068dfeaf1f5b Mon Sep 17 00:00:00 2001 From: leaysgur <6259812+leaysgur@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:28:32 +0000 Subject: [PATCH] feat(formatter_markdown): implement markdown formatter (#26434) - [x] Add `oxc_formatter_markdown` crate - `publish = false` as always - [x] Implement basic syntax formatting - [x] Implement GFM, extended syntaxes - [x] Support directive container `::: foo` - [x] Fixed reported, upstream bugs - [x] Publish `oxc_markdown_parser` and use it - [x] Bump `Cargo.toml` version - [x] ~~Embedded code fence w/ Oxfmt integration~~ => Later PR --- .gitattributes | 1 + .github/CODEOWNERS | 1 + .github/workflows/ci.yml | 6 +- Cargo.lock | 26 + Cargo.toml | 1 + crates/oxc_formatter_core/FORMATTER_POLICY.md | 1 + crates/oxc_formatter_markdown/AGENTS.md | 96 +++ crates/oxc_formatter_markdown/CLAUDE.md | 1 + crates/oxc_formatter_markdown/Cargo.toml | 39 + crates/oxc_formatter_markdown/DIVERGENCES.md | 421 ++++++++++ crates/oxc_formatter_markdown/build.rs | 15 + .../examples/markdown_formatter.rs | 74 ++ crates/oxc_formatter_markdown/src/context.rs | 143 ++++ crates/oxc_formatter_markdown/src/format.rs | 119 +++ crates/oxc_formatter_markdown/src/lib.rs | 26 + crates/oxc_formatter_markdown/src/options.rs | 92 +++ .../oxc_formatter_markdown/src/print/block.rs | 390 +++++++++ .../src/print/blockquote.rs | 22 + .../oxc_formatter_markdown/src/print/cjk.rs | 181 +++++ .../oxc_formatter_markdown/src/print/code.rs | 67 ++ .../src/print/escape.rs | 113 +++ .../src/print/inline.rs | 754 ++++++++++++++++++ .../src/print/line_shape.rs | 201 +++++ .../oxc_formatter_markdown/src/print/link.rs | 277 +++++++ .../oxc_formatter_markdown/src/print/list.rs | 311 ++++++++ .../oxc_formatter_markdown/src/print/mod.rs | 177 ++++ .../src/print/pairing.rs | 105 +++ .../oxc_formatter_markdown/src/print/parts.rs | 194 +++++ .../oxc_formatter_markdown/src/print/table.rs | 120 +++ .../oxc_formatter_markdown/src/print/text.rs | 361 +++++++++ .../tests/conformance.rs | 103 +++ .../tests/fixtures/fingerprint.rs | 344 ++++++++ .../tests/fixtures/markdown/basic.md | 26 + .../tests/fixtures/markdown/basic.md.snap | 93 +++ .../fixtures/markdown/indented-code-tab.md | 3 + .../markdown/indented-code-tab.md.snap | 24 + .../markdown/link-url-special-chars.md | 10 + .../markdown/link-url-special-chars.md.snap | 47 ++ .../markdown/list-multiline-inline.md | 20 + .../markdown/list-multiline-inline.md.snap | 75 ++ .../tests/fixtures/markdown/oxfmt-ignore.md | 13 + .../fixtures/markdown/oxfmt-ignore.md.snap | 54 ++ .../tests/fixtures/markdown/prose-wrap/cjk.md | 23 + .../fixtures/markdown/prose-wrap/cjk.md.snap | 156 ++++ .../prose-wrap/container-directive.md | 21 + .../prose-wrap/container-directive.md.snap | 172 ++++ .../ignored-block-trailing-quote-line.md | 8 + .../ignored-block-trailing-quote-line.md.snap | 81 ++ .../markdown/prose-wrap/line-shapes.md | 20 + .../markdown/prose-wrap/line-shapes.md.snap | 175 ++++ .../markdown/prose-wrap/liquid-flow.md | 11 + .../markdown/prose-wrap/liquid-flow.md.snap | 108 +++ .../fixtures/markdown/prose-wrap/options.json | 1 + .../markdown/prose-wrap/stray-delimiters.md | 24 + .../prose-wrap/stray-delimiters.md.snap | 203 +++++ .../prose-wrap/wiki-link-code-span.md | 4 + .../prose-wrap/wiki-link-code-span.md.snap | 59 ++ .../markdown/prose-wrap/wrap-block-starts.md | 32 + .../prose-wrap/wrap-block-starts.md.snap | 256 ++++++ .../tests/fixtures/markdown/single-tilde.md | 3 + .../fixtures/markdown/single-tilde.md.snap | 24 + .../tab-width-4/indented-code-block.md | 17 + .../tab-width-4/indented-code-block.md.snap | 66 ++ .../markdown/tab-width-4/list-html-block.md | 23 + .../tab-width-4/list-html-block.md.snap | 84 ++ .../tab-width-4/list-indented-code.md | 14 + .../tab-width-4/list-indented-code.md.snap | 57 ++ .../markdown/tab-width-4/options.json | 1 + .../fixtures/markdown/thematic-break-first.md | 5 + .../markdown/thematic-break-first.md.snap | 30 + .../tests/fixtures/mod.rs | 83 ++ .../tests/fixtures/options.rs | 42 + .../tests/invariants.rs | 303 +++++++ crates/oxc_formatter_markdown/tests/mod.rs | 1 + .../conformance__prettier-markdown.snap | 26 + oxc_release.toml | 1 + 76 files changed, 7278 insertions(+), 3 deletions(-) create mode 100644 crates/oxc_formatter_markdown/AGENTS.md create mode 100644 crates/oxc_formatter_markdown/CLAUDE.md create mode 100644 crates/oxc_formatter_markdown/Cargo.toml create mode 100644 crates/oxc_formatter_markdown/DIVERGENCES.md create mode 100644 crates/oxc_formatter_markdown/build.rs create mode 100644 crates/oxc_formatter_markdown/examples/markdown_formatter.rs create mode 100644 crates/oxc_formatter_markdown/src/context.rs create mode 100644 crates/oxc_formatter_markdown/src/format.rs create mode 100644 crates/oxc_formatter_markdown/src/lib.rs create mode 100644 crates/oxc_formatter_markdown/src/options.rs create mode 100644 crates/oxc_formatter_markdown/src/print/block.rs create mode 100644 crates/oxc_formatter_markdown/src/print/blockquote.rs create mode 100644 crates/oxc_formatter_markdown/src/print/cjk.rs create mode 100644 crates/oxc_formatter_markdown/src/print/code.rs create mode 100644 crates/oxc_formatter_markdown/src/print/escape.rs create mode 100644 crates/oxc_formatter_markdown/src/print/inline.rs create mode 100644 crates/oxc_formatter_markdown/src/print/line_shape.rs create mode 100644 crates/oxc_formatter_markdown/src/print/link.rs create mode 100644 crates/oxc_formatter_markdown/src/print/list.rs create mode 100644 crates/oxc_formatter_markdown/src/print/mod.rs create mode 100644 crates/oxc_formatter_markdown/src/print/pairing.rs create mode 100644 crates/oxc_formatter_markdown/src/print/parts.rs create mode 100644 crates/oxc_formatter_markdown/src/print/table.rs create mode 100644 crates/oxc_formatter_markdown/src/print/text.rs create mode 100644 crates/oxc_formatter_markdown/tests/conformance.rs create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/fingerprint.rs create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/basic.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/basic.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/indented-code-tab.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/indented-code-tab.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/link-url-special-chars.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/link-url-special-chars.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/list-multiline-inline.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/list-multiline-inline.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/oxfmt-ignore.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/oxfmt-ignore.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/cjk.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/cjk.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/container-directive.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/container-directive.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/ignored-block-trailing-quote-line.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/ignored-block-trailing-quote-line.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/line-shapes.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/line-shapes.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/liquid-flow.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/liquid-flow.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/options.json create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/stray-delimiters.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/stray-delimiters.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/wiki-link-code-span.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/wiki-link-code-span.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/wrap-block-starts.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/prose-wrap/wrap-block-starts.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/single-tilde.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/single-tilde.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/indented-code-block.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/indented-code-block.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/list-html-block.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/list-html-block.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/list-indented-code.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/list-indented-code.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/options.json create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/thematic-break-first.md create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/markdown/thematic-break-first.md.snap create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/mod.rs create mode 100644 crates/oxc_formatter_markdown/tests/fixtures/options.rs create mode 100644 crates/oxc_formatter_markdown/tests/invariants.rs create mode 100644 crates/oxc_formatter_markdown/tests/mod.rs create mode 100644 crates/oxc_formatter_markdown/tests/snapshots/conformance__prettier-markdown.snap diff --git a/.gitattributes b/.gitattributes index 520bcef6e6..ea926c4b67 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,7 @@ crates/oxc_formatter_graphql/tests/fixtures/** text=auto eol=lf crates/oxc_formatter_graphql/tests/fixtures/graphql/crlf/** text=auto eol=crlf crates/oxc_formatter_yaml/tests/fixtures/** text=auto eol=lf crates/oxc_formatter_yaml/tests/fixtures/yaml/crlf/** text=auto eol=crlf +crates/oxc_formatter_markdown/tests/fixtures/** text=auto eol=lf apps/oxfmt/test/**/fixtures/** text=auto eol=lf apps/oxlint/test/fixtures/** text=auto eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0ab1842145..7f4db6481a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -33,6 +33,7 @@ pnpm-lock.yaml /crates/oxc_formatter_graphql @leaysgur /crates/oxc_formatter_css @leaysgur /crates/oxc_formatter_yaml @leaysgur +/crates/oxc_formatter_markdown @leaysgur /crates/oxc_formatter_tests @leaysgur /crates/oxc_jsdoc @leaysgur /crates/oxc_regular_expression @leaysgur diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36758bbd1f..e39d15412a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -300,13 +300,13 @@ jobs: - uses: ./.github/actions/check-changes id: filter with: - paths: apps/oxfmt/,apps/shared/,npm/oxfmt/,crates/oxc_formatter/,crates/oxc_formatter_core/,crates/oxc_formatter_css/,crates/oxc_formatter_graphql/,crates/oxc_formatter_json/,crates/oxc_formatter_yaml/,crates/oxc_config/,pnpm-lock.yaml,package.json + paths: apps/oxfmt/,apps/shared/,npm/oxfmt/,crates/oxc_formatter/,crates/oxc_formatter_core/,crates/oxc_formatter_css/,crates/oxc_formatter_graphql/,crates/oxc_formatter_json/,crates/oxc_formatter_yaml/,crates/oxc_formatter_markdown/,crates/oxc_config/,pnpm-lock.yaml,package.json token: ${{ secrets.GITHUB_TOKEN }} pr-number: ${{ github.event.pull_request.number }} - uses: ./.github/actions/check-changes id: filter-conformance with: - paths: apps/oxfmt/,npm/oxfmt/,crates/oxc_formatter/,crates/oxc_formatter_core/,crates/oxc_formatter_css/,crates/oxc_formatter_graphql/,crates/oxc_formatter_json/,crates/oxc_formatter_yaml/,pnpm-lock.yaml,package.json + paths: apps/oxfmt/,npm/oxfmt/,crates/oxc_formatter/,crates/oxc_formatter_core/,crates/oxc_formatter_css/,crates/oxc_formatter_graphql/,crates/oxc_formatter_json/,crates/oxc_formatter_yaml/,crates/oxc_formatter_markdown/,pnpm-lock.yaml,package.json token: ${{ secrets.GITHUB_TOKEN }} pr-number: ${{ github.event.pull_request.number }} - uses: oxc-project/setup-node@f46a72f95efdc55273fcd042d61c84e723b2892c # v1.4.1 @@ -454,7 +454,7 @@ jobs: - uses: ./.github/actions/check-changes id: filter with: - paths: apps/oxfmt/,apps/shared/,npm/oxfmt/,crates/oxc_formatter/,crates/oxc_formatter_core/,crates/oxc_formatter_css/,crates/oxc_formatter_graphql/,crates/oxc_formatter_json/,crates/oxc_formatter_yaml/,crates/oxc_config/,pnpm-lock.yaml,package.json + paths: apps/oxfmt/,apps/shared/,npm/oxfmt/,crates/oxc_formatter/,crates/oxc_formatter_core/,crates/oxc_formatter_css/,crates/oxc_formatter_graphql/,crates/oxc_formatter_json/,crates/oxc_formatter_yaml/,crates/oxc_formatter_markdown/,crates/oxc_config/,pnpm-lock.yaml,package.json token: ${{ secrets.GITHUB_TOKEN }} pr-number: ${{ github.event.pull_request.number }} - uses: samypr100/setup-dev-drive@562171fe8df7401fdf582d766cd3b6ab80d5b1a0 # v4.1.0 diff --git a/Cargo.lock b/Cargo.lock index 7b17e77b50..6a4b680ee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1705,6 +1705,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "oxc-markdown-parser" +version = "0.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "198e25a5dfc6aabe79614588816ec57612622d25e1eff291aae0542f7a0e3c18" +dependencies = [ + "oxc_allocator", +] + [[package]] name = "oxc-schemars" version = "0.9.1" @@ -2129,6 +2138,23 @@ dependencies = [ "pico-args", ] +[[package]] +name = "oxc_formatter_markdown" +version = "0.68.0" +dependencies = [ + "cow-utils", + "insta", + "oxc-markdown-parser", + "oxc_allocator", + "oxc_diagnostics", + "oxc_formatter_core", + "oxc_formatter_tests", + "oxc_span", + "oxc_tasks_common", + "pico-args", + "serde_json", +] + [[package]] name = "oxc_formatter_tests" version = "0.62.0" diff --git a/Cargo.toml b/Cargo.toml index e7d7fde00a..dc86cc75f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -241,6 +241,7 @@ num-bigint = "0.5.0" # Big integers num-traits = "0.2.19" # Numeric traits oxc-css-parser = "0.0.15" # CSS/SCSS/Less parser (raffia 0.12.3 fork: adds `template_placeholder` typed placeholders for css-in-js + valid-syntax coverage fixes (see crates/oxc_formatter_css/AGENTS.md)) oxc-graphql-parser = "0.0.9" # GraphQL parser (direct AST; apollo-parser 0.8.6 fork aligned with graphql-js 17 syntax) +oxc-markdown-parser = "0.0.0" # CommonMark + GFM parser (span-faithful, style-preserving typed AST) oxc-toml = "0.14.5" # TOML formatter oxc-yaml-parser = "0.0.6" # YAML 1.2 parser (comment-preserving typed AST, node naming from `yaml-unist-parser`) papaya = "0.2.4" # Concurrent hash map diff --git a/crates/oxc_formatter_core/FORMATTER_POLICY.md b/crates/oxc_formatter_core/FORMATTER_POLICY.md index 250b2372d0..4461cd62f7 100644 --- a/crates/oxc_formatter_core/FORMATTER_POLICY.md +++ b/crates/oxc_formatter_core/FORMATTER_POLICY.md @@ -7,6 +7,7 @@ Shared policy for every formatter crate in the oxc ecosystem: - `oxc_formatter_css` - `oxc_formatter_graphql` - `oxc_formatter_yaml` +- `oxc_formatter_markdown` using `oxc_formatter_core`, integrated by `apps/oxfmt`. diff --git a/crates/oxc_formatter_markdown/AGENTS.md b/crates/oxc_formatter_markdown/AGENTS.md new file mode 100644 index 0000000000..0e935c091e --- /dev/null +++ b/crates/oxc_formatter_markdown/AGENTS.md @@ -0,0 +1,96 @@ +# Coding agent guides for `crates/oxc_formatter_markdown` + +Follow @../oxc_formatter_core/FORMATTER_POLICY.md , this file holds only the Markdown-specific rules. +Known divergences live in DIVERGENCES.md. + +## Overview + +Prettier compatible Markdown formatter on `oxc_formatter_core`. +Entry points and their contracts are documented in `src/format.rs`. + +Parses with [`oxc-markdown-parser`](https://github.com/oxc-project/oxc-markdown-parser). +Parse behavior targets micromark (what Prettier parses with); its AGENTS.md / DIVERGENCES.md are the reference. +Style facts (markers, fence kind, setext, break kind, list padding, ...) are AST fields and `ParserReturn::blanks` lists the blank lines, +so the printer never re-derives layout from the source text. +Formatter policy (sentence splitting, CJK, aligned lists, escaping) stays here. + +### Escaping + +Text is printed as written. Escapes are added in three places only: + +- `*` / `_` runs inside emphasis +- a lone `===` / `---` word starting a line +- link destinations / titles / labels + +Emphasis marker normalization is verified before it is committed: +`print/pairing.rs` runs the parser's delimiter resolver on the printed text and falls back to the markers as written when the nodes would differ. +Plain text outside emphasis is never escaped and entities are never decoded; +that is what keeps `snake_case`, `:emoji:`, `{#id}` attributes and `` intact for every dialect. + +### Envelope + +Source is normalized to `\n` before parsing (verbatim slices go straight into the IR), the configured line ending is re-emitted at print time, +a leading BOM is preserved. + +Front matter (`---` / `+++`) is not handled (`oxc_formatter_core::spec::parse_front_matter` + `envelope::write_front_matter`, as CSS does). + +## Dialects + +Markdown grammars are open-ended (VitePress, Docusaurus, Pandoc, kramdown, Obsidian, ...), and the parser learns none of them: +a construct exists only when micromark / a Prettier extension parses it or GitHub renders it. +The formatter keeps the rest intact without grammar: no escaping of plain text (see "Escaping" above) and line shapes. + +Line shapes (`line_shape::line_shape`): a paragraph line starting with `:::`, `<<<`, a component tag (`p.__debug.formatDoc(d)).then(console.log)' [filename] +``` + +### Prettier conformance + +At the current version (v3.9.6), these divergences have been confirmed and are intentional (see DIVERGENCES.md): + +Conformance failures classified as divergences (16, all of them): + +- `thematicBreak/simple.md` and `commonmark-test-suite/snippet: example-11.md`, `example-50.md` to `example-54.md` (`#leading-thematic-break`) +- `commonmark-test-suite/snippet: example-2.md` (`#indented-code-tab`) +- `link/encodedLink.md` (`#url-escaping`) +- `gfm-test-suite/snippet: example-491.md` (`#single-tilde-strikethrough`) +- `liquid/*.md` (`#liquid-flow-tags`) +- `blockquote/notext-end.md` (`#line-shapes`) +- `paragraph/cjk.md` (`#container-directive`) +- `commonmark-test-suite/snippet: example-412.md` (`#stray-delimiters`) + +### Fixture fingerprint + +`tests/fixtures/fingerprint.rs` serializes the AST's meaning (structure, decoded text, destinations, labels) and ignores what formatting may change +(spans, markers, fence style, text splitting, whitespace runs, tightness, whitespace next to Chinese / Japanese characters, trailing whitespace of verbatim lines, info string whitespace). +The harness asserts it is identical for input and output: every fixture is a `parse(format(x)) ≅ parse(x)` check, which idempotency alone cannot give (a corrupted output is often a fixpoint). +Extend the ignore set only with a reason written next to it. + +### Fuzzed invariants + +`tests/invariants.rs` formats a deterministic token-soup corpus (the parser repo's differential generator) under every `proseWrap` and checks the fingerprint and idempotency. +The default corpus must fail exactly on `KNOWN_FAILURES` (the documents and their classes are listed there); +remove an entry when its class is fixed, any other failure is a regression. +`MD_FUZZ_SEED` / `MD_FUZZ_COUNT` run other corpora, `MD_FUZZ_FILE` prints one document's fingerprints. diff --git a/crates/oxc_formatter_markdown/CLAUDE.md b/crates/oxc_formatter_markdown/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/crates/oxc_formatter_markdown/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/crates/oxc_formatter_markdown/Cargo.toml b/crates/oxc_formatter_markdown/Cargo.toml new file mode 100644 index 0000000000..d46d96e878 --- /dev/null +++ b/crates/oxc_formatter_markdown/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "oxc_formatter_markdown" +version = "0.68.0" +authors.workspace = true +categories.workspace = true +edition.workspace = true +homepage.workspace = true +include = ["/src"] +keywords.workspace = true +license.workspace = true +publish = false +repository.workspace = true +rust-version.workspace = true +description.workspace = true + +[lints] +workspace = true + +[dependencies] +cow-utils = { workspace = true } +oxc-markdown-parser = { workspace = true } +oxc_allocator = { workspace = true } +oxc_diagnostics = { workspace = true } +oxc_formatter_core = { workspace = true } +oxc_span = { workspace = true } + +[dev-dependencies] +insta = { workspace = true } +oxc_formatter_tests = { workspace = true, features = ["conformance"] } +oxc_tasks_common = { workspace = true } +pico-args = { workspace = true } +serde_json = { workspace = true } + +[build-dependencies] +oxc_formatter_tests = { workspace = true } + +[lib] +doctest = false +test = false diff --git a/crates/oxc_formatter_markdown/DIVERGENCES.md b/crates/oxc_formatter_markdown/DIVERGENCES.md new file mode 100644 index 0000000000..40f679ffc2 --- /dev/null +++ b/crates/oxc_formatter_markdown/DIVERGENCES.md @@ -0,0 +1,421 @@ +# Known divergences + +Admission reasons and rules: see `crates/oxc_formatter_core/FORMATTER_POLICY.md` "Known divergences". + +## leading-thematic-break + +- Why: semantics (prettier/prettier#19839) +- Pin: `tests/fixtures/markdown/thematic-break-first.md` + +```markdown + +--- + +text + +--- + + +*** + +text + +--- + + +--- + +text + +--- +``` + +A document whose first block is a thematic break gets `***`, never `---`: +Prettier's own next run would read `---` ... `---` as front matter and swallow `text` into it. +Prettier `main` prints `***` since #19839; the pin (3.9.6) still prints `---`. + +## url-escaping + +- Why: semantics (prettier/prettier#19482, prettier/prettier#19849, prettier/prettier#19891) +- Pin: `tests/fixtures/markdown/link-url-special-chars.md` + +```markdown + +[a](https://x/()foo->bar) + + +[a](bar>) + + +[a]() +``` + +Destinations follow Prettier `main`: `<`, `>` inside the angle brackets are backslash-escaped (the pin percent-encodes them, which changes the URL), +a `\` that would start an escape is doubled, and `&` that would start a character reference becomes `\&` +(the pin drops the escape and the reference is decoded on the next parse). +Titles get the same `\&` treatment. + +## indented-code-tab + +- Why: uniform-rule (same construct, same output: space-indented code block) +- Pin: `tests/fixtures/markdown/indented-code-tab.md` + +````markdown + + foo + + + foo + + +``` +foo +``` +```` + +An indented code block is printed indented, however its 4 columns were written. +Prettier recognizes indented code from the raw text with a regex (four spaces or a tab), +so an indent written as spaces plus a tab becomes a fenced block. + +## ignored-block-trailing-quote-line + +- Why: uniform-rule (same construct, same output: the `proseWrap: preserve` layout) +- Pin: `tests/fixtures/markdown/prose-wrap/ignored-block-trailing-quote-line.md` + +```markdown + +> +> - a long item +> + +> next + + +> +> - a long item + +> next + + +> +> - a long item +> + +> next +``` + +A blockquote's trailing blank `>` line is dropped, whatever `proseWrap` is. +Prettier drops it under `preserve` and `never` but prints a bare `>` under `always` when the block before it is `prettier-ignore`d. + +## list-indented-code-alignment + +- Why: semantics (prettier/prettier#19644, prettier/prettier#19647, prettier/prettier#19990) +- Pin: `tests/fixtures/markdown/tab-width-4/list-indented-code.md`, `tests/fixtures/markdown/tab-width-4/indented-code-block.md` + +```markdown + +- foo + + code + + +- foo + + code + + +- foo + + code +``` + +An indented code block inside a list item is printed at the item's content column + 4, nothing more: +the code's content is exactly what follows that column, so any extra alignment becomes part of it on the next parse. +The pin aligns it by the task checkbox (continuation lines drift by 4 per run, #19644); +#19647 aligned it by the tab width instead, which shifted the content whenever `tabWidth` exceeded the marker width. +Prettier `main` prints it as we do since #19990 (`indented-code-block.md` is its fixture); the pin (3.9.6) still differs. + +The same rule keeps the marker of an ordered list unpadded when an item starts with indented code +(the content column is the marker plus one space, so any padding lands inside the code). +Prettier pads it when the code starts at a tab stop in the source, which `>2. foo` does only because of the `>`: + +```markdown + +>2. foo + + +> 2. foo + + +> 2. foo +``` + +An HTML block after a task item's paragraph keeps the item's content column too +(Prettier drops the alignment when the html starts at another column than the paragraph, which moves it out of the item): + +```markdown + +- [ ] a + + diff --git a/crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/list-html-block.md.snap b/crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/list-html-block.md.snap new file mode 100644 index 0000000000..56319ba4f1 --- /dev/null +++ b/crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/list-html-block.md.snap @@ -0,0 +1,84 @@ +--- +source: crates/oxc_formatter_markdown/tests/fixtures/mod.rs +--- +==================== Input ==================== +1. Enable images: + + ```json + {} + ``` + +

+ example +

+ +2. Continue. + +- item + +
+ html at the content column plus one +
+ +- [ ] task item + + + +==================== Output ==================== +------------------------------- +{ printWidth: 80, tabWidth: 4 } +------------------------------- +1. Enable images: + + ```json + {} + ``` + +

+ example +

+ +2. Continue. + +- item + +
+ html at the content column plus one +
+ +- [ ] task item + + + +-------------------------------- +{ printWidth: 100, tabWidth: 4 } +-------------------------------- +1. Enable images: + + ```json + {} + ``` + +

+ example +

+ +2. Continue. + +- item + +
+ html at the content column plus one +
+ +- [ ] task item + + + +===================== End ===================== diff --git a/crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/list-indented-code.md b/crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/list-indented-code.md new file mode 100644 index 0000000000..f12dd19540 --- /dev/null +++ b/crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/list-indented-code.md @@ -0,0 +1,14 @@ +- foo + + bar + + indented code in a bullet item + second line + +- [ ] task + + indented code in a task item + second line + +>2. ordered item starting with indented code +> second line diff --git a/crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/list-indented-code.md.snap b/crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/list-indented-code.md.snap new file mode 100644 index 0000000000..5238c0b6fe --- /dev/null +++ b/crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/list-indented-code.md.snap @@ -0,0 +1,57 @@ +--- +source: crates/oxc_formatter_markdown/tests/fixtures/mod.rs +--- +==================== Input ==================== +- foo + + bar + + indented code in a bullet item + second line + +- [ ] task + + indented code in a task item + second line + +>2. ordered item starting with indented code +> second line + +==================== Output ==================== +------------------------------- +{ printWidth: 80, tabWidth: 4 } +------------------------------- +- foo + + bar + + indented code in a bullet item + second line + +- [ ] task + + indented code in a task item + second line + +> 2. ordered item starting with indented code +> second line + +-------------------------------- +{ printWidth: 100, tabWidth: 4 } +-------------------------------- +- foo + + bar + + indented code in a bullet item + second line + +- [ ] task + + indented code in a task item + second line + +> 2. ordered item starting with indented code +> second line + +===================== End ===================== diff --git a/crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/options.json b/crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/options.json new file mode 100644 index 0000000000..73bf03b43f --- /dev/null +++ b/crates/oxc_formatter_markdown/tests/fixtures/markdown/tab-width-4/options.json @@ -0,0 +1 @@ +[{ "tabWidth": 4 }] diff --git a/crates/oxc_formatter_markdown/tests/fixtures/markdown/thematic-break-first.md b/crates/oxc_formatter_markdown/tests/fixtures/markdown/thematic-break-first.md new file mode 100644 index 0000000000..2d5bc80d2c --- /dev/null +++ b/crates/oxc_formatter_markdown/tests/fixtures/markdown/thematic-break-first.md @@ -0,0 +1,5 @@ +--- + +A document that opens with a thematic break. + +--- diff --git a/crates/oxc_formatter_markdown/tests/fixtures/markdown/thematic-break-first.md.snap b/crates/oxc_formatter_markdown/tests/fixtures/markdown/thematic-break-first.md.snap new file mode 100644 index 0000000000..df85404c03 --- /dev/null +++ b/crates/oxc_formatter_markdown/tests/fixtures/markdown/thematic-break-first.md.snap @@ -0,0 +1,30 @@ +--- +source: crates/oxc_formatter_markdown/tests/fixtures/mod.rs +--- +==================== Input ==================== +--- + +A document that opens with a thematic break. + +--- + +==================== Output ==================== +------------------ +{ printWidth: 80 } +------------------ +*** + +A document that opens with a thematic break. + +--- + +------------------- +{ printWidth: 100 } +------------------- +*** + +A document that opens with a thematic break. + +--- + +===================== End ===================== diff --git a/crates/oxc_formatter_markdown/tests/fixtures/mod.rs b/crates/oxc_formatter_markdown/tests/fixtures/mod.rs new file mode 100644 index 0000000000..e02649d6c6 --- /dev/null +++ b/crates/oxc_formatter_markdown/tests/fixtures/mod.rs @@ -0,0 +1,83 @@ +use std::path::Path; + +use oxc_allocator::Allocator; +use oxc_formatter_markdown::{MarkdownFormatOptions, format, parse_for_format}; +use oxc_formatter_tests::{FixtureFormatter, OptionSet, build_fixture_snapshot}; + +mod fingerprint; +mod options; +use options::apply_markdown_options; + +struct MarkdownHarness; + +impl FixtureFormatter for MarkdownHarness { + type Options = MarkdownFormatOptions; + /// The semantic fingerprint (see `fingerprint.rs`): `parse(format(x)) ≅ parse(x)`. + type Fingerprint = String; + + fn parse_options(json: &OptionSet) -> Self::Options { + let mut options = MarkdownFormatOptions::default(); + apply_markdown_options(&mut options, json); + options + } + + fn format(source: &str, _path: &Path, options: &Self::Options) -> String { + let allocator = Allocator::default(); + format(&allocator, source, *options) + .expect("format should succeed") + .print() + .expect("print should succeed") + .into_code() + } + + fn fingerprint(source: &str, _path: &Path, _options: &Self::Options) -> String { + let allocator = Allocator::default(); + let parsed = parse_for_format(&allocator, source).expect("source should parse"); + fingerprint::fingerprint(parsed.source, parsed.root) + } +} + +fn test_file(path: &Path) { + // `insta::assert_snapshot!` is invoked from this file so the snapshot's + // `source:` header records this consumer crate, not the shared harness. + let snap = build_fixture_snapshot::(path); + insta::with_settings!({ + snapshot_path => snap.path, + prepend_module_to_snapshot => false, + snapshot_suffix => "", + omit_expression => true, + }, { + insta::assert_snapshot!(snap.name, snap.body); + }); +} + +// Include auto-generated test functions from build.rs +include!(concat!(env!("OUT_DIR"), "/generated_tests.rs")); + +// --- + +/// Prettier prints an empty document as the empty string. +#[test] +fn empty_document() { + let allocator = Allocator::default(); + for source in ["", "\n", "\n\n \n"] { + let formatted = format(&allocator, source, MarkdownFormatOptions::default()) + .expect("empty input should parse") + .print() + .expect("print should succeed") + .into_code(); + assert_eq!(formatted, "", "{source:?}"); + } +} + +/// A leading BOM is preserved (Prettier does the same). +#[test] +fn bom_is_preserved() { + let allocator = Allocator::default(); + let formatted = format(&allocator, "\u{feff}# Title", MarkdownFormatOptions::default()) + .expect("BOM input should parse") + .print() + .expect("print should succeed") + .into_code(); + assert_eq!(formatted, "\u{feff}# Title\n"); +} diff --git a/crates/oxc_formatter_markdown/tests/fixtures/options.rs b/crates/oxc_formatter_markdown/tests/fixtures/options.rs new file mode 100644 index 0000000000..a9b5ec3195 --- /dev/null +++ b/crates/oxc_formatter_markdown/tests/fixtures/options.rs @@ -0,0 +1,42 @@ +//! Prettier option-set → `MarkdownFormatOptions` mapping. +//! +//! Shared by the fixture harness and the conformance target via `#[path]` +//! (one source, no drift; see `oxc_formatter_tests`'s AGENTS.md). + +use oxc_formatter_core::IndentWidth; +use oxc_formatter_markdown::{MarkdownFormatOptions, ProseWrap}; +use oxc_formatter_tests::{OptionSet, apply_core_options}; + +/// Applies the four core options plus the Markdown-specific keys onto `options`. +/// Parsing is lenient like `apply_core_options`: unknown or invalid values are ignored. +pub fn apply_markdown_options(options: &mut MarkdownFormatOptions, json: &OptionSet) { + apply_core_options(options, json); + // Prettier accepts any `tabWidth`; `IndentWidth` caps at 24. + // Every markdown use of it clamps or takes a remainder, + // so the cap prints the same (the suite's `tabWidth: 999` cases). + if let Some(width) = json.get("tabWidth").and_then(serde_json::Value::as_u64) + && width > u64::from(IndentWidth::MAX) + { + options.indent_width = IndentWidth::try_from(IndentWidth::MAX).unwrap(); + } + + for (key, value) in json { + match key.as_str() { + "proseWrap" => { + if let Some(s) = value.as_str() { + options.prose_wrap = match s { + "always" => ProseWrap::Always, + "never" => ProseWrap::Never, + _ => ProseWrap::Preserve, + }; + } + } + "singleQuote" => { + if let Some(b) = value.as_bool() { + options.single_quote = b.into(); + } + } + _ => {} + } + } +} diff --git a/crates/oxc_formatter_markdown/tests/invariants.rs b/crates/oxc_formatter_markdown/tests/invariants.rs new file mode 100644 index 0000000000..97126e4c33 --- /dev/null +++ b/crates/oxc_formatter_markdown/tests/invariants.rs @@ -0,0 +1,303 @@ +//! Fuzzed safety invariants (the parser repo's differential corpus, formatted instead of compared): +//! +//! 1. `parse(format(x)) ≅ parse(x)` (the fixture fingerprint) +//! 2. `format(format(x)) == format(x)` +//! +//! under every `proseWrap`. +//! Deterministic; the default corpus must fail exactly on the known documents (`KNOWN_FAILURES`), +//! any other corpus reports every failure: +//! `MD_FUZZ_COUNT=50000 MD_FUZZ_SEED=7 cargo test -p oxc_formatter_markdown --test invariants`. +//! `MD_FUZZ_FILE=path` prints one document's fingerprints. + +#![expect(clippy::print_stderr)] // the failure reports + +use oxc_allocator::Allocator; +use oxc_formatter_core::{IndentWidth, LineWidth}; +use oxc_formatter_markdown::{MarkdownFormatOptions, ProseWrap, format, parse_for_format}; + +#[path = "fixtures/fingerprint.rs"] +mod fingerprint; + +/// The differential runner's token soup (`tasks/differential/run.ts` in the parser repo). +const TOKENS: &[&str] = &[ + "*", + "**", + "_", + "__", + "`", + "``", + "[", + "]", + "(", + ")", + "<", + ">", + "#", + "##", + "-", + "+", + "1.", + "2)", + ">", + "\\", + "&", + "&", + "#", + "!", + "\"", + "'", + "=", + "~", + "|", + ":", + "a", + "foo", + "bar b", + "é", + "漢字", + "http://x.y", + "user@e.com", + "\\*", + "\\\\", + " ", + " ", + "\t", + "\n", + "\n\n", + " \n", + "\\\n", + "```\n", + "---", + "===", + "***", + " ", + "[x]: /u", + "[x]", + "\"t\"", + "'t'", + "
", + "
", + "", + "![", + "](u)", + "", + "", + "", + "> x\n\n", + "", + "
",
+    "
", + "