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::utils::code_block_utils::CodeBlockUtils;
16use std::collections::HashMap;
17use std::path::PathBuf;
18
19#[cfg(not(target_arch = "wasm32"))]
21macro_rules! profile_section {
22 ($name:expr, $profile:expr, $code:expr) => {{
23 let start = std::time::Instant::now();
24 let result = $code;
25 if $profile {
26 eprintln!("[PROFILE] {}: {:?}", $name, start.elapsed());
27 }
28 result
29 }};
30}
31
32#[cfg(target_arch = "wasm32")]
33macro_rules! profile_section {
34 ($name:expr, $profile:expr, $code:expr) => {{ $code }};
35}
36
37pub(super) struct SkipByteRanges<'a> {
40 pub(super) html_comment_ranges: &'a [crate::utils::skip_context::ByteRange],
41 pub(super) autodoc_ranges: &'a [crate::utils::skip_context::ByteRange],
42 pub(super) quarto_div_ranges: &'a [crate::utils::skip_context::ByteRange],
43 pub(super) pymdown_block_ranges: &'a [crate::utils::skip_context::ByteRange],
44}
45
46use std::sync::{Arc, OnceLock};
47
48pub(super) type ListItemMap = std::collections::HashMap<usize, (bool, String, usize, usize, Option<usize>)>;
50
51pub(super) type ByteRanges = Vec<(usize, usize)>;
53
54pub struct LintContext<'a> {
55 pub content: &'a str,
56 pub line_offsets: Vec<usize>,
57 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)>, }
87
88impl<'a> LintContext<'a> {
89 pub fn new(content: &'a str, flavor: MarkdownFlavor, source_file: Option<PathBuf>) -> Self {
90 #[cfg(not(target_arch = "wasm32"))]
91 let profile = std::env::var("RUMDL_PROFILE_QUADRATIC").is_ok();
92 #[cfg(target_arch = "wasm32")]
93 let profile = false;
94
95 let line_offsets = profile_section!("Line offsets", profile, {
96 let mut offsets = vec![0];
97 for (i, c) in content.char_indices() {
98 if c == '\n' {
99 offsets.push(i + 1);
100 }
101 }
102 offsets
103 });
104
105 let (code_blocks, code_span_ranges) = profile_section!(
107 "Code blocks",
108 profile,
109 CodeBlockUtils::detect_code_blocks_and_spans(content)
110 );
111
112 let html_comment_ranges = profile_section!(
114 "HTML comment ranges",
115 profile,
116 crate::utils::skip_context::compute_html_comment_ranges(content)
117 );
118
119 let autodoc_ranges = profile_section!("Autodoc block ranges", profile, {
121 if flavor == MarkdownFlavor::MkDocs {
122 crate::utils::mkdocstrings_refs::detect_autodoc_block_ranges(content)
123 } else {
124 Vec::new()
125 }
126 });
127
128 let quarto_div_ranges = profile_section!("Quarto div ranges", profile, {
130 if flavor == MarkdownFlavor::Quarto {
131 crate::utils::quarto_divs::detect_div_block_ranges(content)
132 } else {
133 Vec::new()
134 }
135 });
136
137 let pymdown_block_ranges = profile_section!("PyMdown block ranges", profile, {
139 if flavor == MarkdownFlavor::MkDocs {
140 crate::utils::pymdown_blocks::detect_block_ranges(content)
141 } else {
142 Vec::new()
143 }
144 });
145
146 let skip_ranges = SkipByteRanges {
149 html_comment_ranges: &html_comment_ranges,
150 autodoc_ranges: &autodoc_ranges,
151 quarto_div_ranges: &quarto_div_ranges,
152 pymdown_block_ranges: &pymdown_block_ranges,
153 };
154 let (mut lines, emphasis_spans) = profile_section!(
155 "Basic line info",
156 profile,
157 line_computation::compute_basic_line_info(content, &line_offsets, &code_blocks, flavor, &skip_ranges,)
158 );
159
160 profile_section!(
162 "HTML blocks",
163 profile,
164 heading_detection::detect_html_blocks(content, &mut lines)
165 );
166
167 profile_section!(
169 "ESM blocks",
170 profile,
171 flavor_detection::detect_esm_blocks(content, &mut lines, flavor)
172 );
173
174 let (jsx_expression_ranges, mdx_comment_ranges) = profile_section!(
176 "JSX/MDX detection",
177 profile,
178 flavor_detection::detect_jsx_and_mdx_comments(content, &mut lines, flavor, &code_blocks)
179 );
180
181 profile_section!(
183 "MkDocs constructs",
184 profile,
185 flavor_detection::detect_mkdocs_line_info(content, &mut lines, flavor)
186 );
187
188 let obsidian_comment_ranges = profile_section!(
190 "Obsidian comments",
191 profile,
192 flavor_detection::detect_obsidian_comments(content, &mut lines, flavor, &code_span_ranges)
193 );
194
195 let link_byte_ranges = profile_section!(
197 "Link byte ranges",
198 profile,
199 link_parser::collect_link_byte_ranges(content)
200 );
201
202 profile_section!(
204 "Headings & blockquotes",
205 profile,
206 heading_detection::detect_headings_and_blockquotes(
207 content,
208 &mut lines,
209 flavor,
210 &html_comment_ranges,
211 &link_byte_ranges
212 )
213 );
214
215 let code_spans = profile_section!(
217 "Code spans",
218 profile,
219 element_parsers::build_code_spans_from_ranges(content, &lines, &code_span_ranges)
220 );
221
222 for span in &code_spans {
225 if span.end_line > span.line {
226 for line_num in (span.line + 1)..=span.end_line {
228 if let Some(line_info) = lines.get_mut(line_num - 1) {
229 line_info.in_code_span_continuation = true;
230 }
231 }
232 }
233 }
234
235 let (links, broken_links, footnote_refs) = profile_section!(
237 "Links",
238 profile,
239 link_parser::parse_links(content, &lines, &code_blocks, &code_spans, flavor, &html_comment_ranges)
240 );
241
242 let images = profile_section!(
243 "Images",
244 profile,
245 link_parser::parse_images(content, &lines, &code_blocks, &code_spans, &html_comment_ranges)
246 );
247
248 let reference_defs = profile_section!(
249 "Reference defs",
250 profile,
251 link_parser::parse_reference_defs(content, &lines)
252 );
253
254 let reference_defs_map: HashMap<String, usize> = reference_defs
256 .iter()
257 .enumerate()
258 .map(|(idx, def)| (def.id.to_lowercase(), idx))
259 .collect();
260
261 let list_blocks = profile_section!("List blocks", profile, list_blocks::parse_list_blocks(content, &lines));
262
263 let char_frequency = profile_section!(
265 "Char frequency",
266 profile,
267 line_computation::compute_char_frequency(content)
268 );
269
270 let table_blocks = profile_section!(
272 "Table blocks",
273 profile,
274 crate::utils::table_utils::TableUtils::find_table_blocks_with_code_info(
275 content,
276 &code_blocks,
277 &code_spans,
278 &html_comment_ranges,
279 )
280 );
281
282 let line_index = profile_section!(
284 "Line index",
285 profile,
286 crate::utils::range_utils::LineIndex::new(content)
287 );
288
289 let jinja_ranges = profile_section!(
291 "Jinja ranges",
292 profile,
293 crate::utils::jinja_utils::find_jinja_ranges(content)
294 );
295
296 let citation_ranges = profile_section!("Citation ranges", profile, {
298 if flavor == MarkdownFlavor::Quarto {
299 crate::utils::quarto_divs::find_citation_ranges(content)
300 } else {
301 Vec::new()
302 }
303 });
304
305 let shortcode_ranges = profile_section!("Shortcode ranges", profile, {
307 use crate::utils::regex_cache::HUGO_SHORTCODE_REGEX;
308 let mut ranges = Vec::new();
309 for mat in HUGO_SHORTCODE_REGEX.find_iter(content).flatten() {
310 ranges.push((mat.start(), mat.end()));
311 }
312 ranges
313 });
314
315 let inline_config = InlineConfig::from_content_with_code_blocks(content, &code_blocks);
316
317 Self {
318 content,
319 line_offsets,
320 code_blocks,
321 lines,
322 links,
323 images,
324 broken_links,
325 footnote_refs,
326 reference_defs,
327 reference_defs_map,
328 code_spans_cache: OnceLock::from(Arc::new(code_spans)),
329 math_spans_cache: OnceLock::new(), list_blocks,
331 char_frequency,
332 html_tags_cache: OnceLock::new(),
333 emphasis_spans_cache: OnceLock::from(Arc::new(emphasis_spans)),
334 table_rows_cache: OnceLock::new(),
335 bare_urls_cache: OnceLock::new(),
336 has_mixed_list_nesting_cache: OnceLock::new(),
337 html_comment_ranges,
338 table_blocks,
339 line_index,
340 jinja_ranges,
341 flavor,
342 source_file,
343 jsx_expression_ranges,
344 mdx_comment_ranges,
345 citation_ranges,
346 shortcode_ranges,
347 inline_config,
348 obsidian_comment_ranges,
349 }
350 }
351
352 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
357 self.inline_config.is_rule_disabled(rule_name, line_number)
358 }
359
360 pub fn code_spans(&self) -> Arc<Vec<CodeSpan>> {
362 Arc::clone(
363 self.code_spans_cache
364 .get_or_init(|| Arc::new(element_parsers::parse_code_spans(self.content, &self.lines))),
365 )
366 }
367
368 pub fn math_spans(&self) -> Arc<Vec<MathSpan>> {
370 Arc::clone(
371 self.math_spans_cache
372 .get_or_init(|| Arc::new(element_parsers::parse_math_spans(self.content, &self.lines))),
373 )
374 }
375
376 pub fn is_in_math_span(&self, byte_pos: usize) -> bool {
378 let math_spans = self.math_spans();
379 math_spans
380 .iter()
381 .any(|span| byte_pos >= span.byte_offset && byte_pos < span.byte_end)
382 }
383
384 pub fn html_comment_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
386 &self.html_comment_ranges
387 }
388
389 pub fn obsidian_comment_ranges(&self) -> &[(usize, usize)] {
392 &self.obsidian_comment_ranges
393 }
394
395 pub fn is_in_obsidian_comment(&self, byte_pos: usize) -> bool {
399 self.obsidian_comment_ranges
400 .iter()
401 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
402 }
403
404 pub fn is_position_in_obsidian_comment(&self, line_num: usize, col: usize) -> bool {
409 if self.obsidian_comment_ranges.is_empty() {
410 return false;
411 }
412
413 let byte_pos = self.line_index.line_col_to_byte_range(line_num, col).start;
415 self.is_in_obsidian_comment(byte_pos)
416 }
417
418 pub fn html_tags(&self) -> Arc<Vec<HtmlTag>> {
420 Arc::clone(self.html_tags_cache.get_or_init(|| {
421 Arc::new(element_parsers::parse_html_tags(
422 self.content,
423 &self.lines,
424 &self.code_blocks,
425 self.flavor,
426 ))
427 }))
428 }
429
430 pub fn emphasis_spans(&self) -> Arc<Vec<EmphasisSpan>> {
432 Arc::clone(
433 self.emphasis_spans_cache
434 .get()
435 .expect("emphasis_spans_cache initialized during construction"),
436 )
437 }
438
439 pub fn table_rows(&self) -> Arc<Vec<TableRow>> {
441 Arc::clone(
442 self.table_rows_cache
443 .get_or_init(|| Arc::new(element_parsers::parse_table_rows(self.content, &self.lines))),
444 )
445 }
446
447 pub fn bare_urls(&self) -> Arc<Vec<BareUrl>> {
449 Arc::clone(self.bare_urls_cache.get_or_init(|| {
450 Arc::new(element_parsers::parse_bare_urls(
451 self.content,
452 &self.lines,
453 &self.code_blocks,
454 ))
455 }))
456 }
457
458 pub fn has_mixed_list_nesting(&self) -> bool {
462 *self
463 .has_mixed_list_nesting_cache
464 .get_or_init(|| self.compute_mixed_list_nesting())
465 }
466
467 fn compute_mixed_list_nesting(&self) -> bool {
469 let mut stack: Vec<(usize, bool)> = Vec::new();
474 let mut last_was_blank = false;
475
476 for line_info in &self.lines {
477 if line_info.in_code_block
479 || line_info.in_front_matter
480 || line_info.in_mkdocstrings
481 || line_info.in_html_comment
482 || line_info.in_esm_block
483 {
484 continue;
485 }
486
487 if line_info.is_blank {
489 last_was_blank = true;
490 continue;
491 }
492
493 if let Some(list_item) = &line_info.list_item {
494 let current_pos = if list_item.marker_column == 1 {
496 0
497 } else {
498 list_item.marker_column
499 };
500
501 if last_was_blank && current_pos == 0 {
503 stack.clear();
504 }
505 last_was_blank = false;
506
507 while let Some(&(pos, _)) = stack.last() {
509 if pos >= current_pos {
510 stack.pop();
511 } else {
512 break;
513 }
514 }
515
516 if let Some(&(_, parent_is_ordered)) = stack.last()
518 && parent_is_ordered != list_item.is_ordered
519 {
520 return true; }
522
523 stack.push((current_pos, list_item.is_ordered));
524 } else {
525 last_was_blank = false;
527 }
528 }
529
530 false
531 }
532
533 pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
535 match self.line_offsets.binary_search(&offset) {
536 Ok(line) => (line + 1, 1),
537 Err(line) => {
538 let line_start = self.line_offsets.get(line.wrapping_sub(1)).copied().unwrap_or(0);
539 (line, offset - line_start + 1)
540 }
541 }
542 }
543
544 pub fn is_in_code_block_or_span(&self, pos: usize) -> bool {
546 if CodeBlockUtils::is_in_code_block_or_span(&self.code_blocks, pos) {
548 return true;
549 }
550
551 self.code_spans()
553 .iter()
554 .any(|span| pos >= span.byte_offset && pos < span.byte_end)
555 }
556
557 pub fn line_info(&self, line_num: usize) -> Option<&LineInfo> {
559 if line_num > 0 {
560 self.lines.get(line_num - 1)
561 } else {
562 None
563 }
564 }
565
566 pub fn line_to_byte_offset(&self, line_num: usize) -> Option<usize> {
568 self.line_info(line_num).map(|info| info.byte_offset)
569 }
570
571 pub fn get_reference_url(&self, ref_id: &str) -> Option<&str> {
573 let normalized_id = ref_id.to_lowercase();
574 self.reference_defs_map
575 .get(&normalized_id)
576 .map(|&idx| self.reference_defs[idx].url.as_str())
577 }
578
579 pub fn get_reference_def(&self, ref_id: &str) -> Option<&ReferenceDef> {
581 let normalized_id = ref_id.to_lowercase();
582 self.reference_defs_map
583 .get(&normalized_id)
584 .map(|&idx| &self.reference_defs[idx])
585 }
586
587 pub fn has_reference_def(&self, ref_id: &str) -> bool {
589 let normalized_id = ref_id.to_lowercase();
590 self.reference_defs_map.contains_key(&normalized_id)
591 }
592
593 pub fn is_in_list_block(&self, line_num: usize) -> bool {
595 self.list_blocks
596 .iter()
597 .any(|block| line_num >= block.start_line && line_num <= block.end_line)
598 }
599
600 pub fn list_block_for_line(&self, line_num: usize) -> Option<&ListBlock> {
602 self.list_blocks
603 .iter()
604 .find(|block| line_num >= block.start_line && line_num <= block.end_line)
605 }
606
607 pub fn is_in_code_block(&self, line_num: usize) -> bool {
611 if line_num == 0 || line_num > self.lines.len() {
612 return false;
613 }
614 self.lines[line_num - 1].in_code_block
615 }
616
617 pub fn is_in_front_matter(&self, line_num: usize) -> bool {
619 if line_num == 0 || line_num > self.lines.len() {
620 return false;
621 }
622 self.lines[line_num - 1].in_front_matter
623 }
624
625 pub fn is_in_html_block(&self, line_num: usize) -> bool {
627 if line_num == 0 || line_num > self.lines.len() {
628 return false;
629 }
630 self.lines[line_num - 1].in_html_block
631 }
632
633 pub fn is_in_code_span(&self, line_num: usize, col: usize) -> bool {
635 if line_num == 0 || line_num > self.lines.len() {
636 return false;
637 }
638
639 let col_0indexed = if col > 0 { col - 1 } else { 0 };
643 let code_spans = self.code_spans();
644 code_spans.iter().any(|span| {
645 if line_num < span.line || line_num > span.end_line {
647 return false;
648 }
649
650 if span.line == span.end_line {
651 col_0indexed >= span.start_col && col_0indexed < span.end_col
653 } else if line_num == span.line {
654 col_0indexed >= span.start_col
656 } else if line_num == span.end_line {
657 col_0indexed < span.end_col
659 } else {
660 true
662 }
663 })
664 }
665
666 #[inline]
668 pub fn is_byte_offset_in_code_span(&self, byte_offset: usize) -> bool {
669 let code_spans = self.code_spans();
670 code_spans
671 .iter()
672 .any(|span| byte_offset >= span.byte_offset && byte_offset < span.byte_end)
673 }
674
675 #[inline]
677 pub fn is_in_reference_def(&self, byte_pos: usize) -> bool {
678 self.reference_defs
679 .iter()
680 .any(|ref_def| byte_pos >= ref_def.byte_offset && byte_pos < ref_def.byte_end)
681 }
682
683 #[inline]
685 pub fn is_in_html_comment(&self, byte_pos: usize) -> bool {
686 self.html_comment_ranges
687 .iter()
688 .any(|range| byte_pos >= range.start && byte_pos < range.end)
689 }
690
691 #[inline]
694 pub fn is_in_html_tag(&self, byte_pos: usize) -> bool {
695 self.html_tags()
696 .iter()
697 .any(|tag| byte_pos >= tag.byte_offset && byte_pos < tag.byte_end)
698 }
699
700 pub fn is_in_jinja_range(&self, byte_pos: usize) -> bool {
702 self.jinja_ranges
703 .iter()
704 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
705 }
706
707 #[inline]
709 pub fn is_in_jsx_expression(&self, byte_pos: usize) -> bool {
710 self.jsx_expression_ranges
711 .iter()
712 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
713 }
714
715 #[inline]
717 pub fn is_in_mdx_comment(&self, byte_pos: usize) -> bool {
718 self.mdx_comment_ranges
719 .iter()
720 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
721 }
722
723 pub fn jsx_expression_ranges(&self) -> &[(usize, usize)] {
725 &self.jsx_expression_ranges
726 }
727
728 pub fn mdx_comment_ranges(&self) -> &[(usize, usize)] {
730 &self.mdx_comment_ranges
731 }
732
733 #[inline]
736 pub fn is_in_citation(&self, byte_pos: usize) -> bool {
737 self.citation_ranges
738 .iter()
739 .any(|range| byte_pos >= range.start && byte_pos < range.end)
740 }
741
742 pub fn citation_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
744 &self.citation_ranges
745 }
746
747 #[inline]
749 pub fn is_in_shortcode(&self, byte_pos: usize) -> bool {
750 self.shortcode_ranges
751 .iter()
752 .any(|(start, end)| byte_pos >= *start && byte_pos < *end)
753 }
754
755 pub fn shortcode_ranges(&self) -> &[(usize, usize)] {
757 &self.shortcode_ranges
758 }
759
760 pub fn is_in_link_title(&self, byte_pos: usize) -> bool {
762 self.reference_defs.iter().any(|def| {
763 if let (Some(start), Some(end)) = (def.title_byte_start, def.title_byte_end) {
764 byte_pos >= start && byte_pos < end
765 } else {
766 false
767 }
768 })
769 }
770
771 pub fn has_char(&self, ch: char) -> bool {
773 match ch {
774 '#' => self.char_frequency.hash_count > 0,
775 '*' => self.char_frequency.asterisk_count > 0,
776 '_' => self.char_frequency.underscore_count > 0,
777 '-' => self.char_frequency.hyphen_count > 0,
778 '+' => self.char_frequency.plus_count > 0,
779 '>' => self.char_frequency.gt_count > 0,
780 '|' => self.char_frequency.pipe_count > 0,
781 '[' => self.char_frequency.bracket_count > 0,
782 '`' => self.char_frequency.backtick_count > 0,
783 '<' => self.char_frequency.lt_count > 0,
784 '!' => self.char_frequency.exclamation_count > 0,
785 '\n' => self.char_frequency.newline_count > 0,
786 _ => self.content.contains(ch), }
788 }
789
790 pub fn char_count(&self, ch: char) -> usize {
792 match ch {
793 '#' => self.char_frequency.hash_count,
794 '*' => self.char_frequency.asterisk_count,
795 '_' => self.char_frequency.underscore_count,
796 '-' => self.char_frequency.hyphen_count,
797 '+' => self.char_frequency.plus_count,
798 '>' => self.char_frequency.gt_count,
799 '|' => self.char_frequency.pipe_count,
800 '[' => self.char_frequency.bracket_count,
801 '`' => self.char_frequency.backtick_count,
802 '<' => self.char_frequency.lt_count,
803 '!' => self.char_frequency.exclamation_count,
804 '\n' => self.char_frequency.newline_count,
805 _ => self.content.matches(ch).count(), }
807 }
808
809 pub fn likely_has_headings(&self) -> bool {
811 self.char_frequency.hash_count > 0 || self.char_frequency.hyphen_count > 2 }
813
814 pub fn likely_has_lists(&self) -> bool {
816 self.char_frequency.asterisk_count > 0
817 || self.char_frequency.hyphen_count > 0
818 || self.char_frequency.plus_count > 0
819 }
820
821 pub fn likely_has_emphasis(&self) -> bool {
823 self.char_frequency.asterisk_count > 1 || self.char_frequency.underscore_count > 1
824 }
825
826 pub fn likely_has_tables(&self) -> bool {
828 self.char_frequency.pipe_count > 2
829 }
830
831 pub fn likely_has_blockquotes(&self) -> bool {
833 self.char_frequency.gt_count > 0
834 }
835
836 pub fn likely_has_code(&self) -> bool {
838 self.char_frequency.backtick_count > 0
839 }
840
841 pub fn likely_has_links_or_images(&self) -> bool {
843 self.char_frequency.bracket_count > 0 || self.char_frequency.exclamation_count > 0
844 }
845
846 pub fn likely_has_html(&self) -> bool {
848 self.char_frequency.lt_count > 0
849 }
850
851 pub fn blockquote_prefix_for_blank_line(&self, line_idx: usize) -> String {
856 if let Some(line_info) = self.lines.get(line_idx)
857 && let Some(ref bq) = line_info.blockquote
858 {
859 bq.prefix.trim_end().to_string()
860 } else {
861 String::new()
862 }
863 }
864
865 pub fn html_tags_on_line(&self, line_num: usize) -> Vec<HtmlTag> {
867 self.html_tags()
868 .iter()
869 .filter(|tag| tag.line == line_num)
870 .cloned()
871 .collect()
872 }
873
874 pub fn emphasis_spans_on_line(&self, line_num: usize) -> Vec<EmphasisSpan> {
876 self.emphasis_spans()
877 .iter()
878 .filter(|span| span.line == line_num)
879 .cloned()
880 .collect()
881 }
882
883 pub fn table_rows_on_line(&self, line_num: usize) -> Vec<TableRow> {
885 self.table_rows()
886 .iter()
887 .filter(|row| row.line == line_num)
888 .cloned()
889 .collect()
890 }
891
892 pub fn bare_urls_on_line(&self, line_num: usize) -> Vec<BareUrl> {
894 self.bare_urls()
895 .iter()
896 .filter(|url| url.line == line_num)
897 .cloned()
898 .collect()
899 }
900
901 #[inline]
907 fn find_line_for_offset(lines: &[LineInfo], byte_offset: usize) -> (usize, usize, usize) {
908 let idx = match lines.binary_search_by(|line| {
910 if byte_offset < line.byte_offset {
911 std::cmp::Ordering::Greater
912 } else if byte_offset > line.byte_offset + line.byte_len {
913 std::cmp::Ordering::Less
914 } else {
915 std::cmp::Ordering::Equal
916 }
917 }) {
918 Ok(idx) => idx,
919 Err(idx) => idx.saturating_sub(1),
920 };
921
922 let line = &lines[idx];
923 let line_num = idx + 1;
924 let col = byte_offset.saturating_sub(line.byte_offset);
925
926 (idx, line_num, col)
927 }
928
929 #[inline]
931 fn is_offset_in_code_span(code_spans: &[CodeSpan], offset: usize) -> bool {
932 let idx = code_spans.partition_point(|span| span.byte_offset <= offset);
934
935 if idx > 0 {
937 let span = &code_spans[idx - 1];
938 if offset >= span.byte_offset && offset < span.byte_end {
939 return true;
940 }
941 }
942
943 false
944 }
945
946 #[must_use]
966 pub fn valid_headings(&self) -> ValidHeadingsIter<'_> {
967 ValidHeadingsIter::new(&self.lines)
968 }
969
970 #[must_use]
974 pub fn has_valid_headings(&self) -> bool {
975 self.lines
976 .iter()
977 .any(|line| line.heading.as_ref().is_some_and(|h| h.is_valid))
978 }
979}