Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/biome_markdown_formatter/src/markdown/any/inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ impl FormatRule<AnyMdInline> for FormatAnyMdInline {
AnyMdInline::MdEntityReference(node) => node.format().fmt(f),
AnyMdInline::MdHardLine(node) => node.format().fmt(f),
AnyMdInline::MdHtmlBlock(node) => node.format().fmt(f),
AnyMdInline::MdIndentToken(node) => node.format().fmt(f),
AnyMdInline::MdInlineCode(node) => node.format().fmt(f),
AnyMdInline::MdInlineEmphasis(node) => node.format().fmt(f),
AnyMdInline::MdInlineHtml(node) => node.format().fmt(f),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
---
source: crates/biome_formatter_test/src/snapshot_builder.rs
assertion_line: 212
info: markdown/blockquote/code.md
---

# Input

```md
Expand Down Expand Up @@ -67,31 +69,6 @@ info: markdown/blockquote/code.md
>```
```

# Errors
```
code.md:2:2 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Unterminated fenced code block, expected closing triple backticks (```).

1 │ > NOTE: To use `unobtrusive`, `unobtrusive/import`, `unobtrusive/react`, and `unobtrusive/flowtype` together, your eslint config would look like this:
> 2 │ >```json
│ ^^^
3 │ >{
4 │ > "extends": [

i code block started here

1 │ > NOTE: To use `unobtrusive`, `unobtrusive/import`, `unobtrusive/react`, and `unobtrusive/flowtype` together, your eslint config would look like this:
> 2 │ >```json
│ ^^^
3 │ >{
4 │ > "extends": [

i Add closing triple backticks (```) at the start of a new line.


```

# Lines exceeding max width of 80 characters
```
1: > NOTE: To use `unobtrusive`, `unobtrusive/import`, `unobtrusive/react`, and `unobtrusive/flowtype` together, your eslint config would look like this:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
---
source: crates/biome_formatter_test/src/snapshot_builder.rs
assertion_line: 212
info: markdown/blockquote/ignore-code.md
---

# Input

```md
Expand Down Expand Up @@ -142,112 +144,3 @@ info: markdown/blockquote/ignore-code.md
> b = 2
> ````
```

# Errors
```
ignore-code.md:1:3 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Unterminated fenced code block, expected closing triple backticks (```).

> 1 │ > ````md
│ ^^^^
2 │ > <!-- biome-ignore format: prettier ignore -->
3 │ > ```js

i code block started here

> 1 │ > ````md
│ ^^^^
2 │ > <!-- biome-ignore format: prettier ignore -->
3 │ > ```js

i Add closing triple backticks (```) at the start of a new line.

ignore-code.md:8:3 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Unterminated fenced code block, expected closing triple backticks (```).

6 │ > ````
7 │
> 8 │ > ```md
│ ^^^
9 │ > <!-- biome-ignore format: prettier ignore -->
10 │ > - This is a long long

i code block started here

6 │ > ````
7 │
> 8 │ > ```md
│ ^^^
9 │ > <!-- biome-ignore format: prettier ignore -->
10 │ > - This is a long long

i Add closing triple backticks (```) at the start of a new line.

ignore-code.md:16:5 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Unterminated fenced code block, expected closing triple backticks (```).

15 │ > - test
> 16 │ > ```md
│ ^^^
17 │ > <!-- biome-ignore format: prettier ignore -->
18 │ > - This is a long long

i code block started here

15 │ > - test
> 16 │ > ```md
│ ^^^
17 │ > <!-- biome-ignore format: prettier ignore -->
18 │ > - This is a long long

i Add closing triple backticks (```) at the start of a new line.

ignore-code.md:32:3 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Unterminated fenced code block, expected closing triple backticks (```).

30 │ ````
31 │
> 32 │ > ````md
│ ^^^^
33 │ > > ```md
34 │ > > <!-- biome-ignore format: prettier ignore -->

i code block started here

30 │ ````
31 │
> 32 │ > ````md
│ ^^^^
33 │ > > ```md
34 │ > > <!-- biome-ignore format: prettier ignore -->

i Add closing triple backticks (```) at the start of a new line.

ignore-code.md:48:3 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Unterminated fenced code block, expected closing triple backticks (```).

46 │ >·
47 │
> 48 │ > ````js
│ ^^^^
49 │ > // biome-ignore format: prettier ignore
50 │ > const x = 1,

i code block started here

46 │ >·
47 │
> 48 │ > ````js
│ ^^^^
49 │ > // biome-ignore format: prettier ignore
50 │ > const x = 1,

i Add closing triple backticks (```) at the start of a new line.


```
177 changes: 122 additions & 55 deletions crates/biome_markdown_parser/src/syntax/fenced_code_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use biome_parser::{
};

use crate::syntax::parse_error::unterminated_fenced_code;
use crate::syntax::quote::try_bump_quote_marker;
use crate::syntax::{MAX_BLOCK_PREFIX_INDENT, TAB_STOP_SPACES};

/// Minimum number of fence characters required per CommonMark §4.5.
Expand Down Expand Up @@ -276,70 +277,131 @@ fn parse_code_content(

// Consume all tokens until we see the matching closing fence or EOF
Comment thread
ematipico marked this conversation as resolved.
while !p.at(T![EOF]) {
if at_line_start && quote_depth > 0 {
let prev_virtual = p.state().virtual_line_start;
p.state_mut().virtual_line_start = Some(p.cur_range().start());
p.skip_line_indent(MAX_BLOCK_PREFIX_INDENT);
p.state_mut().virtual_line_start = prev_virtual;

let mut ok = true;
for _ in 0..quote_depth {
if p.at(MD_TEXTUAL_LITERAL) && p.cur_text().starts_with('>') {
p.force_relex_regular();
}
match prepare_next_code_content_token(
p,
is_tilde_fence,
fence_len,
fence_indent,
quote_depth,
&mut at_line_start,
) {
CodeContentLoopAction::Break => break,
CodeContentLoopAction::Continue => continue,
CodeContentLoopAction::ConsumeText => {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
CodeContentLoopAction::Continue => continue,
CodeContentLoopAction::ConsumeText => {}
CodeContentLoopAction::Continue |
CodeContentLoopAction::ConsumeText => continue,

@jfmcdowell jfmcdowell Mar 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These arms have different semantics — ConsumeText falls through to bump_code_textual(p) + at_line_start = false, while Continue skips both. Merging them would cause an infinite loop (parser position never advances).

Open to restructuring if you have a different approach in mind — what would you prefer here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Maybe something like this that inverts the enum?

enum CodeContentTokenAction {
    Break,
    Skip,  // renamed from Continue
    Consume,  // renamed from ConsumeText
}

fn parse_code_content(...) {
    // ...
    while !p.at(T![EOF]) {
        match prepare_next_code_content_token(...) {
            CodeContentTokenAction::Break => break,
            CodeContentTokenAction::Skip => continue,
            CodeContentTokenAction::Consume => {
                bump_code_textual(p);
                at_line_start = false;
            }
        }
    }
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Much better yes!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved: I've refactored the control flow to make all three code paths explicit within the match statement (d44b297)

}

if p.at(T![>]) {
p.parse_as_skipped_trivia_tokens(|p| p.bump(T![>]));
} else if p.at(MD_TEXTUAL_LITERAL) && p.cur_text() == ">" {
p.parse_as_skipped_trivia_tokens(|p| p.bump_remap(T![>]));
} else {
ok = false;
break;
}
consume_code_textual(p);
at_line_start = false;
}

if p.at(MD_TEXTUAL_LITERAL) {
let text = p.cur_text();
if text == " " || text == "\t" {
p.parse_as_skipped_trivia_tokens(|p| p.bump(MD_TEXTUAL_LITERAL));
}
}
}
m.complete(p, MD_INLINE_ITEM_LIST);
}

if !ok {
break;
}
at_line_start = false;
}
enum CodeContentLoopAction {
Break,
Continue,
ConsumeText,
}

if p.at(NEWLINE) {
// Preserve newlines as code content and reset virtual line start.
let text_m = p.start();
p.bump_remap(MD_TEXTUAL_LITERAL);
text_m.complete(p, MD_TEXTUAL);
p.set_virtual_line_start();
at_line_start = true;
continue;
}
fn prepare_next_code_content_token(
Comment thread
ematipico marked this conversation as resolved.
p: &mut MarkdownParser,
is_tilde_fence: bool,
fence_len: usize,
fence_indent: usize,
quote_depth: usize,
at_line_start: &mut bool,
) -> CodeContentLoopAction {
if *at_line_start && quote_depth > 0 && !consume_quote_prefixes_in_code_content(p, quote_depth)
{
return CodeContentLoopAction::Break;
}

if consume_code_newline(p) {
*at_line_start = true;
return CodeContentLoopAction::Continue;
}

if at_closing_fence(p, is_tilde_fence, fence_len) {
return CodeContentLoopAction::Break;
}

if *at_line_start && fence_indent > 0 {
skip_fenced_content_indent(p, fence_indent);
if at_closing_fence(p, is_tilde_fence, fence_len) {
break;
return CodeContentLoopAction::Break;
}
}

if at_line_start && fence_indent > 0 {
skip_fenced_content_indent(p, fence_indent);
if at_closing_fence(p, is_tilde_fence, fence_len) {
break;
}
CodeContentLoopAction::ConsumeText
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

fn consume_quote_prefixes_in_code_content(p: &mut MarkdownParser, quote_depth: usize) -> bool {
Comment thread
ematipico marked this conversation as resolved.
let prev_virtual = p.state().virtual_line_start;
p.state_mut().virtual_line_start = Some(p.cur_range().start());
p.skip_line_indent(MAX_BLOCK_PREFIX_INDENT);
p.state_mut().virtual_line_start = prev_virtual;

for _ in 0..quote_depth {
if !consume_quote_prefix_in_code_content(p) {
return false;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Consume the token as code content (including NEWLINE tokens)
let text_m = p.start();
p.bump_remap(MD_TEXTUAL_LITERAL);
text_m.complete(p, MD_TEXTUAL);
at_line_start = false;
p.set_virtual_line_start();
true
}

fn consume_quote_prefix_in_code_content(p: &mut MarkdownParser) -> bool {
Comment thread
ematipico marked this conversation as resolved.
if p.at(MD_TEXTUAL_LITERAL) && p.cur_text().starts_with('>') {
p.force_relex_regular();
}

m.complete(p, MD_INLINE_ITEM_LIST);
if !(p.at(T![>]) || (p.at(MD_TEXTUAL_LITERAL) && p.cur_text() == ">")) {
return false;
}

let prefix_m = p.start();

// Empty pre-marker indent list (initial indent handled by skip_line_indent).
let indent_list_m = p.start();
indent_list_m.complete(p, MD_QUOTE_INDENT_LIST);

let marker_bumped = try_bump_quote_marker(p);
debug_assert!(marker_bumped, "guard above guarantees marker present");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We usually try to use messages to understand what went wrong and/or how to fix it. For example, if a developer lands here, the message should tell what caused the problem, and where to look at for possible fixes (if applicable)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — replaced unreachable!() with a safe fallback (prefix_m.abandon(p); return false), and improved the debug_assert! message to explain the root cause and where to look:

"consume_quote_prefix_in_code_content: quote marker not found after guard confirmed `>` token — check that force_relex_regular and the guard condition are in sync"

if !marker_bumped {
unreachable!("guard above guarantees marker present");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No code that panics in production. Let's find a safer approach

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — replaced unreachable!() with prefix_m.abandon(p); return false. The empty MD_QUOTE_INDENT_LIST that was already completed gets reparented to the parent via abandon, which is harmless in this theoretically unreachable path.

}

// Optional post-marker space
if p.at(MD_TEXTUAL_LITERAL) {
let text = p.cur_text();
if text == " " || text == "\t" {
p.bump_remap(MD_QUOTE_POST_MARKER_SPACE);
}
}

prefix_m.complete(p, MD_QUOTE_PREFIX);
true
}

fn consume_code_newline(p: &mut MarkdownParser) -> bool {
if !p.at(NEWLINE) {
return false;
}

// Preserve newlines as code content and reset virtual line start.
let text_m = p.start();
p.bump_remap(MD_TEXTUAL_LITERAL);
text_m.complete(p, MD_TEXTUAL);
p.set_virtual_line_start();
true
}

fn consume_code_textual(p: &mut MarkdownParser) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There's a bit of misalignment among these new functions. Some return a boolean, some don't, but they all start with consume_*. I would look for a better alignment in naming

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — renamed consume_code_textualbump_code_textual since it unconditionally bumps and doesn't return a bool. The consume_* functions all follow the try-consume pattern (return bool), while bump_* is unconditional.

// Consume the token as code content (including NEWLINE tokens).
let text_m = p.start();
p.bump_remap(MD_TEXTUAL_LITERAL);
text_m.complete(p, MD_TEXTUAL);
}

pub(crate) fn info_string_has_backtick(p: &mut MarkdownParser) -> bool {
Expand Down Expand Up @@ -390,7 +452,9 @@ fn skip_fenced_content_indent(p: &mut MarkdownParser, indent: usize) {
}

consumed += width;
p.parse_as_skipped_trivia_tokens(|p| p.bump(MD_TEXTUAL_LITERAL));
let char_m = p.start();
p.bump_remap(MD_INDENT_CHAR);
char_m.complete(p, MD_INDENT_TOKEN);
}
}

Expand All @@ -399,9 +463,12 @@ fn line_has_closing_fence(p: &MarkdownParser, is_tilde_fence: bool, fence_len: u
return false;
};

let line_start = find_line_start(&source[..start]);
let line_start: usize = match p.state().virtual_line_start {
Some(virtual_start) => virtual_start.into(),
None => find_line_start(&source[..start]),
};

if !is_whitespace_prefix(source, start, line_start) {
if line_start != start && !is_whitespace_prefix(source, start, line_start) {
return false;
}

Expand Down
Loading
Loading