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 std::collections::HashMap;
18use std::path::PathBuf;
19
20#[cfg(not(target_arch = "wasm32"))]
22macro_rules! profile_section {
23 ($name:expr, $profile:expr, $code:expr) => {{
24 let start = std::time::Instant::now();
25 let result = $code;
26 if $profile {
27 eprintln!("[PROFILE] {}: {:?}", $name, start.elapsed());
28 }
29 result
30 }};
31}
32
33#[cfg(target_arch = "wasm32")]
34macro_rules! profile_section {
35 ($name:expr, $profile:expr, $code:expr) => {{ $code }};
36}
37
38pub(super) struct SkipByteRanges<'a> {
41 pub(super) html_comment_ranges: &'a [crate::utils::skip_context::ByteRange],
42 pub(super) autodoc_ranges: &'a [crate::utils::skip_context::ByteRange],
43 pub(super) pandoc_div_ranges: &'a [crate::utils::skip_context::ByteRange],
44 pub(super) pymdown_block_ranges: &'a [crate::utils::skip_context::ByteRange],
45}
46
47use std::sync::{Arc, OnceLock};
48
49pub(super) type ListItemMap = std::collections::HashMap<usize, (bool, String, usize, usize, Option<usize>)>;
51
52pub(super) type ByteRanges = Vec<(usize, usize)>;
54
55pub struct LintContext<'a> {
56 pub content: &'a str,
57 content_lines: Vec<&'a str>, pub line_offsets: Vec<usize>,
59 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>>>, table_rows_cache: OnceLock<Arc<Vec<TableRow>>>, 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)>, }
115
116impl<'a> LintContext<'a> {
117 pub fn new(content: &'a str, flavor: MarkdownFlavor, source_file: Option<PathBuf>) -> Self {
118 #[cfg(not(target_arch = "wasm32"))]
119 let profile = std::env::var("RUMDL_PROFILE_QUADRATIC").is_ok();
120
121 let line_offsets = profile_section!("Line offsets", profile, {
122 let mut offsets = vec![0];
123 for (i, c) in content.char_indices() {
124 if c == '\n' {
125 offsets.push(i + 1);
126 }
127 }
128 offsets
129 });
130
131 let content_lines: Vec<&str> = content.lines().collect();
133
134 let front_matter_end = FrontMatterUtils::get_front_matter_end_line(content);
136
137 let parse_result = profile_section!(
139 "Code blocks",
140 profile,
141 CodeBlockUtils::detect_code_blocks_and_spans(content)
142 );
143 let mut code_blocks = parse_result.code_blocks;
144 let code_span_ranges = parse_result.code_spans;
145 let code_block_details = parse_result.code_block_details;
146 let strong_spans = parse_result.strong_spans;
147 let line_to_list = parse_result.line_to_list;
148 let list_start_values = parse_result.list_start_values;
149
150 let html_comment_ranges = profile_section!(
152 "HTML comment ranges",
153 profile,
154 crate::utils::skip_context::compute_html_comment_ranges(content)
155 );
156
157 let autodoc_ranges = profile_section!("Autodoc block ranges", profile, {
161 if flavor.supports_colon_code_fences() || flavor.supports_myst_directives() {
162 Vec::new()
163 } else {
164 crate::utils::mkdocstrings_refs::detect_autodoc_block_ranges(content)
165 }
166 });
167
168 let pandoc_div_ranges = profile_section!("Pandoc div ranges", profile, {
170 if flavor.is_pandoc_compatible() {
171 crate::utils::pandoc::detect_div_block_ranges(content)
172 } else {
173 Vec::new()
174 }
175 });
176
177 let pymdown_block_ranges = profile_section!("PyMdown block ranges", profile, {
179 if flavor == MarkdownFlavor::MkDocs {
180 crate::utils::pymdown_blocks::detect_block_ranges(content)
181 } else {
182 Vec::new()
183 }
184 });
185
186 let skip_ranges = SkipByteRanges {
189 html_comment_ranges: &html_comment_ranges,
190 autodoc_ranges: &autodoc_ranges,
191 pandoc_div_ranges: &pandoc_div_ranges,
192 pymdown_block_ranges: &pymdown_block_ranges,
193 };
194 let (mut lines, emphasis_spans) = profile_section!(
195 "Basic line info",
196 profile,
197 line_computation::compute_basic_line_info(
198 content,
199 &content_lines,
200 &line_offsets,
201 &code_blocks,
202 flavor,
203 &skip_ranges,
204 front_matter_end,
205 )
206 );
207
208 profile_section!(
210 "HTML blocks",
211 profile,
212 heading_detection::detect_html_blocks(content, &mut lines)
213 );
214
215 profile_section!(
217 "ESM blocks",
218 profile,
219 flavor_detection::detect_esm_blocks(content, &mut lines, flavor)
220 );
221
222 profile_section!(
224 "JSX block detection",
225 profile,
226 flavor_detection::detect_jsx_blocks(content, &mut lines, flavor)
227 );
228
229 let (jsx_expression_ranges, mdx_comment_ranges) = profile_section!(
231 "JSX/MDX detection",
232 profile,
233 flavor_detection::detect_jsx_and_mdx_comments(content, &mut lines, flavor, &code_blocks)
234 );
235
236 profile_section!(
241 "Markdown-in-HTML blocks",
242 profile,
243 flavor_detection::detect_markdown_html_blocks(&content_lines, &mut lines)
244 );
245
246 profile_section!(
248 "MkDocs constructs",
249 profile,
250 flavor_detection::detect_mkdocs_line_info(&content_lines, &mut lines, flavor)
251 );
252
253 profile_section!(
258 "Footnote definitions",
259 profile,
260 detect_footnote_definitions(content, &mut lines, &line_offsets)
261 );
262
263 {
266 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
267 for &(start, end) in &code_blocks {
268 let start_line = line_offsets
269 .partition_point(|&offset| offset <= start)
270 .saturating_sub(1);
271 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
272
273 let mut sub_start: Option<usize> = None;
274 for (i, &offset) in line_offsets[start_line..end_line]
275 .iter()
276 .enumerate()
277 .map(|(j, o)| (j + start_line, o))
278 {
279 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
280 if is_real_code && sub_start.is_none() {
281 let byte_start = if i == start_line { start } else { offset };
282 sub_start = Some(byte_start);
283 } else if !is_real_code && sub_start.is_some() {
284 new_code_blocks.push((sub_start.unwrap(), offset));
285 sub_start = None;
286 }
287 }
288 if let Some(s) = sub_start {
289 new_code_blocks.push((s, end));
290 }
291 }
292 code_blocks = new_code_blocks;
293 }
294
295 let has_markdown_html = lines.iter().any(|l| l.in_mkdocs_html_markdown);
303 if flavor == MarkdownFlavor::MkDocs || has_markdown_html {
304 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
305 for &(start, end) in &code_blocks {
306 let start_line = line_offsets
307 .partition_point(|&offset| offset <= start)
308 .saturating_sub(1);
309 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
310
311 let mut sub_start: Option<usize> = None;
313 for (i, &offset) in line_offsets[start_line..end_line]
314 .iter()
315 .enumerate()
316 .map(|(j, o)| (j + start_line, o))
317 {
318 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
319 if is_real_code && sub_start.is_none() {
320 let byte_start = if i == start_line { start } else { offset };
321 sub_start = Some(byte_start);
322 } else if !is_real_code && sub_start.is_some() {
323 new_code_blocks.push((sub_start.unwrap(), offset));
324 sub_start = None;
325 }
326 }
327 if let Some(s) = sub_start {
328 new_code_blocks.push((s, end));
329 }
330 }
331 code_blocks = new_code_blocks;
332 }
333
334 if flavor.supports_jsx() {
338 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
339 for &(start, end) in &code_blocks {
340 let start_line = line_offsets
341 .partition_point(|&offset| offset <= start)
342 .saturating_sub(1);
343 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
344
345 let mut sub_start: Option<usize> = None;
346 for (i, &offset) in line_offsets[start_line..end_line]
347 .iter()
348 .enumerate()
349 .map(|(j, o)| (j + start_line, o))
350 {
351 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
352 if is_real_code && sub_start.is_none() {
353 let byte_start = if i == start_line { start } else { offset };
354 sub_start = Some(byte_start);
355 } else if !is_real_code && sub_start.is_some() {
356 new_code_blocks.push((sub_start.unwrap(), offset));
357 sub_start = None;
358 }
359 }
360 if let Some(s) = sub_start {
361 new_code_blocks.push((s, end));
362 }
363 }
364 code_blocks = new_code_blocks;
365 }
366
367 let colon_fence_ranges = profile_section!(
370 "Azure colon fence detection",
371 profile,
372 flavor_detection::detect_azure_colon_fences(content, &mut lines, flavor)
373 );
374 if !colon_fence_ranges.is_empty() {
375 code_blocks.extend(colon_fence_ranges.iter().copied());
376 code_blocks.sort_by_key(|&(start, _)| start);
377 }
378
379 let myst_directive_ranges = profile_section!(
382 "MyST colon directives",
383 profile,
384 flavor_detection::detect_myst_colon_directives(content, &mut lines, flavor)
385 );
386
387 let myst_comment_ranges = profile_section!(
389 "MyST comments",
390 profile,
391 flavor_detection::detect_myst_comments(content, &mut lines, flavor)
392 );
393
394 profile_section!(
397 "MyST backtick directives",
398 profile,
399 flavor_detection::detect_myst_backtick_directives(
400 content,
401 &mut lines,
402 flavor,
403 &code_block_details,
404 &line_offsets
405 )
406 );
407
408 if flavor.supports_myst_directives() {
411 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
412 for &(start, end) in &code_blocks {
413 let start_line = line_offsets
414 .partition_point(|&offset| offset <= start)
415 .saturating_sub(1);
416 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
417
418 let mut sub_start: Option<usize> = None;
419 for (i, &offset) in line_offsets[start_line..end_line]
420 .iter()
421 .enumerate()
422 .map(|(j, o)| (j + start_line, o))
423 {
424 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
425 if is_real_code && sub_start.is_none() {
426 let byte_start = if i == start_line { start } else { offset };
427 sub_start = Some(byte_start);
428 } else if !is_real_code && sub_start.is_some() {
429 new_code_blocks.push((sub_start.unwrap(), offset));
430 sub_start = None;
431 }
432 }
433 if let Some(s) = sub_start {
434 new_code_blocks.push((s, end));
435 }
436 }
437 code_blocks = new_code_blocks;
438 }
439
440 profile_section!(
442 "Kramdown constructs",
443 profile,
444 flavor_detection::detect_kramdown_line_info(content, &mut lines, flavor)
445 );
446
447 for line in &mut lines {
452 if line.in_kramdown_extension_block {
453 line.list_item = None;
454 line.is_horizontal_rule = false;
455 line.blockquote = None;
456 line.is_kramdown_block_ial = false;
457 }
458 }
459
460 let obsidian_comment_ranges = profile_section!(
462 "Obsidian comments",
463 profile,
464 flavor_detection::detect_obsidian_comments(content, &mut lines, flavor, &code_span_ranges)
465 );
466
467 let myst_role_ranges = profile_section!(
469 "MyST roles",
470 profile,
471 flavor_detection::detect_myst_role_ranges(content, &lines, flavor, &code_blocks)
472 );
473
474 let pulldown_result = profile_section!(
478 "Links, images & link ranges",
479 profile,
480 link_parser::parse_links_images_pulldown(content, &lines, &code_blocks, flavor, &html_comment_ranges)
481 );
482
483 profile_section!(
485 "Headings & blockquotes",
486 profile,
487 heading_detection::detect_headings_and_blockquotes(
488 &content_lines,
489 &mut lines,
490 flavor,
491 &html_comment_ranges,
492 &pulldown_result.link_byte_ranges,
493 front_matter_end,
494 )
495 );
496
497 for line in &mut lines {
499 if line.in_kramdown_extension_block {
500 line.heading = None;
501 }
502 }
503
504 let mut code_spans = profile_section!(
506 "Code spans",
507 profile,
508 element_parsers::build_code_spans_from_ranges(content, &lines, &code_span_ranges)
509 );
510
511 if flavor == MarkdownFlavor::MkDocs {
515 let extra = profile_section!(
516 "MkDocs code spans",
517 profile,
518 element_parsers::scan_mkdocs_container_code_spans(content, &lines, &code_span_ranges,)
519 );
520 if !extra.is_empty() {
521 code_spans.extend(extra);
522 code_spans.sort_by_key(|span| span.byte_offset);
523 }
524 }
525
526 if flavor == MarkdownFlavor::MDX {
531 let extra = profile_section!(
532 "MDX JSX code spans",
533 profile,
534 element_parsers::scan_jsx_block_code_spans(content, &lines, &code_span_ranges)
535 );
536 if !extra.is_empty() {
537 code_spans.extend(extra);
538 code_spans.sort_by_key(|span| span.byte_offset);
539 }
540 }
541
542 for span in &code_spans {
545 if span.end_line > span.line {
546 for line_num in (span.line + 1)..=span.end_line {
548 if let Some(line_info) = lines.get_mut(line_num - 1) {
549 line_info.in_code_span_continuation = true;
550 }
551 }
552 }
553 }
554
555 let (links, images, broken_links, footnote_refs) = profile_section!(
557 "Links & images finalize",
558 profile,
559 link_parser::finalize_links_and_images(
560 content,
561 &lines,
562 &code_blocks,
563 &code_spans,
564 flavor,
565 &html_comment_ranges,
566 pulldown_result
567 )
568 );
569
570 let reference_defs = profile_section!(
571 "Reference defs",
572 profile,
573 link_parser::parse_reference_defs(content, &lines)
574 );
575
576 let list_blocks = profile_section!("List blocks", profile, list_blocks::parse_list_blocks(content, &lines));
577
578 let char_frequency = profile_section!(
580 "Char frequency",
581 profile,
582 line_computation::compute_char_frequency(content)
583 );
584
585 let table_blocks = profile_section!(
587 "Table blocks",
588 profile,
589 crate::utils::table_utils::TableUtils::find_table_blocks_with_code_info(
590 content,
591 &code_blocks,
592 &code_spans,
593 &html_comment_ranges,
594 )
595 );
596
597 let links = links
600 .into_iter()
601 .filter(|link| !lines.get(link.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
602 .collect::<Vec<_>>();
603 let images = images
604 .into_iter()
605 .filter(|img| !lines.get(img.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
606 .collect::<Vec<_>>();
607 let broken_links = broken_links
608 .into_iter()
609 .filter(|bl| {
610 let line_idx = line_offsets
612 .partition_point(|&offset| offset <= bl.span.start)
613 .saturating_sub(1);
614 !lines.get(line_idx).is_some_and(|l| l.in_kramdown_extension_block)
615 })
616 .collect::<Vec<_>>();
617 let footnote_refs = footnote_refs
618 .into_iter()
619 .filter(|fr| !lines.get(fr.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
620 .collect::<Vec<_>>();
621 let reference_defs = reference_defs
622 .into_iter()
623 .filter(|def| !lines.get(def.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
624 .collect::<Vec<_>>();
625 let list_blocks = list_blocks
626 .into_iter()
627 .filter(|block| {
628 !lines
629 .get(block.start_line - 1)
630 .is_some_and(|l| l.in_kramdown_extension_block)
631 })
632 .collect::<Vec<_>>();
633 let table_blocks = table_blocks
634 .into_iter()
635 .filter(|block| {
636 !lines
638 .get(block.start_line)
639 .is_some_and(|l| l.in_kramdown_extension_block)
640 })
641 .collect::<Vec<_>>();
642 let emphasis_spans = emphasis_spans
643 .into_iter()
644 .filter(|span| !lines.get(span.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
645 .collect::<Vec<_>>();
646
647 for block in &list_blocks {
651 for line_num in block.start_line..=block.end_line {
653 if let Some(li) = lines.get_mut(line_num - 1) {
654 li.in_list_block = true;
655 }
656 }
657 }
658 for block in &table_blocks {
659 for idx in block.start_line..=block.end_line {
661 if let Some(li) = lines.get_mut(idx) {
662 li.in_table_block = true;
663 }
664 }
665 }
666
667 let reference_defs_map: HashMap<String, usize> = reference_defs
669 .iter()
670 .enumerate()
671 .map(|(idx, def)| (def.id.to_lowercase(), idx))
672 .collect();
673
674 let link_title_ranges: Vec<(usize, usize)> = reference_defs
676 .iter()
677 .filter_map(|def| match (def.title_byte_start, def.title_byte_end) {
678 (Some(start), Some(end)) => Some((start, end)),
679 _ => None,
680 })
681 .collect();
682
683 let line_index = profile_section!(
685 "Line index",
686 profile,
687 crate::utils::range_utils::LineIndex::with_line_starts_and_code_blocks(
688 content,
689 line_offsets.clone(),
690 &code_blocks,
691 )
692 );
693
694 let jinja_ranges = profile_section!(
696 "Jinja ranges",
697 profile,
698 crate::utils::jinja_utils::find_jinja_ranges(content)
699 );
700
701 let citation_ranges = profile_section!("Citation ranges", profile, {
703 if flavor.is_pandoc_compatible() {
704 crate::utils::pandoc::find_citation_ranges(content)
705 } else {
706 Vec::new()
707 }
708 });
709
710 let inline_footnote_ranges = profile_section!("Inline footnote ranges", profile, {
712 if flavor.is_pandoc_compatible() {
713 crate::utils::pandoc::detect_inline_footnote_ranges(content)
714 } else {
715 Vec::new()
716 }
717 });
718
719 let pandoc_header_slugs = profile_section!("Pandoc header slugs", profile, {
721 if flavor.is_pandoc_compatible() {
722 crate::utils::pandoc::collect_pandoc_header_slugs(content)
723 } else {
724 std::collections::HashSet::new()
725 }
726 });
727
728 let example_list_marker_ranges = profile_section!("Example list markers", profile, {
730 if flavor.is_pandoc_compatible() {
731 crate::utils::pandoc::detect_example_list_marker_ranges(content)
732 } else {
733 Vec::new()
734 }
735 });
736
737 let example_reference_ranges = profile_section!("Example references", profile, {
739 if flavor.is_pandoc_compatible() {
740 crate::utils::pandoc::detect_example_reference_ranges(content, &example_list_marker_ranges)
741 } else {
742 Vec::new()
743 }
744 });
745
746 let sub_super_ranges = profile_section!("Subscript/superscript ranges", profile, {
748 if flavor.is_pandoc_compatible() {
749 crate::utils::pandoc::detect_subscript_superscript_ranges(content)
750 } else {
751 Vec::new()
752 }
753 });
754
755 let inline_code_attr_ranges = profile_section!("Inline code attribute ranges", profile, {
757 if flavor.is_pandoc_compatible() {
758 crate::utils::pandoc::detect_inline_code_attr_ranges(content)
759 } else {
760 Vec::new()
761 }
762 });
763
764 let bracketed_span_ranges = profile_section!("Bracketed span ranges", profile, {
766 if flavor.is_pandoc_compatible() {
767 crate::utils::pandoc::detect_bracketed_span_ranges(content)
768 } else {
769 Vec::new()
770 }
771 });
772
773 let line_block_ranges = profile_section!("Line block ranges", profile, {
775 if flavor.is_pandoc_compatible() {
776 crate::utils::pandoc::detect_line_block_ranges(content)
777 } else {
778 Vec::new()
779 }
780 });
781
782 let pipe_table_caption_ranges = profile_section!("Pipe-table caption ranges", profile, {
784 if flavor.is_pandoc_compatible() {
785 crate::utils::pandoc::detect_pipe_table_caption_ranges(content)
786 } else {
787 Vec::new()
788 }
789 });
790
791 let pandoc_metadata_ranges = profile_section!("Pandoc metadata ranges", profile, {
793 if flavor.is_pandoc_compatible() {
794 crate::utils::pandoc::detect_yaml_metadata_block_ranges(content)
795 } else {
796 Vec::new()
797 }
798 });
799
800 let grid_table_ranges = profile_section!("Grid table ranges", profile, {
802 if flavor.is_pandoc_compatible() {
803 crate::utils::pandoc::detect_grid_table_ranges(content)
804 } else {
805 Vec::new()
806 }
807 });
808
809 let multi_line_table_ranges = profile_section!("Multi-line table ranges", profile, {
811 if flavor.is_pandoc_compatible() {
812 crate::utils::pandoc::detect_multi_line_table_ranges(content)
813 } else {
814 Vec::new()
815 }
816 });
817
818 let shortcode_ranges = profile_section!("Shortcode ranges", profile, {
820 use crate::utils::regex_cache::HUGO_SHORTCODE_REGEX;
821 let mut ranges = Vec::new();
822 for mat in HUGO_SHORTCODE_REGEX.find_iter(content) {
823 ranges.push((mat.start(), mat.end()));
824 }
825 ranges
826 });
827
828 let inline_config = InlineConfig::from_content_with_code_blocks(content, &code_blocks);
829
830 Self {
831 content,
832 content_lines,
833 line_offsets,
834 code_blocks,
835 code_block_details,
836 strong_spans,
837 line_to_list,
838 list_start_values,
839 lines,
840 links,
841 images,
842 broken_links,
843 footnote_refs,
844 reference_defs,
845 reference_defs_map,
846 code_spans_cache: OnceLock::from(Arc::new(code_spans)),
847 math_spans_cache: OnceLock::new(), math_byte_ranges_cache: OnceLock::new(), list_blocks,
850 char_frequency,
851 html_tags_cache: OnceLock::new(),
852 jsx_component_tags_cache: OnceLock::new(),
853 emphasis_spans_cache: OnceLock::from(Arc::new(emphasis_spans)),
854 table_rows_cache: OnceLock::new(),
855 bare_urls_cache: OnceLock::new(),
856 has_mixed_list_nesting_cache: OnceLock::new(),
857 html_comment_ranges,
858 table_blocks,
859 line_index,
860 jinja_ranges,
861 flavor,
862 source_file,
863 jsx_expression_ranges,
864 mdx_comment_ranges,
865 citation_ranges,
866 pandoc_div_ranges,
867 colon_fence_ranges,
868 inline_footnote_ranges,
869 pandoc_header_slugs,
870 example_list_marker_ranges,
871 example_reference_ranges,
872 sub_super_ranges,
873 inline_code_attr_ranges,
874 bracketed_span_ranges,
875 line_block_ranges,
876 pipe_table_caption_ranges,
877 pandoc_metadata_ranges,
878 grid_table_ranges,
879 multi_line_table_ranges,
880 shortcode_ranges,
881 link_title_ranges,
882 code_span_byte_ranges: code_span_ranges,
883 inline_config,
884 obsidian_comment_ranges,
885 lazy_cont_lines_cache: OnceLock::new(),
886 myst_directive_ranges,
887 myst_comment_ranges,
888 myst_role_ranges,
889 }
890 }
891
892 #[inline]
895 fn binary_search_ranges(ranges: &[(usize, usize)], pos: usize) -> bool {
896 let idx = ranges.partition_point(|&(start, _)| start <= pos);
898 idx > 0 && pos < ranges[idx - 1].1
900 }
901
902 pub fn is_in_code_span_byte(&self, pos: usize) -> bool {
904 Self::binary_search_ranges(&self.code_span_byte_ranges, pos)
905 }
906
907 pub fn is_in_link(&self, pos: usize) -> bool {
909 let idx = self.links.partition_point(|link| link.byte_offset <= pos);
910 if idx > 0 && pos < self.links[idx - 1].byte_end {
911 return true;
912 }
913 let idx = self.images.partition_point(|img| img.byte_offset <= pos);
914 if idx > 0 && pos < self.images[idx - 1].byte_end {
915 return true;
916 }
917 self.is_in_reference_def(pos)
918 }
919
920 pub fn inline_config(&self) -> &InlineConfig {
922 &self.inline_config
923 }
924
925 pub fn colon_fence_ranges(&self) -> &[(usize, usize)] {
928 &self.colon_fence_ranges
929 }
930
931 pub fn raw_lines(&self) -> &[&'a str] {
935 &self.content_lines
936 }
937
938 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
943 self.inline_config.is_rule_disabled(rule_name, line_number)
944 }
945
946 pub fn code_spans(&self) -> Arc<Vec<CodeSpan>> {
948 Arc::clone(
949 self.code_spans_cache
950 .get_or_init(|| Arc::new(element_parsers::parse_code_spans(self.content, &self.lines))),
951 )
952 }
953
954 pub fn math_byte_ranges(&self) -> &[(usize, usize)] {
958 self.math_byte_ranges_cache
959 .get_or_init(|| crate::utils::skip_context::math_byte_ranges(self.content))
960 }
961
962 pub fn math_spans(&self) -> Arc<Vec<MathSpan>> {
964 Arc::clone(
965 self.math_spans_cache
966 .get_or_init(|| Arc::new(element_parsers::parse_math_spans(self.content, &self.lines))),
967 )
968 }
969
970 pub fn is_in_math_span(&self, byte_pos: usize) -> bool {
972 let math_spans = self.math_spans();
973 let idx = math_spans.partition_point(|span| span.byte_offset <= byte_pos);
975 idx > 0 && byte_pos < math_spans[idx - 1].byte_end
976 }
977
978 pub fn html_comment_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
980 &self.html_comment_ranges
981 }
982
983 pub fn is_in_obsidian_comment(&self, byte_pos: usize) -> bool {
987 Self::binary_search_ranges(&self.obsidian_comment_ranges, byte_pos)
988 }
989
990 pub fn is_position_in_obsidian_comment(&self, line_num: usize, col: usize) -> bool {
995 if self.obsidian_comment_ranges.is_empty() {
996 return false;
997 }
998
999 let byte_pos = self.line_index.line_col_to_byte_range(line_num, col).start;
1001 self.is_in_obsidian_comment(byte_pos)
1002 }
1003
1004 pub fn myst_directive_ranges(&self) -> &[(usize, usize)] {
1006 &self.myst_directive_ranges
1007 }
1008
1009 pub fn is_in_myst_role(&self, byte_pos: usize) -> bool {
1011 Self::binary_search_ranges(&self.myst_role_ranges, byte_pos)
1012 }
1013
1014 pub fn is_in_myst_comment(&self, byte_pos: usize) -> bool {
1016 Self::binary_search_ranges(&self.myst_comment_ranges, byte_pos)
1017 }
1018
1019 pub fn is_myst_colon_directive_opener_line(&self, line_num: usize) -> bool {
1026 if !self.flavor.supports_myst_directives() {
1027 return false;
1028 }
1029 self.lines.get(line_num.wrapping_sub(1)).is_some_and(|info| {
1030 info.in_myst_directive
1031 && flavor_detection::myst_colon_directive_opener(info.content(self.content)).is_some()
1032 })
1033 }
1034
1035 fn filter_kramdown_tags(&self, tags: Vec<HtmlTag>) -> Vec<HtmlTag> {
1037 tags.into_iter()
1038 .filter(|tag| {
1039 !self
1040 .lines
1041 .get(tag.line - 1)
1042 .is_some_and(|l| l.in_kramdown_extension_block)
1043 })
1044 .collect()
1045 }
1046
1047 pub fn html_tags(&self) -> Arc<Vec<HtmlTag>> {
1053 Arc::clone(self.html_tags_cache.get_or_init(|| {
1054 let (html_tags, jsx_component_tags) =
1055 element_parsers::parse_html_tags(self.content, &self.lines, &self.code_blocks, self.flavor);
1056 let _ = self
1058 .jsx_component_tags_cache
1059 .set(Arc::new(self.filter_kramdown_tags(jsx_component_tags)));
1060 Arc::new(self.filter_kramdown_tags(html_tags))
1061 }))
1062 }
1063
1064 pub fn jsx_component_tags(&self) -> Arc<Vec<HtmlTag>> {
1067 if let Some(cached) = self.jsx_component_tags_cache.get() {
1068 return Arc::clone(cached);
1069 }
1070 let _ = self.html_tags();
1072 Arc::clone(
1073 self.jsx_component_tags_cache
1074 .get()
1075 .expect("html_tags() populates jsx_component_tags_cache"),
1076 )
1077 }
1078
1079 pub fn emphasis_spans(&self) -> Arc<Vec<EmphasisSpan>> {
1081 Arc::clone(
1082 self.emphasis_spans_cache
1083 .get()
1084 .expect("emphasis_spans_cache initialized during construction"),
1085 )
1086 }
1087
1088 pub fn table_rows(&self) -> Arc<Vec<TableRow>> {
1090 Arc::clone(
1091 self.table_rows_cache
1092 .get_or_init(|| Arc::new(element_parsers::parse_table_rows(self.content, &self.lines))),
1093 )
1094 }
1095
1096 pub fn bare_urls(&self) -> Arc<Vec<BareUrl>> {
1098 Arc::clone(self.bare_urls_cache.get_or_init(|| {
1099 Arc::new(element_parsers::parse_bare_urls(
1100 self.content,
1101 &self.lines,
1102 &self.code_blocks,
1103 ))
1104 }))
1105 }
1106
1107 pub fn lazy_continuation_lines(&self) -> Arc<Vec<LazyContLine>> {
1109 Arc::clone(self.lazy_cont_lines_cache.get_or_init(|| {
1110 Arc::new(element_parsers::detect_lazy_continuation_lines(
1111 self.content,
1112 &self.lines,
1113 &self.line_offsets,
1114 ))
1115 }))
1116 }
1117
1118 pub fn has_mixed_list_nesting(&self) -> bool {
1122 *self
1123 .has_mixed_list_nesting_cache
1124 .get_or_init(|| self.compute_mixed_list_nesting())
1125 }
1126
1127 fn compute_mixed_list_nesting(&self) -> bool {
1129 let mut stack: Vec<(usize, bool)> = Vec::new();
1134 let mut last_was_blank = false;
1135
1136 for line_info in &self.lines {
1137 if line_info.in_code_block
1139 || line_info.in_front_matter
1140 || line_info.in_mkdocstrings
1141 || line_info.in_html_comment
1142 || line_info.in_mdx_comment
1143 || line_info.in_esm_block
1144 {
1145 continue;
1146 }
1147
1148 if line_info.is_blank {
1150 last_was_blank = true;
1151 continue;
1152 }
1153
1154 if let Some(list_item) = &line_info.list_item {
1155 let current_pos = if list_item.marker_column == 1 {
1157 0
1158 } else {
1159 list_item.marker_column
1160 };
1161
1162 if last_was_blank && current_pos == 0 {
1164 stack.clear();
1165 }
1166 last_was_blank = false;
1167
1168 while let Some(&(pos, _)) = stack.last() {
1170 if pos >= current_pos {
1171 stack.pop();
1172 } else {
1173 break;
1174 }
1175 }
1176
1177 if let Some(&(_, parent_is_ordered)) = stack.last()
1179 && parent_is_ordered != list_item.is_ordered
1180 {
1181 return true; }
1183
1184 stack.push((current_pos, list_item.is_ordered));
1185 } else {
1186 last_was_blank = false;
1188 }
1189 }
1190
1191 false
1192 }
1193
1194 pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
1196 match self.line_offsets.binary_search(&offset) {
1197 Ok(line) => (line + 1, 1),
1198 Err(line) => {
1199 let line_start = self.line_offsets.get(line.wrapping_sub(1)).copied().unwrap_or(0);
1200 (line, offset - line_start + 1)
1201 }
1202 }
1203 }
1204
1205 pub fn is_in_code_block_or_span(&self, pos: usize) -> bool {
1207 if CodeBlockUtils::is_in_code_block_or_span(&self.code_blocks, pos) {
1209 return true;
1210 }
1211
1212 self.is_byte_offset_in_code_span(pos)
1214 }
1215
1216 pub fn line_info(&self, line_num: usize) -> Option<&LineInfo> {
1218 if line_num > 0 {
1219 self.lines.get(line_num - 1)
1220 } else {
1221 None
1222 }
1223 }
1224
1225 pub fn get_reference_url(&self, ref_id: &str) -> Option<&str> {
1227 let normalized_id = ref_id.to_lowercase();
1228 self.reference_defs_map
1229 .get(&normalized_id)
1230 .map(|&idx| self.reference_defs[idx].url.as_str())
1231 }
1232
1233 pub fn is_in_list_block(&self, line_num: usize) -> bool {
1235 if line_num == 0 || line_num > self.lines.len() {
1236 return false;
1237 }
1238 self.lines[line_num - 1].in_list_block
1239 }
1240
1241 pub fn is_in_html_block(&self, line_num: usize) -> bool {
1243 if line_num == 0 || line_num > self.lines.len() {
1244 return false;
1245 }
1246 self.lines[line_num - 1].in_html_block
1247 }
1248
1249 pub fn is_in_table_block(&self, line_num: usize) -> bool {
1255 if line_num == 0 || line_num > self.lines.len() {
1256 return false;
1257 }
1258 self.lines[line_num - 1].in_table_block
1259 }
1260
1261 pub fn is_in_code_span(&self, line_num: usize, col: usize) -> bool {
1263 if line_num == 0 || line_num > self.lines.len() {
1264 return false;
1265 }
1266
1267 let col_0indexed = if col > 0 { col - 1 } else { 0 };
1271 let code_spans = self.code_spans();
1272 code_spans.iter().any(|span| {
1273 if line_num < span.line || line_num > span.end_line {
1275 return false;
1276 }
1277
1278 if span.line == span.end_line {
1279 col_0indexed >= span.start_col && col_0indexed < span.end_col
1281 } else if line_num == span.line {
1282 col_0indexed >= span.start_col
1284 } else if line_num == span.end_line {
1285 col_0indexed < span.end_col
1287 } else {
1288 true
1290 }
1291 })
1292 }
1293
1294 #[inline]
1296 pub fn is_byte_offset_in_code_span(&self, byte_offset: usize) -> bool {
1297 let code_spans = self.code_spans();
1298 let idx = code_spans.partition_point(|span| span.byte_offset <= byte_offset);
1299 idx > 0 && byte_offset < code_spans[idx - 1].byte_end
1300 }
1301
1302 #[inline]
1304 pub fn is_in_reference_def(&self, byte_pos: usize) -> bool {
1305 let idx = self.reference_defs.partition_point(|rd| rd.byte_offset <= byte_pos);
1306 idx > 0 && byte_pos < self.reference_defs[idx - 1].byte_end
1307 }
1308
1309 #[inline]
1311 pub fn is_in_html_comment(&self, byte_pos: usize) -> bool {
1312 let idx = self.html_comment_ranges.partition_point(|r| r.start <= byte_pos);
1313 idx > 0 && byte_pos < self.html_comment_ranges[idx - 1].end
1314 }
1315
1316 #[inline]
1319 pub fn is_in_html_tag(&self, byte_pos: usize) -> bool {
1320 let tags = self.html_tags();
1321 let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1322 idx > 0 && byte_pos < tags[idx - 1].byte_end
1323 }
1324
1325 #[inline]
1329 pub fn is_in_jsx_component_tag(&self, byte_pos: usize) -> bool {
1330 if !self.flavor.supports_jsx() {
1331 return false;
1332 }
1333 let tags = self.jsx_component_tags();
1334 let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1335 idx > 0 && byte_pos < tags[idx - 1].byte_end
1336 }
1337
1338 pub fn is_in_jinja_range(&self, byte_pos: usize) -> bool {
1340 Self::binary_search_ranges(&self.jinja_ranges, byte_pos)
1341 }
1342
1343 #[inline]
1345 pub fn is_in_jsx_expression(&self, byte_pos: usize) -> bool {
1346 Self::binary_search_ranges(&self.jsx_expression_ranges, byte_pos)
1347 }
1348
1349 #[inline]
1351 pub fn is_in_mdx_comment(&self, byte_pos: usize) -> bool {
1352 Self::binary_search_ranges(&self.mdx_comment_ranges, byte_pos)
1353 }
1354
1355 #[inline]
1358 pub fn is_in_citation(&self, byte_pos: usize) -> bool {
1359 let idx = self.citation_ranges.partition_point(|r| r.start <= byte_pos);
1360 idx > 0 && byte_pos < self.citation_ranges[idx - 1].end
1361 }
1362
1363 #[inline]
1365 pub fn citation_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1366 &self.citation_ranges
1367 }
1368
1369 #[inline]
1372 pub fn is_in_div_block(&self, byte_pos: usize) -> bool {
1373 let idx = self.pandoc_div_ranges.partition_point(|r| r.start <= byte_pos);
1374 idx > 0 && byte_pos < self.pandoc_div_ranges[idx - 1].end
1375 }
1376
1377 #[inline]
1380 pub fn is_in_inline_footnote(&self, byte_pos: usize) -> bool {
1381 let idx = self.inline_footnote_ranges.partition_point(|r| r.start <= byte_pos);
1382 idx > 0 && byte_pos < self.inline_footnote_ranges[idx - 1].end
1383 }
1384
1385 #[inline]
1388 pub fn is_in_example_list_marker(&self, byte_pos: usize) -> bool {
1389 let idx = self.example_list_marker_ranges.partition_point(|r| r.start <= byte_pos);
1390 idx > 0 && byte_pos < self.example_list_marker_ranges[idx - 1].end
1391 }
1392
1393 #[inline]
1396 pub fn is_in_example_reference(&self, byte_pos: usize) -> bool {
1397 let idx = self.example_reference_ranges.partition_point(|r| r.start <= byte_pos);
1398 idx > 0 && byte_pos < self.example_reference_ranges[idx - 1].end
1399 }
1400
1401 #[inline]
1404 pub fn is_in_subscript_or_superscript(&self, byte_pos: usize) -> bool {
1405 let idx = self.sub_super_ranges.partition_point(|r| r.start <= byte_pos);
1406 idx > 0 && byte_pos < self.sub_super_ranges[idx - 1].end
1407 }
1408
1409 #[inline]
1413 pub fn is_in_inline_code_attr(&self, byte_pos: usize) -> bool {
1414 let idx = self.inline_code_attr_ranges.partition_point(|r| r.start <= byte_pos);
1415 idx > 0 && byte_pos < self.inline_code_attr_ranges[idx - 1].end
1416 }
1417
1418 #[inline]
1421 pub fn is_in_bracketed_span(&self, byte_pos: usize) -> bool {
1422 let idx = self.bracketed_span_ranges.partition_point(|r| r.start <= byte_pos);
1423 idx > 0 && byte_pos < self.bracketed_span_ranges[idx - 1].end
1424 }
1425
1426 #[inline]
1429 pub fn is_in_line_block(&self, byte_pos: usize) -> bool {
1430 let idx = self.line_block_ranges.partition_point(|r| r.start <= byte_pos);
1431 idx > 0 && byte_pos < self.line_block_ranges[idx - 1].end
1432 }
1433
1434 #[inline]
1438 pub fn is_in_pipe_table_caption(&self, byte_pos: usize) -> bool {
1439 let idx = self.pipe_table_caption_ranges.partition_point(|r| r.start <= byte_pos);
1440 idx > 0 && byte_pos < self.pipe_table_caption_ranges[idx - 1].end
1441 }
1442
1443 #[inline]
1446 pub fn is_in_pandoc_metadata(&self, byte_pos: usize) -> bool {
1447 let idx = self.pandoc_metadata_ranges.partition_point(|r| r.start <= byte_pos);
1448 idx > 0 && byte_pos < self.pandoc_metadata_ranges[idx - 1].end
1449 }
1450
1451 #[inline]
1454 pub fn is_in_grid_table(&self, byte_pos: usize) -> bool {
1455 let idx = self.grid_table_ranges.partition_point(|r| r.start <= byte_pos);
1456 idx > 0 && byte_pos < self.grid_table_ranges[idx - 1].end
1457 }
1458
1459 #[inline]
1462 pub fn is_in_multi_line_table(&self, byte_pos: usize) -> bool {
1463 let idx = self.multi_line_table_ranges.partition_point(|r| r.start <= byte_pos);
1464 idx > 0 && byte_pos < self.multi_line_table_ranges[idx - 1].end
1465 }
1466
1467 pub fn matches_implicit_header_reference(&self, link_text: &str) -> bool {
1472 let slug = crate::utils::pandoc::pandoc_header_slug(link_text);
1473 self.pandoc_header_slugs.contains(&slug)
1474 }
1475
1476 #[inline]
1482 pub fn has_pandoc_slug(&self, slug: &str) -> bool {
1483 self.pandoc_header_slugs.contains(slug)
1484 }
1485
1486 #[inline]
1488 pub fn is_in_shortcode(&self, byte_pos: usize) -> bool {
1489 Self::binary_search_ranges(&self.shortcode_ranges, byte_pos)
1490 }
1491
1492 #[inline]
1494 pub fn shortcode_ranges(&self) -> &[(usize, usize)] {
1495 &self.shortcode_ranges
1496 }
1497
1498 pub fn is_in_link_title(&self, byte_pos: usize) -> bool {
1500 Self::binary_search_ranges(&self.link_title_ranges, byte_pos)
1501 }
1502
1503 pub fn has_char(&self, ch: char) -> bool {
1505 match ch {
1506 '#' => self.char_frequency.hash_count > 0,
1507 '*' => self.char_frequency.asterisk_count > 0,
1508 '_' => self.char_frequency.underscore_count > 0,
1509 '-' => self.char_frequency.hyphen_count > 0,
1510 '+' => self.char_frequency.plus_count > 0,
1511 '>' => self.char_frequency.gt_count > 0,
1512 '|' => self.char_frequency.pipe_count > 0,
1513 '[' => self.char_frequency.bracket_count > 0,
1514 '`' => self.char_frequency.backtick_count > 0,
1515 '<' => self.char_frequency.lt_count > 0,
1516 '!' => self.char_frequency.exclamation_count > 0,
1517 '\n' => self.char_frequency.newline_count > 0,
1518 _ => self.content.contains(ch), }
1520 }
1521
1522 pub fn char_count(&self, ch: char) -> usize {
1524 match ch {
1525 '#' => self.char_frequency.hash_count,
1526 '*' => self.char_frequency.asterisk_count,
1527 '_' => self.char_frequency.underscore_count,
1528 '-' => self.char_frequency.hyphen_count,
1529 '+' => self.char_frequency.plus_count,
1530 '>' => self.char_frequency.gt_count,
1531 '|' => self.char_frequency.pipe_count,
1532 '[' => self.char_frequency.bracket_count,
1533 '`' => self.char_frequency.backtick_count,
1534 '<' => self.char_frequency.lt_count,
1535 '!' => self.char_frequency.exclamation_count,
1536 '\n' => self.char_frequency.newline_count,
1537 _ => self.content.matches(ch).count(), }
1539 }
1540
1541 pub fn likely_has_headings(&self) -> bool {
1543 self.char_frequency.hash_count > 0 || self.char_frequency.hyphen_count > 2 || self.content.contains('=') }
1545
1546 pub fn likely_has_lists(&self) -> bool {
1548 self.char_frequency.asterisk_count > 0
1549 || self.char_frequency.hyphen_count > 0
1550 || self.char_frequency.plus_count > 0
1551 }
1552
1553 pub fn likely_has_emphasis(&self) -> bool {
1555 self.char_frequency.asterisk_count > 1 || self.char_frequency.underscore_count > 1
1556 }
1557
1558 pub fn likely_has_tables(&self) -> bool {
1560 self.char_frequency.pipe_count > 2
1561 }
1562
1563 pub fn likely_has_blockquotes(&self) -> bool {
1565 self.char_frequency.gt_count > 0
1566 }
1567
1568 pub fn likely_has_code(&self) -> bool {
1570 self.char_frequency.backtick_count > 0
1571 }
1572
1573 pub fn likely_has_links_or_images(&self) -> bool {
1575 self.char_frequency.bracket_count > 0 || self.char_frequency.exclamation_count > 0
1576 }
1577
1578 pub fn likely_has_html(&self) -> bool {
1580 self.char_frequency.lt_count > 0
1581 }
1582
1583 pub fn blockquote_prefix_for_blank_line(&self, line_idx: usize) -> String {
1588 if let Some(line_info) = self.lines.get(line_idx)
1589 && let Some(ref bq) = line_info.blockquote
1590 {
1591 bq.prefix.trim_end().to_string()
1592 } else {
1593 String::new()
1594 }
1595 }
1596
1597 #[inline]
1603 fn find_line_for_offset(lines: &[LineInfo], byte_offset: usize) -> (usize, usize, usize) {
1604 let idx = match lines.binary_search_by(|line| {
1606 if byte_offset < line.byte_offset {
1607 std::cmp::Ordering::Greater
1608 } else if byte_offset > line.byte_offset + line.byte_len {
1609 std::cmp::Ordering::Less
1610 } else {
1611 std::cmp::Ordering::Equal
1612 }
1613 }) {
1614 Ok(idx) => idx,
1615 Err(idx) => idx.saturating_sub(1),
1616 };
1617
1618 let line = &lines[idx];
1619 let line_num = idx + 1;
1620 let col = byte_offset.saturating_sub(line.byte_offset);
1621
1622 (idx, line_num, col)
1623 }
1624
1625 #[inline]
1627 fn is_offset_in_code_span(code_spans: &[CodeSpan], offset: usize) -> bool {
1628 let idx = code_spans.partition_point(|span| span.byte_offset <= offset);
1630
1631 if idx > 0 {
1633 let span = &code_spans[idx - 1];
1634 if offset >= span.byte_offset && offset < span.byte_end {
1635 return true;
1636 }
1637 }
1638
1639 false
1640 }
1641
1642 #[must_use]
1662 pub fn valid_headings(&self) -> ValidHeadingsIter<'_> {
1663 ValidHeadingsIter::new(&self.lines)
1664 }
1665
1666 #[must_use]
1670 pub fn has_valid_headings(&self) -> bool {
1671 self.lines
1672 .iter()
1673 .any(|line| line.heading.as_ref().is_some_and(|h| h.is_valid))
1674 }
1675}
1676
1677fn detect_footnote_definitions(content: &str, lines: &mut [types::LineInfo], line_offsets: &[usize]) {
1686 use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
1687
1688 let options = crate::utils::rumdl_parser_options();
1689 let parser = Parser::new_ext(content, options).into_offset_iter();
1690
1691 let mut footnote_ranges: Vec<(usize, usize)> = Vec::new();
1693 let mut fenced_code_ranges: Vec<(usize, usize)> = Vec::new();
1694 let mut in_footnote = false;
1695
1696 for (event, range) in parser {
1697 match event {
1698 Event::Start(Tag::FootnoteDefinition(_)) => {
1699 in_footnote = true;
1700 footnote_ranges.push((range.start, range.end));
1701 }
1702 Event::End(TagEnd::FootnoteDefinition) => {
1703 in_footnote = false;
1704 }
1705 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) if in_footnote => {
1706 fenced_code_ranges.push((range.start, range.end));
1707 }
1708 _ => {}
1709 }
1710 }
1711
1712 let byte_to_line = |byte_offset: usize| -> usize {
1713 line_offsets
1714 .partition_point(|&offset| offset <= byte_offset)
1715 .saturating_sub(1)
1716 };
1717
1718 for &(start, end) in &footnote_ranges {
1720 let start_line = byte_to_line(start);
1721 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
1722
1723 for line in &mut lines[start_line..end_line] {
1724 line.in_footnote_definition = true;
1725 line.in_code_block = false;
1726 }
1727 }
1728
1729 for &(start, end) in &fenced_code_ranges {
1731 let start_line = byte_to_line(start);
1732 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
1733
1734 for line in &mut lines[start_line..end_line] {
1735 line.in_code_block = true;
1736 }
1737 }
1738}