#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ParsedBlockquotePrefix<'a> {
pub indent: &'a str,
pub prefix: &'a str,
pub content: &'a str,
pub nesting_level: usize,
pub spaces_after_marker: &'a str,
}
#[inline]
fn is_space_or_tab(byte: u8) -> bool {
byte == b' ' || byte == b'\t'
}
#[inline]
pub fn parse_blockquote_prefix(line: &str) -> Option<ParsedBlockquotePrefix<'_>> {
let bytes = line.as_bytes();
let mut pos = 0;
while pos < bytes.len() && is_space_or_tab(bytes[pos]) {
pos += 1;
}
let indent_end = pos;
if pos >= bytes.len() || bytes[pos] != b'>' {
return None;
}
let mut nesting_level = 0;
let mut prefix_end = pos;
let mut spaces_after_marker_start = pos;
let mut spaces_after_marker_end = pos;
loop {
if pos >= bytes.len() || bytes[pos] != b'>' {
break;
}
nesting_level += 1;
pos += 1; let marker_end = pos;
if pos < bytes.len() && is_space_or_tab(bytes[pos]) {
pos += 1;
}
let content_start_candidate = pos;
while pos < bytes.len() && is_space_or_tab(bytes[pos]) {
pos += 1;
}
if pos < bytes.len() && bytes[pos] == b'>' {
continue;
}
prefix_end = content_start_candidate;
spaces_after_marker_start = marker_end;
spaces_after_marker_end = pos;
break;
}
Some(ParsedBlockquotePrefix {
indent: &line[..indent_end],
prefix: &line[..prefix_end],
content: &line[prefix_end..],
nesting_level,
spaces_after_marker: &line[spaces_after_marker_start..spaces_after_marker_end],
})
}
pub fn effective_indent_in_blockquote(line_content: &str, expected_bq_level: usize, fallback_indent: usize) -> usize {
if expected_bq_level == 0 {
return fallback_indent;
}
let line_bq_level = line_content
.chars()
.take_while(|c| *c == '>' || c.is_whitespace())
.filter(|&c| c == '>')
.count();
if line_bq_level != expected_bq_level {
return fallback_indent;
}
let mut pos = 0;
let mut found_markers = 0;
for c in line_content.chars() {
pos += c.len_utf8();
if c == '>' {
found_markers += 1;
if found_markers == line_bq_level {
if line_content.get(pos..pos + 1) == Some(" ") {
pos += 1;
}
break;
}
}
}
let after_bq = &line_content[pos..];
after_bq.len() - after_bq.trim_start().len()
}
fn count_blockquote_level(line_content: &str) -> usize {
line_content
.chars()
.take_while(|c| *c == '>' || c.is_whitespace())
.filter(|&c| c == '>')
.count()
}
pub fn content_after_blockquote(line_content: &str, expected_bq_level: usize) -> &str {
if expected_bq_level == 0 {
return line_content;
}
let actual_level = count_blockquote_level(line_content);
if actual_level != expected_bq_level {
return line_content;
}
let mut pos = 0;
let mut found_markers = 0;
for c in line_content.chars() {
pos += c.len_utf8();
if c == '>' {
found_markers += 1;
if found_markers == expected_bq_level {
if line_content.get(pos..pos + 1) == Some(" ") {
pos += 1;
}
break;
}
}
}
&line_content[pos..]
}
pub fn strip_blockquote_prefix(line: &str) -> &str {
match parse_blockquote_prefix(line) {
Some(parsed) => parsed.content,
None => line,
}
}
fn indent_columns(line: &str) -> usize {
let mut column = 0;
for c in line.chars() {
match c {
' ' => column += 1,
'\t' => column += 4 - column % 4,
_ => break,
}
}
column
}
fn list_marker_len(text: &str) -> Option<usize> {
let token = text.split([' ', '\t']).next().unwrap_or("");
let digits = token.trim_end_matches(['.', ')']);
let is_marker = matches!(token, "-" | "+" | "*")
|| (token.len() == digits.len() + 1
&& (1..=9).contains(&digits.len())
&& digits.bytes().all(|b| b.is_ascii_digit()));
is_marker.then_some(token.len())
}
fn column_at(line: &str, offset: usize) -> usize {
line[..offset].chars().fold(0, |column, c| match c {
'\t' => column + 4 - column % 4,
_ => column + 1,
})
}
pub fn quote_text_start_in_items(line: &str, from: usize) -> Option<usize> {
let mut offset = from;
let mut opened_item = false;
let mut column = column_at(line, from);
loop {
let rest = &line[offset..];
let text = rest.trim_start_matches([' ', '\t']);
let text_column = rest[..rest.len() - text.len()].chars().fold(column, |column, c| {
if c == '\t' { column + 4 - column % 4 } else { column + 1 }
});
if text_column - column >= if opened_item { 5 } else { 4 } {
return None;
}
if let Some(marker_len) = list_marker_len(text).filter(|&len| text.len() > len) {
offset += rest.len() - text.len() + marker_len;
column = text_column + marker_len;
opened_item = true;
continue;
}
if !opened_item {
return None;
}
let parsed = parse_blockquote_prefix(rest)?;
if !quote_markers_are_real(rest) {
return None;
}
return Some(line.len() - parsed.content.len());
}
}
fn written_item_columns(line: &str, content_column: usize, bare: bool) -> Vec<usize> {
let mut columns = vec![content_column + usize::from(bare)];
if bare {
return columns;
}
let mut position = 0;
let mut column = 0;
let mut column_at_forward = |target: usize| -> usize {
for c in line[position..target].chars() {
column += if c == '\t' { 4 - column % 4 } else { 1 };
}
position = target;
column
};
let Some(mut offset) = line
.char_indices()
.map(|(index, _)| index)
.chain(std::iter::once(line.len()))
.find(|&index| column_at_forward(index) >= content_column)
else {
return columns;
};
while let Some(marker_len) =
list_marker_len(&line[offset..]).filter(|_| !crate::utils::thematic_break::is_thematic_break(&line[offset..]))
{
let after_marker = offset + marker_len;
let spaces = line[after_marker..].len() - line[after_marker..].trim_start_matches([' ', '\t']).len();
let marker_end = column_at_forward(after_marker);
let rest_blank = line[after_marker + spaces..].trim().is_empty();
let spaces_columns = column_at_forward(after_marker + spaces) - marker_end;
if rest_blank || spaces_columns >= 5 {
columns.push(marker_end + 1);
break;
}
columns.push(marker_end + spaces_columns);
offset = after_marker + spaces;
}
columns
}
pub fn quote_markers_are_paragraph_text(
ctx: &crate::lint_context::LintContext,
edges: &[usize],
line_num: usize,
line: &str,
) -> bool {
line_num > 0 && !ctx.lines[line_num - 1].is_blank && indent_columns(line) >= edges[line_num] + 4
}
pub fn container_edges(ctx: &crate::lint_context::LintContext) -> Vec<usize> {
container_edges_with_margins(ctx)
.into_iter()
.map(|(edge, _)| edge)
.collect()
}
pub fn container_edges_with_margins(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
let mut open: Vec<(usize, bool, usize, usize)> = Vec::new();
let mut edges = Vec::with_capacity(ctx.lines.len());
let mut lazy_taint = false;
for (index, info) in ctx.lines.iter().enumerate() {
let depth = info.blockquote.as_ref().map_or(0, |quote| quote.nesting_level);
if !info.is_blank {
let line = info.content(ctx.content);
let quoted = info.blockquote.is_some();
let margin = match parse_blockquote_prefix(line) {
Some(parsed) if quoted && !line.contains('\t') => column_at(line, parsed.prefix.len()),
_ => 0,
};
let measured = |opened_quoted: bool,
opened_margin: usize,
opened_depth: usize,
content: usize,
column: usize,
raw_column: usize|
-> bool {
if opened_quoted && quoted {
depth < opened_depth || content.saturating_sub(opened_margin) > column.saturating_sub(margin)
} else {
content > raw_column
}
};
match info.list_item.as_deref() {
Some(item) => {
while open.last().is_some_and(|&(content, oq, om, od)| {
measured(oq, om, od, content, item.marker_column, item.marker_column)
}) {
open.pop();
}
edges.push(open.last().map_or((0, 0), |&(content, _, om, od)| {
(content, if depth > od || lazy_taint { 0 } else { om })
}));
let bare = line.trim_end().chars().count() <= item.content_column;
let columns = if item.commonmark_items.is_empty() {
written_item_columns(line, item.content_column, bare)
} else {
item.commonmark_items.iter().map(|item| item.content_column).collect()
};
let relative = quoted && open.iter().all(|&(_, oq, _, _)| oq);
open.extend(columns.into_iter().map(|content| (content, relative, margin, depth)));
lazy_taint = false;
continue;
}
None => {
let column = match parse_blockquote_prefix(line) {
Some(parsed) if quoted => column_at(line, parsed.prefix.len()) + indent_columns(parsed.content),
_ => indent_columns(line),
};
let before = open.len();
while open.last().is_some_and(|&(content, oq, om, od)| {
measured(oq, om, od, content, column, indent_columns(line))
}) {
open.pop();
}
if index > 0 && !ctx.lines[index - 1].is_blank && open.len() < before {
lazy_taint = true;
}
if open.is_empty() {
lazy_taint = false;
}
}
}
}
edges.push(open.last().map_or((0, 0), |&(content, _, om, od)| {
(content, if depth > od || lazy_taint { 0 } else { om })
}));
}
edges
}
pub fn is_indented_past_edge(edges: &[usize], line_num: usize, line: &str) -> bool {
indent_columns(line) >= edges[line_num] + 4
}
pub fn quote_markers_are_real(line: &str) -> bool {
let mut column = 0;
let mut after_marker: Option<usize> = None;
for c in line.chars() {
match c {
' ' => column += 1,
'\t' => column += 4 - column % 4,
'>' => {
if after_marker.is_some_and(|end| column >= end + 1 + 4) {
return false;
}
column += 1;
after_marker = Some(column);
}
_ => break,
}
}
true
}
pub fn quote_margin_len(line: &str, depth: usize) -> Option<usize> {
let mut pos = 0;
let mut markers = 0;
while markers < depth {
let rest = &line[pos..];
let next = rest.trim_start_matches([' ', '\t']);
if !next.starts_with('>') {
break;
}
pos += rest.len() - next.len() + 1;
markers += 1;
}
if markers == 0 {
return None;
}
Some(pos + usize::from(line[pos..].starts_with([' ', '\t'])))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_blockquote_prefix_compact_nested() {
let parsed = parse_blockquote_prefix(">> text").expect("should parse compact nested blockquote");
assert_eq!(parsed.indent, "");
assert_eq!(parsed.prefix, ">> ");
assert_eq!(parsed.content, "text");
assert_eq!(parsed.nesting_level, 2);
assert_eq!(parsed.spaces_after_marker, " ");
}
#[test]
fn test_parse_blockquote_prefix_spaced_nested() {
let parsed = parse_blockquote_prefix("> > text").expect("should parse spaced nested blockquote");
assert_eq!(parsed.indent, "");
assert_eq!(parsed.prefix, "> > ");
assert_eq!(parsed.content, " text");
assert_eq!(parsed.nesting_level, 2);
assert_eq!(parsed.spaces_after_marker, " ");
}
#[test]
fn test_parse_blockquote_prefix_with_indent() {
let parsed = parse_blockquote_prefix(" > quote").expect("should parse indented blockquote");
assert_eq!(parsed.indent, " ");
assert_eq!(parsed.prefix, " > ");
assert_eq!(parsed.content, "quote");
assert_eq!(parsed.nesting_level, 1);
assert_eq!(parsed.spaces_after_marker, " ");
}
#[test]
fn test_parse_blockquote_prefix_non_blockquote() {
assert!(parse_blockquote_prefix("plain text").is_none());
assert!(parse_blockquote_prefix(" plain text").is_none());
}
#[test]
fn test_effective_indent_no_blockquote_context() {
assert_eq!(effective_indent_in_blockquote("text", 0, 0), 0);
assert_eq!(effective_indent_in_blockquote(" text", 0, 3), 3);
assert_eq!(effective_indent_in_blockquote("> text", 0, 5), 5);
}
#[test]
fn test_effective_indent_single_level_blockquote() {
assert_eq!(effective_indent_in_blockquote("> text", 1, 99), 0);
assert_eq!(effective_indent_in_blockquote("> text", 1, 99), 1);
assert_eq!(effective_indent_in_blockquote("> text", 1, 99), 2);
assert_eq!(effective_indent_in_blockquote("> text", 1, 99), 3);
}
#[test]
fn test_effective_indent_no_space_after_marker() {
assert_eq!(effective_indent_in_blockquote(">text", 1, 99), 0);
assert_eq!(effective_indent_in_blockquote(">>text", 2, 99), 0);
}
#[test]
fn test_effective_indent_nested_blockquote_compact() {
assert_eq!(effective_indent_in_blockquote(">> text", 2, 99), 0);
assert_eq!(effective_indent_in_blockquote(">> text", 2, 99), 1);
assert_eq!(effective_indent_in_blockquote(">> text", 2, 99), 2);
}
#[test]
fn test_effective_indent_nested_blockquote_spaced() {
assert_eq!(effective_indent_in_blockquote("> > text", 2, 99), 0);
assert_eq!(effective_indent_in_blockquote("> > text", 2, 99), 1);
assert_eq!(effective_indent_in_blockquote("> > text", 2, 99), 2);
}
#[test]
fn test_effective_indent_mismatched_level() {
assert_eq!(effective_indent_in_blockquote("> text", 2, 42), 42);
assert_eq!(effective_indent_in_blockquote(">> text", 1, 42), 42);
assert_eq!(effective_indent_in_blockquote("text", 1, 42), 42);
}
#[test]
fn test_effective_indent_empty_blockquote() {
assert_eq!(effective_indent_in_blockquote(">", 1, 99), 0);
assert_eq!(effective_indent_in_blockquote("> ", 1, 99), 0);
assert_eq!(effective_indent_in_blockquote("> ", 1, 99), 1);
}
#[test]
fn test_effective_indent_issue_268_case() {
assert_eq!(effective_indent_in_blockquote("> Opening the app", 1, 0), 2);
assert_eq!(
effective_indent_in_blockquote("> [**See preview here!**](https://example.com)", 1, 0),
2
);
}
#[test]
fn test_effective_indent_triple_nested() {
assert_eq!(effective_indent_in_blockquote("> > > text", 3, 99), 0);
assert_eq!(effective_indent_in_blockquote("> > > text", 3, 99), 1);
assert_eq!(effective_indent_in_blockquote(">>> text", 3, 99), 0);
assert_eq!(effective_indent_in_blockquote(">>> text", 3, 99), 1);
}
#[test]
fn test_count_blockquote_level_none() {
assert_eq!(count_blockquote_level("regular text"), 0);
assert_eq!(count_blockquote_level(" indented text"), 0);
assert_eq!(count_blockquote_level(""), 0);
}
#[test]
fn test_count_blockquote_level_single() {
assert_eq!(count_blockquote_level("> text"), 1);
assert_eq!(count_blockquote_level(">text"), 1);
assert_eq!(count_blockquote_level(">"), 1);
}
#[test]
fn test_count_blockquote_level_nested() {
assert_eq!(count_blockquote_level(">> text"), 2);
assert_eq!(count_blockquote_level("> > text"), 2);
assert_eq!(count_blockquote_level(">>> text"), 3);
assert_eq!(count_blockquote_level("> > > text"), 3);
}
#[test]
fn test_content_after_blockquote_no_quote() {
assert_eq!(content_after_blockquote("text", 0), "text");
assert_eq!(content_after_blockquote(" indented", 0), " indented");
}
#[test]
fn test_content_after_blockquote_single() {
assert_eq!(content_after_blockquote("> text", 1), "text");
assert_eq!(content_after_blockquote(">text", 1), "text");
assert_eq!(content_after_blockquote("> indented", 1), " indented");
}
#[test]
fn test_content_after_blockquote_nested() {
assert_eq!(content_after_blockquote(">> text", 2), "text");
assert_eq!(content_after_blockquote("> > text", 2), "text");
assert_eq!(content_after_blockquote("> > indented", 2), " indented");
}
#[test]
fn test_content_after_blockquote_mismatched_level() {
assert_eq!(content_after_blockquote("> text", 2), "> text");
assert_eq!(content_after_blockquote(">> text", 1), ">> text");
}
}