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 fn code_block_ranges(content: &str, flavor: MarkdownFlavor) -> Vec<(usize, usize)> {
283 LintContext::new(content, flavor, None).code_blocks
284}
285
286impl<'a> LintContext<'a> {
287 pub fn source_file(&self) -> Option<&Path> {
292 self.source_file.as_deref()
293 }
294
295 pub fn link_target_policy(&self) -> Option<&LinkTargetPolicy> {
296 self.link_target_policy.as_ref()
297 }
298
299 pub fn with_link_target_policy(mut self, policy: LinkTargetPolicy) -> Self {
300 self.link_target_policy = Some(policy);
301 self
302 }
303
304 pub fn new(content: &'a str, flavor: MarkdownFlavor, source_file: Option<PathBuf>) -> Self {
305 #[cfg(not(target_arch = "wasm32"))]
306 let profile = std::env::var("RUMDL_PROFILE_QUADRATIC").is_ok();
307
308 let line_offsets = profile_section!("Line offsets", profile, {
309 let mut offsets = vec![0];
310 for (i, c) in content.char_indices() {
311 if c == '\n' {
312 offsets.push(i + 1);
313 }
314 }
315 offsets
316 });
317
318 let content_lines: Vec<&str> = content.lines().collect();
320
321 #[allow(clippy::disallowed_methods)]
325 let front_matter_end = FrontMatterUtils::get_front_matter_end_line(content);
326
327 let parse_result = profile_section!(
329 "Code blocks",
330 profile,
331 CodeBlockUtils::detect_code_blocks_and_spans(content)
332 );
333 let mut code_blocks = parse_result.code_blocks;
334 let mut code_span_ranges = parse_result.code_spans;
335 let code_block_details = parse_result.code_block_details;
336 let strong_spans = parse_result.strong_spans;
337 let line_to_list = parse_result.line_to_list;
338 let list_start_values = parse_result.list_start_values;
339 let html_blocks = parse_result.html_blocks;
340
341 let containers = profile_section!(
344 "Container lines",
345 profile,
346 flavor_detection::detect_container_lines(&content_lines, flavor)
347 );
348
349 let comment_code_block_ranges: Vec<(usize, usize)> = code_block_details
358 .iter()
359 .flat_map(|detail| {
360 if detail.is_fenced {
361 return vec![(detail.start, detail.end)];
362 }
363 let start_line = line_offsets
364 .partition_point(|&offset| offset <= detail.start)
365 .saturating_sub(1);
366 let end_line = line_offsets.partition_point(|&offset| offset < detail.end);
367 containers
368 .code_line_spans_in(start_line..end_line)
369 .into_iter()
370 .map(|span| {
371 let start = line_offsets[span.start].max(detail.start);
372 let end = line_offsets
373 .get(span.end)
374 .copied()
375 .unwrap_or(content.len())
376 .min(detail.end);
377 (start, end)
378 })
379 .collect()
380 })
381 .collect();
382 let body_start = line_offsets.get(front_matter_end).copied().unwrap_or(content.len());
388 let html_comment_scan = profile_section!(
389 "HTML comment ranges",
390 profile,
391 crate::utils::skip_context::scan_html_comments(
392 content,
393 &code_span_ranges,
394 &comment_code_block_ranges,
395 body_start
396 )
397 );
398 let mut html_comment_ranges = html_comment_scan.ranges;
399 let unterminated_html_comment = html_comment_scan.unterminated;
400
401 let autodoc_ranges = profile_section!("Autodoc block ranges", profile, {
405 if flavor.supports_colon_code_fences() || flavor.supports_myst_directives() {
406 Vec::new()
407 } else {
408 crate::utils::mkdocstrings_refs::detect_autodoc_block_ranges(content)
409 }
410 });
411
412 let pandoc_div_ranges = profile_section!("Pandoc div ranges", profile, {
414 if flavor.is_pandoc_compatible() {
415 crate::utils::pandoc::detect_div_block_ranges(content)
416 } else {
417 Vec::new()
418 }
419 });
420
421 let pymdown_block_ranges = profile_section!("PyMdown block ranges", profile, {
423 if flavor == MarkdownFlavor::MkDocs {
424 crate::utils::pymdown_blocks::detect_block_ranges(content)
425 } else {
426 Vec::new()
427 }
428 });
429
430 let skip_ranges = SkipByteRanges {
433 html_comment_ranges: &html_comment_ranges,
434 autodoc_ranges: &autodoc_ranges,
435 pandoc_div_ranges: &pandoc_div_ranges,
436 pymdown_block_ranges: &pymdown_block_ranges,
437 };
438 let (mut lines, emphasis_spans) = profile_section!(
439 "Basic line info",
440 profile,
441 line_computation::compute_basic_line_info(
442 content,
443 &content_lines,
444 &line_offsets,
445 &code_blocks,
446 flavor,
447 &skip_ranges,
448 front_matter_end,
449 )
450 );
451
452 profile_section!(
454 "HTML blocks",
455 profile,
456 heading_detection::detect_html_blocks(content, &mut lines)
457 );
458
459 profile_section!(
461 "ESM blocks",
462 profile,
463 flavor_detection::detect_esm_blocks(content, &mut lines, flavor)
464 );
465
466 profile_section!(
468 "JSX block detection",
469 profile,
470 flavor_detection::detect_jsx_blocks(content, &mut lines, flavor)
471 );
472
473 let (jsx_expression_ranges, mdx_comment_ranges) = profile_section!(
475 "JSX/MDX detection",
476 profile,
477 flavor_detection::detect_jsx_and_mdx_comments(content, &mut lines, flavor, &code_blocks)
478 );
479
480 profile_section!(
485 "Markdown-in-HTML blocks",
486 profile,
487 flavor_detection::detect_markdown_html_blocks(&mut lines, &containers)
488 );
489
490 let mdx_context = if flavor == MarkdownFlavor::MDX {
491 mdx::MdxContext::parse(content, &lines)
492 } else {
493 None
494 };
495 let (jsx_expression_ranges, mdx_comment_ranges) = if let Some(mdx) = &mdx_context {
496 mdx.apply_lines(&mut lines);
497 code_blocks.clone_from(&mdx.code_blocks);
498 code_span_ranges.clone_from(&mdx.code_spans);
499 (mdx.expressions.clone(), mdx.comments.clone())
500 } else {
501 (jsx_expression_ranges, mdx_comment_ranges)
502 };
503
504 profile_section!(
506 "MkDocs constructs",
507 profile,
508 flavor_detection::detect_mkdocs_line_info(&content_lines, &mut lines, flavor, &containers)
509 );
510
511 profile_section!(
516 "Footnote definitions",
517 profile,
518 detect_footnote_definitions(content, &mut lines, &line_offsets)
519 );
520
521 {
524 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
525 for &(start, end) in &code_blocks {
526 let start_line = line_offsets
527 .partition_point(|&offset| offset <= start)
528 .saturating_sub(1);
529 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
530
531 let mut sub_start: Option<usize> = None;
532 for (i, &offset) in line_offsets[start_line..end_line]
533 .iter()
534 .enumerate()
535 .map(|(j, o)| (j + start_line, o))
536 {
537 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
538 if is_real_code && sub_start.is_none() {
539 let byte_start = if i == start_line { start } else { offset };
540 sub_start = Some(byte_start);
541 } else if !is_real_code && sub_start.is_some() {
542 new_code_blocks.push((sub_start.unwrap(), offset));
543 sub_start = None;
544 }
545 }
546 if let Some(s) = sub_start {
547 new_code_blocks.push((s, end));
548 }
549 }
550 code_blocks = new_code_blocks;
551 }
552
553 let has_markdown_html = lines.iter().any(|l| l.in_mkdocs_html_markdown);
561 if flavor == MarkdownFlavor::MkDocs || has_markdown_html {
562 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
563 for &(start, end) in &code_blocks {
564 let start_line = line_offsets
565 .partition_point(|&offset| offset <= start)
566 .saturating_sub(1);
567 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
568
569 let mut sub_start: Option<usize> = None;
571 for (i, &offset) in line_offsets[start_line..end_line]
572 .iter()
573 .enumerate()
574 .map(|(j, o)| (j + start_line, o))
575 {
576 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
577 if is_real_code && sub_start.is_none() {
578 let byte_start = if i == start_line { start } else { offset };
579 sub_start = Some(byte_start);
580 } else if !is_real_code && sub_start.is_some() {
581 new_code_blocks.push((sub_start.unwrap(), offset));
582 sub_start = None;
583 }
584 }
585 if let Some(s) = sub_start {
586 new_code_blocks.push((s, end));
587 }
588 }
589 code_blocks = new_code_blocks;
590 }
591
592 if flavor.supports_jsx() {
596 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
597 for &(start, end) in &code_blocks {
598 let start_line = line_offsets
599 .partition_point(|&offset| offset <= start)
600 .saturating_sub(1);
601 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
602
603 let mut sub_start: Option<usize> = None;
604 for (i, &offset) in line_offsets[start_line..end_line]
605 .iter()
606 .enumerate()
607 .map(|(j, o)| (j + start_line, o))
608 {
609 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
610 if is_real_code && sub_start.is_none() {
611 let byte_start = if i == start_line { start } else { offset };
612 sub_start = Some(byte_start);
613 } else if !is_real_code && sub_start.is_some() {
614 new_code_blocks.push((sub_start.unwrap(), offset));
615 sub_start = None;
616 }
617 }
618 if let Some(s) = sub_start {
619 new_code_blocks.push((s, end));
620 }
621 }
622 code_blocks = new_code_blocks;
623
624 let mut jsx_fence_ranges: Vec<(usize, usize)> = Vec::new();
631 let mut run: Option<(usize, usize)> = None;
632 for line in &lines {
633 if line.in_jsx_block && line.in_code_block {
634 let line_end = line.byte_offset + line.byte_len;
635 match &mut run {
636 Some((_, end)) => *end = line_end,
637 None => run = Some((line.byte_offset, line_end)),
638 }
639 } else if let Some(r) = run.take() {
640 jsx_fence_ranges.push(r);
641 }
642 }
643 if let Some(r) = run.take() {
644 jsx_fence_ranges.push(r);
645 }
646 if !jsx_fence_ranges.is_empty() {
647 code_blocks.extend(jsx_fence_ranges);
648 code_blocks.sort_by_key(|&(start, _)| start);
649 }
650 }
651
652 let colon_fence_details = profile_section!(
655 "Azure colon fence detection",
656 profile,
657 flavor_detection::detect_azure_colon_fences(content, &mut lines, flavor)
658 );
659 if !colon_fence_details.is_empty() {
660 code_blocks.extend(colon_fence_details.iter().map(|fence| (fence.start, fence.end)));
661 code_blocks.sort_by_key(|&(start, _)| start);
662 }
663
664 let myst_directive_ranges = profile_section!(
667 "MyST colon directives",
668 profile,
669 flavor_detection::detect_myst_colon_directives(content, &mut lines, flavor)
670 );
671
672 let myst_comment_ranges = profile_section!(
674 "MyST comments",
675 profile,
676 flavor_detection::detect_myst_comments(content, &mut lines, flavor)
677 );
678
679 profile_section!(
682 "MyST backtick directives",
683 profile,
684 flavor_detection::detect_myst_backtick_directives(
685 content,
686 &mut lines,
687 flavor,
688 &code_block_details,
689 &line_offsets
690 )
691 );
692
693 if flavor.supports_myst_directives() {
696 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
697 for &(start, end) in &code_blocks {
698 let start_line = line_offsets
699 .partition_point(|&offset| offset <= start)
700 .saturating_sub(1);
701 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
702
703 let mut sub_start: Option<usize> = None;
704 for (i, &offset) in line_offsets[start_line..end_line]
705 .iter()
706 .enumerate()
707 .map(|(j, o)| (j + start_line, o))
708 {
709 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
710 if is_real_code && sub_start.is_none() {
711 let byte_start = if i == start_line { start } else { offset };
712 sub_start = Some(byte_start);
713 } else if !is_real_code && sub_start.is_some() {
714 new_code_blocks.push((sub_start.unwrap(), offset));
715 sub_start = None;
716 }
717 }
718 if let Some(s) = sub_start {
719 new_code_blocks.push((s, end));
720 }
721 }
722 code_blocks = new_code_blocks;
723 }
724
725 profile_section!(
727 "Kramdown constructs",
728 profile,
729 flavor_detection::detect_kramdown_line_info(content, &mut lines, flavor)
730 );
731
732 for line in &mut lines {
737 if line.in_kramdown_extension_block {
738 line.list_item = None;
739 line.is_horizontal_rule = false;
740 line.blockquote = None;
741 line.is_kramdown_block_ial = false;
742 }
743 }
744
745 let obsidian_comment_scan = profile_section!(
747 "Obsidian comments",
748 profile,
749 flavor_detection::detect_obsidian_comments(
750 content,
751 &mut lines,
752 flavor,
753 &code_span_ranges,
754 &html_comment_ranges,
755 body_start
756 )
757 );
758 let mut obsidian_comment_ranges = obsidian_comment_scan.ranges;
759 let mut unterminated_obsidian_comment = obsidian_comment_scan.unterminated;
760
761 let unterminated_html_comment = crate::utils::skip_context::unterminated_html_comment_outside(
766 unterminated_html_comment,
767 &obsidian_comment_ranges,
768 content,
769 &code_span_ranges,
770 &comment_code_block_ranges,
771 body_start,
772 );
773
774 if let Some(range) = unterminated_html_comment.and_then(|opener| {
787 crate::utils::skip_context::unterminated_comment_range(opener, &html_blocks)
788 .or_else(|| container_comment_range(opener, &containers, &lines, content))
789 }) {
790 html_comment_ranges.push(range);
793
794 for line in &mut lines {
800 let text = line.content(content);
801 let content_start = line.byte_offset + line.indent;
802 let content_end = line.byte_offset + text.trim_end().len();
803 line.in_html_comment = crate::utils::skip_context::is_line_entirely_in_html_comment(
804 &html_comment_ranges,
805 content_start,
806 content_end,
807 );
808 line.in_obsidian_comment = false;
809 }
810
811 let obsidian_rescan = flavor_detection::detect_obsidian_comments(
822 content,
823 &mut lines,
824 flavor,
825 &code_span_ranges,
826 &html_comment_ranges,
827 body_start,
828 );
829 obsidian_comment_ranges = obsidian_rescan.ranges;
830 unterminated_obsidian_comment = obsidian_rescan.unterminated;
831 }
832
833 let myst_role_ranges = profile_section!(
835 "MyST roles",
836 profile,
837 flavor_detection::detect_myst_role_ranges(content, &lines, flavor, &code_blocks)
838 );
839
840 let mut pulldown_result = profile_section!(
844 "Links, images & link ranges",
845 profile,
846 link_parser::parse_links_images_pulldown(content, &lines, &code_blocks, flavor, &html_comment_ranges)
847 );
848
849 if let Some(mdx) = &mdx_context {
850 let (links, images) = mdx.links_and_images(content, &lines);
851 pulldown_result.link_byte_ranges = links.iter().map(|link| (link.byte_offset, link.byte_end)).collect();
852 pulldown_result.link_found_positions = links.iter().map(|link| link.byte_offset).collect();
853 pulldown_result.image_found_positions = images.iter().map(|image| image.byte_offset).collect();
854 pulldown_result.links = links;
855 pulldown_result.images = images;
856 pulldown_result.footnote_refs = mdx.footnote_refs();
857 pulldown_result
858 .broken_links
859 .retain(|link| mdx.contains_text(link.span.start, link.span.end));
860 }
861
862 let mut blockquote_headings = profile_section!(
864 "Headings & blockquotes",
865 profile,
866 heading_detection::detect_headings_and_blockquotes(
867 &content_lines,
868 &mut lines,
869 flavor,
870 &html_comment_ranges,
871 &pulldown_result.link_byte_ranges,
872 front_matter_end,
873 )
874 );
875
876 for line in &mut lines {
878 if line.in_kramdown_extension_block {
879 line.heading = None;
880 }
881 }
882 for (line, heading) in lines.iter().zip(&mut blockquote_headings) {
883 if line.in_kramdown_extension_block {
884 *heading = None;
885 }
886 }
887
888 for line in &mut lines {
899 if line.is_horizontal_rule
900 && (line.in_code_block
901 || line.in_html_block
902 || line.in_html_comment
903 || line.in_math_block
904 || line.in_mdx_comment
905 || line.in_obsidian_comment)
906 {
907 line.is_horizontal_rule = false;
908 }
909 }
910
911 let mut code_spans = profile_section!(
913 "Code spans",
914 profile,
915 element_parsers::build_code_spans_from_ranges(content, &lines, &code_span_ranges)
916 );
917
918 if flavor == MarkdownFlavor::MkDocs {
922 let extra = profile_section!(
923 "MkDocs code spans",
924 profile,
925 element_parsers::scan_mkdocs_container_code_spans(content, &lines, &code_span_ranges,)
926 );
927 if !extra.is_empty() {
928 code_spans.extend(extra);
929 code_spans.sort_by_key(|span| span.byte_offset);
930 }
931 }
932
933 if flavor == MarkdownFlavor::MDX && mdx_context.is_none() {
938 let extra = profile_section!(
939 "MDX JSX code spans",
940 profile,
941 element_parsers::scan_jsx_block_code_spans(content, &lines, &code_span_ranges)
942 );
943 if !extra.is_empty() {
944 code_spans.extend(extra);
945 code_spans.sort_by_key(|span| span.byte_offset);
946 }
947 }
948
949 for span in &code_spans {
952 if span.end_line > span.line {
953 for line_num in (span.line + 1)..=span.end_line {
955 if let Some(line_info) = lines.get_mut(line_num - 1) {
956 line_info.in_code_span_continuation = true;
957 }
958 }
959 }
960 }
961
962 let (links, images, broken_links, footnote_refs) = profile_section!(
964 "Links & images finalize",
965 profile,
966 link_parser::finalize_links_and_images(
967 content,
968 &lines,
969 flavor,
970 &link_parser::LinkExclusions {
971 code_blocks: &code_blocks,
972 code_spans: &code_spans,
973 html_comment_ranges: &html_comment_ranges,
974 mdx: mdx_context.as_ref(),
975 },
976 pulldown_result,
977 )
978 );
979
980 let reference_defs = profile_section!("Reference defs", profile, {
981 if let Some(mdx) = &mdx_context {
982 mdx.reference_defs(content)
983 } else {
984 link_parser::parse_reference_defs(content, &lines)
985 }
986 });
987
988 let list_blocks = profile_section!("List blocks", profile, list_blocks::parse_list_blocks(content, &lines));
989
990 let char_frequency = profile_section!(
992 "Char frequency",
993 profile,
994 line_computation::compute_char_frequency(content)
995 );
996
997 let table_blocks = profile_section!(
999 "Table blocks",
1000 profile,
1001 crate::utils::table_utils::TableUtils::find_table_blocks_with_code_info(
1002 content,
1003 &code_blocks,
1004 &code_spans,
1005 &html_comment_ranges,
1006 flavor,
1007 )
1008 );
1009
1010 let links = links
1013 .into_iter()
1014 .filter(|link| !lines.get(link.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1015 .collect::<Vec<_>>();
1016 let images = images
1017 .into_iter()
1018 .filter(|img| !lines.get(img.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1019 .collect::<Vec<_>>();
1020 let broken_links = broken_links
1021 .into_iter()
1022 .filter(|bl| {
1023 let line_idx = line_offsets
1025 .partition_point(|&offset| offset <= bl.span.start)
1026 .saturating_sub(1);
1027 !lines.get(line_idx).is_some_and(|l| l.in_kramdown_extension_block)
1028 })
1029 .collect::<Vec<_>>();
1030 let footnote_refs = footnote_refs
1031 .into_iter()
1032 .filter(|fr| !lines.get(fr.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1033 .collect::<Vec<_>>();
1034 let reference_defs = reference_defs
1035 .into_iter()
1036 .filter(|def| !lines.get(def.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1037 .collect::<Vec<_>>();
1038 let list_blocks = list_blocks
1039 .into_iter()
1040 .filter(|block| {
1041 !lines
1042 .get(block.start_line - 1)
1043 .is_some_and(|l| l.in_kramdown_extension_block)
1044 })
1045 .collect::<Vec<_>>();
1046 let table_blocks = table_blocks
1047 .into_iter()
1048 .filter(|block| {
1049 !lines
1051 .get(block.start_line)
1052 .is_some_and(|l| l.in_kramdown_extension_block)
1053 })
1054 .collect::<Vec<_>>();
1055 let emphasis_spans = emphasis_spans
1056 .into_iter()
1057 .filter(|span| !lines.get(span.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1058 .collect::<Vec<_>>();
1059
1060 for block in &list_blocks {
1064 for line_num in block.start_line..=block.end_line {
1066 if let Some(li) = lines.get_mut(line_num - 1) {
1067 li.in_list_block = true;
1068 }
1069 }
1070 }
1071 for block in &table_blocks {
1072 for idx in block.start_line..=block.end_line {
1074 if let Some(li) = lines.get_mut(idx) {
1075 li.in_table_block = true;
1076 }
1077 }
1078 }
1079
1080 let reference_defs_map: HashMap<String, usize> = reference_defs
1082 .iter()
1083 .enumerate()
1084 .map(|(idx, def)| (def.id.to_lowercase(), idx))
1085 .collect();
1086
1087 let link_title_ranges: Vec<(usize, usize)> = reference_defs
1089 .iter()
1090 .filter_map(|def| match (def.title_byte_start, def.title_byte_end) {
1091 (Some(start), Some(end)) => Some((start, end)),
1092 _ => None,
1093 })
1094 .collect();
1095
1096 let line_index = profile_section!(
1098 "Line index",
1099 profile,
1100 crate::utils::range_utils::LineIndex::with_line_starts_and_code_blocks(
1101 content,
1102 line_offsets.clone(),
1103 &code_blocks,
1104 )
1105 );
1106
1107 let jinja_ranges = profile_section!(
1109 "Jinja ranges",
1110 profile,
1111 crate::utils::jinja_utils::find_jinja_ranges(content)
1112 );
1113
1114 let citation_ranges = profile_section!("Citation ranges", profile, {
1116 if flavor.is_pandoc_compatible() {
1117 crate::utils::pandoc::find_citation_ranges(content)
1118 } else {
1119 Vec::new()
1120 }
1121 });
1122
1123 let inline_footnote_ranges = profile_section!("Inline footnote ranges", profile, {
1125 if flavor.is_pandoc_compatible() {
1126 crate::utils::pandoc::detect_inline_footnote_ranges(content)
1127 } else {
1128 Vec::new()
1129 }
1130 });
1131
1132 let pandoc_header_slugs = profile_section!("Pandoc header slugs", profile, {
1134 if flavor.is_pandoc_compatible() {
1135 crate::utils::pandoc::collect_pandoc_header_slugs(content)
1136 } else {
1137 std::collections::HashSet::new()
1138 }
1139 });
1140
1141 let example_list_marker_ranges = profile_section!("Example list markers", profile, {
1143 if flavor.is_pandoc_compatible() {
1144 crate::utils::pandoc::detect_example_list_marker_ranges(content)
1145 } else {
1146 Vec::new()
1147 }
1148 });
1149
1150 let example_reference_ranges = profile_section!("Example references", profile, {
1152 if flavor.is_pandoc_compatible() {
1153 crate::utils::pandoc::detect_example_reference_ranges(content, &example_list_marker_ranges)
1154 } else {
1155 Vec::new()
1156 }
1157 });
1158
1159 let sub_super_ranges = profile_section!("Subscript/superscript ranges", profile, {
1161 if flavor.is_pandoc_compatible() {
1162 crate::utils::pandoc::detect_subscript_superscript_ranges(content)
1163 } else {
1164 Vec::new()
1165 }
1166 });
1167
1168 let inline_code_attr_ranges = profile_section!("Inline code attribute ranges", profile, {
1170 if flavor.is_pandoc_compatible() {
1171 crate::utils::pandoc::detect_inline_code_attr_ranges(content)
1172 } else {
1173 Vec::new()
1174 }
1175 });
1176
1177 let bracketed_span_ranges = profile_section!("Bracketed span ranges", profile, {
1179 if flavor.is_pandoc_compatible() {
1180 crate::utils::pandoc::detect_bracketed_span_ranges(content)
1181 } else {
1182 Vec::new()
1183 }
1184 });
1185
1186 let line_block_ranges = profile_section!("Line block ranges", profile, {
1188 if flavor.is_pandoc_compatible() {
1189 crate::utils::pandoc::detect_line_block_ranges(content)
1190 } else {
1191 Vec::new()
1192 }
1193 });
1194
1195 let pipe_table_caption_ranges = profile_section!("Pipe-table caption ranges", profile, {
1197 if flavor.is_pandoc_compatible() {
1198 crate::utils::pandoc::detect_pipe_table_caption_ranges(content)
1199 } else {
1200 Vec::new()
1201 }
1202 });
1203
1204 let pandoc_metadata_ranges = profile_section!("Pandoc metadata ranges", profile, {
1206 if flavor.is_pandoc_compatible() {
1207 crate::utils::pandoc::detect_yaml_metadata_block_ranges(content)
1208 } else {
1209 Vec::new()
1210 }
1211 });
1212
1213 let grid_table_ranges = profile_section!("Grid table ranges", profile, {
1215 if flavor.is_pandoc_compatible() {
1216 crate::utils::pandoc::detect_grid_table_ranges(content)
1217 } else {
1218 Vec::new()
1219 }
1220 });
1221
1222 let multi_line_table_ranges = profile_section!("Multi-line table ranges", profile, {
1224 if flavor.is_pandoc_compatible() {
1225 crate::utils::pandoc::detect_multi_line_table_ranges(content)
1226 } else {
1227 Vec::new()
1228 }
1229 });
1230
1231 let shortcode_ranges = profile_section!("Shortcode ranges", profile, {
1233 use crate::utils::regex_cache::HUGO_SHORTCODE_REGEX;
1234 let mut ranges = Vec::new();
1235 for mat in HUGO_SHORTCODE_REGEX.find_iter(content) {
1236 ranges.push((mat.start(), mat.end()));
1237 }
1238 ranges
1239 });
1240
1241 let inline_config = InlineConfig::from_content_with_code_blocks(content, &code_blocks);
1242 Self {
1243 content,
1244 content_lines,
1245 line_offsets,
1246 code_blocks,
1247 code_block_details,
1248 strong_spans,
1249 line_to_list,
1250 list_start_values,
1251 commonmark_ordered_lists_cache: OnceLock::new(),
1252 lines,
1253 blockquote_headings,
1254 links,
1255 images,
1256 broken_links,
1257 footnote_refs,
1258 reference_defs,
1259 reference_defs_map,
1260 code_spans_cache: OnceLock::from(Arc::new(code_spans)),
1261 math_spans_cache: OnceLock::new(), math_byte_ranges_cache: OnceLock::new(), list_blocks,
1264 char_frequency,
1265 html_tags_cache: OnceLock::new(),
1266 jsx_component_tags_cache: OnceLock::new(),
1267 emphasis_spans_cache: OnceLock::from(Arc::new(emphasis_spans)),
1268 bare_urls_cache: OnceLock::new(),
1269 has_mixed_list_nesting_cache: OnceLock::new(),
1270 html_comment_ranges,
1271 table_blocks,
1272 line_index,
1273 jinja_ranges,
1274 flavor,
1275 source_file,
1276 link_target_policy: None,
1277 jsx_expression_ranges,
1278 mdx_comment_ranges,
1279 citation_ranges,
1280 pandoc_div_ranges,
1281 colon_fence_details,
1282 inline_footnote_ranges,
1283 pandoc_header_slugs,
1284 example_list_marker_ranges,
1285 example_reference_ranges,
1286 sub_super_ranges,
1287 inline_code_attr_ranges,
1288 bracketed_span_ranges,
1289 line_block_ranges,
1290 pipe_table_caption_ranges,
1291 pandoc_metadata_ranges,
1292 grid_table_ranges,
1293 multi_line_table_ranges,
1294 shortcode_ranges,
1295 link_title_ranges,
1296 code_span_byte_ranges: code_span_ranges,
1297 inline_config,
1298 obsidian_comment_ranges,
1299 unterminated_html_comment,
1300 unterminated_obsidian_comment,
1301 lazy_cont_lines_cache: OnceLock::new(),
1302 myst_directive_ranges,
1303 myst_comment_ranges,
1304 myst_role_ranges,
1305 front_matter_end,
1306 }
1307 }
1308
1309 pub fn front_matter_end_line(&self) -> usize {
1314 self.front_matter_end
1315 }
1316
1317 #[inline]
1320 fn binary_search_ranges(ranges: &[(usize, usize)], pos: usize) -> bool {
1321 let idx = ranges.partition_point(|&(start, _)| start <= pos);
1323 idx > 0 && pos < ranges[idx - 1].1
1325 }
1326
1327 pub fn is_in_code_span_byte(&self, pos: usize) -> bool {
1329 Self::binary_search_ranges(&self.code_span_byte_ranges, pos)
1330 }
1331
1332 pub fn is_in_link(&self, pos: usize) -> bool {
1334 self.link_containing(pos).is_some() || self.image_containing(pos).is_some() || self.is_in_reference_def(pos)
1335 }
1336
1337 pub fn is_in_bare_url(&self, pos: usize) -> bool {
1339 let bare_urls = self.bare_urls();
1340 let idx = bare_urls.partition_point(|url| url.byte_offset <= pos);
1342 idx > 0 && pos < bare_urls[idx - 1].byte_end
1343 }
1344
1345 pub fn inline_config(&self) -> &InlineConfig {
1347 &self.inline_config
1348 }
1349
1350 pub fn colon_fence_details(&self) -> &[CodeBlockDetail] {
1355 &self.colon_fence_details
1356 }
1357
1358 pub fn raw_lines(&self) -> &[&'a str] {
1362 &self.content_lines
1363 }
1364
1365 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
1370 self.inline_config.is_rule_disabled(rule_name, line_number)
1371 }
1372
1373 pub fn code_spans(&self) -> Arc<Vec<CodeSpan>> {
1375 Arc::clone(
1376 self.code_spans_cache
1377 .get_or_init(|| Arc::new(element_parsers::parse_code_spans(self.content, &self.lines))),
1378 )
1379 }
1380
1381 pub fn math_byte_ranges(&self) -> &[(usize, usize)] {
1385 self.math_byte_ranges_cache
1386 .get_or_init(|| crate::utils::skip_context::math_byte_ranges(self.content))
1387 }
1388
1389 pub fn math_spans(&self) -> Arc<Vec<MathSpan>> {
1391 Arc::clone(
1392 self.math_spans_cache
1393 .get_or_init(|| Arc::new(element_parsers::parse_math_spans(self.content, &self.lines))),
1394 )
1395 }
1396
1397 pub fn is_in_math_span(&self, byte_pos: usize) -> bool {
1399 let math_spans = self.math_spans();
1400 let idx = math_spans.partition_point(|span| span.byte_offset <= byte_pos);
1402 idx > 0 && byte_pos < math_spans[idx - 1].byte_end
1403 }
1404
1405 pub fn html_comment_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1407 &self.html_comment_ranges
1408 }
1409
1410 pub fn unterminated_html_comment(&self) -> Option<usize> {
1415 self.unterminated_html_comment
1416 }
1417
1418 pub fn unterminated_obsidian_comment(&self) -> Option<usize> {
1422 self.unterminated_obsidian_comment
1423 }
1424
1425 pub fn is_in_obsidian_comment(&self, byte_pos: usize) -> bool {
1429 Self::binary_search_ranges(&self.obsidian_comment_ranges, byte_pos)
1430 }
1431
1432 pub fn is_position_in_obsidian_comment(&self, line_num: usize, col: usize) -> bool {
1437 if self.obsidian_comment_ranges.is_empty() {
1438 return false;
1439 }
1440
1441 let byte_pos = self.line_index.line_col_to_byte_range(line_num, col).start;
1443 self.is_in_obsidian_comment(byte_pos)
1444 }
1445
1446 pub fn myst_directive_ranges(&self) -> &[(usize, usize)] {
1448 &self.myst_directive_ranges
1449 }
1450
1451 pub fn is_in_myst_role(&self, byte_pos: usize) -> bool {
1453 Self::binary_search_ranges(&self.myst_role_ranges, byte_pos)
1454 }
1455
1456 pub fn is_in_myst_comment(&self, byte_pos: usize) -> bool {
1458 Self::binary_search_ranges(&self.myst_comment_ranges, byte_pos)
1459 }
1460
1461 pub fn is_myst_colon_directive_opener_line(&self, line_num: usize) -> bool {
1468 if !self.flavor.supports_myst_directives() {
1469 return false;
1470 }
1471 self.lines.get(line_num.wrapping_sub(1)).is_some_and(|info| {
1472 info.in_myst_directive
1473 && flavor_detection::myst_colon_directive_opener(info.content(self.content)).is_some()
1474 })
1475 }
1476
1477 fn filter_kramdown_tags(&self, tags: Vec<HtmlTag>) -> Vec<HtmlTag> {
1479 tags.into_iter()
1480 .filter(|tag| {
1481 !self
1482 .lines
1483 .get(tag.line - 1)
1484 .is_some_and(|l| l.in_kramdown_extension_block)
1485 })
1486 .collect()
1487 }
1488
1489 pub fn html_tags(&self) -> Arc<Vec<HtmlTag>> {
1495 Arc::clone(self.html_tags_cache.get_or_init(|| {
1496 let (html_tags, jsx_component_tags) =
1497 element_parsers::parse_html_tags(self.content, &self.lines, &self.code_blocks, self.flavor);
1498 let _ = self
1500 .jsx_component_tags_cache
1501 .set(Arc::new(self.filter_kramdown_tags(jsx_component_tags)));
1502 Arc::new(self.filter_kramdown_tags(html_tags))
1503 }))
1504 }
1505
1506 pub fn jsx_component_tags(&self) -> Arc<Vec<HtmlTag>> {
1509 if let Some(cached) = self.jsx_component_tags_cache.get() {
1510 return Arc::clone(cached);
1511 }
1512 let _ = self.html_tags();
1514 Arc::clone(
1515 self.jsx_component_tags_cache
1516 .get()
1517 .expect("html_tags() populates jsx_component_tags_cache"),
1518 )
1519 }
1520
1521 pub fn emphasis_spans(&self) -> Arc<Vec<EmphasisSpan>> {
1523 Arc::clone(
1524 self.emphasis_spans_cache
1525 .get()
1526 .expect("emphasis_spans_cache initialized during construction"),
1527 )
1528 }
1529
1530 pub fn bare_urls(&self) -> Arc<Vec<BareUrl>> {
1532 Arc::clone(self.bare_urls_cache.get_or_init(|| {
1533 Arc::new(element_parsers::parse_bare_urls(
1534 self.content,
1535 &self.lines,
1536 &self.code_blocks,
1537 ))
1538 }))
1539 }
1540
1541 pub fn lazy_continuation_lines(&self) -> Arc<Vec<LazyContLine>> {
1543 Arc::clone(self.lazy_cont_lines_cache.get_or_init(|| {
1544 Arc::new(element_parsers::detect_lazy_continuation_lines(
1545 self.content,
1546 &self.lines,
1547 &self.line_offsets,
1548 ))
1549 }))
1550 }
1551
1552 pub fn has_mixed_list_nesting(&self) -> bool {
1556 *self
1557 .has_mixed_list_nesting_cache
1558 .get_or_init(|| self.compute_mixed_list_nesting())
1559 }
1560
1561 fn compute_mixed_list_nesting(&self) -> bool {
1563 let mut stack: Vec<(usize, bool)> = Vec::new();
1568 let mut last_was_blank = false;
1569
1570 for line_info in &self.lines {
1571 if line_info.in_code_block
1573 || line_info.in_front_matter
1574 || line_info.in_mkdocstrings
1575 || line_info.in_html_comment
1576 || line_info.in_mdx_comment
1577 || line_info.in_esm_block
1578 {
1579 continue;
1580 }
1581
1582 if line_info.is_blank {
1584 last_was_blank = true;
1585 continue;
1586 }
1587
1588 if let Some(list_item) = &line_info.list_item {
1589 let current_pos = if list_item.marker_column == 1 {
1591 0
1592 } else {
1593 list_item.marker_column
1594 };
1595
1596 if last_was_blank && current_pos == 0 {
1598 stack.clear();
1599 }
1600 last_was_blank = false;
1601
1602 while let Some(&(pos, _)) = stack.last() {
1604 if pos >= current_pos {
1605 stack.pop();
1606 } else {
1607 break;
1608 }
1609 }
1610
1611 if let Some(&(_, parent_is_ordered)) = stack.last()
1613 && parent_is_ordered != list_item.is_ordered
1614 {
1615 return true; }
1617
1618 stack.push((current_pos, list_item.is_ordered));
1619 } else {
1620 last_was_blank = false;
1622 }
1623 }
1624
1625 false
1626 }
1627
1628 pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
1634 match self.line_offsets.binary_search(&offset) {
1635 Ok(line) => (line + 1, 1),
1636 Err(line) => {
1637 let line_start = self.line_offsets.get(line.wrapping_sub(1)).copied().unwrap_or(0);
1638 let col = byte_to_char_count(&self.content[line_start..], offset.saturating_sub(line_start));
1640 (line, col)
1641 }
1642 }
1643 }
1644
1645 pub fn line_start_byte(&self, line_number: usize) -> Option<usize> {
1651 self.line_index.get_line_start_byte(line_number)
1652 }
1653
1654 pub fn line_column_byte_range(&self, line_number: usize, column: usize) -> Range<usize> {
1660 self.line_index.line_col_to_byte_range(line_number, column)
1661 }
1662
1663 pub fn line_column_byte_range_with_length(&self, line_number: usize, column: usize, length: usize) -> Range<usize> {
1668 self.line_index
1669 .line_col_to_byte_range_with_length(line_number, column, length)
1670 }
1671
1672 pub fn whole_line_byte_range(&self, line_number: usize) -> Range<usize> {
1675 self.line_index.whole_line_range(line_number)
1676 }
1677
1678 pub fn line_text_byte_range(&self, line_number: usize, start_column: usize, end_column: usize) -> Range<usize> {
1683 self.line_index.line_text_range(line_number, start_column, end_column)
1684 }
1685
1686 pub fn line_content_byte_range(&self, line_number: usize) -> Range<usize> {
1689 self.line_index.line_content_range(line_number)
1690 }
1691
1692 pub fn line_span_byte_range(&self, start_line: usize, end_line: usize) -> Range<usize> {
1694 self.line_index.multi_line_range(start_line, end_line)
1695 }
1696
1697 pub fn is_in_code_block_or_span(&self, pos: usize) -> bool {
1699 if CodeBlockUtils::is_in_code_block_or_span(&self.code_blocks, pos) {
1701 return true;
1702 }
1703
1704 self.is_byte_offset_in_code_span(pos)
1706 }
1707
1708 pub fn line_info(&self, line_num: usize) -> Option<&LineInfo> {
1710 if line_num > 0 {
1711 self.lines.get(line_num - 1)
1712 } else {
1713 None
1714 }
1715 }
1716
1717 pub fn links(&self) -> &[ParsedLink<'a>] {
1719 &self.links
1720 }
1721
1722 pub fn images(&self) -> &[ParsedImage<'a>] {
1724 &self.images
1725 }
1726
1727 pub fn broken_links(&self) -> &[BrokenLinkInfo] {
1729 &self.broken_links
1730 }
1731
1732 pub fn footnote_references(&self) -> &[FootnoteRef] {
1734 &self.footnote_refs
1735 }
1736
1737 pub fn reference_definitions(&self) -> &[ReferenceDef] {
1739 &self.reference_defs
1740 }
1741
1742 pub fn links_on_line(&self, line_number: usize) -> &[ParsedLink<'a>] {
1744 let start = self.links.partition_point(|link| link.line < line_number);
1745 let end = self.links.partition_point(|link| link.line <= line_number);
1746 &self.links[start..end]
1747 }
1748
1749 pub fn images_on_line(&self, line_number: usize) -> &[ParsedImage<'a>] {
1751 let start = self.images.partition_point(|image| image.line < line_number);
1752 let end = self.images.partition_point(|image| image.line <= line_number);
1753 &self.images[start..end]
1754 }
1755
1756 pub fn link_starting_at(&self, byte_offset: usize) -> Option<&ParsedLink<'a>> {
1758 self.links
1759 .binary_search_by_key(&byte_offset, |link| link.byte_offset)
1760 .ok()
1761 .map(|index| &self.links[index])
1762 }
1763
1764 pub fn image_starting_at(&self, byte_offset: usize) -> Option<&ParsedImage<'a>> {
1766 self.images
1767 .binary_search_by_key(&byte_offset, |image| image.byte_offset)
1768 .ok()
1769 .map(|index| &self.images[index])
1770 }
1771
1772 pub fn link_containing(&self, byte_offset: usize) -> Option<&ParsedLink<'a>> {
1774 let index = self.links.partition_point(|link| link.byte_offset <= byte_offset);
1775 self.links
1776 .get(index.checked_sub(1)?)
1777 .filter(|link| byte_offset < link.byte_end)
1778 }
1779
1780 pub fn image_containing(&self, byte_offset: usize) -> Option<&ParsedImage<'a>> {
1782 let index = self.images.partition_point(|image| image.byte_offset <= byte_offset);
1783 self.images
1784 .get(index.checked_sub(1)?)
1785 .filter(|image| byte_offset < image.byte_end)
1786 }
1787
1788 pub fn links_starting_before_or_at(&self, byte_offset: usize) -> &[ParsedLink<'a>] {
1790 let end = self.links.partition_point(|link| link.byte_offset <= byte_offset);
1791 &self.links[..end]
1792 }
1793
1794 pub fn reference_definition(&self, ref_id: &str) -> Option<&ReferenceDef> {
1796 let normalized_id = ref_id.to_lowercase();
1797 self.reference_defs_map
1798 .get(&normalized_id)
1799 .map(|&index| &self.reference_defs[index])
1800 }
1801
1802 pub fn get_reference_url(&self, ref_id: &str) -> Option<&str> {
1804 self.reference_definition(ref_id)
1805 .map(|definition| definition.url.as_str())
1806 }
1807
1808 pub fn is_in_list_block(&self, line_num: usize) -> bool {
1810 if line_num == 0 || line_num > self.lines.len() {
1811 return false;
1812 }
1813 self.lines[line_num - 1].in_list_block
1814 }
1815
1816 pub fn is_in_html_block(&self, line_num: usize) -> bool {
1818 if line_num == 0 || line_num > self.lines.len() {
1819 return false;
1820 }
1821 self.lines[line_num - 1].in_html_block
1822 }
1823
1824 pub fn is_in_table_block(&self, line_num: usize) -> bool {
1830 if line_num == 0 || line_num > self.lines.len() {
1831 return false;
1832 }
1833 self.lines[line_num - 1].in_table_block
1834 }
1835
1836 pub fn is_in_code_span(&self, line_num: usize, col: usize) -> bool {
1838 if line_num == 0 || line_num > self.lines.len() {
1839 return false;
1840 }
1841
1842 let col_0indexed = if col > 0 { col - 1 } else { 0 };
1846 let code_spans = self.code_spans();
1847 code_spans.iter().any(|span| {
1848 if line_num < span.line || line_num > span.end_line {
1850 return false;
1851 }
1852
1853 if span.line == span.end_line {
1854 col_0indexed >= span.start_col && col_0indexed < span.end_col
1856 } else if line_num == span.line {
1857 col_0indexed >= span.start_col
1859 } else if line_num == span.end_line {
1860 col_0indexed < span.end_col
1862 } else {
1863 true
1865 }
1866 })
1867 }
1868
1869 #[inline]
1871 pub fn is_byte_offset_in_code_span(&self, byte_offset: usize) -> bool {
1872 let code_spans = self.code_spans();
1873 let idx = code_spans.partition_point(|span| span.byte_offset <= byte_offset);
1874 idx > 0 && byte_offset < code_spans[idx - 1].byte_end
1875 }
1876
1877 #[inline]
1879 pub fn is_in_reference_def(&self, byte_pos: usize) -> bool {
1880 let idx = self.reference_defs.partition_point(|rd| rd.byte_offset <= byte_pos);
1881 idx > 0 && byte_pos < self.reference_defs[idx - 1].byte_end
1882 }
1883
1884 #[inline]
1886 pub fn is_in_html_comment(&self, byte_pos: usize) -> bool {
1887 let idx = self.html_comment_ranges.partition_point(|r| r.start <= byte_pos);
1888 idx > 0 && byte_pos < self.html_comment_ranges[idx - 1].end
1889 }
1890
1891 #[inline]
1894 pub fn is_in_html_tag(&self, byte_pos: usize) -> bool {
1895 let tags = self.html_tags();
1896 let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1897 idx > 0 && byte_pos < tags[idx - 1].byte_end
1898 }
1899
1900 #[inline]
1904 pub fn is_in_jsx_component_tag(&self, byte_pos: usize) -> bool {
1905 if !self.flavor.supports_jsx() {
1906 return false;
1907 }
1908 let tags = self.jsx_component_tags();
1909 let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1910 idx > 0 && byte_pos < tags[idx - 1].byte_end
1911 }
1912
1913 pub fn is_in_jinja_range(&self, byte_pos: usize) -> bool {
1915 Self::binary_search_ranges(&self.jinja_ranges, byte_pos)
1916 }
1917
1918 #[inline]
1920 pub fn is_in_jsx_expression(&self, byte_pos: usize) -> bool {
1921 Self::binary_search_ranges(&self.jsx_expression_ranges, byte_pos)
1922 }
1923
1924 #[inline]
1926 pub fn is_in_mdx_comment(&self, byte_pos: usize) -> bool {
1927 Self::binary_search_ranges(&self.mdx_comment_ranges, byte_pos)
1928 }
1929
1930 #[inline]
1933 pub fn is_in_citation(&self, byte_pos: usize) -> bool {
1934 let idx = self.citation_ranges.partition_point(|r| r.start <= byte_pos);
1935 idx > 0 && byte_pos < self.citation_ranges[idx - 1].end
1936 }
1937
1938 #[inline]
1940 pub fn citation_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1941 &self.citation_ranges
1942 }
1943
1944 #[inline]
1947 pub fn is_in_div_block(&self, byte_pos: usize) -> bool {
1948 let idx = self.pandoc_div_ranges.partition_point(|r| r.start <= byte_pos);
1949 idx > 0 && byte_pos < self.pandoc_div_ranges[idx - 1].end
1950 }
1951
1952 #[inline]
1955 pub fn is_in_inline_footnote(&self, byte_pos: usize) -> bool {
1956 let idx = self.inline_footnote_ranges.partition_point(|r| r.start <= byte_pos);
1957 idx > 0 && byte_pos < self.inline_footnote_ranges[idx - 1].end
1958 }
1959
1960 #[inline]
1963 pub fn is_in_example_list_marker(&self, byte_pos: usize) -> bool {
1964 let idx = self.example_list_marker_ranges.partition_point(|r| r.start <= byte_pos);
1965 idx > 0 && byte_pos < self.example_list_marker_ranges[idx - 1].end
1966 }
1967
1968 #[inline]
1971 pub fn is_in_example_reference(&self, byte_pos: usize) -> bool {
1972 let idx = self.example_reference_ranges.partition_point(|r| r.start <= byte_pos);
1973 idx > 0 && byte_pos < self.example_reference_ranges[idx - 1].end
1974 }
1975
1976 #[inline]
1979 pub fn is_in_subscript_or_superscript(&self, byte_pos: usize) -> bool {
1980 let idx = self.sub_super_ranges.partition_point(|r| r.start <= byte_pos);
1981 idx > 0 && byte_pos < self.sub_super_ranges[idx - 1].end
1982 }
1983
1984 #[inline]
1988 pub fn is_in_inline_code_attr(&self, byte_pos: usize) -> bool {
1989 let idx = self.inline_code_attr_ranges.partition_point(|r| r.start <= byte_pos);
1990 idx > 0 && byte_pos < self.inline_code_attr_ranges[idx - 1].end
1991 }
1992
1993 #[inline]
1996 pub fn is_in_bracketed_span(&self, byte_pos: usize) -> bool {
1997 let idx = self.bracketed_span_ranges.partition_point(|r| r.start <= byte_pos);
1998 idx > 0 && byte_pos < self.bracketed_span_ranges[idx - 1].end
1999 }
2000
2001 #[inline]
2004 pub fn is_in_line_block(&self, byte_pos: usize) -> bool {
2005 let idx = self.line_block_ranges.partition_point(|r| r.start <= byte_pos);
2006 idx > 0 && byte_pos < self.line_block_ranges[idx - 1].end
2007 }
2008
2009 #[inline]
2013 pub fn is_in_pipe_table_caption(&self, byte_pos: usize) -> bool {
2014 let idx = self.pipe_table_caption_ranges.partition_point(|r| r.start <= byte_pos);
2015 idx > 0 && byte_pos < self.pipe_table_caption_ranges[idx - 1].end
2016 }
2017
2018 #[inline]
2021 pub fn is_in_pandoc_metadata(&self, byte_pos: usize) -> bool {
2022 let idx = self.pandoc_metadata_ranges.partition_point(|r| r.start <= byte_pos);
2023 idx > 0 && byte_pos < self.pandoc_metadata_ranges[idx - 1].end
2024 }
2025
2026 #[inline]
2029 pub fn is_in_grid_table(&self, byte_pos: usize) -> bool {
2030 let idx = self.grid_table_ranges.partition_point(|r| r.start <= byte_pos);
2031 idx > 0 && byte_pos < self.grid_table_ranges[idx - 1].end
2032 }
2033
2034 #[inline]
2037 pub fn is_in_multi_line_table(&self, byte_pos: usize) -> bool {
2038 let idx = self.multi_line_table_ranges.partition_point(|r| r.start <= byte_pos);
2039 idx > 0 && byte_pos < self.multi_line_table_ranges[idx - 1].end
2040 }
2041
2042 pub fn matches_implicit_header_reference(&self, link_text: &str) -> bool {
2047 let slug = crate::utils::pandoc::pandoc_header_slug(link_text);
2048 self.pandoc_header_slugs.contains(&slug)
2049 }
2050
2051 #[inline]
2057 pub fn has_pandoc_slug(&self, slug: &str) -> bool {
2058 self.pandoc_header_slugs.contains(slug)
2059 }
2060
2061 #[inline]
2063 pub fn is_in_shortcode(&self, byte_pos: usize) -> bool {
2064 Self::binary_search_ranges(&self.shortcode_ranges, byte_pos)
2065 }
2066
2067 #[inline]
2069 pub fn shortcode_ranges(&self) -> &[(usize, usize)] {
2070 &self.shortcode_ranges
2071 }
2072
2073 pub fn is_in_link_title(&self, byte_pos: usize) -> bool {
2075 Self::binary_search_ranges(&self.link_title_ranges, byte_pos)
2076 }
2077
2078 pub fn has_char(&self, ch: char) -> bool {
2080 match ch {
2081 '#' => self.char_frequency.hash_count > 0,
2082 '*' => self.char_frequency.asterisk_count > 0,
2083 '_' => self.char_frequency.underscore_count > 0,
2084 '-' => self.char_frequency.hyphen_count > 0,
2085 '+' => self.char_frequency.plus_count > 0,
2086 '>' => self.char_frequency.gt_count > 0,
2087 '|' => self.char_frequency.pipe_count > 0,
2088 '[' => self.char_frequency.bracket_count > 0,
2089 '`' => self.char_frequency.backtick_count > 0,
2090 '<' => self.char_frequency.lt_count > 0,
2091 '!' => self.char_frequency.exclamation_count > 0,
2092 '\n' => self.char_frequency.newline_count > 0,
2093 _ => self.content.contains(ch), }
2095 }
2096
2097 pub fn char_count(&self, ch: char) -> usize {
2099 match ch {
2100 '#' => self.char_frequency.hash_count,
2101 '*' => self.char_frequency.asterisk_count,
2102 '_' => self.char_frequency.underscore_count,
2103 '-' => self.char_frequency.hyphen_count,
2104 '+' => self.char_frequency.plus_count,
2105 '>' => self.char_frequency.gt_count,
2106 '|' => self.char_frequency.pipe_count,
2107 '[' => self.char_frequency.bracket_count,
2108 '`' => self.char_frequency.backtick_count,
2109 '<' => self.char_frequency.lt_count,
2110 '!' => self.char_frequency.exclamation_count,
2111 '\n' => self.char_frequency.newline_count,
2112 _ => self.content.matches(ch).count(), }
2114 }
2115
2116 pub fn likely_has_headings(&self) -> bool {
2118 self.char_frequency.hash_count > 0 || self.char_frequency.hyphen_count > 2 || self.content.contains('=') }
2120
2121 pub fn likely_has_lists(&self) -> bool {
2125 self.char_frequency.asterisk_count > 0
2126 || self.char_frequency.hyphen_count > 0
2127 || self.char_frequency.plus_count > 0
2128 }
2129
2130 pub fn likely_has_emphasis(&self) -> bool {
2132 self.char_frequency.asterisk_count > 1 || self.char_frequency.underscore_count > 1
2133 }
2134
2135 pub fn likely_has_tables(&self) -> bool {
2137 self.char_frequency.pipe_count > 2
2138 }
2139
2140 pub fn likely_has_blockquotes(&self) -> bool {
2142 self.char_frequency.gt_count > 0
2143 }
2144
2145 pub fn likely_has_code(&self) -> bool {
2147 self.char_frequency.backtick_count > 0
2148 }
2149
2150 pub fn likely_has_links_or_images(&self) -> bool {
2152 self.char_frequency.bracket_count > 0 || self.char_frequency.exclamation_count > 0
2153 }
2154
2155 pub fn likely_has_html(&self) -> bool {
2157 self.char_frequency.lt_count > 0
2158 }
2159
2160 pub fn blockquote_prefix_for_blank_line(&self, line_idx: usize) -> String {
2165 if let Some(line_info) = self.lines.get(line_idx)
2166 && let Some(ref bq) = line_info.blockquote
2167 {
2168 bq.prefix.trim_end().to_string()
2169 } else {
2170 String::new()
2171 }
2172 }
2173
2174 #[inline]
2185 fn find_line_for_offset(lines: &[LineInfo], content: &str, byte_offset: usize) -> (usize, usize, usize) {
2186 let idx = match lines.binary_search_by(|line| {
2188 if byte_offset < line.byte_offset {
2189 std::cmp::Ordering::Greater
2190 } else if byte_offset > line.byte_offset + line.byte_len {
2191 std::cmp::Ordering::Less
2192 } else {
2193 std::cmp::Ordering::Equal
2194 }
2195 }) {
2196 Ok(idx) => idx,
2197 Err(idx) => idx.saturating_sub(1),
2198 };
2199
2200 let line = &lines[idx];
2201 let line_num = idx + 1;
2202 let byte_col = byte_offset.saturating_sub(line.byte_offset);
2203 let col = byte_to_char_count(line.content(content), byte_col) - 1;
2206
2207 (idx, line_num, col)
2208 }
2209
2210 #[inline]
2212 fn is_offset_in_code_span(code_spans: &[CodeSpan], offset: usize) -> bool {
2213 let idx = code_spans.partition_point(|span| span.byte_offset <= offset);
2215
2216 if idx > 0 {
2218 let span = &code_spans[idx - 1];
2219 if offset >= span.byte_offset && offset < span.byte_end {
2220 return true;
2221 }
2222 }
2223
2224 false
2225 }
2226
2227 #[must_use]
2247 pub fn valid_headings(&self) -> ValidHeadingsIter<'_> {
2248 ValidHeadingsIter::new(&self.lines)
2249 }
2250
2251 #[must_use]
2255 pub fn has_valid_headings(&self) -> bool {
2256 self.lines
2257 .iter()
2258 .any(|line| line.heading.as_ref().is_some_and(|h| h.is_valid))
2259 }
2260
2261 #[must_use]
2263 pub fn list_items(&self) -> ParsedListItemsIter<'_> {
2264 ParsedListItemsIter::new(&self.lines)
2265 }
2266
2267 #[must_use]
2269 pub fn list_item_on_line(&self, line_num: usize) -> Option<ParsedListItem<'_>> {
2270 let line_info = self.lines.get(line_num.checked_sub(1)?)?;
2271 Some(ParsedListItem::new(
2272 line_num,
2273 line_info.list_item.as_deref()?,
2274 line_info,
2275 ))
2276 }
2277
2278 #[must_use]
2280 pub fn parsed_list_blocks(&self) -> ParsedListBlocks<'_> {
2281 ParsedListBlocks::new(&self.list_blocks, &self.lines)
2282 }
2283
2284 #[must_use]
2288 pub fn list_block_item_groups(&self, block: &ListBlock) -> Vec<Vec<usize>> {
2289 list_blocks::item_lines_by_list(self.content, &self.lines, block)
2290 }
2291
2292 #[must_use]
2294 pub fn has_list_items(&self) -> bool {
2295 self.lines.iter().any(|line| line.list_item.is_some())
2296 }
2297
2298 #[must_use]
2300 pub fn has_unordered_list_items(&self) -> bool {
2301 self.lines
2302 .iter()
2303 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
2304 }
2305
2306 #[must_use]
2308 pub fn commonmark_ordered_lists(&self) -> CommonMarkOrderedLists<'_> {
2309 let lists = self
2310 .commonmark_ordered_lists_cache
2311 .get_or_init(|| build_commonmark_ordered_lists(&self.lines, &self.line_to_list, &self.list_start_values));
2312 CommonMarkOrderedLists::new(lists, &self.lines)
2313 }
2314
2315 #[must_use]
2323 pub fn headings(&self) -> ParsedHeadingsIter<'_> {
2324 ParsedHeadingsIter::new(&self.lines, &self.blockquote_headings)
2325 }
2326
2327 #[must_use]
2329 pub fn heading_on_line(&self, line_num: usize) -> Option<ParsedHeading<'_>> {
2330 let idx = line_num.checked_sub(1)?;
2331 let line_info = self.lines.get(idx)?;
2332 let (heading, blockquote_depth) = match line_info.heading.as_deref() {
2333 Some(heading) => (heading, 0),
2334 None => (
2335 self.blockquote_headings.get(idx)?.as_deref()?,
2336 line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level),
2337 ),
2338 };
2339 Some(ParsedHeading {
2340 line_num,
2341 heading,
2342 line_info,
2343 blockquote_depth,
2344 })
2345 }
2346}
2347
2348fn container_comment_range(
2360 opener: usize,
2361 containers: &flavor_detection::ContainerLines,
2362 lines: &[types::LineInfo],
2363 content: &str,
2364) -> Option<crate::utils::skip_context::ByteRange> {
2365 let line_index = lines
2366 .partition_point(|line| line.byte_offset <= opener)
2367 .checked_sub(1)?;
2368 let line = lines.get(line_index)?;
2369 if line.byte_offset + line.indent != opener {
2370 return None;
2371 }
2372 if !containers.is_container_body(line_index) {
2373 return None;
2374 }
2375 let end_line = lines.get(containers.body_end_line(line_index)?)?;
2376 Some(crate::utils::skip_context::ByteRange {
2377 start: opener,
2378 end: (end_line.byte_offset + end_line.byte_len).min(content.len()),
2379 })
2380}
2381
2382fn detect_footnote_definitions(content: &str, lines: &mut [types::LineInfo], line_offsets: &[usize]) {
2391 use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
2392
2393 let options = crate::utils::rumdl_parser_options();
2394 let parser = Parser::new_ext(content, options).into_offset_iter();
2395
2396 let mut footnote_ranges: Vec<(usize, usize)> = Vec::new();
2398 let mut fenced_code_ranges: Vec<(usize, usize)> = Vec::new();
2399 let mut in_footnote = false;
2400
2401 for (event, range) in parser {
2402 match event {
2403 Event::Start(Tag::FootnoteDefinition(_)) => {
2404 in_footnote = true;
2405 footnote_ranges.push((range.start, range.end));
2406 }
2407 Event::End(TagEnd::FootnoteDefinition) => {
2408 in_footnote = false;
2409 }
2410 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) if in_footnote => {
2411 fenced_code_ranges.push((range.start, range.end));
2412 }
2413 _ => {}
2414 }
2415 }
2416
2417 let byte_to_line = |byte_offset: usize| -> usize {
2418 line_offsets
2419 .partition_point(|&offset| offset <= byte_offset)
2420 .saturating_sub(1)
2421 };
2422
2423 for &(start, end) in &footnote_ranges {
2425 let start_line = byte_to_line(start);
2426 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
2427
2428 for line in &mut lines[start_line..end_line] {
2429 line.in_footnote_definition = true;
2430 line.in_code_block = false;
2431 }
2432 }
2433
2434 for &(start, end) in &fenced_code_ranges {
2436 let start_line = byte_to_line(start);
2437 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
2438
2439 for line in &mut lines[start_line..end_line] {
2440 line.in_code_block = true;
2441 }
2442 }
2443}