1use crate::config::MarkdownFlavor;
7use crate::lint_context::LintContext;
8use crate::utils::kramdown_utils::is_math_block_delimiter;
9use crate::utils::mkdocs_admonitions;
10use crate::utils::mkdocs_critic;
11use crate::utils::mkdocs_extensions;
12use crate::utils::mkdocs_footnotes;
13use crate::utils::mkdocs_icons;
14use crate::utils::mkdocs_snippets;
15use crate::utils::mkdocs_tabs;
16use crate::utils::regex_cache::HTML_COMMENT_PATTERN;
17use regex::Regex;
18use std::sync::LazyLock;
19
20static INLINE_MATH_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\$[^$]*\$\$|\$[^$\n]*\$").unwrap());
29
30#[derive(Debug, Clone, Copy)]
32pub struct ByteRange {
33 pub start: usize,
34 pub end: usize,
35}
36
37pub fn compute_html_comment_ranges(content: &str) -> Vec<ByteRange> {
40 HTML_COMMENT_PATTERN
41 .find_iter(content)
42 .map(|m| ByteRange {
43 start: m.start(),
44 end: m.end(),
45 })
46 .collect()
47}
48
49pub fn is_in_html_comment_ranges(ranges: &[ByteRange], byte_pos: usize) -> bool {
52 ranges
54 .binary_search_by(|range| {
55 if byte_pos < range.start {
56 std::cmp::Ordering::Greater
57 } else if byte_pos >= range.end {
58 std::cmp::Ordering::Less
59 } else {
60 std::cmp::Ordering::Equal
61 }
62 })
63 .is_ok()
64}
65
66pub fn is_line_entirely_in_html_comment(ranges: &[ByteRange], line_start: usize, line_end: usize) -> bool {
69 for range in ranges {
70 if line_start >= range.start && line_start < range.end {
72 return line_end <= range.end;
73 }
74 }
75 false
76}
77
78pub fn is_in_front_matter(content: &str, line_num: usize) -> bool {
80 let lines: Vec<&str> = content.lines().collect();
81
82 if !lines.is_empty() && lines[0] == "---" {
84 for (i, line) in lines.iter().enumerate().skip(1) {
85 if *line == "---" {
86 return line_num <= i;
87 }
88 }
89 }
90
91 if !lines.is_empty() && lines[0] == "+++" {
93 for (i, line) in lines.iter().enumerate().skip(1) {
94 if *line == "+++" {
95 return line_num <= i;
96 }
97 }
98 }
99
100 false
101}
102
103#[inline]
105pub fn is_in_jsx_expression(ctx: &LintContext, byte_pos: usize) -> bool {
106 ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_jsx_expression(byte_pos)
107}
108
109#[inline]
111pub fn is_in_mdx_comment(ctx: &LintContext, byte_pos: usize) -> bool {
112 ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_mdx_comment(byte_pos)
113}
114
115pub fn is_mkdocs_snippet_line(line: &str, flavor: MarkdownFlavor) -> bool {
117 flavor == MarkdownFlavor::MkDocs && mkdocs_snippets::is_snippet_marker(line)
118}
119
120pub fn is_mkdocs_admonition_line(line: &str, flavor: MarkdownFlavor) -> bool {
122 flavor == MarkdownFlavor::MkDocs && mkdocs_admonitions::is_admonition_marker(line)
123}
124
125pub fn is_mkdocs_footnote_line(line: &str, flavor: MarkdownFlavor) -> bool {
127 flavor == MarkdownFlavor::MkDocs && mkdocs_footnotes::is_footnote_definition(line)
128}
129
130pub fn is_mkdocs_tab_line(line: &str, flavor: MarkdownFlavor) -> bool {
132 flavor == MarkdownFlavor::MkDocs && mkdocs_tabs::is_tab_marker(line)
133}
134
135pub fn is_mkdocs_critic_line(line: &str, flavor: MarkdownFlavor) -> bool {
137 flavor == MarkdownFlavor::MkDocs && mkdocs_critic::contains_critic_markup(line)
138}
139
140pub fn is_in_html_comment(content: &str, byte_pos: usize) -> bool {
142 for m in HTML_COMMENT_PATTERN.find_iter(content) {
143 if m.start() <= byte_pos && byte_pos < m.end() {
144 return true;
145 }
146 }
147 false
148}
149
150pub fn is_in_html_tag(ctx: &LintContext, byte_pos: usize) -> bool {
152 for html_tag in ctx.html_tags().iter() {
153 if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
154 return true;
155 }
156 }
157 false
158}
159
160pub fn is_in_math_context(ctx: &LintContext, byte_pos: usize) -> bool {
162 let content = ctx.content;
163
164 if is_in_math_block(content, byte_pos) {
166 return true;
167 }
168
169 if is_in_inline_math(content, byte_pos) {
171 return true;
172 }
173
174 false
175}
176
177pub fn is_in_math_block(content: &str, byte_pos: usize) -> bool {
179 let mut in_math_block = false;
180 let mut current_pos = 0;
181
182 for line in content.lines() {
183 let line_start = current_pos;
184 let line_end = current_pos + line.len();
185
186 if is_math_block_delimiter(line) {
188 if byte_pos >= line_start && byte_pos <= line_end {
189 return true;
191 }
192 in_math_block = !in_math_block;
193 } else if in_math_block && byte_pos >= line_start && byte_pos <= line_end {
194 return true;
196 }
197
198 current_pos = line_end + 1; }
200
201 false
202}
203
204pub fn is_in_inline_math(content: &str, byte_pos: usize) -> bool {
206 for m in INLINE_MATH_REGEX.find_iter(content) {
208 if m.start() <= byte_pos && byte_pos < m.end() {
209 return true;
210 }
211 }
212 false
213}
214
215pub fn is_in_table_cell(ctx: &LintContext, line_num: usize, _col: usize) -> bool {
217 for table_row in ctx.table_rows().iter() {
219 if table_row.line == line_num {
220 return true;
224 }
225 }
226 false
227}
228
229pub fn is_table_line(line: &str) -> bool {
231 let trimmed = line.trim();
232
233 if trimmed
235 .chars()
236 .all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
237 && trimmed.contains('|')
238 && trimmed.contains('-')
239 {
240 return true;
241 }
242
243 if (trimmed.starts_with('|') || trimmed.ends_with('|')) && trimmed.matches('|').count() >= 2 {
245 return true;
246 }
247
248 false
249}
250
251pub fn is_in_icon_shortcode(line: &str, position: usize, _flavor: MarkdownFlavor) -> bool {
254 mkdocs_icons::is_in_any_shortcode(line, position)
257}
258
259pub fn is_in_pymdown_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
265 match flavor {
266 MarkdownFlavor::MkDocs => mkdocs_extensions::is_in_pymdown_markup(line, position),
267 MarkdownFlavor::Obsidian => {
268 mkdocs_extensions::is_in_mark(line, position)
270 }
271 _ => false,
272 }
273}
274
275pub fn is_in_inline_html_code(line: &str, position: usize) -> bool {
280 const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
282
283 let bytes = line.as_bytes();
284
285 for tag in TAGS {
286 let open_bytes = format!("<{tag}").into_bytes();
287 let close_pattern = format!("</{tag}>").into_bytes();
288
289 let mut search_from = 0;
290 while search_from + open_bytes.len() <= bytes.len() {
291 let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
293 break;
294 };
295
296 let after_tag = open_abs + open_bytes.len();
297
298 if after_tag < bytes.len() {
300 let next = bytes[after_tag];
301 if next != b'>' && next != b' ' && next != b'\t' {
302 search_from = after_tag;
303 continue;
304 }
305 }
306
307 let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
309 break;
310 };
311 let content_start = after_tag + tag_close + 1;
312
313 let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
315 break;
316 };
317 let content_end = close_start;
318
319 if position >= content_start && position < content_end {
320 return true;
321 }
322
323 search_from = close_start + close_pattern.len();
324 }
325 }
326 false
327}
328
329fn find_case_insensitive(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
331 if needle.is_empty() || from + needle.len() > haystack.len() {
332 return None;
333 }
334 for i in from..=haystack.len() - needle.len() {
335 if haystack[i..i + needle.len()]
336 .iter()
337 .zip(needle.iter())
338 .all(|(h, n)| h.eq_ignore_ascii_case(n))
339 {
340 return Some(i);
341 }
342 }
343 None
344}
345
346pub fn is_in_mkdocs_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
350 if is_in_icon_shortcode(line, position, flavor) {
351 return true;
352 }
353 if is_in_pymdown_markup(line, position, flavor) {
354 return true;
355 }
356 false
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn test_html_comment_detection() {
365 let content = "Text <!-- comment --> more text";
366 assert!(is_in_html_comment(content, 10)); assert!(!is_in_html_comment(content, 0)); assert!(!is_in_html_comment(content, 25)); }
370
371 #[test]
372 fn test_is_line_entirely_in_html_comment() {
373 let content = "<!--\ncomment\n--> Content after comment";
375 let ranges = compute_html_comment_ranges(content);
376 assert!(is_line_entirely_in_html_comment(&ranges, 0, 4));
378 assert!(is_line_entirely_in_html_comment(&ranges, 5, 12));
380 assert!(!is_line_entirely_in_html_comment(&ranges, 13, 38));
382
383 let content2 = "<!-- comment --> Not a comment";
385 let ranges2 = compute_html_comment_ranges(content2);
386 assert!(!is_line_entirely_in_html_comment(&ranges2, 0, 30));
388
389 let content3 = "<!-- comment -->";
391 let ranges3 = compute_html_comment_ranges(content3);
392 assert!(is_line_entirely_in_html_comment(&ranges3, 0, 16));
394
395 let content4 = "Text before <!-- comment -->";
397 let ranges4 = compute_html_comment_ranges(content4);
398 assert!(!is_line_entirely_in_html_comment(&ranges4, 0, 28));
400 }
401
402 #[test]
403 fn test_math_block_detection() {
404 let content = "Text\n$$\nmath content\n$$\nmore text";
405 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)); }
410
411 #[test]
412 fn test_inline_math_detection() {
413 let content = "Text $x + y$ and $$a^2 + b^2$$ here";
414 assert!(is_in_inline_math(content, 7)); assert!(is_in_inline_math(content, 20)); assert!(!is_in_inline_math(content, 0)); assert!(!is_in_inline_math(content, 35)); }
419
420 #[test]
421 fn test_table_line_detection() {
422 assert!(is_table_line("| Header | Column |"));
423 assert!(is_table_line("|--------|--------|"));
424 assert!(is_table_line("| Cell 1 | Cell 2 |"));
425 assert!(!is_table_line("Regular text"));
426 assert!(!is_table_line("Just a pipe | here"));
427 }
428
429 #[test]
430 fn test_is_in_front_matter() {
431 let yaml_content = r#"---
433title: "My Post"
434tags: ["test", "example"]
435---
436
437# Content"#;
438
439 assert!(
440 is_in_front_matter(yaml_content, 0),
441 "Line 1 should be in YAML front matter"
442 );
443 assert!(
444 is_in_front_matter(yaml_content, 2),
445 "Line 3 should be in YAML front matter"
446 );
447 assert!(
448 is_in_front_matter(yaml_content, 3),
449 "Line 4 should be in YAML front matter"
450 );
451 assert!(
452 !is_in_front_matter(yaml_content, 4),
453 "Line 5 should NOT be in front matter"
454 );
455
456 let toml_content = r#"+++
458title = "My Post"
459tags = ["test", "example"]
460+++
461
462# Content"#;
463
464 assert!(
465 is_in_front_matter(toml_content, 0),
466 "Line 1 should be in TOML front matter"
467 );
468 assert!(
469 is_in_front_matter(toml_content, 2),
470 "Line 3 should be in TOML front matter"
471 );
472 assert!(
473 is_in_front_matter(toml_content, 3),
474 "Line 4 should be in TOML front matter"
475 );
476 assert!(
477 !is_in_front_matter(toml_content, 4),
478 "Line 5 should NOT be in front matter"
479 );
480
481 let mixed_content = r#"# Content
483
484+++
485title = "Not frontmatter"
486+++
487
488More content"#;
489
490 assert!(
491 !is_in_front_matter(mixed_content, 2),
492 "TOML block not at beginning should NOT be front matter"
493 );
494 assert!(
495 !is_in_front_matter(mixed_content, 3),
496 "TOML block not at beginning should NOT be front matter"
497 );
498 assert!(
499 !is_in_front_matter(mixed_content, 4),
500 "TOML block not at beginning should NOT be front matter"
501 );
502 }
503
504 #[test]
505 fn test_is_in_icon_shortcode() {
506 let line = "Click :material-check: to confirm";
507 assert!(!is_in_icon_shortcode(line, 0, MarkdownFlavor::MkDocs));
509 assert!(is_in_icon_shortcode(line, 6, MarkdownFlavor::MkDocs));
511 assert!(is_in_icon_shortcode(line, 15, MarkdownFlavor::MkDocs));
512 assert!(is_in_icon_shortcode(line, 21, MarkdownFlavor::MkDocs));
513 assert!(!is_in_icon_shortcode(line, 22, MarkdownFlavor::MkDocs));
515 }
516
517 #[test]
518 fn test_is_in_pymdown_markup() {
519 let line = "Press ++ctrl+c++ to copy";
521 assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::MkDocs));
522 assert!(is_in_pymdown_markup(line, 6, MarkdownFlavor::MkDocs));
523 assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::MkDocs));
524 assert!(!is_in_pymdown_markup(line, 17, MarkdownFlavor::MkDocs));
525
526 let line2 = "This is ==highlighted== text";
528 assert!(!is_in_pymdown_markup(line2, 0, MarkdownFlavor::MkDocs));
529 assert!(is_in_pymdown_markup(line2, 8, MarkdownFlavor::MkDocs));
530 assert!(is_in_pymdown_markup(line2, 15, MarkdownFlavor::MkDocs));
531 assert!(!is_in_pymdown_markup(line2, 23, MarkdownFlavor::MkDocs));
532
533 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Standard));
535 }
536
537 #[test]
538 fn test_is_in_mkdocs_markup() {
539 let line = ":material-check: and ++ctrl++";
541 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)); }
545
546 #[test]
549 fn test_obsidian_highlight_basic() {
550 let line = "This is ==highlighted== text";
552 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)); }
559
560 #[test]
561 fn test_obsidian_highlight_multiple() {
562 let line = "Both ==one== and ==two== here";
564 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)); }
569
570 #[test]
571 fn test_obsidian_highlight_not_standard_flavor() {
572 let line = "This is ==highlighted== text";
574 assert!(!is_in_pymdown_markup(line, 8, MarkdownFlavor::Standard));
575 assert!(!is_in_pymdown_markup(line, 15, MarkdownFlavor::Standard));
576 }
577
578 #[test]
579 fn test_obsidian_highlight_with_spaces_inside() {
580 let line = "This is ==text with spaces== here";
582 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)); }
586
587 #[test]
588 fn test_obsidian_does_not_support_keys_notation() {
589 let line = "Press ++ctrl+c++ to copy";
591 assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
592 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
593 }
594
595 #[test]
596 fn test_obsidian_mkdocs_markup_function() {
597 let line = "This is ==highlighted== text";
599 assert!(is_in_mkdocs_markup(line, 10, MarkdownFlavor::Obsidian)); assert!(!is_in_mkdocs_markup(line, 0, MarkdownFlavor::Obsidian)); }
602
603 #[test]
604 fn test_obsidian_highlight_edge_cases() {
605 let line = "Test ==== here";
607 assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
609
610 let line2 = "Test ==a== here";
612 assert!(is_in_pymdown_markup(line2, 5, MarkdownFlavor::Obsidian));
613 assert!(is_in_pymdown_markup(line2, 7, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line2, 9, MarkdownFlavor::Obsidian)); let line3 = "a === b";
618 assert!(!is_in_pymdown_markup(line3, 3, MarkdownFlavor::Obsidian));
619 }
620
621 #[test]
622 fn test_obsidian_highlight_unclosed() {
623 let line = "This ==starts but never ends";
625 assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian));
626 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
627 }
628
629 #[test]
630 fn test_inline_html_code_basic() {
631 let line = "The formula is <code>a * b * c</code> in math.";
632 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)); }
639
640 #[test]
641 fn test_inline_html_code_multiple_tags() {
642 let line = "<kbd>Ctrl</kbd> + <samp>output</samp>";
643 assert!(is_in_inline_html_code(line, 5)); assert!(is_in_inline_html_code(line, 24)); assert!(!is_in_inline_html_code(line, 16)); }
647
648 #[test]
649 fn test_inline_html_code_with_attributes() {
650 let line = r#"<code class="lang">x * y</code>"#;
651 assert!(is_in_inline_html_code(line, 19)); assert!(is_in_inline_html_code(line, 23)); assert!(!is_in_inline_html_code(line, 0)); }
655
656 #[test]
657 fn test_inline_html_code_case_insensitive() {
658 let line = "<CODE>a * b</CODE>";
659 assert!(is_in_inline_html_code(line, 6)); assert!(is_in_inline_html_code(line, 8)); }
662
663 #[test]
664 fn test_inline_html_code_var_and_pre() {
665 let line = "<var>x * y</var> and <pre>a * b</pre>";
666 assert!(is_in_inline_html_code(line, 5)); assert!(is_in_inline_html_code(line, 26)); assert!(!is_in_inline_html_code(line, 17)); }
670
671 #[test]
672 fn test_inline_html_code_unclosed() {
673 let line = "<code>a * b without closing";
675 assert!(!is_in_inline_html_code(line, 6));
676 }
677
678 #[test]
679 fn test_inline_html_code_no_substring_match() {
680 let line = "<variable>a * b</variable>";
682 assert!(!is_in_inline_html_code(line, 11));
683
684 let line2 = "<keyboard>x * y</keyboard>";
686 assert!(!is_in_inline_html_code(line2, 11));
687 }
688}