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 (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!("Autodoc block ranges", profile, {
129 if flavor == MarkdownFlavor::MkDocs {
130 crate::utils::mkdocstrings_refs::detect_autodoc_block_ranges(content)
131 } else {
132 Vec::new()
133 }
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 profile_section!(
206 "Kramdown constructs",
207 profile,
208 flavor_detection::detect_kramdown_line_info(content, &mut lines, flavor)
209 );
210
211 for line in &mut lines {
216 if line.in_kramdown_extension_block {
217 line.list_item = None;
218 line.is_horizontal_rule = false;
219 line.blockquote = None;
220 line.is_kramdown_block_ial = false;
221 }
222 }
223
224 let obsidian_comment_ranges = profile_section!(
226 "Obsidian comments",
227 profile,
228 flavor_detection::detect_obsidian_comments(content, &mut lines, flavor, &code_span_ranges)
229 );
230
231 let link_byte_ranges = profile_section!(
233 "Link byte ranges",
234 profile,
235 link_parser::collect_link_byte_ranges(content)
236 );
237
238 profile_section!(
240 "Headings & blockquotes",
241 profile,
242 heading_detection::detect_headings_and_blockquotes(
243 &content_lines,
244 &mut lines,
245 flavor,
246 &html_comment_ranges,
247 &link_byte_ranges,
248 front_matter_end,
249 )
250 );
251
252 for line in &mut lines {
254 if line.in_kramdown_extension_block {
255 line.heading = None;
256 }
257 }
258
259 let code_spans = profile_section!(
261 "Code spans",
262 profile,
263 element_parsers::build_code_spans_from_ranges(content, &lines, &code_span_ranges)
264 );
265
266 for span in &code_spans {
269 if span.end_line > span.line {
270 for line_num in (span.line + 1)..=span.end_line {
272 if let Some(line_info) = lines.get_mut(line_num - 1) {
273 line_info.in_code_span_continuation = true;
274 }
275 }
276 }
277 }
278
279 let (links, broken_links, footnote_refs) = profile_section!(
281 "Links",
282 profile,
283 link_parser::parse_links(content, &lines, &code_blocks, &code_spans, flavor, &html_comment_ranges)
284 );
285
286 let images = profile_section!(
287 "Images",
288 profile,
289 link_parser::parse_images(content, &lines, &code_blocks, &code_spans, &html_comment_ranges)
290 );
291
292 let reference_defs = profile_section!(
293 "Reference defs",
294 profile,
295 link_parser::parse_reference_defs(content, &lines)
296 );
297
298 let list_blocks = profile_section!("List blocks", profile, list_blocks::parse_list_blocks(content, &lines));
299
300 let char_frequency = profile_section!(
302 "Char frequency",
303 profile,
304 line_computation::compute_char_frequency(content)
305 );
306
307 let table_blocks = profile_section!(
309 "Table blocks",
310 profile,
311 crate::utils::table_utils::TableUtils::find_table_blocks_with_code_info(
312 content,
313 &code_blocks,
314 &code_spans,
315 &html_comment_ranges,
316 )
317 );
318
319 let links = links
322 .into_iter()
323 .filter(|link| !lines.get(link.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
324 .collect::<Vec<_>>();
325 let images = images
326 .into_iter()
327 .filter(|img| !lines.get(img.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
328 .collect::<Vec<_>>();
329 let broken_links = broken_links
330 .into_iter()
331 .filter(|bl| {
332 let line_idx = line_offsets
334 .partition_point(|&offset| offset <= bl.span.start)
335 .saturating_sub(1);
336 !lines.get(line_idx).is_some_and(|l| l.in_kramdown_extension_block)
337 })
338 .collect::<Vec<_>>();
339 let footnote_refs = footnote_refs
340 .into_iter()
341 .filter(|fr| !lines.get(fr.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
342 .collect::<Vec<_>>();
343 let reference_defs = reference_defs
344 .into_iter()
345 .filter(|def| !lines.get(def.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
346 .collect::<Vec<_>>();
347 let list_blocks = list_blocks
348 .into_iter()
349 .filter(|block| {
350 !lines
351 .get(block.start_line - 1)
352 .is_some_and(|l| l.in_kramdown_extension_block)
353 })
354 .collect::<Vec<_>>();
355 let table_blocks = table_blocks
356 .into_iter()
357 .filter(|block| {
358 !lines
360 .get(block.start_line)
361 .is_some_and(|l| l.in_kramdown_extension_block)
362 })
363 .collect::<Vec<_>>();
364 let emphasis_spans = emphasis_spans
365 .into_iter()
366 .filter(|span| !lines.get(span.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
367 .collect::<Vec<_>>();
368
369 let reference_defs_map: HashMap<String, usize> = reference_defs
371 .iter()
372 .enumerate()
373 .map(|(idx, def)| (def.id.to_lowercase(), idx))
374 .collect();
375
376 let line_index = profile_section!(
378 "Line index",
379 profile,
380 crate::utils::range_utils::LineIndex::with_line_starts_and_code_blocks(
381 content,
382 line_offsets.clone(),
383 &code_blocks,
384 )
385 );
386
387 let jinja_ranges = profile_section!(
389 "Jinja ranges",
390 profile,
391 crate::utils::jinja_utils::find_jinja_ranges(content)
392 );
393
394 let citation_ranges = profile_section!("Citation ranges", profile, {
396 if flavor == MarkdownFlavor::Quarto {
397 crate::utils::quarto_divs::find_citation_ranges(content)
398 } else {
399 Vec::new()
400 }
401 });
402
403 let shortcode_ranges = profile_section!("Shortcode ranges", profile, {
405 use crate::utils::regex_cache::HUGO_SHORTCODE_REGEX;
406 let mut ranges = Vec::new();
407 for mat in HUGO_SHORTCODE_REGEX.find_iter(content).flatten() {
408 ranges.push((mat.start(), mat.end()));
409 }
410 ranges
411 });
412
413 let inline_config = InlineConfig::from_content_with_code_blocks(content, &code_blocks);
414
415 Self {
416 content,
417 content_lines,
418 line_offsets,
419 code_blocks,
420 lines,
421 links,
422 images,
423 broken_links,
424 footnote_refs,
425 reference_defs,
426 reference_defs_map,
427 code_spans_cache: OnceLock::from(Arc::new(code_spans)),
428 math_spans_cache: OnceLock::new(), list_blocks,
430 char_frequency,
431 html_tags_cache: OnceLock::new(),
432 emphasis_spans_cache: OnceLock::from(Arc::new(emphasis_spans)),
433 table_rows_cache: OnceLock::new(),
434 bare_urls_cache: OnceLock::new(),
435 has_mixed_list_nesting_cache: OnceLock::new(),
436 html_comment_ranges,
437 table_blocks,
438 line_index,
439 jinja_ranges,
440 flavor,
441 source_file,
442 jsx_expression_ranges,
443 mdx_comment_ranges,
444 citation_ranges,
445 shortcode_ranges,
446 inline_config,
447 obsidian_comment_ranges,
448 }
449 }
450
451 pub fn inline_config(&self) -> &InlineConfig {
453 &self.inline_config
454 }
455
456 pub fn raw_lines(&self) -> &[&'a str] {
460 &self.content_lines
461 }
462
463 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
468 self.inline_config.is_rule_disabled(rule_name, line_number)
469 }
470
471 pub fn code_spans(&self) -> Arc<Vec<CodeSpan>> {
473 Arc::clone(
474 self.code_spans_cache
475 .get_or_init(|| Arc::new(element_parsers::parse_code_spans(self.content, &self.lines))),
476 )
477 }
478
479 pub fn math_spans(&self) -> Arc<Vec<MathSpan>> {
481 Arc::clone(
482 self.math_spans_cache
483 .get_or_init(|| Arc::new(element_parsers::parse_math_spans(self.content, &self.lines))),
484 )
485 }
486
487 pub fn is_in_math_span(&self, byte_pos: usize) -> bool {
489 let math_spans = self.math_spans();
490 math_spans
491 .iter()
492 .any(|span| byte_pos >= span.byte_offset && byte_pos < span.byte_end)
493 }
494
495 pub fn html_comment_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
497 &self.html_comment_ranges
498 }
499
500 pub fn obsidian_comment_ranges(&self) -> &[(usize, usize)] {
503 &self.obsidian_comment_ranges
504 }
505
506 pub fn is_in_obsidian_comment(&self, byte_pos: usize) -> bool {
510 self.obsidian_comment_ranges
511 .iter()
512 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
513 }
514
515 pub fn is_position_in_obsidian_comment(&self, line_num: usize, col: usize) -> bool {
520 if self.obsidian_comment_ranges.is_empty() {
521 return false;
522 }
523
524 let byte_pos = self.line_index.line_col_to_byte_range(line_num, col).start;
526 self.is_in_obsidian_comment(byte_pos)
527 }
528
529 pub fn html_tags(&self) -> Arc<Vec<HtmlTag>> {
531 Arc::clone(self.html_tags_cache.get_or_init(|| {
532 let tags = element_parsers::parse_html_tags(self.content, &self.lines, &self.code_blocks, self.flavor);
533 Arc::new(
535 tags.into_iter()
536 .filter(|tag| {
537 !self
538 .lines
539 .get(tag.line - 1)
540 .is_some_and(|l| l.in_kramdown_extension_block)
541 })
542 .collect(),
543 )
544 }))
545 }
546
547 pub fn emphasis_spans(&self) -> Arc<Vec<EmphasisSpan>> {
549 Arc::clone(
550 self.emphasis_spans_cache
551 .get()
552 .expect("emphasis_spans_cache initialized during construction"),
553 )
554 }
555
556 pub fn table_rows(&self) -> Arc<Vec<TableRow>> {
558 Arc::clone(
559 self.table_rows_cache
560 .get_or_init(|| Arc::new(element_parsers::parse_table_rows(self.content, &self.lines))),
561 )
562 }
563
564 pub fn bare_urls(&self) -> Arc<Vec<BareUrl>> {
566 Arc::clone(self.bare_urls_cache.get_or_init(|| {
567 Arc::new(element_parsers::parse_bare_urls(
568 self.content,
569 &self.lines,
570 &self.code_blocks,
571 ))
572 }))
573 }
574
575 pub fn has_mixed_list_nesting(&self) -> bool {
579 *self
580 .has_mixed_list_nesting_cache
581 .get_or_init(|| self.compute_mixed_list_nesting())
582 }
583
584 fn compute_mixed_list_nesting(&self) -> bool {
586 let mut stack: Vec<(usize, bool)> = Vec::new();
591 let mut last_was_blank = false;
592
593 for line_info in &self.lines {
594 if line_info.in_code_block
596 || line_info.in_front_matter
597 || line_info.in_mkdocstrings
598 || line_info.in_html_comment
599 || line_info.in_esm_block
600 {
601 continue;
602 }
603
604 if line_info.is_blank {
606 last_was_blank = true;
607 continue;
608 }
609
610 if let Some(list_item) = &line_info.list_item {
611 let current_pos = if list_item.marker_column == 1 {
613 0
614 } else {
615 list_item.marker_column
616 };
617
618 if last_was_blank && current_pos == 0 {
620 stack.clear();
621 }
622 last_was_blank = false;
623
624 while let Some(&(pos, _)) = stack.last() {
626 if pos >= current_pos {
627 stack.pop();
628 } else {
629 break;
630 }
631 }
632
633 if let Some(&(_, parent_is_ordered)) = stack.last()
635 && parent_is_ordered != list_item.is_ordered
636 {
637 return true; }
639
640 stack.push((current_pos, list_item.is_ordered));
641 } else {
642 last_was_blank = false;
644 }
645 }
646
647 false
648 }
649
650 pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
652 match self.line_offsets.binary_search(&offset) {
653 Ok(line) => (line + 1, 1),
654 Err(line) => {
655 let line_start = self.line_offsets.get(line.wrapping_sub(1)).copied().unwrap_or(0);
656 (line, offset - line_start + 1)
657 }
658 }
659 }
660
661 pub fn is_in_code_block_or_span(&self, pos: usize) -> bool {
663 if CodeBlockUtils::is_in_code_block_or_span(&self.code_blocks, pos) {
665 return true;
666 }
667
668 self.code_spans()
670 .iter()
671 .any(|span| pos >= span.byte_offset && pos < span.byte_end)
672 }
673
674 pub fn line_info(&self, line_num: usize) -> Option<&LineInfo> {
676 if line_num > 0 {
677 self.lines.get(line_num - 1)
678 } else {
679 None
680 }
681 }
682
683 pub fn line_to_byte_offset(&self, line_num: usize) -> Option<usize> {
685 self.line_info(line_num).map(|info| info.byte_offset)
686 }
687
688 pub fn get_reference_url(&self, ref_id: &str) -> Option<&str> {
690 let normalized_id = ref_id.to_lowercase();
691 self.reference_defs_map
692 .get(&normalized_id)
693 .map(|&idx| self.reference_defs[idx].url.as_str())
694 }
695
696 pub fn get_reference_def(&self, ref_id: &str) -> Option<&ReferenceDef> {
698 let normalized_id = ref_id.to_lowercase();
699 self.reference_defs_map
700 .get(&normalized_id)
701 .map(|&idx| &self.reference_defs[idx])
702 }
703
704 pub fn has_reference_def(&self, ref_id: &str) -> bool {
706 let normalized_id = ref_id.to_lowercase();
707 self.reference_defs_map.contains_key(&normalized_id)
708 }
709
710 pub fn is_in_list_block(&self, line_num: usize) -> bool {
712 self.list_blocks
713 .iter()
714 .any(|block| line_num >= block.start_line && line_num <= block.end_line)
715 }
716
717 pub fn list_block_for_line(&self, line_num: usize) -> Option<&ListBlock> {
719 self.list_blocks
720 .iter()
721 .find(|block| line_num >= block.start_line && line_num <= block.end_line)
722 }
723
724 pub fn is_in_code_block(&self, line_num: usize) -> bool {
728 if line_num == 0 || line_num > self.lines.len() {
729 return false;
730 }
731 self.lines[line_num - 1].in_code_block
732 }
733
734 pub fn is_in_front_matter(&self, line_num: usize) -> bool {
736 if line_num == 0 || line_num > self.lines.len() {
737 return false;
738 }
739 self.lines[line_num - 1].in_front_matter
740 }
741
742 pub fn is_in_html_block(&self, line_num: usize) -> bool {
744 if line_num == 0 || line_num > self.lines.len() {
745 return false;
746 }
747 self.lines[line_num - 1].in_html_block
748 }
749
750 pub fn is_in_code_span(&self, line_num: usize, col: usize) -> bool {
752 if line_num == 0 || line_num > self.lines.len() {
753 return false;
754 }
755
756 let col_0indexed = if col > 0 { col - 1 } else { 0 };
760 let code_spans = self.code_spans();
761 code_spans.iter().any(|span| {
762 if line_num < span.line || line_num > span.end_line {
764 return false;
765 }
766
767 if span.line == span.end_line {
768 col_0indexed >= span.start_col && col_0indexed < span.end_col
770 } else if line_num == span.line {
771 col_0indexed >= span.start_col
773 } else if line_num == span.end_line {
774 col_0indexed < span.end_col
776 } else {
777 true
779 }
780 })
781 }
782
783 #[inline]
785 pub fn is_byte_offset_in_code_span(&self, byte_offset: usize) -> bool {
786 let code_spans = self.code_spans();
787 code_spans
788 .iter()
789 .any(|span| byte_offset >= span.byte_offset && byte_offset < span.byte_end)
790 }
791
792 #[inline]
794 pub fn is_in_reference_def(&self, byte_pos: usize) -> bool {
795 self.reference_defs
796 .iter()
797 .any(|ref_def| byte_pos >= ref_def.byte_offset && byte_pos < ref_def.byte_end)
798 }
799
800 #[inline]
802 pub fn is_in_html_comment(&self, byte_pos: usize) -> bool {
803 self.html_comment_ranges
804 .iter()
805 .any(|range| byte_pos >= range.start && byte_pos < range.end)
806 }
807
808 #[inline]
811 pub fn is_in_html_tag(&self, byte_pos: usize) -> bool {
812 self.html_tags()
813 .iter()
814 .any(|tag| byte_pos >= tag.byte_offset && byte_pos < tag.byte_end)
815 }
816
817 pub fn is_in_jinja_range(&self, byte_pos: usize) -> bool {
819 self.jinja_ranges
820 .iter()
821 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
822 }
823
824 #[inline]
826 pub fn is_in_jsx_expression(&self, byte_pos: usize) -> bool {
827 self.jsx_expression_ranges
828 .iter()
829 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
830 }
831
832 #[inline]
834 pub fn is_in_mdx_comment(&self, byte_pos: usize) -> bool {
835 self.mdx_comment_ranges
836 .iter()
837 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
838 }
839
840 pub fn jsx_expression_ranges(&self) -> &[(usize, usize)] {
842 &self.jsx_expression_ranges
843 }
844
845 pub fn mdx_comment_ranges(&self) -> &[(usize, usize)] {
847 &self.mdx_comment_ranges
848 }
849
850 #[inline]
853 pub fn is_in_citation(&self, byte_pos: usize) -> bool {
854 self.citation_ranges
855 .iter()
856 .any(|range| byte_pos >= range.start && byte_pos < range.end)
857 }
858
859 pub fn citation_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
861 &self.citation_ranges
862 }
863
864 #[inline]
866 pub fn is_in_shortcode(&self, byte_pos: usize) -> bool {
867 self.shortcode_ranges
868 .iter()
869 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
870 }
871
872 pub fn shortcode_ranges(&self) -> &[(usize, usize)] {
874 &self.shortcode_ranges
875 }
876
877 pub fn is_in_link_title(&self, byte_pos: usize) -> bool {
879 self.reference_defs.iter().any(|def| {
880 if let (Some(start), Some(end)) = (def.title_byte_start, def.title_byte_end) {
881 byte_pos >= start && byte_pos < end
882 } else {
883 false
884 }
885 })
886 }
887
888 pub fn has_char(&self, ch: char) -> bool {
890 match ch {
891 '#' => self.char_frequency.hash_count > 0,
892 '*' => self.char_frequency.asterisk_count > 0,
893 '_' => self.char_frequency.underscore_count > 0,
894 '-' => self.char_frequency.hyphen_count > 0,
895 '+' => self.char_frequency.plus_count > 0,
896 '>' => self.char_frequency.gt_count > 0,
897 '|' => self.char_frequency.pipe_count > 0,
898 '[' => self.char_frequency.bracket_count > 0,
899 '`' => self.char_frequency.backtick_count > 0,
900 '<' => self.char_frequency.lt_count > 0,
901 '!' => self.char_frequency.exclamation_count > 0,
902 '\n' => self.char_frequency.newline_count > 0,
903 _ => self.content.contains(ch), }
905 }
906
907 pub fn char_count(&self, ch: char) -> usize {
909 match ch {
910 '#' => self.char_frequency.hash_count,
911 '*' => self.char_frequency.asterisk_count,
912 '_' => self.char_frequency.underscore_count,
913 '-' => self.char_frequency.hyphen_count,
914 '+' => self.char_frequency.plus_count,
915 '>' => self.char_frequency.gt_count,
916 '|' => self.char_frequency.pipe_count,
917 '[' => self.char_frequency.bracket_count,
918 '`' => self.char_frequency.backtick_count,
919 '<' => self.char_frequency.lt_count,
920 '!' => self.char_frequency.exclamation_count,
921 '\n' => self.char_frequency.newline_count,
922 _ => self.content.matches(ch).count(), }
924 }
925
926 pub fn likely_has_headings(&self) -> bool {
928 self.char_frequency.hash_count > 0 || self.char_frequency.hyphen_count > 2 }
930
931 pub fn likely_has_lists(&self) -> bool {
933 self.char_frequency.asterisk_count > 0
934 || self.char_frequency.hyphen_count > 0
935 || self.char_frequency.plus_count > 0
936 }
937
938 pub fn likely_has_emphasis(&self) -> bool {
940 self.char_frequency.asterisk_count > 1 || self.char_frequency.underscore_count > 1
941 }
942
943 pub fn likely_has_tables(&self) -> bool {
945 self.char_frequency.pipe_count > 2
946 }
947
948 pub fn likely_has_blockquotes(&self) -> bool {
950 self.char_frequency.gt_count > 0
951 }
952
953 pub fn likely_has_code(&self) -> bool {
955 self.char_frequency.backtick_count > 0
956 }
957
958 pub fn likely_has_links_or_images(&self) -> bool {
960 self.char_frequency.bracket_count > 0 || self.char_frequency.exclamation_count > 0
961 }
962
963 pub fn likely_has_html(&self) -> bool {
965 self.char_frequency.lt_count > 0
966 }
967
968 pub fn blockquote_prefix_for_blank_line(&self, line_idx: usize) -> String {
973 if let Some(line_info) = self.lines.get(line_idx)
974 && let Some(ref bq) = line_info.blockquote
975 {
976 bq.prefix.trim_end().to_string()
977 } else {
978 String::new()
979 }
980 }
981
982 pub fn html_tags_on_line(&self, line_num: usize) -> Vec<HtmlTag> {
984 self.html_tags()
985 .iter()
986 .filter(|tag| tag.line == line_num)
987 .cloned()
988 .collect()
989 }
990
991 pub fn emphasis_spans_on_line(&self, line_num: usize) -> Vec<EmphasisSpan> {
993 self.emphasis_spans()
994 .iter()
995 .filter(|span| span.line == line_num)
996 .cloned()
997 .collect()
998 }
999
1000 pub fn table_rows_on_line(&self, line_num: usize) -> Vec<TableRow> {
1002 self.table_rows()
1003 .iter()
1004 .filter(|row| row.line == line_num)
1005 .cloned()
1006 .collect()
1007 }
1008
1009 pub fn bare_urls_on_line(&self, line_num: usize) -> Vec<BareUrl> {
1011 self.bare_urls()
1012 .iter()
1013 .filter(|url| url.line == line_num)
1014 .cloned()
1015 .collect()
1016 }
1017
1018 #[inline]
1024 fn find_line_for_offset(lines: &[LineInfo], byte_offset: usize) -> (usize, usize, usize) {
1025 let idx = match lines.binary_search_by(|line| {
1027 if byte_offset < line.byte_offset {
1028 std::cmp::Ordering::Greater
1029 } else if byte_offset > line.byte_offset + line.byte_len {
1030 std::cmp::Ordering::Less
1031 } else {
1032 std::cmp::Ordering::Equal
1033 }
1034 }) {
1035 Ok(idx) => idx,
1036 Err(idx) => idx.saturating_sub(1),
1037 };
1038
1039 let line = &lines[idx];
1040 let line_num = idx + 1;
1041 let col = byte_offset.saturating_sub(line.byte_offset);
1042
1043 (idx, line_num, col)
1044 }
1045
1046 #[inline]
1048 fn is_offset_in_code_span(code_spans: &[CodeSpan], offset: usize) -> bool {
1049 let idx = code_spans.partition_point(|span| span.byte_offset <= offset);
1051
1052 if idx > 0 {
1054 let span = &code_spans[idx - 1];
1055 if offset >= span.byte_offset && offset < span.byte_end {
1056 return true;
1057 }
1058 }
1059
1060 false
1061 }
1062
1063 #[must_use]
1083 pub fn valid_headings(&self) -> ValidHeadingsIter<'_> {
1084 ValidHeadingsIter::new(&self.lines)
1085 }
1086
1087 #[must_use]
1091 pub fn has_valid_headings(&self) -> bool {
1092 self.lines
1093 .iter()
1094 .any(|line| line.heading.as_ref().is_some_and(|h| h.is_valid))
1095 }
1096}