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 mut 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 if flavor == MarkdownFlavor::MkDocs {
307 let extra = profile_section!(
308 "MkDocs code spans",
309 profile,
310 element_parsers::scan_mkdocs_container_code_spans(content, &lines, &code_span_ranges,)
311 );
312 if !extra.is_empty() {
313 code_spans.extend(extra);
314 code_spans.sort_by_key(|span| span.byte_offset);
315 }
316 }
317
318 for span in &code_spans {
321 if span.end_line > span.line {
322 for line_num in (span.line + 1)..=span.end_line {
324 if let Some(line_info) = lines.get_mut(line_num - 1) {
325 line_info.in_code_span_continuation = true;
326 }
327 }
328 }
329 }
330
331 let (links, broken_links, footnote_refs) = profile_section!(
333 "Links",
334 profile,
335 link_parser::parse_links(content, &lines, &code_blocks, &code_spans, flavor, &html_comment_ranges)
336 );
337
338 let images = profile_section!(
339 "Images",
340 profile,
341 link_parser::parse_images(content, &lines, &code_blocks, &code_spans, &html_comment_ranges)
342 );
343
344 let reference_defs = profile_section!(
345 "Reference defs",
346 profile,
347 link_parser::parse_reference_defs(content, &lines)
348 );
349
350 let list_blocks = profile_section!("List blocks", profile, list_blocks::parse_list_blocks(content, &lines));
351
352 let char_frequency = profile_section!(
354 "Char frequency",
355 profile,
356 line_computation::compute_char_frequency(content)
357 );
358
359 let table_blocks = profile_section!(
361 "Table blocks",
362 profile,
363 crate::utils::table_utils::TableUtils::find_table_blocks_with_code_info(
364 content,
365 &code_blocks,
366 &code_spans,
367 &html_comment_ranges,
368 )
369 );
370
371 let links = links
374 .into_iter()
375 .filter(|link| !lines.get(link.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
376 .collect::<Vec<_>>();
377 let images = images
378 .into_iter()
379 .filter(|img| !lines.get(img.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
380 .collect::<Vec<_>>();
381 let broken_links = broken_links
382 .into_iter()
383 .filter(|bl| {
384 let line_idx = line_offsets
386 .partition_point(|&offset| offset <= bl.span.start)
387 .saturating_sub(1);
388 !lines.get(line_idx).is_some_and(|l| l.in_kramdown_extension_block)
389 })
390 .collect::<Vec<_>>();
391 let footnote_refs = footnote_refs
392 .into_iter()
393 .filter(|fr| !lines.get(fr.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
394 .collect::<Vec<_>>();
395 let reference_defs = reference_defs
396 .into_iter()
397 .filter(|def| !lines.get(def.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
398 .collect::<Vec<_>>();
399 let list_blocks = list_blocks
400 .into_iter()
401 .filter(|block| {
402 !lines
403 .get(block.start_line - 1)
404 .is_some_and(|l| l.in_kramdown_extension_block)
405 })
406 .collect::<Vec<_>>();
407 let table_blocks = table_blocks
408 .into_iter()
409 .filter(|block| {
410 !lines
412 .get(block.start_line)
413 .is_some_and(|l| l.in_kramdown_extension_block)
414 })
415 .collect::<Vec<_>>();
416 let emphasis_spans = emphasis_spans
417 .into_iter()
418 .filter(|span| !lines.get(span.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
419 .collect::<Vec<_>>();
420
421 let reference_defs_map: HashMap<String, usize> = reference_defs
423 .iter()
424 .enumerate()
425 .map(|(idx, def)| (def.id.to_lowercase(), idx))
426 .collect();
427
428 let line_index = profile_section!(
430 "Line index",
431 profile,
432 crate::utils::range_utils::LineIndex::with_line_starts_and_code_blocks(
433 content,
434 line_offsets.clone(),
435 &code_blocks,
436 )
437 );
438
439 let jinja_ranges = profile_section!(
441 "Jinja ranges",
442 profile,
443 crate::utils::jinja_utils::find_jinja_ranges(content)
444 );
445
446 let citation_ranges = profile_section!("Citation ranges", profile, {
448 if flavor == MarkdownFlavor::Quarto {
449 crate::utils::quarto_divs::find_citation_ranges(content)
450 } else {
451 Vec::new()
452 }
453 });
454
455 let shortcode_ranges = profile_section!("Shortcode ranges", profile, {
457 use crate::utils::regex_cache::HUGO_SHORTCODE_REGEX;
458 let mut ranges = Vec::new();
459 for mat in HUGO_SHORTCODE_REGEX.find_iter(content).flatten() {
460 ranges.push((mat.start(), mat.end()));
461 }
462 ranges
463 });
464
465 let inline_config = InlineConfig::from_content_with_code_blocks(content, &code_blocks);
466
467 Self {
468 content,
469 content_lines,
470 line_offsets,
471 code_blocks,
472 lines,
473 links,
474 images,
475 broken_links,
476 footnote_refs,
477 reference_defs,
478 reference_defs_map,
479 code_spans_cache: OnceLock::from(Arc::new(code_spans)),
480 math_spans_cache: OnceLock::new(), list_blocks,
482 char_frequency,
483 html_tags_cache: OnceLock::new(),
484 emphasis_spans_cache: OnceLock::from(Arc::new(emphasis_spans)),
485 table_rows_cache: OnceLock::new(),
486 bare_urls_cache: OnceLock::new(),
487 has_mixed_list_nesting_cache: OnceLock::new(),
488 html_comment_ranges,
489 table_blocks,
490 line_index,
491 jinja_ranges,
492 flavor,
493 source_file,
494 jsx_expression_ranges,
495 mdx_comment_ranges,
496 citation_ranges,
497 shortcode_ranges,
498 inline_config,
499 obsidian_comment_ranges,
500 }
501 }
502
503 pub fn inline_config(&self) -> &InlineConfig {
505 &self.inline_config
506 }
507
508 pub fn raw_lines(&self) -> &[&'a str] {
512 &self.content_lines
513 }
514
515 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
520 self.inline_config.is_rule_disabled(rule_name, line_number)
521 }
522
523 pub fn code_spans(&self) -> Arc<Vec<CodeSpan>> {
525 Arc::clone(
526 self.code_spans_cache
527 .get_or_init(|| Arc::new(element_parsers::parse_code_spans(self.content, &self.lines))),
528 )
529 }
530
531 pub fn math_spans(&self) -> Arc<Vec<MathSpan>> {
533 Arc::clone(
534 self.math_spans_cache
535 .get_or_init(|| Arc::new(element_parsers::parse_math_spans(self.content, &self.lines))),
536 )
537 }
538
539 pub fn is_in_math_span(&self, byte_pos: usize) -> bool {
541 let math_spans = self.math_spans();
542 math_spans
543 .iter()
544 .any(|span| byte_pos >= span.byte_offset && byte_pos < span.byte_end)
545 }
546
547 pub fn html_comment_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
549 &self.html_comment_ranges
550 }
551
552 pub fn obsidian_comment_ranges(&self) -> &[(usize, usize)] {
555 &self.obsidian_comment_ranges
556 }
557
558 pub fn is_in_obsidian_comment(&self, byte_pos: usize) -> bool {
562 self.obsidian_comment_ranges
563 .iter()
564 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
565 }
566
567 pub fn is_position_in_obsidian_comment(&self, line_num: usize, col: usize) -> bool {
572 if self.obsidian_comment_ranges.is_empty() {
573 return false;
574 }
575
576 let byte_pos = self.line_index.line_col_to_byte_range(line_num, col).start;
578 self.is_in_obsidian_comment(byte_pos)
579 }
580
581 pub fn html_tags(&self) -> Arc<Vec<HtmlTag>> {
583 Arc::clone(self.html_tags_cache.get_or_init(|| {
584 let tags = element_parsers::parse_html_tags(self.content, &self.lines, &self.code_blocks, self.flavor);
585 Arc::new(
587 tags.into_iter()
588 .filter(|tag| {
589 !self
590 .lines
591 .get(tag.line - 1)
592 .is_some_and(|l| l.in_kramdown_extension_block)
593 })
594 .collect(),
595 )
596 }))
597 }
598
599 pub fn emphasis_spans(&self) -> Arc<Vec<EmphasisSpan>> {
601 Arc::clone(
602 self.emphasis_spans_cache
603 .get()
604 .expect("emphasis_spans_cache initialized during construction"),
605 )
606 }
607
608 pub fn table_rows(&self) -> Arc<Vec<TableRow>> {
610 Arc::clone(
611 self.table_rows_cache
612 .get_or_init(|| Arc::new(element_parsers::parse_table_rows(self.content, &self.lines))),
613 )
614 }
615
616 pub fn bare_urls(&self) -> Arc<Vec<BareUrl>> {
618 Arc::clone(self.bare_urls_cache.get_or_init(|| {
619 Arc::new(element_parsers::parse_bare_urls(
620 self.content,
621 &self.lines,
622 &self.code_blocks,
623 ))
624 }))
625 }
626
627 pub fn has_mixed_list_nesting(&self) -> bool {
631 *self
632 .has_mixed_list_nesting_cache
633 .get_or_init(|| self.compute_mixed_list_nesting())
634 }
635
636 fn compute_mixed_list_nesting(&self) -> bool {
638 let mut stack: Vec<(usize, bool)> = Vec::new();
643 let mut last_was_blank = false;
644
645 for line_info in &self.lines {
646 if line_info.in_code_block
648 || line_info.in_front_matter
649 || line_info.in_mkdocstrings
650 || line_info.in_html_comment
651 || line_info.in_esm_block
652 {
653 continue;
654 }
655
656 if line_info.is_blank {
658 last_was_blank = true;
659 continue;
660 }
661
662 if let Some(list_item) = &line_info.list_item {
663 let current_pos = if list_item.marker_column == 1 {
665 0
666 } else {
667 list_item.marker_column
668 };
669
670 if last_was_blank && current_pos == 0 {
672 stack.clear();
673 }
674 last_was_blank = false;
675
676 while let Some(&(pos, _)) = stack.last() {
678 if pos >= current_pos {
679 stack.pop();
680 } else {
681 break;
682 }
683 }
684
685 if let Some(&(_, parent_is_ordered)) = stack.last()
687 && parent_is_ordered != list_item.is_ordered
688 {
689 return true; }
691
692 stack.push((current_pos, list_item.is_ordered));
693 } else {
694 last_was_blank = false;
696 }
697 }
698
699 false
700 }
701
702 pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
704 match self.line_offsets.binary_search(&offset) {
705 Ok(line) => (line + 1, 1),
706 Err(line) => {
707 let line_start = self.line_offsets.get(line.wrapping_sub(1)).copied().unwrap_or(0);
708 (line, offset - line_start + 1)
709 }
710 }
711 }
712
713 pub fn is_in_code_block_or_span(&self, pos: usize) -> bool {
715 if CodeBlockUtils::is_in_code_block_or_span(&self.code_blocks, pos) {
717 return true;
718 }
719
720 self.code_spans()
722 .iter()
723 .any(|span| pos >= span.byte_offset && pos < span.byte_end)
724 }
725
726 pub fn line_info(&self, line_num: usize) -> Option<&LineInfo> {
728 if line_num > 0 {
729 self.lines.get(line_num - 1)
730 } else {
731 None
732 }
733 }
734
735 pub fn line_to_byte_offset(&self, line_num: usize) -> Option<usize> {
737 self.line_info(line_num).map(|info| info.byte_offset)
738 }
739
740 pub fn get_reference_url(&self, ref_id: &str) -> Option<&str> {
742 let normalized_id = ref_id.to_lowercase();
743 self.reference_defs_map
744 .get(&normalized_id)
745 .map(|&idx| self.reference_defs[idx].url.as_str())
746 }
747
748 pub fn get_reference_def(&self, ref_id: &str) -> Option<&ReferenceDef> {
750 let normalized_id = ref_id.to_lowercase();
751 self.reference_defs_map
752 .get(&normalized_id)
753 .map(|&idx| &self.reference_defs[idx])
754 }
755
756 pub fn has_reference_def(&self, ref_id: &str) -> bool {
758 let normalized_id = ref_id.to_lowercase();
759 self.reference_defs_map.contains_key(&normalized_id)
760 }
761
762 pub fn is_in_list_block(&self, line_num: usize) -> bool {
764 self.list_blocks
765 .iter()
766 .any(|block| line_num >= block.start_line && line_num <= block.end_line)
767 }
768
769 pub fn list_block_for_line(&self, line_num: usize) -> Option<&ListBlock> {
771 self.list_blocks
772 .iter()
773 .find(|block| line_num >= block.start_line && line_num <= block.end_line)
774 }
775
776 pub fn is_in_code_block(&self, line_num: usize) -> bool {
780 if line_num == 0 || line_num > self.lines.len() {
781 return false;
782 }
783 self.lines[line_num - 1].in_code_block
784 }
785
786 pub fn is_in_front_matter(&self, line_num: usize) -> bool {
788 if line_num == 0 || line_num > self.lines.len() {
789 return false;
790 }
791 self.lines[line_num - 1].in_front_matter
792 }
793
794 pub fn is_in_html_block(&self, line_num: usize) -> bool {
796 if line_num == 0 || line_num > self.lines.len() {
797 return false;
798 }
799 self.lines[line_num - 1].in_html_block
800 }
801
802 pub fn is_in_code_span(&self, line_num: usize, col: usize) -> bool {
804 if line_num == 0 || line_num > self.lines.len() {
805 return false;
806 }
807
808 let col_0indexed = if col > 0 { col - 1 } else { 0 };
812 let code_spans = self.code_spans();
813 code_spans.iter().any(|span| {
814 if line_num < span.line || line_num > span.end_line {
816 return false;
817 }
818
819 if span.line == span.end_line {
820 col_0indexed >= span.start_col && col_0indexed < span.end_col
822 } else if line_num == span.line {
823 col_0indexed >= span.start_col
825 } else if line_num == span.end_line {
826 col_0indexed < span.end_col
828 } else {
829 true
831 }
832 })
833 }
834
835 #[inline]
837 pub fn is_byte_offset_in_code_span(&self, byte_offset: usize) -> bool {
838 let code_spans = self.code_spans();
839 code_spans
840 .iter()
841 .any(|span| byte_offset >= span.byte_offset && byte_offset < span.byte_end)
842 }
843
844 #[inline]
846 pub fn is_in_reference_def(&self, byte_pos: usize) -> bool {
847 self.reference_defs
848 .iter()
849 .any(|ref_def| byte_pos >= ref_def.byte_offset && byte_pos < ref_def.byte_end)
850 }
851
852 #[inline]
854 pub fn is_in_html_comment(&self, byte_pos: usize) -> bool {
855 self.html_comment_ranges
856 .iter()
857 .any(|range| byte_pos >= range.start && byte_pos < range.end)
858 }
859
860 #[inline]
863 pub fn is_in_html_tag(&self, byte_pos: usize) -> bool {
864 self.html_tags()
865 .iter()
866 .any(|tag| byte_pos >= tag.byte_offset && byte_pos < tag.byte_end)
867 }
868
869 pub fn is_in_jinja_range(&self, byte_pos: usize) -> bool {
871 self.jinja_ranges
872 .iter()
873 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
874 }
875
876 #[inline]
878 pub fn is_in_jsx_expression(&self, byte_pos: usize) -> bool {
879 self.jsx_expression_ranges
880 .iter()
881 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
882 }
883
884 #[inline]
886 pub fn is_in_mdx_comment(&self, byte_pos: usize) -> bool {
887 self.mdx_comment_ranges
888 .iter()
889 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
890 }
891
892 pub fn jsx_expression_ranges(&self) -> &[(usize, usize)] {
894 &self.jsx_expression_ranges
895 }
896
897 pub fn mdx_comment_ranges(&self) -> &[(usize, usize)] {
899 &self.mdx_comment_ranges
900 }
901
902 #[inline]
905 pub fn is_in_citation(&self, byte_pos: usize) -> bool {
906 self.citation_ranges
907 .iter()
908 .any(|range| byte_pos >= range.start && byte_pos < range.end)
909 }
910
911 pub fn citation_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
913 &self.citation_ranges
914 }
915
916 #[inline]
918 pub fn is_in_shortcode(&self, byte_pos: usize) -> bool {
919 self.shortcode_ranges
920 .iter()
921 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
922 }
923
924 pub fn shortcode_ranges(&self) -> &[(usize, usize)] {
926 &self.shortcode_ranges
927 }
928
929 pub fn is_in_link_title(&self, byte_pos: usize) -> bool {
931 self.reference_defs.iter().any(|def| {
932 if let (Some(start), Some(end)) = (def.title_byte_start, def.title_byte_end) {
933 byte_pos >= start && byte_pos < end
934 } else {
935 false
936 }
937 })
938 }
939
940 pub fn has_char(&self, ch: char) -> bool {
942 match ch {
943 '#' => self.char_frequency.hash_count > 0,
944 '*' => self.char_frequency.asterisk_count > 0,
945 '_' => self.char_frequency.underscore_count > 0,
946 '-' => self.char_frequency.hyphen_count > 0,
947 '+' => self.char_frequency.plus_count > 0,
948 '>' => self.char_frequency.gt_count > 0,
949 '|' => self.char_frequency.pipe_count > 0,
950 '[' => self.char_frequency.bracket_count > 0,
951 '`' => self.char_frequency.backtick_count > 0,
952 '<' => self.char_frequency.lt_count > 0,
953 '!' => self.char_frequency.exclamation_count > 0,
954 '\n' => self.char_frequency.newline_count > 0,
955 _ => self.content.contains(ch), }
957 }
958
959 pub fn char_count(&self, ch: char) -> usize {
961 match ch {
962 '#' => self.char_frequency.hash_count,
963 '*' => self.char_frequency.asterisk_count,
964 '_' => self.char_frequency.underscore_count,
965 '-' => self.char_frequency.hyphen_count,
966 '+' => self.char_frequency.plus_count,
967 '>' => self.char_frequency.gt_count,
968 '|' => self.char_frequency.pipe_count,
969 '[' => self.char_frequency.bracket_count,
970 '`' => self.char_frequency.backtick_count,
971 '<' => self.char_frequency.lt_count,
972 '!' => self.char_frequency.exclamation_count,
973 '\n' => self.char_frequency.newline_count,
974 _ => self.content.matches(ch).count(), }
976 }
977
978 pub fn likely_has_headings(&self) -> bool {
980 self.char_frequency.hash_count > 0 || self.char_frequency.hyphen_count > 2 }
982
983 pub fn likely_has_lists(&self) -> bool {
985 self.char_frequency.asterisk_count > 0
986 || self.char_frequency.hyphen_count > 0
987 || self.char_frequency.plus_count > 0
988 }
989
990 pub fn likely_has_emphasis(&self) -> bool {
992 self.char_frequency.asterisk_count > 1 || self.char_frequency.underscore_count > 1
993 }
994
995 pub fn likely_has_tables(&self) -> bool {
997 self.char_frequency.pipe_count > 2
998 }
999
1000 pub fn likely_has_blockquotes(&self) -> bool {
1002 self.char_frequency.gt_count > 0
1003 }
1004
1005 pub fn likely_has_code(&self) -> bool {
1007 self.char_frequency.backtick_count > 0
1008 }
1009
1010 pub fn likely_has_links_or_images(&self) -> bool {
1012 self.char_frequency.bracket_count > 0 || self.char_frequency.exclamation_count > 0
1013 }
1014
1015 pub fn likely_has_html(&self) -> bool {
1017 self.char_frequency.lt_count > 0
1018 }
1019
1020 pub fn blockquote_prefix_for_blank_line(&self, line_idx: usize) -> String {
1025 if let Some(line_info) = self.lines.get(line_idx)
1026 && let Some(ref bq) = line_info.blockquote
1027 {
1028 bq.prefix.trim_end().to_string()
1029 } else {
1030 String::new()
1031 }
1032 }
1033
1034 pub fn html_tags_on_line(&self, line_num: usize) -> Vec<HtmlTag> {
1036 self.html_tags()
1037 .iter()
1038 .filter(|tag| tag.line == line_num)
1039 .cloned()
1040 .collect()
1041 }
1042
1043 pub fn emphasis_spans_on_line(&self, line_num: usize) -> Vec<EmphasisSpan> {
1045 self.emphasis_spans()
1046 .iter()
1047 .filter(|span| span.line == line_num)
1048 .cloned()
1049 .collect()
1050 }
1051
1052 pub fn table_rows_on_line(&self, line_num: usize) -> Vec<TableRow> {
1054 self.table_rows()
1055 .iter()
1056 .filter(|row| row.line == line_num)
1057 .cloned()
1058 .collect()
1059 }
1060
1061 pub fn bare_urls_on_line(&self, line_num: usize) -> Vec<BareUrl> {
1063 self.bare_urls()
1064 .iter()
1065 .filter(|url| url.line == line_num)
1066 .cloned()
1067 .collect()
1068 }
1069
1070 #[inline]
1076 fn find_line_for_offset(lines: &[LineInfo], byte_offset: usize) -> (usize, usize, usize) {
1077 let idx = match lines.binary_search_by(|line| {
1079 if byte_offset < line.byte_offset {
1080 std::cmp::Ordering::Greater
1081 } else if byte_offset > line.byte_offset + line.byte_len {
1082 std::cmp::Ordering::Less
1083 } else {
1084 std::cmp::Ordering::Equal
1085 }
1086 }) {
1087 Ok(idx) => idx,
1088 Err(idx) => idx.saturating_sub(1),
1089 };
1090
1091 let line = &lines[idx];
1092 let line_num = idx + 1;
1093 let col = byte_offset.saturating_sub(line.byte_offset);
1094
1095 (idx, line_num, col)
1096 }
1097
1098 #[inline]
1100 fn is_offset_in_code_span(code_spans: &[CodeSpan], offset: usize) -> bool {
1101 let idx = code_spans.partition_point(|span| span.byte_offset <= offset);
1103
1104 if idx > 0 {
1106 let span = &code_spans[idx - 1];
1107 if offset >= span.byte_offset && offset < span.byte_end {
1108 return true;
1109 }
1110 }
1111
1112 false
1113 }
1114
1115 #[must_use]
1135 pub fn valid_headings(&self) -> ValidHeadingsIter<'_> {
1136 ValidHeadingsIter::new(&self.lines)
1137 }
1138
1139 #[must_use]
1143 pub fn has_valid_headings(&self) -> bool {
1144 self.lines
1145 .iter()
1146 .any(|line| line.heading.as_ref().is_some_and(|h| h.is_valid))
1147 }
1148}