1use crate::config::MarkdownFlavor;
7use crate::lint_context::LintContext;
8use crate::utils::mkdocs_admonitions;
9use crate::utils::mkdocs_critic;
10use crate::utils::mkdocs_extensions;
11use crate::utils::mkdocs_footnotes;
12use crate::utils::mkdocs_icons;
13use crate::utils::mkdocs_snippets;
14use crate::utils::mkdocs_tabs;
15use crate::utils::regex_cache::HTML_COMMENT_PATTERN;
16use regex::Regex;
17use std::sync::LazyLock;
18
19static INLINE_MATH_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\$[^$]*\$\$|\$[^$\n]*\$").unwrap());
28
29#[derive(Debug, Clone, Copy)]
31pub struct ByteRange {
32 pub start: usize,
33 pub end: usize,
34}
35
36pub fn compute_html_comment_ranges(content: &str) -> Vec<ByteRange> {
39 HTML_COMMENT_PATTERN
40 .find_iter(content)
41 .map(|m| ByteRange {
42 start: m.start(),
43 end: m.end(),
44 })
45 .collect()
46}
47
48pub fn is_in_html_comment_ranges(ranges: &[ByteRange], byte_pos: usize) -> bool {
51 ranges
53 .binary_search_by(|range| {
54 if byte_pos < range.start {
55 std::cmp::Ordering::Greater
56 } else if byte_pos >= range.end {
57 std::cmp::Ordering::Less
58 } else {
59 std::cmp::Ordering::Equal
60 }
61 })
62 .is_ok()
63}
64
65pub fn is_line_entirely_in_html_comment(ranges: &[ByteRange], line_start: usize, line_end: usize) -> bool {
68 for range in ranges {
69 if line_start >= range.start && line_start < range.end {
71 return line_end <= range.end;
72 }
73 }
74 false
75}
76
77#[inline]
79pub fn is_in_jsx_expression(ctx: &LintContext, byte_pos: usize) -> bool {
80 ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_jsx_expression(byte_pos)
81}
82
83#[inline]
85pub fn is_in_mdx_comment(ctx: &LintContext, byte_pos: usize) -> bool {
86 ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_mdx_comment(byte_pos)
87}
88
89pub fn is_mkdocs_snippet_line(line: &str, flavor: MarkdownFlavor) -> bool {
91 flavor == MarkdownFlavor::MkDocs && mkdocs_snippets::is_snippet_marker(line)
92}
93
94pub fn is_mkdocs_admonition_line(line: &str, flavor: MarkdownFlavor) -> bool {
96 flavor == MarkdownFlavor::MkDocs && mkdocs_admonitions::is_admonition_marker(line)
97}
98
99pub fn is_mkdocs_footnote_line(line: &str, flavor: MarkdownFlavor) -> bool {
101 flavor == MarkdownFlavor::MkDocs && mkdocs_footnotes::is_footnote_definition(line)
102}
103
104pub fn is_mkdocs_tab_line(line: &str, flavor: MarkdownFlavor) -> bool {
106 flavor == MarkdownFlavor::MkDocs && mkdocs_tabs::is_tab_marker(line)
107}
108
109pub fn is_mkdocs_critic_line(line: &str, flavor: MarkdownFlavor) -> bool {
111 flavor == MarkdownFlavor::MkDocs && mkdocs_critic::contains_critic_markup(line)
112}
113
114pub fn is_in_html_comment(content: &str, byte_pos: usize) -> bool {
116 for m in HTML_COMMENT_PATTERN.find_iter(content) {
117 if m.start() <= byte_pos && byte_pos < m.end() {
118 return true;
119 }
120 }
121 false
122}
123
124pub fn is_in_html_tag(ctx: &LintContext, byte_pos: usize) -> bool {
126 for html_tag in ctx.html_tags().iter() {
127 if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
128 return true;
129 }
130 }
131 false
132}
133
134pub fn is_in_math_context(ctx: &LintContext, byte_pos: usize) -> bool {
141 math_byte_ranges(ctx.content)
142 .iter()
143 .any(|&(start, end)| byte_pos >= start && byte_pos < end)
144}
145
146pub(crate) fn math_block_ranges(content: &str) -> Vec<(usize, usize)> {
157 let bytes = content.as_bytes();
158 let mut ranges = Vec::new();
159 let mut open: Option<usize> = None;
160 let mut line_start = 0usize;
161 let mut i = 0;
162 while i < bytes.len() {
163 match bytes[i] {
164 b'\n' => {
165 line_start = i + 1;
166 i += 1;
167 }
168 b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'$' => {
169 match open {
170 None => {
171 let starts_line = bytes[line_start..i]
174 .iter()
175 .all(|&b| b == b' ' || b == b'\t' || b == b'>');
176 if starts_line {
177 open = Some(i);
178 }
179 }
180 Some(start) => {
181 ranges.push((start, i + 2));
182 open = None;
183 }
184 }
185 i += 2;
186 }
187 _ => i += 1,
188 }
189 }
190 ranges
191}
192
193pub fn is_in_math_block(content: &str, byte_pos: usize) -> bool {
200 math_block_ranges(content)
201 .iter()
202 .any(|&(start, end)| byte_pos >= start && byte_pos < end)
203}
204
205pub fn is_in_inline_math(content: &str, byte_pos: usize) -> bool {
214 for m in INLINE_MATH_REGEX.find_iter(content) {
215 if content[m.start()..m.end()].starts_with("$$") {
216 continue;
217 }
218 if m.start() <= byte_pos && byte_pos < m.end() {
219 return true;
220 }
221 }
222 false
223}
224
225pub fn math_byte_ranges(content: &str) -> Vec<(usize, usize)> {
233 let mut ranges = math_block_ranges(content);
234 for m in INLINE_MATH_REGEX.find_iter(content) {
235 if content[m.start()..m.end()].starts_with("$$") {
236 continue;
237 }
238 ranges.push((m.start(), m.end()));
239 }
240 ranges
241}
242
243pub fn is_in_table_cell(ctx: &LintContext, line_num: usize, _col: usize) -> bool {
245 for table_row in ctx.table_rows().iter() {
247 if table_row.line == line_num {
248 return true;
252 }
253 }
254 false
255}
256
257pub fn is_table_line(line: &str) -> bool {
259 let trimmed = line.trim();
260
261 if trimmed
263 .chars()
264 .all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
265 && trimmed.contains('|')
266 && trimmed.contains('-')
267 {
268 return true;
269 }
270
271 if (trimmed.starts_with('|') || trimmed.ends_with('|')) && trimmed.matches('|').count() >= 2 {
273 return true;
274 }
275
276 false
277}
278
279pub fn is_in_icon_shortcode(line: &str, position: usize, _flavor: MarkdownFlavor) -> bool {
282 mkdocs_icons::is_in_any_shortcode(line, position)
285}
286
287pub fn is_in_pymdown_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
293 match flavor {
294 MarkdownFlavor::MkDocs => mkdocs_extensions::is_in_pymdown_markup(line, position),
295 MarkdownFlavor::Obsidian => {
296 mkdocs_extensions::is_in_mark(line, position)
298 }
299 _ => false,
300 }
301}
302
303pub fn is_in_inline_html_code(line: &str, position: usize) -> bool {
308 const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
310
311 let bytes = line.as_bytes();
312
313 for tag in TAGS {
314 let open_bytes = format!("<{tag}").into_bytes();
315 let close_pattern = format!("</{tag}>").into_bytes();
316
317 let mut search_from = 0;
318 while search_from + open_bytes.len() <= bytes.len() {
319 let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
321 break;
322 };
323
324 let after_tag = open_abs + open_bytes.len();
325
326 if after_tag < bytes.len() {
328 let next = bytes[after_tag];
329 if next != b'>' && next != b' ' && next != b'\t' {
330 search_from = after_tag;
331 continue;
332 }
333 }
334
335 let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
337 break;
338 };
339 let content_start = after_tag + tag_close + 1;
340
341 let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
343 break;
344 };
345 let content_end = close_start;
346
347 if position >= content_start && position < content_end {
348 return true;
349 }
350
351 search_from = close_start + close_pattern.len();
352 }
353 }
354 false
355}
356
357fn find_case_insensitive(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
359 if needle.is_empty() || from + needle.len() > haystack.len() {
360 return None;
361 }
362 for i in from..=haystack.len() - needle.len() {
363 if haystack[i..i + needle.len()]
364 .iter()
365 .zip(needle.iter())
366 .all(|(h, n)| h.eq_ignore_ascii_case(n))
367 {
368 return Some(i);
369 }
370 }
371 None
372}
373
374pub fn is_in_mkdocs_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
378 if is_in_icon_shortcode(line, position, flavor) {
379 return true;
380 }
381 if is_in_pymdown_markup(line, position, flavor) {
382 return true;
383 }
384 false
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn test_html_comment_detection() {
393 let content = "Text <!-- comment --> more text";
394 assert!(is_in_html_comment(content, 10)); assert!(!is_in_html_comment(content, 0)); assert!(!is_in_html_comment(content, 25)); }
398
399 #[test]
400 fn test_is_line_entirely_in_html_comment() {
401 let content = "<!--\ncomment\n--> Content after comment";
403 let ranges = compute_html_comment_ranges(content);
404 assert!(is_line_entirely_in_html_comment(&ranges, 0, 4));
406 assert!(is_line_entirely_in_html_comment(&ranges, 5, 12));
408 assert!(!is_line_entirely_in_html_comment(&ranges, 13, 38));
410
411 let content2 = "<!-- comment --> Not a comment";
413 let ranges2 = compute_html_comment_ranges(content2);
414 assert!(!is_line_entirely_in_html_comment(&ranges2, 0, 30));
416
417 let content3 = "<!-- comment -->";
419 let ranges3 = compute_html_comment_ranges(content3);
420 assert!(is_line_entirely_in_html_comment(&ranges3, 0, 16));
422
423 let content4 = "Text before <!-- comment -->";
425 let ranges4 = compute_html_comment_ranges(content4);
426 assert!(!is_line_entirely_in_html_comment(&ranges4, 0, 28));
428 }
429
430 #[test]
431 fn test_math_block_detection() {
432 let content = "Text\n$$\nmath content\n$$\nmore text";
433 assert!(is_in_math_block(content, 8)); assert!(is_in_math_block(content, 15)); assert!(!is_in_math_block(content, 0)); assert!(!is_in_math_block(content, 30)); }
438
439 #[test]
440 fn test_stray_double_dollar_in_prose_is_not_math() {
441 let content = "Note: $$ is used for display math and $$ closes it";
445 let between = content.find("is used").unwrap();
446 assert!(
447 !is_in_math_block(content, between),
448 "stray paired `$$` in prose must not be treated as a math block"
449 );
450 assert!(math_block_ranges(content).is_empty());
451 }
452
453 #[test]
454 fn test_blockquoted_double_dollar_opens_block() {
455 let content = "> $$\n> x = y\n> $$\n";
457 let inside = content.find("x = y").unwrap();
458 assert!(is_in_math_block(content, inside), "blockquoted math interior");
459 }
460
461 #[test]
462 fn test_self_contained_single_line_block_leaves_trailing_prose() {
463 let content = "$$ a $$ and __not math__\n";
465 let in_math = content.find('a').unwrap();
466 assert!(is_in_math_block(content, in_math), "single-line math interior");
467 let after = content.find("not math").unwrap();
468 assert!(!is_in_math_block(content, after), "trailing prose is lintable");
469 }
470
471 #[test]
472 fn test_math_block_closes_with_content_before_fence() {
473 let content = "$$\nx = y\n\\end{x}$$\nafter __text__ here";
477
478 let inside = content.find("x = y").unwrap();
479 assert!(is_in_math_block(content, inside), "interior must be math");
480
481 let after = content.find("after").unwrap();
482 assert!(
483 !is_in_math_block(content, after),
484 "content after a content-sharing closing fence must NOT be math"
485 );
486 }
487
488 #[test]
489 fn test_inline_math_detection() {
490 let content = "Text $x + y$ and $$a^2 + b^2$$ here";
491 assert!(is_in_inline_math(content, 7), "inside the single-`$` inline span");
492 assert!(!is_in_inline_math(content, 20), "mid-line $$...$$ is not inline math");
496 assert!(
497 !is_in_math_block(content, 20),
498 "mid-line $$...$$ is not a line-start display block"
499 );
500 assert!(!is_in_inline_math(content, 0), "before any math");
501 assert!(!is_in_inline_math(content, 35), "after the spans");
502 }
503
504 #[test]
505 fn test_table_line_detection() {
506 assert!(is_table_line("| Header | Column |"));
507 assert!(is_table_line("|--------|--------|"));
508 assert!(is_table_line("| Cell 1 | Cell 2 |"));
509 assert!(!is_table_line("Regular text"));
510 assert!(!is_table_line("Just a pipe | here"));
511 }
512
513 #[test]
514 fn test_is_in_icon_shortcode() {
515 let line = "Click :material-check: to confirm";
516 assert!(!is_in_icon_shortcode(line, 0, MarkdownFlavor::MkDocs));
518 assert!(is_in_icon_shortcode(line, 6, MarkdownFlavor::MkDocs));
520 assert!(is_in_icon_shortcode(line, 15, MarkdownFlavor::MkDocs));
521 assert!(is_in_icon_shortcode(line, 21, MarkdownFlavor::MkDocs));
522 assert!(!is_in_icon_shortcode(line, 22, MarkdownFlavor::MkDocs));
524 }
525
526 #[test]
527 fn test_is_in_pymdown_markup() {
528 let line = "Press ++ctrl+c++ to copy";
530 assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::MkDocs));
531 assert!(is_in_pymdown_markup(line, 6, MarkdownFlavor::MkDocs));
532 assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::MkDocs));
533 assert!(!is_in_pymdown_markup(line, 17, MarkdownFlavor::MkDocs));
534
535 let line2 = "This is ==highlighted== text";
537 assert!(!is_in_pymdown_markup(line2, 0, MarkdownFlavor::MkDocs));
538 assert!(is_in_pymdown_markup(line2, 8, MarkdownFlavor::MkDocs));
539 assert!(is_in_pymdown_markup(line2, 15, MarkdownFlavor::MkDocs));
540 assert!(!is_in_pymdown_markup(line2, 23, MarkdownFlavor::MkDocs));
541
542 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Standard));
544 }
545
546 #[test]
547 fn test_is_in_mkdocs_markup() {
548 let line = ":material-check: and ++ctrl++";
550 assert!(is_in_mkdocs_markup(line, 5, MarkdownFlavor::MkDocs)); assert!(is_in_mkdocs_markup(line, 23, MarkdownFlavor::MkDocs)); assert!(!is_in_mkdocs_markup(line, 17, MarkdownFlavor::MkDocs)); }
554
555 #[test]
558 fn test_obsidian_highlight_basic() {
559 let line = "This is ==highlighted== text";
561 assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 22, MarkdownFlavor::Obsidian)); assert!(!is_in_pymdown_markup(line, 23, MarkdownFlavor::Obsidian)); }
568
569 #[test]
570 fn test_obsidian_highlight_multiple() {
571 let line = "Both ==one== and ==two== here";
573 assert!(is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); assert!(!is_in_pymdown_markup(line, 12, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 17, MarkdownFlavor::Obsidian)); }
578
579 #[test]
580 fn test_obsidian_highlight_not_standard_flavor() {
581 let line = "This is ==highlighted== text";
583 assert!(!is_in_pymdown_markup(line, 8, MarkdownFlavor::Standard));
584 assert!(!is_in_pymdown_markup(line, 15, MarkdownFlavor::Standard));
585 }
586
587 #[test]
588 fn test_obsidian_highlight_with_spaces_inside() {
589 let line = "This is ==text with spaces== here";
591 assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 27, MarkdownFlavor::Obsidian)); }
595
596 #[test]
597 fn test_obsidian_does_not_support_keys_notation() {
598 let line = "Press ++ctrl+c++ to copy";
600 assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
601 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
602 }
603
604 #[test]
605 fn test_obsidian_mkdocs_markup_function() {
606 let line = "This is ==highlighted== text";
608 assert!(is_in_mkdocs_markup(line, 10, MarkdownFlavor::Obsidian)); assert!(!is_in_mkdocs_markup(line, 0, MarkdownFlavor::Obsidian)); }
611
612 #[test]
613 fn test_obsidian_highlight_edge_cases() {
614 let line = "Test ==== here";
616 assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
618
619 let line2 = "Test ==a== here";
621 assert!(is_in_pymdown_markup(line2, 5, MarkdownFlavor::Obsidian));
622 assert!(is_in_pymdown_markup(line2, 7, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line2, 9, MarkdownFlavor::Obsidian)); let line3 = "a === b";
627 assert!(!is_in_pymdown_markup(line3, 3, MarkdownFlavor::Obsidian));
628 }
629
630 #[test]
631 fn test_obsidian_highlight_unclosed() {
632 let line = "This ==starts but never ends";
634 assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian));
635 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
636 }
637
638 #[test]
639 fn test_inline_html_code_basic() {
640 let line = "The formula is <code>a * b * c</code> in math.";
641 assert!(is_in_inline_html_code(line, 21)); assert!(is_in_inline_html_code(line, 25)); assert!(!is_in_inline_html_code(line, 0)); assert!(!is_in_inline_html_code(line, 40)); }
648
649 #[test]
650 fn test_inline_html_code_multiple_tags() {
651 let line = "<kbd>Ctrl</kbd> + <samp>output</samp>";
652 assert!(is_in_inline_html_code(line, 5)); assert!(is_in_inline_html_code(line, 24)); assert!(!is_in_inline_html_code(line, 16)); }
656
657 #[test]
658 fn test_inline_html_code_with_attributes() {
659 let line = r#"<code class="lang">x * y</code>"#;
660 assert!(is_in_inline_html_code(line, 19)); assert!(is_in_inline_html_code(line, 23)); assert!(!is_in_inline_html_code(line, 0)); }
664
665 #[test]
666 fn test_inline_html_code_case_insensitive() {
667 let line = "<CODE>a * b</CODE>";
668 assert!(is_in_inline_html_code(line, 6)); assert!(is_in_inline_html_code(line, 8)); }
671
672 #[test]
673 fn test_inline_html_code_var_and_pre() {
674 let line = "<var>x * y</var> and <pre>a * b</pre>";
675 assert!(is_in_inline_html_code(line, 5)); assert!(is_in_inline_html_code(line, 26)); assert!(!is_in_inline_html_code(line, 17)); }
679
680 #[test]
681 fn test_inline_html_code_unclosed() {
682 let line = "<code>a * b without closing";
684 assert!(!is_in_inline_html_code(line, 6));
685 }
686
687 #[test]
688 fn test_inline_html_code_no_substring_match() {
689 let line = "<variable>a * b</variable>";
691 assert!(!is_in_inline_html_code(line, 11));
692
693 let line2 = "<keyboard>x * y</keyboard>";
695 assert!(!is_in_inline_html_code(line2, 11));
696 }
697}