1pub mod types;
2pub use types::*;
3
4mod element_parsers;
5mod flavor_detection;
6mod heading_detection;
7mod line_computation;
8mod link_parser;
9mod list_blocks;
10#[cfg(test)]
11mod tests;
12
13use crate::config::MarkdownFlavor;
14use crate::inline_config::InlineConfig;
15use crate::rules::front_matter_utils::FrontMatterUtils;
16use crate::utils::code_block_utils::{CodeBlockDetail, CodeBlockUtils};
17use crate::utils::range_utils::byte_to_char_count;
18use std::collections::HashMap;
19use std::path::PathBuf;
20
21#[cfg(not(target_arch = "wasm32"))]
23macro_rules! profile_section {
24 ($name:expr, $profile:expr, $code:expr) => {{
25 let start = std::time::Instant::now();
26 let result = $code;
27 if $profile {
28 eprintln!("[PROFILE] {}: {:?}", $name, start.elapsed());
29 }
30 result
31 }};
32}
33
34#[cfg(target_arch = "wasm32")]
35macro_rules! profile_section {
36 ($name:expr, $profile:expr, $code:expr) => {{ $code }};
37}
38
39pub(super) struct SkipByteRanges<'a> {
42 pub(super) html_comment_ranges: &'a [crate::utils::skip_context::ByteRange],
43 pub(super) autodoc_ranges: &'a [crate::utils::skip_context::ByteRange],
44 pub(super) pandoc_div_ranges: &'a [crate::utils::skip_context::ByteRange],
45 pub(super) pymdown_block_ranges: &'a [crate::utils::skip_context::ByteRange],
46}
47
48use std::sync::{Arc, OnceLock};
49
50pub(super) type ListItemMap = std::collections::HashMap<usize, (bool, String, usize, usize, Option<usize>)>;
52
53pub(super) type ByteRanges = Vec<(usize, usize)>;
55
56pub struct LintContext<'a> {
57 pub content: &'a str,
58 content_lines: Vec<&'a str>, pub line_offsets: Vec<usize>,
60 pub code_blocks: Vec<(usize, usize)>, pub code_block_details: Vec<CodeBlockDetail>, pub strong_spans: Vec<crate::utils::code_block_utils::StrongSpanDetail>, pub line_to_list: crate::utils::code_block_utils::LineToListMap, pub list_start_values: crate::utils::code_block_utils::ListStartValues, pub lines: Vec<LineInfo>, pub links: Vec<ParsedLink<'a>>, pub images: Vec<ParsedImage<'a>>, pub broken_links: Vec<BrokenLinkInfo>, pub footnote_refs: Vec<FootnoteRef>, pub 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>, pub line_index: crate::utils::range_utils::LineIndex<'a>, jinja_ranges: Vec<(usize, usize)>, pub flavor: MarkdownFlavor, pub source_file: Option<PathBuf>, 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_ranges: Vec<(usize, usize)>, 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)>, 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, }
116
117impl<'a> LintContext<'a> {
118 pub fn new(content: &'a str, flavor: MarkdownFlavor, source_file: Option<PathBuf>) -> Self {
119 #[cfg(not(target_arch = "wasm32"))]
120 let profile = std::env::var("RUMDL_PROFILE_QUADRATIC").is_ok();
121
122 let line_offsets = profile_section!("Line offsets", profile, {
123 let mut offsets = vec![0];
124 for (i, c) in content.char_indices() {
125 if c == '\n' {
126 offsets.push(i + 1);
127 }
128 }
129 offsets
130 });
131
132 let content_lines: Vec<&str> = content.lines().collect();
134
135 #[allow(clippy::disallowed_methods)]
139 let front_matter_end = FrontMatterUtils::get_front_matter_end_line(content);
140
141 let parse_result = profile_section!(
143 "Code blocks",
144 profile,
145 CodeBlockUtils::detect_code_blocks_and_spans(content)
146 );
147 let mut code_blocks = parse_result.code_blocks;
148 let code_span_ranges = parse_result.code_spans;
149 let code_block_details = parse_result.code_block_details;
150 let strong_spans = parse_result.strong_spans;
151 let line_to_list = parse_result.line_to_list;
152 let list_start_values = parse_result.list_start_values;
153
154 let fenced_code_block_ranges: Vec<(usize, usize)> = code_block_details
162 .iter()
163 .filter(|detail| detail.is_fenced)
164 .map(|detail| (detail.start, detail.end))
165 .collect();
166 let html_comment_ranges = profile_section!(
167 "HTML comment ranges",
168 profile,
169 crate::utils::skip_context::compute_html_comment_ranges_filtered(
170 content,
171 &code_span_ranges,
172 &fenced_code_block_ranges
173 )
174 );
175
176 let autodoc_ranges = profile_section!("Autodoc block ranges", profile, {
180 if flavor.supports_colon_code_fences() || flavor.supports_myst_directives() {
181 Vec::new()
182 } else {
183 crate::utils::mkdocstrings_refs::detect_autodoc_block_ranges(content)
184 }
185 });
186
187 let pandoc_div_ranges = profile_section!("Pandoc div ranges", profile, {
189 if flavor.is_pandoc_compatible() {
190 crate::utils::pandoc::detect_div_block_ranges(content)
191 } else {
192 Vec::new()
193 }
194 });
195
196 let pymdown_block_ranges = profile_section!("PyMdown block ranges", profile, {
198 if flavor == MarkdownFlavor::MkDocs {
199 crate::utils::pymdown_blocks::detect_block_ranges(content)
200 } else {
201 Vec::new()
202 }
203 });
204
205 let skip_ranges = SkipByteRanges {
208 html_comment_ranges: &html_comment_ranges,
209 autodoc_ranges: &autodoc_ranges,
210 pandoc_div_ranges: &pandoc_div_ranges,
211 pymdown_block_ranges: &pymdown_block_ranges,
212 };
213 let (mut lines, emphasis_spans) = profile_section!(
214 "Basic line info",
215 profile,
216 line_computation::compute_basic_line_info(
217 content,
218 &content_lines,
219 &line_offsets,
220 &code_blocks,
221 flavor,
222 &skip_ranges,
223 front_matter_end,
224 )
225 );
226
227 profile_section!(
229 "HTML blocks",
230 profile,
231 heading_detection::detect_html_blocks(content, &mut lines)
232 );
233
234 profile_section!(
236 "ESM blocks",
237 profile,
238 flavor_detection::detect_esm_blocks(content, &mut lines, flavor)
239 );
240
241 profile_section!(
243 "JSX block detection",
244 profile,
245 flavor_detection::detect_jsx_blocks(content, &mut lines, flavor)
246 );
247
248 let (jsx_expression_ranges, mdx_comment_ranges) = profile_section!(
250 "JSX/MDX detection",
251 profile,
252 flavor_detection::detect_jsx_and_mdx_comments(content, &mut lines, flavor, &code_blocks)
253 );
254
255 profile_section!(
260 "Markdown-in-HTML blocks",
261 profile,
262 flavor_detection::detect_markdown_html_blocks(&content_lines, &mut lines)
263 );
264
265 profile_section!(
267 "MkDocs constructs",
268 profile,
269 flavor_detection::detect_mkdocs_line_info(&content_lines, &mut lines, flavor)
270 );
271
272 profile_section!(
277 "Footnote definitions",
278 profile,
279 detect_footnote_definitions(content, &mut lines, &line_offsets)
280 );
281
282 {
285 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
286 for &(start, end) in &code_blocks {
287 let start_line = line_offsets
288 .partition_point(|&offset| offset <= start)
289 .saturating_sub(1);
290 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
291
292 let mut sub_start: Option<usize> = None;
293 for (i, &offset) in line_offsets[start_line..end_line]
294 .iter()
295 .enumerate()
296 .map(|(j, o)| (j + start_line, o))
297 {
298 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
299 if is_real_code && sub_start.is_none() {
300 let byte_start = if i == start_line { start } else { offset };
301 sub_start = Some(byte_start);
302 } else if !is_real_code && sub_start.is_some() {
303 new_code_blocks.push((sub_start.unwrap(), offset));
304 sub_start = None;
305 }
306 }
307 if let Some(s) = sub_start {
308 new_code_blocks.push((s, end));
309 }
310 }
311 code_blocks = new_code_blocks;
312 }
313
314 let has_markdown_html = lines.iter().any(|l| l.in_mkdocs_html_markdown);
322 if flavor == MarkdownFlavor::MkDocs || has_markdown_html {
323 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
324 for &(start, end) in &code_blocks {
325 let start_line = line_offsets
326 .partition_point(|&offset| offset <= start)
327 .saturating_sub(1);
328 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
329
330 let mut sub_start: Option<usize> = None;
332 for (i, &offset) in line_offsets[start_line..end_line]
333 .iter()
334 .enumerate()
335 .map(|(j, o)| (j + start_line, o))
336 {
337 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
338 if is_real_code && sub_start.is_none() {
339 let byte_start = if i == start_line { start } else { offset };
340 sub_start = Some(byte_start);
341 } else if !is_real_code && sub_start.is_some() {
342 new_code_blocks.push((sub_start.unwrap(), offset));
343 sub_start = None;
344 }
345 }
346 if let Some(s) = sub_start {
347 new_code_blocks.push((s, end));
348 }
349 }
350 code_blocks = new_code_blocks;
351 }
352
353 if flavor.supports_jsx() {
357 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
358 for &(start, end) in &code_blocks {
359 let start_line = line_offsets
360 .partition_point(|&offset| offset <= start)
361 .saturating_sub(1);
362 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
363
364 let mut sub_start: Option<usize> = None;
365 for (i, &offset) in line_offsets[start_line..end_line]
366 .iter()
367 .enumerate()
368 .map(|(j, o)| (j + start_line, o))
369 {
370 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
371 if is_real_code && sub_start.is_none() {
372 let byte_start = if i == start_line { start } else { offset };
373 sub_start = Some(byte_start);
374 } else if !is_real_code && sub_start.is_some() {
375 new_code_blocks.push((sub_start.unwrap(), offset));
376 sub_start = None;
377 }
378 }
379 if let Some(s) = sub_start {
380 new_code_blocks.push((s, end));
381 }
382 }
383 code_blocks = new_code_blocks;
384
385 let mut jsx_fence_ranges: Vec<(usize, usize)> = Vec::new();
392 let mut run: Option<(usize, usize)> = None;
393 for line in &lines {
394 if line.in_jsx_block && line.in_code_block {
395 let line_end = line.byte_offset + line.byte_len;
396 match &mut run {
397 Some((_, end)) => *end = line_end,
398 None => run = Some((line.byte_offset, line_end)),
399 }
400 } else if let Some(r) = run.take() {
401 jsx_fence_ranges.push(r);
402 }
403 }
404 if let Some(r) = run.take() {
405 jsx_fence_ranges.push(r);
406 }
407 if !jsx_fence_ranges.is_empty() {
408 code_blocks.extend(jsx_fence_ranges);
409 code_blocks.sort_by_key(|&(start, _)| start);
410 }
411 }
412
413 let colon_fence_ranges = profile_section!(
416 "Azure colon fence detection",
417 profile,
418 flavor_detection::detect_azure_colon_fences(content, &mut lines, flavor)
419 );
420 if !colon_fence_ranges.is_empty() {
421 code_blocks.extend(colon_fence_ranges.iter().copied());
422 code_blocks.sort_by_key(|&(start, _)| start);
423 }
424
425 let myst_directive_ranges = profile_section!(
428 "MyST colon directives",
429 profile,
430 flavor_detection::detect_myst_colon_directives(content, &mut lines, flavor)
431 );
432
433 let myst_comment_ranges = profile_section!(
435 "MyST comments",
436 profile,
437 flavor_detection::detect_myst_comments(content, &mut lines, flavor)
438 );
439
440 profile_section!(
443 "MyST backtick directives",
444 profile,
445 flavor_detection::detect_myst_backtick_directives(
446 content,
447 &mut lines,
448 flavor,
449 &code_block_details,
450 &line_offsets
451 )
452 );
453
454 if flavor.supports_myst_directives() {
457 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
458 for &(start, end) in &code_blocks {
459 let start_line = line_offsets
460 .partition_point(|&offset| offset <= start)
461 .saturating_sub(1);
462 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
463
464 let mut sub_start: Option<usize> = None;
465 for (i, &offset) in line_offsets[start_line..end_line]
466 .iter()
467 .enumerate()
468 .map(|(j, o)| (j + start_line, o))
469 {
470 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
471 if is_real_code && sub_start.is_none() {
472 let byte_start = if i == start_line { start } else { offset };
473 sub_start = Some(byte_start);
474 } else if !is_real_code && sub_start.is_some() {
475 new_code_blocks.push((sub_start.unwrap(), offset));
476 sub_start = None;
477 }
478 }
479 if let Some(s) = sub_start {
480 new_code_blocks.push((s, end));
481 }
482 }
483 code_blocks = new_code_blocks;
484 }
485
486 profile_section!(
488 "Kramdown constructs",
489 profile,
490 flavor_detection::detect_kramdown_line_info(content, &mut lines, flavor)
491 );
492
493 for line in &mut lines {
498 if line.in_kramdown_extension_block {
499 line.list_item = None;
500 line.is_horizontal_rule = false;
501 line.blockquote = None;
502 line.is_kramdown_block_ial = false;
503 }
504 }
505
506 let obsidian_comment_ranges = profile_section!(
508 "Obsidian comments",
509 profile,
510 flavor_detection::detect_obsidian_comments(content, &mut lines, flavor, &code_span_ranges)
511 );
512
513 let myst_role_ranges = profile_section!(
515 "MyST roles",
516 profile,
517 flavor_detection::detect_myst_role_ranges(content, &lines, flavor, &code_blocks)
518 );
519
520 let pulldown_result = profile_section!(
524 "Links, images & link ranges",
525 profile,
526 link_parser::parse_links_images_pulldown(content, &lines, &code_blocks, flavor, &html_comment_ranges)
527 );
528
529 profile_section!(
531 "Headings & blockquotes",
532 profile,
533 heading_detection::detect_headings_and_blockquotes(
534 &content_lines,
535 &mut lines,
536 flavor,
537 &html_comment_ranges,
538 &pulldown_result.link_byte_ranges,
539 front_matter_end,
540 )
541 );
542
543 for line in &mut lines {
545 if line.in_kramdown_extension_block {
546 line.heading = None;
547 }
548 }
549
550 let mut code_spans = profile_section!(
552 "Code spans",
553 profile,
554 element_parsers::build_code_spans_from_ranges(content, &lines, &code_span_ranges)
555 );
556
557 if flavor == MarkdownFlavor::MkDocs {
561 let extra = profile_section!(
562 "MkDocs code spans",
563 profile,
564 element_parsers::scan_mkdocs_container_code_spans(content, &lines, &code_span_ranges,)
565 );
566 if !extra.is_empty() {
567 code_spans.extend(extra);
568 code_spans.sort_by_key(|span| span.byte_offset);
569 }
570 }
571
572 if flavor == MarkdownFlavor::MDX {
577 let extra = profile_section!(
578 "MDX JSX code spans",
579 profile,
580 element_parsers::scan_jsx_block_code_spans(content, &lines, &code_span_ranges)
581 );
582 if !extra.is_empty() {
583 code_spans.extend(extra);
584 code_spans.sort_by_key(|span| span.byte_offset);
585 }
586 }
587
588 for span in &code_spans {
591 if span.end_line > span.line {
592 for line_num in (span.line + 1)..=span.end_line {
594 if let Some(line_info) = lines.get_mut(line_num - 1) {
595 line_info.in_code_span_continuation = true;
596 }
597 }
598 }
599 }
600
601 let (links, images, broken_links, footnote_refs) = profile_section!(
603 "Links & images finalize",
604 profile,
605 link_parser::finalize_links_and_images(
606 content,
607 &lines,
608 &code_blocks,
609 &code_spans,
610 flavor,
611 &html_comment_ranges,
612 pulldown_result
613 )
614 );
615
616 let reference_defs = profile_section!(
617 "Reference defs",
618 profile,
619 link_parser::parse_reference_defs(content, &lines)
620 );
621
622 let list_blocks = profile_section!("List blocks", profile, list_blocks::parse_list_blocks(content, &lines));
623
624 let char_frequency = profile_section!(
626 "Char frequency",
627 profile,
628 line_computation::compute_char_frequency(content)
629 );
630
631 let table_blocks = profile_section!(
633 "Table blocks",
634 profile,
635 crate::utils::table_utils::TableUtils::find_table_blocks_with_code_info(
636 content,
637 &code_blocks,
638 &code_spans,
639 &html_comment_ranges,
640 )
641 );
642
643 let links = links
646 .into_iter()
647 .filter(|link| !lines.get(link.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
648 .collect::<Vec<_>>();
649 let images = images
650 .into_iter()
651 .filter(|img| !lines.get(img.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
652 .collect::<Vec<_>>();
653 let broken_links = broken_links
654 .into_iter()
655 .filter(|bl| {
656 let line_idx = line_offsets
658 .partition_point(|&offset| offset <= bl.span.start)
659 .saturating_sub(1);
660 !lines.get(line_idx).is_some_and(|l| l.in_kramdown_extension_block)
661 })
662 .collect::<Vec<_>>();
663 let footnote_refs = footnote_refs
664 .into_iter()
665 .filter(|fr| !lines.get(fr.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
666 .collect::<Vec<_>>();
667 let reference_defs = reference_defs
668 .into_iter()
669 .filter(|def| !lines.get(def.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
670 .collect::<Vec<_>>();
671 let list_blocks = list_blocks
672 .into_iter()
673 .filter(|block| {
674 !lines
675 .get(block.start_line - 1)
676 .is_some_and(|l| l.in_kramdown_extension_block)
677 })
678 .collect::<Vec<_>>();
679 let table_blocks = table_blocks
680 .into_iter()
681 .filter(|block| {
682 !lines
684 .get(block.start_line)
685 .is_some_and(|l| l.in_kramdown_extension_block)
686 })
687 .collect::<Vec<_>>();
688 let emphasis_spans = emphasis_spans
689 .into_iter()
690 .filter(|span| !lines.get(span.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
691 .collect::<Vec<_>>();
692
693 for block in &list_blocks {
697 for line_num in block.start_line..=block.end_line {
699 if let Some(li) = lines.get_mut(line_num - 1) {
700 li.in_list_block = true;
701 }
702 }
703 }
704 for block in &table_blocks {
705 for idx in block.start_line..=block.end_line {
707 if let Some(li) = lines.get_mut(idx) {
708 li.in_table_block = true;
709 }
710 }
711 }
712
713 let reference_defs_map: HashMap<String, usize> = reference_defs
715 .iter()
716 .enumerate()
717 .map(|(idx, def)| (def.id.to_lowercase(), idx))
718 .collect();
719
720 let link_title_ranges: Vec<(usize, usize)> = reference_defs
722 .iter()
723 .filter_map(|def| match (def.title_byte_start, def.title_byte_end) {
724 (Some(start), Some(end)) => Some((start, end)),
725 _ => None,
726 })
727 .collect();
728
729 let line_index = profile_section!(
731 "Line index",
732 profile,
733 crate::utils::range_utils::LineIndex::with_line_starts_and_code_blocks(
734 content,
735 line_offsets.clone(),
736 &code_blocks,
737 )
738 );
739
740 let jinja_ranges = profile_section!(
742 "Jinja ranges",
743 profile,
744 crate::utils::jinja_utils::find_jinja_ranges(content)
745 );
746
747 let citation_ranges = profile_section!("Citation ranges", profile, {
749 if flavor.is_pandoc_compatible() {
750 crate::utils::pandoc::find_citation_ranges(content)
751 } else {
752 Vec::new()
753 }
754 });
755
756 let inline_footnote_ranges = profile_section!("Inline footnote ranges", profile, {
758 if flavor.is_pandoc_compatible() {
759 crate::utils::pandoc::detect_inline_footnote_ranges(content)
760 } else {
761 Vec::new()
762 }
763 });
764
765 let pandoc_header_slugs = profile_section!("Pandoc header slugs", profile, {
767 if flavor.is_pandoc_compatible() {
768 crate::utils::pandoc::collect_pandoc_header_slugs(content)
769 } else {
770 std::collections::HashSet::new()
771 }
772 });
773
774 let example_list_marker_ranges = profile_section!("Example list markers", profile, {
776 if flavor.is_pandoc_compatible() {
777 crate::utils::pandoc::detect_example_list_marker_ranges(content)
778 } else {
779 Vec::new()
780 }
781 });
782
783 let example_reference_ranges = profile_section!("Example references", profile, {
785 if flavor.is_pandoc_compatible() {
786 crate::utils::pandoc::detect_example_reference_ranges(content, &example_list_marker_ranges)
787 } else {
788 Vec::new()
789 }
790 });
791
792 let sub_super_ranges = profile_section!("Subscript/superscript ranges", profile, {
794 if flavor.is_pandoc_compatible() {
795 crate::utils::pandoc::detect_subscript_superscript_ranges(content)
796 } else {
797 Vec::new()
798 }
799 });
800
801 let inline_code_attr_ranges = profile_section!("Inline code attribute ranges", profile, {
803 if flavor.is_pandoc_compatible() {
804 crate::utils::pandoc::detect_inline_code_attr_ranges(content)
805 } else {
806 Vec::new()
807 }
808 });
809
810 let bracketed_span_ranges = profile_section!("Bracketed span ranges", profile, {
812 if flavor.is_pandoc_compatible() {
813 crate::utils::pandoc::detect_bracketed_span_ranges(content)
814 } else {
815 Vec::new()
816 }
817 });
818
819 let line_block_ranges = profile_section!("Line block ranges", profile, {
821 if flavor.is_pandoc_compatible() {
822 crate::utils::pandoc::detect_line_block_ranges(content)
823 } else {
824 Vec::new()
825 }
826 });
827
828 let pipe_table_caption_ranges = profile_section!("Pipe-table caption ranges", profile, {
830 if flavor.is_pandoc_compatible() {
831 crate::utils::pandoc::detect_pipe_table_caption_ranges(content)
832 } else {
833 Vec::new()
834 }
835 });
836
837 let pandoc_metadata_ranges = profile_section!("Pandoc metadata ranges", profile, {
839 if flavor.is_pandoc_compatible() {
840 crate::utils::pandoc::detect_yaml_metadata_block_ranges(content)
841 } else {
842 Vec::new()
843 }
844 });
845
846 let grid_table_ranges = profile_section!("Grid table ranges", profile, {
848 if flavor.is_pandoc_compatible() {
849 crate::utils::pandoc::detect_grid_table_ranges(content)
850 } else {
851 Vec::new()
852 }
853 });
854
855 let multi_line_table_ranges = profile_section!("Multi-line table ranges", profile, {
857 if flavor.is_pandoc_compatible() {
858 crate::utils::pandoc::detect_multi_line_table_ranges(content)
859 } else {
860 Vec::new()
861 }
862 });
863
864 let shortcode_ranges = profile_section!("Shortcode ranges", profile, {
866 use crate::utils::regex_cache::HUGO_SHORTCODE_REGEX;
867 let mut ranges = Vec::new();
868 for mat in HUGO_SHORTCODE_REGEX.find_iter(content) {
869 ranges.push((mat.start(), mat.end()));
870 }
871 ranges
872 });
873
874 let inline_config = InlineConfig::from_content_with_code_blocks(content, &code_blocks);
875
876 Self {
877 content,
878 content_lines,
879 line_offsets,
880 code_blocks,
881 code_block_details,
882 strong_spans,
883 line_to_list,
884 list_start_values,
885 lines,
886 links,
887 images,
888 broken_links,
889 footnote_refs,
890 reference_defs,
891 reference_defs_map,
892 code_spans_cache: OnceLock::from(Arc::new(code_spans)),
893 math_spans_cache: OnceLock::new(), math_byte_ranges_cache: OnceLock::new(), list_blocks,
896 char_frequency,
897 html_tags_cache: OnceLock::new(),
898 jsx_component_tags_cache: OnceLock::new(),
899 emphasis_spans_cache: OnceLock::from(Arc::new(emphasis_spans)),
900 bare_urls_cache: OnceLock::new(),
901 has_mixed_list_nesting_cache: OnceLock::new(),
902 html_comment_ranges,
903 table_blocks,
904 line_index,
905 jinja_ranges,
906 flavor,
907 source_file,
908 jsx_expression_ranges,
909 mdx_comment_ranges,
910 citation_ranges,
911 pandoc_div_ranges,
912 colon_fence_ranges,
913 inline_footnote_ranges,
914 pandoc_header_slugs,
915 example_list_marker_ranges,
916 example_reference_ranges,
917 sub_super_ranges,
918 inline_code_attr_ranges,
919 bracketed_span_ranges,
920 line_block_ranges,
921 pipe_table_caption_ranges,
922 pandoc_metadata_ranges,
923 grid_table_ranges,
924 multi_line_table_ranges,
925 shortcode_ranges,
926 link_title_ranges,
927 code_span_byte_ranges: code_span_ranges,
928 inline_config,
929 obsidian_comment_ranges,
930 lazy_cont_lines_cache: OnceLock::new(),
931 myst_directive_ranges,
932 myst_comment_ranges,
933 myst_role_ranges,
934 front_matter_end,
935 }
936 }
937
938 pub fn front_matter_end_line(&self) -> usize {
943 self.front_matter_end
944 }
945
946 #[inline]
949 fn binary_search_ranges(ranges: &[(usize, usize)], pos: usize) -> bool {
950 let idx = ranges.partition_point(|&(start, _)| start <= pos);
952 idx > 0 && pos < ranges[idx - 1].1
954 }
955
956 pub fn is_in_code_span_byte(&self, pos: usize) -> bool {
958 Self::binary_search_ranges(&self.code_span_byte_ranges, pos)
959 }
960
961 pub fn is_in_link(&self, pos: usize) -> bool {
963 let idx = self.links.partition_point(|link| link.byte_offset <= pos);
964 if idx > 0 && pos < self.links[idx - 1].byte_end {
965 return true;
966 }
967 let idx = self.images.partition_point(|img| img.byte_offset <= pos);
968 if idx > 0 && pos < self.images[idx - 1].byte_end {
969 return true;
970 }
971 self.is_in_reference_def(pos)
972 }
973
974 pub fn inline_config(&self) -> &InlineConfig {
976 &self.inline_config
977 }
978
979 pub fn colon_fence_ranges(&self) -> &[(usize, usize)] {
982 &self.colon_fence_ranges
983 }
984
985 pub fn raw_lines(&self) -> &[&'a str] {
989 &self.content_lines
990 }
991
992 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
997 self.inline_config.is_rule_disabled(rule_name, line_number)
998 }
999
1000 pub fn code_spans(&self) -> Arc<Vec<CodeSpan>> {
1002 Arc::clone(
1003 self.code_spans_cache
1004 .get_or_init(|| Arc::new(element_parsers::parse_code_spans(self.content, &self.lines))),
1005 )
1006 }
1007
1008 pub fn math_byte_ranges(&self) -> &[(usize, usize)] {
1012 self.math_byte_ranges_cache
1013 .get_or_init(|| crate::utils::skip_context::math_byte_ranges(self.content))
1014 }
1015
1016 pub fn math_spans(&self) -> Arc<Vec<MathSpan>> {
1018 Arc::clone(
1019 self.math_spans_cache
1020 .get_or_init(|| Arc::new(element_parsers::parse_math_spans(self.content, &self.lines))),
1021 )
1022 }
1023
1024 pub fn is_in_math_span(&self, byte_pos: usize) -> bool {
1026 let math_spans = self.math_spans();
1027 let idx = math_spans.partition_point(|span| span.byte_offset <= byte_pos);
1029 idx > 0 && byte_pos < math_spans[idx - 1].byte_end
1030 }
1031
1032 pub fn html_comment_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1034 &self.html_comment_ranges
1035 }
1036
1037 pub fn is_in_obsidian_comment(&self, byte_pos: usize) -> bool {
1041 Self::binary_search_ranges(&self.obsidian_comment_ranges, byte_pos)
1042 }
1043
1044 pub fn is_position_in_obsidian_comment(&self, line_num: usize, col: usize) -> bool {
1049 if self.obsidian_comment_ranges.is_empty() {
1050 return false;
1051 }
1052
1053 let byte_pos = self.line_index.line_col_to_byte_range(line_num, col).start;
1055 self.is_in_obsidian_comment(byte_pos)
1056 }
1057
1058 pub fn myst_directive_ranges(&self) -> &[(usize, usize)] {
1060 &self.myst_directive_ranges
1061 }
1062
1063 pub fn is_in_myst_role(&self, byte_pos: usize) -> bool {
1065 Self::binary_search_ranges(&self.myst_role_ranges, byte_pos)
1066 }
1067
1068 pub fn is_in_myst_comment(&self, byte_pos: usize) -> bool {
1070 Self::binary_search_ranges(&self.myst_comment_ranges, byte_pos)
1071 }
1072
1073 pub fn is_myst_colon_directive_opener_line(&self, line_num: usize) -> bool {
1080 if !self.flavor.supports_myst_directives() {
1081 return false;
1082 }
1083 self.lines.get(line_num.wrapping_sub(1)).is_some_and(|info| {
1084 info.in_myst_directive
1085 && flavor_detection::myst_colon_directive_opener(info.content(self.content)).is_some()
1086 })
1087 }
1088
1089 fn filter_kramdown_tags(&self, tags: Vec<HtmlTag>) -> Vec<HtmlTag> {
1091 tags.into_iter()
1092 .filter(|tag| {
1093 !self
1094 .lines
1095 .get(tag.line - 1)
1096 .is_some_and(|l| l.in_kramdown_extension_block)
1097 })
1098 .collect()
1099 }
1100
1101 pub fn html_tags(&self) -> Arc<Vec<HtmlTag>> {
1107 Arc::clone(self.html_tags_cache.get_or_init(|| {
1108 let (html_tags, jsx_component_tags) =
1109 element_parsers::parse_html_tags(self.content, &self.lines, &self.code_blocks, self.flavor);
1110 let _ = self
1112 .jsx_component_tags_cache
1113 .set(Arc::new(self.filter_kramdown_tags(jsx_component_tags)));
1114 Arc::new(self.filter_kramdown_tags(html_tags))
1115 }))
1116 }
1117
1118 pub fn jsx_component_tags(&self) -> Arc<Vec<HtmlTag>> {
1121 if let Some(cached) = self.jsx_component_tags_cache.get() {
1122 return Arc::clone(cached);
1123 }
1124 let _ = self.html_tags();
1126 Arc::clone(
1127 self.jsx_component_tags_cache
1128 .get()
1129 .expect("html_tags() populates jsx_component_tags_cache"),
1130 )
1131 }
1132
1133 pub fn emphasis_spans(&self) -> Arc<Vec<EmphasisSpan>> {
1135 Arc::clone(
1136 self.emphasis_spans_cache
1137 .get()
1138 .expect("emphasis_spans_cache initialized during construction"),
1139 )
1140 }
1141
1142 pub fn bare_urls(&self) -> Arc<Vec<BareUrl>> {
1144 Arc::clone(self.bare_urls_cache.get_or_init(|| {
1145 Arc::new(element_parsers::parse_bare_urls(
1146 self.content,
1147 &self.lines,
1148 &self.code_blocks,
1149 ))
1150 }))
1151 }
1152
1153 pub fn lazy_continuation_lines(&self) -> Arc<Vec<LazyContLine>> {
1155 Arc::clone(self.lazy_cont_lines_cache.get_or_init(|| {
1156 Arc::new(element_parsers::detect_lazy_continuation_lines(
1157 self.content,
1158 &self.lines,
1159 &self.line_offsets,
1160 ))
1161 }))
1162 }
1163
1164 pub fn has_mixed_list_nesting(&self) -> bool {
1168 *self
1169 .has_mixed_list_nesting_cache
1170 .get_or_init(|| self.compute_mixed_list_nesting())
1171 }
1172
1173 fn compute_mixed_list_nesting(&self) -> bool {
1175 let mut stack: Vec<(usize, bool)> = Vec::new();
1180 let mut last_was_blank = false;
1181
1182 for line_info in &self.lines {
1183 if line_info.in_code_block
1185 || line_info.in_front_matter
1186 || line_info.in_mkdocstrings
1187 || line_info.in_html_comment
1188 || line_info.in_mdx_comment
1189 || line_info.in_esm_block
1190 {
1191 continue;
1192 }
1193
1194 if line_info.is_blank {
1196 last_was_blank = true;
1197 continue;
1198 }
1199
1200 if let Some(list_item) = &line_info.list_item {
1201 let current_pos = if list_item.marker_column == 1 {
1203 0
1204 } else {
1205 list_item.marker_column
1206 };
1207
1208 if last_was_blank && current_pos == 0 {
1210 stack.clear();
1211 }
1212 last_was_blank = false;
1213
1214 while let Some(&(pos, _)) = stack.last() {
1216 if pos >= current_pos {
1217 stack.pop();
1218 } else {
1219 break;
1220 }
1221 }
1222
1223 if let Some(&(_, parent_is_ordered)) = stack.last()
1225 && parent_is_ordered != list_item.is_ordered
1226 {
1227 return true; }
1229
1230 stack.push((current_pos, list_item.is_ordered));
1231 } else {
1232 last_was_blank = false;
1234 }
1235 }
1236
1237 false
1238 }
1239
1240 pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
1246 match self.line_offsets.binary_search(&offset) {
1247 Ok(line) => (line + 1, 1),
1248 Err(line) => {
1249 let line_start = self.line_offsets.get(line.wrapping_sub(1)).copied().unwrap_or(0);
1250 let col = byte_to_char_count(&self.content[line_start..], offset.saturating_sub(line_start));
1252 (line, col)
1253 }
1254 }
1255 }
1256
1257 pub fn is_in_code_block_or_span(&self, pos: usize) -> bool {
1259 if CodeBlockUtils::is_in_code_block_or_span(&self.code_blocks, pos) {
1261 return true;
1262 }
1263
1264 self.is_byte_offset_in_code_span(pos)
1266 }
1267
1268 pub fn line_info(&self, line_num: usize) -> Option<&LineInfo> {
1270 if line_num > 0 {
1271 self.lines.get(line_num - 1)
1272 } else {
1273 None
1274 }
1275 }
1276
1277 pub fn get_reference_url(&self, ref_id: &str) -> Option<&str> {
1279 let normalized_id = ref_id.to_lowercase();
1280 self.reference_defs_map
1281 .get(&normalized_id)
1282 .map(|&idx| self.reference_defs[idx].url.as_str())
1283 }
1284
1285 pub fn is_in_list_block(&self, line_num: usize) -> bool {
1287 if line_num == 0 || line_num > self.lines.len() {
1288 return false;
1289 }
1290 self.lines[line_num - 1].in_list_block
1291 }
1292
1293 pub fn is_in_html_block(&self, line_num: usize) -> bool {
1295 if line_num == 0 || line_num > self.lines.len() {
1296 return false;
1297 }
1298 self.lines[line_num - 1].in_html_block
1299 }
1300
1301 pub fn is_in_table_block(&self, line_num: usize) -> bool {
1307 if line_num == 0 || line_num > self.lines.len() {
1308 return false;
1309 }
1310 self.lines[line_num - 1].in_table_block
1311 }
1312
1313 pub fn is_in_code_span(&self, line_num: usize, col: usize) -> bool {
1315 if line_num == 0 || line_num > self.lines.len() {
1316 return false;
1317 }
1318
1319 let col_0indexed = if col > 0 { col - 1 } else { 0 };
1323 let code_spans = self.code_spans();
1324 code_spans.iter().any(|span| {
1325 if line_num < span.line || line_num > span.end_line {
1327 return false;
1328 }
1329
1330 if span.line == span.end_line {
1331 col_0indexed >= span.start_col && col_0indexed < span.end_col
1333 } else if line_num == span.line {
1334 col_0indexed >= span.start_col
1336 } else if line_num == span.end_line {
1337 col_0indexed < span.end_col
1339 } else {
1340 true
1342 }
1343 })
1344 }
1345
1346 #[inline]
1348 pub fn is_byte_offset_in_code_span(&self, byte_offset: usize) -> bool {
1349 let code_spans = self.code_spans();
1350 let idx = code_spans.partition_point(|span| span.byte_offset <= byte_offset);
1351 idx > 0 && byte_offset < code_spans[idx - 1].byte_end
1352 }
1353
1354 #[inline]
1356 pub fn is_in_reference_def(&self, byte_pos: usize) -> bool {
1357 let idx = self.reference_defs.partition_point(|rd| rd.byte_offset <= byte_pos);
1358 idx > 0 && byte_pos < self.reference_defs[idx - 1].byte_end
1359 }
1360
1361 #[inline]
1363 pub fn is_in_html_comment(&self, byte_pos: usize) -> bool {
1364 let idx = self.html_comment_ranges.partition_point(|r| r.start <= byte_pos);
1365 idx > 0 && byte_pos < self.html_comment_ranges[idx - 1].end
1366 }
1367
1368 #[inline]
1371 pub fn is_in_html_tag(&self, byte_pos: usize) -> bool {
1372 let tags = self.html_tags();
1373 let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1374 idx > 0 && byte_pos < tags[idx - 1].byte_end
1375 }
1376
1377 #[inline]
1381 pub fn is_in_jsx_component_tag(&self, byte_pos: usize) -> bool {
1382 if !self.flavor.supports_jsx() {
1383 return false;
1384 }
1385 let tags = self.jsx_component_tags();
1386 let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1387 idx > 0 && byte_pos < tags[idx - 1].byte_end
1388 }
1389
1390 pub fn is_in_jinja_range(&self, byte_pos: usize) -> bool {
1392 Self::binary_search_ranges(&self.jinja_ranges, byte_pos)
1393 }
1394
1395 #[inline]
1397 pub fn is_in_jsx_expression(&self, byte_pos: usize) -> bool {
1398 Self::binary_search_ranges(&self.jsx_expression_ranges, byte_pos)
1399 }
1400
1401 #[inline]
1403 pub fn is_in_mdx_comment(&self, byte_pos: usize) -> bool {
1404 Self::binary_search_ranges(&self.mdx_comment_ranges, byte_pos)
1405 }
1406
1407 #[inline]
1410 pub fn is_in_citation(&self, byte_pos: usize) -> bool {
1411 let idx = self.citation_ranges.partition_point(|r| r.start <= byte_pos);
1412 idx > 0 && byte_pos < self.citation_ranges[idx - 1].end
1413 }
1414
1415 #[inline]
1417 pub fn citation_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1418 &self.citation_ranges
1419 }
1420
1421 #[inline]
1424 pub fn is_in_div_block(&self, byte_pos: usize) -> bool {
1425 let idx = self.pandoc_div_ranges.partition_point(|r| r.start <= byte_pos);
1426 idx > 0 && byte_pos < self.pandoc_div_ranges[idx - 1].end
1427 }
1428
1429 #[inline]
1432 pub fn is_in_inline_footnote(&self, byte_pos: usize) -> bool {
1433 let idx = self.inline_footnote_ranges.partition_point(|r| r.start <= byte_pos);
1434 idx > 0 && byte_pos < self.inline_footnote_ranges[idx - 1].end
1435 }
1436
1437 #[inline]
1440 pub fn is_in_example_list_marker(&self, byte_pos: usize) -> bool {
1441 let idx = self.example_list_marker_ranges.partition_point(|r| r.start <= byte_pos);
1442 idx > 0 && byte_pos < self.example_list_marker_ranges[idx - 1].end
1443 }
1444
1445 #[inline]
1448 pub fn is_in_example_reference(&self, byte_pos: usize) -> bool {
1449 let idx = self.example_reference_ranges.partition_point(|r| r.start <= byte_pos);
1450 idx > 0 && byte_pos < self.example_reference_ranges[idx - 1].end
1451 }
1452
1453 #[inline]
1456 pub fn is_in_subscript_or_superscript(&self, byte_pos: usize) -> bool {
1457 let idx = self.sub_super_ranges.partition_point(|r| r.start <= byte_pos);
1458 idx > 0 && byte_pos < self.sub_super_ranges[idx - 1].end
1459 }
1460
1461 #[inline]
1465 pub fn is_in_inline_code_attr(&self, byte_pos: usize) -> bool {
1466 let idx = self.inline_code_attr_ranges.partition_point(|r| r.start <= byte_pos);
1467 idx > 0 && byte_pos < self.inline_code_attr_ranges[idx - 1].end
1468 }
1469
1470 #[inline]
1473 pub fn is_in_bracketed_span(&self, byte_pos: usize) -> bool {
1474 let idx = self.bracketed_span_ranges.partition_point(|r| r.start <= byte_pos);
1475 idx > 0 && byte_pos < self.bracketed_span_ranges[idx - 1].end
1476 }
1477
1478 #[inline]
1481 pub fn is_in_line_block(&self, byte_pos: usize) -> bool {
1482 let idx = self.line_block_ranges.partition_point(|r| r.start <= byte_pos);
1483 idx > 0 && byte_pos < self.line_block_ranges[idx - 1].end
1484 }
1485
1486 #[inline]
1490 pub fn is_in_pipe_table_caption(&self, byte_pos: usize) -> bool {
1491 let idx = self.pipe_table_caption_ranges.partition_point(|r| r.start <= byte_pos);
1492 idx > 0 && byte_pos < self.pipe_table_caption_ranges[idx - 1].end
1493 }
1494
1495 #[inline]
1498 pub fn is_in_pandoc_metadata(&self, byte_pos: usize) -> bool {
1499 let idx = self.pandoc_metadata_ranges.partition_point(|r| r.start <= byte_pos);
1500 idx > 0 && byte_pos < self.pandoc_metadata_ranges[idx - 1].end
1501 }
1502
1503 #[inline]
1506 pub fn is_in_grid_table(&self, byte_pos: usize) -> bool {
1507 let idx = self.grid_table_ranges.partition_point(|r| r.start <= byte_pos);
1508 idx > 0 && byte_pos < self.grid_table_ranges[idx - 1].end
1509 }
1510
1511 #[inline]
1514 pub fn is_in_multi_line_table(&self, byte_pos: usize) -> bool {
1515 let idx = self.multi_line_table_ranges.partition_point(|r| r.start <= byte_pos);
1516 idx > 0 && byte_pos < self.multi_line_table_ranges[idx - 1].end
1517 }
1518
1519 pub fn matches_implicit_header_reference(&self, link_text: &str) -> bool {
1524 let slug = crate::utils::pandoc::pandoc_header_slug(link_text);
1525 self.pandoc_header_slugs.contains(&slug)
1526 }
1527
1528 #[inline]
1534 pub fn has_pandoc_slug(&self, slug: &str) -> bool {
1535 self.pandoc_header_slugs.contains(slug)
1536 }
1537
1538 #[inline]
1540 pub fn is_in_shortcode(&self, byte_pos: usize) -> bool {
1541 Self::binary_search_ranges(&self.shortcode_ranges, byte_pos)
1542 }
1543
1544 #[inline]
1546 pub fn shortcode_ranges(&self) -> &[(usize, usize)] {
1547 &self.shortcode_ranges
1548 }
1549
1550 pub fn is_in_link_title(&self, byte_pos: usize) -> bool {
1552 Self::binary_search_ranges(&self.link_title_ranges, byte_pos)
1553 }
1554
1555 pub fn has_char(&self, ch: char) -> bool {
1557 match ch {
1558 '#' => self.char_frequency.hash_count > 0,
1559 '*' => self.char_frequency.asterisk_count > 0,
1560 '_' => self.char_frequency.underscore_count > 0,
1561 '-' => self.char_frequency.hyphen_count > 0,
1562 '+' => self.char_frequency.plus_count > 0,
1563 '>' => self.char_frequency.gt_count > 0,
1564 '|' => self.char_frequency.pipe_count > 0,
1565 '[' => self.char_frequency.bracket_count > 0,
1566 '`' => self.char_frequency.backtick_count > 0,
1567 '<' => self.char_frequency.lt_count > 0,
1568 '!' => self.char_frequency.exclamation_count > 0,
1569 '\n' => self.char_frequency.newline_count > 0,
1570 _ => self.content.contains(ch), }
1572 }
1573
1574 pub fn char_count(&self, ch: char) -> usize {
1576 match ch {
1577 '#' => self.char_frequency.hash_count,
1578 '*' => self.char_frequency.asterisk_count,
1579 '_' => self.char_frequency.underscore_count,
1580 '-' => self.char_frequency.hyphen_count,
1581 '+' => self.char_frequency.plus_count,
1582 '>' => self.char_frequency.gt_count,
1583 '|' => self.char_frequency.pipe_count,
1584 '[' => self.char_frequency.bracket_count,
1585 '`' => self.char_frequency.backtick_count,
1586 '<' => self.char_frequency.lt_count,
1587 '!' => self.char_frequency.exclamation_count,
1588 '\n' => self.char_frequency.newline_count,
1589 _ => self.content.matches(ch).count(), }
1591 }
1592
1593 pub fn likely_has_headings(&self) -> bool {
1595 self.char_frequency.hash_count > 0 || self.char_frequency.hyphen_count > 2 || self.content.contains('=') }
1597
1598 pub fn likely_has_lists(&self) -> bool {
1600 self.char_frequency.asterisk_count > 0
1601 || self.char_frequency.hyphen_count > 0
1602 || self.char_frequency.plus_count > 0
1603 }
1604
1605 pub fn likely_has_emphasis(&self) -> bool {
1607 self.char_frequency.asterisk_count > 1 || self.char_frequency.underscore_count > 1
1608 }
1609
1610 pub fn likely_has_tables(&self) -> bool {
1612 self.char_frequency.pipe_count > 2
1613 }
1614
1615 pub fn likely_has_blockquotes(&self) -> bool {
1617 self.char_frequency.gt_count > 0
1618 }
1619
1620 pub fn likely_has_code(&self) -> bool {
1622 self.char_frequency.backtick_count > 0
1623 }
1624
1625 pub fn likely_has_links_or_images(&self) -> bool {
1627 self.char_frequency.bracket_count > 0 || self.char_frequency.exclamation_count > 0
1628 }
1629
1630 pub fn likely_has_html(&self) -> bool {
1632 self.char_frequency.lt_count > 0
1633 }
1634
1635 pub fn blockquote_prefix_for_blank_line(&self, line_idx: usize) -> String {
1640 if let Some(line_info) = self.lines.get(line_idx)
1641 && let Some(ref bq) = line_info.blockquote
1642 {
1643 bq.prefix.trim_end().to_string()
1644 } else {
1645 String::new()
1646 }
1647 }
1648
1649 #[inline]
1660 fn find_line_for_offset(lines: &[LineInfo], content: &str, byte_offset: usize) -> (usize, usize, usize) {
1661 let idx = match lines.binary_search_by(|line| {
1663 if byte_offset < line.byte_offset {
1664 std::cmp::Ordering::Greater
1665 } else if byte_offset > line.byte_offset + line.byte_len {
1666 std::cmp::Ordering::Less
1667 } else {
1668 std::cmp::Ordering::Equal
1669 }
1670 }) {
1671 Ok(idx) => idx,
1672 Err(idx) => idx.saturating_sub(1),
1673 };
1674
1675 let line = &lines[idx];
1676 let line_num = idx + 1;
1677 let byte_col = byte_offset.saturating_sub(line.byte_offset);
1678 let col = byte_to_char_count(line.content(content), byte_col) - 1;
1681
1682 (idx, line_num, col)
1683 }
1684
1685 #[inline]
1687 fn is_offset_in_code_span(code_spans: &[CodeSpan], offset: usize) -> bool {
1688 let idx = code_spans.partition_point(|span| span.byte_offset <= offset);
1690
1691 if idx > 0 {
1693 let span = &code_spans[idx - 1];
1694 if offset >= span.byte_offset && offset < span.byte_end {
1695 return true;
1696 }
1697 }
1698
1699 false
1700 }
1701
1702 #[must_use]
1722 pub fn valid_headings(&self) -> ValidHeadingsIter<'_> {
1723 ValidHeadingsIter::new(&self.lines)
1724 }
1725
1726 #[must_use]
1730 pub fn has_valid_headings(&self) -> bool {
1731 self.lines
1732 .iter()
1733 .any(|line| line.heading.as_ref().is_some_and(|h| h.is_valid))
1734 }
1735}
1736
1737fn detect_footnote_definitions(content: &str, lines: &mut [types::LineInfo], line_offsets: &[usize]) {
1746 use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
1747
1748 let options = crate::utils::rumdl_parser_options();
1749 let parser = Parser::new_ext(content, options).into_offset_iter();
1750
1751 let mut footnote_ranges: Vec<(usize, usize)> = Vec::new();
1753 let mut fenced_code_ranges: Vec<(usize, usize)> = Vec::new();
1754 let mut in_footnote = false;
1755
1756 for (event, range) in parser {
1757 match event {
1758 Event::Start(Tag::FootnoteDefinition(_)) => {
1759 in_footnote = true;
1760 footnote_ranges.push((range.start, range.end));
1761 }
1762 Event::End(TagEnd::FootnoteDefinition) => {
1763 in_footnote = false;
1764 }
1765 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) if in_footnote => {
1766 fenced_code_ranges.push((range.start, range.end));
1767 }
1768 _ => {}
1769 }
1770 }
1771
1772 let byte_to_line = |byte_offset: usize| -> usize {
1773 line_offsets
1774 .partition_point(|&offset| offset <= byte_offset)
1775 .saturating_sub(1)
1776 };
1777
1778 for &(start, end) in &footnote_ranges {
1780 let start_line = byte_to_line(start);
1781 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
1782
1783 for line in &mut lines[start_line..end_line] {
1784 line.in_footnote_definition = true;
1785 line.in_code_block = false;
1786 }
1787 }
1788
1789 for &(start, end) in &fenced_code_ranges {
1791 let start_line = byte_to_line(start);
1792 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
1793
1794 for line in &mut lines[start_line..end_line] {
1795 line.in_code_block = true;
1796 }
1797 }
1798}