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