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