1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::rules::front_matter_utils::{FrontMatterType, FrontMatterUtils};
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::sync::LazyLock;
7
8static JSON_KEY_PATTERN: LazyLock<Regex> =
10 LazyLock::new(|| Regex::new(r#"^\s*"([^"]+)"\s*:"#).expect("Invalid JSON key regex"));
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
17pub struct MD072Config {
18 #[serde(default)]
20 pub enabled: bool,
21}
22
23impl RuleConfig for MD072Config {
24 const RULE_NAME: &'static str = "MD072";
25}
26
27#[derive(Clone, Default)]
40pub struct MD072FrontmatterKeySort {
41 config: MD072Config,
42}
43
44impl MD072FrontmatterKeySort {
45 pub fn new() -> Self {
46 Self::default()
47 }
48
49 pub fn from_config_struct(config: MD072Config) -> Self {
51 Self { config }
52 }
53
54 fn has_comments(frontmatter_lines: &[&str]) -> bool {
56 frontmatter_lines.iter().any(|line| line.trim_start().starts_with('#'))
57 }
58
59 fn extract_yaml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
61 let mut keys = Vec::new();
62
63 for (idx, line) in frontmatter_lines.iter().enumerate() {
64 if !line.starts_with(' ')
66 && !line.starts_with('\t')
67 && let Some(colon_pos) = line.find(':')
68 {
69 let key = line[..colon_pos].trim();
70 if !key.is_empty() && !key.starts_with('#') {
71 keys.push((idx, key.to_string()));
72 }
73 }
74 }
75
76 keys
77 }
78
79 fn extract_toml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
81 let mut keys = Vec::new();
82
83 for (idx, line) in frontmatter_lines.iter().enumerate() {
84 let trimmed = line.trim();
85 if trimmed.is_empty() || trimmed.starts_with('#') {
87 continue;
88 }
89 if trimmed.starts_with('[') {
91 break;
92 }
93 if !line.starts_with(' ')
95 && !line.starts_with('\t')
96 && let Some(eq_pos) = line.find('=')
97 {
98 let key = line[..eq_pos].trim();
99 if !key.is_empty() {
100 keys.push((idx, key.to_string()));
101 }
102 }
103 }
104
105 keys
106 }
107
108 fn extract_json_keys(frontmatter_lines: &[&str]) -> Vec<String> {
110 let mut keys = Vec::new();
115 let mut depth: usize = 0;
116
117 for line in frontmatter_lines {
118 let line_start_depth = depth;
120
121 for ch in line.chars() {
123 match ch {
124 '{' | '[' => depth += 1,
125 '}' | ']' => depth = depth.saturating_sub(1),
126 _ => {}
127 }
128 }
129
130 if line_start_depth == 0
132 && let Some(captures) = JSON_KEY_PATTERN.captures(line)
133 && let Some(key_match) = captures.get(1)
134 {
135 keys.push(key_match.as_str().to_string());
136 }
137 }
138
139 keys
140 }
141
142 fn find_first_unsorted_pair(keys: &[String]) -> Option<(&str, &str)> {
145 for i in 1..keys.len() {
146 if keys[i].to_lowercase() < keys[i - 1].to_lowercase() {
147 return Some((&keys[i], &keys[i - 1]));
148 }
149 }
150 None
151 }
152
153 fn find_first_unsorted_indexed_pair(keys: &[(usize, String)]) -> Option<(&str, &str)> {
156 for i in 1..keys.len() {
157 if keys[i].1.to_lowercase() < keys[i - 1].1.to_lowercase() {
158 return Some((&keys[i].1, &keys[i - 1].1));
159 }
160 }
161 None
162 }
163
164 fn are_keys_sorted(keys: &[String]) -> bool {
166 Self::find_first_unsorted_pair(keys).is_none()
167 }
168
169 fn are_indexed_keys_sorted(keys: &[(usize, String)]) -> bool {
171 Self::find_first_unsorted_indexed_pair(keys).is_none()
172 }
173}
174
175impl Rule for MD072FrontmatterKeySort {
176 fn name(&self) -> &'static str {
177 "MD072"
178 }
179
180 fn description(&self) -> &'static str {
181 "Frontmatter keys should be sorted alphabetically"
182 }
183
184 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
185 if !self.config.enabled {
186 return Ok(Vec::new());
187 }
188
189 let content = ctx.content;
190 let mut warnings = Vec::new();
191
192 if content.is_empty() {
193 return Ok(warnings);
194 }
195
196 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
197
198 match fm_type {
199 FrontMatterType::Yaml => {
200 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
201 if frontmatter_lines.is_empty() {
202 return Ok(warnings);
203 }
204
205 let keys = Self::extract_yaml_keys(&frontmatter_lines);
206 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_indexed_pair(&keys) else {
207 return Ok(warnings);
208 };
209
210 let has_comments = Self::has_comments(&frontmatter_lines);
211
212 let fix = if has_comments {
213 None
214 } else {
215 match self.fix_yaml(content) {
217 Ok(fixed_content) if fixed_content != content => Some(Fix {
218 range: 0..content.len(),
219 replacement: fixed_content,
220 }),
221 _ => None,
222 }
223 };
224
225 let message = if has_comments {
226 format!(
227 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
228 )
229 } else {
230 format!(
231 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
232 )
233 };
234
235 warnings.push(LintWarning {
236 rule_name: Some(self.name().to_string()),
237 message,
238 line: 2, column: 1,
240 end_line: 2,
241 end_column: 1,
242 severity: Severity::Warning,
243 fix,
244 });
245 }
246 FrontMatterType::Toml => {
247 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
248 if frontmatter_lines.is_empty() {
249 return Ok(warnings);
250 }
251
252 let keys = Self::extract_toml_keys(&frontmatter_lines);
253 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_indexed_pair(&keys) else {
254 return Ok(warnings);
255 };
256
257 let has_comments = Self::has_comments(&frontmatter_lines);
258
259 let fix = if has_comments {
260 None
261 } else {
262 match self.fix_toml(content) {
264 Ok(fixed_content) if fixed_content != content => Some(Fix {
265 range: 0..content.len(),
266 replacement: fixed_content,
267 }),
268 _ => None,
269 }
270 };
271
272 let message = if has_comments {
273 format!(
274 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
275 )
276 } else {
277 format!(
278 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
279 )
280 };
281
282 warnings.push(LintWarning {
283 rule_name: Some(self.name().to_string()),
284 message,
285 line: 2, column: 1,
287 end_line: 2,
288 end_column: 1,
289 severity: Severity::Warning,
290 fix,
291 });
292 }
293 FrontMatterType::Json => {
294 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
295 if frontmatter_lines.is_empty() {
296 return Ok(warnings);
297 }
298
299 let keys = Self::extract_json_keys(&frontmatter_lines);
300 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_pair(&keys) else {
301 return Ok(warnings);
302 };
303
304 let fix = match self.fix_json(content) {
306 Ok(fixed_content) if fixed_content != content => Some(Fix {
307 range: 0..content.len(),
308 replacement: fixed_content,
309 }),
310 _ => None,
311 };
312
313 let message = format!(
314 "JSON frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
315 );
316
317 warnings.push(LintWarning {
318 rule_name: Some(self.name().to_string()),
319 message,
320 line: 2, column: 1,
322 end_line: 2,
323 end_column: 1,
324 severity: Severity::Warning,
325 fix,
326 });
327 }
328 _ => {
329 }
331 }
332
333 Ok(warnings)
334 }
335
336 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
337 if !self.config.enabled {
338 return Ok(ctx.content.to_string());
339 }
340
341 let content = ctx.content;
342
343 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
344
345 match fm_type {
346 FrontMatterType::Yaml => self.fix_yaml(content),
347 FrontMatterType::Toml => self.fix_toml(content),
348 FrontMatterType::Json => self.fix_json(content),
349 _ => Ok(content.to_string()),
350 }
351 }
352
353 fn category(&self) -> RuleCategory {
354 RuleCategory::FrontMatter
355 }
356
357 fn as_any(&self) -> &dyn std::any::Any {
358 self
359 }
360
361 fn default_config_section(&self) -> Option<(String, toml::Value)> {
362 let default_config = MD072Config::default();
363 let json_value = serde_json::to_value(&default_config).ok()?;
364 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
365
366 if let toml::Value::Table(table) = toml_value {
367 if !table.is_empty() {
368 Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
369 } else {
370 let mut table = toml::map::Map::new();
372 table.insert("enabled".to_string(), toml::Value::Boolean(false));
373 Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
374 }
375 } else {
376 None
377 }
378 }
379
380 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
381 where
382 Self: Sized,
383 {
384 let rule_config = crate::rule_config_serde::load_rule_config::<MD072Config>(config);
385 Box::new(Self::from_config_struct(rule_config))
386 }
387}
388
389impl MD072FrontmatterKeySort {
390 fn fix_yaml(&self, content: &str) -> Result<String, LintError> {
391 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
392 if frontmatter_lines.is_empty() {
393 return Ok(content.to_string());
394 }
395
396 if Self::has_comments(&frontmatter_lines) {
398 return Ok(content.to_string());
399 }
400
401 let keys = Self::extract_yaml_keys(&frontmatter_lines);
402 if Self::are_indexed_keys_sorted(&keys) {
403 return Ok(content.to_string());
404 }
405
406 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
409
410 for (i, (line_idx, key)) in keys.iter().enumerate() {
411 let start = *line_idx;
412 let end = if i + 1 < keys.len() {
413 keys[i + 1].0
414 } else {
415 frontmatter_lines.len()
416 };
417
418 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
419 key_blocks.push((key.to_lowercase(), block_lines));
420 }
421
422 key_blocks.sort_by(|a, b| a.0.cmp(&b.0));
424
425 let content_lines: Vec<&str> = content.lines().collect();
427 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
428
429 let mut result = String::new();
430 result.push_str("---\n");
431 for (_, lines) in &key_blocks {
432 for line in lines {
433 result.push_str(line);
434 result.push('\n');
435 }
436 }
437 result.push_str("---");
438
439 if fm_end < content_lines.len() {
440 result.push('\n');
441 result.push_str(&content_lines[fm_end..].join("\n"));
442 }
443
444 Ok(result)
445 }
446
447 fn fix_toml(&self, content: &str) -> Result<String, LintError> {
448 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
449 if frontmatter_lines.is_empty() {
450 return Ok(content.to_string());
451 }
452
453 if Self::has_comments(&frontmatter_lines) {
455 return Ok(content.to_string());
456 }
457
458 let keys = Self::extract_toml_keys(&frontmatter_lines);
459 if Self::are_indexed_keys_sorted(&keys) {
460 return Ok(content.to_string());
461 }
462
463 let fm_content = frontmatter_lines.join("\n");
465
466 match toml::from_str::<toml::Value>(&fm_content) {
467 Ok(value) => {
468 if let toml::Value::Table(table) = value {
469 let mut sorted_table = toml::map::Map::new();
472 let mut keys: Vec<_> = table.keys().cloned().collect();
473 keys.sort_by_key(|a| a.to_lowercase());
474
475 for key in keys {
476 if let Some(value) = table.get(&key) {
477 sorted_table.insert(key, value.clone());
478 }
479 }
480
481 match toml::to_string_pretty(&toml::Value::Table(sorted_table)) {
482 Ok(sorted_toml) => {
483 let lines: Vec<&str> = content.lines().collect();
484 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
485
486 let mut result = String::new();
487 result.push_str("+++\n");
488 result.push_str(sorted_toml.trim_end());
489 result.push_str("\n+++");
490
491 if fm_end < lines.len() {
492 result.push('\n');
493 result.push_str(&lines[fm_end..].join("\n"));
494 }
495
496 Ok(result)
497 }
498 Err(_) => Ok(content.to_string()),
499 }
500 } else {
501 Ok(content.to_string())
502 }
503 }
504 Err(_) => Ok(content.to_string()),
505 }
506 }
507
508 fn fix_json(&self, content: &str) -> Result<String, LintError> {
509 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
510 if frontmatter_lines.is_empty() {
511 return Ok(content.to_string());
512 }
513
514 let keys = Self::extract_json_keys(&frontmatter_lines);
515
516 if keys.is_empty() || Self::are_keys_sorted(&keys) {
517 return Ok(content.to_string());
518 }
519
520 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
522
523 match serde_json::from_str::<serde_json::Value>(&json_content) {
525 Ok(serde_json::Value::Object(map)) => {
526 let mut sorted_map = serde_json::Map::new();
528 let mut keys: Vec<_> = map.keys().cloned().collect();
529 keys.sort_by_key(|a| a.to_lowercase());
530
531 for key in keys {
532 if let Some(value) = map.get(&key) {
533 sorted_map.insert(key, value.clone());
534 }
535 }
536
537 match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
538 Ok(sorted_json) => {
539 let lines: Vec<&str> = content.lines().collect();
540 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
541
542 let mut result = String::new();
545 result.push_str(&sorted_json);
546
547 if fm_end < lines.len() {
548 result.push('\n');
549 result.push_str(&lines[fm_end..].join("\n"));
550 }
551
552 Ok(result)
553 }
554 Err(_) => Ok(content.to_string()),
555 }
556 }
557 _ => Ok(content.to_string()),
558 }
559 }
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 use crate::lint_context::LintContext;
566
567 fn create_enabled_rule() -> MD072FrontmatterKeySort {
569 MD072FrontmatterKeySort::from_config_struct(MD072Config { enabled: true })
570 }
571
572 #[test]
575 fn test_disabled_by_default() {
576 let rule = MD072FrontmatterKeySort::new();
577 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579 let result = rule.check(&ctx).unwrap();
580
581 assert!(result.is_empty());
583 }
584
585 #[test]
586 fn test_enabled_via_config() {
587 let rule = create_enabled_rule();
588 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590 let result = rule.check(&ctx).unwrap();
591
592 assert_eq!(result.len(), 1);
594 }
595
596 #[test]
599 fn test_no_frontmatter() {
600 let rule = create_enabled_rule();
601 let content = "# Heading\n\nContent.";
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603 let result = rule.check(&ctx).unwrap();
604
605 assert!(result.is_empty());
606 }
607
608 #[test]
609 fn test_yaml_sorted_keys() {
610 let rule = create_enabled_rule();
611 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613 let result = rule.check(&ctx).unwrap();
614
615 assert!(result.is_empty());
616 }
617
618 #[test]
619 fn test_yaml_unsorted_keys() {
620 let rule = create_enabled_rule();
621 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623 let result = rule.check(&ctx).unwrap();
624
625 assert_eq!(result.len(), 1);
626 assert!(result[0].message.contains("YAML"));
627 assert!(result[0].message.contains("not sorted"));
628 assert!(result[0].message.contains("'author' should come before 'title'"));
630 }
631
632 #[test]
633 fn test_yaml_case_insensitive_sort() {
634 let rule = create_enabled_rule();
635 let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
637 let result = rule.check(&ctx).unwrap();
638
639 assert!(result.is_empty());
641 }
642
643 #[test]
644 fn test_yaml_fix_sorts_keys() {
645 let rule = create_enabled_rule();
646 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648 let fixed = rule.fix(&ctx).unwrap();
649
650 let author_pos = fixed.find("author:").unwrap();
652 let title_pos = fixed.find("title:").unwrap();
653 assert!(author_pos < title_pos);
654 }
655
656 #[test]
657 fn test_yaml_no_fix_with_comments() {
658 let rule = create_enabled_rule();
659 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661 let result = rule.check(&ctx).unwrap();
662
663 assert_eq!(result.len(), 1);
664 assert!(result[0].message.contains("auto-fix unavailable"));
665 assert!(result[0].fix.is_none());
666
667 let fixed = rule.fix(&ctx).unwrap();
669 assert_eq!(fixed, content);
670 }
671
672 #[test]
673 fn test_yaml_single_key() {
674 let rule = create_enabled_rule();
675 let content = "---\ntitle: Test\n---\n\n# Heading";
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677 let result = rule.check(&ctx).unwrap();
678
679 assert!(result.is_empty());
681 }
682
683 #[test]
684 fn test_yaml_nested_keys_ignored() {
685 let rule = create_enabled_rule();
686 let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689 let result = rule.check(&ctx).unwrap();
690
691 assert!(result.is_empty());
693 }
694
695 #[test]
696 fn test_yaml_fix_idempotent() {
697 let rule = create_enabled_rule();
698 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let fixed_once = rule.fix(&ctx).unwrap();
701
702 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
703 let fixed_twice = rule.fix(&ctx2).unwrap();
704
705 assert_eq!(fixed_once, fixed_twice);
706 }
707
708 #[test]
709 fn test_yaml_complex_values() {
710 let rule = create_enabled_rule();
711 let content =
713 "---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
715 let result = rule.check(&ctx).unwrap();
716
717 assert!(result.is_empty());
719 }
720
721 #[test]
724 fn test_toml_sorted_keys() {
725 let rule = create_enabled_rule();
726 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728 let result = rule.check(&ctx).unwrap();
729
730 assert!(result.is_empty());
731 }
732
733 #[test]
734 fn test_toml_unsorted_keys() {
735 let rule = create_enabled_rule();
736 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738 let result = rule.check(&ctx).unwrap();
739
740 assert_eq!(result.len(), 1);
741 assert!(result[0].message.contains("TOML"));
742 assert!(result[0].message.contains("not sorted"));
743 }
744
745 #[test]
746 fn test_toml_fix_sorts_keys() {
747 let rule = create_enabled_rule();
748 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750 let fixed = rule.fix(&ctx).unwrap();
751
752 let author_pos = fixed.find("author").unwrap();
754 let title_pos = fixed.find("title").unwrap();
755 assert!(author_pos < title_pos);
756 }
757
758 #[test]
759 fn test_toml_no_fix_with_comments() {
760 let rule = create_enabled_rule();
761 let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763 let result = rule.check(&ctx).unwrap();
764
765 assert_eq!(result.len(), 1);
766 assert!(result[0].message.contains("auto-fix unavailable"));
767
768 let fixed = rule.fix(&ctx).unwrap();
770 assert_eq!(fixed, content);
771 }
772
773 #[test]
776 fn test_json_sorted_keys() {
777 let rule = create_enabled_rule();
778 let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780 let result = rule.check(&ctx).unwrap();
781
782 assert!(result.is_empty());
783 }
784
785 #[test]
786 fn test_json_unsorted_keys() {
787 let rule = create_enabled_rule();
788 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791
792 assert_eq!(result.len(), 1);
793 assert!(result[0].message.contains("JSON"));
794 assert!(result[0].message.contains("not sorted"));
795 }
796
797 #[test]
798 fn test_json_fix_sorts_keys() {
799 let rule = create_enabled_rule();
800 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802 let fixed = rule.fix(&ctx).unwrap();
803
804 let author_pos = fixed.find("author").unwrap();
806 let title_pos = fixed.find("title").unwrap();
807 assert!(author_pos < title_pos);
808 }
809
810 #[test]
811 fn test_json_always_fixable() {
812 let rule = create_enabled_rule();
813 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let result = rule.check(&ctx).unwrap();
817
818 assert_eq!(result.len(), 1);
819 assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
821 }
822
823 #[test]
826 fn test_empty_content() {
827 let rule = create_enabled_rule();
828 let content = "";
829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
830 let result = rule.check(&ctx).unwrap();
831
832 assert!(result.is_empty());
833 }
834
835 #[test]
836 fn test_empty_frontmatter() {
837 let rule = create_enabled_rule();
838 let content = "---\n---\n\n# Heading";
839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
840 let result = rule.check(&ctx).unwrap();
841
842 assert!(result.is_empty());
843 }
844
845 #[test]
846 fn test_toml_nested_tables_ignored() {
847 let rule = create_enabled_rule();
849 let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
850 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
851 let result = rule.check(&ctx).unwrap();
852
853 assert_eq!(result.len(), 1);
855 assert!(result[0].message.contains("'sort_by' should come before 'title'"));
857 assert!(!result[0].message.contains("we_have_extra"));
858 }
859
860 #[test]
861 fn test_toml_nested_taxonomies_ignored() {
862 let rule = create_enabled_rule();
864 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 let result = rule.check(&ctx).unwrap();
867
868 assert_eq!(result.len(), 1);
870 assert!(result[0].message.contains("'date' should come before 'title'"));
872 assert!(!result[0].message.contains("categories"));
873 assert!(!result[0].message.contains("tags"));
874 }
875
876 #[test]
879 fn test_yaml_unicode_keys() {
880 let rule = create_enabled_rule();
881 let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
884 let result = rule.check(&ctx).unwrap();
885
886 assert_eq!(result.len(), 1);
888 }
889
890 #[test]
891 fn test_yaml_keys_with_special_characters() {
892 let rule = create_enabled_rule();
893 let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896 let result = rule.check(&ctx).unwrap();
897
898 assert!(result.is_empty());
900 }
901
902 #[test]
903 fn test_yaml_keys_with_numbers() {
904 let rule = create_enabled_rule();
905 let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907 let result = rule.check(&ctx).unwrap();
908
909 assert!(result.is_empty());
911 }
912
913 #[test]
914 fn test_yaml_multiline_string_block_literal() {
915 let rule = create_enabled_rule();
916 let content =
917 "---\ndescription: |\n This is a\n multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919 let result = rule.check(&ctx).unwrap();
920
921 assert_eq!(result.len(), 1);
923 assert!(result[0].message.contains("'author' should come before 'title'"));
924 }
925
926 #[test]
927 fn test_yaml_multiline_string_folded() {
928 let rule = create_enabled_rule();
929 let content = "---\ndescription: >\n This is a\n folded string\nauthor: John\n---\n\n# Heading";
930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
931 let result = rule.check(&ctx).unwrap();
932
933 assert_eq!(result.len(), 1);
935 }
936
937 #[test]
938 fn test_yaml_fix_preserves_multiline_values() {
939 let rule = create_enabled_rule();
940 let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
942 let fixed = rule.fix(&ctx).unwrap();
943
944 let desc_pos = fixed.find("description").unwrap();
946 let title_pos = fixed.find("title").unwrap();
947 assert!(desc_pos < title_pos);
948 }
949
950 #[test]
951 fn test_yaml_quoted_keys() {
952 let rule = create_enabled_rule();
953 let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955 let result = rule.check(&ctx).unwrap();
956
957 assert!(result.is_empty());
959 }
960
961 #[test]
962 fn test_yaml_duplicate_keys() {
963 let rule = create_enabled_rule();
965 let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
966 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
967 let result = rule.check(&ctx).unwrap();
968
969 assert_eq!(result.len(), 1);
971 }
972
973 #[test]
974 fn test_toml_inline_table() {
975 let rule = create_enabled_rule();
976 let content =
977 "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
979 let result = rule.check(&ctx).unwrap();
980
981 assert!(result.is_empty());
983 }
984
985 #[test]
986 fn test_toml_array_of_tables() {
987 let rule = create_enabled_rule();
988 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
990 let result = rule.check(&ctx).unwrap();
991
992 assert_eq!(result.len(), 1);
994 assert!(result[0].message.contains("'date' should come before 'title'"));
996 }
997
998 #[test]
999 fn test_json_nested_objects() {
1000 let rule = create_enabled_rule();
1001 let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003 let result = rule.check(&ctx).unwrap();
1004
1005 assert!(result.is_empty());
1007 }
1008
1009 #[test]
1010 fn test_json_arrays() {
1011 let rule = create_enabled_rule();
1012 let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1013 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1014 let result = rule.check(&ctx).unwrap();
1015
1016 assert_eq!(result.len(), 1);
1018 }
1019
1020 #[test]
1021 fn test_fix_preserves_content_after_frontmatter() {
1022 let rule = create_enabled_rule();
1023 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025 let fixed = rule.fix(&ctx).unwrap();
1026
1027 assert!(fixed.contains("# Heading"));
1029 assert!(fixed.contains("Paragraph 1."));
1030 assert!(fixed.contains("- List item"));
1031 assert!(fixed.contains("- Another item"));
1032 }
1033
1034 #[test]
1035 fn test_fix_yaml_produces_valid_yaml() {
1036 let rule = create_enabled_rule();
1037 let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039 let fixed = rule.fix(&ctx).unwrap();
1040
1041 let lines: Vec<&str> = fixed.lines().collect();
1044 let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1045 let fm_content: String = lines[1..fm_end].join("\n");
1046
1047 let parsed: Result<serde_yml::Value, _> = serde_yml::from_str(&fm_content);
1049 assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1050 }
1051
1052 #[test]
1053 fn test_fix_toml_produces_valid_toml() {
1054 let rule = create_enabled_rule();
1055 let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1057 let fixed = rule.fix(&ctx).unwrap();
1058
1059 let lines: Vec<&str> = fixed.lines().collect();
1061 let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1062 let fm_content: String = lines[1..fm_end].join("\n");
1063
1064 let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1066 assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1067 }
1068
1069 #[test]
1070 fn test_fix_json_produces_valid_json() {
1071 let rule = create_enabled_rule();
1072 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1074 let fixed = rule.fix(&ctx).unwrap();
1075
1076 let json_end = fixed.find("\n\n").unwrap();
1078 let json_content = &fixed[..json_end];
1079
1080 let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1082 assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1083 }
1084
1085 #[test]
1086 fn test_many_keys_performance() {
1087 let rule = create_enabled_rule();
1088 let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1090 keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1092
1093 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1094 let result = rule.check(&ctx).unwrap();
1095
1096 assert_eq!(result.len(), 1);
1098 }
1099
1100 #[test]
1101 fn test_yaml_empty_value() {
1102 let rule = create_enabled_rule();
1103 let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1105 let result = rule.check(&ctx).unwrap();
1106
1107 assert_eq!(result.len(), 1);
1109 }
1110
1111 #[test]
1112 fn test_yaml_null_value() {
1113 let rule = create_enabled_rule();
1114 let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1116 let result = rule.check(&ctx).unwrap();
1117
1118 assert_eq!(result.len(), 1);
1119 }
1120
1121 #[test]
1122 fn test_yaml_boolean_values() {
1123 let rule = create_enabled_rule();
1124 let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1126 let result = rule.check(&ctx).unwrap();
1127
1128 assert_eq!(result.len(), 1);
1130 }
1131
1132 #[test]
1133 fn test_toml_boolean_values() {
1134 let rule = create_enabled_rule();
1135 let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1137 let result = rule.check(&ctx).unwrap();
1138
1139 assert_eq!(result.len(), 1);
1140 }
1141
1142 #[test]
1143 fn test_yaml_list_at_top_level() {
1144 let rule = create_enabled_rule();
1145 let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
1146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147 let result = rule.check(&ctx).unwrap();
1148
1149 assert_eq!(result.len(), 1);
1151 }
1152
1153 #[test]
1154 fn test_three_keys_all_orderings() {
1155 let rule = create_enabled_rule();
1156
1157 let orderings = [
1159 ("a, b, c", "---\na: 1\nb: 2\nc: 3\n---\n\n# H", true), ("a, c, b", "---\na: 1\nc: 3\nb: 2\n---\n\n# H", false), ("b, a, c", "---\nb: 2\na: 1\nc: 3\n---\n\n# H", false), ("b, c, a", "---\nb: 2\nc: 3\na: 1\n---\n\n# H", false), ("c, a, b", "---\nc: 3\na: 1\nb: 2\n---\n\n# H", false), ("c, b, a", "---\nc: 3\nb: 2\na: 1\n---\n\n# H", false), ];
1166
1167 for (name, content, should_pass) in orderings {
1168 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1169 let result = rule.check(&ctx).unwrap();
1170 assert_eq!(
1171 result.is_empty(),
1172 should_pass,
1173 "Ordering {name} should {} pass",
1174 if should_pass { "" } else { "not" }
1175 );
1176 }
1177 }
1178
1179 #[test]
1180 fn test_crlf_line_endings() {
1181 let rule = create_enabled_rule();
1182 let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1184 let result = rule.check(&ctx).unwrap();
1185
1186 assert_eq!(result.len(), 1);
1188 }
1189
1190 #[test]
1191 fn test_json_escaped_quotes_in_keys() {
1192 let rule = create_enabled_rule();
1193 let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196 let result = rule.check(&ctx).unwrap();
1197
1198 assert_eq!(result.len(), 1);
1200 }
1201
1202 #[test]
1205 fn test_warning_fix_yaml_sorts_keys() {
1206 let rule = create_enabled_rule();
1207 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1209 let warnings = rule.check(&ctx).unwrap();
1210
1211 assert_eq!(warnings.len(), 1);
1212 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1213
1214 let fix = warnings[0].fix.as_ref().unwrap();
1215 assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1216
1217 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1219
1220 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1222 let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1223 assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1224 }
1225
1226 #[test]
1227 fn test_warning_fix_preserves_yaml_list_indentation() {
1228 let rule = create_enabled_rule();
1229 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1230 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1231 let warnings = rule.check(&ctx).unwrap();
1232
1233 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1234
1235 assert!(
1237 fixed.contains(" - hello"),
1238 "List indentation should be preserved: {fixed}"
1239 );
1240 assert!(
1241 fixed.contains(" - world"),
1242 "List indentation should be preserved: {fixed}"
1243 );
1244 }
1245
1246 #[test]
1247 fn test_warning_fix_preserves_nested_object_indentation() {
1248 let rule = create_enabled_rule();
1249 let content = "---\nzzzz: value\naaaa:\n nested_key: nested_value\n another: 123\n---\n\n# Heading\n";
1250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1251 let warnings = rule.check(&ctx).unwrap();
1252
1253 assert_eq!(warnings.len(), 1);
1254 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1255
1256 let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1258 let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1259 assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1260
1261 assert!(
1263 fixed.contains(" nested_key: nested_value"),
1264 "Nested object indentation should be preserved: {fixed}"
1265 );
1266 assert!(
1267 fixed.contains(" another: 123"),
1268 "Nested object indentation should be preserved: {fixed}"
1269 );
1270 }
1271
1272 #[test]
1273 fn test_warning_fix_preserves_deeply_nested_structure() {
1274 let rule = create_enabled_rule();
1275 let content = "---\nzzz: top\naaa:\n level1:\n level2:\n - item1\n - item2\n---\n\n# Content\n";
1276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277 let warnings = rule.check(&ctx).unwrap();
1278
1279 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1280
1281 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1283 let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1284 assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1285
1286 assert!(fixed.contains(" level1:"), "2-space indent should be preserved");
1288 assert!(fixed.contains(" level2:"), "4-space indent should be preserved");
1289 assert!(fixed.contains(" - item1"), "6-space indent should be preserved");
1290 assert!(fixed.contains(" - item2"), "6-space indent should be preserved");
1291 }
1292
1293 #[test]
1294 fn test_warning_fix_toml_sorts_keys() {
1295 let rule = create_enabled_rule();
1296 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298 let warnings = rule.check(&ctx).unwrap();
1299
1300 assert_eq!(warnings.len(), 1);
1301 assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1302
1303 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1304
1305 let author_pos = fixed.find("author").expect("author should exist");
1307 let title_pos = fixed.find("title").expect("title should exist");
1308 assert!(author_pos < title_pos, "author should come before title");
1309 }
1310
1311 #[test]
1312 fn test_warning_fix_json_sorts_keys() {
1313 let rule = create_enabled_rule();
1314 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1316 let warnings = rule.check(&ctx).unwrap();
1317
1318 assert_eq!(warnings.len(), 1);
1319 assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1320
1321 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1322
1323 let author_pos = fixed.find("author").expect("author should exist");
1325 let title_pos = fixed.find("title").expect("title should exist");
1326 assert!(author_pos < title_pos, "author should come before title");
1327 }
1328
1329 #[test]
1330 fn test_warning_fix_no_fix_when_comments_present() {
1331 let rule = create_enabled_rule();
1332 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1334 let warnings = rule.check(&ctx).unwrap();
1335
1336 assert_eq!(warnings.len(), 1);
1337 assert!(
1338 warnings[0].fix.is_none(),
1339 "Warning should NOT have a fix when comments are present"
1340 );
1341 assert!(
1342 warnings[0].message.contains("auto-fix unavailable"),
1343 "Message should indicate auto-fix is unavailable"
1344 );
1345 }
1346
1347 #[test]
1348 fn test_warning_fix_preserves_content_after_frontmatter() {
1349 let rule = create_enabled_rule();
1350 let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1352 let warnings = rule.check(&ctx).unwrap();
1353
1354 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1355
1356 assert!(fixed.contains("# Heading"), "Heading should be preserved");
1358 assert!(
1359 fixed.contains("Paragraph with content."),
1360 "Paragraph should be preserved"
1361 );
1362 assert!(fixed.contains("- List item"), "List item should be preserved");
1363 }
1364
1365 #[test]
1366 fn test_warning_fix_idempotent() {
1367 let rule = create_enabled_rule();
1368 let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370 let warnings = rule.check(&ctx).unwrap();
1371
1372 let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1373
1374 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1376 let warnings2 = rule.check(&ctx2).unwrap();
1377
1378 assert!(
1379 warnings2.is_empty(),
1380 "After fixing, no more warnings should be produced"
1381 );
1382 }
1383
1384 #[test]
1385 fn test_warning_fix_preserves_multiline_block_literal() {
1386 let rule = create_enabled_rule();
1387 let content = "---\nzzz: simple\naaa: |\n Line 1 of block\n Line 2 of block\n---\n\n# Heading\n";
1388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1389 let warnings = rule.check(&ctx).unwrap();
1390
1391 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1392
1393 assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1395 assert!(
1396 fixed.contains(" Line 1 of block"),
1397 "Block literal line 1 should be preserved with indent"
1398 );
1399 assert!(
1400 fixed.contains(" Line 2 of block"),
1401 "Block literal line 2 should be preserved with indent"
1402 );
1403 }
1404
1405 #[test]
1406 fn test_warning_fix_preserves_folded_string() {
1407 let rule = create_enabled_rule();
1408 let content = "---\nzzz: simple\naaa: >\n Folded line 1\n Folded line 2\n---\n\n# Content\n";
1409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1410 let warnings = rule.check(&ctx).unwrap();
1411
1412 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1413
1414 assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1416 assert!(
1417 fixed.contains(" Folded line 1"),
1418 "Folded line 1 should be preserved with indent"
1419 );
1420 assert!(
1421 fixed.contains(" Folded line 2"),
1422 "Folded line 2 should be preserved with indent"
1423 );
1424 }
1425
1426 #[test]
1427 fn test_warning_fix_preserves_4_space_indentation() {
1428 let rule = create_enabled_rule();
1429 let content = "---\nzzz: value\naaa:\n nested: with_4_spaces\n another: value\n---\n\n# Heading\n";
1431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1432 let warnings = rule.check(&ctx).unwrap();
1433
1434 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1435
1436 assert!(
1438 fixed.contains(" nested: with_4_spaces"),
1439 "4-space indentation should be preserved: {fixed}"
1440 );
1441 assert!(
1442 fixed.contains(" another: value"),
1443 "4-space indentation should be preserved: {fixed}"
1444 );
1445 }
1446
1447 #[test]
1448 fn test_warning_fix_preserves_tab_indentation() {
1449 let rule = create_enabled_rule();
1450 let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1453 let warnings = rule.check(&ctx).unwrap();
1454
1455 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1456
1457 assert!(
1459 fixed.contains("\tnested: with_tab"),
1460 "Tab indentation should be preserved: {fixed}"
1461 );
1462 assert!(
1463 fixed.contains("\tanother: value"),
1464 "Tab indentation should be preserved: {fixed}"
1465 );
1466 }
1467
1468 #[test]
1469 fn test_warning_fix_preserves_inline_list() {
1470 let rule = create_enabled_rule();
1471 let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474 let warnings = rule.check(&ctx).unwrap();
1475
1476 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1477
1478 assert!(
1480 fixed.contains("aaa: [one, two, three]"),
1481 "Inline list should be preserved exactly: {fixed}"
1482 );
1483 }
1484
1485 #[test]
1486 fn test_warning_fix_preserves_quoted_strings() {
1487 let rule = create_enabled_rule();
1488 let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1491 let warnings = rule.check(&ctx).unwrap();
1492
1493 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1494
1495 assert!(
1497 fixed.contains("aaa: \"value with: colon\""),
1498 "Double-quoted string should be preserved: {fixed}"
1499 );
1500 assert!(
1501 fixed.contains("bbb: 'single quotes'"),
1502 "Single-quoted string should be preserved: {fixed}"
1503 );
1504 }
1505}