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#[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 }
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 if element.children.is_empty() {
99 debug!("Skipping self-closing admonition");
100 return None;
101 }
102
103 let position = element.position.as_ref()?;
104 let adjusted_range = AdjustedRange::from_unadjusted_position(position, context);
106
107 let rope = context.rope();
108
109 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 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 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 let needs_opening_newline = if lines.len() >= 2 {
163 !lines[1].trim().is_empty()
165 } else {
166 false
167 };
168
169 let needs_closing_newline = if closing_tag_line > 0 {
171 !lines[closing_tag_line - 1].trim().is_empty()
173 } else {
174 false
175 };
176
177 if needs_opening_newline {
179 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 if needs_closing_newline {
198 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 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 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}