use crate::parser::utils::chunk_options::hashpipe_comment_prefix;
use crate::syntax::SyntaxKind;
use rowan::GreenNodeBuilder;
use super::blockquotes::{count_blockquote_markers, strip_n_blockquote_markers};
use crate::parser::utils::container_stack::byte_index_at_column;
use crate::parser::utils::helpers::{strip_leading_spaces, strip_newline};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CodeBlockType {
DisplayShortcut { language: String },
DisplayExplicit { classes: Vec<String> },
Executable { language: String },
Raw { format: String },
Plain,
}
#[derive(Debug, Clone, PartialEq)]
pub struct InfoString {
pub raw: String,
pub block_type: CodeBlockType,
pub attributes: Vec<(String, Option<String>)>, }
impl InfoString {
pub fn parse(raw: &str) -> Self {
let trimmed = raw.trim();
if trimmed.is_empty() {
return InfoString {
raw: raw.to_string(),
block_type: CodeBlockType::Plain,
attributes: Vec::new(),
};
}
if let Some(stripped) = trimmed.strip_prefix('{')
&& let Some(content) = stripped.strip_suffix('}')
{
return Self::parse_explicit(raw, content);
}
if let Some(brace_start) = trimmed.find('{') {
let language = trimmed[..brace_start].trim();
if !language.is_empty() && !language.contains(char::is_whitespace) {
let attr_part = &trimmed[brace_start..];
if let Some(stripped) = attr_part.strip_prefix('{')
&& let Some(content) = stripped.strip_suffix('}')
{
let attrs = Self::parse_attributes(content);
return InfoString {
raw: raw.to_string(),
block_type: CodeBlockType::DisplayShortcut {
language: language.to_string(),
},
attributes: attrs,
};
}
}
}
let language = trimmed.split_whitespace().next().unwrap_or(trimmed);
InfoString {
raw: raw.to_string(),
block_type: CodeBlockType::DisplayShortcut {
language: language.to_string(),
},
attributes: Vec::new(),
}
}
fn parse_explicit(raw: &str, content: &str) -> Self {
let trimmed_content = content.trim();
if let Some(format_name) = trimmed_content.strip_prefix('=') {
if !format_name.is_empty()
&& format_name.chars().all(|c| c.is_alphanumeric())
&& !format_name.contains(char::is_whitespace)
{
return InfoString {
raw: raw.to_string(),
block_type: CodeBlockType::Raw {
format: format_name.to_string(),
},
attributes: Vec::new(),
};
}
}
let prelim_attrs = Self::parse_chunk_options(content);
let mut first_lang_token = None;
for (key, val) in prelim_attrs.iter() {
if val.is_none() && !key.starts_with('#') {
first_lang_token = Some(key.as_str());
break;
}
}
let first_token = first_lang_token.unwrap_or("");
if first_token.starts_with('.') {
let attrs = Self::parse_pandoc_attributes(content);
let classes: Vec<String> = attrs
.iter()
.filter(|(k, v)| k.starts_with('.') && v.is_none())
.map(|(k, _)| k[1..].to_string())
.collect();
let non_class_attrs: Vec<(String, Option<String>)> = attrs
.into_iter()
.filter(|(k, _)| !k.starts_with('.') || k.contains('='))
.collect();
InfoString {
raw: raw.to_string(),
block_type: CodeBlockType::DisplayExplicit { classes },
attributes: non_class_attrs,
}
} else if !first_token.is_empty() && !first_token.starts_with('#') {
let attrs = Self::parse_chunk_options(content);
let lang_index = attrs.iter().position(|(k, _)| k == first_token).unwrap();
let mut has_implicit_label = false;
let implicit_label_value = if lang_index + 1 < attrs.len() {
if let (label_key, None) = &attrs[lang_index + 1] {
has_implicit_label = true;
Some(label_key.clone())
} else {
None
}
} else {
None
};
let mut final_attrs: Vec<(String, Option<String>)> = attrs
.into_iter()
.enumerate()
.filter(|(i, _)| {
if *i == lang_index {
return false;
}
if has_implicit_label && *i == lang_index + 1 {
return false;
}
true
})
.map(|(_, attr)| attr)
.collect();
if let Some(label_val) = implicit_label_value {
final_attrs.insert(0, ("label".to_string(), Some(label_val)));
}
InfoString {
raw: raw.to_string(),
block_type: CodeBlockType::Executable {
language: first_token.to_string(),
},
attributes: final_attrs,
}
} else {
let attrs = Self::parse_pandoc_attributes(content);
InfoString {
raw: raw.to_string(),
block_type: CodeBlockType::Plain,
attributes: attrs,
}
}
}
fn parse_pandoc_attributes(content: &str) -> Vec<(String, Option<String>)> {
let mut attrs = Vec::new();
let mut chars = content.chars().peekable();
while chars.peek().is_some() {
while matches!(chars.peek(), Some(&' ') | Some(&'\t')) {
chars.next();
}
if chars.peek().is_none() {
break;
}
let mut key = String::new();
while let Some(&ch) = chars.peek() {
if ch == '=' || ch == ' ' || ch == '\t' {
break;
}
key.push(ch);
chars.next();
}
if key.is_empty() {
break;
}
while matches!(chars.peek(), Some(&' ') | Some(&'\t')) {
chars.next();
}
if chars.peek() == Some(&'=') {
chars.next();
while matches!(chars.peek(), Some(&' ') | Some(&'\t')) {
chars.next();
}
let value = if chars.peek() == Some(&'"') {
chars.next(); let mut val = String::new();
while let Some(&ch) = chars.peek() {
chars.next();
if ch == '"' {
break;
}
if ch == '\\' {
if let Some(&next_ch) = chars.peek() {
chars.next();
val.push(next_ch);
}
} else {
val.push(ch);
}
}
val
} else {
let mut val = String::new();
while let Some(&ch) = chars.peek() {
if ch == ' ' || ch == '\t' {
break;
}
val.push(ch);
chars.next();
}
val
};
attrs.push((key, Some(value)));
} else {
attrs.push((key, None));
}
}
attrs
}
fn parse_chunk_options(content: &str) -> Vec<(String, Option<String>)> {
let mut attrs = Vec::new();
let mut chars = content.chars().peekable();
while chars.peek().is_some() {
while matches!(chars.peek(), Some(&' ') | Some(&'\t') | Some(&',')) {
chars.next();
}
if chars.peek().is_none() {
break;
}
let mut key = String::new();
while let Some(&ch) = chars.peek() {
if ch == '=' || ch == ' ' || ch == '\t' || ch == ',' {
break;
}
key.push(ch);
chars.next();
}
if key.is_empty() {
break;
}
while matches!(chars.peek(), Some(&' ') | Some(&'\t') | Some(&',')) {
chars.next();
}
if chars.peek() == Some(&'=') {
chars.next();
while matches!(chars.peek(), Some(&' ') | Some(&'\t') | Some(&',')) {
chars.next();
}
let value = if chars.peek() == Some(&'"') {
chars.next(); let mut val = String::new();
while let Some(&ch) = chars.peek() {
chars.next();
if ch == '"' {
break;
}
if ch == '\\' {
if let Some(&next_ch) = chars.peek() {
chars.next();
val.push(next_ch);
}
} else {
val.push(ch);
}
}
val
} else {
let mut val = String::new();
let mut depth = 0; let mut in_quote: Option<char> = None; let mut escaped = false;
while let Some(&ch) = chars.peek() {
if escaped {
val.push(ch);
chars.next();
escaped = false;
continue;
}
if ch == '\\' {
val.push(ch);
chars.next();
escaped = true;
continue;
}
if let Some(quote_char) = in_quote {
val.push(ch);
chars.next();
if ch == quote_char {
in_quote = None; }
continue;
}
if ch == '"' || ch == '\'' {
in_quote = Some(ch);
val.push(ch);
chars.next();
continue;
}
if ch == '(' || ch == '[' || ch == '{' {
depth += 1;
val.push(ch);
chars.next();
continue;
}
if ch == ')' || ch == ']' || ch == '}' {
depth -= 1;
val.push(ch);
chars.next();
continue;
}
if depth == 0 && (ch == ' ' || ch == '\t' || ch == ',') {
break;
}
val.push(ch);
chars.next();
}
val
};
attrs.push((key, Some(value)));
} else {
attrs.push((key, None));
}
}
attrs
}
fn parse_attributes(content: &str) -> Vec<(String, Option<String>)> {
Self::parse_chunk_options(content)
}
}
#[derive(Debug, Clone)]
pub(crate) struct FenceInfo {
pub fence_char: char,
pub fence_count: usize,
pub info_string: String,
}
pub(crate) fn is_gfm_math_fence(fence: &FenceInfo) -> bool {
fence.info_string.trim() == "math"
}
pub(crate) fn try_parse_fence_open(content: &str) -> Option<FenceInfo> {
let trimmed = strip_leading_spaces(content);
let (fence_char, fence_count) = if trimmed.starts_with('`') {
let count = trimmed.chars().take_while(|&c| c == '`').count();
('`', count)
} else if trimmed.starts_with('~') {
let count = trimmed.chars().take_while(|&c| c == '~').count();
('~', count)
} else {
return None;
};
if fence_count < 3 {
return None;
}
let info_string_raw = &trimmed[fence_count..];
let (info_string_trimmed, _) = strip_newline(info_string_raw);
let info_string = if let Some(stripped) = info_string_trimmed.strip_prefix(' ') {
stripped.to_string()
} else {
info_string_trimmed.to_string()
};
if fence_char == '`' && info_string.contains('`') {
return None;
}
Some(FenceInfo {
fence_char,
fence_count,
info_string,
})
}
fn prepare_fence_open_line<'a>(
builder: &mut GreenNodeBuilder<'static>,
source_line: &'a str,
first_line_override: Option<&'a str>,
bq_depth: usize,
base_indent: usize,
) -> (&'a str, &'a str) {
let first_line = first_line_override.unwrap_or(source_line);
let first_inner = if bq_depth > 0 && first_line_override.is_none() {
strip_n_blockquote_markers(first_line, bq_depth)
} else {
if bq_depth > 0 && first_line_override.is_some() && source_line != first_line {
let stripped = strip_n_blockquote_markers(source_line, bq_depth);
let prefix_len = source_line.len().saturating_sub(stripped.len());
if prefix_len > 0 {
emit_blockquote_prefix_tokens(builder, &source_line[..prefix_len]);
}
}
first_line
};
let first_base_indent = if first_line_override.is_some() {
0
} else {
base_indent
};
let first_base_indent_bytes = byte_index_at_column(first_inner, first_base_indent);
let first_stripped = if first_base_indent > 0 && first_inner.len() >= first_base_indent_bytes {
let indent_str = &first_inner[..first_base_indent_bytes];
if !indent_str.is_empty() {
builder.token(SyntaxKind::WHITESPACE.into(), indent_str);
}
&first_inner[first_base_indent_bytes..]
} else {
first_inner
};
let first_trimmed = strip_leading_spaces(first_stripped);
let leading_ws_len = first_stripped.len().saturating_sub(first_trimmed.len());
if leading_ws_len > 0 {
builder.token(
SyntaxKind::WHITESPACE.into(),
&first_stripped[..leading_ws_len],
);
}
(first_trimmed, first_inner)
}
fn emit_blockquote_prefix_tokens(builder: &mut GreenNodeBuilder<'static>, prefix: &str) {
for ch in prefix.chars() {
if ch == '>' {
builder.token(SyntaxKind::BLOCK_QUOTE_MARKER.into(), ">");
} else {
let mut buf = [0u8; 4];
builder.token(SyntaxKind::WHITESPACE.into(), ch.encode_utf8(&mut buf));
}
}
}
fn emit_content_line_prefixes<'a>(
builder: &mut GreenNodeBuilder<'static>,
content_line: &'a str,
bq_depth: usize,
base_indent: usize,
) -> &'a str {
let after_blockquote = if bq_depth > 0 {
let stripped = strip_n_blockquote_markers(content_line, bq_depth);
let prefix_len = content_line.len().saturating_sub(stripped.len());
if prefix_len > 0 {
emit_blockquote_prefix_tokens(builder, &content_line[..prefix_len]);
}
stripped
} else {
content_line
};
let base_indent_bytes = byte_index_at_column(after_blockquote, base_indent);
if base_indent > 0 && after_blockquote.len() >= base_indent_bytes {
let indent_str = &after_blockquote[..base_indent_bytes];
if !indent_str.is_empty() {
builder.token(SyntaxKind::WHITESPACE.into(), indent_str);
}
&after_blockquote[base_indent_bytes..]
} else {
after_blockquote
}
}
fn strip_content_line_prefixes(content_line: &str, bq_depth: usize, base_indent: usize) -> &str {
let after_blockquote = if bq_depth > 0 {
strip_n_blockquote_markers(content_line, bq_depth)
} else {
content_line
};
let base_indent_bytes = byte_index_at_column(after_blockquote, base_indent);
if base_indent > 0 && after_blockquote.len() >= base_indent_bytes {
&after_blockquote[base_indent_bytes..]
} else {
after_blockquote
}
}
pub(crate) fn compute_hashpipe_preamble_line_count(
content_lines: &[&str],
prefix: &str,
bq_depth: usize,
base_indent: usize,
) -> usize {
let mut line_idx = 0usize;
while line_idx < content_lines.len() {
let preview_after_indent =
strip_content_line_prefixes(content_lines[line_idx], bq_depth, base_indent);
let (preview_without_newline, _) = strip_newline(preview_after_indent);
if !is_hashpipe_option_line(preview_without_newline, prefix)
&& !is_hashpipe_continuation_line(preview_without_newline, prefix)
{
break;
}
line_idx += 1;
}
line_idx
}
fn emit_hashpipe_option_line(
builder: &mut GreenNodeBuilder<'static>,
line_without_newline: &str,
prefix: &str,
) -> bool {
if !is_hashpipe_option_line(line_without_newline, prefix) {
return false;
}
let trimmed_start = line_without_newline.trim_start_matches([' ', '\t']);
let leading_ws_len = line_without_newline
.len()
.saturating_sub(trimmed_start.len());
let after_prefix = &trimmed_start[prefix.len()..];
let ws_after_prefix_len = after_prefix
.len()
.saturating_sub(after_prefix.trim_start_matches([' ', '\t']).len());
let rest = &after_prefix[ws_after_prefix_len..];
let Some(colon_idx) = rest.find(':') else {
return false;
};
let key_with_ws = &rest[..colon_idx];
let key = key_with_ws.trim_end_matches([' ', '\t']);
if key.is_empty() {
return false;
}
let key_ws_suffix = &key_with_ws[key.len()..];
let after_colon = &rest[colon_idx + 1..];
let value_ws_prefix_len = after_colon
.len()
.saturating_sub(after_colon.trim_start_matches([' ', '\t']).len());
let value_with_trailing = &after_colon[value_ws_prefix_len..];
let value = value_with_trailing.trim_end_matches([' ', '\t']);
let value_ws_suffix = &value_with_trailing[value.len()..];
builder.start_node(SyntaxKind::CHUNK_OPTION.into());
if leading_ws_len > 0 {
builder.token(
SyntaxKind::WHITESPACE.into(),
&line_without_newline[..leading_ws_len],
);
}
builder.token(SyntaxKind::HASHPIPE_PREFIX.into(), prefix);
if ws_after_prefix_len > 0 {
builder.token(
SyntaxKind::WHITESPACE.into(),
&after_prefix[..ws_after_prefix_len],
);
}
builder.token(SyntaxKind::CHUNK_OPTION_KEY.into(), key);
if !key_ws_suffix.is_empty() {
builder.token(SyntaxKind::WHITESPACE.into(), key_ws_suffix);
}
builder.token(SyntaxKind::TEXT.into(), ":");
if value_ws_prefix_len > 0 {
builder.token(
SyntaxKind::WHITESPACE.into(),
&after_colon[..value_ws_prefix_len],
);
}
if !value.is_empty() {
if let Some(quote) = value.chars().next()
&& (quote == '"' || quote == '\'')
&& value.ends_with(quote)
&& value.len() >= 2
{
builder.token(SyntaxKind::CHUNK_OPTION_QUOTE.into(), &value[..1]);
builder.token(
SyntaxKind::CHUNK_OPTION_VALUE.into(),
&value[1..value.len() - 1],
);
builder.token(
SyntaxKind::CHUNK_OPTION_QUOTE.into(),
&value[value.len() - 1..],
);
} else {
builder.token(SyntaxKind::CHUNK_OPTION_VALUE.into(), value);
}
}
if !value_ws_suffix.is_empty() {
builder.token(SyntaxKind::WHITESPACE.into(), value_ws_suffix);
}
builder.finish_node();
true
}
fn emit_hashpipe_continuation_line(
builder: &mut GreenNodeBuilder<'static>,
line_without_newline: &str,
prefix: &str,
) -> bool {
if !is_hashpipe_continuation_line(line_without_newline, prefix) {
return false;
}
let trimmed_start = line_without_newline.trim_start_matches([' ', '\t']);
let leading_ws_len = line_without_newline
.len()
.saturating_sub(trimmed_start.len());
let after_prefix = &trimmed_start[prefix.len()..];
let ws_after_prefix_len = after_prefix
.len()
.saturating_sub(after_prefix.trim_start_matches([' ', '\t']).len());
let continuation_with_trailing = &after_prefix[ws_after_prefix_len..];
let continuation_value = continuation_with_trailing.trim_end_matches([' ', '\t']);
if continuation_value.is_empty() {
return false;
}
let continuation_ws_suffix = &continuation_with_trailing[continuation_value.len()..];
builder.start_node(SyntaxKind::CHUNK_OPTION.into());
if leading_ws_len > 0 {
builder.token(
SyntaxKind::WHITESPACE.into(),
&line_without_newline[..leading_ws_len],
);
}
builder.token(SyntaxKind::HASHPIPE_PREFIX.into(), prefix);
if ws_after_prefix_len > 0 {
builder.token(
SyntaxKind::WHITESPACE.into(),
&after_prefix[..ws_after_prefix_len],
);
}
builder.token(SyntaxKind::CHUNK_OPTION_VALUE.into(), continuation_value);
if !continuation_ws_suffix.is_empty() {
builder.token(SyntaxKind::WHITESPACE.into(), continuation_ws_suffix);
}
builder.finish_node();
true
}
fn is_hashpipe_option_line(line_without_newline: &str, prefix: &str) -> bool {
let trimmed_start = line_without_newline.trim_start_matches([' ', '\t']);
if !trimmed_start.starts_with(prefix) {
return false;
}
let after_prefix = &trimmed_start[prefix.len()..];
let rest = after_prefix.trim_start_matches([' ', '\t']);
let Some(colon_idx) = rest.find(':') else {
return false;
};
let key = rest[..colon_idx].trim_end_matches([' ', '\t']);
if key.is_empty() {
return false;
}
true
}
fn is_hashpipe_continuation_line(line_without_newline: &str, prefix: &str) -> bool {
let trimmed_start = line_without_newline.trim_start_matches([' ', '\t']);
if !trimmed_start.starts_with(prefix) {
return false;
}
let after_prefix = &trimmed_start[prefix.len()..];
let Some(first) = after_prefix.chars().next() else {
return false;
};
if first != ' ' && first != '\t' {
return false;
}
!after_prefix.trim_start_matches([' ', '\t']).is_empty()
}
pub(crate) fn is_closing_fence(content: &str, fence: &FenceInfo) -> bool {
let trimmed = strip_leading_spaces(content);
if !trimmed.starts_with(fence.fence_char) {
return false;
}
let closing_count = trimmed
.chars()
.take_while(|&c| c == fence.fence_char)
.count();
if closing_count < fence.fence_count {
return false;
}
trimmed[closing_count..].trim().is_empty()
}
fn emit_chunk_options(builder: &mut GreenNodeBuilder<'static>, content: &str) {
if content.trim().is_empty() {
builder.token(SyntaxKind::TEXT.into(), content);
return;
}
builder.start_node(SyntaxKind::CHUNK_OPTIONS.into());
let mut pos = 0;
let bytes = content.as_bytes();
while pos < bytes.len() {
let ws_start = pos;
while pos < bytes.len() {
let ch = bytes[pos] as char;
if ch != ' ' && ch != '\t' && ch != ',' {
break;
}
pos += 1;
}
if pos > ws_start {
builder.token(SyntaxKind::TEXT.into(), &content[ws_start..pos]);
}
if pos >= bytes.len() {
break;
}
if bytes[pos] as char == '}' {
builder.token(SyntaxKind::TEXT.into(), &content[pos..pos + 1]);
pos += 1;
if pos < bytes.len() {
builder.token(SyntaxKind::TEXT.into(), &content[pos..]);
}
break;
}
let key_start = pos;
while pos < bytes.len() {
let ch = bytes[pos] as char;
if ch == '=' || ch == ' ' || ch == '\t' || ch == ',' || ch == '}' {
break;
}
pos += 1;
}
if pos == key_start {
if pos < bytes.len() {
builder.token(SyntaxKind::TEXT.into(), &content[pos..]);
}
break;
}
let key = &content[key_start..pos];
let ws_before_eq_start = pos;
while pos < bytes.len() && matches!(bytes[pos] as char, ' ' | '\t') {
pos += 1;
}
if pos < bytes.len() && bytes[pos] as char == '=' {
builder.start_node(SyntaxKind::CHUNK_OPTION.into());
builder.token(SyntaxKind::CHUNK_OPTION_KEY.into(), key);
if pos > ws_before_eq_start {
builder.token(SyntaxKind::TEXT.into(), &content[ws_before_eq_start..pos]);
}
builder.token(SyntaxKind::TEXT.into(), "=");
pos += 1;
let ws_after_eq_start = pos;
while pos < bytes.len() && matches!(bytes[pos] as char, ' ' | '\t') {
pos += 1;
}
if pos > ws_after_eq_start {
builder.token(SyntaxKind::TEXT.into(), &content[ws_after_eq_start..pos]);
}
if pos < bytes.len() {
let quote_char = bytes[pos] as char;
if quote_char == '"' || quote_char == '\'' {
builder.token(
SyntaxKind::CHUNK_OPTION_QUOTE.into(),
&content[pos..pos + 1],
);
pos += 1;
let val_start = pos;
let mut escaped = false;
while pos < bytes.len() {
let ch = bytes[pos] as char;
if !escaped && ch == quote_char {
break;
}
escaped = !escaped && ch == '\\';
pos += 1;
}
if pos > val_start {
builder.token(
SyntaxKind::CHUNK_OPTION_VALUE.into(),
&content[val_start..pos],
);
}
if pos < bytes.len() && bytes[pos] as char == quote_char {
builder.token(
SyntaxKind::CHUNK_OPTION_QUOTE.into(),
&content[pos..pos + 1],
);
pos += 1;
}
} else {
let val_start = pos;
let mut depth = 0;
while pos < bytes.len() {
let ch = bytes[pos] as char;
match ch {
'(' | '[' | '{' => depth += 1,
')' | ']' => {
if depth > 0 {
depth -= 1;
} else {
break;
}
}
'}' => {
if depth > 0 {
depth -= 1;
} else {
break; }
}
',' if depth == 0 => {
break; }
' ' | '\t' if depth == 0 => {
break; }
_ => {}
}
pos += 1;
}
if pos > val_start {
builder.token(
SyntaxKind::CHUNK_OPTION_VALUE.into(),
&content[val_start..pos],
);
}
}
}
builder.finish_node(); } else {
if pos > ws_before_eq_start {
builder.start_node(SyntaxKind::CHUNK_LABEL.into());
builder.token(SyntaxKind::TEXT.into(), key);
builder.finish_node(); builder.token(SyntaxKind::TEXT.into(), &content[ws_before_eq_start..pos]);
} else {
builder.start_node(SyntaxKind::CHUNK_LABEL.into());
builder.token(SyntaxKind::TEXT.into(), key);
builder.finish_node(); }
}
}
builder.finish_node(); }
fn emit_code_info_node(builder: &mut GreenNodeBuilder<'static>, info_string: &str) {
builder.start_node(SyntaxKind::CODE_INFO.into());
let info = InfoString::parse(info_string);
match &info.block_type {
CodeBlockType::DisplayShortcut { language } => {
builder.token(SyntaxKind::CODE_LANGUAGE.into(), language);
let after_lang = &info_string[language.len()..];
if !after_lang.is_empty() {
builder.token(SyntaxKind::TEXT.into(), after_lang);
}
}
CodeBlockType::Executable { language } => {
builder.token(SyntaxKind::TEXT.into(), "{");
builder.token(SyntaxKind::CODE_LANGUAGE.into(), language);
let start_offset = 1 + language.len(); if start_offset < info_string.len() {
let rest = &info_string[start_offset..];
emit_chunk_options(builder, rest);
}
}
CodeBlockType::DisplayExplicit { classes } => {
if let Some(lang) = classes.first() {
let needle = format!(".{}", lang);
if let Some(lang_start) = info_string.find(&needle) {
if lang_start > 0 {
builder.token(SyntaxKind::TEXT.into(), &info_string[..lang_start]);
}
builder.token(SyntaxKind::TEXT.into(), ".");
builder.token(SyntaxKind::CODE_LANGUAGE.into(), lang);
let after_lang_start = lang_start + 1 + lang.len();
if after_lang_start < info_string.len() {
builder.token(SyntaxKind::TEXT.into(), &info_string[after_lang_start..]);
}
} else {
builder.token(SyntaxKind::TEXT.into(), info_string);
}
} else {
builder.token(SyntaxKind::TEXT.into(), info_string);
}
}
CodeBlockType::Raw { .. } | CodeBlockType::Plain => {
builder.token(SyntaxKind::TEXT.into(), info_string);
}
}
builder.finish_node(); }
pub(crate) fn parse_fenced_code_block(
builder: &mut GreenNodeBuilder<'static>,
lines: &[&str],
start_pos: usize,
fence: FenceInfo,
bq_depth: usize,
base_indent: usize,
first_line_override: Option<&str>,
) -> usize {
builder.start_node(SyntaxKind::CODE_BLOCK.into());
let (first_trimmed, _first_inner) = prepare_fence_open_line(
builder,
lines[start_pos],
first_line_override,
bq_depth,
base_indent,
);
builder.start_node(SyntaxKind::CODE_FENCE_OPEN.into());
builder.token(
SyntaxKind::CODE_FENCE_MARKER.into(),
&first_trimmed[..fence.fence_count],
);
let after_fence = &first_trimmed[fence.fence_count..];
if let Some(_space_stripped) = after_fence.strip_prefix(' ') {
builder.token(SyntaxKind::WHITESPACE.into(), " ");
if !fence.info_string.is_empty() {
emit_code_info_node(builder, &fence.info_string);
}
} else if !fence.info_string.is_empty() {
emit_code_info_node(builder, &fence.info_string);
}
let (_, newline_str) = strip_newline(first_trimmed);
if !newline_str.is_empty() {
builder.token(SyntaxKind::NEWLINE.into(), newline_str);
}
builder.finish_node();
let mut current_pos = start_pos + 1;
let mut content_lines: Vec<&str> = Vec::new(); let mut found_closing = false;
while current_pos < lines.len() {
let line = lines[current_pos];
let (line_bq_depth, _) = count_blockquote_markers(line);
if line_bq_depth < bq_depth {
break;
}
let inner = if bq_depth > 0 {
strip_n_blockquote_markers(line, bq_depth)
} else {
line
};
let base_indent_bytes = byte_index_at_column(inner, base_indent);
let inner_stripped = if base_indent > 0 && inner.len() >= base_indent_bytes {
&inner[base_indent_bytes..]
} else {
inner
};
if is_closing_fence(inner_stripped, &fence) {
found_closing = true;
current_pos += 1;
break;
}
content_lines.push(line);
current_pos += 1;
}
if !content_lines.is_empty() {
builder.start_node(SyntaxKind::CODE_CONTENT.into());
let hashpipe_prefix = match InfoString::parse(&fence.info_string).block_type {
CodeBlockType::Executable { language } => hashpipe_comment_prefix(&language),
_ => None,
};
let mut line_idx = 0usize;
if let Some(prefix) = hashpipe_prefix {
let prepared_hashpipe_lines =
compute_hashpipe_preamble_line_count(&content_lines, prefix, bq_depth, base_indent);
if prepared_hashpipe_lines > 0 {
builder.start_node(SyntaxKind::HASHPIPE_YAML_PREAMBLE.into());
builder.start_node(SyntaxKind::HASHPIPE_YAML_CONTENT.into());
while line_idx < prepared_hashpipe_lines {
let content_line = content_lines[line_idx];
let after_indent =
emit_content_line_prefixes(builder, content_line, bq_depth, base_indent);
let (line_without_newline, newline_str) = strip_newline(after_indent);
if !emit_hashpipe_option_line(builder, line_without_newline, prefix) {
let _ =
emit_hashpipe_continuation_line(builder, line_without_newline, prefix);
}
if !newline_str.is_empty() {
builder.token(SyntaxKind::NEWLINE.into(), newline_str);
}
line_idx += 1;
}
builder.finish_node(); builder.finish_node(); }
}
for content_line in content_lines.iter().skip(line_idx) {
let after_indent =
emit_content_line_prefixes(builder, content_line, bq_depth, base_indent);
let (line_without_newline, newline_str) = strip_newline(after_indent);
if !line_without_newline.is_empty() {
builder.token(SyntaxKind::TEXT.into(), line_without_newline);
}
if !newline_str.is_empty() {
builder.token(SyntaxKind::NEWLINE.into(), newline_str);
}
}
builder.finish_node(); }
if found_closing {
let closing_line = lines[current_pos - 1];
let closing_after_blockquote = if bq_depth > 0 {
let stripped = strip_n_blockquote_markers(closing_line, bq_depth);
let prefix_len = closing_line.len().saturating_sub(stripped.len());
if prefix_len > 0 {
emit_blockquote_prefix_tokens(builder, &closing_line[..prefix_len]);
}
stripped
} else {
closing_line
};
let base_indent_bytes = byte_index_at_column(closing_after_blockquote, base_indent);
if base_indent > 0 && closing_after_blockquote.len() >= base_indent_bytes {
let indent_str = &closing_after_blockquote[..base_indent_bytes];
if !indent_str.is_empty() {
builder.token(SyntaxKind::WHITESPACE.into(), indent_str);
}
}
let closing_stripped =
if base_indent > 0 && closing_after_blockquote.len() >= base_indent_bytes {
&closing_after_blockquote[base_indent_bytes..]
} else {
closing_after_blockquote
};
let (closing_without_newline, newline_str) = strip_newline(closing_stripped);
let closing_trimmed_start = strip_leading_spaces(closing_without_newline);
let leading_ws_len = closing_without_newline.len() - closing_trimmed_start.len();
let closing_count = closing_trimmed_start
.chars()
.take_while(|&c| c == fence.fence_char)
.count();
let trailing_after_marker = &closing_trimmed_start[closing_count..];
builder.start_node(SyntaxKind::CODE_FENCE_CLOSE.into());
if leading_ws_len > 0 {
builder.token(
SyntaxKind::WHITESPACE.into(),
&closing_without_newline[..leading_ws_len],
);
}
builder.token(
SyntaxKind::CODE_FENCE_MARKER.into(),
&closing_trimmed_start[..closing_count],
);
if !trailing_after_marker.is_empty() {
builder.token(SyntaxKind::WHITESPACE.into(), trailing_after_marker);
}
if !newline_str.is_empty() {
builder.token(SyntaxKind::NEWLINE.into(), newline_str);
}
builder.finish_node(); }
builder.finish_node();
current_pos
}
pub(crate) fn parse_fenced_math_block(
builder: &mut GreenNodeBuilder<'static>,
lines: &[&str],
start_pos: usize,
fence: FenceInfo,
bq_depth: usize,
base_indent: usize,
first_line_override: Option<&str>,
) -> usize {
builder.start_node(SyntaxKind::DISPLAY_MATH.into());
let (first_trimmed, _first_inner) = prepare_fence_open_line(
builder,
lines[start_pos],
first_line_override,
bq_depth,
base_indent,
);
let (opening_without_newline, opening_newline) = strip_newline(first_trimmed);
builder.token(
SyntaxKind::DISPLAY_MATH_MARKER.into(),
opening_without_newline,
);
if !opening_newline.is_empty() {
builder.token(SyntaxKind::NEWLINE.into(), opening_newline);
}
let mut current_pos = start_pos + 1;
let mut content_lines: Vec<&str> = Vec::new();
let mut found_closing = false;
while current_pos < lines.len() {
let line = lines[current_pos];
let (line_bq_depth, _) = count_blockquote_markers(line);
if line_bq_depth < bq_depth {
break;
}
let inner = if bq_depth > 0 {
strip_n_blockquote_markers(line, bq_depth)
} else {
line
};
let base_indent_bytes = byte_index_at_column(inner, base_indent);
let inner_stripped = if base_indent > 0 && inner.len() >= base_indent_bytes {
&inner[base_indent_bytes..]
} else {
inner
};
if is_closing_fence(inner_stripped, &fence) {
found_closing = true;
current_pos += 1;
break;
}
content_lines.push(line);
current_pos += 1;
}
if !content_lines.is_empty() {
let mut content = String::new();
for content_line in content_lines {
let after_indent =
emit_content_line_prefixes(builder, content_line, bq_depth, base_indent);
let (line_without_newline, newline_str) = strip_newline(after_indent);
content.push_str(line_without_newline);
content.push_str(newline_str);
}
builder.token(SyntaxKind::TEXT.into(), &content);
}
if found_closing {
let closing_line = lines[current_pos - 1];
let closing_after_blockquote = if bq_depth > 0 {
let stripped = strip_n_blockquote_markers(closing_line, bq_depth);
let prefix_len = closing_line.len().saturating_sub(stripped.len());
if prefix_len > 0 {
emit_blockquote_prefix_tokens(builder, &closing_line[..prefix_len]);
}
stripped
} else {
closing_line
};
let base_indent_bytes = byte_index_at_column(closing_after_blockquote, base_indent);
if base_indent > 0 && closing_after_blockquote.len() >= base_indent_bytes {
let indent_str = &closing_after_blockquote[..base_indent_bytes];
if !indent_str.is_empty() {
builder.token(SyntaxKind::WHITESPACE.into(), indent_str);
}
}
let closing_stripped =
if base_indent > 0 && closing_after_blockquote.len() >= base_indent_bytes {
&closing_after_blockquote[base_indent_bytes..]
} else {
closing_after_blockquote
};
let (closing_without_newline, newline_str) = strip_newline(closing_stripped);
let closing_trimmed_start = strip_leading_spaces(closing_without_newline);
let leading_ws_len = closing_without_newline.len() - closing_trimmed_start.len();
let closing_count = closing_trimmed_start
.chars()
.take_while(|&c| c == fence.fence_char)
.count();
let trailing_after_marker = &closing_trimmed_start[closing_count..];
if leading_ws_len > 0 {
builder.token(
SyntaxKind::WHITESPACE.into(),
&closing_without_newline[..leading_ws_len],
);
}
builder.token(
SyntaxKind::DISPLAY_MATH_MARKER.into(),
&closing_trimmed_start[..closing_count],
);
if !trailing_after_marker.is_empty() {
builder.token(SyntaxKind::WHITESPACE.into(), trailing_after_marker);
}
if !newline_str.is_empty() {
builder.token(SyntaxKind::NEWLINE.into(), newline_str);
}
}
builder.finish_node(); current_pos
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backtick_fence() {
let fence = try_parse_fence_open("```python").unwrap();
assert_eq!(fence.fence_char, '`');
assert_eq!(fence.fence_count, 3);
assert_eq!(fence.info_string, "python");
}
#[test]
fn test_tilde_fence() {
let fence = try_parse_fence_open("~~~").unwrap();
assert_eq!(fence.fence_char, '~');
assert_eq!(fence.fence_count, 3);
assert_eq!(fence.info_string, "");
}
#[test]
fn test_long_fence() {
let fence = try_parse_fence_open("`````").unwrap();
assert_eq!(fence.fence_count, 5);
}
#[test]
fn test_two_backticks_invalid() {
assert!(try_parse_fence_open("``").is_none());
}
#[test]
fn test_backtick_fence_with_backtick_in_info_is_invalid() {
assert!(try_parse_fence_open("`````hi````there`````").is_none());
}
#[test]
fn test_closing_fence() {
let fence = FenceInfo {
fence_char: '`',
fence_count: 3,
info_string: String::new(),
};
assert!(is_closing_fence("```", &fence));
assert!(is_closing_fence("````", &fence));
assert!(!is_closing_fence("``", &fence));
assert!(!is_closing_fence("~~~", &fence));
}
#[test]
fn test_fenced_code_preserves_leading_gt() {
let input = "```\n> foo\n```\n";
let tree = crate::parse(input, None);
assert_eq!(tree.text().to_string(), input);
}
#[test]
fn test_fenced_code_in_blockquote_preserves_opening_fence_marker() {
let input = "> ```\n> code\n> ```\n";
let tree = crate::parse(input, None);
assert_eq!(tree.text().to_string(), input);
}
#[test]
fn test_fenced_code_in_definition_list_with_unicode_content_does_not_panic() {
let input = "Term\n: ```\n├── pyproject.toml\n```\n";
let tree = crate::parse(input, None);
assert_eq!(tree.text().to_string(), input);
}
#[test]
fn test_info_string_plain() {
let info = InfoString::parse("");
assert_eq!(info.block_type, CodeBlockType::Plain);
assert!(info.attributes.is_empty());
}
#[test]
fn test_info_string_shortcut() {
let info = InfoString::parse("python");
assert_eq!(
info.block_type,
CodeBlockType::DisplayShortcut {
language: "python".to_string()
}
);
assert!(info.attributes.is_empty());
}
#[test]
fn test_info_string_shortcut_with_trailing() {
let info = InfoString::parse("python extra stuff");
assert_eq!(
info.block_type,
CodeBlockType::DisplayShortcut {
language: "python".to_string()
}
);
}
#[test]
fn test_info_string_display_explicit() {
let info = InfoString::parse("{.python}");
assert_eq!(
info.block_type,
CodeBlockType::DisplayExplicit {
classes: vec!["python".to_string()]
}
);
}
#[test]
fn test_info_string_display_explicit_multiple() {
let info = InfoString::parse("{.python .numberLines}");
assert_eq!(
info.block_type,
CodeBlockType::DisplayExplicit {
classes: vec!["python".to_string(), "numberLines".to_string()]
}
);
}
#[test]
fn test_info_string_executable() {
let info = InfoString::parse("{python}");
assert_eq!(
info.block_type,
CodeBlockType::Executable {
language: "python".to_string()
}
);
}
#[test]
fn test_info_string_executable_with_options() {
let info = InfoString::parse("{python echo=false warning=true}");
assert_eq!(
info.block_type,
CodeBlockType::Executable {
language: "python".to_string()
}
);
assert_eq!(info.attributes.len(), 2);
assert_eq!(
info.attributes[0],
("echo".to_string(), Some("false".to_string()))
);
assert_eq!(
info.attributes[1],
("warning".to_string(), Some("true".to_string()))
);
}
#[test]
fn test_info_string_executable_with_commas() {
let info = InfoString::parse("{r, echo=FALSE, warning=TRUE}");
assert_eq!(
info.block_type,
CodeBlockType::Executable {
language: "r".to_string()
}
);
assert_eq!(info.attributes.len(), 2);
assert_eq!(
info.attributes[0],
("echo".to_string(), Some("FALSE".to_string()))
);
assert_eq!(
info.attributes[1],
("warning".to_string(), Some("TRUE".to_string()))
);
}
#[test]
fn test_info_string_executable_mixed_commas_spaces() {
let info = InfoString::parse("{r, echo=FALSE, label=\"my chunk\"}");
assert_eq!(
info.block_type,
CodeBlockType::Executable {
language: "r".to_string()
}
);
assert_eq!(info.attributes.len(), 2);
assert_eq!(
info.attributes[0],
("echo".to_string(), Some("FALSE".to_string()))
);
assert_eq!(
info.attributes[1],
("label".to_string(), Some("my chunk".to_string()))
);
}
#[test]
fn test_info_string_mixed_shortcut_and_attrs() {
let info = InfoString::parse("python {.numberLines}");
assert_eq!(
info.block_type,
CodeBlockType::DisplayShortcut {
language: "python".to_string()
}
);
assert_eq!(info.attributes.len(), 1);
assert_eq!(info.attributes[0], (".numberLines".to_string(), None));
}
#[test]
fn test_info_string_mixed_with_key_value() {
let info = InfoString::parse("python {.numberLines startFrom=\"100\"}");
assert_eq!(
info.block_type,
CodeBlockType::DisplayShortcut {
language: "python".to_string()
}
);
assert_eq!(info.attributes.len(), 2);
assert_eq!(info.attributes[0], (".numberLines".to_string(), None));
assert_eq!(
info.attributes[1],
("startFrom".to_string(), Some("100".to_string()))
);
}
#[test]
fn test_info_string_explicit_with_id_and_classes() {
let info = InfoString::parse("{#mycode .haskell .numberLines startFrom=\"100\"}");
assert_eq!(
info.block_type,
CodeBlockType::DisplayExplicit {
classes: vec!["haskell".to_string(), "numberLines".to_string()]
}
);
let has_id = info.attributes.iter().any(|(k, _)| k == "#mycode");
let has_start = info
.attributes
.iter()
.any(|(k, v)| k == "startFrom" && v == &Some("100".to_string()));
assert!(has_id);
assert!(has_start);
}
#[test]
fn test_info_string_raw_html() {
let info = InfoString::parse("{=html}");
assert_eq!(
info.block_type,
CodeBlockType::Raw {
format: "html".to_string()
}
);
assert!(info.attributes.is_empty());
}
#[test]
fn test_info_string_raw_latex() {
let info = InfoString::parse("{=latex}");
assert_eq!(
info.block_type,
CodeBlockType::Raw {
format: "latex".to_string()
}
);
}
#[test]
fn test_info_string_raw_openxml() {
let info = InfoString::parse("{=openxml}");
assert_eq!(
info.block_type,
CodeBlockType::Raw {
format: "openxml".to_string()
}
);
}
#[test]
fn test_info_string_raw_ms() {
let info = InfoString::parse("{=ms}");
assert_eq!(
info.block_type,
CodeBlockType::Raw {
format: "ms".to_string()
}
);
}
#[test]
fn test_info_string_raw_html5() {
let info = InfoString::parse("{=html5}");
assert_eq!(
info.block_type,
CodeBlockType::Raw {
format: "html5".to_string()
}
);
}
#[test]
fn test_info_string_raw_not_combined_with_attrs() {
let info = InfoString::parse("{=html .class}");
assert_ne!(
info.block_type,
CodeBlockType::Raw {
format: "html".to_string()
}
);
}
#[test]
fn test_parse_pandoc_attributes_spaces() {
let attrs = InfoString::parse_pandoc_attributes(".python .numberLines startFrom=\"10\"");
assert_eq!(attrs.len(), 3);
assert_eq!(attrs[0], (".python".to_string(), None));
assert_eq!(attrs[1], (".numberLines".to_string(), None));
assert_eq!(attrs[2], ("startFrom".to_string(), Some("10".to_string())));
}
#[test]
fn test_parse_pandoc_attributes_no_commas() {
let attrs = InfoString::parse_pandoc_attributes("#id .class key=value");
assert_eq!(attrs.len(), 3);
assert_eq!(attrs[0], ("#id".to_string(), None));
assert_eq!(attrs[1], (".class".to_string(), None));
assert_eq!(attrs[2], ("key".to_string(), Some("value".to_string())));
}
#[test]
fn test_parse_chunk_options_commas() {
let attrs = InfoString::parse_chunk_options("r, echo=FALSE, warning=TRUE");
assert_eq!(attrs.len(), 3);
assert_eq!(attrs[0], ("r".to_string(), None));
assert_eq!(attrs[1], ("echo".to_string(), Some("FALSE".to_string())));
assert_eq!(attrs[2], ("warning".to_string(), Some("TRUE".to_string())));
}
#[test]
fn test_parse_chunk_options_no_spaces() {
let attrs = InfoString::parse_chunk_options("r,echo=FALSE,warning=TRUE");
assert_eq!(attrs.len(), 3);
assert_eq!(attrs[0], ("r".to_string(), None));
assert_eq!(attrs[1], ("echo".to_string(), Some("FALSE".to_string())));
assert_eq!(attrs[2], ("warning".to_string(), Some("TRUE".to_string())));
}
#[test]
fn test_parse_chunk_options_mixed() {
let attrs = InfoString::parse_chunk_options("python echo=False, warning=True");
assert_eq!(attrs.len(), 3);
assert_eq!(attrs[0], ("python".to_string(), None));
assert_eq!(attrs[1], ("echo".to_string(), Some("False".to_string())));
assert_eq!(attrs[2], ("warning".to_string(), Some("True".to_string())));
}
#[test]
fn test_parse_chunk_options_nested_function_call() {
let attrs = InfoString::parse_chunk_options(r#"r pep-cg, dependson=c("foo", "bar")"#);
assert_eq!(attrs.len(), 3);
assert_eq!(attrs[0], ("r".to_string(), None));
assert_eq!(attrs[1], ("pep-cg".to_string(), None));
assert_eq!(
attrs[2],
(
"dependson".to_string(),
Some(r#"c("foo", "bar")"#.to_string())
)
);
}
#[test]
fn test_parse_chunk_options_nested_with_spaces() {
let attrs = InfoString::parse_chunk_options(r#"r, cache.path=file.path("cache", "dir")"#);
assert_eq!(attrs.len(), 2);
assert_eq!(attrs[0], ("r".to_string(), None));
assert_eq!(
attrs[1],
(
"cache.path".to_string(),
Some(r#"file.path("cache", "dir")"#.to_string())
)
);
}
#[test]
fn test_parse_chunk_options_deeply_nested() {
let attrs = InfoString::parse_chunk_options(r#"r, x=list(a=c(1,2), b=c(3,4))"#);
assert_eq!(attrs.len(), 2);
assert_eq!(attrs[0], ("r".to_string(), None));
assert_eq!(
attrs[1],
(
"x".to_string(),
Some(r#"list(a=c(1,2), b=c(3,4))"#.to_string())
)
);
}
#[test]
fn test_parse_chunk_options_brackets_and_braces() {
let attrs = InfoString::parse_chunk_options(r#"r, data=df[rows, cols], config={a:1, b:2}"#);
assert_eq!(attrs.len(), 3);
assert_eq!(attrs[0], ("r".to_string(), None));
assert_eq!(
attrs[1],
("data".to_string(), Some("df[rows, cols]".to_string()))
);
assert_eq!(
attrs[2],
("config".to_string(), Some("{a:1, b:2}".to_string()))
);
}
#[test]
fn test_parse_chunk_options_quotes_with_parens() {
let attrs = InfoString::parse_chunk_options(r#"r, label="test (with parens)", echo=TRUE"#);
assert_eq!(attrs.len(), 3);
assert_eq!(attrs[0], ("r".to_string(), None));
assert_eq!(
attrs[1],
("label".to_string(), Some("test (with parens)".to_string()))
);
assert_eq!(attrs[2], ("echo".to_string(), Some("TRUE".to_string())));
}
#[test]
fn test_parse_chunk_options_escaped_quotes() {
let attrs = InfoString::parse_chunk_options(r#"r, label="has \"quoted\" text""#);
assert_eq!(attrs.len(), 2);
assert_eq!(attrs[0], ("r".to_string(), None));
assert_eq!(
attrs[1],
(
"label".to_string(),
Some(r#"has "quoted" text"#.to_string())
)
);
}
#[test]
fn test_display_vs_executable_parsing() {
let info1 = InfoString::parse("{.python .numberLines startFrom=\"10\"}");
assert!(matches!(
info1.block_type,
CodeBlockType::DisplayExplicit { .. }
));
let info2 = InfoString::parse("{r, echo=FALSE, warning=TRUE}");
assert!(matches!(info2.block_type, CodeBlockType::Executable { .. }));
assert_eq!(info2.attributes.len(), 2);
}
#[test]
fn test_info_string_executable_implicit_label() {
let info = InfoString::parse("{r mylabel}");
assert!(matches!(
info.block_type,
CodeBlockType::Executable { ref language } if language == "r"
));
assert_eq!(info.attributes.len(), 1);
assert_eq!(
info.attributes[0],
("label".to_string(), Some("mylabel".to_string()))
);
}
#[test]
fn test_info_string_executable_implicit_label_with_options() {
let info = InfoString::parse("{r mylabel, echo=FALSE}");
assert!(matches!(
info.block_type,
CodeBlockType::Executable { ref language } if language == "r"
));
assert_eq!(info.attributes.len(), 2);
assert_eq!(
info.attributes[0],
("label".to_string(), Some("mylabel".to_string()))
);
assert_eq!(
info.attributes[1],
("echo".to_string(), Some("FALSE".to_string()))
);
}
#[test]
fn test_compute_hashpipe_preamble_line_count_for_block_scalar() {
let content_lines = vec![
"#| fig-cap: |\n",
"#| A caption\n",
"#| spanning lines\n",
"a <- 1\n",
];
let count = compute_hashpipe_preamble_line_count(&content_lines, "#|", 0, 0);
assert_eq!(count, 3);
}
#[test]
fn test_compute_hashpipe_preamble_line_count_stops_at_non_option() {
let content_lines = vec!["#| label: fig-plot\n", "plot(1:10)\n", "#| echo: false\n"];
let count = compute_hashpipe_preamble_line_count(&content_lines, "#|", 0, 0);
assert_eq!(count, 1);
}
#[test]
fn test_compute_hashpipe_preamble_line_count_stops_at_standalone_prefix() {
let content_lines = vec!["#| label: fig-plot\n", "#|\n", "plot(1:10)\n"];
let count = compute_hashpipe_preamble_line_count(&content_lines, "#|", 0, 0);
assert_eq!(count, 1);
}
}