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 let obsidian_comment_ranges = profile_section!(
206 "Obsidian comments",
207 profile,
208 flavor_detection::detect_obsidian_comments(content, &mut lines, flavor, &code_span_ranges)
209 );
210
211 let link_byte_ranges = profile_section!(
213 "Link byte ranges",
214 profile,
215 link_parser::collect_link_byte_ranges(content)
216 );
217
218 profile_section!(
220 "Headings & blockquotes",
221 profile,
222 heading_detection::detect_headings_and_blockquotes(
223 &content_lines,
224 &mut lines,
225 flavor,
226 &html_comment_ranges,
227 &link_byte_ranges,
228 front_matter_end,
229 )
230 );
231
232 let code_spans = profile_section!(
234 "Code spans",
235 profile,
236 element_parsers::build_code_spans_from_ranges(content, &lines, &code_span_ranges)
237 );
238
239 for span in &code_spans {
242 if span.end_line > span.line {
243 for line_num in (span.line + 1)..=span.end_line {
245 if let Some(line_info) = lines.get_mut(line_num - 1) {
246 line_info.in_code_span_continuation = true;
247 }
248 }
249 }
250 }
251
252 let (links, broken_links, footnote_refs) = profile_section!(
254 "Links",
255 profile,
256 link_parser::parse_links(content, &lines, &code_blocks, &code_spans, flavor, &html_comment_ranges)
257 );
258
259 let images = profile_section!(
260 "Images",
261 profile,
262 link_parser::parse_images(content, &lines, &code_blocks, &code_spans, &html_comment_ranges)
263 );
264
265 let reference_defs = profile_section!(
266 "Reference defs",
267 profile,
268 link_parser::parse_reference_defs(content, &lines)
269 );
270
271 let reference_defs_map: HashMap<String, usize> = reference_defs
273 .iter()
274 .enumerate()
275 .map(|(idx, def)| (def.id.to_lowercase(), idx))
276 .collect();
277
278 let list_blocks = profile_section!("List blocks", profile, list_blocks::parse_list_blocks(content, &lines));
279
280 let char_frequency = profile_section!(
282 "Char frequency",
283 profile,
284 line_computation::compute_char_frequency(content)
285 );
286
287 let table_blocks = profile_section!(
289 "Table blocks",
290 profile,
291 crate::utils::table_utils::TableUtils::find_table_blocks_with_code_info(
292 content,
293 &code_blocks,
294 &code_spans,
295 &html_comment_ranges,
296 )
297 );
298
299 let line_index = profile_section!(
301 "Line index",
302 profile,
303 crate::utils::range_utils::LineIndex::with_line_starts_and_code_blocks(
304 content,
305 line_offsets.clone(),
306 &code_blocks,
307 )
308 );
309
310 let jinja_ranges = profile_section!(
312 "Jinja ranges",
313 profile,
314 crate::utils::jinja_utils::find_jinja_ranges(content)
315 );
316
317 let citation_ranges = profile_section!("Citation ranges", profile, {
319 if flavor == MarkdownFlavor::Quarto {
320 crate::utils::quarto_divs::find_citation_ranges(content)
321 } else {
322 Vec::new()
323 }
324 });
325
326 let shortcode_ranges = profile_section!("Shortcode ranges", profile, {
328 use crate::utils::regex_cache::HUGO_SHORTCODE_REGEX;
329 let mut ranges = Vec::new();
330 for mat in HUGO_SHORTCODE_REGEX.find_iter(content).flatten() {
331 ranges.push((mat.start(), mat.end()));
332 }
333 ranges
334 });
335
336 let inline_config = InlineConfig::from_content_with_code_blocks(content, &code_blocks);
337
338 Self {
339 content,
340 content_lines,
341 line_offsets,
342 code_blocks,
343 lines,
344 links,
345 images,
346 broken_links,
347 footnote_refs,
348 reference_defs,
349 reference_defs_map,
350 code_spans_cache: OnceLock::from(Arc::new(code_spans)),
351 math_spans_cache: OnceLock::new(), list_blocks,
353 char_frequency,
354 html_tags_cache: OnceLock::new(),
355 emphasis_spans_cache: OnceLock::from(Arc::new(emphasis_spans)),
356 table_rows_cache: OnceLock::new(),
357 bare_urls_cache: OnceLock::new(),
358 has_mixed_list_nesting_cache: OnceLock::new(),
359 html_comment_ranges,
360 table_blocks,
361 line_index,
362 jinja_ranges,
363 flavor,
364 source_file,
365 jsx_expression_ranges,
366 mdx_comment_ranges,
367 citation_ranges,
368 shortcode_ranges,
369 inline_config,
370 obsidian_comment_ranges,
371 }
372 }
373
374 pub fn inline_config(&self) -> &InlineConfig {
376 &self.inline_config
377 }
378
379 pub fn raw_lines(&self) -> &[&'a str] {
383 &self.content_lines
384 }
385
386 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
391 self.inline_config.is_rule_disabled(rule_name, line_number)
392 }
393
394 pub fn code_spans(&self) -> Arc<Vec<CodeSpan>> {
396 Arc::clone(
397 self.code_spans_cache
398 .get_or_init(|| Arc::new(element_parsers::parse_code_spans(self.content, &self.lines))),
399 )
400 }
401
402 pub fn math_spans(&self) -> Arc<Vec<MathSpan>> {
404 Arc::clone(
405 self.math_spans_cache
406 .get_or_init(|| Arc::new(element_parsers::parse_math_spans(self.content, &self.lines))),
407 )
408 }
409
410 pub fn is_in_math_span(&self, byte_pos: usize) -> bool {
412 let math_spans = self.math_spans();
413 math_spans
414 .iter()
415 .any(|span| byte_pos >= span.byte_offset && byte_pos < span.byte_end)
416 }
417
418 pub fn html_comment_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
420 &self.html_comment_ranges
421 }
422
423 pub fn obsidian_comment_ranges(&self) -> &[(usize, usize)] {
426 &self.obsidian_comment_ranges
427 }
428
429 pub fn is_in_obsidian_comment(&self, byte_pos: usize) -> bool {
433 self.obsidian_comment_ranges
434 .iter()
435 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
436 }
437
438 pub fn is_position_in_obsidian_comment(&self, line_num: usize, col: usize) -> bool {
443 if self.obsidian_comment_ranges.is_empty() {
444 return false;
445 }
446
447 let byte_pos = self.line_index.line_col_to_byte_range(line_num, col).start;
449 self.is_in_obsidian_comment(byte_pos)
450 }
451
452 pub fn html_tags(&self) -> Arc<Vec<HtmlTag>> {
454 Arc::clone(self.html_tags_cache.get_or_init(|| {
455 Arc::new(element_parsers::parse_html_tags(
456 self.content,
457 &self.lines,
458 &self.code_blocks,
459 self.flavor,
460 ))
461 }))
462 }
463
464 pub fn emphasis_spans(&self) -> Arc<Vec<EmphasisSpan>> {
466 Arc::clone(
467 self.emphasis_spans_cache
468 .get()
469 .expect("emphasis_spans_cache initialized during construction"),
470 )
471 }
472
473 pub fn table_rows(&self) -> Arc<Vec<TableRow>> {
475 Arc::clone(
476 self.table_rows_cache
477 .get_or_init(|| Arc::new(element_parsers::parse_table_rows(self.content, &self.lines))),
478 )
479 }
480
481 pub fn bare_urls(&self) -> Arc<Vec<BareUrl>> {
483 Arc::clone(self.bare_urls_cache.get_or_init(|| {
484 Arc::new(element_parsers::parse_bare_urls(
485 self.content,
486 &self.lines,
487 &self.code_blocks,
488 ))
489 }))
490 }
491
492 pub fn has_mixed_list_nesting(&self) -> bool {
496 *self
497 .has_mixed_list_nesting_cache
498 .get_or_init(|| self.compute_mixed_list_nesting())
499 }
500
501 fn compute_mixed_list_nesting(&self) -> bool {
503 let mut stack: Vec<(usize, bool)> = Vec::new();
508 let mut last_was_blank = false;
509
510 for line_info in &self.lines {
511 if line_info.in_code_block
513 || line_info.in_front_matter
514 || line_info.in_mkdocstrings
515 || line_info.in_html_comment
516 || line_info.in_esm_block
517 {
518 continue;
519 }
520
521 if line_info.is_blank {
523 last_was_blank = true;
524 continue;
525 }
526
527 if let Some(list_item) = &line_info.list_item {
528 let current_pos = if list_item.marker_column == 1 {
530 0
531 } else {
532 list_item.marker_column
533 };
534
535 if last_was_blank && current_pos == 0 {
537 stack.clear();
538 }
539 last_was_blank = false;
540
541 while let Some(&(pos, _)) = stack.last() {
543 if pos >= current_pos {
544 stack.pop();
545 } else {
546 break;
547 }
548 }
549
550 if let Some(&(_, parent_is_ordered)) = stack.last()
552 && parent_is_ordered != list_item.is_ordered
553 {
554 return true; }
556
557 stack.push((current_pos, list_item.is_ordered));
558 } else {
559 last_was_blank = false;
561 }
562 }
563
564 false
565 }
566
567 pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
569 match self.line_offsets.binary_search(&offset) {
570 Ok(line) => (line + 1, 1),
571 Err(line) => {
572 let line_start = self.line_offsets.get(line.wrapping_sub(1)).copied().unwrap_or(0);
573 (line, offset - line_start + 1)
574 }
575 }
576 }
577
578 pub fn is_in_code_block_or_span(&self, pos: usize) -> bool {
580 if CodeBlockUtils::is_in_code_block_or_span(&self.code_blocks, pos) {
582 return true;
583 }
584
585 self.code_spans()
587 .iter()
588 .any(|span| pos >= span.byte_offset && pos < span.byte_end)
589 }
590
591 pub fn line_info(&self, line_num: usize) -> Option<&LineInfo> {
593 if line_num > 0 {
594 self.lines.get(line_num - 1)
595 } else {
596 None
597 }
598 }
599
600 pub fn line_to_byte_offset(&self, line_num: usize) -> Option<usize> {
602 self.line_info(line_num).map(|info| info.byte_offset)
603 }
604
605 pub fn get_reference_url(&self, ref_id: &str) -> Option<&str> {
607 let normalized_id = ref_id.to_lowercase();
608 self.reference_defs_map
609 .get(&normalized_id)
610 .map(|&idx| self.reference_defs[idx].url.as_str())
611 }
612
613 pub fn get_reference_def(&self, ref_id: &str) -> Option<&ReferenceDef> {
615 let normalized_id = ref_id.to_lowercase();
616 self.reference_defs_map
617 .get(&normalized_id)
618 .map(|&idx| &self.reference_defs[idx])
619 }
620
621 pub fn has_reference_def(&self, ref_id: &str) -> bool {
623 let normalized_id = ref_id.to_lowercase();
624 self.reference_defs_map.contains_key(&normalized_id)
625 }
626
627 pub fn is_in_list_block(&self, line_num: usize) -> bool {
629 self.list_blocks
630 .iter()
631 .any(|block| line_num >= block.start_line && line_num <= block.end_line)
632 }
633
634 pub fn list_block_for_line(&self, line_num: usize) -> Option<&ListBlock> {
636 self.list_blocks
637 .iter()
638 .find(|block| line_num >= block.start_line && line_num <= block.end_line)
639 }
640
641 pub fn is_in_code_block(&self, line_num: usize) -> bool {
645 if line_num == 0 || line_num > self.lines.len() {
646 return false;
647 }
648 self.lines[line_num - 1].in_code_block
649 }
650
651 pub fn is_in_front_matter(&self, line_num: usize) -> bool {
653 if line_num == 0 || line_num > self.lines.len() {
654 return false;
655 }
656 self.lines[line_num - 1].in_front_matter
657 }
658
659 pub fn is_in_html_block(&self, line_num: usize) -> bool {
661 if line_num == 0 || line_num > self.lines.len() {
662 return false;
663 }
664 self.lines[line_num - 1].in_html_block
665 }
666
667 pub fn is_in_code_span(&self, line_num: usize, col: usize) -> bool {
669 if line_num == 0 || line_num > self.lines.len() {
670 return false;
671 }
672
673 let col_0indexed = if col > 0 { col - 1 } else { 0 };
677 let code_spans = self.code_spans();
678 code_spans.iter().any(|span| {
679 if line_num < span.line || line_num > span.end_line {
681 return false;
682 }
683
684 if span.line == span.end_line {
685 col_0indexed >= span.start_col && col_0indexed < span.end_col
687 } else if line_num == span.line {
688 col_0indexed >= span.start_col
690 } else if line_num == span.end_line {
691 col_0indexed < span.end_col
693 } else {
694 true
696 }
697 })
698 }
699
700 #[inline]
702 pub fn is_byte_offset_in_code_span(&self, byte_offset: usize) -> bool {
703 let code_spans = self.code_spans();
704 code_spans
705 .iter()
706 .any(|span| byte_offset >= span.byte_offset && byte_offset < span.byte_end)
707 }
708
709 #[inline]
711 pub fn is_in_reference_def(&self, byte_pos: usize) -> bool {
712 self.reference_defs
713 .iter()
714 .any(|ref_def| byte_pos >= ref_def.byte_offset && byte_pos < ref_def.byte_end)
715 }
716
717 #[inline]
719 pub fn is_in_html_comment(&self, byte_pos: usize) -> bool {
720 self.html_comment_ranges
721 .iter()
722 .any(|range| byte_pos >= range.start && byte_pos < range.end)
723 }
724
725 #[inline]
728 pub fn is_in_html_tag(&self, byte_pos: usize) -> bool {
729 self.html_tags()
730 .iter()
731 .any(|tag| byte_pos >= tag.byte_offset && byte_pos < tag.byte_end)
732 }
733
734 pub fn is_in_jinja_range(&self, byte_pos: usize) -> bool {
736 self.jinja_ranges
737 .iter()
738 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
739 }
740
741 #[inline]
743 pub fn is_in_jsx_expression(&self, byte_pos: usize) -> bool {
744 self.jsx_expression_ranges
745 .iter()
746 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
747 }
748
749 #[inline]
751 pub fn is_in_mdx_comment(&self, byte_pos: usize) -> bool {
752 self.mdx_comment_ranges
753 .iter()
754 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
755 }
756
757 pub fn jsx_expression_ranges(&self) -> &[(usize, usize)] {
759 &self.jsx_expression_ranges
760 }
761
762 pub fn mdx_comment_ranges(&self) -> &[(usize, usize)] {
764 &self.mdx_comment_ranges
765 }
766
767 #[inline]
770 pub fn is_in_citation(&self, byte_pos: usize) -> bool {
771 self.citation_ranges
772 .iter()
773 .any(|range| byte_pos >= range.start && byte_pos < range.end)
774 }
775
776 pub fn citation_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
778 &self.citation_ranges
779 }
780
781 #[inline]
783 pub fn is_in_shortcode(&self, byte_pos: usize) -> bool {
784 self.shortcode_ranges
785 .iter()
786 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
787 }
788
789 pub fn shortcode_ranges(&self) -> &[(usize, usize)] {
791 &self.shortcode_ranges
792 }
793
794 pub fn is_in_link_title(&self, byte_pos: usize) -> bool {
796 self.reference_defs.iter().any(|def| {
797 if let (Some(start), Some(end)) = (def.title_byte_start, def.title_byte_end) {
798 byte_pos >= start && byte_pos < end
799 } else {
800 false
801 }
802 })
803 }
804
805 pub fn has_char(&self, ch: char) -> bool {
807 match ch {
808 '#' => self.char_frequency.hash_count > 0,
809 '*' => self.char_frequency.asterisk_count > 0,
810 '_' => self.char_frequency.underscore_count > 0,
811 '-' => self.char_frequency.hyphen_count > 0,
812 '+' => self.char_frequency.plus_count > 0,
813 '>' => self.char_frequency.gt_count > 0,
814 '|' => self.char_frequency.pipe_count > 0,
815 '[' => self.char_frequency.bracket_count > 0,
816 '`' => self.char_frequency.backtick_count > 0,
817 '<' => self.char_frequency.lt_count > 0,
818 '!' => self.char_frequency.exclamation_count > 0,
819 '\n' => self.char_frequency.newline_count > 0,
820 _ => self.content.contains(ch), }
822 }
823
824 pub fn char_count(&self, ch: char) -> usize {
826 match ch {
827 '#' => self.char_frequency.hash_count,
828 '*' => self.char_frequency.asterisk_count,
829 '_' => self.char_frequency.underscore_count,
830 '-' => self.char_frequency.hyphen_count,
831 '+' => self.char_frequency.plus_count,
832 '>' => self.char_frequency.gt_count,
833 '|' => self.char_frequency.pipe_count,
834 '[' => self.char_frequency.bracket_count,
835 '`' => self.char_frequency.backtick_count,
836 '<' => self.char_frequency.lt_count,
837 '!' => self.char_frequency.exclamation_count,
838 '\n' => self.char_frequency.newline_count,
839 _ => self.content.matches(ch).count(), }
841 }
842
843 pub fn likely_has_headings(&self) -> bool {
845 self.char_frequency.hash_count > 0 || self.char_frequency.hyphen_count > 2 }
847
848 pub fn likely_has_lists(&self) -> bool {
850 self.char_frequency.asterisk_count > 0
851 || self.char_frequency.hyphen_count > 0
852 || self.char_frequency.plus_count > 0
853 }
854
855 pub fn likely_has_emphasis(&self) -> bool {
857 self.char_frequency.asterisk_count > 1 || self.char_frequency.underscore_count > 1
858 }
859
860 pub fn likely_has_tables(&self) -> bool {
862 self.char_frequency.pipe_count > 2
863 }
864
865 pub fn likely_has_blockquotes(&self) -> bool {
867 self.char_frequency.gt_count > 0
868 }
869
870 pub fn likely_has_code(&self) -> bool {
872 self.char_frequency.backtick_count > 0
873 }
874
875 pub fn likely_has_links_or_images(&self) -> bool {
877 self.char_frequency.bracket_count > 0 || self.char_frequency.exclamation_count > 0
878 }
879
880 pub fn likely_has_html(&self) -> bool {
882 self.char_frequency.lt_count > 0
883 }
884
885 pub fn blockquote_prefix_for_blank_line(&self, line_idx: usize) -> String {
890 if let Some(line_info) = self.lines.get(line_idx)
891 && let Some(ref bq) = line_info.blockquote
892 {
893 bq.prefix.trim_end().to_string()
894 } else {
895 String::new()
896 }
897 }
898
899 pub fn html_tags_on_line(&self, line_num: usize) -> Vec<HtmlTag> {
901 self.html_tags()
902 .iter()
903 .filter(|tag| tag.line == line_num)
904 .cloned()
905 .collect()
906 }
907
908 pub fn emphasis_spans_on_line(&self, line_num: usize) -> Vec<EmphasisSpan> {
910 self.emphasis_spans()
911 .iter()
912 .filter(|span| span.line == line_num)
913 .cloned()
914 .collect()
915 }
916
917 pub fn table_rows_on_line(&self, line_num: usize) -> Vec<TableRow> {
919 self.table_rows()
920 .iter()
921 .filter(|row| row.line == line_num)
922 .cloned()
923 .collect()
924 }
925
926 pub fn bare_urls_on_line(&self, line_num: usize) -> Vec<BareUrl> {
928 self.bare_urls()
929 .iter()
930 .filter(|url| url.line == line_num)
931 .cloned()
932 .collect()
933 }
934
935 #[inline]
941 fn find_line_for_offset(lines: &[LineInfo], byte_offset: usize) -> (usize, usize, usize) {
942 let idx = match lines.binary_search_by(|line| {
944 if byte_offset < line.byte_offset {
945 std::cmp::Ordering::Greater
946 } else if byte_offset > line.byte_offset + line.byte_len {
947 std::cmp::Ordering::Less
948 } else {
949 std::cmp::Ordering::Equal
950 }
951 }) {
952 Ok(idx) => idx,
953 Err(idx) => idx.saturating_sub(1),
954 };
955
956 let line = &lines[idx];
957 let line_num = idx + 1;
958 let col = byte_offset.saturating_sub(line.byte_offset);
959
960 (idx, line_num, col)
961 }
962
963 #[inline]
965 fn is_offset_in_code_span(code_spans: &[CodeSpan], offset: usize) -> bool {
966 let idx = code_spans.partition_point(|span| span.byte_offset <= offset);
968
969 if idx > 0 {
971 let span = &code_spans[idx - 1];
972 if offset >= span.byte_offset && offset < span.byte_end {
973 return true;
974 }
975 }
976
977 false
978 }
979
980 #[must_use]
1000 pub fn valid_headings(&self) -> ValidHeadingsIter<'_> {
1001 ValidHeadingsIter::new(&self.lines)
1002 }
1003
1004 #[must_use]
1008 pub fn has_valid_headings(&self) -> bool {
1009 self.lines
1010 .iter()
1011 .any(|line| line.heading.as_ref().is_some_and(|h| h.is_valid))
1012 }
1013}