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