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::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) quarto_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 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>>>, pub list_blocks: Vec<ListBlock>, pub char_frequency: CharFrequency, html_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>, shortcode_ranges: Vec<(usize, usize)>, inline_config: InlineConfig, obsidian_comment_ranges: Vec<(usize, usize)>, }
89
90impl<'a> LintContext<'a> {
91 pub fn new(content: &'a str, flavor: MarkdownFlavor, source_file: Option<PathBuf>) -> Self {
92 #[cfg(not(target_arch = "wasm32"))]
93 let profile = std::env::var("RUMDL_PROFILE_QUADRATIC").is_ok();
94 #[cfg(target_arch = "wasm32")]
95 let profile = false;
96
97 let line_offsets = profile_section!("Line offsets", profile, {
98 let mut offsets = vec![0];
99 for (i, c) in content.char_indices() {
100 if c == '\n' {
101 offsets.push(i + 1);
102 }
103 }
104 offsets
105 });
106
107 let content_lines: Vec<&str> = content.lines().collect();
109
110 let front_matter_end = FrontMatterUtils::get_front_matter_end_line(content);
112
113 let (mut code_blocks, code_span_ranges) = profile_section!(
115 "Code blocks",
116 profile,
117 CodeBlockUtils::detect_code_blocks_and_spans(content)
118 );
119
120 let html_comment_ranges = profile_section!(
122 "HTML comment ranges",
123 profile,
124 crate::utils::skip_context::compute_html_comment_ranges(content)
125 );
126
127 let autodoc_ranges = profile_section!(
131 "Autodoc block ranges",
132 profile,
133 crate::utils::mkdocstrings_refs::detect_autodoc_block_ranges(content)
134 );
135
136 let quarto_div_ranges = profile_section!("Quarto div ranges", profile, {
138 if flavor == MarkdownFlavor::Quarto {
139 crate::utils::quarto_divs::detect_div_block_ranges(content)
140 } else {
141 Vec::new()
142 }
143 });
144
145 let pymdown_block_ranges = profile_section!("PyMdown block ranges", profile, {
147 if flavor == MarkdownFlavor::MkDocs {
148 crate::utils::pymdown_blocks::detect_block_ranges(content)
149 } else {
150 Vec::new()
151 }
152 });
153
154 let skip_ranges = SkipByteRanges {
157 html_comment_ranges: &html_comment_ranges,
158 autodoc_ranges: &autodoc_ranges,
159 quarto_div_ranges: &quarto_div_ranges,
160 pymdown_block_ranges: &pymdown_block_ranges,
161 };
162 let (mut lines, emphasis_spans) = profile_section!(
163 "Basic line info",
164 profile,
165 line_computation::compute_basic_line_info(
166 content,
167 &content_lines,
168 &line_offsets,
169 &code_blocks,
170 flavor,
171 &skip_ranges,
172 front_matter_end,
173 )
174 );
175
176 profile_section!(
178 "HTML blocks",
179 profile,
180 heading_detection::detect_html_blocks(content, &mut lines)
181 );
182
183 profile_section!(
185 "ESM blocks",
186 profile,
187 flavor_detection::detect_esm_blocks(content, &mut lines, flavor)
188 );
189
190 let (jsx_expression_ranges, mdx_comment_ranges) = profile_section!(
192 "JSX/MDX detection",
193 profile,
194 flavor_detection::detect_jsx_and_mdx_comments(content, &mut lines, flavor, &code_blocks)
195 );
196
197 profile_section!(
199 "MkDocs constructs",
200 profile,
201 flavor_detection::detect_mkdocs_line_info(&content_lines, &mut lines, flavor)
202 );
203
204 if flavor == MarkdownFlavor::MkDocs {
211 let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
212 for &(start, end) in &code_blocks {
213 let start_line = line_offsets
214 .partition_point(|&offset| offset <= start)
215 .saturating_sub(1);
216 let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
217
218 let mut sub_start: Option<usize> = None;
220 for (i, &offset) in line_offsets[start_line..end_line]
221 .iter()
222 .enumerate()
223 .map(|(j, o)| (j + start_line, o))
224 {
225 let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
226 if is_real_code && sub_start.is_none() {
227 let byte_start = if i == start_line { start } else { offset };
228 sub_start = Some(byte_start);
229 } else if !is_real_code && sub_start.is_some() {
230 new_code_blocks.push((sub_start.unwrap(), offset));
231 sub_start = None;
232 }
233 }
234 if let Some(s) = sub_start {
235 new_code_blocks.push((s, end));
236 }
237 }
238 code_blocks = new_code_blocks;
239 }
240
241 profile_section!(
243 "Kramdown constructs",
244 profile,
245 flavor_detection::detect_kramdown_line_info(content, &mut lines, flavor)
246 );
247
248 for line in &mut lines {
253 if line.in_kramdown_extension_block {
254 line.list_item = None;
255 line.is_horizontal_rule = false;
256 line.blockquote = None;
257 line.is_kramdown_block_ial = false;
258 }
259 }
260
261 let obsidian_comment_ranges = profile_section!(
263 "Obsidian comments",
264 profile,
265 flavor_detection::detect_obsidian_comments(content, &mut lines, flavor, &code_span_ranges)
266 );
267
268 let link_byte_ranges = profile_section!(
270 "Link byte ranges",
271 profile,
272 link_parser::collect_link_byte_ranges(content)
273 );
274
275 profile_section!(
277 "Headings & blockquotes",
278 profile,
279 heading_detection::detect_headings_and_blockquotes(
280 &content_lines,
281 &mut lines,
282 flavor,
283 &html_comment_ranges,
284 &link_byte_ranges,
285 front_matter_end,
286 )
287 );
288
289 for line in &mut lines {
291 if line.in_kramdown_extension_block {
292 line.heading = None;
293 }
294 }
295
296 let code_spans = profile_section!(
298 "Code spans",
299 profile,
300 element_parsers::build_code_spans_from_ranges(content, &lines, &code_span_ranges)
301 );
302
303 for span in &code_spans {
306 if span.end_line > span.line {
307 for line_num in (span.line + 1)..=span.end_line {
309 if let Some(line_info) = lines.get_mut(line_num - 1) {
310 line_info.in_code_span_continuation = true;
311 }
312 }
313 }
314 }
315
316 let (links, broken_links, footnote_refs) = profile_section!(
318 "Links",
319 profile,
320 link_parser::parse_links(content, &lines, &code_blocks, &code_spans, flavor, &html_comment_ranges)
321 );
322
323 let images = profile_section!(
324 "Images",
325 profile,
326 link_parser::parse_images(content, &lines, &code_blocks, &code_spans, &html_comment_ranges)
327 );
328
329 let reference_defs = profile_section!(
330 "Reference defs",
331 profile,
332 link_parser::parse_reference_defs(content, &lines)
333 );
334
335 let list_blocks = profile_section!("List blocks", profile, list_blocks::parse_list_blocks(content, &lines));
336
337 let char_frequency = profile_section!(
339 "Char frequency",
340 profile,
341 line_computation::compute_char_frequency(content)
342 );
343
344 let table_blocks = profile_section!(
346 "Table blocks",
347 profile,
348 crate::utils::table_utils::TableUtils::find_table_blocks_with_code_info(
349 content,
350 &code_blocks,
351 &code_spans,
352 &html_comment_ranges,
353 )
354 );
355
356 let links = links
359 .into_iter()
360 .filter(|link| !lines.get(link.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
361 .collect::<Vec<_>>();
362 let images = images
363 .into_iter()
364 .filter(|img| !lines.get(img.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
365 .collect::<Vec<_>>();
366 let broken_links = broken_links
367 .into_iter()
368 .filter(|bl| {
369 let line_idx = line_offsets
371 .partition_point(|&offset| offset <= bl.span.start)
372 .saturating_sub(1);
373 !lines.get(line_idx).is_some_and(|l| l.in_kramdown_extension_block)
374 })
375 .collect::<Vec<_>>();
376 let footnote_refs = footnote_refs
377 .into_iter()
378 .filter(|fr| !lines.get(fr.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
379 .collect::<Vec<_>>();
380 let reference_defs = reference_defs
381 .into_iter()
382 .filter(|def| !lines.get(def.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
383 .collect::<Vec<_>>();
384 let list_blocks = list_blocks
385 .into_iter()
386 .filter(|block| {
387 !lines
388 .get(block.start_line - 1)
389 .is_some_and(|l| l.in_kramdown_extension_block)
390 })
391 .collect::<Vec<_>>();
392 let table_blocks = table_blocks
393 .into_iter()
394 .filter(|block| {
395 !lines
397 .get(block.start_line)
398 .is_some_and(|l| l.in_kramdown_extension_block)
399 })
400 .collect::<Vec<_>>();
401 let emphasis_spans = emphasis_spans
402 .into_iter()
403 .filter(|span| !lines.get(span.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
404 .collect::<Vec<_>>();
405
406 let reference_defs_map: HashMap<String, usize> = reference_defs
408 .iter()
409 .enumerate()
410 .map(|(idx, def)| (def.id.to_lowercase(), idx))
411 .collect();
412
413 let line_index = profile_section!(
415 "Line index",
416 profile,
417 crate::utils::range_utils::LineIndex::with_line_starts_and_code_blocks(
418 content,
419 line_offsets.clone(),
420 &code_blocks,
421 )
422 );
423
424 let jinja_ranges = profile_section!(
426 "Jinja ranges",
427 profile,
428 crate::utils::jinja_utils::find_jinja_ranges(content)
429 );
430
431 let citation_ranges = profile_section!("Citation ranges", profile, {
433 if flavor == MarkdownFlavor::Quarto {
434 crate::utils::quarto_divs::find_citation_ranges(content)
435 } else {
436 Vec::new()
437 }
438 });
439
440 let shortcode_ranges = profile_section!("Shortcode ranges", profile, {
442 use crate::utils::regex_cache::HUGO_SHORTCODE_REGEX;
443 let mut ranges = Vec::new();
444 for mat in HUGO_SHORTCODE_REGEX.find_iter(content).flatten() {
445 ranges.push((mat.start(), mat.end()));
446 }
447 ranges
448 });
449
450 let inline_config = InlineConfig::from_content_with_code_blocks(content, &code_blocks);
451
452 Self {
453 content,
454 content_lines,
455 line_offsets,
456 code_blocks,
457 lines,
458 links,
459 images,
460 broken_links,
461 footnote_refs,
462 reference_defs,
463 reference_defs_map,
464 code_spans_cache: OnceLock::from(Arc::new(code_spans)),
465 math_spans_cache: OnceLock::new(), list_blocks,
467 char_frequency,
468 html_tags_cache: OnceLock::new(),
469 emphasis_spans_cache: OnceLock::from(Arc::new(emphasis_spans)),
470 table_rows_cache: OnceLock::new(),
471 bare_urls_cache: OnceLock::new(),
472 has_mixed_list_nesting_cache: OnceLock::new(),
473 html_comment_ranges,
474 table_blocks,
475 line_index,
476 jinja_ranges,
477 flavor,
478 source_file,
479 jsx_expression_ranges,
480 mdx_comment_ranges,
481 citation_ranges,
482 shortcode_ranges,
483 inline_config,
484 obsidian_comment_ranges,
485 }
486 }
487
488 pub fn inline_config(&self) -> &InlineConfig {
490 &self.inline_config
491 }
492
493 pub fn raw_lines(&self) -> &[&'a str] {
497 &self.content_lines
498 }
499
500 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
505 self.inline_config.is_rule_disabled(rule_name, line_number)
506 }
507
508 pub fn code_spans(&self) -> Arc<Vec<CodeSpan>> {
510 Arc::clone(
511 self.code_spans_cache
512 .get_or_init(|| Arc::new(element_parsers::parse_code_spans(self.content, &self.lines))),
513 )
514 }
515
516 pub fn math_spans(&self) -> Arc<Vec<MathSpan>> {
518 Arc::clone(
519 self.math_spans_cache
520 .get_or_init(|| Arc::new(element_parsers::parse_math_spans(self.content, &self.lines))),
521 )
522 }
523
524 pub fn is_in_math_span(&self, byte_pos: usize) -> bool {
526 let math_spans = self.math_spans();
527 math_spans
528 .iter()
529 .any(|span| byte_pos >= span.byte_offset && byte_pos < span.byte_end)
530 }
531
532 pub fn html_comment_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
534 &self.html_comment_ranges
535 }
536
537 pub fn obsidian_comment_ranges(&self) -> &[(usize, usize)] {
540 &self.obsidian_comment_ranges
541 }
542
543 pub fn is_in_obsidian_comment(&self, byte_pos: usize) -> bool {
547 self.obsidian_comment_ranges
548 .iter()
549 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
550 }
551
552 pub fn is_position_in_obsidian_comment(&self, line_num: usize, col: usize) -> bool {
557 if self.obsidian_comment_ranges.is_empty() {
558 return false;
559 }
560
561 let byte_pos = self.line_index.line_col_to_byte_range(line_num, col).start;
563 self.is_in_obsidian_comment(byte_pos)
564 }
565
566 pub fn html_tags(&self) -> Arc<Vec<HtmlTag>> {
568 Arc::clone(self.html_tags_cache.get_or_init(|| {
569 let tags = element_parsers::parse_html_tags(self.content, &self.lines, &self.code_blocks, self.flavor);
570 Arc::new(
572 tags.into_iter()
573 .filter(|tag| {
574 !self
575 .lines
576 .get(tag.line - 1)
577 .is_some_and(|l| l.in_kramdown_extension_block)
578 })
579 .collect(),
580 )
581 }))
582 }
583
584 pub fn emphasis_spans(&self) -> Arc<Vec<EmphasisSpan>> {
586 Arc::clone(
587 self.emphasis_spans_cache
588 .get()
589 .expect("emphasis_spans_cache initialized during construction"),
590 )
591 }
592
593 pub fn table_rows(&self) -> Arc<Vec<TableRow>> {
595 Arc::clone(
596 self.table_rows_cache
597 .get_or_init(|| Arc::new(element_parsers::parse_table_rows(self.content, &self.lines))),
598 )
599 }
600
601 pub fn bare_urls(&self) -> Arc<Vec<BareUrl>> {
603 Arc::clone(self.bare_urls_cache.get_or_init(|| {
604 Arc::new(element_parsers::parse_bare_urls(
605 self.content,
606 &self.lines,
607 &self.code_blocks,
608 ))
609 }))
610 }
611
612 pub fn has_mixed_list_nesting(&self) -> bool {
616 *self
617 .has_mixed_list_nesting_cache
618 .get_or_init(|| self.compute_mixed_list_nesting())
619 }
620
621 fn compute_mixed_list_nesting(&self) -> bool {
623 let mut stack: Vec<(usize, bool)> = Vec::new();
628 let mut last_was_blank = false;
629
630 for line_info in &self.lines {
631 if line_info.in_code_block
633 || line_info.in_front_matter
634 || line_info.in_mkdocstrings
635 || line_info.in_html_comment
636 || line_info.in_esm_block
637 {
638 continue;
639 }
640
641 if line_info.is_blank {
643 last_was_blank = true;
644 continue;
645 }
646
647 if let Some(list_item) = &line_info.list_item {
648 let current_pos = if list_item.marker_column == 1 {
650 0
651 } else {
652 list_item.marker_column
653 };
654
655 if last_was_blank && current_pos == 0 {
657 stack.clear();
658 }
659 last_was_blank = false;
660
661 while let Some(&(pos, _)) = stack.last() {
663 if pos >= current_pos {
664 stack.pop();
665 } else {
666 break;
667 }
668 }
669
670 if let Some(&(_, parent_is_ordered)) = stack.last()
672 && parent_is_ordered != list_item.is_ordered
673 {
674 return true; }
676
677 stack.push((current_pos, list_item.is_ordered));
678 } else {
679 last_was_blank = false;
681 }
682 }
683
684 false
685 }
686
687 pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
689 match self.line_offsets.binary_search(&offset) {
690 Ok(line) => (line + 1, 1),
691 Err(line) => {
692 let line_start = self.line_offsets.get(line.wrapping_sub(1)).copied().unwrap_or(0);
693 (line, offset - line_start + 1)
694 }
695 }
696 }
697
698 pub fn is_in_code_block_or_span(&self, pos: usize) -> bool {
700 if CodeBlockUtils::is_in_code_block_or_span(&self.code_blocks, pos) {
702 return true;
703 }
704
705 self.code_spans()
707 .iter()
708 .any(|span| pos >= span.byte_offset && pos < span.byte_end)
709 }
710
711 pub fn line_info(&self, line_num: usize) -> Option<&LineInfo> {
713 if line_num > 0 {
714 self.lines.get(line_num - 1)
715 } else {
716 None
717 }
718 }
719
720 pub fn line_to_byte_offset(&self, line_num: usize) -> Option<usize> {
722 self.line_info(line_num).map(|info| info.byte_offset)
723 }
724
725 pub fn get_reference_url(&self, ref_id: &str) -> Option<&str> {
727 let normalized_id = ref_id.to_lowercase();
728 self.reference_defs_map
729 .get(&normalized_id)
730 .map(|&idx| self.reference_defs[idx].url.as_str())
731 }
732
733 pub fn get_reference_def(&self, ref_id: &str) -> Option<&ReferenceDef> {
735 let normalized_id = ref_id.to_lowercase();
736 self.reference_defs_map
737 .get(&normalized_id)
738 .map(|&idx| &self.reference_defs[idx])
739 }
740
741 pub fn has_reference_def(&self, ref_id: &str) -> bool {
743 let normalized_id = ref_id.to_lowercase();
744 self.reference_defs_map.contains_key(&normalized_id)
745 }
746
747 pub fn is_in_list_block(&self, line_num: usize) -> bool {
749 self.list_blocks
750 .iter()
751 .any(|block| line_num >= block.start_line && line_num <= block.end_line)
752 }
753
754 pub fn list_block_for_line(&self, line_num: usize) -> Option<&ListBlock> {
756 self.list_blocks
757 .iter()
758 .find(|block| line_num >= block.start_line && line_num <= block.end_line)
759 }
760
761 pub fn is_in_code_block(&self, line_num: usize) -> bool {
765 if line_num == 0 || line_num > self.lines.len() {
766 return false;
767 }
768 self.lines[line_num - 1].in_code_block
769 }
770
771 pub fn is_in_front_matter(&self, line_num: usize) -> bool {
773 if line_num == 0 || line_num > self.lines.len() {
774 return false;
775 }
776 self.lines[line_num - 1].in_front_matter
777 }
778
779 pub fn is_in_html_block(&self, line_num: usize) -> bool {
781 if line_num == 0 || line_num > self.lines.len() {
782 return false;
783 }
784 self.lines[line_num - 1].in_html_block
785 }
786
787 pub fn is_in_code_span(&self, line_num: usize, col: usize) -> bool {
789 if line_num == 0 || line_num > self.lines.len() {
790 return false;
791 }
792
793 let col_0indexed = if col > 0 { col - 1 } else { 0 };
797 let code_spans = self.code_spans();
798 code_spans.iter().any(|span| {
799 if line_num < span.line || line_num > span.end_line {
801 return false;
802 }
803
804 if span.line == span.end_line {
805 col_0indexed >= span.start_col && col_0indexed < span.end_col
807 } else if line_num == span.line {
808 col_0indexed >= span.start_col
810 } else if line_num == span.end_line {
811 col_0indexed < span.end_col
813 } else {
814 true
816 }
817 })
818 }
819
820 #[inline]
822 pub fn is_byte_offset_in_code_span(&self, byte_offset: usize) -> bool {
823 let code_spans = self.code_spans();
824 code_spans
825 .iter()
826 .any(|span| byte_offset >= span.byte_offset && byte_offset < span.byte_end)
827 }
828
829 #[inline]
831 pub fn is_in_reference_def(&self, byte_pos: usize) -> bool {
832 self.reference_defs
833 .iter()
834 .any(|ref_def| byte_pos >= ref_def.byte_offset && byte_pos < ref_def.byte_end)
835 }
836
837 #[inline]
839 pub fn is_in_html_comment(&self, byte_pos: usize) -> bool {
840 self.html_comment_ranges
841 .iter()
842 .any(|range| byte_pos >= range.start && byte_pos < range.end)
843 }
844
845 #[inline]
848 pub fn is_in_html_tag(&self, byte_pos: usize) -> bool {
849 self.html_tags()
850 .iter()
851 .any(|tag| byte_pos >= tag.byte_offset && byte_pos < tag.byte_end)
852 }
853
854 pub fn is_in_jinja_range(&self, byte_pos: usize) -> bool {
856 self.jinja_ranges
857 .iter()
858 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
859 }
860
861 #[inline]
863 pub fn is_in_jsx_expression(&self, byte_pos: usize) -> bool {
864 self.jsx_expression_ranges
865 .iter()
866 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
867 }
868
869 #[inline]
871 pub fn is_in_mdx_comment(&self, byte_pos: usize) -> bool {
872 self.mdx_comment_ranges
873 .iter()
874 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
875 }
876
877 pub fn jsx_expression_ranges(&self) -> &[(usize, usize)] {
879 &self.jsx_expression_ranges
880 }
881
882 pub fn mdx_comment_ranges(&self) -> &[(usize, usize)] {
884 &self.mdx_comment_ranges
885 }
886
887 #[inline]
890 pub fn is_in_citation(&self, byte_pos: usize) -> bool {
891 self.citation_ranges
892 .iter()
893 .any(|range| byte_pos >= range.start && byte_pos < range.end)
894 }
895
896 pub fn citation_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
898 &self.citation_ranges
899 }
900
901 #[inline]
903 pub fn is_in_shortcode(&self, byte_pos: usize) -> bool {
904 self.shortcode_ranges
905 .iter()
906 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
907 }
908
909 pub fn shortcode_ranges(&self) -> &[(usize, usize)] {
911 &self.shortcode_ranges
912 }
913
914 pub fn is_in_link_title(&self, byte_pos: usize) -> bool {
916 self.reference_defs.iter().any(|def| {
917 if let (Some(start), Some(end)) = (def.title_byte_start, def.title_byte_end) {
918 byte_pos >= start && byte_pos < end
919 } else {
920 false
921 }
922 })
923 }
924
925 pub fn has_char(&self, ch: char) -> bool {
927 match ch {
928 '#' => self.char_frequency.hash_count > 0,
929 '*' => self.char_frequency.asterisk_count > 0,
930 '_' => self.char_frequency.underscore_count > 0,
931 '-' => self.char_frequency.hyphen_count > 0,
932 '+' => self.char_frequency.plus_count > 0,
933 '>' => self.char_frequency.gt_count > 0,
934 '|' => self.char_frequency.pipe_count > 0,
935 '[' => self.char_frequency.bracket_count > 0,
936 '`' => self.char_frequency.backtick_count > 0,
937 '<' => self.char_frequency.lt_count > 0,
938 '!' => self.char_frequency.exclamation_count > 0,
939 '\n' => self.char_frequency.newline_count > 0,
940 _ => self.content.contains(ch), }
942 }
943
944 pub fn char_count(&self, ch: char) -> usize {
946 match ch {
947 '#' => self.char_frequency.hash_count,
948 '*' => self.char_frequency.asterisk_count,
949 '_' => self.char_frequency.underscore_count,
950 '-' => self.char_frequency.hyphen_count,
951 '+' => self.char_frequency.plus_count,
952 '>' => self.char_frequency.gt_count,
953 '|' => self.char_frequency.pipe_count,
954 '[' => self.char_frequency.bracket_count,
955 '`' => self.char_frequency.backtick_count,
956 '<' => self.char_frequency.lt_count,
957 '!' => self.char_frequency.exclamation_count,
958 '\n' => self.char_frequency.newline_count,
959 _ => self.content.matches(ch).count(), }
961 }
962
963 pub fn likely_has_headings(&self) -> bool {
965 self.char_frequency.hash_count > 0 || self.char_frequency.hyphen_count > 2 }
967
968 pub fn likely_has_lists(&self) -> bool {
970 self.char_frequency.asterisk_count > 0
971 || self.char_frequency.hyphen_count > 0
972 || self.char_frequency.plus_count > 0
973 }
974
975 pub fn likely_has_emphasis(&self) -> bool {
977 self.char_frequency.asterisk_count > 1 || self.char_frequency.underscore_count > 1
978 }
979
980 pub fn likely_has_tables(&self) -> bool {
982 self.char_frequency.pipe_count > 2
983 }
984
985 pub fn likely_has_blockquotes(&self) -> bool {
987 self.char_frequency.gt_count > 0
988 }
989
990 pub fn likely_has_code(&self) -> bool {
992 self.char_frequency.backtick_count > 0
993 }
994
995 pub fn likely_has_links_or_images(&self) -> bool {
997 self.char_frequency.bracket_count > 0 || self.char_frequency.exclamation_count > 0
998 }
999
1000 pub fn likely_has_html(&self) -> bool {
1002 self.char_frequency.lt_count > 0
1003 }
1004
1005 pub fn blockquote_prefix_for_blank_line(&self, line_idx: usize) -> String {
1010 if let Some(line_info) = self.lines.get(line_idx)
1011 && let Some(ref bq) = line_info.blockquote
1012 {
1013 bq.prefix.trim_end().to_string()
1014 } else {
1015 String::new()
1016 }
1017 }
1018
1019 pub fn html_tags_on_line(&self, line_num: usize) -> Vec<HtmlTag> {
1021 self.html_tags()
1022 .iter()
1023 .filter(|tag| tag.line == line_num)
1024 .cloned()
1025 .collect()
1026 }
1027
1028 pub fn emphasis_spans_on_line(&self, line_num: usize) -> Vec<EmphasisSpan> {
1030 self.emphasis_spans()
1031 .iter()
1032 .filter(|span| span.line == line_num)
1033 .cloned()
1034 .collect()
1035 }
1036
1037 pub fn table_rows_on_line(&self, line_num: usize) -> Vec<TableRow> {
1039 self.table_rows()
1040 .iter()
1041 .filter(|row| row.line == line_num)
1042 .cloned()
1043 .collect()
1044 }
1045
1046 pub fn bare_urls_on_line(&self, line_num: usize) -> Vec<BareUrl> {
1048 self.bare_urls()
1049 .iter()
1050 .filter(|url| url.line == line_num)
1051 .cloned()
1052 .collect()
1053 }
1054
1055 #[inline]
1061 fn find_line_for_offset(lines: &[LineInfo], byte_offset: usize) -> (usize, usize, usize) {
1062 let idx = match lines.binary_search_by(|line| {
1064 if byte_offset < line.byte_offset {
1065 std::cmp::Ordering::Greater
1066 } else if byte_offset > line.byte_offset + line.byte_len {
1067 std::cmp::Ordering::Less
1068 } else {
1069 std::cmp::Ordering::Equal
1070 }
1071 }) {
1072 Ok(idx) => idx,
1073 Err(idx) => idx.saturating_sub(1),
1074 };
1075
1076 let line = &lines[idx];
1077 let line_num = idx + 1;
1078 let col = byte_offset.saturating_sub(line.byte_offset);
1079
1080 (idx, line_num, col)
1081 }
1082
1083 #[inline]
1085 fn is_offset_in_code_span(code_spans: &[CodeSpan], offset: usize) -> bool {
1086 let idx = code_spans.partition_point(|span| span.byte_offset <= offset);
1088
1089 if idx > 0 {
1091 let span = &code_spans[idx - 1];
1092 if offset >= span.byte_offset && offset < span.byte_end {
1093 return true;
1094 }
1095 }
1096
1097 false
1098 }
1099
1100 #[must_use]
1120 pub fn valid_headings(&self) -> ValidHeadingsIter<'_> {
1121 ValidHeadingsIter::new(&self.lines)
1122 }
1123
1124 #[must_use]
1128 pub fn has_valid_headings(&self) -> bool {
1129 self.lines
1130 .iter()
1131 .any(|line| line.heading.as_ref().is_some_and(|h| h.is_valid))
1132 }
1133}