1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct ParsedBlockquotePrefix<'a> {
13 pub indent: &'a str,
15 pub prefix: &'a str,
17 pub content: &'a str,
19 pub nesting_level: usize,
21 pub spaces_after_marker: &'a str,
23}
24
25#[inline]
26fn is_space_or_tab(byte: u8) -> bool {
27 byte == b' ' || byte == b'\t'
28}
29
30#[inline]
35pub fn parse_blockquote_prefix(line: &str) -> Option<ParsedBlockquotePrefix<'_>> {
36 let bytes = line.as_bytes();
37 let mut pos = 0;
38
39 while pos < bytes.len() && is_space_or_tab(bytes[pos]) {
41 pos += 1;
42 }
43 let indent_end = pos;
44
45 if pos >= bytes.len() || bytes[pos] != b'>' {
46 return None;
47 }
48
49 let mut nesting_level = 0;
50 let mut prefix_end = pos;
51 let mut spaces_after_marker_start = pos;
52 let mut spaces_after_marker_end = pos;
53
54 loop {
55 if pos >= bytes.len() || bytes[pos] != b'>' {
56 break;
57 }
58
59 nesting_level += 1;
60 pos += 1; let marker_end = pos;
62
63 if pos < bytes.len() && is_space_or_tab(bytes[pos]) {
65 pos += 1;
66 }
67 let content_start_candidate = pos;
68
69 while pos < bytes.len() && is_space_or_tab(bytes[pos]) {
71 pos += 1;
72 }
73
74 if pos < bytes.len() && bytes[pos] == b'>' {
76 continue;
77 }
78
79 prefix_end = content_start_candidate;
81 spaces_after_marker_start = marker_end;
82 spaces_after_marker_end = pos;
83 break;
84 }
85
86 Some(ParsedBlockquotePrefix {
87 indent: &line[..indent_end],
88 prefix: &line[..prefix_end],
89 content: &line[prefix_end..],
90 nesting_level,
91 spaces_after_marker: &line[spaces_after_marker_start..spaces_after_marker_end],
92 })
93}
94
95pub fn effective_indent_in_blockquote(line_content: &str, expected_bq_level: usize, fallback_indent: usize) -> usize {
132 if expected_bq_level == 0 {
133 return fallback_indent;
134 }
135
136 let line_bq_level = line_content
139 .chars()
140 .take_while(|c| *c == '>' || c.is_whitespace())
141 .filter(|&c| c == '>')
142 .count();
143
144 if line_bq_level != expected_bq_level {
145 return fallback_indent;
146 }
147
148 let mut pos = 0;
150 let mut found_markers = 0;
151 for c in line_content.chars() {
152 pos += c.len_utf8();
153 if c == '>' {
154 found_markers += 1;
155 if found_markers == line_bq_level {
156 if line_content.get(pos..pos + 1) == Some(" ") {
158 pos += 1;
159 }
160 break;
161 }
162 }
163 }
164
165 let after_bq = &line_content[pos..];
166 after_bq.len() - after_bq.trim_start().len()
167}
168
169fn count_blockquote_level(line_content: &str) -> usize {
173 line_content
174 .chars()
175 .take_while(|c| *c == '>' || c.is_whitespace())
176 .filter(|&c| c == '>')
177 .count()
178}
179
180pub fn content_after_blockquote(line_content: &str, expected_bq_level: usize) -> &str {
196 if expected_bq_level == 0 {
197 return line_content;
198 }
199
200 let actual_level = count_blockquote_level(line_content);
202 if actual_level != expected_bq_level {
203 return line_content;
204 }
205
206 let mut pos = 0;
207 let mut found_markers = 0;
208 for c in line_content.chars() {
209 pos += c.len_utf8();
210 if c == '>' {
211 found_markers += 1;
212 if found_markers == expected_bq_level {
213 if line_content.get(pos..pos + 1) == Some(" ") {
215 pos += 1;
216 }
217 break;
218 }
219 }
220 }
221
222 &line_content[pos..]
223}
224
225pub fn strip_blockquote_prefix(line: &str) -> &str {
243 match parse_blockquote_prefix(line) {
244 Some(parsed) => parsed.content,
245 None => line,
246 }
247}
248
249fn indent_columns(line: &str) -> usize {
251 let mut column = 0;
252 for c in line.chars() {
253 match c {
254 ' ' => column += 1,
255 '\t' => column += 4 - column % 4,
256 _ => break,
257 }
258 }
259 column
260}
261
262fn list_marker_len(text: &str) -> Option<usize> {
265 let token = text.split([' ', '\t']).next().unwrap_or("");
266 let digits = token.trim_end_matches(['.', ')']);
267 let is_marker = matches!(token, "-" | "+" | "*")
268 || (token.len() == digits.len() + 1
269 && (1..=9).contains(&digits.len())
270 && digits.bytes().all(|b| b.is_ascii_digit()));
271 is_marker.then_some(token.len())
272}
273
274fn column_at(line: &str, offset: usize) -> usize {
276 line[..offset].chars().fold(0, |column, c| match c {
277 '\t' => column + 4 - column % 4,
278 _ => column + 1,
279 })
280}
281
282pub fn quote_text_start_in_items(line: &str, from: usize) -> Option<usize> {
289 let mut offset = from;
290 let mut opened_item = false;
291 let mut column = column_at(line, from);
294 loop {
295 let rest = &line[offset..];
296 let text = rest.trim_start_matches([' ', '\t']);
297 let text_column = rest[..rest.len() - text.len()].chars().fold(column, |column, c| {
298 if c == '\t' { column + 4 - column % 4 } else { column + 1 }
299 });
300 if text_column - column >= if opened_item { 5 } else { 4 } {
303 return None;
304 }
305 if let Some(marker_len) = list_marker_len(text).filter(|&len| text.len() > len) {
306 offset += rest.len() - text.len() + marker_len;
307 column = text_column + marker_len;
308 opened_item = true;
309 continue;
310 }
311 if !opened_item {
312 return None;
313 }
314 let parsed = parse_blockquote_prefix(rest)?;
315 if !quote_markers_are_real(rest) {
316 return None;
317 }
318 return Some(line.len() - parsed.content.len());
319 }
320}
321
322fn written_item_columns(line: &str, content_column: usize, bare: bool) -> Vec<usize> {
326 let mut columns = vec![content_column + usize::from(bare)];
327 if bare {
328 return columns;
329 }
330 let mut position = 0;
333 let mut column = 0;
334 let mut column_at_forward = |target: usize| -> usize {
335 for c in line[position..target].chars() {
336 column += if c == '\t' { 4 - column % 4 } else { 1 };
337 }
338 position = target;
339 column
340 };
341 let Some(mut offset) = line
342 .char_indices()
343 .map(|(index, _)| index)
344 .chain(std::iter::once(line.len()))
345 .find(|&index| column_at_forward(index) >= content_column)
346 else {
347 return columns;
348 };
349 while let Some(marker_len) =
350 list_marker_len(&line[offset..]).filter(|_| !crate::utils::thematic_break::is_thematic_break(&line[offset..]))
351 {
352 let after_marker = offset + marker_len;
353 let spaces = line[after_marker..].len() - line[after_marker..].trim_start_matches([' ', '\t']).len();
354 let marker_end = column_at_forward(after_marker);
355 let rest_blank = line[after_marker + spaces..].trim().is_empty();
356 let spaces_columns = column_at_forward(after_marker + spaces) - marker_end;
357 if rest_blank || spaces_columns >= 5 {
358 columns.push(marker_end + 1);
359 break;
360 }
361 columns.push(marker_end + spaces_columns);
362 offset = after_marker + spaces;
363 }
364 columns
365}
366
367pub fn quote_markers_are_paragraph_text(
372 ctx: &crate::lint_context::LintContext,
373 edges: &[usize],
374 line_num: usize,
375 line: &str,
376) -> bool {
377 line_num > 0 && !ctx.lines[line_num - 1].is_blank && indent_columns(line) >= edges[line_num] + 4
378}
379
380pub fn container_edges(ctx: &crate::lint_context::LintContext) -> Vec<usize> {
389 container_edges_with_margins(ctx)
390 .into_iter()
391 .map(|(edge, _)| edge)
392 .collect()
393}
394
395pub fn container_edges_with_margins(ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize)> {
407 let mut open: Vec<(usize, bool, usize, usize)> = Vec::new();
410 let mut edges = Vec::with_capacity(ctx.lines.len());
411 let mut lazy_taint = false;
414 for (index, info) in ctx.lines.iter().enumerate() {
415 let depth = info.blockquote.as_ref().map_or(0, |quote| quote.nesting_level);
416 if !info.is_blank {
417 let line = info.content(ctx.content);
418 let quoted = info.blockquote.is_some();
419 let margin = match parse_blockquote_prefix(line) {
420 Some(parsed) if quoted && !line.contains('\t') => column_at(line, parsed.prefix.len()),
423 _ => 0,
424 };
425 let measured = |opened_quoted: bool,
428 opened_margin: usize,
429 opened_depth: usize,
430 content: usize,
431 column: usize,
432 raw_column: usize|
433 -> bool {
434 if opened_quoted && quoted {
435 depth < opened_depth || content.saturating_sub(opened_margin) > column.saturating_sub(margin)
437 } else {
438 content > raw_column
439 }
440 };
441 match info.list_item.as_deref() {
442 Some(item) => {
443 while open.last().is_some_and(|&(content, oq, om, od)| {
444 measured(oq, om, od, content, item.marker_column, item.marker_column)
445 }) {
446 open.pop();
447 }
448 edges.push(open.last().map_or((0, 0), |&(content, _, om, od)| {
449 (content, if depth > od || lazy_taint { 0 } else { om })
450 }));
451 let bare = line.trim_end().chars().count() <= item.content_column;
454 let columns = if item.commonmark_items.is_empty() {
455 written_item_columns(line, item.content_column, bare)
456 } else {
457 item.commonmark_items.iter().map(|item| item.content_column).collect()
458 };
459 let relative = quoted && open.iter().all(|&(_, oq, _, _)| oq);
462 open.extend(columns.into_iter().map(|content| (content, relative, margin, depth)));
463 lazy_taint = false;
464 continue;
465 }
466 None => {
467 let column = match parse_blockquote_prefix(line) {
468 Some(parsed) if quoted => column_at(line, parsed.prefix.len()) + indent_columns(parsed.content),
469 _ => indent_columns(line),
470 };
471 let before = open.len();
472 while open.last().is_some_and(|&(content, oq, om, od)| {
473 measured(oq, om, od, content, column, indent_columns(line))
474 }) {
475 open.pop();
476 }
477 if index > 0 && !ctx.lines[index - 1].is_blank && open.len() < before {
478 lazy_taint = true;
479 }
480 if open.is_empty() {
481 lazy_taint = false;
482 }
483 }
484 }
485 }
486 edges.push(open.last().map_or((0, 0), |&(content, _, om, od)| {
487 (content, if depth > od || lazy_taint { 0 } else { om })
488 }));
489 }
490 edges
491}
492
493pub fn is_indented_past_edge(edges: &[usize], line_num: usize, line: &str) -> bool {
497 indent_columns(line) >= edges[line_num] + 4
498}
499
500pub fn quote_markers_are_real(line: &str) -> bool {
504 let mut column = 0;
505 let mut after_marker: Option<usize> = None;
507 for c in line.chars() {
508 match c {
509 ' ' => column += 1,
510 '\t' => column += 4 - column % 4,
511 '>' => {
512 if after_marker.is_some_and(|end| column >= end + 1 + 4) {
513 return false;
514 }
515 column += 1;
516 after_marker = Some(column);
517 }
518 _ => break,
519 }
520 }
521 true
522}
523
524pub fn quote_margin_len(line: &str, depth: usize) -> Option<usize> {
529 let mut pos = 0;
530 let mut markers = 0;
531 while markers < depth {
532 let rest = &line[pos..];
533 let next = rest.trim_start_matches([' ', '\t']);
534 if !next.starts_with('>') {
535 break;
536 }
537 pos += rest.len() - next.len() + 1;
538 markers += 1;
539 }
540 if markers == 0 {
541 return None;
542 }
543 Some(pos + usize::from(line[pos..].starts_with([' ', '\t'])))
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
555 fn test_parse_blockquote_prefix_compact_nested() {
556 let parsed = parse_blockquote_prefix(">> text").expect("should parse compact nested blockquote");
557 assert_eq!(parsed.indent, "");
558 assert_eq!(parsed.prefix, ">> ");
559 assert_eq!(parsed.content, "text");
560 assert_eq!(parsed.nesting_level, 2);
561 assert_eq!(parsed.spaces_after_marker, " ");
562 }
563
564 #[test]
565 fn test_parse_blockquote_prefix_spaced_nested() {
566 let parsed = parse_blockquote_prefix("> > text").expect("should parse spaced nested blockquote");
567 assert_eq!(parsed.indent, "");
568 assert_eq!(parsed.prefix, "> > ");
569 assert_eq!(parsed.content, " text");
570 assert_eq!(parsed.nesting_level, 2);
571 assert_eq!(parsed.spaces_after_marker, " ");
572 }
573
574 #[test]
575 fn test_parse_blockquote_prefix_with_indent() {
576 let parsed = parse_blockquote_prefix(" > quote").expect("should parse indented blockquote");
577 assert_eq!(parsed.indent, " ");
578 assert_eq!(parsed.prefix, " > ");
579 assert_eq!(parsed.content, "quote");
580 assert_eq!(parsed.nesting_level, 1);
581 assert_eq!(parsed.spaces_after_marker, " ");
582 }
583
584 #[test]
585 fn test_parse_blockquote_prefix_non_blockquote() {
586 assert!(parse_blockquote_prefix("plain text").is_none());
587 assert!(parse_blockquote_prefix(" plain text").is_none());
588 }
589
590 #[test]
595 fn test_effective_indent_no_blockquote_context() {
596 assert_eq!(effective_indent_in_blockquote("text", 0, 0), 0);
598 assert_eq!(effective_indent_in_blockquote(" text", 0, 3), 3);
599 assert_eq!(effective_indent_in_blockquote("> text", 0, 5), 5);
600 }
601
602 #[test]
603 fn test_effective_indent_single_level_blockquote() {
604 assert_eq!(effective_indent_in_blockquote("> text", 1, 99), 0);
606 assert_eq!(effective_indent_in_blockquote("> text", 1, 99), 1);
607 assert_eq!(effective_indent_in_blockquote("> text", 1, 99), 2);
608 assert_eq!(effective_indent_in_blockquote("> text", 1, 99), 3);
609 }
610
611 #[test]
612 fn test_effective_indent_no_space_after_marker() {
613 assert_eq!(effective_indent_in_blockquote(">text", 1, 99), 0);
615 assert_eq!(effective_indent_in_blockquote(">>text", 2, 99), 0);
616 }
617
618 #[test]
619 fn test_effective_indent_nested_blockquote_compact() {
620 assert_eq!(effective_indent_in_blockquote(">> text", 2, 99), 0);
622 assert_eq!(effective_indent_in_blockquote(">> text", 2, 99), 1);
623 assert_eq!(effective_indent_in_blockquote(">> text", 2, 99), 2);
624 }
625
626 #[test]
627 fn test_effective_indent_nested_blockquote_spaced() {
628 assert_eq!(effective_indent_in_blockquote("> > text", 2, 99), 0);
630 assert_eq!(effective_indent_in_blockquote("> > text", 2, 99), 1);
631 assert_eq!(effective_indent_in_blockquote("> > text", 2, 99), 2);
632 }
633
634 #[test]
635 fn test_effective_indent_mismatched_level() {
636 assert_eq!(effective_indent_in_blockquote("> text", 2, 42), 42);
638 assert_eq!(effective_indent_in_blockquote(">> text", 1, 42), 42);
639 assert_eq!(effective_indent_in_blockquote("text", 1, 42), 42);
640 }
641
642 #[test]
643 fn test_effective_indent_empty_blockquote() {
644 assert_eq!(effective_indent_in_blockquote(">", 1, 99), 0);
646 assert_eq!(effective_indent_in_blockquote("> ", 1, 99), 0);
647 assert_eq!(effective_indent_in_blockquote("> ", 1, 99), 1);
648 }
649
650 #[test]
651 fn test_effective_indent_issue_268_case() {
652 assert_eq!(effective_indent_in_blockquote("> Opening the app", 1, 0), 2);
655 assert_eq!(
656 effective_indent_in_blockquote("> [**See preview here!**](https://example.com)", 1, 0),
657 2
658 );
659 }
660
661 #[test]
662 fn test_effective_indent_triple_nested() {
663 assert_eq!(effective_indent_in_blockquote("> > > text", 3, 99), 0);
665 assert_eq!(effective_indent_in_blockquote("> > > text", 3, 99), 1);
666 assert_eq!(effective_indent_in_blockquote(">>> text", 3, 99), 0);
667 assert_eq!(effective_indent_in_blockquote(">>> text", 3, 99), 1);
668 }
669
670 #[test]
675 fn test_count_blockquote_level_none() {
676 assert_eq!(count_blockquote_level("regular text"), 0);
677 assert_eq!(count_blockquote_level(" indented text"), 0);
678 assert_eq!(count_blockquote_level(""), 0);
679 }
680
681 #[test]
682 fn test_count_blockquote_level_single() {
683 assert_eq!(count_blockquote_level("> text"), 1);
684 assert_eq!(count_blockquote_level(">text"), 1);
685 assert_eq!(count_blockquote_level(">"), 1);
686 }
687
688 #[test]
689 fn test_count_blockquote_level_nested() {
690 assert_eq!(count_blockquote_level(">> text"), 2);
691 assert_eq!(count_blockquote_level("> > text"), 2);
692 assert_eq!(count_blockquote_level(">>> text"), 3);
693 assert_eq!(count_blockquote_level("> > > text"), 3);
694 }
695
696 #[test]
701 fn test_content_after_blockquote_no_quote() {
702 assert_eq!(content_after_blockquote("text", 0), "text");
703 assert_eq!(content_after_blockquote(" indented", 0), " indented");
704 }
705
706 #[test]
707 fn test_content_after_blockquote_single() {
708 assert_eq!(content_after_blockquote("> text", 1), "text");
709 assert_eq!(content_after_blockquote(">text", 1), "text");
710 assert_eq!(content_after_blockquote("> indented", 1), " indented");
711 }
712
713 #[test]
714 fn test_content_after_blockquote_nested() {
715 assert_eq!(content_after_blockquote(">> text", 2), "text");
716 assert_eq!(content_after_blockquote("> > text", 2), "text");
717 assert_eq!(content_after_blockquote("> > indented", 2), " indented");
718 }
719
720 #[test]
721 fn test_content_after_blockquote_mismatched_level() {
722 assert_eq!(content_after_blockquote("> text", 2), "> text");
724 assert_eq!(content_after_blockquote(">> text", 1), ">> text");
725 }
726}