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>>>, 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)>, front_matter_end: usize, }
117
118impl<'a> LintContext<'a> {
119 pub fn new(content: &'a str, flavor: MarkdownFlavor, source_file: Option<PathBuf>) -> Self {
120 #[cfg(not(target_arch = "wasm32"))]
121 let profile = std::env::var("RUMDL_PROFILE_QUADRATIC").is_ok();
122
123 let line_offsets = profile_section!("Line offsets", profile, {
124 let mut offsets = vec![0];
125 for (i, c) in content.char_indices() {
126 if c == '\n' {
127 offsets.push(i + 1);
128 }
129 }
130 offsets
131 });
132
133 let content_lines: Vec<&str> = content.lines().collect();
135
136 #[allow(clippy::disallowed_methods)]
140 let front_matter_end = FrontMatterUtils::get_front_matter_end_line(content);
141
142 let parse_result = profile_section!(
144 "Code blocks",
145 profile,
146 CodeBlockUtils::detect_code_blocks_and_spans(content)
147 );
148 let mut code_blocks = parse_result.code_blocks;
149 let code_span_ranges = parse_result.code_spans;
150 let code_block_details = parse_result.code_block_details;
151 let strong_spans = parse_result.strong_spans;
152 let line_to_list = parse_result.line_to_list;
153 let list_start_values = parse_result.list_start_values;
154
155 let html_comment_ranges = profile_section!(
157 "HTML comment ranges",
158 profile,
159 crate::utils::skip_context::compute_html_comment_ranges(content)
160 );
161
162 let autodoc_ranges = profile_section!("Autodoc block ranges", profile, {
166 if flavor.supports_colon_code_fences() || flavor.supports_myst_directives() {
167 Vec::new()
168 } else {
169 crate::utils::mkdocstrings_refs::detect_autodoc_block_ranges(content)
170 }
171 });
172
173 let pandoc_div_ranges = profile_section!("Pandoc div ranges", profile, {
175 if flavor.is_pandoc_compatible() {
176 crate::utils::pandoc::detect_div_block_ranges(content)
177 } else {
178 Vec::new()
179 }
180 });
181
182 let pymdown_block_ranges = profile_section!("PyMdown block ranges", profile, {
184 if flavor == MarkdownFlavor::MkDocs {
185 crate::utils::pymdown_blocks::detect_block_ranges(content)
186 } else {
187 Vec::new()
188 }
189 });
190
191 let skip_ranges = SkipByteRanges {
194 html_comment_ranges: &html_comment_ranges,
195 autodoc_ranges: &autodoc_ranges,
196 pandoc_div_ranges: &pandoc_div_ranges,
197 pymdown_block_ranges: &pymdown_block_ranges,
198 };
199 let (mut lines, emphasis_spans) = profile_section!(
200 "Basic line info",
201 profile,
202 line_computation::compute_basic_line_info(
203 content,
204 &content_lines,
205 &line_offsets,
206 &code_blocks,
207 flavor,
208 &skip_ranges,
209 front_matter_end,
210 )
211 );
212
213 profile_section!(
215 "HTML blocks",
216 profile,
217 heading_detection::detect_html_blocks(content, &mut lines)
218 );
219
220 profile_section!(
222 "ESM blocks",
223 profile,
224 flavor_detection::detect_esm_blocks(content, &mut lines, flavor)
225 );
226
227 profile_section!(
229 "JSX block detection",
230 profile,
231 flavor_detection::detect_jsx_blocks(content, &mut lines, flavor)
232 );
233
234 let (jsx_expression_ranges, mdx_comment_ranges) = profile_section!(
236 "JSX/MDX detection",
237 profile,
238 flavor_detection::detect_jsx_and_mdx_comments(content, &mut lines, flavor, &code_blocks)
239 );
240
241 profile_section!(
246 "Markdown-in-HTML blocks",
247 profile,
248 flavor_detection::detect_markdown_html_blocks(&content_lines, &mut lines)
249 );
250
251 profile_section!(
253 "MkDocs constructs",
254 profile,
255 flavor_detection::detect_mkdocs_line_info(&content_lines, &mut lines, flavor)
256 );
257
258 profile_section!(
263 "Footnote definitions",
264 profile,
265 detect_footnote_definitions(content, &mut lines, &line_offsets)
266 );
267
268 {
271 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
272 for &(start, end) in &code_blocks {
273 let start_line = line_offsets
274 .partition_point(|&offset| offset <= start)
275 .saturating_sub(1);
276 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
277
278 let mut sub_start: Option<usize> = None;
279 for (i, &offset) in line_offsets[start_line..end_line]
280 .iter()
281 .enumerate()
282 .map(|(j, o)| (j + start_line, o))
283 {
284 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
285 if is_real_code && sub_start.is_none() {
286 let byte_start = if i == start_line { start } else { offset };
287 sub_start = Some(byte_start);
288 } else if !is_real_code && sub_start.is_some() {
289 new_code_blocks.push((sub_start.unwrap(), offset));
290 sub_start = None;
291 }
292 }
293 if let Some(s) = sub_start {
294 new_code_blocks.push((s, end));
295 }
296 }
297 code_blocks = new_code_blocks;
298 }
299
300 let has_markdown_html = lines.iter().any(|l| l.in_mkdocs_html_markdown);
308 if flavor == MarkdownFlavor::MkDocs || has_markdown_html {
309 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
310 for &(start, end) in &code_blocks {
311 let start_line = line_offsets
312 .partition_point(|&offset| offset <= start)
313 .saturating_sub(1);
314 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
315
316 let mut sub_start: Option<usize> = None;
318 for (i, &offset) in line_offsets[start_line..end_line]
319 .iter()
320 .enumerate()
321 .map(|(j, o)| (j + start_line, o))
322 {
323 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
324 if is_real_code && sub_start.is_none() {
325 let byte_start = if i == start_line { start } else { offset };
326 sub_start = Some(byte_start);
327 } else if !is_real_code && sub_start.is_some() {
328 new_code_blocks.push((sub_start.unwrap(), offset));
329 sub_start = None;
330 }
331 }
332 if let Some(s) = sub_start {
333 new_code_blocks.push((s, end));
334 }
335 }
336 code_blocks = new_code_blocks;
337 }
338
339 if flavor.supports_jsx() {
343 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
344 for &(start, end) in &code_blocks {
345 let start_line = line_offsets
346 .partition_point(|&offset| offset <= start)
347 .saturating_sub(1);
348 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
349
350 let mut sub_start: Option<usize> = None;
351 for (i, &offset) in line_offsets[start_line..end_line]
352 .iter()
353 .enumerate()
354 .map(|(j, o)| (j + start_line, o))
355 {
356 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
357 if is_real_code && sub_start.is_none() {
358 let byte_start = if i == start_line { start } else { offset };
359 sub_start = Some(byte_start);
360 } else if !is_real_code && sub_start.is_some() {
361 new_code_blocks.push((sub_start.unwrap(), offset));
362 sub_start = None;
363 }
364 }
365 if let Some(s) = sub_start {
366 new_code_blocks.push((s, end));
367 }
368 }
369 code_blocks = new_code_blocks;
370 }
371
372 let colon_fence_ranges = profile_section!(
375 "Azure colon fence detection",
376 profile,
377 flavor_detection::detect_azure_colon_fences(content, &mut lines, flavor)
378 );
379 if !colon_fence_ranges.is_empty() {
380 code_blocks.extend(colon_fence_ranges.iter().copied());
381 code_blocks.sort_by_key(|&(start, _)| start);
382 }
383
384 let myst_directive_ranges = profile_section!(
387 "MyST colon directives",
388 profile,
389 flavor_detection::detect_myst_colon_directives(content, &mut lines, flavor)
390 );
391
392 let myst_comment_ranges = profile_section!(
394 "MyST comments",
395 profile,
396 flavor_detection::detect_myst_comments(content, &mut lines, flavor)
397 );
398
399 profile_section!(
402 "MyST backtick directives",
403 profile,
404 flavor_detection::detect_myst_backtick_directives(
405 content,
406 &mut lines,
407 flavor,
408 &code_block_details,
409 &line_offsets
410 )
411 );
412
413 if flavor.supports_myst_directives() {
416 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
417 for &(start, end) in &code_blocks {
418 let start_line = line_offsets
419 .partition_point(|&offset| offset <= start)
420 .saturating_sub(1);
421 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
422
423 let mut sub_start: Option<usize> = None;
424 for (i, &offset) in line_offsets[start_line..end_line]
425 .iter()
426 .enumerate()
427 .map(|(j, o)| (j + start_line, o))
428 {
429 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
430 if is_real_code && sub_start.is_none() {
431 let byte_start = if i == start_line { start } else { offset };
432 sub_start = Some(byte_start);
433 } else if !is_real_code && sub_start.is_some() {
434 new_code_blocks.push((sub_start.unwrap(), offset));
435 sub_start = None;
436 }
437 }
438 if let Some(s) = sub_start {
439 new_code_blocks.push((s, end));
440 }
441 }
442 code_blocks = new_code_blocks;
443 }
444
445 profile_section!(
447 "Kramdown constructs",
448 profile,
449 flavor_detection::detect_kramdown_line_info(content, &mut lines, flavor)
450 );
451
452 for line in &mut lines {
457 if line.in_kramdown_extension_block {
458 line.list_item = None;
459 line.is_horizontal_rule = false;
460 line.blockquote = None;
461 line.is_kramdown_block_ial = false;
462 }
463 }
464
465 let obsidian_comment_ranges = profile_section!(
467 "Obsidian comments",
468 profile,
469 flavor_detection::detect_obsidian_comments(content, &mut lines, flavor, &code_span_ranges)
470 );
471
472 let myst_role_ranges = profile_section!(
474 "MyST roles",
475 profile,
476 flavor_detection::detect_myst_role_ranges(content, &lines, flavor, &code_blocks)
477 );
478
479 let pulldown_result = profile_section!(
483 "Links, images & link ranges",
484 profile,
485 link_parser::parse_links_images_pulldown(content, &lines, &code_blocks, flavor, &html_comment_ranges)
486 );
487
488 profile_section!(
490 "Headings & blockquotes",
491 profile,
492 heading_detection::detect_headings_and_blockquotes(
493 &content_lines,
494 &mut lines,
495 flavor,
496 &html_comment_ranges,
497 &pulldown_result.link_byte_ranges,
498 front_matter_end,
499 )
500 );
501
502 for line in &mut lines {
504 if line.in_kramdown_extension_block {
505 line.heading = None;
506 }
507 }
508
509 let mut code_spans = profile_section!(
511 "Code spans",
512 profile,
513 element_parsers::build_code_spans_from_ranges(content, &lines, &code_span_ranges)
514 );
515
516 if flavor == MarkdownFlavor::MkDocs {
520 let extra = profile_section!(
521 "MkDocs code spans",
522 profile,
523 element_parsers::scan_mkdocs_container_code_spans(content, &lines, &code_span_ranges,)
524 );
525 if !extra.is_empty() {
526 code_spans.extend(extra);
527 code_spans.sort_by_key(|span| span.byte_offset);
528 }
529 }
530
531 if flavor == MarkdownFlavor::MDX {
536 let extra = profile_section!(
537 "MDX JSX code spans",
538 profile,
539 element_parsers::scan_jsx_block_code_spans(content, &lines, &code_span_ranges)
540 );
541 if !extra.is_empty() {
542 code_spans.extend(extra);
543 code_spans.sort_by_key(|span| span.byte_offset);
544 }
545 }
546
547 for span in &code_spans {
550 if span.end_line > span.line {
551 for line_num in (span.line + 1)..=span.end_line {
553 if let Some(line_info) = lines.get_mut(line_num - 1) {
554 line_info.in_code_span_continuation = true;
555 }
556 }
557 }
558 }
559
560 let (links, images, broken_links, footnote_refs) = profile_section!(
562 "Links & images finalize",
563 profile,
564 link_parser::finalize_links_and_images(
565 content,
566 &lines,
567 &code_blocks,
568 &code_spans,
569 flavor,
570 &html_comment_ranges,
571 pulldown_result
572 )
573 );
574
575 let reference_defs = profile_section!(
576 "Reference defs",
577 profile,
578 link_parser::parse_reference_defs(content, &lines)
579 );
580
581 let list_blocks = profile_section!("List blocks", profile, list_blocks::parse_list_blocks(content, &lines));
582
583 let char_frequency = profile_section!(
585 "Char frequency",
586 profile,
587 line_computation::compute_char_frequency(content)
588 );
589
590 let table_blocks = profile_section!(
592 "Table blocks",
593 profile,
594 crate::utils::table_utils::TableUtils::find_table_blocks_with_code_info(
595 content,
596 &code_blocks,
597 &code_spans,
598 &html_comment_ranges,
599 )
600 );
601
602 let links = links
605 .into_iter()
606 .filter(|link| !lines.get(link.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
607 .collect::<Vec<_>>();
608 let images = images
609 .into_iter()
610 .filter(|img| !lines.get(img.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
611 .collect::<Vec<_>>();
612 let broken_links = broken_links
613 .into_iter()
614 .filter(|bl| {
615 let line_idx = line_offsets
617 .partition_point(|&offset| offset <= bl.span.start)
618 .saturating_sub(1);
619 !lines.get(line_idx).is_some_and(|l| l.in_kramdown_extension_block)
620 })
621 .collect::<Vec<_>>();
622 let footnote_refs = footnote_refs
623 .into_iter()
624 .filter(|fr| !lines.get(fr.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
625 .collect::<Vec<_>>();
626 let reference_defs = reference_defs
627 .into_iter()
628 .filter(|def| !lines.get(def.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
629 .collect::<Vec<_>>();
630 let list_blocks = list_blocks
631 .into_iter()
632 .filter(|block| {
633 !lines
634 .get(block.start_line - 1)
635 .is_some_and(|l| l.in_kramdown_extension_block)
636 })
637 .collect::<Vec<_>>();
638 let table_blocks = table_blocks
639 .into_iter()
640 .filter(|block| {
641 !lines
643 .get(block.start_line)
644 .is_some_and(|l| l.in_kramdown_extension_block)
645 })
646 .collect::<Vec<_>>();
647 let emphasis_spans = emphasis_spans
648 .into_iter()
649 .filter(|span| !lines.get(span.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
650 .collect::<Vec<_>>();
651
652 for block in &list_blocks {
656 for line_num in block.start_line..=block.end_line {
658 if let Some(li) = lines.get_mut(line_num - 1) {
659 li.in_list_block = true;
660 }
661 }
662 }
663 for block in &table_blocks {
664 for idx in block.start_line..=block.end_line {
666 if let Some(li) = lines.get_mut(idx) {
667 li.in_table_block = true;
668 }
669 }
670 }
671
672 let reference_defs_map: HashMap<String, usize> = reference_defs
674 .iter()
675 .enumerate()
676 .map(|(idx, def)| (def.id.to_lowercase(), idx))
677 .collect();
678
679 let link_title_ranges: Vec<(usize, usize)> = reference_defs
681 .iter()
682 .filter_map(|def| match (def.title_byte_start, def.title_byte_end) {
683 (Some(start), Some(end)) => Some((start, end)),
684 _ => None,
685 })
686 .collect();
687
688 let line_index = profile_section!(
690 "Line index",
691 profile,
692 crate::utils::range_utils::LineIndex::with_line_starts_and_code_blocks(
693 content,
694 line_offsets.clone(),
695 &code_blocks,
696 )
697 );
698
699 let jinja_ranges = profile_section!(
701 "Jinja ranges",
702 profile,
703 crate::utils::jinja_utils::find_jinja_ranges(content)
704 );
705
706 let citation_ranges = profile_section!("Citation ranges", profile, {
708 if flavor.is_pandoc_compatible() {
709 crate::utils::pandoc::find_citation_ranges(content)
710 } else {
711 Vec::new()
712 }
713 });
714
715 let inline_footnote_ranges = profile_section!("Inline footnote ranges", profile, {
717 if flavor.is_pandoc_compatible() {
718 crate::utils::pandoc::detect_inline_footnote_ranges(content)
719 } else {
720 Vec::new()
721 }
722 });
723
724 let pandoc_header_slugs = profile_section!("Pandoc header slugs", profile, {
726 if flavor.is_pandoc_compatible() {
727 crate::utils::pandoc::collect_pandoc_header_slugs(content)
728 } else {
729 std::collections::HashSet::new()
730 }
731 });
732
733 let example_list_marker_ranges = profile_section!("Example list markers", profile, {
735 if flavor.is_pandoc_compatible() {
736 crate::utils::pandoc::detect_example_list_marker_ranges(content)
737 } else {
738 Vec::new()
739 }
740 });
741
742 let example_reference_ranges = profile_section!("Example references", profile, {
744 if flavor.is_pandoc_compatible() {
745 crate::utils::pandoc::detect_example_reference_ranges(content, &example_list_marker_ranges)
746 } else {
747 Vec::new()
748 }
749 });
750
751 let sub_super_ranges = profile_section!("Subscript/superscript ranges", profile, {
753 if flavor.is_pandoc_compatible() {
754 crate::utils::pandoc::detect_subscript_superscript_ranges(content)
755 } else {
756 Vec::new()
757 }
758 });
759
760 let inline_code_attr_ranges = profile_section!("Inline code attribute ranges", profile, {
762 if flavor.is_pandoc_compatible() {
763 crate::utils::pandoc::detect_inline_code_attr_ranges(content)
764 } else {
765 Vec::new()
766 }
767 });
768
769 let bracketed_span_ranges = profile_section!("Bracketed span ranges", profile, {
771 if flavor.is_pandoc_compatible() {
772 crate::utils::pandoc::detect_bracketed_span_ranges(content)
773 } else {
774 Vec::new()
775 }
776 });
777
778 let line_block_ranges = profile_section!("Line block ranges", profile, {
780 if flavor.is_pandoc_compatible() {
781 crate::utils::pandoc::detect_line_block_ranges(content)
782 } else {
783 Vec::new()
784 }
785 });
786
787 let pipe_table_caption_ranges = profile_section!("Pipe-table caption ranges", profile, {
789 if flavor.is_pandoc_compatible() {
790 crate::utils::pandoc::detect_pipe_table_caption_ranges(content)
791 } else {
792 Vec::new()
793 }
794 });
795
796 let pandoc_metadata_ranges = profile_section!("Pandoc metadata ranges", profile, {
798 if flavor.is_pandoc_compatible() {
799 crate::utils::pandoc::detect_yaml_metadata_block_ranges(content)
800 } else {
801 Vec::new()
802 }
803 });
804
805 let grid_table_ranges = profile_section!("Grid table ranges", profile, {
807 if flavor.is_pandoc_compatible() {
808 crate::utils::pandoc::detect_grid_table_ranges(content)
809 } else {
810 Vec::new()
811 }
812 });
813
814 let multi_line_table_ranges = profile_section!("Multi-line table ranges", profile, {
816 if flavor.is_pandoc_compatible() {
817 crate::utils::pandoc::detect_multi_line_table_ranges(content)
818 } else {
819 Vec::new()
820 }
821 });
822
823 let shortcode_ranges = profile_section!("Shortcode ranges", profile, {
825 use crate::utils::regex_cache::HUGO_SHORTCODE_REGEX;
826 let mut ranges = Vec::new();
827 for mat in HUGO_SHORTCODE_REGEX.find_iter(content) {
828 ranges.push((mat.start(), mat.end()));
829 }
830 ranges
831 });
832
833 let inline_config = InlineConfig::from_content_with_code_blocks(content, &code_blocks);
834
835 Self {
836 content,
837 content_lines,
838 line_offsets,
839 code_blocks,
840 code_block_details,
841 strong_spans,
842 line_to_list,
843 list_start_values,
844 lines,
845 links,
846 images,
847 broken_links,
848 footnote_refs,
849 reference_defs,
850 reference_defs_map,
851 code_spans_cache: OnceLock::from(Arc::new(code_spans)),
852 math_spans_cache: OnceLock::new(), math_byte_ranges_cache: OnceLock::new(), list_blocks,
855 char_frequency,
856 html_tags_cache: OnceLock::new(),
857 jsx_component_tags_cache: OnceLock::new(),
858 emphasis_spans_cache: OnceLock::from(Arc::new(emphasis_spans)),
859 table_rows_cache: OnceLock::new(),
860 bare_urls_cache: OnceLock::new(),
861 has_mixed_list_nesting_cache: OnceLock::new(),
862 html_comment_ranges,
863 table_blocks,
864 line_index,
865 jinja_ranges,
866 flavor,
867 source_file,
868 jsx_expression_ranges,
869 mdx_comment_ranges,
870 citation_ranges,
871 pandoc_div_ranges,
872 colon_fence_ranges,
873 inline_footnote_ranges,
874 pandoc_header_slugs,
875 example_list_marker_ranges,
876 example_reference_ranges,
877 sub_super_ranges,
878 inline_code_attr_ranges,
879 bracketed_span_ranges,
880 line_block_ranges,
881 pipe_table_caption_ranges,
882 pandoc_metadata_ranges,
883 grid_table_ranges,
884 multi_line_table_ranges,
885 shortcode_ranges,
886 link_title_ranges,
887 code_span_byte_ranges: code_span_ranges,
888 inline_config,
889 obsidian_comment_ranges,
890 lazy_cont_lines_cache: OnceLock::new(),
891 myst_directive_ranges,
892 myst_comment_ranges,
893 myst_role_ranges,
894 front_matter_end,
895 }
896 }
897
898 pub fn front_matter_end_line(&self) -> usize {
903 self.front_matter_end
904 }
905
906 #[inline]
909 fn binary_search_ranges(ranges: &[(usize, usize)], pos: usize) -> bool {
910 let idx = ranges.partition_point(|&(start, _)| start <= pos);
912 idx > 0 && pos < ranges[idx - 1].1
914 }
915
916 pub fn is_in_code_span_byte(&self, pos: usize) -> bool {
918 Self::binary_search_ranges(&self.code_span_byte_ranges, pos)
919 }
920
921 pub fn is_in_link(&self, pos: usize) -> bool {
923 let idx = self.links.partition_point(|link| link.byte_offset <= pos);
924 if idx > 0 && pos < self.links[idx - 1].byte_end {
925 return true;
926 }
927 let idx = self.images.partition_point(|img| img.byte_offset <= pos);
928 if idx > 0 && pos < self.images[idx - 1].byte_end {
929 return true;
930 }
931 self.is_in_reference_def(pos)
932 }
933
934 pub fn inline_config(&self) -> &InlineConfig {
936 &self.inline_config
937 }
938
939 pub fn colon_fence_ranges(&self) -> &[(usize, usize)] {
942 &self.colon_fence_ranges
943 }
944
945 pub fn raw_lines(&self) -> &[&'a str] {
949 &self.content_lines
950 }
951
952 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
957 self.inline_config.is_rule_disabled(rule_name, line_number)
958 }
959
960 pub fn code_spans(&self) -> Arc<Vec<CodeSpan>> {
962 Arc::clone(
963 self.code_spans_cache
964 .get_or_init(|| Arc::new(element_parsers::parse_code_spans(self.content, &self.lines))),
965 )
966 }
967
968 pub fn math_byte_ranges(&self) -> &[(usize, usize)] {
972 self.math_byte_ranges_cache
973 .get_or_init(|| crate::utils::skip_context::math_byte_ranges(self.content))
974 }
975
976 pub fn math_spans(&self) -> Arc<Vec<MathSpan>> {
978 Arc::clone(
979 self.math_spans_cache
980 .get_or_init(|| Arc::new(element_parsers::parse_math_spans(self.content, &self.lines))),
981 )
982 }
983
984 pub fn is_in_math_span(&self, byte_pos: usize) -> bool {
986 let math_spans = self.math_spans();
987 let idx = math_spans.partition_point(|span| span.byte_offset <= byte_pos);
989 idx > 0 && byte_pos < math_spans[idx - 1].byte_end
990 }
991
992 pub fn html_comment_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
994 &self.html_comment_ranges
995 }
996
997 pub fn is_in_obsidian_comment(&self, byte_pos: usize) -> bool {
1001 Self::binary_search_ranges(&self.obsidian_comment_ranges, byte_pos)
1002 }
1003
1004 pub fn is_position_in_obsidian_comment(&self, line_num: usize, col: usize) -> bool {
1009 if self.obsidian_comment_ranges.is_empty() {
1010 return false;
1011 }
1012
1013 let byte_pos = self.line_index.line_col_to_byte_range(line_num, col).start;
1015 self.is_in_obsidian_comment(byte_pos)
1016 }
1017
1018 pub fn myst_directive_ranges(&self) -> &[(usize, usize)] {
1020 &self.myst_directive_ranges
1021 }
1022
1023 pub fn is_in_myst_role(&self, byte_pos: usize) -> bool {
1025 Self::binary_search_ranges(&self.myst_role_ranges, byte_pos)
1026 }
1027
1028 pub fn is_in_myst_comment(&self, byte_pos: usize) -> bool {
1030 Self::binary_search_ranges(&self.myst_comment_ranges, byte_pos)
1031 }
1032
1033 pub fn is_myst_colon_directive_opener_line(&self, line_num: usize) -> bool {
1040 if !self.flavor.supports_myst_directives() {
1041 return false;
1042 }
1043 self.lines.get(line_num.wrapping_sub(1)).is_some_and(|info| {
1044 info.in_myst_directive
1045 && flavor_detection::myst_colon_directive_opener(info.content(self.content)).is_some()
1046 })
1047 }
1048
1049 fn filter_kramdown_tags(&self, tags: Vec<HtmlTag>) -> Vec<HtmlTag> {
1051 tags.into_iter()
1052 .filter(|tag| {
1053 !self
1054 .lines
1055 .get(tag.line - 1)
1056 .is_some_and(|l| l.in_kramdown_extension_block)
1057 })
1058 .collect()
1059 }
1060
1061 pub fn html_tags(&self) -> Arc<Vec<HtmlTag>> {
1067 Arc::clone(self.html_tags_cache.get_or_init(|| {
1068 let (html_tags, jsx_component_tags) =
1069 element_parsers::parse_html_tags(self.content, &self.lines, &self.code_blocks, self.flavor);
1070 let _ = self
1072 .jsx_component_tags_cache
1073 .set(Arc::new(self.filter_kramdown_tags(jsx_component_tags)));
1074 Arc::new(self.filter_kramdown_tags(html_tags))
1075 }))
1076 }
1077
1078 pub fn jsx_component_tags(&self) -> Arc<Vec<HtmlTag>> {
1081 if let Some(cached) = self.jsx_component_tags_cache.get() {
1082 return Arc::clone(cached);
1083 }
1084 let _ = self.html_tags();
1086 Arc::clone(
1087 self.jsx_component_tags_cache
1088 .get()
1089 .expect("html_tags() populates jsx_component_tags_cache"),
1090 )
1091 }
1092
1093 pub fn emphasis_spans(&self) -> Arc<Vec<EmphasisSpan>> {
1095 Arc::clone(
1096 self.emphasis_spans_cache
1097 .get()
1098 .expect("emphasis_spans_cache initialized during construction"),
1099 )
1100 }
1101
1102 pub fn table_rows(&self) -> Arc<Vec<TableRow>> {
1104 Arc::clone(
1105 self.table_rows_cache
1106 .get_or_init(|| Arc::new(element_parsers::parse_table_rows(self.content, &self.lines))),
1107 )
1108 }
1109
1110 pub fn bare_urls(&self) -> Arc<Vec<BareUrl>> {
1112 Arc::clone(self.bare_urls_cache.get_or_init(|| {
1113 Arc::new(element_parsers::parse_bare_urls(
1114 self.content,
1115 &self.lines,
1116 &self.code_blocks,
1117 ))
1118 }))
1119 }
1120
1121 pub fn lazy_continuation_lines(&self) -> Arc<Vec<LazyContLine>> {
1123 Arc::clone(self.lazy_cont_lines_cache.get_or_init(|| {
1124 Arc::new(element_parsers::detect_lazy_continuation_lines(
1125 self.content,
1126 &self.lines,
1127 &self.line_offsets,
1128 ))
1129 }))
1130 }
1131
1132 pub fn has_mixed_list_nesting(&self) -> bool {
1136 *self
1137 .has_mixed_list_nesting_cache
1138 .get_or_init(|| self.compute_mixed_list_nesting())
1139 }
1140
1141 fn compute_mixed_list_nesting(&self) -> bool {
1143 let mut stack: Vec<(usize, bool)> = Vec::new();
1148 let mut last_was_blank = false;
1149
1150 for line_info in &self.lines {
1151 if line_info.in_code_block
1153 || line_info.in_front_matter
1154 || line_info.in_mkdocstrings
1155 || line_info.in_html_comment
1156 || line_info.in_mdx_comment
1157 || line_info.in_esm_block
1158 {
1159 continue;
1160 }
1161
1162 if line_info.is_blank {
1164 last_was_blank = true;
1165 continue;
1166 }
1167
1168 if let Some(list_item) = &line_info.list_item {
1169 let current_pos = if list_item.marker_column == 1 {
1171 0
1172 } else {
1173 list_item.marker_column
1174 };
1175
1176 if last_was_blank && current_pos == 0 {
1178 stack.clear();
1179 }
1180 last_was_blank = false;
1181
1182 while let Some(&(pos, _)) = stack.last() {
1184 if pos >= current_pos {
1185 stack.pop();
1186 } else {
1187 break;
1188 }
1189 }
1190
1191 if let Some(&(_, parent_is_ordered)) = stack.last()
1193 && parent_is_ordered != list_item.is_ordered
1194 {
1195 return true; }
1197
1198 stack.push((current_pos, list_item.is_ordered));
1199 } else {
1200 last_was_blank = false;
1202 }
1203 }
1204
1205 false
1206 }
1207
1208 pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
1214 match self.line_offsets.binary_search(&offset) {
1215 Ok(line) => (line + 1, 1),
1216 Err(line) => {
1217 let line_start = self.line_offsets.get(line.wrapping_sub(1)).copied().unwrap_or(0);
1218 let col = byte_to_char_count(&self.content[line_start..], offset.saturating_sub(line_start));
1220 (line, col)
1221 }
1222 }
1223 }
1224
1225 pub fn is_in_code_block_or_span(&self, pos: usize) -> bool {
1227 if CodeBlockUtils::is_in_code_block_or_span(&self.code_blocks, pos) {
1229 return true;
1230 }
1231
1232 self.is_byte_offset_in_code_span(pos)
1234 }
1235
1236 pub fn line_info(&self, line_num: usize) -> Option<&LineInfo> {
1238 if line_num > 0 {
1239 self.lines.get(line_num - 1)
1240 } else {
1241 None
1242 }
1243 }
1244
1245 pub fn get_reference_url(&self, ref_id: &str) -> Option<&str> {
1247 let normalized_id = ref_id.to_lowercase();
1248 self.reference_defs_map
1249 .get(&normalized_id)
1250 .map(|&idx| self.reference_defs[idx].url.as_str())
1251 }
1252
1253 pub fn is_in_list_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_list_block
1259 }
1260
1261 pub fn is_in_html_block(&self, line_num: usize) -> bool {
1263 if line_num == 0 || line_num > self.lines.len() {
1264 return false;
1265 }
1266 self.lines[line_num - 1].in_html_block
1267 }
1268
1269 pub fn is_in_table_block(&self, line_num: usize) -> bool {
1275 if line_num == 0 || line_num > self.lines.len() {
1276 return false;
1277 }
1278 self.lines[line_num - 1].in_table_block
1279 }
1280
1281 pub fn is_in_code_span(&self, line_num: usize, col: usize) -> bool {
1283 if line_num == 0 || line_num > self.lines.len() {
1284 return false;
1285 }
1286
1287 let col_0indexed = if col > 0 { col - 1 } else { 0 };
1291 let code_spans = self.code_spans();
1292 code_spans.iter().any(|span| {
1293 if line_num < span.line || line_num > span.end_line {
1295 return false;
1296 }
1297
1298 if span.line == span.end_line {
1299 col_0indexed >= span.start_col && col_0indexed < span.end_col
1301 } else if line_num == span.line {
1302 col_0indexed >= span.start_col
1304 } else if line_num == span.end_line {
1305 col_0indexed < span.end_col
1307 } else {
1308 true
1310 }
1311 })
1312 }
1313
1314 #[inline]
1316 pub fn is_byte_offset_in_code_span(&self, byte_offset: usize) -> bool {
1317 let code_spans = self.code_spans();
1318 let idx = code_spans.partition_point(|span| span.byte_offset <= byte_offset);
1319 idx > 0 && byte_offset < code_spans[idx - 1].byte_end
1320 }
1321
1322 #[inline]
1324 pub fn is_in_reference_def(&self, byte_pos: usize) -> bool {
1325 let idx = self.reference_defs.partition_point(|rd| rd.byte_offset <= byte_pos);
1326 idx > 0 && byte_pos < self.reference_defs[idx - 1].byte_end
1327 }
1328
1329 #[inline]
1331 pub fn is_in_html_comment(&self, byte_pos: usize) -> bool {
1332 let idx = self.html_comment_ranges.partition_point(|r| r.start <= byte_pos);
1333 idx > 0 && byte_pos < self.html_comment_ranges[idx - 1].end
1334 }
1335
1336 #[inline]
1339 pub fn is_in_html_tag(&self, byte_pos: usize) -> bool {
1340 let tags = self.html_tags();
1341 let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1342 idx > 0 && byte_pos < tags[idx - 1].byte_end
1343 }
1344
1345 #[inline]
1349 pub fn is_in_jsx_component_tag(&self, byte_pos: usize) -> bool {
1350 if !self.flavor.supports_jsx() {
1351 return false;
1352 }
1353 let tags = self.jsx_component_tags();
1354 let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1355 idx > 0 && byte_pos < tags[idx - 1].byte_end
1356 }
1357
1358 pub fn is_in_jinja_range(&self, byte_pos: usize) -> bool {
1360 Self::binary_search_ranges(&self.jinja_ranges, byte_pos)
1361 }
1362
1363 #[inline]
1365 pub fn is_in_jsx_expression(&self, byte_pos: usize) -> bool {
1366 Self::binary_search_ranges(&self.jsx_expression_ranges, byte_pos)
1367 }
1368
1369 #[inline]
1371 pub fn is_in_mdx_comment(&self, byte_pos: usize) -> bool {
1372 Self::binary_search_ranges(&self.mdx_comment_ranges, byte_pos)
1373 }
1374
1375 #[inline]
1378 pub fn is_in_citation(&self, byte_pos: usize) -> bool {
1379 let idx = self.citation_ranges.partition_point(|r| r.start <= byte_pos);
1380 idx > 0 && byte_pos < self.citation_ranges[idx - 1].end
1381 }
1382
1383 #[inline]
1385 pub fn citation_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1386 &self.citation_ranges
1387 }
1388
1389 #[inline]
1392 pub fn is_in_div_block(&self, byte_pos: usize) -> bool {
1393 let idx = self.pandoc_div_ranges.partition_point(|r| r.start <= byte_pos);
1394 idx > 0 && byte_pos < self.pandoc_div_ranges[idx - 1].end
1395 }
1396
1397 #[inline]
1400 pub fn is_in_inline_footnote(&self, byte_pos: usize) -> bool {
1401 let idx = self.inline_footnote_ranges.partition_point(|r| r.start <= byte_pos);
1402 idx > 0 && byte_pos < self.inline_footnote_ranges[idx - 1].end
1403 }
1404
1405 #[inline]
1408 pub fn is_in_example_list_marker(&self, byte_pos: usize) -> bool {
1409 let idx = self.example_list_marker_ranges.partition_point(|r| r.start <= byte_pos);
1410 idx > 0 && byte_pos < self.example_list_marker_ranges[idx - 1].end
1411 }
1412
1413 #[inline]
1416 pub fn is_in_example_reference(&self, byte_pos: usize) -> bool {
1417 let idx = self.example_reference_ranges.partition_point(|r| r.start <= byte_pos);
1418 idx > 0 && byte_pos < self.example_reference_ranges[idx - 1].end
1419 }
1420
1421 #[inline]
1424 pub fn is_in_subscript_or_superscript(&self, byte_pos: usize) -> bool {
1425 let idx = self.sub_super_ranges.partition_point(|r| r.start <= byte_pos);
1426 idx > 0 && byte_pos < self.sub_super_ranges[idx - 1].end
1427 }
1428
1429 #[inline]
1433 pub fn is_in_inline_code_attr(&self, byte_pos: usize) -> bool {
1434 let idx = self.inline_code_attr_ranges.partition_point(|r| r.start <= byte_pos);
1435 idx > 0 && byte_pos < self.inline_code_attr_ranges[idx - 1].end
1436 }
1437
1438 #[inline]
1441 pub fn is_in_bracketed_span(&self, byte_pos: usize) -> bool {
1442 let idx = self.bracketed_span_ranges.partition_point(|r| r.start <= byte_pos);
1443 idx > 0 && byte_pos < self.bracketed_span_ranges[idx - 1].end
1444 }
1445
1446 #[inline]
1449 pub fn is_in_line_block(&self, byte_pos: usize) -> bool {
1450 let idx = self.line_block_ranges.partition_point(|r| r.start <= byte_pos);
1451 idx > 0 && byte_pos < self.line_block_ranges[idx - 1].end
1452 }
1453
1454 #[inline]
1458 pub fn is_in_pipe_table_caption(&self, byte_pos: usize) -> bool {
1459 let idx = self.pipe_table_caption_ranges.partition_point(|r| r.start <= byte_pos);
1460 idx > 0 && byte_pos < self.pipe_table_caption_ranges[idx - 1].end
1461 }
1462
1463 #[inline]
1466 pub fn is_in_pandoc_metadata(&self, byte_pos: usize) -> bool {
1467 let idx = self.pandoc_metadata_ranges.partition_point(|r| r.start <= byte_pos);
1468 idx > 0 && byte_pos < self.pandoc_metadata_ranges[idx - 1].end
1469 }
1470
1471 #[inline]
1474 pub fn is_in_grid_table(&self, byte_pos: usize) -> bool {
1475 let idx = self.grid_table_ranges.partition_point(|r| r.start <= byte_pos);
1476 idx > 0 && byte_pos < self.grid_table_ranges[idx - 1].end
1477 }
1478
1479 #[inline]
1482 pub fn is_in_multi_line_table(&self, byte_pos: usize) -> bool {
1483 let idx = self.multi_line_table_ranges.partition_point(|r| r.start <= byte_pos);
1484 idx > 0 && byte_pos < self.multi_line_table_ranges[idx - 1].end
1485 }
1486
1487 pub fn matches_implicit_header_reference(&self, link_text: &str) -> bool {
1492 let slug = crate::utils::pandoc::pandoc_header_slug(link_text);
1493 self.pandoc_header_slugs.contains(&slug)
1494 }
1495
1496 #[inline]
1502 pub fn has_pandoc_slug(&self, slug: &str) -> bool {
1503 self.pandoc_header_slugs.contains(slug)
1504 }
1505
1506 #[inline]
1508 pub fn is_in_shortcode(&self, byte_pos: usize) -> bool {
1509 Self::binary_search_ranges(&self.shortcode_ranges, byte_pos)
1510 }
1511
1512 #[inline]
1514 pub fn shortcode_ranges(&self) -> &[(usize, usize)] {
1515 &self.shortcode_ranges
1516 }
1517
1518 pub fn is_in_link_title(&self, byte_pos: usize) -> bool {
1520 Self::binary_search_ranges(&self.link_title_ranges, byte_pos)
1521 }
1522
1523 pub fn has_char(&self, ch: char) -> bool {
1525 match ch {
1526 '#' => self.char_frequency.hash_count > 0,
1527 '*' => self.char_frequency.asterisk_count > 0,
1528 '_' => self.char_frequency.underscore_count > 0,
1529 '-' => self.char_frequency.hyphen_count > 0,
1530 '+' => self.char_frequency.plus_count > 0,
1531 '>' => self.char_frequency.gt_count > 0,
1532 '|' => self.char_frequency.pipe_count > 0,
1533 '[' => self.char_frequency.bracket_count > 0,
1534 '`' => self.char_frequency.backtick_count > 0,
1535 '<' => self.char_frequency.lt_count > 0,
1536 '!' => self.char_frequency.exclamation_count > 0,
1537 '\n' => self.char_frequency.newline_count > 0,
1538 _ => self.content.contains(ch), }
1540 }
1541
1542 pub fn char_count(&self, ch: char) -> usize {
1544 match ch {
1545 '#' => self.char_frequency.hash_count,
1546 '*' => self.char_frequency.asterisk_count,
1547 '_' => self.char_frequency.underscore_count,
1548 '-' => self.char_frequency.hyphen_count,
1549 '+' => self.char_frequency.plus_count,
1550 '>' => self.char_frequency.gt_count,
1551 '|' => self.char_frequency.pipe_count,
1552 '[' => self.char_frequency.bracket_count,
1553 '`' => self.char_frequency.backtick_count,
1554 '<' => self.char_frequency.lt_count,
1555 '!' => self.char_frequency.exclamation_count,
1556 '\n' => self.char_frequency.newline_count,
1557 _ => self.content.matches(ch).count(), }
1559 }
1560
1561 pub fn likely_has_headings(&self) -> bool {
1563 self.char_frequency.hash_count > 0 || self.char_frequency.hyphen_count > 2 || self.content.contains('=') }
1565
1566 pub fn likely_has_lists(&self) -> bool {
1568 self.char_frequency.asterisk_count > 0
1569 || self.char_frequency.hyphen_count > 0
1570 || self.char_frequency.plus_count > 0
1571 }
1572
1573 pub fn likely_has_emphasis(&self) -> bool {
1575 self.char_frequency.asterisk_count > 1 || self.char_frequency.underscore_count > 1
1576 }
1577
1578 pub fn likely_has_tables(&self) -> bool {
1580 self.char_frequency.pipe_count > 2
1581 }
1582
1583 pub fn likely_has_blockquotes(&self) -> bool {
1585 self.char_frequency.gt_count > 0
1586 }
1587
1588 pub fn likely_has_code(&self) -> bool {
1590 self.char_frequency.backtick_count > 0
1591 }
1592
1593 pub fn likely_has_links_or_images(&self) -> bool {
1595 self.char_frequency.bracket_count > 0 || self.char_frequency.exclamation_count > 0
1596 }
1597
1598 pub fn likely_has_html(&self) -> bool {
1600 self.char_frequency.lt_count > 0
1601 }
1602
1603 pub fn blockquote_prefix_for_blank_line(&self, line_idx: usize) -> String {
1608 if let Some(line_info) = self.lines.get(line_idx)
1609 && let Some(ref bq) = line_info.blockquote
1610 {
1611 bq.prefix.trim_end().to_string()
1612 } else {
1613 String::new()
1614 }
1615 }
1616
1617 #[inline]
1628 fn find_line_for_offset(lines: &[LineInfo], content: &str, byte_offset: usize) -> (usize, usize, usize) {
1629 let idx = match lines.binary_search_by(|line| {
1631 if byte_offset < line.byte_offset {
1632 std::cmp::Ordering::Greater
1633 } else if byte_offset > line.byte_offset + line.byte_len {
1634 std::cmp::Ordering::Less
1635 } else {
1636 std::cmp::Ordering::Equal
1637 }
1638 }) {
1639 Ok(idx) => idx,
1640 Err(idx) => idx.saturating_sub(1),
1641 };
1642
1643 let line = &lines[idx];
1644 let line_num = idx + 1;
1645 let byte_col = byte_offset.saturating_sub(line.byte_offset);
1646 let col = byte_to_char_count(line.content(content), byte_col) - 1;
1649
1650 (idx, line_num, col)
1651 }
1652
1653 #[inline]
1655 fn is_offset_in_code_span(code_spans: &[CodeSpan], offset: usize) -> bool {
1656 let idx = code_spans.partition_point(|span| span.byte_offset <= offset);
1658
1659 if idx > 0 {
1661 let span = &code_spans[idx - 1];
1662 if offset >= span.byte_offset && offset < span.byte_end {
1663 return true;
1664 }
1665 }
1666
1667 false
1668 }
1669
1670 #[must_use]
1690 pub fn valid_headings(&self) -> ValidHeadingsIter<'_> {
1691 ValidHeadingsIter::new(&self.lines)
1692 }
1693
1694 #[must_use]
1698 pub fn has_valid_headings(&self) -> bool {
1699 self.lines
1700 .iter()
1701 .any(|line| line.heading.as_ref().is_some_and(|h| h.is_valid))
1702 }
1703}
1704
1705fn detect_footnote_definitions(content: &str, lines: &mut [types::LineInfo], line_offsets: &[usize]) {
1714 use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
1715
1716 let options = crate::utils::rumdl_parser_options();
1717 let parser = Parser::new_ext(content, options).into_offset_iter();
1718
1719 let mut footnote_ranges: Vec<(usize, usize)> = Vec::new();
1721 let mut fenced_code_ranges: Vec<(usize, usize)> = Vec::new();
1722 let mut in_footnote = false;
1723
1724 for (event, range) in parser {
1725 match event {
1726 Event::Start(Tag::FootnoteDefinition(_)) => {
1727 in_footnote = true;
1728 footnote_ranges.push((range.start, range.end));
1729 }
1730 Event::End(TagEnd::FootnoteDefinition) => {
1731 in_footnote = false;
1732 }
1733 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) if in_footnote => {
1734 fenced_code_ranges.push((range.start, range.end));
1735 }
1736 _ => {}
1737 }
1738 }
1739
1740 let byte_to_line = |byte_offset: usize| -> usize {
1741 line_offsets
1742 .partition_point(|&offset| offset <= byte_offset)
1743 .saturating_sub(1)
1744 };
1745
1746 for &(start, end) in &footnote_ranges {
1748 let start_line = byte_to_line(start);
1749 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
1750
1751 for line in &mut lines[start_line..end_line] {
1752 line.in_footnote_definition = true;
1753 line.in_code_block = false;
1754 }
1755 }
1756
1757 for &(start, end) in &fenced_code_ranges {
1759 let start_line = byte_to_line(start);
1760 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
1761
1762 for line in &mut lines[start_line..end_line] {
1763 line.in_code_block = true;
1764 }
1765 }
1766}