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::mkdocstrings_refs;
17use crate::utils::regex_cache::HTML_COMMENT_PATTERN;
18use regex::Regex;
19use std::sync::LazyLock;
20
21static INLINE_MATH_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\$[^$]*\$\$|\$[^$\n]*\$").unwrap());
30
31#[derive(Debug, Clone, Copy)]
33pub struct ByteRange {
34 pub start: usize,
35 pub end: usize,
36}
37
38pub fn compute_html_comment_ranges(content: &str) -> Vec<ByteRange> {
41 HTML_COMMENT_PATTERN
42 .find_iter(content)
43 .map(|m| ByteRange {
44 start: m.start(),
45 end: m.end(),
46 })
47 .collect()
48}
49
50pub fn is_in_html_comment_ranges(ranges: &[ByteRange], byte_pos: usize) -> bool {
53 ranges
55 .binary_search_by(|range| {
56 if byte_pos < range.start {
57 std::cmp::Ordering::Greater
58 } else if byte_pos >= range.end {
59 std::cmp::Ordering::Less
60 } else {
61 std::cmp::Ordering::Equal
62 }
63 })
64 .is_ok()
65}
66
67pub fn is_line_entirely_in_html_comment(ranges: &[ByteRange], line_start: usize, line_end: usize) -> bool {
70 for range in ranges {
71 if line_start >= range.start && line_start < range.end {
73 return line_end <= range.end;
74 }
75 }
76 false
77}
78
79pub fn is_in_front_matter(content: &str, line_num: usize) -> bool {
81 let lines: Vec<&str> = content.lines().collect();
82
83 if !lines.is_empty() && lines[0] == "---" {
85 for (i, line) in lines.iter().enumerate().skip(1) {
86 if *line == "---" {
87 return line_num <= i;
88 }
89 }
90 }
91
92 if !lines.is_empty() && lines[0] == "+++" {
94 for (i, line) in lines.iter().enumerate().skip(1) {
95 if *line == "+++" {
96 return line_num <= i;
97 }
98 }
99 }
100
101 false
102}
103
104pub fn is_in_skip_context(ctx: &LintContext, byte_pos: usize) -> bool {
106 if ctx.is_in_code_block_or_span(byte_pos) {
108 return true;
109 }
110
111 if is_in_html_comment(ctx.content, byte_pos) {
113 return true;
114 }
115
116 if is_in_math_context(ctx, byte_pos) {
118 return true;
119 }
120
121 if is_in_html_tag(ctx, byte_pos) {
123 return true;
124 }
125
126 if ctx.flavor == MarkdownFlavor::MDX {
128 if ctx.is_in_jsx_expression(byte_pos) {
130 return true;
131 }
132 if ctx.is_in_mdx_comment(byte_pos) {
134 return true;
135 }
136 }
137
138 if ctx.flavor == MarkdownFlavor::MkDocs {
140 if mkdocs_snippets::is_within_snippet_section(ctx.content, byte_pos) {
141 return true;
142 }
143 if mkdocs_snippets::is_within_snippet_block(ctx.content, byte_pos) {
144 return true;
145 }
146 }
147
148 if ctx.flavor == MarkdownFlavor::MkDocs && mkdocs_admonitions::is_within_admonition(ctx.content, byte_pos) {
150 return true;
151 }
152
153 if ctx.flavor == MarkdownFlavor::MkDocs && mkdocs_footnotes::is_within_footnote_definition(ctx.content, byte_pos) {
155 return true;
156 }
157
158 if ctx.flavor == MarkdownFlavor::MkDocs && mkdocs_tabs::is_within_tab_content(ctx.content, byte_pos) {
160 return true;
161 }
162
163 if ctx.flavor == MarkdownFlavor::MkDocs && mkdocstrings_refs::is_within_autodoc_block(ctx.content, byte_pos) {
165 return true;
166 }
167
168 if ctx.flavor == MarkdownFlavor::MkDocs && mkdocs_critic::is_within_critic_markup(ctx.content, byte_pos) {
170 return true;
171 }
172
173 false
174}
175
176#[inline]
178pub fn is_in_jsx_expression(ctx: &LintContext, byte_pos: usize) -> bool {
179 ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_jsx_expression(byte_pos)
180}
181
182#[inline]
184pub fn is_in_mdx_comment(ctx: &LintContext, byte_pos: usize) -> bool {
185 ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_mdx_comment(byte_pos)
186}
187
188pub fn is_mkdocs_snippet_line(line: &str, flavor: MarkdownFlavor) -> bool {
190 flavor == MarkdownFlavor::MkDocs && mkdocs_snippets::is_snippet_marker(line)
191}
192
193pub fn is_mkdocs_admonition_line(line: &str, flavor: MarkdownFlavor) -> bool {
195 flavor == MarkdownFlavor::MkDocs && mkdocs_admonitions::is_admonition_marker(line)
196}
197
198pub fn is_mkdocs_footnote_line(line: &str, flavor: MarkdownFlavor) -> bool {
200 flavor == MarkdownFlavor::MkDocs && mkdocs_footnotes::is_footnote_definition(line)
201}
202
203pub fn is_mkdocs_tab_line(line: &str, flavor: MarkdownFlavor) -> bool {
205 flavor == MarkdownFlavor::MkDocs && mkdocs_tabs::is_tab_marker(line)
206}
207
208pub fn is_mkdocstrings_autodoc_line(line: &str, flavor: MarkdownFlavor) -> bool {
210 flavor == MarkdownFlavor::MkDocs && mkdocstrings_refs::is_autodoc_marker(line)
211}
212
213pub fn is_mkdocs_critic_line(line: &str, flavor: MarkdownFlavor) -> bool {
215 flavor == MarkdownFlavor::MkDocs && mkdocs_critic::contains_critic_markup(line)
216}
217
218pub fn is_in_html_comment(content: &str, byte_pos: usize) -> bool {
220 for m in HTML_COMMENT_PATTERN.find_iter(content) {
221 if m.start() <= byte_pos && byte_pos < m.end() {
222 return true;
223 }
224 }
225 false
226}
227
228pub fn is_in_html_tag(ctx: &LintContext, byte_pos: usize) -> bool {
230 for html_tag in ctx.html_tags().iter() {
231 if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
232 return true;
233 }
234 }
235 false
236}
237
238pub fn is_in_math_context(ctx: &LintContext, byte_pos: usize) -> bool {
240 let content = ctx.content;
241
242 if is_in_math_block(content, byte_pos) {
244 return true;
245 }
246
247 if is_in_inline_math(content, byte_pos) {
249 return true;
250 }
251
252 false
253}
254
255pub fn is_in_math_block(content: &str, byte_pos: usize) -> bool {
257 let mut in_math_block = false;
258 let mut current_pos = 0;
259
260 for line in content.lines() {
261 let line_start = current_pos;
262 let line_end = current_pos + line.len();
263
264 if is_math_block_delimiter(line) {
266 if byte_pos >= line_start && byte_pos <= line_end {
267 return true;
269 }
270 in_math_block = !in_math_block;
271 } else if in_math_block && byte_pos >= line_start && byte_pos <= line_end {
272 return true;
274 }
275
276 current_pos = line_end + 1; }
278
279 false
280}
281
282pub fn is_in_inline_math(content: &str, byte_pos: usize) -> bool {
284 for m in INLINE_MATH_REGEX.find_iter(content) {
286 if m.start() <= byte_pos && byte_pos < m.end() {
287 return true;
288 }
289 }
290 false
291}
292
293pub fn is_in_table_cell(ctx: &LintContext, line_num: usize, _col: usize) -> bool {
295 for table_row in ctx.table_rows().iter() {
297 if table_row.line == line_num {
298 return true;
302 }
303 }
304 false
305}
306
307pub fn is_table_line(line: &str) -> bool {
309 let trimmed = line.trim();
310
311 if trimmed
313 .chars()
314 .all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
315 && trimmed.contains('|')
316 && trimmed.contains('-')
317 {
318 return true;
319 }
320
321 if (trimmed.starts_with('|') || trimmed.ends_with('|')) && trimmed.matches('|').count() >= 2 {
323 return true;
324 }
325
326 false
327}
328
329pub fn is_in_icon_shortcode(line: &str, position: usize, _flavor: MarkdownFlavor) -> bool {
332 mkdocs_icons::is_in_any_shortcode(line, position)
335}
336
337pub fn is_in_pymdown_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
343 match flavor {
344 MarkdownFlavor::MkDocs => mkdocs_extensions::is_in_pymdown_markup(line, position),
345 MarkdownFlavor::Obsidian => {
346 mkdocs_extensions::is_in_mark(line, position)
348 }
349 _ => false,
350 }
351}
352
353pub fn is_in_mkdocs_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
357 if is_in_icon_shortcode(line, position, flavor) {
358 return true;
359 }
360 if is_in_pymdown_markup(line, position, flavor) {
361 return true;
362 }
363 false
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 #[test]
371 fn test_html_comment_detection() {
372 let content = "Text <!-- comment --> more text";
373 assert!(is_in_html_comment(content, 10)); assert!(!is_in_html_comment(content, 0)); assert!(!is_in_html_comment(content, 25)); }
377
378 #[test]
379 fn test_is_line_entirely_in_html_comment() {
380 let content = "<!--\ncomment\n--> Content after comment";
382 let ranges = compute_html_comment_ranges(content);
383 assert!(is_line_entirely_in_html_comment(&ranges, 0, 4));
385 assert!(is_line_entirely_in_html_comment(&ranges, 5, 12));
387 assert!(!is_line_entirely_in_html_comment(&ranges, 13, 38));
389
390 let content2 = "<!-- comment --> Not a comment";
392 let ranges2 = compute_html_comment_ranges(content2);
393 assert!(!is_line_entirely_in_html_comment(&ranges2, 0, 30));
395
396 let content3 = "<!-- comment -->";
398 let ranges3 = compute_html_comment_ranges(content3);
399 assert!(is_line_entirely_in_html_comment(&ranges3, 0, 16));
401
402 let content4 = "Text before <!-- comment -->";
404 let ranges4 = compute_html_comment_ranges(content4);
405 assert!(!is_line_entirely_in_html_comment(&ranges4, 0, 28));
407 }
408
409 #[test]
410 fn test_math_block_detection() {
411 let content = "Text\n$$\nmath content\n$$\nmore text";
412 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)); }
417
418 #[test]
419 fn test_inline_math_detection() {
420 let content = "Text $x + y$ and $$a^2 + b^2$$ here";
421 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)); }
426
427 #[test]
428 fn test_table_line_detection() {
429 assert!(is_table_line("| Header | Column |"));
430 assert!(is_table_line("|--------|--------|"));
431 assert!(is_table_line("| Cell 1 | Cell 2 |"));
432 assert!(!is_table_line("Regular text"));
433 assert!(!is_table_line("Just a pipe | here"));
434 }
435
436 #[test]
437 fn test_is_in_front_matter() {
438 let yaml_content = r#"---
440title: "My Post"
441tags: ["test", "example"]
442---
443
444# Content"#;
445
446 assert!(
447 is_in_front_matter(yaml_content, 0),
448 "Line 1 should be in YAML front matter"
449 );
450 assert!(
451 is_in_front_matter(yaml_content, 2),
452 "Line 3 should be in YAML front matter"
453 );
454 assert!(
455 is_in_front_matter(yaml_content, 3),
456 "Line 4 should be in YAML front matter"
457 );
458 assert!(
459 !is_in_front_matter(yaml_content, 4),
460 "Line 5 should NOT be in front matter"
461 );
462
463 let toml_content = r#"+++
465title = "My Post"
466tags = ["test", "example"]
467+++
468
469# Content"#;
470
471 assert!(
472 is_in_front_matter(toml_content, 0),
473 "Line 1 should be in TOML front matter"
474 );
475 assert!(
476 is_in_front_matter(toml_content, 2),
477 "Line 3 should be in TOML front matter"
478 );
479 assert!(
480 is_in_front_matter(toml_content, 3),
481 "Line 4 should be in TOML front matter"
482 );
483 assert!(
484 !is_in_front_matter(toml_content, 4),
485 "Line 5 should NOT be in front matter"
486 );
487
488 let mixed_content = r#"# Content
490
491+++
492title = "Not frontmatter"
493+++
494
495More content"#;
496
497 assert!(
498 !is_in_front_matter(mixed_content, 2),
499 "TOML block not at beginning should NOT be front matter"
500 );
501 assert!(
502 !is_in_front_matter(mixed_content, 3),
503 "TOML block not at beginning should NOT be front matter"
504 );
505 assert!(
506 !is_in_front_matter(mixed_content, 4),
507 "TOML block not at beginning should NOT be front matter"
508 );
509 }
510
511 #[test]
512 fn test_is_in_icon_shortcode() {
513 let line = "Click :material-check: to confirm";
514 assert!(!is_in_icon_shortcode(line, 0, MarkdownFlavor::MkDocs));
516 assert!(is_in_icon_shortcode(line, 6, MarkdownFlavor::MkDocs));
518 assert!(is_in_icon_shortcode(line, 15, MarkdownFlavor::MkDocs));
519 assert!(is_in_icon_shortcode(line, 21, MarkdownFlavor::MkDocs));
520 assert!(!is_in_icon_shortcode(line, 22, MarkdownFlavor::MkDocs));
522 }
523
524 #[test]
525 fn test_is_in_pymdown_markup() {
526 let line = "Press ++ctrl+c++ to copy";
528 assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::MkDocs));
529 assert!(is_in_pymdown_markup(line, 6, MarkdownFlavor::MkDocs));
530 assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::MkDocs));
531 assert!(!is_in_pymdown_markup(line, 17, MarkdownFlavor::MkDocs));
532
533 let line2 = "This is ==highlighted== text";
535 assert!(!is_in_pymdown_markup(line2, 0, MarkdownFlavor::MkDocs));
536 assert!(is_in_pymdown_markup(line2, 8, MarkdownFlavor::MkDocs));
537 assert!(is_in_pymdown_markup(line2, 15, MarkdownFlavor::MkDocs));
538 assert!(!is_in_pymdown_markup(line2, 23, MarkdownFlavor::MkDocs));
539
540 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Standard));
542 }
543
544 #[test]
545 fn test_is_in_mkdocs_markup() {
546 let line = ":material-check: and ++ctrl++";
548 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)); }
552
553 #[test]
556 fn test_obsidian_highlight_basic() {
557 let line = "This is ==highlighted== text";
559 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)); }
566
567 #[test]
568 fn test_obsidian_highlight_multiple() {
569 let line = "Both ==one== and ==two== here";
571 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)); }
576
577 #[test]
578 fn test_obsidian_highlight_not_standard_flavor() {
579 let line = "This is ==highlighted== text";
581 assert!(!is_in_pymdown_markup(line, 8, MarkdownFlavor::Standard));
582 assert!(!is_in_pymdown_markup(line, 15, MarkdownFlavor::Standard));
583 }
584
585 #[test]
586 fn test_obsidian_highlight_with_spaces_inside() {
587 let line = "This is ==text with spaces== here";
589 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)); }
593
594 #[test]
595 fn test_obsidian_does_not_support_keys_notation() {
596 let line = "Press ++ctrl+c++ to copy";
598 assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
599 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
600 }
601
602 #[test]
603 fn test_obsidian_mkdocs_markup_function() {
604 let line = "This is ==highlighted== text";
606 assert!(is_in_mkdocs_markup(line, 10, MarkdownFlavor::Obsidian)); assert!(!is_in_mkdocs_markup(line, 0, MarkdownFlavor::Obsidian)); }
609
610 #[test]
611 fn test_obsidian_highlight_edge_cases() {
612 let line = "Test ==== here";
614 assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
616
617 let line2 = "Test ==a== here";
619 assert!(is_in_pymdown_markup(line2, 5, MarkdownFlavor::Obsidian));
620 assert!(is_in_pymdown_markup(line2, 7, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line2, 9, MarkdownFlavor::Obsidian)); let line3 = "a === b";
625 assert!(!is_in_pymdown_markup(line3, 3, MarkdownFlavor::Obsidian));
626 }
627
628 #[test]
629 fn test_obsidian_highlight_unclosed() {
630 let line = "This ==starts but never ends";
632 assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian));
633 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
634 }
635}