Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 2 additions & 19 deletions crates/biome_markdown_parser/src/syntax/inline/links.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ use crate::syntax::parse_error::{unclosed_image, unclosed_link};
use crate::syntax::reference::normalize_reference_label;
use crate::syntax::{
LinkDestinationKind, MAX_LINK_DESTINATION_PAREN_DEPTH, ParenDepthResult,
ends_with_unescaped_close, try_update_paren_depth, validate_link_destination_text,
ends_with_unescaped_close, get_title_close_char, is_whitespace_token, try_update_paren_depth,
validate_link_destination_text,
};

/// Parse link starting with `[` - dispatches to inline link or reference link.
Expand Down Expand Up @@ -594,11 +595,6 @@ fn bump_textual_link_def(p: &mut MarkdownParser) {
item.complete(p, MD_TEXTUAL);
}

fn is_whitespace_token(p: &MarkdownParser) -> bool {
let text = p.cur_text();
!text.is_empty() && text.chars().all(|c| c == ' ' || c == '\t')
}

fn inline_title_starts_after_whitespace_tokens(p: &mut MarkdownParser) -> bool {
p.lookahead(|p| {
let mut saw_whitespace = false;
Expand Down Expand Up @@ -778,19 +774,6 @@ fn bump_link_def_separator(p: &mut MarkdownParser) {
}
}

fn get_title_close_char(p: &MarkdownParser) -> Option<char> {
let text = p.cur_text();
if text.starts_with('"') {
Some('"')
} else if text.starts_with('\'') {
Some('\'')
} else if p.at(L_PAREN) {
Some(')')
} else {
None
}
}

fn parse_title_content(p: &mut MarkdownParser, close_char: Option<char>) {
let Some(close_char) = close_char else {
return;
Expand Down
25 changes: 2 additions & 23 deletions crates/biome_markdown_parser/src/syntax/link_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ use crate::lexer::MarkdownLexContext;
use crate::syntax::reference::normalize_reference_label;
use crate::syntax::{
LinkDestinationKind, MAX_BLOCK_PREFIX_INDENT, MAX_LINK_DESTINATION_PAREN_DEPTH,
ParenDepthResult, ends_with_unescaped_close, try_update_paren_depth,
validate_link_destination_text,
ParenDepthResult, ends_with_unescaped_close, get_title_close_char, is_whitespace_token,
try_update_paren_depth, validate_link_destination_text,
};

/// Maximum label length per CommonMark spec (999 characters).
Expand Down Expand Up @@ -632,21 +632,6 @@ fn parse_link_title(p: &mut MarkdownParser) {
m.complete(p, MD_LINK_TITLE);
}

/// Get the closing character for a title based on current token.
/// Returns None if not at a title start.
fn get_title_close_char(p: &MarkdownParser) -> Option<char> {
let text = p.cur_text();
if text.starts_with('"') {
Some('"')
} else if text.starts_with('\'') {
Some('\'')
} else if p.at(L_PAREN) {
Some(')')
} else {
None
}
}

/// Parse title content until closing delimiter, including trailing whitespace.
///
/// Inside title quotes, we use Regular context so whitespace doesn't split tokens.
Expand Down Expand Up @@ -708,12 +693,6 @@ fn parse_title_content(p: &mut MarkdownParser, close_char: Option<char>) {
}
}

/// Check if current token is whitespace (space or tab).
fn is_whitespace_token(p: &MarkdownParser) -> bool {
let text = p.cur_text();
!text.is_empty() && text.chars().all(|c| c == ' ' || c == '\t')
}

/// Consume the current token as an MdTextual node.
///
/// This is a helper to reduce boilerplate for the common pattern:
Expand Down
42 changes: 25 additions & 17 deletions crates/biome_markdown_parser/src/syntax/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,27 @@ use thematic_break_block::{at_thematic_break_block, parse_thematic_break_block};

use crate::MarkdownParser;

/// Check if current token is whitespace (space or tab).
pub(crate) fn is_whitespace_token(p: &MarkdownParser) -> bool {
let text = p.cur_text();
!text.is_empty() && text.chars().all(|c| c == ' ' || c == '\t')
Comment thread
ematipico marked this conversation as resolved.
}

/// Get the closing character for a title based on current token.
/// Returns `None` if not at a title start.
pub(crate) fn get_title_close_char(p: &MarkdownParser) -> Option<char> {

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.

What's a "title"?

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.

Added CommonMark spec references — this is the link title from §4.7 (link reference definitions) and §6.3 (inline links), i.e. the "title", 'title', or (title) part after the destination.

let text = p.cur_text();
if text.starts_with('"') {
Some('"')
} else if text.starts_with('\'') {
Some('\'')
} else if p.at(L_PAREN) {
Some(')')
} else {
None
}
}

/// Maximum paren nesting allowed in link destinations per CommonMark.
pub(crate) const MAX_LINK_DESTINATION_PAREN_DEPTH: i32 = 32;

Expand Down Expand Up @@ -644,24 +665,11 @@ pub(crate) fn is_dash_only_thematic_break_text(text: &str) -> bool {
!text.is_empty() && text.trim().chars().all(|c| c == '-')
}

/// Token-based check: is the current line a setext underline?
///
/// Call after consuming a NEWLINE token. Skips 0–3 columns of leading whitespace
/// (tabs expand to the next tab stop per CommonMark §2.2), then checks for
/// `MD_SETEXT_UNDERLINE_LITERAL` or a dash-only `MD_THEMATIC_BREAK_LITERAL`.
///
/// Returns `Some(bytes_consumed)` if the line is a setext underline, `None` otherwise.
/// The byte count includes only the whitespace tokens consumed during the indent skip,
/// NOT the underline token itself. Callers that track byte budgets must subtract this.
///
/// This is the shared helper for setext detection in inline contexts.
/// Used by `has_matching_code_span_closer`, `parse_inline_html`, and `parse_inline_item_list`.
/// Returns `Some(indent_bytes)` if the current line is a setext underline.
///
/// Context safety: this function does NOT call `allow_setext_heading` because the token
/// stream itself encodes context. In blockquotes, `R_ANGLE` tokens appear after NEWLINE
/// before content, so the whitespace-only skip naturally rejects those lines. In list
/// items, the indent reflected in the token stream is the raw line indent, and the
/// `columns < 4` check correctly rejects lines with 4+ columns of leading whitespace.
/// Call this after consuming `NEWLINE`. It skips up to 3 columns of leading
/// whitespace, then checks for a setext underline token or a dash-only thematic
/// break token. The returned byte count covers only the skipped whitespace.
pub(crate) fn at_setext_underline_after_newline(p: &mut MarkdownParser) -> Option<usize> {
let mut columns = 0;
let mut bytes_consumed = 0;
Expand Down
Loading