Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -79,21 +79,3 @@ info: markdown/list/issue-17652.md
1. Another
2. List
```

# Errors
```
issue-17652.md:11:1 parse ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Expected an ordered list item

9 │ 2. List
10 │
> 11 │ 1. Some text, and code block below, with newline after code block
│ ^^
12 │
13 │ 1. Another

i Ordered list items start with a number followed by `.` or `)` at the beginning of a line


```
10 changes: 10 additions & 0 deletions crates/biome_markdown_parser/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ pub(crate) struct MarkdownParserState {
pub(crate) list_item_indents: Vec<ListItemIndent>,
/// Recorded quote marker indents keyed by quote node range.
pub(crate) quote_indents: Vec<QuoteIndent>,
/// Whether the most recently parsed list ended with a blank line.
pub(crate) last_list_ends_with_blank: bool,
/// Virtual line start override for container prefixes (e.g., block quotes).
pub(crate) virtual_line_start: Option<TextSize>,
/// Flag to unwind quote parsing when nesting exceeds the maximum depth.
Expand Down Expand Up @@ -192,6 +194,14 @@ impl<'source> MarkdownParser<'source> {
self.state.quote_indents.push(QuoteIndent { range, indent });
}

pub(crate) fn set_last_list_ends_with_blank(&mut self, value: bool) {
self.state.last_list_ends_with_blank = value;
}

pub(crate) fn take_last_list_ends_with_blank(&mut self) -> bool {
std::mem::take(&mut self.state.last_list_ends_with_blank)
}

/// Re-lex the current token using LinkDefinition context.
/// This makes whitespace produce separate tokens for destination/title parsing.
pub(crate) fn re_lex_link_definition(&mut self) {
Expand Down
195 changes: 87 additions & 108 deletions crates/biome_markdown_parser/src/syntax/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ fn compute_marker_indent(p: &MarkdownParser) -> usize {
return p.line_start_leading_indent();
}

// If the current token still contains the preserved pre-marker
// indentation, measure to the first non-whitespace character on the
// line. Measuring to the current token start would incorrectly report
// column 0 for nested list markers.
if p.at(MD_TEXTUAL_LITERAL) && is_whitespace_only(p.cur_text()) {
return p.line_start_leading_indent();
}

// Virtual line start: compute actual column from source text.
// The leading whitespace was skipped as trivia, but we need the
// real column for indented code block detection in nested lists.
Expand Down Expand Up @@ -311,7 +319,7 @@ struct ListItemBlankInfo {

fn skip_blank_lines_between_items(
p: &mut MarkdownParser,
has_item_after_blank_lines: fn(&mut MarkdownParser) -> bool,
has_item_after_blank_lines: impl Fn(&mut MarkdownParser) -> bool,
is_tight: &mut bool,
last_item_ends_with_blank: &mut bool,
) {
Expand Down Expand Up @@ -361,7 +369,7 @@ fn parse_list_element_common<M, FMarker, FParse>(
marker_state: &mut Option<M>,
current_marker: FMarker,
parse_item: FParse,
has_item_after_blank_lines: fn(&mut MarkdownParser) -> bool,
has_item_after_blank_lines: impl Fn(&mut MarkdownParser) -> bool,
is_tight: &mut bool,
last_item_ends_with_blank: &mut bool,
) -> ParsedSyntax
Expand Down Expand Up @@ -486,7 +494,7 @@ impl ParseNodeList for BulletList {
&mut self.marker_kind,
current_bullet_marker,
parse_bullet,
has_bullet_item_after_blank_lines,
|p| has_bullet_item_after_blank_lines_at_indent(p, self.marker_indent),
&mut self.is_tight,
&mut self.last_item_ends_with_blank,
)
Expand All @@ -512,34 +520,10 @@ impl ParseNodeList for BulletList {
|p, _marker_kind| {
let next_is_bullet_at_indent = p.lookahead(|p| {
p.bump(NEWLINE);
// Count indent before marker (tabs expand to next tab stop)
let mut indent = 0usize;
while p.at(MD_TEXTUAL_LITERAL) {
let text = p.cur_text();
if text == " " {
indent += 1;
p.bump(MD_TEXTUAL_LITERAL);
} else if text == "\t" {
indent += TAB_STOP_SPACES - (indent % TAB_STOP_SPACES);
p.bump(MD_TEXTUAL_LITERAL);
} else {
break;
}
}
// Check indent matches this list's marker indent
let indent_ok = if marker_indent == 0 {
indent <= MAX_BLOCK_PREFIX_INDENT
} else {
indent >= marker_indent && indent <= marker_indent + MAX_BLOCK_PREFIX_INDENT
};
if !indent_ok {
return false;
}
if p.at(T![-]) || p.at(T![*]) || p.at(T![+]) {
p.bump(p.cur());
return marker_followed_by_whitespace_or_eol(p);
}
false
// Don't set virtual_line_start here — it would cause
// list_item_within_indent to zero out base_indent,
// making the inner list claim items at outer indent.
at_bullet_list_item_with_base_indent(p, marker_indent)
});
if next_is_bullet_at_indent {
Some(false)
Expand Down Expand Up @@ -572,6 +556,7 @@ impl ParseNodeList for BulletList {
let range = completed.range(p);

p.record_list_tightness(range, self.is_tight);
p.set_last_list_ends_with_blank(self.last_item_ends_with_blank);
completed
}
}
Expand Down Expand Up @@ -831,14 +816,17 @@ struct OrderedList {
last_item_ends_with_blank: bool,
/// The delimiter for this ordered list (`.` or `)`).
marker_delim: Option<char>,
/// The indentation level of the list marker (0 for top-level).
marker_indent: usize,
}

impl OrderedList {
fn new() -> Self {
fn new(marker_indent: usize) -> Self {
Self {
is_tight: true,
last_item_ends_with_blank: false,
marker_delim: None,
marker_indent,
}
}
}
Expand All @@ -855,13 +843,19 @@ impl ParseNodeList for OrderedList {
&mut self.marker_delim,
current_ordered_delim,
parse_ordered_bullet,
has_ordered_item_after_blank_lines,
|p| has_ordered_item_after_blank_lines_at_indent(p, self.marker_indent),
&mut self.is_tight,
Comment on lines +846 to 847
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.

⚠️ Potential issue | 🟠 Major

Keep the ordered-list delimiter in the indent-aware lookahead.

These branches now only answer “is there an ordered item here?” and lose whether it was . or ). The newline path also asks current_ordered_delim(p) at the pre-NEWLINE position, so the marker_delim check can be skipped entirely. That can merge mixed-delimiter items into one list when this continuation path is taken.

Also applies to: 855-882

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_markdown_parser/src/syntax/list.rs` around lines 846 - 847, The
lookahead used in has_ordered_item_after_blank_lines_at_indent (and the similar
branches around the 855-882 region) currently only returns a boolean so it loses
the ordered-item delimiter ('.' vs ')'), causing mixed-delimiter items to be
merged; update the lookahead API and its call sites (e.g., where
has_ordered_item_after_blank_lines_at_indent is invoked alongside
self.marker_indent and &mut self.is_tight) to return or propagate the actual
marker delimiter (or accept marker_delim as an input) and use that delimiter
when validating continuations (instead of calling current_ordered_delim at the
pre-NEWLINE position or skipping marker_delim checks). Ensure functions that
decide continuation branches (the newline path and other ordered-item checks)
receive and compare the marker_delim so delimiter type is preserved across the
lookahead.

&mut self.last_item_ends_with_blank,
)
}

fn is_at_list_end(&self, p: &mut Self::Parser<'_>) -> bool {
let marker_indent = self.marker_indent;

if p.at_line_start() && at_blank_line_start(p) {
return !has_ordered_item_after_blank_lines_at_indent(p, marker_indent);
}

is_at_list_end_common(
p,
self.marker_delim,
Expand All @@ -871,12 +865,7 @@ impl ParseNodeList for OrderedList {
|p, marker_delim| {
let next_is_ordered = p.lookahead(|p| {
p.bump(NEWLINE);
skip_leading_whitespace_tokens(p);
if p.at(MD_ORDERED_LIST_MARKER) {
p.bump(MD_ORDERED_LIST_MARKER);
return marker_followed_by_whitespace_or_eol(p);
}
false
at_order_list_item_with_base_indent(p, marker_indent)
});
if next_is_ordered {
if let (Some(current_delim), Some(next_delim)) =
Expand All @@ -887,7 +876,10 @@ impl ParseNodeList for OrderedList {
}
return Some(false);
}
Some(!has_ordered_item_after_blank_lines(p))
Some(!has_ordered_item_after_blank_lines_at_indent(
p,
marker_indent,
))
},
)
}
Expand All @@ -909,6 +901,7 @@ impl ParseNodeList for OrderedList {
let completed = m.complete(p, Self::LIST_KIND);
let range = completed.range(p);
p.record_list_tightness(range, self.is_tight);
p.set_last_list_ends_with_blank(self.last_item_ends_with_blank);
completed
}
}
Expand Down Expand Up @@ -974,7 +967,8 @@ pub(crate) fn parse_order_list_item(p: &mut MarkdownParser) -> ParsedSyntax {
p.state_mut().list_nesting_depth += 1;

// Use ParseNodeList to parse the list with proper recovery
let mut list_helper = OrderedList::new();
let marker_indent = compute_marker_indent(p);
let mut list_helper = OrderedList::new(marker_indent);
list_helper.parse_list(p);

// Decrement list depth
Expand Down Expand Up @@ -1844,6 +1838,10 @@ fn parse_first_line_blocks(
false
};
} else {
if p.take_last_list_ends_with_blank() {
state.has_blank_line = true;
state.last_was_blank = true;
}
state.last_block_was_paragraph = false;
}
state.first_line = false;
Expand Down Expand Up @@ -2014,10 +2012,6 @@ fn check_continuation_indent(
let is_indent_code_block =
allow_indent_code_block && indent >= state.required_indent + INDENT_CODE_BLOCK_SPACES;
if !is_indent_code_block {
// Sufficient indentation - skip it and continue
// (emitting indent tokens here is not possible because MdIndentToken
// is not a valid child of MdBlockList — leave as trivia)
p.skip_line_indent(state.required_indent);
let prev_virtual = p.state().virtual_line_start;
p.state_mut().virtual_line_start = Some(p.cur_range().start());

Expand All @@ -2042,6 +2036,12 @@ fn check_continuation_indent(
};
}

// Consume the structural indent as trivia so that block
// detection (at_bullet_list_item, etc.) works on the
// de-indented content. See §5.2 continuation lines.
p.skip_line_indent(state.required_indent);
p.set_virtual_line_start();

return ContinuationResult {
action: LoopAction::FallThrough,
restore: VirtualLineRestore::Restore(prev_virtual),
Expand All @@ -2065,6 +2065,16 @@ fn check_continuation_indent(
};
}

// Once a nested item's continuation drops back to its marker column,
// the line belongs to the parent item rather than lazily extending
// the nested paragraph.
if state.marker_indent > 0 && indent <= state.marker_indent {
return ContinuationResult {
action: LoopAction::Break,
restore: VirtualLineRestore::None,
};
}

// Lazy continuation per CommonMark §5.2
if !state.last_block_was_paragraph {
return ContinuationResult {
Expand Down Expand Up @@ -2104,6 +2114,10 @@ fn parse_continuation_block(
} else {
false
};
if p.take_last_list_ends_with_blank() {
state.has_blank_line = true;
state.last_was_blank = true;
}

if let VirtualLineRestore::Restore(prev_virtual) = restore {
p.state_mut().virtual_line_start = prev_virtual;
Expand Down Expand Up @@ -2513,13 +2527,12 @@ fn consume_blank_line(p: &mut MarkdownParser) {
/// they just make it "loose". This function peeks ahead across blank lines
/// to see if another bullet item follows.
fn has_bullet_item_after_blank_lines(p: &mut MarkdownParser) -> bool {
has_list_item_after_blank_lines(p, |p| {
if p.at(T![-]) || p.at(T![*]) || p.at(T![+]) {
p.bump(p.cur());
marker_followed_by_whitespace_or_eol(p)
} else {
false
}
has_list_item_after_blank_lines_at_indent(p, 0, |p| {
let prev_virtual = p.state().virtual_line_start;
p.state_mut().virtual_line_start = Some(p.cur_range().start());
let result = at_bullet_list_item_with_base_indent(p, 0);
p.state_mut().virtual_line_start = prev_virtual;
result
})
}

Expand All @@ -2530,12 +2543,11 @@ fn has_bullet_item_after_blank_lines_at_indent(
expected_indent: usize,
) -> bool {
has_list_item_after_blank_lines_at_indent(p, expected_indent, |p| {
if p.at(T![-]) || p.at(T![*]) || p.at(T![+]) {
p.bump(p.cur());
marker_followed_by_whitespace_or_eol(p)
} else {
false
}
let prev_virtual = p.state().virtual_line_start;
p.state_mut().virtual_line_start = Some(p.cur_range().start());
let result = at_bullet_list_item_with_base_indent(p, expected_indent);
p.state_mut().virtual_line_start = prev_virtual;
result
})
}

Expand Down Expand Up @@ -2598,58 +2610,25 @@ where
/// they just make it "loose". This function peeks ahead across blank lines
/// to see if another ordered item follows.
fn has_ordered_item_after_blank_lines(p: &mut MarkdownParser) -> bool {
has_list_item_after_blank_lines(p, |p| p.at(MD_ORDERED_LIST_MARKER))
has_list_item_after_blank_lines_at_indent(p, 0, |p| {
let prev_virtual = p.state().virtual_line_start;
p.state_mut().virtual_line_start = Some(p.cur_range().start());
let result = at_order_list_item_with_base_indent(p, 0);
p.state_mut().virtual_line_start = prev_virtual;
result
})
}

fn has_list_item_after_blank_lines<F>(p: &mut MarkdownParser, has_marker: F) -> bool
where
F: Fn(&mut MarkdownParser) -> bool,
{
p.lookahead(|p| {
// Skip all blank lines
loop {
// Skip whitespace on current line
while p.at(MD_TEXTUAL_LITERAL) {
let text = p.cur_text();
if text == " " || text == "\t" {
p.bump(MD_TEXTUAL_LITERAL);
} else {
break;
}
}

// If at NEWLINE, consume it and continue checking
if p.at(NEWLINE) {
p.bump(NEWLINE);
continue;
}

// Reached non-blank content or EOF
break;
}

// Check for marker directly (avoid nested lookahead issues)
// Skip leading indent (up to 3 spaces for list items)
let mut indent = 0;
while p.at(MD_TEXTUAL_LITERAL) {
let text = p.cur_text();
if text == " " {
indent += 1;
p.bump(MD_TEXTUAL_LITERAL);
} else if text == "\t" {
indent += TAB_STOP_SPACES - (indent % TAB_STOP_SPACES);
p.bump(MD_TEXTUAL_LITERAL);
} else {
break;
}
}

// More than 3 spaces indent = indented code block, not a list item
if indent > MAX_BLOCK_PREFIX_INDENT {
return false;
}

has_marker(p)
fn has_ordered_item_after_blank_lines_at_indent(
p: &mut MarkdownParser,
expected_indent: usize,
) -> bool {
has_list_item_after_blank_lines_at_indent(p, expected_indent, |p| {
let prev_virtual = p.state().virtual_line_start;
p.state_mut().virtual_line_start = Some(p.cur_range().start());
let result = at_order_list_item_with_base_indent(p, expected_indent);
p.state_mut().virtual_line_start = prev_virtual;
result
})
}

Expand Down
Loading
Loading