Skip to content

refactor(md/parse): two-phase parse - #11256

Merged
ematipico merged 2 commits into
mainfrom
refactor/two-phase-md-parse
Aug 6, 2026
Merged

refactor(md/parse): two-phase parse#11256
ematipico merged 2 commits into
mainfrom
refactor/two-phase-md-parse

Conversation

@ematipico

Copy link
Copy Markdown
Member

Summary

This PR should increase the speed of our markdown parser.

The previous version was parsing the same document twice. This is correct because that's how CommonMark suggests implementing the MD parser; however, our implementation was using a full-fledged parsing phase only to retrieve the link references.

This PR refactors the markdown parsing into exactly two phases:

  • the first phase stays as is; however, it defers the parsing of inline nodes into a second phase
  • the second phase restructures the vector of events by adding the correct nodes. The deferred nodes have a local state, so that we can just jump into the range we need to parse, using the state from phase 1.

Hopefully this should make our parser a bit faster.

Test Plan

Existing tests should pass.

Docs

N/A

Designed by me, implemented with a coding agent, and fixed by me.

@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 10e41a4

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions github-actions Bot added A-Parser Area: parser A-Formatter Area: formatter L-Markdown Language: Markdown labels Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

✅ Organic activity

No automation signals detected in the analyzed events.

View full analysis →

This is an automated analysis by AgentScan

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c116987d-8f46-4c72-b2bf-487ee70288e1

📥 Commits

Reviewing files that changed from the base of the PR and between 496501a and 10e41a4.

📒 Files selected for processing (1)
  • crates/biome_markdown_parser/src/parser.rs

Walkthrough

Markdown parsing now records paragraph and ATX heading inline regions for deferred processing. Link-reference definitions use source ranges and checkpoint restoration. The lexer and token source support bounded ranges. A new inline phase reparses deferred regions after definition collection, then replaces events and merges trivia and diagnostics. Tests cover headings, nested blockquote lists, emphasis delimiters, CST round-tripping, and diagnostic ordering.

Possibly related PRs

  • biomejs/biome#10199: Both changes update Markdown link-reference parsing and rendering behaviour.
  • biomejs/biome#10844: Both changes modify Markdown reference-link handling and add related coverage.

Suggested reviewers: vznh, dyc3

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: refactoring Markdown parsing into two phases.
Description check ✅ Passed The description explains the two-phase Markdown parser refactor, its performance goal, implementation approach, and test plan.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/two-phase-md-parse

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (5)
crates/biome_parser/src/diagnostic.rs (1)

159-161: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the new public accessor and retire the duplicate.

span() is a new public API without rustdoc. diagnostic_range() at line 373 already returns the same field as Option<&TextRange> for crate-internal use. TextRange is Copy, so span() covers both needs and merge_diagnostics can call it.

As per coding guidelines: "Use rustdoc documentation for documenting new features".

♻️ Proposed change
+    /// Returns the source range the diagnostic points at, if any.
     pub fn span(&self) -> Option<TextRange> {
         self.span
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/biome_parser/src/diagnostic.rs` around lines 159 - 161, Add rustdoc
documentation to the public Diagnostic::span accessor, remove the duplicate
diagnostic_range method, and update merge_diagnostics to use span() instead.

Source: Coding guidelines

crates/biome_markdown_parser/src/lexer/mod.rs (2)

256-274: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use checked_sub for the byte before start.

usize::from(start) - 1 is safe today only because || short-circuits when start == 0. A later reordering of the two arms would introduce an underflow panic. checked_sub removes the dependency on evaluation order.

♻️ Proposed tweak
-            after_newline: start == TextSize::from(0)
-                || matches!(
-                    source.as_bytes().get(usize::from(start) - 1),
-                    Some(b'\n' | b'\r')
-                ),
+            after_newline: match usize::from(start).checked_sub(1) {
+                None => true,
+                Some(previous) => {
+                    matches!(source.as_bytes().get(previous), Some(b'\n' | b'\r'))
+                }
+            },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/biome_markdown_parser/src/lexer/mod.rs` around lines 256 - 274, Update
from_valid_range to compute the preceding byte index with checked_sub before
calling source.as_bytes().get, preserving the existing after_newline behavior
for start at zero and positions preceded by newline characters without relying
on short-circuit evaluation order.

248-254: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a lexer test with a bounded range.

The only caller, parse_inline_fragment, always passes source.len() as the range end. Every new self.end bound is therefore exercised with end == source.len(), which is the old behaviour. The bounds in is_eof, byte_at, is_ordered_list_marker_at, and is_at_trailing_hash_closing_whitespace have no coverage for a genuinely truncated range, and a regression there would be silent.

Please add a lexer test that builds a lexer over a strict sub-range and asserts EOF at the range end. crates/biome_markdown_parser/src/lexer/tests.rs already has the assert_lex! scaffolding.

As per coding guidelines: "All code changes must include appropriate tests; parser changes must cover valid and error cases".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/biome_markdown_parser/src/lexer/mod.rs` around lines 248 - 254, Add a
lexer test in the existing tests module using the assert_lex! scaffolding that
constructs a lexer with a range ending before source.len(). Assert that the
lexer reports EOF at the bounded range end, exercising the from_range path and
the self.end checks in is_eof and related byte-bound methods without changing
production behavior.

Source: Coding guidelines

crates/biome_markdown_parser/src/lib.rs (1)

45-49: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This assertion cannot fail, and it hides the real case.

parse_deferred_inlines calls output.deferred_inlines.clear() unconditionally before it returns, so this debug_assert! always holds. It does not verify that the records were resolved.

The case worth detecting is different: parse_deferred_inlines has three continue paths that skip a record, and parse_inline_fragment can return None. Those records are then dropped with no signal, and the first-pass events stay in the tree. Please either assert on a counter of unresolved records, or drop the assertion so it does not read as a guarantee.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/biome_markdown_parser/src/lib.rs` around lines 45 - 49, Replace the
vacuous debug_assert! on output.deferred_inlines in the parser flow with
validation that detects records skipped by parse_deferred_inlines, including
continue paths and parse_inline_fragment returning None; alternatively remove
the assertion entirely so it does not imply successful resolution. Anchor the
change to parse_deferred_inlines and preserve the existing parsing behavior.
crates/biome_markdown_parser/src/inline_phase.rs (1)

107-112: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Merge the trivia lists instead of sorting the whole document.

reparsed_ranges is ordered, output.trivia is in source order, and inline_trivia is produced in source order per fragment. A full sort_by_key over every trivia piece in the document is O(n log n) on each parse that reparses at least one fragment. A merge is O(n) and matches what the diagnostics path already does with merge_diagnostics.

For a performance-focused PR this is worth aligning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/biome_markdown_parser/src/inline_phase.rs` around lines 107 - 112, The
trivia handling after overlaps_ordered_ranges should merge the existing
output.trivia and newly produced inline_trivia in source order instead of
sorting the entire document with Trivia::offset. Reuse or mirror the linear
merge approach used by merge_diagnostics, preserving ordering and avoiding the
O(n log n) sort.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/biome_markdown_parser/src/inline_phase.rs`:
- Around line 148-160: In the fragment splice flow around parser.cur_range(),
retain the existing debug_assert_eq! but add a release-safe check for the
end-offset mismatch before calling wrapper.complete or constructing output.
Return None when parser.cur_range().start() differs from
deferred.source_range.end(), preserving the first-pass events instead of
splicing an invalid event stream.

In `@crates/biome_markdown_parser/src/parser.rs`:
- Around line 808-838: Update MarkdownParser::rewind to return immediately when
link_reference_definitions_len already matches the current definition count, and
change link-reference definition storage so each range retains its normalized
hash alongside it. Rebuild ranges_by_hash during truncation by reusing the
stored hashes instead of calling normalized_label_hash or re-normalizing labels,
while preserving existing truncation behavior.

---

Nitpick comments:
In `@crates/biome_markdown_parser/src/inline_phase.rs`:
- Around line 107-112: The trivia handling after overlaps_ordered_ranges should
merge the existing output.trivia and newly produced inline_trivia in source
order instead of sorting the entire document with Trivia::offset. Reuse or
mirror the linear merge approach used by merge_diagnostics, preserving ordering
and avoiding the O(n log n) sort.

In `@crates/biome_markdown_parser/src/lexer/mod.rs`:
- Around line 256-274: Update from_valid_range to compute the preceding byte
index with checked_sub before calling source.as_bytes().get, preserving the
existing after_newline behavior for start at zero and positions preceded by
newline characters without relying on short-circuit evaluation order.
- Around line 248-254: Add a lexer test in the existing tests module using the
assert_lex! scaffolding that constructs a lexer with a range ending before
source.len(). Assert that the lexer reports EOF at the bounded range end,
exercising the from_range path and the self.end checks in is_eof and related
byte-bound methods without changing production behavior.

In `@crates/biome_markdown_parser/src/lib.rs`:
- Around line 45-49: Replace the vacuous debug_assert! on
output.deferred_inlines in the parser flow with validation that detects records
skipped by parse_deferred_inlines, including continue paths and
parse_inline_fragment returning None; alternatively remove the assertion
entirely so it does not imply successful resolution. Anchor the change to
parse_deferred_inlines and preserve the existing parsing behavior.

In `@crates/biome_parser/src/diagnostic.rs`:
- Around line 159-161: Add rustdoc documentation to the public Diagnostic::span
accessor, remove the duplicate diagnostic_range method, and update
merge_diagnostics to use span() instead.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5bcef1dc-1cca-45b1-b19b-902e1caddd04

📥 Commits

Reviewing files that changed from the base of the PR and between 6be7be1 and 496501a.

⛔ Files ignored due to path filters (1)
  • crates/biome_markdown_formatter/tests/specs/markdown/reference_link.md.snap is excluded by !**/*.snap and included by **
📒 Files selected for processing (13)
  • crates/biome_markdown_formatter/tests/specs/markdown/reference_link.md
  • crates/biome_markdown_parser/src/inline_phase.rs
  • crates/biome_markdown_parser/src/lexer/mod.rs
  • crates/biome_markdown_parser/src/lib.rs
  • crates/biome_markdown_parser/src/link_reference.rs
  • crates/biome_markdown_parser/src/parser.rs
  • crates/biome_markdown_parser/src/syntax/header.rs
  • crates/biome_markdown_parser/src/syntax/link_block.rs
  • crates/biome_markdown_parser/src/syntax/mod.rs
  • crates/biome_markdown_parser/src/to_html.rs
  • crates/biome_markdown_parser/src/token_source.rs
  • crates/biome_markdown_parser/tests/cst_invariants.rs
  • crates/biome_parser/src/diagnostic.rs
💤 Files with no reviewable changes (1)
  • crates/biome_markdown_parser/src/link_reference.rs

Comment thread crates/biome_markdown_parser/src/inline_phase.rs
Comment thread crates/biome_markdown_parser/src/parser.rs
@codspeed-hq

codspeed-hq Bot commented Aug 6, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 66.42%

⚡ 28 improved benchmarks
✅ 15 untouched benchmarks
⏩ 238 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
synthetic/emphasis-heavy.md[cached] 2.3 ms 1.2 ms +98.87%
synthetic/inline-html.md[cached] 1,135.5 µs 571.1 µs +98.83%
spec/inline-html.md[cached] 1,536.2 µs 806.5 µs +90.48%
real/readme-style.md[cached] 2.9 ms 1.5 ms +89.66%
synthetic/nested-lists.md[cached] 5.6 ms 2.9 ms +88.97%
spec/emphasis.md[cached] 3.1 ms 1.6 ms +87.19%
synthetic/emphasis-heavy.md[uncached] 2.4 ms 1.3 ms +86.46%
synthetic/nested-lists.md[uncached] 5.6 ms 3 ms +86.15%
synthetic/inline-html.md[uncached] 1,154.5 µs 620.9 µs +85.94%
spec/autolinks.md[cached] 1,458 µs 784.4 µs +85.87%
synthetic/long-paragraphs.md[cached] 2 ms 1.1 ms +85.25%
synthetic/blockquotes-nested.md[cached] 3 ms 1.7 ms +84.38%
real/readme-style.md[uncached] 2.9 ms 1.6 ms +81.83%
spec/emphasis.md[uncached] 3.1 ms 1.7 ms +81.4%
synthetic/blockquotes-nested.md[uncached] 3.1 ms 1.7 ms +80.26%
spec/blockquotes.md[cached] 529.4 µs 296.8 µs +78.34%
spec/inline-html.md[uncached] 1,548.4 µs 869.2 µs +78.14%
synthetic/long-paragraphs.md[uncached] 2.1 ms 1.2 ms +77.23%
spec/autolinks.md[uncached] 1,453.8 µs 837.1 µs +73.66%
spec/blockquotes.md[uncached] 521.4 µs 305.5 µs +70.65%
... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing refactor/two-phase-md-parse (10e41a4) with main (51997e6)

Open in CodSpeed

Footnotes

  1. 238 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Parser conformance results on

js/262

Test result main count This PR count Difference
Total 53595 53595 0
Passed 52312 52312 0
Failed 1241 1241 0
Panics 42 42 0
Coverage 97.61% 97.61% 0.00%

jsx/babel

Test result main count This PR count Difference
Total 38 38 0
Passed 37 37 0
Failed 1 1 0
Panics 0 0 0
Coverage 97.37% 97.37% 0.00%

markdown/commonmark

Test result main count This PR count Difference
Total 652 652 0
Passed 652 652 0
Failed 0 0 0
Panics 0 0 0
Coverage 100.00% 100.00% 0.00%

symbols/microsoft

Test result main count This PR count Difference
Total 5467 5467 0
Passed 1915 1915 0
Failed 3552 3552 0
Panics 0 0 0
Coverage 35.03% 35.03% 0.00%

ts/babel

Test result main count This PR count Difference
Total 676 676 0
Passed 592 592 0
Failed 84 84 0
Panics 0 0 0
Coverage 87.57% 87.57% 0.00%

ts/microsoft

Test result main count This PR count Difference
Total 18876 18876 0
Passed 13010 13010 0
Failed 5865 5865 0
Panics 1 1 0
Coverage 68.92% 68.92% 0.00%

@ematipico
ematipico merged commit c4a07bf into main Aug 6, 2026
42 of 128 checks passed
@ematipico
ematipico deleted the refactor/two-phase-md-parse branch August 6, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Formatter Area: formatter A-Parser Area: parser L-Markdown Language: Markdown

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants