1pub mod types;
2pub(crate) use heading_detection::is_paragraph_text_line;
3pub use types::*;
4
5mod element_parsers;
6mod flavor_detection;
7mod heading_detection;
8mod line_computation;
9mod link_parser;
10mod list_blocks;
11#[cfg(test)]
12mod tests;
13
14use crate::config::MarkdownFlavor;
15use crate::inline_config::InlineConfig;
16use crate::rules::front_matter_utils::FrontMatterUtils;
17use crate::utils::code_block_utils::{CodeBlockDetail, CodeBlockUtils};
18use crate::utils::range_utils::byte_to_char_count;
19use std::collections::HashMap;
20use std::ops::Range;
21use std::path::{Path, PathBuf};
22
23#[derive(Debug, Clone)]
26pub struct LinkTargetPolicy {
27 supplied_paths: Arc<std::collections::HashSet<PathBuf>>,
28 allow_disk_fallback: bool,
29}
30
31impl LinkTargetPolicy {
32 pub fn open_world<I, P>(paths: I) -> Self
35 where
36 I: IntoIterator<Item = P>,
37 P: AsRef<Path>,
38 {
39 Self::from_paths(paths, true)
40 }
41
42 pub fn closed_world<I, P>(paths: I) -> Self
44 where
45 I: IntoIterator<Item = P>,
46 P: AsRef<Path>,
47 {
48 Self::from_paths(paths, false)
49 }
50
51 fn from_paths<I, P>(paths: I, allow_disk_fallback: bool) -> Self
52 where
53 I: IntoIterator<Item = P>,
54 P: AsRef<Path>,
55 {
56 let mut roots = Vec::new();
57 if let Ok(cwd) = std::env::current_dir() {
58 if let Ok(canonical_cwd) = cwd.canonicalize()
59 && canonical_cwd != cwd
60 {
61 roots.push(canonical_cwd);
62 }
63 roots.push(cwd);
64 }
65 Self::from_paths_with_roots(paths, allow_disk_fallback, roots)
66 }
67
68 fn from_paths_with_roots<I, P, R, Q>(paths: I, allow_disk_fallback: bool, roots: R) -> Self
69 where
70 I: IntoIterator<Item = P>,
71 P: AsRef<Path>,
72 R: IntoIterator<Item = Q>,
73 Q: AsRef<Path>,
74 {
75 let roots: Vec<PathBuf> = roots
76 .into_iter()
77 .map(|root| crate::workspace_index::normalize_relative_path(root.as_ref()))
78 .collect();
79 let mut supplied_paths = std::collections::HashSet::new();
80 for path in paths {
81 let path = path.as_ref();
82 supplied_paths.insert(crate::workspace_index::normalize_relative_path(path));
83
84 if path.is_relative() {
85 for root in &roots {
86 supplied_paths.insert(crate::workspace_index::normalize_relative_path(&root.join(path)));
87 }
88 } else {
89 for source_root in &roots {
90 if let Ok(relative) = path.strip_prefix(source_root) {
91 for root in &roots {
92 supplied_paths
93 .insert(crate::workspace_index::normalize_relative_path(&root.join(relative)));
94 }
95 }
96 }
97 }
98 }
99 Self {
100 supplied_paths: Arc::new(supplied_paths),
101 allow_disk_fallback,
102 }
103 }
104
105 pub fn contains(&self, path: &Path) -> bool {
106 self.supplied_paths
107 .contains(&crate::workspace_index::normalize_relative_path(path))
108 }
109
110 pub(crate) fn resolve_supplied(&self, path: &Path) -> Option<PathBuf> {
111 let normalized = crate::workspace_index::normalize_relative_path(path);
112 if self.supplied_paths.contains(&normalized) {
113 return Some(normalized);
114 }
115
116 if path.extension().is_none() {
117 for extension in ["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"] {
118 let candidate = crate::workspace_index::normalize_relative_path(&path.with_extension(extension));
119 if self.supplied_paths.contains(&candidate) {
120 return Some(candidate);
121 }
122 }
123 }
124
125 None
126 }
127
128 pub fn contains_with_markdown_extension(&self, path: &Path) -> bool {
129 self.resolve_supplied(path).is_some()
130 }
131
132 pub fn allow_disk_fallback(&self) -> bool {
133 self.allow_disk_fallback
134 }
135}
136
137#[cfg(not(target_arch = "wasm32"))]
139macro_rules! profile_section {
140 ($name:expr, $profile:expr, $code:expr) => {{
141 let start = std::time::Instant::now();
142 let result = $code;
143 if $profile {
144 eprintln!("[PROFILE] {}: {:?}", $name, start.elapsed());
145 }
146 result
147 }};
148}
149
150fn build_commonmark_ordered_lists(
151 lines: &[LineInfo],
152 line_to_list: &crate::utils::code_block_utils::LineToListMap,
153 list_start_values: &crate::utils::code_block_utils::ListStartValues,
154) -> Vec<CommonMarkOrderedListInfo> {
155 let mut grouped_lines: HashMap<usize, Vec<usize>> = HashMap::new();
156
157 for (&line_num, &list_id) in line_to_list {
158 let is_ordered_item = line_num
159 .checked_sub(1)
160 .and_then(|index| lines.get(index))
161 .and_then(|line| line.list_item.as_deref())
162 .is_some_and(|item| item.is_ordered);
163 if is_ordered_item {
164 grouped_lines.entry(list_id).or_default().push(line_num);
165 }
166 }
167
168 let mut lists: Vec<_> = grouped_lines
169 .into_iter()
170 .map(|(list_id, mut item_lines)| {
171 item_lines.sort_unstable();
172 CommonMarkOrderedListInfo {
173 start_value: list_start_values.get(&list_id).copied().unwrap_or(1),
174 item_lines,
175 }
176 })
177 .collect();
178 lists.sort_by_key(|list| list.item_lines.first().copied().unwrap_or(0));
179 lists
180}
181
182#[cfg(target_arch = "wasm32")]
183macro_rules! profile_section {
184 ($name:expr, $profile:expr, $code:expr) => {{ $code }};
185}
186
187pub(super) struct SkipByteRanges<'a> {
190 pub(super) html_comment_ranges: &'a [crate::utils::skip_context::ByteRange],
191 pub(super) autodoc_ranges: &'a [crate::utils::skip_context::ByteRange],
192 pub(super) pandoc_div_ranges: &'a [crate::utils::skip_context::ByteRange],
193 pub(super) pymdown_block_ranges: &'a [crate::utils::skip_context::ByteRange],
194}
195
196use std::sync::{Arc, OnceLock};
197
198pub(super) type ListItemMap = std::collections::HashMap<usize, (bool, String, usize, usize, Option<usize>)>;
200
201pub(super) type ByteRanges = Vec<(usize, usize)>;
203
204pub struct LintContext<'a> {
205 pub content: &'a str,
206 content_lines: Vec<&'a str>, pub line_offsets: Vec<usize>,
208 pub code_blocks: Vec<(usize, usize)>, pub code_block_details: Vec<CodeBlockDetail>, pub strong_spans: Vec<crate::utils::code_block_utils::StrongSpanDetail>, line_to_list: crate::utils::code_block_utils::LineToListMap, list_start_values: crate::utils::code_block_utils::ListStartValues, commonmark_ordered_lists_cache: OnceLock<Vec<CommonMarkOrderedListInfo>>, pub lines: Vec<LineInfo>, blockquote_headings: Vec<Option<Box<HeadingInfo>>>, links: Vec<ParsedLink<'a>>, images: Vec<ParsedImage<'a>>, broken_links: Vec<BrokenLinkInfo>, footnote_refs: Vec<FootnoteRef>, reference_defs: Vec<ReferenceDef>, reference_defs_map: HashMap<String, usize>, code_spans_cache: OnceLock<Arc<Vec<CodeSpan>>>, math_spans_cache: OnceLock<Arc<Vec<MathSpan>>>, math_byte_ranges_cache: OnceLock<Vec<(usize, usize)>>, pub list_blocks: Vec<ListBlock>, pub char_frequency: CharFrequency, html_tags_cache: OnceLock<Arc<Vec<HtmlTag>>>, jsx_component_tags_cache: OnceLock<Arc<Vec<HtmlTag>>>, emphasis_spans_cache: OnceLock<Arc<Vec<EmphasisSpan>>>, bare_urls_cache: OnceLock<Arc<Vec<BareUrl>>>, has_mixed_list_nesting_cache: OnceLock<bool>, html_comment_ranges: Vec<crate::utils::skip_context::ByteRange>, pub table_blocks: Vec<crate::utils::table_utils::TableBlock>, line_index: crate::utils::range_utils::LineIndex<'a>, jinja_ranges: Vec<(usize, usize)>, pub flavor: MarkdownFlavor, source_file: Option<PathBuf>, link_target_policy: Option<LinkTargetPolicy>, jsx_expression_ranges: Vec<(usize, usize)>, mdx_comment_ranges: Vec<(usize, usize)>, citation_ranges: Vec<crate::utils::skip_context::ByteRange>, pandoc_div_ranges: Vec<crate::utils::skip_context::ByteRange>, colon_fence_details: Vec<CodeBlockDetail>, inline_footnote_ranges: Vec<crate::utils::skip_context::ByteRange>, pandoc_header_slugs: std::collections::HashSet<String>, example_list_marker_ranges: Vec<crate::utils::skip_context::ByteRange>, example_reference_ranges: Vec<crate::utils::skip_context::ByteRange>, sub_super_ranges: Vec<crate::utils::skip_context::ByteRange>, inline_code_attr_ranges: Vec<crate::utils::skip_context::ByteRange>, bracketed_span_ranges: Vec<crate::utils::skip_context::ByteRange>, line_block_ranges: Vec<crate::utils::skip_context::ByteRange>, pipe_table_caption_ranges: Vec<crate::utils::skip_context::ByteRange>, pandoc_metadata_ranges: Vec<crate::utils::skip_context::ByteRange>, grid_table_ranges: Vec<crate::utils::skip_context::ByteRange>, multi_line_table_ranges: Vec<crate::utils::skip_context::ByteRange>, shortcode_ranges: Vec<(usize, usize)>, link_title_ranges: Vec<(usize, usize)>, code_span_byte_ranges: Vec<(usize, usize)>, inline_config: InlineConfig, obsidian_comment_ranges: Vec<(usize, usize)>, unterminated_html_comment: Option<usize>, unterminated_obsidian_comment: Option<usize>, lazy_cont_lines_cache: OnceLock<Arc<Vec<LazyContLine>>>, myst_directive_ranges: Vec<(usize, usize)>, myst_comment_ranges: Vec<(usize, usize)>, myst_role_ranges: Vec<(usize, usize)>, front_matter_end: usize, }
269
270pub fn code_block_ranges(content: &str, flavor: MarkdownFlavor) -> Vec<(usize, usize)> {
281 LintContext::new(content, flavor, None).code_blocks
282}
283
284impl<'a> LintContext<'a> {
285 pub fn source_file(&self) -> Option<&Path> {
290 self.source_file.as_deref()
291 }
292
293 pub fn link_target_policy(&self) -> Option<&LinkTargetPolicy> {
294 self.link_target_policy.as_ref()
295 }
296
297 pub fn with_link_target_policy(mut self, policy: LinkTargetPolicy) -> Self {
298 self.link_target_policy = Some(policy);
299 self
300 }
301
302 pub fn new(content: &'a str, flavor: MarkdownFlavor, source_file: Option<PathBuf>) -> Self {
303 #[cfg(not(target_arch = "wasm32"))]
304 let profile = std::env::var("RUMDL_PROFILE_QUADRATIC").is_ok();
305
306 let line_offsets = profile_section!("Line offsets", profile, {
307 let mut offsets = vec![0];
308 for (i, c) in content.char_indices() {
309 if c == '\n' {
310 offsets.push(i + 1);
311 }
312 }
313 offsets
314 });
315
316 let content_lines: Vec<&str> = content.lines().collect();
318
319 #[allow(clippy::disallowed_methods)]
323 let front_matter_end = FrontMatterUtils::get_front_matter_end_line(content);
324
325 let parse_result = profile_section!(
327 "Code blocks",
328 profile,
329 CodeBlockUtils::detect_code_blocks_and_spans(content)
330 );
331 let mut code_blocks = parse_result.code_blocks;
332 let code_span_ranges = parse_result.code_spans;
333 let code_block_details = parse_result.code_block_details;
334 let strong_spans = parse_result.strong_spans;
335 let line_to_list = parse_result.line_to_list;
336 let list_start_values = parse_result.list_start_values;
337 let html_blocks = parse_result.html_blocks;
338
339 let containers = profile_section!(
342 "Container lines",
343 profile,
344 flavor_detection::detect_container_lines(&content_lines, flavor)
345 );
346
347 let comment_code_block_ranges: Vec<(usize, usize)> = code_block_details
356 .iter()
357 .flat_map(|detail| {
358 if detail.is_fenced {
359 return vec![(detail.start, detail.end)];
360 }
361 let start_line = line_offsets
362 .partition_point(|&offset| offset <= detail.start)
363 .saturating_sub(1);
364 let end_line = line_offsets.partition_point(|&offset| offset < detail.end);
365 containers
366 .code_line_spans_in(start_line..end_line)
367 .into_iter()
368 .map(|span| {
369 let start = line_offsets[span.start].max(detail.start);
370 let end = line_offsets
371 .get(span.end)
372 .copied()
373 .unwrap_or(content.len())
374 .min(detail.end);
375 (start, end)
376 })
377 .collect()
378 })
379 .collect();
380 let body_start = line_offsets.get(front_matter_end).copied().unwrap_or(content.len());
386 let html_comment_scan = profile_section!(
387 "HTML comment ranges",
388 profile,
389 crate::utils::skip_context::scan_html_comments(
390 content,
391 &code_span_ranges,
392 &comment_code_block_ranges,
393 body_start
394 )
395 );
396 let mut html_comment_ranges = html_comment_scan.ranges;
397 let unterminated_html_comment = html_comment_scan.unterminated;
398
399 let autodoc_ranges = profile_section!("Autodoc block ranges", profile, {
403 if flavor.supports_colon_code_fences() || flavor.supports_myst_directives() {
404 Vec::new()
405 } else {
406 crate::utils::mkdocstrings_refs::detect_autodoc_block_ranges(content)
407 }
408 });
409
410 let pandoc_div_ranges = profile_section!("Pandoc div ranges", profile, {
412 if flavor.is_pandoc_compatible() {
413 crate::utils::pandoc::detect_div_block_ranges(content)
414 } else {
415 Vec::new()
416 }
417 });
418
419 let pymdown_block_ranges = profile_section!("PyMdown block ranges", profile, {
421 if flavor == MarkdownFlavor::MkDocs {
422 crate::utils::pymdown_blocks::detect_block_ranges(content)
423 } else {
424 Vec::new()
425 }
426 });
427
428 let skip_ranges = SkipByteRanges {
431 html_comment_ranges: &html_comment_ranges,
432 autodoc_ranges: &autodoc_ranges,
433 pandoc_div_ranges: &pandoc_div_ranges,
434 pymdown_block_ranges: &pymdown_block_ranges,
435 };
436 let (mut lines, emphasis_spans) = profile_section!(
437 "Basic line info",
438 profile,
439 line_computation::compute_basic_line_info(
440 content,
441 &content_lines,
442 &line_offsets,
443 &code_blocks,
444 flavor,
445 &skip_ranges,
446 front_matter_end,
447 )
448 );
449
450 profile_section!(
452 "HTML blocks",
453 profile,
454 heading_detection::detect_html_blocks(content, &mut lines)
455 );
456
457 profile_section!(
459 "ESM blocks",
460 profile,
461 flavor_detection::detect_esm_blocks(content, &mut lines, flavor)
462 );
463
464 profile_section!(
466 "JSX block detection",
467 profile,
468 flavor_detection::detect_jsx_blocks(content, &mut lines, flavor)
469 );
470
471 let (jsx_expression_ranges, mdx_comment_ranges) = profile_section!(
473 "JSX/MDX detection",
474 profile,
475 flavor_detection::detect_jsx_and_mdx_comments(content, &mut lines, flavor, &code_blocks)
476 );
477
478 profile_section!(
483 "Markdown-in-HTML blocks",
484 profile,
485 flavor_detection::detect_markdown_html_blocks(&mut lines, &containers)
486 );
487
488 profile_section!(
490 "MkDocs constructs",
491 profile,
492 flavor_detection::detect_mkdocs_line_info(&content_lines, &mut lines, flavor, &containers)
493 );
494
495 profile_section!(
500 "Footnote definitions",
501 profile,
502 detect_footnote_definitions(content, &mut lines, &line_offsets)
503 );
504
505 {
508 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
509 for &(start, end) in &code_blocks {
510 let start_line = line_offsets
511 .partition_point(|&offset| offset <= start)
512 .saturating_sub(1);
513 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
514
515 let mut sub_start: Option<usize> = None;
516 for (i, &offset) in line_offsets[start_line..end_line]
517 .iter()
518 .enumerate()
519 .map(|(j, o)| (j + start_line, o))
520 {
521 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
522 if is_real_code && sub_start.is_none() {
523 let byte_start = if i == start_line { start } else { offset };
524 sub_start = Some(byte_start);
525 } else if !is_real_code && sub_start.is_some() {
526 new_code_blocks.push((sub_start.unwrap(), offset));
527 sub_start = None;
528 }
529 }
530 if let Some(s) = sub_start {
531 new_code_blocks.push((s, end));
532 }
533 }
534 code_blocks = new_code_blocks;
535 }
536
537 let has_markdown_html = lines.iter().any(|l| l.in_mkdocs_html_markdown);
545 if flavor == MarkdownFlavor::MkDocs || has_markdown_html {
546 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
547 for &(start, end) in &code_blocks {
548 let start_line = line_offsets
549 .partition_point(|&offset| offset <= start)
550 .saturating_sub(1);
551 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
552
553 let mut sub_start: Option<usize> = None;
555 for (i, &offset) in line_offsets[start_line..end_line]
556 .iter()
557 .enumerate()
558 .map(|(j, o)| (j + start_line, o))
559 {
560 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
561 if is_real_code && sub_start.is_none() {
562 let byte_start = if i == start_line { start } else { offset };
563 sub_start = Some(byte_start);
564 } else if !is_real_code && sub_start.is_some() {
565 new_code_blocks.push((sub_start.unwrap(), offset));
566 sub_start = None;
567 }
568 }
569 if let Some(s) = sub_start {
570 new_code_blocks.push((s, end));
571 }
572 }
573 code_blocks = new_code_blocks;
574 }
575
576 if flavor.supports_jsx() {
580 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
581 for &(start, end) in &code_blocks {
582 let start_line = line_offsets
583 .partition_point(|&offset| offset <= start)
584 .saturating_sub(1);
585 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
586
587 let mut sub_start: Option<usize> = None;
588 for (i, &offset) in line_offsets[start_line..end_line]
589 .iter()
590 .enumerate()
591 .map(|(j, o)| (j + start_line, o))
592 {
593 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
594 if is_real_code && sub_start.is_none() {
595 let byte_start = if i == start_line { start } else { offset };
596 sub_start = Some(byte_start);
597 } else if !is_real_code && sub_start.is_some() {
598 new_code_blocks.push((sub_start.unwrap(), offset));
599 sub_start = None;
600 }
601 }
602 if let Some(s) = sub_start {
603 new_code_blocks.push((s, end));
604 }
605 }
606 code_blocks = new_code_blocks;
607
608 let mut jsx_fence_ranges: Vec<(usize, usize)> = Vec::new();
615 let mut run: Option<(usize, usize)> = None;
616 for line in &lines {
617 if line.in_jsx_block && line.in_code_block {
618 let line_end = line.byte_offset + line.byte_len;
619 match &mut run {
620 Some((_, end)) => *end = line_end,
621 None => run = Some((line.byte_offset, line_end)),
622 }
623 } else if let Some(r) = run.take() {
624 jsx_fence_ranges.push(r);
625 }
626 }
627 if let Some(r) = run.take() {
628 jsx_fence_ranges.push(r);
629 }
630 if !jsx_fence_ranges.is_empty() {
631 code_blocks.extend(jsx_fence_ranges);
632 code_blocks.sort_by_key(|&(start, _)| start);
633 }
634 }
635
636 let colon_fence_details = profile_section!(
639 "Azure colon fence detection",
640 profile,
641 flavor_detection::detect_azure_colon_fences(content, &mut lines, flavor)
642 );
643 if !colon_fence_details.is_empty() {
644 code_blocks.extend(colon_fence_details.iter().map(|fence| (fence.start, fence.end)));
645 code_blocks.sort_by_key(|&(start, _)| start);
646 }
647
648 let myst_directive_ranges = profile_section!(
651 "MyST colon directives",
652 profile,
653 flavor_detection::detect_myst_colon_directives(content, &mut lines, flavor)
654 );
655
656 let myst_comment_ranges = profile_section!(
658 "MyST comments",
659 profile,
660 flavor_detection::detect_myst_comments(content, &mut lines, flavor)
661 );
662
663 profile_section!(
666 "MyST backtick directives",
667 profile,
668 flavor_detection::detect_myst_backtick_directives(
669 content,
670 &mut lines,
671 flavor,
672 &code_block_details,
673 &line_offsets
674 )
675 );
676
677 if flavor.supports_myst_directives() {
680 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
681 for &(start, end) in &code_blocks {
682 let start_line = line_offsets
683 .partition_point(|&offset| offset <= start)
684 .saturating_sub(1);
685 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
686
687 let mut sub_start: Option<usize> = None;
688 for (i, &offset) in line_offsets[start_line..end_line]
689 .iter()
690 .enumerate()
691 .map(|(j, o)| (j + start_line, o))
692 {
693 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
694 if is_real_code && sub_start.is_none() {
695 let byte_start = if i == start_line { start } else { offset };
696 sub_start = Some(byte_start);
697 } else if !is_real_code && sub_start.is_some() {
698 new_code_blocks.push((sub_start.unwrap(), offset));
699 sub_start = None;
700 }
701 }
702 if let Some(s) = sub_start {
703 new_code_blocks.push((s, end));
704 }
705 }
706 code_blocks = new_code_blocks;
707 }
708
709 profile_section!(
711 "Kramdown constructs",
712 profile,
713 flavor_detection::detect_kramdown_line_info(content, &mut lines, flavor)
714 );
715
716 for line in &mut lines {
721 if line.in_kramdown_extension_block {
722 line.list_item = None;
723 line.is_horizontal_rule = false;
724 line.blockquote = None;
725 line.is_kramdown_block_ial = false;
726 }
727 }
728
729 let obsidian_comment_scan = profile_section!(
731 "Obsidian comments",
732 profile,
733 flavor_detection::detect_obsidian_comments(
734 content,
735 &mut lines,
736 flavor,
737 &code_span_ranges,
738 &html_comment_ranges,
739 body_start
740 )
741 );
742 let mut obsidian_comment_ranges = obsidian_comment_scan.ranges;
743 let mut unterminated_obsidian_comment = obsidian_comment_scan.unterminated;
744
745 let unterminated_html_comment = crate::utils::skip_context::unterminated_html_comment_outside(
750 unterminated_html_comment,
751 &obsidian_comment_ranges,
752 content,
753 &code_span_ranges,
754 &comment_code_block_ranges,
755 body_start,
756 );
757
758 if let Some(range) = unterminated_html_comment.and_then(|opener| {
771 crate::utils::skip_context::unterminated_comment_range(opener, &html_blocks)
772 .or_else(|| container_comment_range(opener, &containers, &lines, content))
773 }) {
774 html_comment_ranges.push(range);
777
778 for line in &mut lines {
784 let text = line.content(content);
785 let content_start = line.byte_offset + line.indent;
786 let content_end = line.byte_offset + text.trim_end().len();
787 line.in_html_comment = crate::utils::skip_context::is_line_entirely_in_html_comment(
788 &html_comment_ranges,
789 content_start,
790 content_end,
791 );
792 line.in_obsidian_comment = false;
793 }
794
795 let obsidian_rescan = flavor_detection::detect_obsidian_comments(
806 content,
807 &mut lines,
808 flavor,
809 &code_span_ranges,
810 &html_comment_ranges,
811 body_start,
812 );
813 obsidian_comment_ranges = obsidian_rescan.ranges;
814 unterminated_obsidian_comment = obsidian_rescan.unterminated;
815 }
816
817 let myst_role_ranges = profile_section!(
819 "MyST roles",
820 profile,
821 flavor_detection::detect_myst_role_ranges(content, &lines, flavor, &code_blocks)
822 );
823
824 let pulldown_result = profile_section!(
828 "Links, images & link ranges",
829 profile,
830 link_parser::parse_links_images_pulldown(content, &lines, &code_blocks, flavor, &html_comment_ranges)
831 );
832
833 let mut blockquote_headings = profile_section!(
835 "Headings & blockquotes",
836 profile,
837 heading_detection::detect_headings_and_blockquotes(
838 &content_lines,
839 &mut lines,
840 flavor,
841 &html_comment_ranges,
842 &pulldown_result.link_byte_ranges,
843 front_matter_end,
844 )
845 );
846
847 for line in &mut lines {
849 if line.in_kramdown_extension_block {
850 line.heading = None;
851 }
852 }
853 for (line, heading) in lines.iter().zip(&mut blockquote_headings) {
854 if line.in_kramdown_extension_block {
855 *heading = None;
856 }
857 }
858
859 for line in &mut lines {
870 if line.is_horizontal_rule
871 && (line.in_code_block
872 || line.in_html_block
873 || line.in_html_comment
874 || line.in_math_block
875 || line.in_mdx_comment
876 || line.in_obsidian_comment)
877 {
878 line.is_horizontal_rule = false;
879 }
880 }
881
882 let mut code_spans = profile_section!(
884 "Code spans",
885 profile,
886 element_parsers::build_code_spans_from_ranges(content, &lines, &code_span_ranges)
887 );
888
889 if flavor == MarkdownFlavor::MkDocs {
893 let extra = profile_section!(
894 "MkDocs code spans",
895 profile,
896 element_parsers::scan_mkdocs_container_code_spans(content, &lines, &code_span_ranges,)
897 );
898 if !extra.is_empty() {
899 code_spans.extend(extra);
900 code_spans.sort_by_key(|span| span.byte_offset);
901 }
902 }
903
904 if flavor == MarkdownFlavor::MDX {
909 let extra = profile_section!(
910 "MDX JSX code spans",
911 profile,
912 element_parsers::scan_jsx_block_code_spans(content, &lines, &code_span_ranges)
913 );
914 if !extra.is_empty() {
915 code_spans.extend(extra);
916 code_spans.sort_by_key(|span| span.byte_offset);
917 }
918 }
919
920 for span in &code_spans {
923 if span.end_line > span.line {
924 for line_num in (span.line + 1)..=span.end_line {
926 if let Some(line_info) = lines.get_mut(line_num - 1) {
927 line_info.in_code_span_continuation = true;
928 }
929 }
930 }
931 }
932
933 let (links, images, broken_links, footnote_refs) = profile_section!(
935 "Links & images finalize",
936 profile,
937 link_parser::finalize_links_and_images(
938 content,
939 &lines,
940 &code_blocks,
941 &code_spans,
942 flavor,
943 &html_comment_ranges,
944 pulldown_result
945 )
946 );
947
948 let reference_defs = profile_section!(
949 "Reference defs",
950 profile,
951 link_parser::parse_reference_defs(content, &lines)
952 );
953
954 let list_blocks = profile_section!("List blocks", profile, list_blocks::parse_list_blocks(content, &lines));
955
956 let char_frequency = profile_section!(
958 "Char frequency",
959 profile,
960 line_computation::compute_char_frequency(content)
961 );
962
963 let table_blocks = profile_section!(
965 "Table blocks",
966 profile,
967 crate::utils::table_utils::TableUtils::find_table_blocks_with_code_info(
968 content,
969 &code_blocks,
970 &code_spans,
971 &html_comment_ranges,
972 flavor,
973 )
974 );
975
976 let links = links
979 .into_iter()
980 .filter(|link| !lines.get(link.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
981 .collect::<Vec<_>>();
982 let images = images
983 .into_iter()
984 .filter(|img| !lines.get(img.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
985 .collect::<Vec<_>>();
986 let broken_links = broken_links
987 .into_iter()
988 .filter(|bl| {
989 let line_idx = line_offsets
991 .partition_point(|&offset| offset <= bl.span.start)
992 .saturating_sub(1);
993 !lines.get(line_idx).is_some_and(|l| l.in_kramdown_extension_block)
994 })
995 .collect::<Vec<_>>();
996 let footnote_refs = footnote_refs
997 .into_iter()
998 .filter(|fr| !lines.get(fr.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
999 .collect::<Vec<_>>();
1000 let reference_defs = reference_defs
1001 .into_iter()
1002 .filter(|def| !lines.get(def.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1003 .collect::<Vec<_>>();
1004 let list_blocks = list_blocks
1005 .into_iter()
1006 .filter(|block| {
1007 !lines
1008 .get(block.start_line - 1)
1009 .is_some_and(|l| l.in_kramdown_extension_block)
1010 })
1011 .collect::<Vec<_>>();
1012 let table_blocks = table_blocks
1013 .into_iter()
1014 .filter(|block| {
1015 !lines
1017 .get(block.start_line)
1018 .is_some_and(|l| l.in_kramdown_extension_block)
1019 })
1020 .collect::<Vec<_>>();
1021 let emphasis_spans = emphasis_spans
1022 .into_iter()
1023 .filter(|span| !lines.get(span.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1024 .collect::<Vec<_>>();
1025
1026 for block in &list_blocks {
1030 for line_num in block.start_line..=block.end_line {
1032 if let Some(li) = lines.get_mut(line_num - 1) {
1033 li.in_list_block = true;
1034 }
1035 }
1036 }
1037 for block in &table_blocks {
1038 for idx in block.start_line..=block.end_line {
1040 if let Some(li) = lines.get_mut(idx) {
1041 li.in_table_block = true;
1042 }
1043 }
1044 }
1045
1046 let reference_defs_map: HashMap<String, usize> = reference_defs
1048 .iter()
1049 .enumerate()
1050 .map(|(idx, def)| (def.id.to_lowercase(), idx))
1051 .collect();
1052
1053 let link_title_ranges: Vec<(usize, usize)> = reference_defs
1055 .iter()
1056 .filter_map(|def| match (def.title_byte_start, def.title_byte_end) {
1057 (Some(start), Some(end)) => Some((start, end)),
1058 _ => None,
1059 })
1060 .collect();
1061
1062 let line_index = profile_section!(
1064 "Line index",
1065 profile,
1066 crate::utils::range_utils::LineIndex::with_line_starts_and_code_blocks(
1067 content,
1068 line_offsets.clone(),
1069 &code_blocks,
1070 )
1071 );
1072
1073 let jinja_ranges = profile_section!(
1075 "Jinja ranges",
1076 profile,
1077 crate::utils::jinja_utils::find_jinja_ranges(content)
1078 );
1079
1080 let citation_ranges = profile_section!("Citation ranges", profile, {
1082 if flavor.is_pandoc_compatible() {
1083 crate::utils::pandoc::find_citation_ranges(content)
1084 } else {
1085 Vec::new()
1086 }
1087 });
1088
1089 let inline_footnote_ranges = profile_section!("Inline footnote ranges", profile, {
1091 if flavor.is_pandoc_compatible() {
1092 crate::utils::pandoc::detect_inline_footnote_ranges(content)
1093 } else {
1094 Vec::new()
1095 }
1096 });
1097
1098 let pandoc_header_slugs = profile_section!("Pandoc header slugs", profile, {
1100 if flavor.is_pandoc_compatible() {
1101 crate::utils::pandoc::collect_pandoc_header_slugs(content)
1102 } else {
1103 std::collections::HashSet::new()
1104 }
1105 });
1106
1107 let example_list_marker_ranges = profile_section!("Example list markers", profile, {
1109 if flavor.is_pandoc_compatible() {
1110 crate::utils::pandoc::detect_example_list_marker_ranges(content)
1111 } else {
1112 Vec::new()
1113 }
1114 });
1115
1116 let example_reference_ranges = profile_section!("Example references", profile, {
1118 if flavor.is_pandoc_compatible() {
1119 crate::utils::pandoc::detect_example_reference_ranges(content, &example_list_marker_ranges)
1120 } else {
1121 Vec::new()
1122 }
1123 });
1124
1125 let sub_super_ranges = profile_section!("Subscript/superscript ranges", profile, {
1127 if flavor.is_pandoc_compatible() {
1128 crate::utils::pandoc::detect_subscript_superscript_ranges(content)
1129 } else {
1130 Vec::new()
1131 }
1132 });
1133
1134 let inline_code_attr_ranges = profile_section!("Inline code attribute ranges", profile, {
1136 if flavor.is_pandoc_compatible() {
1137 crate::utils::pandoc::detect_inline_code_attr_ranges(content)
1138 } else {
1139 Vec::new()
1140 }
1141 });
1142
1143 let bracketed_span_ranges = profile_section!("Bracketed span ranges", profile, {
1145 if flavor.is_pandoc_compatible() {
1146 crate::utils::pandoc::detect_bracketed_span_ranges(content)
1147 } else {
1148 Vec::new()
1149 }
1150 });
1151
1152 let line_block_ranges = profile_section!("Line block ranges", profile, {
1154 if flavor.is_pandoc_compatible() {
1155 crate::utils::pandoc::detect_line_block_ranges(content)
1156 } else {
1157 Vec::new()
1158 }
1159 });
1160
1161 let pipe_table_caption_ranges = profile_section!("Pipe-table caption ranges", profile, {
1163 if flavor.is_pandoc_compatible() {
1164 crate::utils::pandoc::detect_pipe_table_caption_ranges(content)
1165 } else {
1166 Vec::new()
1167 }
1168 });
1169
1170 let pandoc_metadata_ranges = profile_section!("Pandoc metadata ranges", profile, {
1172 if flavor.is_pandoc_compatible() {
1173 crate::utils::pandoc::detect_yaml_metadata_block_ranges(content)
1174 } else {
1175 Vec::new()
1176 }
1177 });
1178
1179 let grid_table_ranges = profile_section!("Grid table ranges", profile, {
1181 if flavor.is_pandoc_compatible() {
1182 crate::utils::pandoc::detect_grid_table_ranges(content)
1183 } else {
1184 Vec::new()
1185 }
1186 });
1187
1188 let multi_line_table_ranges = profile_section!("Multi-line table ranges", profile, {
1190 if flavor.is_pandoc_compatible() {
1191 crate::utils::pandoc::detect_multi_line_table_ranges(content)
1192 } else {
1193 Vec::new()
1194 }
1195 });
1196
1197 let shortcode_ranges = profile_section!("Shortcode ranges", profile, {
1199 use crate::utils::regex_cache::HUGO_SHORTCODE_REGEX;
1200 let mut ranges = Vec::new();
1201 for mat in HUGO_SHORTCODE_REGEX.find_iter(content) {
1202 ranges.push((mat.start(), mat.end()));
1203 }
1204 ranges
1205 });
1206
1207 let inline_config = InlineConfig::from_content_with_code_blocks(content, &code_blocks);
1208 Self {
1209 content,
1210 content_lines,
1211 line_offsets,
1212 code_blocks,
1213 code_block_details,
1214 strong_spans,
1215 line_to_list,
1216 list_start_values,
1217 commonmark_ordered_lists_cache: OnceLock::new(),
1218 lines,
1219 blockquote_headings,
1220 links,
1221 images,
1222 broken_links,
1223 footnote_refs,
1224 reference_defs,
1225 reference_defs_map,
1226 code_spans_cache: OnceLock::from(Arc::new(code_spans)),
1227 math_spans_cache: OnceLock::new(), math_byte_ranges_cache: OnceLock::new(), list_blocks,
1230 char_frequency,
1231 html_tags_cache: OnceLock::new(),
1232 jsx_component_tags_cache: OnceLock::new(),
1233 emphasis_spans_cache: OnceLock::from(Arc::new(emphasis_spans)),
1234 bare_urls_cache: OnceLock::new(),
1235 has_mixed_list_nesting_cache: OnceLock::new(),
1236 html_comment_ranges,
1237 table_blocks,
1238 line_index,
1239 jinja_ranges,
1240 flavor,
1241 source_file,
1242 link_target_policy: None,
1243 jsx_expression_ranges,
1244 mdx_comment_ranges,
1245 citation_ranges,
1246 pandoc_div_ranges,
1247 colon_fence_details,
1248 inline_footnote_ranges,
1249 pandoc_header_slugs,
1250 example_list_marker_ranges,
1251 example_reference_ranges,
1252 sub_super_ranges,
1253 inline_code_attr_ranges,
1254 bracketed_span_ranges,
1255 line_block_ranges,
1256 pipe_table_caption_ranges,
1257 pandoc_metadata_ranges,
1258 grid_table_ranges,
1259 multi_line_table_ranges,
1260 shortcode_ranges,
1261 link_title_ranges,
1262 code_span_byte_ranges: code_span_ranges,
1263 inline_config,
1264 obsidian_comment_ranges,
1265 unterminated_html_comment,
1266 unterminated_obsidian_comment,
1267 lazy_cont_lines_cache: OnceLock::new(),
1268 myst_directive_ranges,
1269 myst_comment_ranges,
1270 myst_role_ranges,
1271 front_matter_end,
1272 }
1273 }
1274
1275 pub fn front_matter_end_line(&self) -> usize {
1280 self.front_matter_end
1281 }
1282
1283 #[inline]
1286 fn binary_search_ranges(ranges: &[(usize, usize)], pos: usize) -> bool {
1287 let idx = ranges.partition_point(|&(start, _)| start <= pos);
1289 idx > 0 && pos < ranges[idx - 1].1
1291 }
1292
1293 pub fn is_in_code_span_byte(&self, pos: usize) -> bool {
1295 Self::binary_search_ranges(&self.code_span_byte_ranges, pos)
1296 }
1297
1298 pub fn is_in_link(&self, pos: usize) -> bool {
1300 self.link_containing(pos).is_some() || self.image_containing(pos).is_some() || self.is_in_reference_def(pos)
1301 }
1302
1303 pub fn is_in_bare_url(&self, pos: usize) -> bool {
1305 let bare_urls = self.bare_urls();
1306 let idx = bare_urls.partition_point(|url| url.byte_offset <= pos);
1308 idx > 0 && pos < bare_urls[idx - 1].byte_end
1309 }
1310
1311 pub fn inline_config(&self) -> &InlineConfig {
1313 &self.inline_config
1314 }
1315
1316 pub fn colon_fence_details(&self) -> &[CodeBlockDetail] {
1321 &self.colon_fence_details
1322 }
1323
1324 pub fn raw_lines(&self) -> &[&'a str] {
1328 &self.content_lines
1329 }
1330
1331 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
1336 self.inline_config.is_rule_disabled(rule_name, line_number)
1337 }
1338
1339 pub fn code_spans(&self) -> Arc<Vec<CodeSpan>> {
1341 Arc::clone(
1342 self.code_spans_cache
1343 .get_or_init(|| Arc::new(element_parsers::parse_code_spans(self.content, &self.lines))),
1344 )
1345 }
1346
1347 pub fn math_byte_ranges(&self) -> &[(usize, usize)] {
1351 self.math_byte_ranges_cache
1352 .get_or_init(|| crate::utils::skip_context::math_byte_ranges(self.content))
1353 }
1354
1355 pub fn math_spans(&self) -> Arc<Vec<MathSpan>> {
1357 Arc::clone(
1358 self.math_spans_cache
1359 .get_or_init(|| Arc::new(element_parsers::parse_math_spans(self.content, &self.lines))),
1360 )
1361 }
1362
1363 pub fn is_in_math_span(&self, byte_pos: usize) -> bool {
1365 let math_spans = self.math_spans();
1366 let idx = math_spans.partition_point(|span| span.byte_offset <= byte_pos);
1368 idx > 0 && byte_pos < math_spans[idx - 1].byte_end
1369 }
1370
1371 pub fn html_comment_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1373 &self.html_comment_ranges
1374 }
1375
1376 pub fn unterminated_html_comment(&self) -> Option<usize> {
1381 self.unterminated_html_comment
1382 }
1383
1384 pub fn unterminated_obsidian_comment(&self) -> Option<usize> {
1388 self.unterminated_obsidian_comment
1389 }
1390
1391 pub fn is_in_obsidian_comment(&self, byte_pos: usize) -> bool {
1395 Self::binary_search_ranges(&self.obsidian_comment_ranges, byte_pos)
1396 }
1397
1398 pub fn is_position_in_obsidian_comment(&self, line_num: usize, col: usize) -> bool {
1403 if self.obsidian_comment_ranges.is_empty() {
1404 return false;
1405 }
1406
1407 let byte_pos = self.line_index.line_col_to_byte_range(line_num, col).start;
1409 self.is_in_obsidian_comment(byte_pos)
1410 }
1411
1412 pub fn myst_directive_ranges(&self) -> &[(usize, usize)] {
1414 &self.myst_directive_ranges
1415 }
1416
1417 pub fn is_in_myst_role(&self, byte_pos: usize) -> bool {
1419 Self::binary_search_ranges(&self.myst_role_ranges, byte_pos)
1420 }
1421
1422 pub fn is_in_myst_comment(&self, byte_pos: usize) -> bool {
1424 Self::binary_search_ranges(&self.myst_comment_ranges, byte_pos)
1425 }
1426
1427 pub fn is_myst_colon_directive_opener_line(&self, line_num: usize) -> bool {
1434 if !self.flavor.supports_myst_directives() {
1435 return false;
1436 }
1437 self.lines.get(line_num.wrapping_sub(1)).is_some_and(|info| {
1438 info.in_myst_directive
1439 && flavor_detection::myst_colon_directive_opener(info.content(self.content)).is_some()
1440 })
1441 }
1442
1443 fn filter_kramdown_tags(&self, tags: Vec<HtmlTag>) -> Vec<HtmlTag> {
1445 tags.into_iter()
1446 .filter(|tag| {
1447 !self
1448 .lines
1449 .get(tag.line - 1)
1450 .is_some_and(|l| l.in_kramdown_extension_block)
1451 })
1452 .collect()
1453 }
1454
1455 pub fn html_tags(&self) -> Arc<Vec<HtmlTag>> {
1461 Arc::clone(self.html_tags_cache.get_or_init(|| {
1462 let (html_tags, jsx_component_tags) =
1463 element_parsers::parse_html_tags(self.content, &self.lines, &self.code_blocks, self.flavor);
1464 let _ = self
1466 .jsx_component_tags_cache
1467 .set(Arc::new(self.filter_kramdown_tags(jsx_component_tags)));
1468 Arc::new(self.filter_kramdown_tags(html_tags))
1469 }))
1470 }
1471
1472 pub fn jsx_component_tags(&self) -> Arc<Vec<HtmlTag>> {
1475 if let Some(cached) = self.jsx_component_tags_cache.get() {
1476 return Arc::clone(cached);
1477 }
1478 let _ = self.html_tags();
1480 Arc::clone(
1481 self.jsx_component_tags_cache
1482 .get()
1483 .expect("html_tags() populates jsx_component_tags_cache"),
1484 )
1485 }
1486
1487 pub fn emphasis_spans(&self) -> Arc<Vec<EmphasisSpan>> {
1489 Arc::clone(
1490 self.emphasis_spans_cache
1491 .get()
1492 .expect("emphasis_spans_cache initialized during construction"),
1493 )
1494 }
1495
1496 pub fn bare_urls(&self) -> Arc<Vec<BareUrl>> {
1498 Arc::clone(self.bare_urls_cache.get_or_init(|| {
1499 Arc::new(element_parsers::parse_bare_urls(
1500 self.content,
1501 &self.lines,
1502 &self.code_blocks,
1503 ))
1504 }))
1505 }
1506
1507 pub fn lazy_continuation_lines(&self) -> Arc<Vec<LazyContLine>> {
1509 Arc::clone(self.lazy_cont_lines_cache.get_or_init(|| {
1510 Arc::new(element_parsers::detect_lazy_continuation_lines(
1511 self.content,
1512 &self.lines,
1513 &self.line_offsets,
1514 ))
1515 }))
1516 }
1517
1518 pub fn has_mixed_list_nesting(&self) -> bool {
1522 *self
1523 .has_mixed_list_nesting_cache
1524 .get_or_init(|| self.compute_mixed_list_nesting())
1525 }
1526
1527 fn compute_mixed_list_nesting(&self) -> bool {
1529 let mut stack: Vec<(usize, bool)> = Vec::new();
1534 let mut last_was_blank = false;
1535
1536 for line_info in &self.lines {
1537 if line_info.in_code_block
1539 || line_info.in_front_matter
1540 || line_info.in_mkdocstrings
1541 || line_info.in_html_comment
1542 || line_info.in_mdx_comment
1543 || line_info.in_esm_block
1544 {
1545 continue;
1546 }
1547
1548 if line_info.is_blank {
1550 last_was_blank = true;
1551 continue;
1552 }
1553
1554 if let Some(list_item) = &line_info.list_item {
1555 let current_pos = if list_item.marker_column == 1 {
1557 0
1558 } else {
1559 list_item.marker_column
1560 };
1561
1562 if last_was_blank && current_pos == 0 {
1564 stack.clear();
1565 }
1566 last_was_blank = false;
1567
1568 while let Some(&(pos, _)) = stack.last() {
1570 if pos >= current_pos {
1571 stack.pop();
1572 } else {
1573 break;
1574 }
1575 }
1576
1577 if let Some(&(_, parent_is_ordered)) = stack.last()
1579 && parent_is_ordered != list_item.is_ordered
1580 {
1581 return true; }
1583
1584 stack.push((current_pos, list_item.is_ordered));
1585 } else {
1586 last_was_blank = false;
1588 }
1589 }
1590
1591 false
1592 }
1593
1594 pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
1600 match self.line_offsets.binary_search(&offset) {
1601 Ok(line) => (line + 1, 1),
1602 Err(line) => {
1603 let line_start = self.line_offsets.get(line.wrapping_sub(1)).copied().unwrap_or(0);
1604 let col = byte_to_char_count(&self.content[line_start..], offset.saturating_sub(line_start));
1606 (line, col)
1607 }
1608 }
1609 }
1610
1611 pub fn line_start_byte(&self, line_number: usize) -> Option<usize> {
1617 self.line_index.get_line_start_byte(line_number)
1618 }
1619
1620 pub fn line_column_byte_range(&self, line_number: usize, column: usize) -> Range<usize> {
1626 self.line_index.line_col_to_byte_range(line_number, column)
1627 }
1628
1629 pub fn line_column_byte_range_with_length(&self, line_number: usize, column: usize, length: usize) -> Range<usize> {
1634 self.line_index
1635 .line_col_to_byte_range_with_length(line_number, column, length)
1636 }
1637
1638 pub fn whole_line_byte_range(&self, line_number: usize) -> Range<usize> {
1641 self.line_index.whole_line_range(line_number)
1642 }
1643
1644 pub fn line_text_byte_range(&self, line_number: usize, start_column: usize, end_column: usize) -> Range<usize> {
1649 self.line_index.line_text_range(line_number, start_column, end_column)
1650 }
1651
1652 pub fn line_content_byte_range(&self, line_number: usize) -> Range<usize> {
1655 self.line_index.line_content_range(line_number)
1656 }
1657
1658 pub fn line_span_byte_range(&self, start_line: usize, end_line: usize) -> Range<usize> {
1660 self.line_index.multi_line_range(start_line, end_line)
1661 }
1662
1663 pub fn is_in_code_block_or_span(&self, pos: usize) -> bool {
1665 if CodeBlockUtils::is_in_code_block_or_span(&self.code_blocks, pos) {
1667 return true;
1668 }
1669
1670 self.is_byte_offset_in_code_span(pos)
1672 }
1673
1674 pub fn line_info(&self, line_num: usize) -> Option<&LineInfo> {
1676 if line_num > 0 {
1677 self.lines.get(line_num - 1)
1678 } else {
1679 None
1680 }
1681 }
1682
1683 pub fn links(&self) -> &[ParsedLink<'a>] {
1685 &self.links
1686 }
1687
1688 pub fn images(&self) -> &[ParsedImage<'a>] {
1690 &self.images
1691 }
1692
1693 pub fn broken_links(&self) -> &[BrokenLinkInfo] {
1695 &self.broken_links
1696 }
1697
1698 pub fn footnote_references(&self) -> &[FootnoteRef] {
1700 &self.footnote_refs
1701 }
1702
1703 pub fn reference_definitions(&self) -> &[ReferenceDef] {
1705 &self.reference_defs
1706 }
1707
1708 pub fn links_on_line(&self, line_number: usize) -> &[ParsedLink<'a>] {
1710 let start = self.links.partition_point(|link| link.line < line_number);
1711 let end = self.links.partition_point(|link| link.line <= line_number);
1712 &self.links[start..end]
1713 }
1714
1715 pub fn images_on_line(&self, line_number: usize) -> &[ParsedImage<'a>] {
1717 let start = self.images.partition_point(|image| image.line < line_number);
1718 let end = self.images.partition_point(|image| image.line <= line_number);
1719 &self.images[start..end]
1720 }
1721
1722 pub fn link_starting_at(&self, byte_offset: usize) -> Option<&ParsedLink<'a>> {
1724 self.links
1725 .binary_search_by_key(&byte_offset, |link| link.byte_offset)
1726 .ok()
1727 .map(|index| &self.links[index])
1728 }
1729
1730 pub fn image_starting_at(&self, byte_offset: usize) -> Option<&ParsedImage<'a>> {
1732 self.images
1733 .binary_search_by_key(&byte_offset, |image| image.byte_offset)
1734 .ok()
1735 .map(|index| &self.images[index])
1736 }
1737
1738 pub fn link_containing(&self, byte_offset: usize) -> Option<&ParsedLink<'a>> {
1740 let index = self.links.partition_point(|link| link.byte_offset <= byte_offset);
1741 self.links
1742 .get(index.checked_sub(1)?)
1743 .filter(|link| byte_offset < link.byte_end)
1744 }
1745
1746 pub fn image_containing(&self, byte_offset: usize) -> Option<&ParsedImage<'a>> {
1748 let index = self.images.partition_point(|image| image.byte_offset <= byte_offset);
1749 self.images
1750 .get(index.checked_sub(1)?)
1751 .filter(|image| byte_offset < image.byte_end)
1752 }
1753
1754 pub fn links_starting_before_or_at(&self, byte_offset: usize) -> &[ParsedLink<'a>] {
1756 let end = self.links.partition_point(|link| link.byte_offset <= byte_offset);
1757 &self.links[..end]
1758 }
1759
1760 pub fn reference_definition(&self, ref_id: &str) -> Option<&ReferenceDef> {
1762 let normalized_id = ref_id.to_lowercase();
1763 self.reference_defs_map
1764 .get(&normalized_id)
1765 .map(|&index| &self.reference_defs[index])
1766 }
1767
1768 pub fn get_reference_url(&self, ref_id: &str) -> Option<&str> {
1770 self.reference_definition(ref_id)
1771 .map(|definition| definition.url.as_str())
1772 }
1773
1774 pub fn is_in_list_block(&self, line_num: usize) -> bool {
1776 if line_num == 0 || line_num > self.lines.len() {
1777 return false;
1778 }
1779 self.lines[line_num - 1].in_list_block
1780 }
1781
1782 pub fn is_in_html_block(&self, line_num: usize) -> bool {
1784 if line_num == 0 || line_num > self.lines.len() {
1785 return false;
1786 }
1787 self.lines[line_num - 1].in_html_block
1788 }
1789
1790 pub fn is_in_table_block(&self, line_num: usize) -> bool {
1796 if line_num == 0 || line_num > self.lines.len() {
1797 return false;
1798 }
1799 self.lines[line_num - 1].in_table_block
1800 }
1801
1802 pub fn is_in_code_span(&self, line_num: usize, col: usize) -> bool {
1804 if line_num == 0 || line_num > self.lines.len() {
1805 return false;
1806 }
1807
1808 let col_0indexed = if col > 0 { col - 1 } else { 0 };
1812 let code_spans = self.code_spans();
1813 code_spans.iter().any(|span| {
1814 if line_num < span.line || line_num > span.end_line {
1816 return false;
1817 }
1818
1819 if span.line == span.end_line {
1820 col_0indexed >= span.start_col && col_0indexed < span.end_col
1822 } else if line_num == span.line {
1823 col_0indexed >= span.start_col
1825 } else if line_num == span.end_line {
1826 col_0indexed < span.end_col
1828 } else {
1829 true
1831 }
1832 })
1833 }
1834
1835 #[inline]
1837 pub fn is_byte_offset_in_code_span(&self, byte_offset: usize) -> bool {
1838 let code_spans = self.code_spans();
1839 let idx = code_spans.partition_point(|span| span.byte_offset <= byte_offset);
1840 idx > 0 && byte_offset < code_spans[idx - 1].byte_end
1841 }
1842
1843 #[inline]
1845 pub fn is_in_reference_def(&self, byte_pos: usize) -> bool {
1846 let idx = self.reference_defs.partition_point(|rd| rd.byte_offset <= byte_pos);
1847 idx > 0 && byte_pos < self.reference_defs[idx - 1].byte_end
1848 }
1849
1850 #[inline]
1852 pub fn is_in_html_comment(&self, byte_pos: usize) -> bool {
1853 let idx = self.html_comment_ranges.partition_point(|r| r.start <= byte_pos);
1854 idx > 0 && byte_pos < self.html_comment_ranges[idx - 1].end
1855 }
1856
1857 #[inline]
1860 pub fn is_in_html_tag(&self, byte_pos: usize) -> bool {
1861 let tags = self.html_tags();
1862 let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1863 idx > 0 && byte_pos < tags[idx - 1].byte_end
1864 }
1865
1866 #[inline]
1870 pub fn is_in_jsx_component_tag(&self, byte_pos: usize) -> bool {
1871 if !self.flavor.supports_jsx() {
1872 return false;
1873 }
1874 let tags = self.jsx_component_tags();
1875 let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1876 idx > 0 && byte_pos < tags[idx - 1].byte_end
1877 }
1878
1879 pub fn is_in_jinja_range(&self, byte_pos: usize) -> bool {
1881 Self::binary_search_ranges(&self.jinja_ranges, byte_pos)
1882 }
1883
1884 #[inline]
1886 pub fn is_in_jsx_expression(&self, byte_pos: usize) -> bool {
1887 Self::binary_search_ranges(&self.jsx_expression_ranges, byte_pos)
1888 }
1889
1890 #[inline]
1892 pub fn is_in_mdx_comment(&self, byte_pos: usize) -> bool {
1893 Self::binary_search_ranges(&self.mdx_comment_ranges, byte_pos)
1894 }
1895
1896 #[inline]
1899 pub fn is_in_citation(&self, byte_pos: usize) -> bool {
1900 let idx = self.citation_ranges.partition_point(|r| r.start <= byte_pos);
1901 idx > 0 && byte_pos < self.citation_ranges[idx - 1].end
1902 }
1903
1904 #[inline]
1906 pub fn citation_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1907 &self.citation_ranges
1908 }
1909
1910 #[inline]
1913 pub fn is_in_div_block(&self, byte_pos: usize) -> bool {
1914 let idx = self.pandoc_div_ranges.partition_point(|r| r.start <= byte_pos);
1915 idx > 0 && byte_pos < self.pandoc_div_ranges[idx - 1].end
1916 }
1917
1918 #[inline]
1921 pub fn is_in_inline_footnote(&self, byte_pos: usize) -> bool {
1922 let idx = self.inline_footnote_ranges.partition_point(|r| r.start <= byte_pos);
1923 idx > 0 && byte_pos < self.inline_footnote_ranges[idx - 1].end
1924 }
1925
1926 #[inline]
1929 pub fn is_in_example_list_marker(&self, byte_pos: usize) -> bool {
1930 let idx = self.example_list_marker_ranges.partition_point(|r| r.start <= byte_pos);
1931 idx > 0 && byte_pos < self.example_list_marker_ranges[idx - 1].end
1932 }
1933
1934 #[inline]
1937 pub fn is_in_example_reference(&self, byte_pos: usize) -> bool {
1938 let idx = self.example_reference_ranges.partition_point(|r| r.start <= byte_pos);
1939 idx > 0 && byte_pos < self.example_reference_ranges[idx - 1].end
1940 }
1941
1942 #[inline]
1945 pub fn is_in_subscript_or_superscript(&self, byte_pos: usize) -> bool {
1946 let idx = self.sub_super_ranges.partition_point(|r| r.start <= byte_pos);
1947 idx > 0 && byte_pos < self.sub_super_ranges[idx - 1].end
1948 }
1949
1950 #[inline]
1954 pub fn is_in_inline_code_attr(&self, byte_pos: usize) -> bool {
1955 let idx = self.inline_code_attr_ranges.partition_point(|r| r.start <= byte_pos);
1956 idx > 0 && byte_pos < self.inline_code_attr_ranges[idx - 1].end
1957 }
1958
1959 #[inline]
1962 pub fn is_in_bracketed_span(&self, byte_pos: usize) -> bool {
1963 let idx = self.bracketed_span_ranges.partition_point(|r| r.start <= byte_pos);
1964 idx > 0 && byte_pos < self.bracketed_span_ranges[idx - 1].end
1965 }
1966
1967 #[inline]
1970 pub fn is_in_line_block(&self, byte_pos: usize) -> bool {
1971 let idx = self.line_block_ranges.partition_point(|r| r.start <= byte_pos);
1972 idx > 0 && byte_pos < self.line_block_ranges[idx - 1].end
1973 }
1974
1975 #[inline]
1979 pub fn is_in_pipe_table_caption(&self, byte_pos: usize) -> bool {
1980 let idx = self.pipe_table_caption_ranges.partition_point(|r| r.start <= byte_pos);
1981 idx > 0 && byte_pos < self.pipe_table_caption_ranges[idx - 1].end
1982 }
1983
1984 #[inline]
1987 pub fn is_in_pandoc_metadata(&self, byte_pos: usize) -> bool {
1988 let idx = self.pandoc_metadata_ranges.partition_point(|r| r.start <= byte_pos);
1989 idx > 0 && byte_pos < self.pandoc_metadata_ranges[idx - 1].end
1990 }
1991
1992 #[inline]
1995 pub fn is_in_grid_table(&self, byte_pos: usize) -> bool {
1996 let idx = self.grid_table_ranges.partition_point(|r| r.start <= byte_pos);
1997 idx > 0 && byte_pos < self.grid_table_ranges[idx - 1].end
1998 }
1999
2000 #[inline]
2003 pub fn is_in_multi_line_table(&self, byte_pos: usize) -> bool {
2004 let idx = self.multi_line_table_ranges.partition_point(|r| r.start <= byte_pos);
2005 idx > 0 && byte_pos < self.multi_line_table_ranges[idx - 1].end
2006 }
2007
2008 pub fn matches_implicit_header_reference(&self, link_text: &str) -> bool {
2013 let slug = crate::utils::pandoc::pandoc_header_slug(link_text);
2014 self.pandoc_header_slugs.contains(&slug)
2015 }
2016
2017 #[inline]
2023 pub fn has_pandoc_slug(&self, slug: &str) -> bool {
2024 self.pandoc_header_slugs.contains(slug)
2025 }
2026
2027 #[inline]
2029 pub fn is_in_shortcode(&self, byte_pos: usize) -> bool {
2030 Self::binary_search_ranges(&self.shortcode_ranges, byte_pos)
2031 }
2032
2033 #[inline]
2035 pub fn shortcode_ranges(&self) -> &[(usize, usize)] {
2036 &self.shortcode_ranges
2037 }
2038
2039 pub fn is_in_link_title(&self, byte_pos: usize) -> bool {
2041 Self::binary_search_ranges(&self.link_title_ranges, byte_pos)
2042 }
2043
2044 pub fn has_char(&self, ch: char) -> bool {
2046 match ch {
2047 '#' => self.char_frequency.hash_count > 0,
2048 '*' => self.char_frequency.asterisk_count > 0,
2049 '_' => self.char_frequency.underscore_count > 0,
2050 '-' => self.char_frequency.hyphen_count > 0,
2051 '+' => self.char_frequency.plus_count > 0,
2052 '>' => self.char_frequency.gt_count > 0,
2053 '|' => self.char_frequency.pipe_count > 0,
2054 '[' => self.char_frequency.bracket_count > 0,
2055 '`' => self.char_frequency.backtick_count > 0,
2056 '<' => self.char_frequency.lt_count > 0,
2057 '!' => self.char_frequency.exclamation_count > 0,
2058 '\n' => self.char_frequency.newline_count > 0,
2059 _ => self.content.contains(ch), }
2061 }
2062
2063 pub fn char_count(&self, ch: char) -> usize {
2065 match ch {
2066 '#' => self.char_frequency.hash_count,
2067 '*' => self.char_frequency.asterisk_count,
2068 '_' => self.char_frequency.underscore_count,
2069 '-' => self.char_frequency.hyphen_count,
2070 '+' => self.char_frequency.plus_count,
2071 '>' => self.char_frequency.gt_count,
2072 '|' => self.char_frequency.pipe_count,
2073 '[' => self.char_frequency.bracket_count,
2074 '`' => self.char_frequency.backtick_count,
2075 '<' => self.char_frequency.lt_count,
2076 '!' => self.char_frequency.exclamation_count,
2077 '\n' => self.char_frequency.newline_count,
2078 _ => self.content.matches(ch).count(), }
2080 }
2081
2082 pub fn likely_has_headings(&self) -> bool {
2084 self.char_frequency.hash_count > 0 || self.char_frequency.hyphen_count > 2 || self.content.contains('=') }
2086
2087 pub fn likely_has_lists(&self) -> bool {
2089 self.char_frequency.asterisk_count > 0
2090 || self.char_frequency.hyphen_count > 0
2091 || self.char_frequency.plus_count > 0
2092 }
2093
2094 pub fn likely_has_emphasis(&self) -> bool {
2096 self.char_frequency.asterisk_count > 1 || self.char_frequency.underscore_count > 1
2097 }
2098
2099 pub fn likely_has_tables(&self) -> bool {
2101 self.char_frequency.pipe_count > 2
2102 }
2103
2104 pub fn likely_has_blockquotes(&self) -> bool {
2106 self.char_frequency.gt_count > 0
2107 }
2108
2109 pub fn likely_has_code(&self) -> bool {
2111 self.char_frequency.backtick_count > 0
2112 }
2113
2114 pub fn likely_has_links_or_images(&self) -> bool {
2116 self.char_frequency.bracket_count > 0 || self.char_frequency.exclamation_count > 0
2117 }
2118
2119 pub fn likely_has_html(&self) -> bool {
2121 self.char_frequency.lt_count > 0
2122 }
2123
2124 pub fn blockquote_prefix_for_blank_line(&self, line_idx: usize) -> String {
2129 if let Some(line_info) = self.lines.get(line_idx)
2130 && let Some(ref bq) = line_info.blockquote
2131 {
2132 bq.prefix.trim_end().to_string()
2133 } else {
2134 String::new()
2135 }
2136 }
2137
2138 #[inline]
2149 fn find_line_for_offset(lines: &[LineInfo], content: &str, byte_offset: usize) -> (usize, usize, usize) {
2150 let idx = match lines.binary_search_by(|line| {
2152 if byte_offset < line.byte_offset {
2153 std::cmp::Ordering::Greater
2154 } else if byte_offset > line.byte_offset + line.byte_len {
2155 std::cmp::Ordering::Less
2156 } else {
2157 std::cmp::Ordering::Equal
2158 }
2159 }) {
2160 Ok(idx) => idx,
2161 Err(idx) => idx.saturating_sub(1),
2162 };
2163
2164 let line = &lines[idx];
2165 let line_num = idx + 1;
2166 let byte_col = byte_offset.saturating_sub(line.byte_offset);
2167 let col = byte_to_char_count(line.content(content), byte_col) - 1;
2170
2171 (idx, line_num, col)
2172 }
2173
2174 #[inline]
2176 fn is_offset_in_code_span(code_spans: &[CodeSpan], offset: usize) -> bool {
2177 let idx = code_spans.partition_point(|span| span.byte_offset <= offset);
2179
2180 if idx > 0 {
2182 let span = &code_spans[idx - 1];
2183 if offset >= span.byte_offset && offset < span.byte_end {
2184 return true;
2185 }
2186 }
2187
2188 false
2189 }
2190
2191 #[must_use]
2211 pub fn valid_headings(&self) -> ValidHeadingsIter<'_> {
2212 ValidHeadingsIter::new(&self.lines)
2213 }
2214
2215 #[must_use]
2219 pub fn has_valid_headings(&self) -> bool {
2220 self.lines
2221 .iter()
2222 .any(|line| line.heading.as_ref().is_some_and(|h| h.is_valid))
2223 }
2224
2225 #[must_use]
2227 pub fn list_items(&self) -> ParsedListItemsIter<'_> {
2228 ParsedListItemsIter::new(&self.lines)
2229 }
2230
2231 #[must_use]
2233 pub fn list_item_on_line(&self, line_num: usize) -> Option<ParsedListItem<'_>> {
2234 let line_info = self.lines.get(line_num.checked_sub(1)?)?;
2235 Some(ParsedListItem::new(
2236 line_num,
2237 line_info.list_item.as_deref()?,
2238 line_info,
2239 ))
2240 }
2241
2242 #[must_use]
2244 pub fn parsed_list_blocks(&self) -> ParsedListBlocks<'_> {
2245 ParsedListBlocks::new(&self.list_blocks, &self.lines)
2246 }
2247
2248 #[must_use]
2252 pub fn list_block_item_groups(&self, block: &ListBlock) -> Vec<Vec<usize>> {
2253 list_blocks::item_lines_by_list(self.content, &self.lines, block)
2254 }
2255
2256 #[must_use]
2258 pub fn has_list_items(&self) -> bool {
2259 self.lines.iter().any(|line| line.list_item.is_some())
2260 }
2261
2262 #[must_use]
2264 pub fn has_unordered_list_items(&self) -> bool {
2265 self.lines
2266 .iter()
2267 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
2268 }
2269
2270 #[must_use]
2272 pub fn commonmark_ordered_lists(&self) -> CommonMarkOrderedLists<'_> {
2273 let lists = self
2274 .commonmark_ordered_lists_cache
2275 .get_or_init(|| build_commonmark_ordered_lists(&self.lines, &self.line_to_list, &self.list_start_values));
2276 CommonMarkOrderedLists::new(lists, &self.lines)
2277 }
2278
2279 #[must_use]
2287 pub fn headings(&self) -> ParsedHeadingsIter<'_> {
2288 ParsedHeadingsIter::new(&self.lines, &self.blockquote_headings)
2289 }
2290
2291 #[must_use]
2293 pub fn heading_on_line(&self, line_num: usize) -> Option<ParsedHeading<'_>> {
2294 let idx = line_num.checked_sub(1)?;
2295 let line_info = self.lines.get(idx)?;
2296 let (heading, blockquote_depth) = match line_info.heading.as_deref() {
2297 Some(heading) => (heading, 0),
2298 None => (
2299 self.blockquote_headings.get(idx)?.as_deref()?,
2300 line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level),
2301 ),
2302 };
2303 Some(ParsedHeading {
2304 line_num,
2305 heading,
2306 line_info,
2307 blockquote_depth,
2308 })
2309 }
2310}
2311
2312fn container_comment_range(
2324 opener: usize,
2325 containers: &flavor_detection::ContainerLines,
2326 lines: &[types::LineInfo],
2327 content: &str,
2328) -> Option<crate::utils::skip_context::ByteRange> {
2329 let line_index = lines
2330 .partition_point(|line| line.byte_offset <= opener)
2331 .checked_sub(1)?;
2332 let line = lines.get(line_index)?;
2333 if line.byte_offset + line.indent != opener {
2334 return None;
2335 }
2336 if !containers.is_container_body(line_index) {
2337 return None;
2338 }
2339 let end_line = lines.get(containers.body_end_line(line_index)?)?;
2340 Some(crate::utils::skip_context::ByteRange {
2341 start: opener,
2342 end: (end_line.byte_offset + end_line.byte_len).min(content.len()),
2343 })
2344}
2345
2346fn detect_footnote_definitions(content: &str, lines: &mut [types::LineInfo], line_offsets: &[usize]) {
2355 use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
2356
2357 let options = crate::utils::rumdl_parser_options();
2358 let parser = Parser::new_ext(content, options).into_offset_iter();
2359
2360 let mut footnote_ranges: Vec<(usize, usize)> = Vec::new();
2362 let mut fenced_code_ranges: Vec<(usize, usize)> = Vec::new();
2363 let mut in_footnote = false;
2364
2365 for (event, range) in parser {
2366 match event {
2367 Event::Start(Tag::FootnoteDefinition(_)) => {
2368 in_footnote = true;
2369 footnote_ranges.push((range.start, range.end));
2370 }
2371 Event::End(TagEnd::FootnoteDefinition) => {
2372 in_footnote = false;
2373 }
2374 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) if in_footnote => {
2375 fenced_code_ranges.push((range.start, range.end));
2376 }
2377 _ => {}
2378 }
2379 }
2380
2381 let byte_to_line = |byte_offset: usize| -> usize {
2382 line_offsets
2383 .partition_point(|&offset| offset <= byte_offset)
2384 .saturating_sub(1)
2385 };
2386
2387 for &(start, end) in &footnote_ranges {
2389 let start_line = byte_to_line(start);
2390 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
2391
2392 for line in &mut lines[start_line..end_line] {
2393 line.in_footnote_definition = true;
2394 line.in_code_block = false;
2395 }
2396 }
2397
2398 for &(start, end) in &fenced_code_ranges {
2400 let start_line = byte_to_line(start);
2401 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
2402
2403 for line in &mut lines[start_line..end_line] {
2404 line.in_code_block = true;
2405 }
2406 }
2407}