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;
12mod mdx;
13#[cfg(test)]
14mod tests;
15
16use crate::config::MarkdownFlavor;
17use crate::inline_config::InlineConfig;
18use crate::rules::front_matter_utils::FrontMatterUtils;
19use crate::utils::code_block_utils::{CodeBlockDetail, CodeBlockUtils};
20use crate::utils::range_utils::byte_to_char_count;
21use std::collections::HashMap;
22use std::ops::Range;
23use std::path::{Path, PathBuf};
24
25#[derive(Debug, Clone)]
28pub struct LinkTargetPolicy {
29 supplied_paths: Arc<std::collections::HashSet<PathBuf>>,
30 allow_disk_fallback: bool,
31}
32
33impl LinkTargetPolicy {
34 pub fn open_world<I, P>(paths: I) -> Self
37 where
38 I: IntoIterator<Item = P>,
39 P: AsRef<Path>,
40 {
41 Self::from_paths(paths, true)
42 }
43
44 pub fn closed_world<I, P>(paths: I) -> Self
46 where
47 I: IntoIterator<Item = P>,
48 P: AsRef<Path>,
49 {
50 Self::from_paths(paths, false)
51 }
52
53 fn from_paths<I, P>(paths: I, allow_disk_fallback: bool) -> Self
54 where
55 I: IntoIterator<Item = P>,
56 P: AsRef<Path>,
57 {
58 let mut roots = Vec::new();
59 if let Ok(cwd) = std::env::current_dir() {
60 if let Ok(canonical_cwd) = cwd.canonicalize()
61 && canonical_cwd != cwd
62 {
63 roots.push(canonical_cwd);
64 }
65 roots.push(cwd);
66 }
67 Self::from_paths_with_roots(paths, allow_disk_fallback, roots)
68 }
69
70 fn from_paths_with_roots<I, P, R, Q>(paths: I, allow_disk_fallback: bool, roots: R) -> Self
71 where
72 I: IntoIterator<Item = P>,
73 P: AsRef<Path>,
74 R: IntoIterator<Item = Q>,
75 Q: AsRef<Path>,
76 {
77 let roots: Vec<PathBuf> = roots
78 .into_iter()
79 .map(|root| crate::workspace_index::normalize_relative_path(root.as_ref()))
80 .collect();
81 let mut supplied_paths = std::collections::HashSet::new();
82 for path in paths {
83 let path = path.as_ref();
84 supplied_paths.insert(crate::workspace_index::normalize_relative_path(path));
85
86 if path.is_relative() {
87 for root in &roots {
88 supplied_paths.insert(crate::workspace_index::normalize_relative_path(&root.join(path)));
89 }
90 } else {
91 for source_root in &roots {
92 if let Ok(relative) = path.strip_prefix(source_root) {
93 for root in &roots {
94 supplied_paths
95 .insert(crate::workspace_index::normalize_relative_path(&root.join(relative)));
96 }
97 }
98 }
99 }
100 }
101 Self {
102 supplied_paths: Arc::new(supplied_paths),
103 allow_disk_fallback,
104 }
105 }
106
107 pub fn contains(&self, path: &Path) -> bool {
108 self.supplied_paths
109 .contains(&crate::workspace_index::normalize_relative_path(path))
110 }
111
112 pub(crate) fn resolve_supplied(&self, path: &Path) -> Option<PathBuf> {
113 let normalized = crate::workspace_index::normalize_relative_path(path);
114 if self.supplied_paths.contains(&normalized) {
115 return Some(normalized);
116 }
117
118 if path.extension().is_none() {
119 for extension in ["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"] {
120 let candidate = crate::workspace_index::normalize_relative_path(&path.with_extension(extension));
121 if self.supplied_paths.contains(&candidate) {
122 return Some(candidate);
123 }
124 }
125 }
126
127 None
128 }
129
130 pub fn contains_with_markdown_extension(&self, path: &Path) -> bool {
131 self.resolve_supplied(path).is_some()
132 }
133
134 pub fn allow_disk_fallback(&self) -> bool {
135 self.allow_disk_fallback
136 }
137}
138
139#[cfg(not(target_arch = "wasm32"))]
141macro_rules! profile_section {
142 ($name:expr, $profile:expr, $code:expr) => {{
143 let start = std::time::Instant::now();
144 let result = $code;
145 if $profile {
146 eprintln!("[PROFILE] {}: {:?}", $name, start.elapsed());
147 }
148 result
149 }};
150}
151
152fn build_commonmark_ordered_lists(
153 lines: &[LineInfo],
154 line_to_list: &crate::utils::code_block_utils::LineToListMap,
155 list_start_values: &crate::utils::code_block_utils::ListStartValues,
156) -> Vec<CommonMarkOrderedListInfo> {
157 let mut grouped_lines: HashMap<usize, Vec<usize>> = HashMap::new();
158
159 for (&line_num, &list_id) in line_to_list {
160 let is_ordered_item = line_num
161 .checked_sub(1)
162 .and_then(|index| lines.get(index))
163 .and_then(|line| line.list_item.as_deref())
164 .is_some_and(|item| item.is_ordered);
165 if is_ordered_item {
166 grouped_lines.entry(list_id).or_default().push(line_num);
167 }
168 }
169
170 let mut lists: Vec<_> = grouped_lines
171 .into_iter()
172 .map(|(list_id, mut item_lines)| {
173 item_lines.sort_unstable();
174 CommonMarkOrderedListInfo {
175 start_value: list_start_values.get(&list_id).copied().unwrap_or(1),
176 item_lines,
177 }
178 })
179 .collect();
180 lists.sort_by_key(|list| list.item_lines.first().copied().unwrap_or(0));
181 lists
182}
183
184#[cfg(target_arch = "wasm32")]
185macro_rules! profile_section {
186 ($name:expr, $profile:expr, $code:expr) => {{ $code }};
187}
188
189pub(super) struct SkipByteRanges<'a> {
192 pub(super) html_comment_ranges: &'a [crate::utils::skip_context::ByteRange],
193 pub(super) autodoc_ranges: &'a [crate::utils::skip_context::ByteRange],
194 pub(super) pandoc_div_ranges: &'a [crate::utils::skip_context::ByteRange],
195 pub(super) pymdown_block_ranges: &'a [crate::utils::skip_context::ByteRange],
196}
197
198use std::sync::{Arc, OnceLock};
199
200pub(super) type ListItemMap = std::collections::HashMap<usize, (bool, String, usize, usize, Option<usize>)>;
202
203pub(super) type ByteRanges = Vec<(usize, usize)>;
205
206pub struct LintContext<'a> {
207 pub content: &'a str,
208 content_lines: Vec<&'a str>, pub line_offsets: Vec<usize>,
210 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, }
271
272pub struct CodeRanges {
274 pub blocks: Vec<(usize, usize)>,
276 pub spans: Vec<(usize, usize)>,
278}
279
280pub fn code_ranges(content: &str, flavor: MarkdownFlavor) -> CodeRanges {
291 let ctx = LintContext::new(content, flavor, None);
292 CodeRanges {
293 spans: code_span_byte_ranges(&ctx.code_spans()),
294 blocks: ctx.code_blocks,
295 }
296}
297
298pub fn code_span_byte_ranges(code_spans: &[CodeSpan]) -> Vec<(usize, usize)> {
300 code_spans
301 .iter()
302 .map(|span| (span.byte_offset, span.byte_end))
303 .collect()
304}
305
306impl<'a> LintContext<'a> {
307 pub fn source_file(&self) -> Option<&Path> {
312 self.source_file.as_deref()
313 }
314
315 pub fn link_target_policy(&self) -> Option<&LinkTargetPolicy> {
316 self.link_target_policy.as_ref()
317 }
318
319 pub fn with_link_target_policy(mut self, policy: LinkTargetPolicy) -> Self {
320 self.link_target_policy = Some(policy);
321 self
322 }
323
324 pub fn new(content: &'a str, flavor: MarkdownFlavor, source_file: Option<PathBuf>) -> Self {
325 #[cfg(not(target_arch = "wasm32"))]
326 let profile = std::env::var("RUMDL_PROFILE_QUADRATIC").is_ok();
327
328 let line_offsets = profile_section!("Line offsets", profile, {
329 let mut offsets = vec![0];
330 for (i, c) in content.char_indices() {
331 if c == '\n' {
332 offsets.push(i + 1);
333 }
334 }
335 offsets
336 });
337
338 let content_lines: Vec<&str> = content.lines().collect();
340
341 #[allow(clippy::disallowed_methods)]
345 let front_matter_end = FrontMatterUtils::get_front_matter_end_line(content);
346
347 let parse_result = profile_section!(
349 "Code blocks",
350 profile,
351 CodeBlockUtils::detect_code_blocks_and_spans(content)
352 );
353 let mut code_blocks = parse_result.code_blocks;
354 let mut code_span_ranges = parse_result.code_spans;
355 let code_block_details = parse_result.code_block_details;
356 let strong_spans = parse_result.strong_spans;
357 let line_to_list = parse_result.line_to_list;
358 let list_start_values = parse_result.list_start_values;
359 let html_blocks = parse_result.html_blocks;
360
361 let containers = profile_section!(
364 "Container lines",
365 profile,
366 flavor_detection::detect_container_lines(&content_lines, flavor)
367 );
368
369 let comment_code_block_ranges: Vec<(usize, usize)> = code_block_details
378 .iter()
379 .flat_map(|detail| {
380 if detail.is_fenced {
381 return vec![(detail.start, detail.end)];
382 }
383 let start_line = line_offsets
384 .partition_point(|&offset| offset <= detail.start)
385 .saturating_sub(1);
386 let end_line = line_offsets.partition_point(|&offset| offset < detail.end);
387 containers
388 .code_line_spans_in(start_line..end_line)
389 .into_iter()
390 .map(|span| {
391 let start = line_offsets[span.start].max(detail.start);
392 let end = line_offsets
393 .get(span.end)
394 .copied()
395 .unwrap_or(content.len())
396 .min(detail.end);
397 (start, end)
398 })
399 .collect()
400 })
401 .collect();
402 let body_start = line_offsets.get(front_matter_end).copied().unwrap_or(content.len());
408 let html_comment_scan = profile_section!(
409 "HTML comment ranges",
410 profile,
411 crate::utils::skip_context::scan_html_comments(
412 content,
413 &code_span_ranges,
414 &comment_code_block_ranges,
415 body_start
416 )
417 );
418 let mut html_comment_ranges = html_comment_scan.ranges;
419 let unterminated_html_comment = html_comment_scan.unterminated;
420
421 let autodoc_ranges = profile_section!("Autodoc block ranges", profile, {
425 if flavor.supports_colon_code_fences() || flavor.supports_myst_directives() {
426 Vec::new()
427 } else {
428 crate::utils::mkdocstrings_refs::detect_autodoc_block_ranges(content)
429 }
430 });
431
432 let pandoc_div_ranges = profile_section!("Pandoc div ranges", profile, {
434 if flavor.is_pandoc_compatible() {
435 crate::utils::pandoc::detect_div_block_ranges(content)
436 } else {
437 Vec::new()
438 }
439 });
440
441 let pymdown_block_ranges = profile_section!("PyMdown block ranges", profile, {
443 if flavor == MarkdownFlavor::MkDocs {
444 crate::utils::pymdown_blocks::detect_block_ranges(content)
445 } else {
446 Vec::new()
447 }
448 });
449
450 let skip_ranges = SkipByteRanges {
453 html_comment_ranges: &html_comment_ranges,
454 autodoc_ranges: &autodoc_ranges,
455 pandoc_div_ranges: &pandoc_div_ranges,
456 pymdown_block_ranges: &pymdown_block_ranges,
457 };
458 let (mut lines, emphasis_spans) = profile_section!(
459 "Basic line info",
460 profile,
461 line_computation::compute_basic_line_info(
462 content,
463 &content_lines,
464 &line_offsets,
465 &code_blocks,
466 flavor,
467 &skip_ranges,
468 front_matter_end,
469 )
470 );
471
472 profile_section!(
474 "HTML blocks",
475 profile,
476 heading_detection::detect_html_blocks(content, &mut lines)
477 );
478
479 profile_section!(
481 "ESM blocks",
482 profile,
483 flavor_detection::detect_esm_blocks(content, &mut lines, flavor)
484 );
485
486 profile_section!(
488 "JSX block detection",
489 profile,
490 flavor_detection::detect_jsx_blocks(content, &mut lines, flavor)
491 );
492
493 let (jsx_expression_ranges, mdx_comment_ranges) = profile_section!(
495 "JSX/MDX detection",
496 profile,
497 flavor_detection::detect_jsx_and_mdx_comments(content, &mut lines, flavor, &code_blocks)
498 );
499
500 profile_section!(
505 "Markdown-in-HTML blocks",
506 profile,
507 flavor_detection::detect_markdown_html_blocks(&mut lines, &containers)
508 );
509
510 let mdx_context = if flavor == MarkdownFlavor::MDX {
511 mdx::MdxContext::parse(content, &lines)
512 } else {
513 None
514 };
515 let (jsx_expression_ranges, mdx_comment_ranges) = if let Some(mdx) = &mdx_context {
516 mdx.apply_lines(&mut lines);
517 code_blocks.clone_from(&mdx.code_blocks);
518 code_span_ranges.clone_from(&mdx.code_spans);
519 (mdx.expressions.clone(), mdx.comments.clone())
520 } else {
521 (jsx_expression_ranges, mdx_comment_ranges)
522 };
523
524 profile_section!(
526 "MkDocs constructs",
527 profile,
528 flavor_detection::detect_mkdocs_line_info(&content_lines, &mut lines, flavor, &containers)
529 );
530
531 profile_section!(
536 "Footnote definitions",
537 profile,
538 detect_footnote_definitions(content, &mut lines, &line_offsets)
539 );
540
541 {
544 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
545 for &(start, end) in &code_blocks {
546 let start_line = line_offsets
547 .partition_point(|&offset| offset <= start)
548 .saturating_sub(1);
549 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
550
551 let mut sub_start: Option<usize> = None;
552 for (i, &offset) in line_offsets[start_line..end_line]
553 .iter()
554 .enumerate()
555 .map(|(j, o)| (j + start_line, o))
556 {
557 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
558 if is_real_code && sub_start.is_none() {
559 let byte_start = if i == start_line { start } else { offset };
560 sub_start = Some(byte_start);
561 } else if !is_real_code && sub_start.is_some() {
562 new_code_blocks.push((sub_start.unwrap(), offset));
563 sub_start = None;
564 }
565 }
566 if let Some(s) = sub_start {
567 new_code_blocks.push((s, end));
568 }
569 }
570 code_blocks = new_code_blocks;
571 }
572
573 let has_markdown_html = lines.iter().any(|l| l.in_mkdocs_html_markdown);
581 if flavor == MarkdownFlavor::MkDocs || has_markdown_html {
582 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
583 for &(start, end) in &code_blocks {
584 let start_line = line_offsets
585 .partition_point(|&offset| offset <= start)
586 .saturating_sub(1);
587 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
588
589 let mut sub_start: Option<usize> = None;
591 for (i, &offset) in line_offsets[start_line..end_line]
592 .iter()
593 .enumerate()
594 .map(|(j, o)| (j + start_line, o))
595 {
596 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
597 if is_real_code && sub_start.is_none() {
598 let byte_start = if i == start_line { start } else { offset };
599 sub_start = Some(byte_start);
600 } else if !is_real_code && sub_start.is_some() {
601 new_code_blocks.push((sub_start.unwrap(), offset));
602 sub_start = None;
603 }
604 }
605 if let Some(s) = sub_start {
606 new_code_blocks.push((s, end));
607 }
608 }
609 code_blocks = new_code_blocks;
610 }
611
612 if flavor.supports_jsx() {
616 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
617 for &(start, end) in &code_blocks {
618 let start_line = line_offsets
619 .partition_point(|&offset| offset <= start)
620 .saturating_sub(1);
621 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
622
623 let mut sub_start: Option<usize> = None;
624 for (i, &offset) in line_offsets[start_line..end_line]
625 .iter()
626 .enumerate()
627 .map(|(j, o)| (j + start_line, o))
628 {
629 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
630 if is_real_code && sub_start.is_none() {
631 let byte_start = if i == start_line { start } else { offset };
632 sub_start = Some(byte_start);
633 } else if !is_real_code && sub_start.is_some() {
634 new_code_blocks.push((sub_start.unwrap(), offset));
635 sub_start = None;
636 }
637 }
638 if let Some(s) = sub_start {
639 new_code_blocks.push((s, end));
640 }
641 }
642 code_blocks = new_code_blocks;
643
644 let mut jsx_fence_ranges: Vec<(usize, usize)> = Vec::new();
651 let mut run: Option<(usize, usize)> = None;
652 for line in &lines {
653 if line.in_jsx_block && line.in_code_block {
654 let line_end = line.byte_offset + line.byte_len;
655 match &mut run {
656 Some((_, end)) => *end = line_end,
657 None => run = Some((line.byte_offset, line_end)),
658 }
659 } else if let Some(r) = run.take() {
660 jsx_fence_ranges.push(r);
661 }
662 }
663 if let Some(r) = run.take() {
664 jsx_fence_ranges.push(r);
665 }
666 if !jsx_fence_ranges.is_empty() {
667 code_blocks.extend(jsx_fence_ranges);
668 code_blocks.sort_by_key(|&(start, _)| start);
669 }
670 }
671
672 let colon_fence_details = profile_section!(
675 "Azure colon fence detection",
676 profile,
677 flavor_detection::detect_azure_colon_fences(content, &mut lines, flavor)
678 );
679 if !colon_fence_details.is_empty() {
680 code_blocks.extend(colon_fence_details.iter().map(|fence| (fence.start, fence.end)));
681 code_blocks.sort_by_key(|&(start, _)| start);
682 }
683
684 let myst_directive_ranges = profile_section!(
687 "MyST colon directives",
688 profile,
689 flavor_detection::detect_myst_colon_directives(content, &mut lines, flavor)
690 );
691
692 let myst_comment_ranges = profile_section!(
694 "MyST comments",
695 profile,
696 flavor_detection::detect_myst_comments(content, &mut lines, flavor)
697 );
698
699 profile_section!(
702 "MyST backtick directives",
703 profile,
704 flavor_detection::detect_myst_backtick_directives(
705 content,
706 &mut lines,
707 flavor,
708 &code_block_details,
709 &line_offsets
710 )
711 );
712
713 if flavor.supports_myst_directives() {
716 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
717 for &(start, end) in &code_blocks {
718 let start_line = line_offsets
719 .partition_point(|&offset| offset <= start)
720 .saturating_sub(1);
721 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
722
723 let mut sub_start: Option<usize> = None;
724 for (i, &offset) in line_offsets[start_line..end_line]
725 .iter()
726 .enumerate()
727 .map(|(j, o)| (j + start_line, o))
728 {
729 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
730 if is_real_code && sub_start.is_none() {
731 let byte_start = if i == start_line { start } else { offset };
732 sub_start = Some(byte_start);
733 } else if !is_real_code && sub_start.is_some() {
734 new_code_blocks.push((sub_start.unwrap(), offset));
735 sub_start = None;
736 }
737 }
738 if let Some(s) = sub_start {
739 new_code_blocks.push((s, end));
740 }
741 }
742 code_blocks = new_code_blocks;
743 }
744
745 profile_section!(
747 "Kramdown constructs",
748 profile,
749 flavor_detection::detect_kramdown_line_info(content, &mut lines, flavor)
750 );
751
752 for line in &mut lines {
757 if line.in_kramdown_extension_block {
758 line.list_item = None;
759 line.is_horizontal_rule = false;
760 line.blockquote = None;
761 line.is_kramdown_block_ial = false;
762 }
763 }
764
765 let obsidian_comment_scan = profile_section!(
767 "Obsidian comments",
768 profile,
769 flavor_detection::detect_obsidian_comments(
770 content,
771 &mut lines,
772 flavor,
773 &code_span_ranges,
774 &html_comment_ranges,
775 body_start
776 )
777 );
778 let mut obsidian_comment_ranges = obsidian_comment_scan.ranges;
779 let mut unterminated_obsidian_comment = obsidian_comment_scan.unterminated;
780
781 let unterminated_html_comment = crate::utils::skip_context::unterminated_html_comment_outside(
786 unterminated_html_comment,
787 &obsidian_comment_ranges,
788 content,
789 &code_span_ranges,
790 &comment_code_block_ranges,
791 body_start,
792 );
793
794 if let Some(range) = unterminated_html_comment.and_then(|opener| {
807 crate::utils::skip_context::unterminated_comment_range(opener, &html_blocks)
808 .or_else(|| container_comment_range(opener, &containers, &lines, content))
809 }) {
810 html_comment_ranges.push(range);
813
814 for line in &mut lines {
820 let text = line.content(content);
821 let content_start = line.byte_offset + line.indent;
822 let content_end = line.byte_offset + text.trim_end().len();
823 line.in_html_comment = crate::utils::skip_context::is_line_entirely_in_html_comment(
824 &html_comment_ranges,
825 content_start,
826 content_end,
827 );
828 line.in_obsidian_comment = false;
829 }
830
831 let obsidian_rescan = flavor_detection::detect_obsidian_comments(
842 content,
843 &mut lines,
844 flavor,
845 &code_span_ranges,
846 &html_comment_ranges,
847 body_start,
848 );
849 obsidian_comment_ranges = obsidian_rescan.ranges;
850 unterminated_obsidian_comment = obsidian_rescan.unterminated;
851 }
852
853 let myst_role_ranges = profile_section!(
855 "MyST roles",
856 profile,
857 flavor_detection::detect_myst_role_ranges(content, &lines, flavor, &code_blocks)
858 );
859
860 let mut pulldown_result = profile_section!(
864 "Links, images & link ranges",
865 profile,
866 link_parser::parse_links_images_pulldown(content, &lines, &code_blocks, flavor, &html_comment_ranges)
867 );
868
869 if let Some(mdx) = &mdx_context {
870 let (links, images) = mdx.links_and_images(content, &lines);
871 pulldown_result.link_byte_ranges = links.iter().map(|link| (link.byte_offset, link.byte_end)).collect();
872 pulldown_result.link_found_positions = links.iter().map(|link| link.byte_offset).collect();
873 pulldown_result.image_found_positions = images.iter().map(|image| image.byte_offset).collect();
874 pulldown_result.links = links;
875 pulldown_result.images = images;
876 pulldown_result.footnote_refs = mdx.footnote_refs();
877 pulldown_result
878 .broken_links
879 .retain(|link| mdx.contains_text(link.span.start, link.span.end));
880 }
881
882 let mut blockquote_headings = profile_section!(
884 "Headings & blockquotes",
885 profile,
886 heading_detection::detect_headings_and_blockquotes(
887 &content_lines,
888 &mut lines,
889 flavor,
890 &html_comment_ranges,
891 &pulldown_result.link_byte_ranges,
892 front_matter_end,
893 )
894 );
895
896 for line in &mut lines {
898 if line.in_kramdown_extension_block {
899 line.heading = None;
900 }
901 }
902 for (line, heading) in lines.iter().zip(&mut blockquote_headings) {
903 if line.in_kramdown_extension_block {
904 *heading = None;
905 }
906 }
907
908 for line in &mut lines {
919 if line.is_horizontal_rule
920 && (line.in_code_block
921 || line.in_html_block
922 || line.in_html_comment
923 || line.in_math_block
924 || line.in_mdx_comment
925 || line.in_obsidian_comment)
926 {
927 line.is_horizontal_rule = false;
928 }
929 }
930
931 let mut code_spans = profile_section!(
933 "Code spans",
934 profile,
935 element_parsers::build_code_spans_from_ranges(content, &lines, &code_span_ranges)
936 );
937
938 if flavor == MarkdownFlavor::MkDocs {
942 let extra = profile_section!(
943 "MkDocs code spans",
944 profile,
945 element_parsers::scan_mkdocs_container_code_spans(content, &lines, &code_span_ranges,)
946 );
947 if !extra.is_empty() {
948 code_spans.extend(extra);
949 code_spans.sort_by_key(|span| span.byte_offset);
950 }
951 }
952
953 if flavor == MarkdownFlavor::MDX && mdx_context.is_none() {
958 let extra = profile_section!(
959 "MDX JSX code spans",
960 profile,
961 element_parsers::scan_jsx_block_code_spans(content, &lines, &code_span_ranges)
962 );
963 if !extra.is_empty() {
964 code_spans.extend(extra);
965 code_spans.sort_by_key(|span| span.byte_offset);
966 }
967 }
968
969 for span in &code_spans {
972 if span.end_line > span.line {
973 for line_num in (span.line + 1)..=span.end_line {
975 if let Some(line_info) = lines.get_mut(line_num - 1) {
976 line_info.in_code_span_continuation = true;
977 }
978 }
979 }
980 }
981
982 let (links, images, broken_links, footnote_refs) = profile_section!(
984 "Links & images finalize",
985 profile,
986 link_parser::finalize_links_and_images(
987 content,
988 &lines,
989 flavor,
990 &link_parser::LinkExclusions {
991 code_blocks: &code_blocks,
992 code_spans: &code_spans,
993 html_comment_ranges: &html_comment_ranges,
994 mdx: mdx_context.as_ref(),
995 },
996 pulldown_result,
997 )
998 );
999
1000 let reference_defs = profile_section!("Reference defs", profile, {
1001 if let Some(mdx) = &mdx_context {
1002 mdx.reference_defs(content)
1003 } else {
1004 link_parser::parse_reference_defs(content, &lines)
1005 }
1006 });
1007
1008 let list_blocks = profile_section!("List blocks", profile, list_blocks::parse_list_blocks(content, &lines));
1009
1010 let char_frequency = profile_section!(
1012 "Char frequency",
1013 profile,
1014 line_computation::compute_char_frequency(content)
1015 );
1016
1017 let table_blocks = profile_section!(
1019 "Table blocks",
1020 profile,
1021 crate::utils::table_utils::TableUtils::find_table_blocks_with_code_info(
1022 content,
1023 &code_blocks,
1024 &code_spans,
1025 &html_comment_ranges,
1026 flavor,
1027 )
1028 );
1029
1030 let links = links
1033 .into_iter()
1034 .filter(|link| !lines.get(link.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1035 .collect::<Vec<_>>();
1036 let images = images
1037 .into_iter()
1038 .filter(|img| !lines.get(img.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1039 .collect::<Vec<_>>();
1040 let broken_links = broken_links
1041 .into_iter()
1042 .filter(|bl| {
1043 let line_idx = line_offsets
1045 .partition_point(|&offset| offset <= bl.span.start)
1046 .saturating_sub(1);
1047 !lines.get(line_idx).is_some_and(|l| l.in_kramdown_extension_block)
1048 })
1049 .collect::<Vec<_>>();
1050 let footnote_refs = footnote_refs
1051 .into_iter()
1052 .filter(|fr| !lines.get(fr.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1053 .collect::<Vec<_>>();
1054 let reference_defs = reference_defs
1055 .into_iter()
1056 .filter(|def| !lines.get(def.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1057 .collect::<Vec<_>>();
1058 let list_blocks = list_blocks
1059 .into_iter()
1060 .filter(|block| {
1061 !lines
1062 .get(block.start_line - 1)
1063 .is_some_and(|l| l.in_kramdown_extension_block)
1064 })
1065 .collect::<Vec<_>>();
1066 let table_blocks = table_blocks
1067 .into_iter()
1068 .filter(|block| {
1069 !lines
1071 .get(block.start_line)
1072 .is_some_and(|l| l.in_kramdown_extension_block)
1073 })
1074 .collect::<Vec<_>>();
1075 let emphasis_spans = emphasis_spans
1076 .into_iter()
1077 .filter(|span| !lines.get(span.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1078 .collect::<Vec<_>>();
1079
1080 for block in &list_blocks {
1084 for line_num in block.start_line..=block.end_line {
1086 if let Some(li) = lines.get_mut(line_num - 1) {
1087 li.in_list_block = true;
1088 }
1089 }
1090 }
1091 for block in &table_blocks {
1092 for idx in block.start_line..=block.end_line {
1094 if let Some(li) = lines.get_mut(idx) {
1095 li.in_table_block = true;
1096 }
1097 }
1098 }
1099
1100 let reference_defs_map: HashMap<String, usize> = reference_defs
1102 .iter()
1103 .enumerate()
1104 .map(|(idx, def)| (def.id.to_lowercase(), idx))
1105 .collect();
1106
1107 let link_title_ranges: Vec<(usize, usize)> = reference_defs
1109 .iter()
1110 .filter_map(|def| match (def.title_byte_start, def.title_byte_end) {
1111 (Some(start), Some(end)) => Some((start, end)),
1112 _ => None,
1113 })
1114 .collect();
1115
1116 let line_index = profile_section!(
1118 "Line index",
1119 profile,
1120 crate::utils::range_utils::LineIndex::with_line_starts_and_code_blocks(
1121 content,
1122 line_offsets.clone(),
1123 &code_blocks,
1124 )
1125 );
1126
1127 let jinja_ranges = profile_section!(
1129 "Jinja ranges",
1130 profile,
1131 crate::utils::jinja_utils::find_jinja_ranges(content)
1132 );
1133
1134 let citation_ranges = profile_section!("Citation ranges", profile, {
1136 if flavor.is_pandoc_compatible() {
1137 crate::utils::pandoc::find_citation_ranges(content)
1138 } else {
1139 Vec::new()
1140 }
1141 });
1142
1143 let inline_footnote_ranges = profile_section!("Inline footnote ranges", profile, {
1145 if flavor.is_pandoc_compatible() {
1146 crate::utils::pandoc::detect_inline_footnote_ranges(content)
1147 } else {
1148 Vec::new()
1149 }
1150 });
1151
1152 let pandoc_header_slugs = profile_section!("Pandoc header slugs", profile, {
1154 if flavor.is_pandoc_compatible() {
1155 crate::utils::pandoc::collect_pandoc_header_slugs(content)
1156 } else {
1157 std::collections::HashSet::new()
1158 }
1159 });
1160
1161 let example_list_marker_ranges = profile_section!("Example list markers", profile, {
1163 if flavor.is_pandoc_compatible() {
1164 crate::utils::pandoc::detect_example_list_marker_ranges(content)
1165 } else {
1166 Vec::new()
1167 }
1168 });
1169
1170 let example_reference_ranges = profile_section!("Example references", profile, {
1172 if flavor.is_pandoc_compatible() {
1173 crate::utils::pandoc::detect_example_reference_ranges(content, &example_list_marker_ranges)
1174 } else {
1175 Vec::new()
1176 }
1177 });
1178
1179 let sub_super_ranges = profile_section!("Subscript/superscript ranges", profile, {
1181 if flavor.is_pandoc_compatible() {
1182 crate::utils::pandoc::detect_subscript_superscript_ranges(content)
1183 } else {
1184 Vec::new()
1185 }
1186 });
1187
1188 let inline_code_attr_ranges = profile_section!("Inline code attribute ranges", profile, {
1190 if flavor.is_pandoc_compatible() {
1191 crate::utils::pandoc::detect_inline_code_attr_ranges(content)
1192 } else {
1193 Vec::new()
1194 }
1195 });
1196
1197 let bracketed_span_ranges = profile_section!("Bracketed span ranges", profile, {
1199 if flavor.is_pandoc_compatible() {
1200 crate::utils::pandoc::detect_bracketed_span_ranges(content)
1201 } else {
1202 Vec::new()
1203 }
1204 });
1205
1206 let line_block_ranges = profile_section!("Line block ranges", profile, {
1208 if flavor.is_pandoc_compatible() {
1209 crate::utils::pandoc::detect_line_block_ranges(content)
1210 } else {
1211 Vec::new()
1212 }
1213 });
1214
1215 let pipe_table_caption_ranges = profile_section!("Pipe-table caption ranges", profile, {
1217 if flavor.is_pandoc_compatible() {
1218 crate::utils::pandoc::detect_pipe_table_caption_ranges(content)
1219 } else {
1220 Vec::new()
1221 }
1222 });
1223
1224 let pandoc_metadata_ranges = profile_section!("Pandoc metadata ranges", profile, {
1226 if flavor.is_pandoc_compatible() {
1227 crate::utils::pandoc::detect_yaml_metadata_block_ranges(content)
1228 } else {
1229 Vec::new()
1230 }
1231 });
1232
1233 let grid_table_ranges = profile_section!("Grid table ranges", profile, {
1235 if flavor.is_pandoc_compatible() {
1236 crate::utils::pandoc::detect_grid_table_ranges(content)
1237 } else {
1238 Vec::new()
1239 }
1240 });
1241
1242 let multi_line_table_ranges = profile_section!("Multi-line table ranges", profile, {
1244 if flavor.is_pandoc_compatible() {
1245 crate::utils::pandoc::detect_multi_line_table_ranges(content)
1246 } else {
1247 Vec::new()
1248 }
1249 });
1250
1251 let shortcode_ranges = profile_section!("Shortcode ranges", profile, {
1253 use crate::utils::regex_cache::HUGO_SHORTCODE_REGEX;
1254 let mut ranges = Vec::new();
1255 for mat in HUGO_SHORTCODE_REGEX.find_iter(content) {
1256 ranges.push((mat.start(), mat.end()));
1257 }
1258 ranges
1259 });
1260
1261 let inline_config =
1262 InlineConfig::from_content_with_code_blocks(content, &code_blocks, &code_span_byte_ranges(&code_spans));
1263 Self {
1264 content,
1265 content_lines,
1266 line_offsets,
1267 code_blocks,
1268 code_block_details,
1269 strong_spans,
1270 line_to_list,
1271 list_start_values,
1272 commonmark_ordered_lists_cache: OnceLock::new(),
1273 lines,
1274 blockquote_headings,
1275 links,
1276 images,
1277 broken_links,
1278 footnote_refs,
1279 reference_defs,
1280 reference_defs_map,
1281 code_spans_cache: OnceLock::from(Arc::new(code_spans)),
1282 math_spans_cache: OnceLock::new(), math_byte_ranges_cache: OnceLock::new(), list_blocks,
1285 char_frequency,
1286 html_tags_cache: OnceLock::new(),
1287 jsx_component_tags_cache: OnceLock::new(),
1288 emphasis_spans_cache: OnceLock::from(Arc::new(emphasis_spans)),
1289 bare_urls_cache: OnceLock::new(),
1290 has_mixed_list_nesting_cache: OnceLock::new(),
1291 html_comment_ranges,
1292 table_blocks,
1293 line_index,
1294 jinja_ranges,
1295 flavor,
1296 source_file,
1297 link_target_policy: None,
1298 jsx_expression_ranges,
1299 mdx_comment_ranges,
1300 citation_ranges,
1301 pandoc_div_ranges,
1302 colon_fence_details,
1303 inline_footnote_ranges,
1304 pandoc_header_slugs,
1305 example_list_marker_ranges,
1306 example_reference_ranges,
1307 sub_super_ranges,
1308 inline_code_attr_ranges,
1309 bracketed_span_ranges,
1310 line_block_ranges,
1311 pipe_table_caption_ranges,
1312 pandoc_metadata_ranges,
1313 grid_table_ranges,
1314 multi_line_table_ranges,
1315 shortcode_ranges,
1316 link_title_ranges,
1317 code_span_byte_ranges: code_span_ranges,
1318 inline_config,
1319 obsidian_comment_ranges,
1320 unterminated_html_comment,
1321 unterminated_obsidian_comment,
1322 lazy_cont_lines_cache: OnceLock::new(),
1323 myst_directive_ranges,
1324 myst_comment_ranges,
1325 myst_role_ranges,
1326 front_matter_end,
1327 }
1328 }
1329
1330 pub fn front_matter_end_line(&self) -> usize {
1335 self.front_matter_end
1336 }
1337
1338 #[inline]
1341 fn binary_search_ranges(ranges: &[(usize, usize)], pos: usize) -> bool {
1342 let idx = ranges.partition_point(|&(start, _)| start <= pos);
1344 idx > 0 && pos < ranges[idx - 1].1
1346 }
1347
1348 pub fn is_in_code_span_byte(&self, pos: usize) -> bool {
1350 Self::binary_search_ranges(&self.code_span_byte_ranges, pos)
1351 }
1352
1353 pub fn is_in_link(&self, pos: usize) -> bool {
1355 self.link_containing(pos).is_some() || self.image_containing(pos).is_some() || self.is_in_reference_def(pos)
1356 }
1357
1358 pub fn is_in_bare_url(&self, pos: usize) -> bool {
1360 let bare_urls = self.bare_urls();
1361 let idx = bare_urls.partition_point(|url| url.byte_offset <= pos);
1363 idx > 0 && pos < bare_urls[idx - 1].byte_end
1364 }
1365
1366 pub fn inline_config(&self) -> &InlineConfig {
1368 &self.inline_config
1369 }
1370
1371 pub fn colon_fence_details(&self) -> &[CodeBlockDetail] {
1376 &self.colon_fence_details
1377 }
1378
1379 pub fn raw_lines(&self) -> &[&'a str] {
1383 &self.content_lines
1384 }
1385
1386 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
1391 self.inline_config.is_rule_disabled(rule_name, line_number)
1392 }
1393
1394 pub fn code_spans(&self) -> Arc<Vec<CodeSpan>> {
1396 Arc::clone(
1397 self.code_spans_cache
1398 .get_or_init(|| Arc::new(element_parsers::parse_code_spans(self.content, &self.lines))),
1399 )
1400 }
1401
1402 pub fn math_byte_ranges(&self) -> &[(usize, usize)] {
1406 self.math_byte_ranges_cache
1407 .get_or_init(|| crate::utils::skip_context::math_byte_ranges(self.content))
1408 }
1409
1410 pub fn math_spans(&self) -> Arc<Vec<MathSpan>> {
1412 Arc::clone(
1413 self.math_spans_cache
1414 .get_or_init(|| Arc::new(element_parsers::parse_math_spans(self.content, &self.lines))),
1415 )
1416 }
1417
1418 pub fn is_in_math_span(&self, byte_pos: usize) -> bool {
1420 let math_spans = self.math_spans();
1421 let idx = math_spans.partition_point(|span| span.byte_offset <= byte_pos);
1423 idx > 0 && byte_pos < math_spans[idx - 1].byte_end
1424 }
1425
1426 pub fn html_comment_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1428 &self.html_comment_ranges
1429 }
1430
1431 pub fn unterminated_html_comment(&self) -> Option<usize> {
1436 self.unterminated_html_comment
1437 }
1438
1439 pub fn unterminated_obsidian_comment(&self) -> Option<usize> {
1443 self.unterminated_obsidian_comment
1444 }
1445
1446 pub fn is_in_obsidian_comment(&self, byte_pos: usize) -> bool {
1450 Self::binary_search_ranges(&self.obsidian_comment_ranges, byte_pos)
1451 }
1452
1453 pub fn is_position_in_obsidian_comment(&self, line_num: usize, col: usize) -> bool {
1458 if self.obsidian_comment_ranges.is_empty() {
1459 return false;
1460 }
1461
1462 let byte_pos = self.line_index.line_col_to_byte_range(line_num, col).start;
1464 self.is_in_obsidian_comment(byte_pos)
1465 }
1466
1467 pub fn myst_directive_ranges(&self) -> &[(usize, usize)] {
1469 &self.myst_directive_ranges
1470 }
1471
1472 pub fn is_in_myst_role(&self, byte_pos: usize) -> bool {
1474 Self::binary_search_ranges(&self.myst_role_ranges, byte_pos)
1475 }
1476
1477 pub fn is_in_myst_comment(&self, byte_pos: usize) -> bool {
1479 Self::binary_search_ranges(&self.myst_comment_ranges, byte_pos)
1480 }
1481
1482 pub fn is_myst_colon_directive_opener_line(&self, line_num: usize) -> bool {
1489 if !self.flavor.supports_myst_directives() {
1490 return false;
1491 }
1492 self.lines.get(line_num.wrapping_sub(1)).is_some_and(|info| {
1493 info.in_myst_directive
1494 && flavor_detection::myst_colon_directive_opener(info.content(self.content)).is_some()
1495 })
1496 }
1497
1498 fn filter_kramdown_tags(&self, tags: Vec<HtmlTag>) -> Vec<HtmlTag> {
1500 tags.into_iter()
1501 .filter(|tag| {
1502 !self
1503 .lines
1504 .get(tag.line - 1)
1505 .is_some_and(|l| l.in_kramdown_extension_block)
1506 })
1507 .collect()
1508 }
1509
1510 pub fn html_tags(&self) -> Arc<Vec<HtmlTag>> {
1516 Arc::clone(self.html_tags_cache.get_or_init(|| {
1517 let (html_tags, jsx_component_tags) =
1518 element_parsers::parse_html_tags(self.content, &self.lines, &self.code_blocks, self.flavor);
1519 let _ = self
1521 .jsx_component_tags_cache
1522 .set(Arc::new(self.filter_kramdown_tags(jsx_component_tags)));
1523 Arc::new(self.filter_kramdown_tags(html_tags))
1524 }))
1525 }
1526
1527 pub fn jsx_component_tags(&self) -> Arc<Vec<HtmlTag>> {
1530 if let Some(cached) = self.jsx_component_tags_cache.get() {
1531 return Arc::clone(cached);
1532 }
1533 let _ = self.html_tags();
1535 Arc::clone(
1536 self.jsx_component_tags_cache
1537 .get()
1538 .expect("html_tags() populates jsx_component_tags_cache"),
1539 )
1540 }
1541
1542 pub fn emphasis_spans(&self) -> Arc<Vec<EmphasisSpan>> {
1544 Arc::clone(
1545 self.emphasis_spans_cache
1546 .get()
1547 .expect("emphasis_spans_cache initialized during construction"),
1548 )
1549 }
1550
1551 pub fn bare_urls(&self) -> Arc<Vec<BareUrl>> {
1553 Arc::clone(self.bare_urls_cache.get_or_init(|| {
1554 Arc::new(element_parsers::parse_bare_urls(
1555 self.content,
1556 &self.lines,
1557 &self.code_blocks,
1558 ))
1559 }))
1560 }
1561
1562 pub fn lazy_continuation_lines(&self) -> Arc<Vec<LazyContLine>> {
1564 Arc::clone(self.lazy_cont_lines_cache.get_or_init(|| {
1565 Arc::new(element_parsers::detect_lazy_continuation_lines(
1566 self.content,
1567 &self.lines,
1568 &self.line_offsets,
1569 ))
1570 }))
1571 }
1572
1573 pub fn has_mixed_list_nesting(&self) -> bool {
1577 *self
1578 .has_mixed_list_nesting_cache
1579 .get_or_init(|| self.compute_mixed_list_nesting())
1580 }
1581
1582 fn compute_mixed_list_nesting(&self) -> bool {
1584 let mut stack: Vec<(usize, bool)> = Vec::new();
1589 let mut last_was_blank = false;
1590
1591 for line_info in &self.lines {
1592 if line_info.in_code_block
1594 || line_info.in_front_matter
1595 || line_info.in_mkdocstrings
1596 || line_info.in_html_comment
1597 || line_info.in_mdx_comment
1598 || line_info.in_esm_block
1599 {
1600 continue;
1601 }
1602
1603 if line_info.is_blank {
1605 last_was_blank = true;
1606 continue;
1607 }
1608
1609 if let Some(list_item) = &line_info.list_item {
1610 let current_pos = if list_item.marker_column == 1 {
1612 0
1613 } else {
1614 list_item.marker_column
1615 };
1616
1617 if last_was_blank && current_pos == 0 {
1619 stack.clear();
1620 }
1621 last_was_blank = false;
1622
1623 while let Some(&(pos, _)) = stack.last() {
1625 if pos >= current_pos {
1626 stack.pop();
1627 } else {
1628 break;
1629 }
1630 }
1631
1632 if let Some(&(_, parent_is_ordered)) = stack.last()
1634 && parent_is_ordered != list_item.is_ordered
1635 {
1636 return true; }
1638
1639 stack.push((current_pos, list_item.is_ordered));
1640 } else {
1641 last_was_blank = false;
1643 }
1644 }
1645
1646 false
1647 }
1648
1649 pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
1655 match self.line_offsets.binary_search(&offset) {
1656 Ok(line) => (line + 1, 1),
1657 Err(line) => {
1658 let line_start = self.line_offsets.get(line.wrapping_sub(1)).copied().unwrap_or(0);
1659 let col = byte_to_char_count(&self.content[line_start..], offset.saturating_sub(line_start));
1661 (line, col)
1662 }
1663 }
1664 }
1665
1666 pub fn line_start_byte(&self, line_number: usize) -> Option<usize> {
1672 self.line_index.get_line_start_byte(line_number)
1673 }
1674
1675 pub fn line_column_byte_range(&self, line_number: usize, column: usize) -> Range<usize> {
1681 self.line_index.line_col_to_byte_range(line_number, column)
1682 }
1683
1684 pub fn line_column_byte_range_with_length(&self, line_number: usize, column: usize, length: usize) -> Range<usize> {
1689 self.line_index
1690 .line_col_to_byte_range_with_length(line_number, column, length)
1691 }
1692
1693 pub fn whole_line_byte_range(&self, line_number: usize) -> Range<usize> {
1696 self.line_index.whole_line_range(line_number)
1697 }
1698
1699 pub fn line_text_byte_range(&self, line_number: usize, start_column: usize, end_column: usize) -> Range<usize> {
1704 self.line_index.line_text_range(line_number, start_column, end_column)
1705 }
1706
1707 pub fn line_content_byte_range(&self, line_number: usize) -> Range<usize> {
1710 self.line_index.line_content_range(line_number)
1711 }
1712
1713 pub fn line_span_byte_range(&self, start_line: usize, end_line: usize) -> Range<usize> {
1715 self.line_index.multi_line_range(start_line, end_line)
1716 }
1717
1718 pub fn is_in_code_block_or_span(&self, pos: usize) -> bool {
1720 if CodeBlockUtils::is_in_code_block_or_span(&self.code_blocks, pos) {
1722 return true;
1723 }
1724
1725 self.is_byte_offset_in_code_span(pos)
1727 }
1728
1729 pub fn line_info(&self, line_num: usize) -> Option<&LineInfo> {
1731 if line_num > 0 {
1732 self.lines.get(line_num - 1)
1733 } else {
1734 None
1735 }
1736 }
1737
1738 pub fn links(&self) -> &[ParsedLink<'a>] {
1740 &self.links
1741 }
1742
1743 pub fn images(&self) -> &[ParsedImage<'a>] {
1745 &self.images
1746 }
1747
1748 pub fn broken_links(&self) -> &[BrokenLinkInfo] {
1750 &self.broken_links
1751 }
1752
1753 pub fn footnote_references(&self) -> &[FootnoteRef] {
1755 &self.footnote_refs
1756 }
1757
1758 pub fn reference_definitions(&self) -> &[ReferenceDef] {
1760 &self.reference_defs
1761 }
1762
1763 pub fn links_on_line(&self, line_number: usize) -> &[ParsedLink<'a>] {
1765 let start = self.links.partition_point(|link| link.line < line_number);
1766 let end = self.links.partition_point(|link| link.line <= line_number);
1767 &self.links[start..end]
1768 }
1769
1770 pub fn images_on_line(&self, line_number: usize) -> &[ParsedImage<'a>] {
1772 let start = self.images.partition_point(|image| image.line < line_number);
1773 let end = self.images.partition_point(|image| image.line <= line_number);
1774 &self.images[start..end]
1775 }
1776
1777 pub fn link_starting_at(&self, byte_offset: usize) -> Option<&ParsedLink<'a>> {
1779 self.links
1780 .binary_search_by_key(&byte_offset, |link| link.byte_offset)
1781 .ok()
1782 .map(|index| &self.links[index])
1783 }
1784
1785 pub fn image_starting_at(&self, byte_offset: usize) -> Option<&ParsedImage<'a>> {
1787 self.images
1788 .binary_search_by_key(&byte_offset, |image| image.byte_offset)
1789 .ok()
1790 .map(|index| &self.images[index])
1791 }
1792
1793 pub fn link_containing(&self, byte_offset: usize) -> Option<&ParsedLink<'a>> {
1795 let index = self.links.partition_point(|link| link.byte_offset <= byte_offset);
1796 self.links
1797 .get(index.checked_sub(1)?)
1798 .filter(|link| byte_offset < link.byte_end)
1799 }
1800
1801 pub fn image_containing(&self, byte_offset: usize) -> Option<&ParsedImage<'a>> {
1803 let index = self.images.partition_point(|image| image.byte_offset <= byte_offset);
1804 self.images
1805 .get(index.checked_sub(1)?)
1806 .filter(|image| byte_offset < image.byte_end)
1807 }
1808
1809 pub fn links_starting_before_or_at(&self, byte_offset: usize) -> &[ParsedLink<'a>] {
1811 let end = self.links.partition_point(|link| link.byte_offset <= byte_offset);
1812 &self.links[..end]
1813 }
1814
1815 pub fn reference_definition(&self, ref_id: &str) -> Option<&ReferenceDef> {
1817 let normalized_id = ref_id.to_lowercase();
1818 self.reference_defs_map
1819 .get(&normalized_id)
1820 .map(|&index| &self.reference_defs[index])
1821 }
1822
1823 pub fn get_reference_url(&self, ref_id: &str) -> Option<&str> {
1825 self.reference_definition(ref_id)
1826 .map(|definition| definition.url.as_str())
1827 }
1828
1829 pub fn is_in_list_block(&self, line_num: usize) -> bool {
1831 if line_num == 0 || line_num > self.lines.len() {
1832 return false;
1833 }
1834 self.lines[line_num - 1].in_list_block
1835 }
1836
1837 pub fn is_in_html_block(&self, line_num: usize) -> bool {
1839 if line_num == 0 || line_num > self.lines.len() {
1840 return false;
1841 }
1842 self.lines[line_num - 1].in_html_block
1843 }
1844
1845 pub fn is_in_table_block(&self, line_num: usize) -> bool {
1851 if line_num == 0 || line_num > self.lines.len() {
1852 return false;
1853 }
1854 self.lines[line_num - 1].in_table_block
1855 }
1856
1857 pub fn is_in_code_span(&self, line_num: usize, col: usize) -> bool {
1859 if line_num == 0 || line_num > self.lines.len() {
1860 return false;
1861 }
1862
1863 let col_0indexed = if col > 0 { col - 1 } else { 0 };
1867 let code_spans = self.code_spans();
1868 code_spans.iter().any(|span| {
1869 if line_num < span.line || line_num > span.end_line {
1871 return false;
1872 }
1873
1874 if span.line == span.end_line {
1875 col_0indexed >= span.start_col && col_0indexed < span.end_col
1877 } else if line_num == span.line {
1878 col_0indexed >= span.start_col
1880 } else if line_num == span.end_line {
1881 col_0indexed < span.end_col
1883 } else {
1884 true
1886 }
1887 })
1888 }
1889
1890 #[inline]
1892 pub fn is_byte_offset_in_code_span(&self, byte_offset: usize) -> bool {
1893 let code_spans = self.code_spans();
1894 let idx = code_spans.partition_point(|span| span.byte_offset <= byte_offset);
1895 idx > 0 && byte_offset < code_spans[idx - 1].byte_end
1896 }
1897
1898 #[inline]
1900 pub fn is_in_reference_def(&self, byte_pos: usize) -> bool {
1901 let idx = self.reference_defs.partition_point(|rd| rd.byte_offset <= byte_pos);
1902 idx > 0 && byte_pos < self.reference_defs[idx - 1].byte_end
1903 }
1904
1905 #[inline]
1907 pub fn is_in_html_comment(&self, byte_pos: usize) -> bool {
1908 let idx = self.html_comment_ranges.partition_point(|r| r.start <= byte_pos);
1909 idx > 0 && byte_pos < self.html_comment_ranges[idx - 1].end
1910 }
1911
1912 #[inline]
1915 pub fn is_in_html_tag(&self, byte_pos: usize) -> bool {
1916 let tags = self.html_tags();
1917 let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1918 idx > 0 && byte_pos < tags[idx - 1].byte_end
1919 }
1920
1921 #[inline]
1925 pub fn is_in_jsx_component_tag(&self, byte_pos: usize) -> bool {
1926 if !self.flavor.supports_jsx() {
1927 return false;
1928 }
1929 let tags = self.jsx_component_tags();
1930 let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1931 idx > 0 && byte_pos < tags[idx - 1].byte_end
1932 }
1933
1934 pub fn is_in_jinja_range(&self, byte_pos: usize) -> bool {
1936 Self::binary_search_ranges(&self.jinja_ranges, byte_pos)
1937 }
1938
1939 #[inline]
1941 pub fn is_in_jsx_expression(&self, byte_pos: usize) -> bool {
1942 Self::binary_search_ranges(&self.jsx_expression_ranges, byte_pos)
1943 }
1944
1945 #[inline]
1947 pub fn is_in_mdx_comment(&self, byte_pos: usize) -> bool {
1948 Self::binary_search_ranges(&self.mdx_comment_ranges, byte_pos)
1949 }
1950
1951 #[inline]
1954 pub fn is_in_citation(&self, byte_pos: usize) -> bool {
1955 let idx = self.citation_ranges.partition_point(|r| r.start <= byte_pos);
1956 idx > 0 && byte_pos < self.citation_ranges[idx - 1].end
1957 }
1958
1959 #[inline]
1961 pub fn citation_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1962 &self.citation_ranges
1963 }
1964
1965 #[inline]
1968 pub fn is_in_div_block(&self, byte_pos: usize) -> bool {
1969 let idx = self.pandoc_div_ranges.partition_point(|r| r.start <= byte_pos);
1970 idx > 0 && byte_pos < self.pandoc_div_ranges[idx - 1].end
1971 }
1972
1973 #[inline]
1976 pub fn is_in_inline_footnote(&self, byte_pos: usize) -> bool {
1977 let idx = self.inline_footnote_ranges.partition_point(|r| r.start <= byte_pos);
1978 idx > 0 && byte_pos < self.inline_footnote_ranges[idx - 1].end
1979 }
1980
1981 #[inline]
1984 pub fn is_in_example_list_marker(&self, byte_pos: usize) -> bool {
1985 let idx = self.example_list_marker_ranges.partition_point(|r| r.start <= byte_pos);
1986 idx > 0 && byte_pos < self.example_list_marker_ranges[idx - 1].end
1987 }
1988
1989 #[inline]
1992 pub fn is_in_example_reference(&self, byte_pos: usize) -> bool {
1993 let idx = self.example_reference_ranges.partition_point(|r| r.start <= byte_pos);
1994 idx > 0 && byte_pos < self.example_reference_ranges[idx - 1].end
1995 }
1996
1997 #[inline]
2000 pub fn is_in_subscript_or_superscript(&self, byte_pos: usize) -> bool {
2001 let idx = self.sub_super_ranges.partition_point(|r| r.start <= byte_pos);
2002 idx > 0 && byte_pos < self.sub_super_ranges[idx - 1].end
2003 }
2004
2005 #[inline]
2009 pub fn is_in_inline_code_attr(&self, byte_pos: usize) -> bool {
2010 let idx = self.inline_code_attr_ranges.partition_point(|r| r.start <= byte_pos);
2011 idx > 0 && byte_pos < self.inline_code_attr_ranges[idx - 1].end
2012 }
2013
2014 #[inline]
2017 pub fn is_in_bracketed_span(&self, byte_pos: usize) -> bool {
2018 let idx = self.bracketed_span_ranges.partition_point(|r| r.start <= byte_pos);
2019 idx > 0 && byte_pos < self.bracketed_span_ranges[idx - 1].end
2020 }
2021
2022 #[inline]
2025 pub fn is_in_line_block(&self, byte_pos: usize) -> bool {
2026 let idx = self.line_block_ranges.partition_point(|r| r.start <= byte_pos);
2027 idx > 0 && byte_pos < self.line_block_ranges[idx - 1].end
2028 }
2029
2030 #[inline]
2034 pub fn is_in_pipe_table_caption(&self, byte_pos: usize) -> bool {
2035 let idx = self.pipe_table_caption_ranges.partition_point(|r| r.start <= byte_pos);
2036 idx > 0 && byte_pos < self.pipe_table_caption_ranges[idx - 1].end
2037 }
2038
2039 #[inline]
2042 pub fn is_in_pandoc_metadata(&self, byte_pos: usize) -> bool {
2043 let idx = self.pandoc_metadata_ranges.partition_point(|r| r.start <= byte_pos);
2044 idx > 0 && byte_pos < self.pandoc_metadata_ranges[idx - 1].end
2045 }
2046
2047 #[inline]
2050 pub fn is_in_grid_table(&self, byte_pos: usize) -> bool {
2051 let idx = self.grid_table_ranges.partition_point(|r| r.start <= byte_pos);
2052 idx > 0 && byte_pos < self.grid_table_ranges[idx - 1].end
2053 }
2054
2055 #[inline]
2058 pub fn is_in_multi_line_table(&self, byte_pos: usize) -> bool {
2059 let idx = self.multi_line_table_ranges.partition_point(|r| r.start <= byte_pos);
2060 idx > 0 && byte_pos < self.multi_line_table_ranges[idx - 1].end
2061 }
2062
2063 pub fn matches_implicit_header_reference(&self, link_text: &str) -> bool {
2068 let slug = crate::utils::pandoc::pandoc_header_slug(link_text);
2069 self.pandoc_header_slugs.contains(&slug)
2070 }
2071
2072 #[inline]
2078 pub fn has_pandoc_slug(&self, slug: &str) -> bool {
2079 self.pandoc_header_slugs.contains(slug)
2080 }
2081
2082 #[inline]
2084 pub fn is_in_shortcode(&self, byte_pos: usize) -> bool {
2085 Self::binary_search_ranges(&self.shortcode_ranges, byte_pos)
2086 }
2087
2088 #[inline]
2090 pub fn shortcode_ranges(&self) -> &[(usize, usize)] {
2091 &self.shortcode_ranges
2092 }
2093
2094 pub fn is_in_link_title(&self, byte_pos: usize) -> bool {
2096 Self::binary_search_ranges(&self.link_title_ranges, byte_pos)
2097 }
2098
2099 pub fn has_char(&self, ch: char) -> bool {
2101 match ch {
2102 '#' => self.char_frequency.hash_count > 0,
2103 '*' => self.char_frequency.asterisk_count > 0,
2104 '_' => self.char_frequency.underscore_count > 0,
2105 '-' => self.char_frequency.hyphen_count > 0,
2106 '+' => self.char_frequency.plus_count > 0,
2107 '>' => self.char_frequency.gt_count > 0,
2108 '|' => self.char_frequency.pipe_count > 0,
2109 '[' => self.char_frequency.bracket_count > 0,
2110 '`' => self.char_frequency.backtick_count > 0,
2111 '<' => self.char_frequency.lt_count > 0,
2112 '!' => self.char_frequency.exclamation_count > 0,
2113 '\n' => self.char_frequency.newline_count > 0,
2114 _ => self.content.contains(ch), }
2116 }
2117
2118 pub fn char_count(&self, ch: char) -> usize {
2120 match ch {
2121 '#' => self.char_frequency.hash_count,
2122 '*' => self.char_frequency.asterisk_count,
2123 '_' => self.char_frequency.underscore_count,
2124 '-' => self.char_frequency.hyphen_count,
2125 '+' => self.char_frequency.plus_count,
2126 '>' => self.char_frequency.gt_count,
2127 '|' => self.char_frequency.pipe_count,
2128 '[' => self.char_frequency.bracket_count,
2129 '`' => self.char_frequency.backtick_count,
2130 '<' => self.char_frequency.lt_count,
2131 '!' => self.char_frequency.exclamation_count,
2132 '\n' => self.char_frequency.newline_count,
2133 _ => self.content.matches(ch).count(), }
2135 }
2136
2137 pub fn likely_has_headings(&self) -> bool {
2139 self.char_frequency.hash_count > 0 || self.char_frequency.hyphen_count > 2 || self.content.contains('=') }
2141
2142 pub fn likely_has_lists(&self) -> bool {
2146 self.char_frequency.asterisk_count > 0
2147 || self.char_frequency.hyphen_count > 0
2148 || self.char_frequency.plus_count > 0
2149 }
2150
2151 pub fn likely_has_emphasis(&self) -> bool {
2153 self.char_frequency.asterisk_count > 1 || self.char_frequency.underscore_count > 1
2154 }
2155
2156 pub fn likely_has_tables(&self) -> bool {
2158 self.char_frequency.pipe_count > 2
2159 }
2160
2161 pub fn likely_has_blockquotes(&self) -> bool {
2163 self.char_frequency.gt_count > 0
2164 }
2165
2166 pub fn likely_has_code(&self) -> bool {
2168 self.char_frequency.backtick_count > 0
2169 }
2170
2171 pub fn likely_has_links_or_images(&self) -> bool {
2173 self.char_frequency.bracket_count > 0 || self.char_frequency.exclamation_count > 0
2174 }
2175
2176 pub fn likely_has_html(&self) -> bool {
2178 self.char_frequency.lt_count > 0
2179 }
2180
2181 pub fn blockquote_prefix_for_blank_line(&self, line_idx: usize) -> String {
2186 if let Some(line_info) = self.lines.get(line_idx)
2187 && let Some(ref bq) = line_info.blockquote
2188 {
2189 bq.prefix.trim_end().to_string()
2190 } else {
2191 String::new()
2192 }
2193 }
2194
2195 #[inline]
2206 fn find_line_for_offset(lines: &[LineInfo], content: &str, byte_offset: usize) -> (usize, usize, usize) {
2207 let idx = match lines.binary_search_by(|line| {
2209 if byte_offset < line.byte_offset {
2210 std::cmp::Ordering::Greater
2211 } else if byte_offset > line.byte_offset + line.byte_len {
2212 std::cmp::Ordering::Less
2213 } else {
2214 std::cmp::Ordering::Equal
2215 }
2216 }) {
2217 Ok(idx) => idx,
2218 Err(idx) => idx.saturating_sub(1),
2219 };
2220
2221 let line = &lines[idx];
2222 let line_num = idx + 1;
2223 let byte_col = byte_offset.saturating_sub(line.byte_offset);
2224 let col = byte_to_char_count(line.content(content), byte_col) - 1;
2227
2228 (idx, line_num, col)
2229 }
2230
2231 #[inline]
2233 fn is_offset_in_code_span(code_spans: &[CodeSpan], offset: usize) -> bool {
2234 let idx = code_spans.partition_point(|span| span.byte_offset <= offset);
2236
2237 if idx > 0 {
2239 let span = &code_spans[idx - 1];
2240 if offset >= span.byte_offset && offset < span.byte_end {
2241 return true;
2242 }
2243 }
2244
2245 false
2246 }
2247
2248 #[must_use]
2268 pub fn valid_headings(&self) -> ValidHeadingsIter<'_> {
2269 ValidHeadingsIter::new(&self.lines)
2270 }
2271
2272 #[must_use]
2276 pub fn has_valid_headings(&self) -> bool {
2277 self.lines
2278 .iter()
2279 .any(|line| line.heading.as_ref().is_some_and(|h| h.is_valid))
2280 }
2281
2282 #[must_use]
2284 pub fn list_items(&self) -> ParsedListItemsIter<'_> {
2285 ParsedListItemsIter::new(&self.lines)
2286 }
2287
2288 #[must_use]
2290 pub fn list_item_on_line(&self, line_num: usize) -> Option<ParsedListItem<'_>> {
2291 let line_info = self.lines.get(line_num.checked_sub(1)?)?;
2292 Some(ParsedListItem::new(
2293 line_num,
2294 line_info.list_item.as_deref()?,
2295 line_info,
2296 ))
2297 }
2298
2299 #[must_use]
2301 pub fn parsed_list_blocks(&self) -> ParsedListBlocks<'_> {
2302 ParsedListBlocks::new(&self.list_blocks, &self.lines)
2303 }
2304
2305 #[must_use]
2309 pub fn list_block_item_groups(&self, block: &ListBlock) -> Vec<Vec<usize>> {
2310 list_blocks::item_lines_by_list(self.content, &self.lines, block)
2311 }
2312
2313 #[must_use]
2315 pub fn has_list_items(&self) -> bool {
2316 self.lines.iter().any(|line| line.list_item.is_some())
2317 }
2318
2319 #[must_use]
2321 pub fn has_unordered_list_items(&self) -> bool {
2322 self.lines
2323 .iter()
2324 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
2325 }
2326
2327 #[must_use]
2329 pub fn commonmark_ordered_lists(&self) -> CommonMarkOrderedLists<'_> {
2330 let lists = self
2331 .commonmark_ordered_lists_cache
2332 .get_or_init(|| build_commonmark_ordered_lists(&self.lines, &self.line_to_list, &self.list_start_values));
2333 CommonMarkOrderedLists::new(lists, &self.lines)
2334 }
2335
2336 #[must_use]
2344 pub fn headings(&self) -> ParsedHeadingsIter<'_> {
2345 ParsedHeadingsIter::new(&self.lines, &self.blockquote_headings)
2346 }
2347
2348 #[must_use]
2350 pub fn heading_on_line(&self, line_num: usize) -> Option<ParsedHeading<'_>> {
2351 let idx = line_num.checked_sub(1)?;
2352 let line_info = self.lines.get(idx)?;
2353 let (heading, blockquote_depth) = match line_info.heading.as_deref() {
2354 Some(heading) => (heading, 0),
2355 None => (
2356 self.blockquote_headings.get(idx)?.as_deref()?,
2357 line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level),
2358 ),
2359 };
2360 Some(ParsedHeading {
2361 line_num,
2362 heading,
2363 line_info,
2364 blockquote_depth,
2365 })
2366 }
2367}
2368
2369fn container_comment_range(
2381 opener: usize,
2382 containers: &flavor_detection::ContainerLines,
2383 lines: &[types::LineInfo],
2384 content: &str,
2385) -> Option<crate::utils::skip_context::ByteRange> {
2386 let line_index = lines
2387 .partition_point(|line| line.byte_offset <= opener)
2388 .checked_sub(1)?;
2389 let line = lines.get(line_index)?;
2390 if line.byte_offset + line.indent != opener {
2391 return None;
2392 }
2393 if !containers.is_container_body(line_index) {
2394 return None;
2395 }
2396 let end_line = lines.get(containers.body_end_line(line_index)?)?;
2397 Some(crate::utils::skip_context::ByteRange {
2398 start: opener,
2399 end: (end_line.byte_offset + end_line.byte_len).min(content.len()),
2400 })
2401}
2402
2403fn detect_footnote_definitions(content: &str, lines: &mut [types::LineInfo], line_offsets: &[usize]) {
2412 use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
2413
2414 let options = crate::utils::rumdl_parser_options();
2415 let parser = Parser::new_ext(content, options).into_offset_iter();
2416
2417 let mut footnote_ranges: Vec<(usize, usize)> = Vec::new();
2419 let mut fenced_code_ranges: Vec<(usize, usize)> = Vec::new();
2420 let mut in_footnote = false;
2421
2422 for (event, range) in parser {
2423 match event {
2424 Event::Start(Tag::FootnoteDefinition(_)) => {
2425 in_footnote = true;
2426 footnote_ranges.push((range.start, range.end));
2427 }
2428 Event::End(TagEnd::FootnoteDefinition) => {
2429 in_footnote = false;
2430 }
2431 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) if in_footnote => {
2432 fenced_code_ranges.push((range.start, range.end));
2433 }
2434 _ => {}
2435 }
2436 }
2437
2438 let byte_to_line = |byte_offset: usize| -> usize {
2439 line_offsets
2440 .partition_point(|&offset| offset <= byte_offset)
2441 .saturating_sub(1)
2442 };
2443
2444 for &(start, end) in &footnote_ranges {
2446 let start_line = byte_to_line(start);
2447 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
2448
2449 for line in &mut lines[start_line..end_line] {
2450 line.in_footnote_definition = true;
2451 line.in_code_block = false;
2452 }
2453 }
2454
2455 for &(start, end) in &fenced_code_ranges {
2457 let start_line = byte_to_line(start);
2458 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
2459
2460 for line in &mut lines[start_line..end_line] {
2461 line.in_code_block = true;
2462 }
2463 }
2464}