supa_mdx_lint/rules/
rule005_admonition_newlines.rs

1use log::debug;
2use markdown::mdast::Node;
3use regex::Regex;
4use std::sync::LazyLock;
5use supa_mdx_macros::RuleName;
6
7use crate::{
8    context::Context,
9    errors::{LintError, LintLevel},
10    fix::{LintCorrection, LintCorrectionInsert},
11    location::{AdjustedRange, DenormalizedLocation},
12};
13
14use super::{Rule, RuleName, RuleSettings};
15
16#[derive(Debug)]
17struct ErrorInfo {
18    message: String,
19    fixes: Vec<LintCorrection>,
20}
21
22static ADMONITION_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
23    Regex::new(r"(?s)<Admonition[^>]*>\s*\n\s*\n.*?\n\s*\n\s*</Admonition>").unwrap()
24});
25
26/// Admonition JSX tags must have empty line separation from their content.
27///
28/// ## Examples
29///
30/// ### Valid
31///
32/// ```mdx
33/// <Admonition type="caution">
34///
35/// This is the content.
36///
37/// </Admonition>
38/// ```
39///
40/// ### Invalid
41///
42/// ```mdx
43/// <Admonition type="caution">
44/// This is the content.
45/// </Admonition>
46/// ```
47///
48/// ## Rule Details
49///
50/// This rule enforces that Admonition components have proper spacing:
51/// - Empty line after the opening `<Admonition>` tag
52/// - Empty line before the closing `</Admonition>` tag
53///
54/// This ensures consistent formatting and improved readability of admonition content.
55#[derive(Debug, Default, RuleName)]
56pub struct Rule005AdmonitionNewlines;
57
58impl Rule for Rule005AdmonitionNewlines {
59    fn default_level(&self) -> LintLevel {
60        LintLevel::Error
61    }
62
63    fn setup(&mut self, _settings: Option<&mut RuleSettings>) {
64        // No configuration options for this rule
65    }
66
67    fn check(&self, ast: &Node, context: &Context, level: LintLevel) -> Option<Vec<LintError>> {
68        if let Node::MdxJsxFlowElement(element) = ast {
69            if element
70                .name
71                .as_ref()
72                .is_some_and(|name| name == "Admonition")
73            {
74                if let Some(error_info) = self.check_admonition_newlines(element, context) {
75                    return LintError::from_node()
76                        .node(ast)
77                        .context(context)
78                        .rule(self.name())
79                        .level(level)
80                        .message(&error_info.message)
81                        .fix(error_info.fixes)
82                        .call()
83                        .map(|error| vec![error]);
84                }
85            }
86        }
87        None
88    }
89}
90
91impl Rule005AdmonitionNewlines {
92    fn check_admonition_newlines(
93        &self,
94        element: &markdown::mdast::MdxJsxFlowElement,
95        context: &Context,
96    ) -> Option<ErrorInfo> {
97        // Check if this is a self-closing admonition (no children)
98        if element.children.is_empty() {
99            debug!("Skipping self-closing admonition");
100            return None;
101        }
102
103        let position = element.position.as_ref()?;
104        // Convert to adjusted range immediately to handle frontmatter offsets
105        let adjusted_range = AdjustedRange::from_unadjusted_position(position, context);
106
107        let rope = context.rope();
108
109        // Extract only the admonition content slice from the rope using adjusted offsets
110        let range: std::ops::Range<usize> = adjusted_range.clone().into();
111        let admonition_slice = rope.byte_slice(range);
112        let admonition_content = admonition_slice.to_string();
113        debug!("Admonition content: {:?}", admonition_content);
114
115        // Check if the content matches the valid pattern
116        if !self.has_proper_newlines(&admonition_content) {
117            let fixes = self.generate_fixes(&admonition_content, &adjusted_range, context);
118            return Some(ErrorInfo {
119                message: "Admonition must have empty lines between tags and content".to_string(),
120                fixes,
121            });
122        }
123
124        None
125    }
126
127    fn has_proper_newlines(&self, content: &str) -> bool {
128        let matches = ADMONITION_PATTERN.is_match(content);
129        debug!(
130            "Pattern match result for content {:?}: {}",
131            content, matches
132        );
133
134        matches
135    }
136
137    fn generate_fixes(
138        &self,
139        content: &str,
140        adjusted_range: &AdjustedRange,
141        context: &Context,
142    ) -> Vec<LintCorrection> {
143        let lines: Vec<&str> = content.lines().collect();
144        if lines.is_empty() {
145            return Vec::new();
146        }
147
148        // Detect the line ending style used in the content
149        let line_ending = if content.contains("\r\n") {
150            "\r\n"
151        } else {
152            "\n"
153        };
154        let line_ending_len = line_ending.len();
155
156        let mut fix_list = Vec::new();
157
158        let opening_tag_line = 0;
159        let closing_tag_line = lines.len() - 1;
160
161        // Check if we need to add an empty line after the opening tag
162        let needs_opening_newline = if lines.len() >= 2 {
163            // Check if there's content immediately after the opening tag (no empty line)
164            !lines[1].trim().is_empty()
165        } else {
166            false
167        };
168
169        // Check if we need to add an empty line before the closing tag
170        let needs_closing_newline = if closing_tag_line > 0 {
171            // Check if there's content immediately before the closing tag (no empty line)
172            !lines[closing_tag_line - 1].trim().is_empty()
173        } else {
174            false
175        };
176
177        // Add fix for opening newline
178        if needs_opening_newline {
179            // Position after the opening tag line + its newline
180            let relative_offset = lines[opening_tag_line].len() + line_ending_len;
181
182            let mut start_point = adjusted_range.start;
183            start_point.increment(relative_offset);
184
185            let location = DenormalizedLocation::from_offset_range(
186                AdjustedRange::new(start_point, start_point),
187                context,
188            );
189
190            fix_list.push(LintCorrection::Insert(LintCorrectionInsert {
191                location,
192                text: line_ending.to_string(),
193            }));
194        }
195
196        // Add fix for closing newline
197        if needs_closing_newline {
198            // Calculate relative position at the start of the closing tag line
199            let mut relative_offset = 0;
200            for (i, line) in lines.iter().enumerate() {
201                if i == closing_tag_line {
202                    break;
203                }
204                relative_offset += line.len() + line_ending_len;
205            }
206
207            let mut start_point = adjusted_range.start;
208            start_point.increment(relative_offset);
209
210            let location = DenormalizedLocation::from_offset_range(
211                AdjustedRange::new(start_point, start_point),
212                context,
213            );
214
215            fix_list.push(LintCorrection::Insert(LintCorrectionInsert {
216                location,
217                text: line_ending.to_string(),
218            }));
219        }
220
221        fix_list
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::context::Context;
229    use crate::parser::parse;
230
231    #[test]
232    fn test_rule005_valid_admonition_with_empty_lines() {
233        let mdx = r#"<Admonition type="caution">
234
235This is the content.
236
237</Admonition>"#;
238
239        let rule = Rule005AdmonitionNewlines::default();
240        let parse_result = parse(mdx).unwrap();
241        let context = Context::builder()
242            .parse_result(&parse_result)
243            .build()
244            .unwrap();
245
246        let admonition = context
247            .parse_result
248            .ast()
249            .children()
250            .unwrap()
251            .get(0)
252            .unwrap();
253        let result = rule.check(admonition, &context, LintLevel::Error);
254
255        assert!(
256            result.is_none(),
257            "Expected no lint errors for valid admonition"
258        );
259    }
260
261    #[test]
262    fn test_rule005_invalid_admonition_without_empty_lines() {
263        let mdx = r#"<Admonition type="caution">
264This is the content.
265</Admonition>"#;
266
267        let rule = Rule005AdmonitionNewlines::default();
268        let parse_result = parse(mdx).unwrap();
269        let context = Context::builder()
270            .parse_result(&parse_result)
271            .build()
272            .unwrap();
273
274        let admonition = context
275            .parse_result
276            .ast()
277            .children()
278            .unwrap()
279            .get(0)
280            .unwrap();
281        let result = rule.check(admonition, &context, LintLevel::Error);
282
283        assert!(
284            result.is_some(),
285            "Expected lint error for invalid admonition"
286        );
287        let errors = result.unwrap();
288        assert_eq!(errors.len(), 1);
289
290        let error = &errors[0];
291        assert_eq!(error.location.start.row, 0);
292        assert_eq!(error.location.start.column, 0);
293    }
294
295    #[test]
296    fn test_rule005_admonition_missing_opening_empty_line() {
297        let mdx = r#"<Admonition type="caution">
298This is the content.
299
300</Admonition>"#;
301
302        let rule = Rule005AdmonitionNewlines::default();
303        let parse_result = parse(mdx).unwrap();
304        let context = Context::builder()
305            .parse_result(&parse_result)
306            .build()
307            .unwrap();
308
309        let admonition = context
310            .parse_result
311            .ast()
312            .children()
313            .unwrap()
314            .get(0)
315            .unwrap();
316        let result = rule.check(admonition, &context, LintLevel::Error);
317
318        assert!(
319            result.is_some(),
320            "Expected lint error for missing opening empty line"
321        );
322        let errors = result.unwrap();
323        assert_eq!(errors.len(), 1);
324
325        let error = &errors[0];
326        assert_eq!(error.location.start.row, 0);
327        assert_eq!(error.location.start.column, 0);
328    }
329
330    #[test]
331    fn test_rule005_admonition_missing_closing_empty_line() {
332        let mdx = r#"<Admonition type="caution">
333
334This is the content.
335</Admonition>"#;
336
337        let rule = Rule005AdmonitionNewlines::default();
338        let parse_result = parse(mdx).unwrap();
339        let context = Context::builder()
340            .parse_result(&parse_result)
341            .build()
342            .unwrap();
343
344        let admonition = context
345            .parse_result
346            .ast()
347            .children()
348            .unwrap()
349            .get(0)
350            .unwrap();
351        let result = rule.check(admonition, &context, LintLevel::Error);
352
353        assert!(
354            result.is_some(),
355            "Expected lint error for missing closing empty line"
356        );
357        let errors = result.unwrap();
358        assert_eq!(errors.len(), 1);
359
360        let error = &errors[0];
361        assert_eq!(error.location.start.row, 0);
362        assert_eq!(error.location.start.column, 0);
363    }
364
365    #[test]
366    fn test_rule005_auto_fix_missing_opening_empty_line() {
367        let mdx = r#"<Admonition type="caution">
368This is the content.
369
370</Admonition>"#;
371
372        let rule = Rule005AdmonitionNewlines::default();
373        let parse_result = parse(mdx).unwrap();
374        let context = Context::builder()
375            .parse_result(&parse_result)
376            .build()
377            .unwrap();
378
379        let admonition = context
380            .parse_result
381            .ast()
382            .children()
383            .unwrap()
384            .get(0)
385            .unwrap();
386        let result = rule.check(admonition, &context, LintLevel::Error);
387
388        assert!(
389            result.is_some(),
390            "Expected lint error for missing opening empty line"
391        );
392        let errors = result.unwrap();
393        assert_eq!(errors.len(), 1);
394
395        let error = &errors[0];
396        assert_eq!(error.location.start.row, 0);
397        assert_eq!(error.location.start.column, 0);
398        assert!(error.fix.is_some(), "Expected fix to be present");
399
400        let fixes = error.fix.as_ref().unwrap();
401        assert_eq!(fixes.len(), 1, "Expected exactly one fix");
402
403        match &fixes[0] {
404            LintCorrection::Insert(fix) => {
405                assert_eq!(fix.text, "\n", "Expected fix to add newline");
406                assert_eq!(fix.location.start.row, 1);
407                assert_eq!(fix.location.start.column, 0);
408            }
409            _ => panic!("Expected Insert fix"),
410        }
411    }
412
413    #[test]
414    fn test_rule005_auto_fix_missing_closing_empty_line() {
415        let mdx = r#"<Admonition type="caution">
416
417This is the content.
418</Admonition>"#;
419
420        let rule = Rule005AdmonitionNewlines::default();
421        let parse_result = parse(mdx).unwrap();
422        let context = Context::builder()
423            .parse_result(&parse_result)
424            .build()
425            .unwrap();
426
427        let admonition = context
428            .parse_result
429            .ast()
430            .children()
431            .unwrap()
432            .get(0)
433            .unwrap();
434        let result = rule.check(admonition, &context, LintLevel::Error);
435
436        assert!(
437            result.is_some(),
438            "Expected lint error for missing closing empty line"
439        );
440        let errors = result.unwrap();
441        assert_eq!(errors.len(), 1);
442
443        let error = &errors[0];
444        assert_eq!(error.location.start.row, 0);
445        assert_eq!(error.location.start.column, 0);
446        assert!(error.fix.is_some(), "Expected fix to be present");
447
448        let fixes = error.fix.as_ref().unwrap();
449        assert_eq!(fixes.len(), 1, "Expected exactly one fix");
450
451        match &fixes[0] {
452            LintCorrection::Insert(fix) => {
453                assert_eq!(fix.text, "\n", "Expected fix to add newline");
454                assert_eq!(fix.location.start.row, 3);
455                assert_eq!(fix.location.start.column, 0);
456            }
457            _ => panic!("Expected Insert fix"),
458        }
459    }
460
461    #[test]
462    fn test_rule005_auto_fix_missing_both_empty_lines() {
463        let mdx = r#"<Admonition type="caution">
464This is the content.
465</Admonition>"#;
466
467        let rule = Rule005AdmonitionNewlines::default();
468        let parse_result = parse(mdx).unwrap();
469        let context = Context::builder()
470            .parse_result(&parse_result)
471            .build()
472            .unwrap();
473
474        let admonition = context
475            .parse_result
476            .ast()
477            .children()
478            .unwrap()
479            .get(0)
480            .unwrap();
481        let result = rule.check(admonition, &context, LintLevel::Error);
482
483        assert!(
484            result.is_some(),
485            "Expected lint error for missing both empty lines"
486        );
487        let errors = result.unwrap();
488        assert_eq!(errors.len(), 1);
489
490        let error = &errors[0];
491        assert_eq!(error.location.start.row, 0);
492        assert_eq!(error.location.start.column, 0);
493        assert!(error.fix.is_some(), "Expected fix to be present");
494
495        let fixes = error.fix.as_ref().unwrap();
496        assert_eq!(fixes.len(), 2, "Expected exactly two fixes");
497
498        // First fix should be for opening newline
499        match &fixes[0] {
500            LintCorrection::Insert(fix) => {
501                assert_eq!(fix.text, "\n", "Expected fix to add newline");
502                assert_eq!(fix.location.start.row, 1);
503                assert_eq!(fix.location.start.column, 0);
504            }
505            _ => panic!("Expected Insert fix"),
506        }
507
508        // Second fix should be for closing newline
509        match &fixes[1] {
510            LintCorrection::Insert(fix) => {
511                assert_eq!(fix.text, "\n", "Expected fix to add newline");
512                assert_eq!(fix.location.start.row, 2);
513                assert_eq!(fix.location.start.column, 0);
514            }
515            _ => panic!("Expected Insert fix"),
516        }
517    }
518
519    #[test]
520    fn test_rule005_no_fix_for_valid_admonition() {
521        let mdx = r#"<Admonition type="caution">
522
523This is the content.
524
525</Admonition>"#;
526
527        let rule = Rule005AdmonitionNewlines::default();
528        let parse_result = parse(mdx).unwrap();
529        let context = Context::builder()
530            .parse_result(&parse_result)
531            .build()
532            .unwrap();
533
534        let admonition = context
535            .parse_result
536            .ast()
537            .children()
538            .unwrap()
539            .get(0)
540            .unwrap();
541        let result = rule.check(admonition, &context, LintLevel::Error);
542
543        assert!(
544            result.is_none(),
545            "Expected no lint error for valid admonition"
546        );
547    }
548
549    #[test]
550    fn test_rule005_self_closing_admonition() {
551        let mdx =
552            r#"<Admonition type="note" label="Data changes are not merged into production." />"#;
553
554        let rule = Rule005AdmonitionNewlines::default();
555        let parse_result = parse(mdx).unwrap();
556        let context = Context::builder()
557            .parse_result(&parse_result)
558            .build()
559            .unwrap();
560
561        let admonition = context
562            .parse_result
563            .ast()
564            .children()
565            .unwrap()
566            .get(0)
567            .unwrap();
568        let result = rule.check(admonition, &context, LintLevel::Error);
569
570        assert!(
571            result.is_none(),
572            "Expected no lint error for self-closing admonition"
573        );
574    }
575}