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 are_keys_sorted(keys: &[String]) -> bool {
144 if keys.len() <= 1 {
145 return true;
146 }
147
148 for i in 1..keys.len() {
149 if keys[i].to_lowercase() < keys[i - 1].to_lowercase() {
150 return false;
151 }
152 }
153
154 true
155 }
156
157 fn are_indexed_keys_sorted(keys: &[(usize, String)]) -> bool {
159 if keys.len() <= 1 {
160 return true;
161 }
162
163 for i in 1..keys.len() {
164 if keys[i].1.to_lowercase() < keys[i - 1].1.to_lowercase() {
165 return false;
166 }
167 }
168
169 true
170 }
171
172 fn get_sorted_keys(keys: &[String]) -> Vec<String> {
174 let mut sorted = keys.to_vec();
175 sorted.sort_by_key(|a| a.to_lowercase());
176 sorted
177 }
178
179 fn get_sorted_indexed_keys(keys: &[(usize, String)]) -> Vec<String> {
181 let mut sorted: Vec<String> = keys.iter().map(|(_, k)| k.clone()).collect();
182 sorted.sort_by_key(|a| a.to_lowercase());
183 sorted
184 }
185}
186
187impl Rule for MD072FrontmatterKeySort {
188 fn name(&self) -> &'static str {
189 "MD072"
190 }
191
192 fn description(&self) -> &'static str {
193 "Frontmatter keys should be sorted alphabetically"
194 }
195
196 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
197 if !self.config.enabled {
198 return Ok(Vec::new());
199 }
200
201 let content = ctx.content;
202 let mut warnings = Vec::new();
203
204 if content.is_empty() {
205 return Ok(warnings);
206 }
207
208 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
209
210 match fm_type {
211 FrontMatterType::Yaml => {
212 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
213 if frontmatter_lines.is_empty() {
214 return Ok(warnings);
215 }
216
217 let keys = Self::extract_yaml_keys(&frontmatter_lines);
218 if Self::are_indexed_keys_sorted(&keys) {
219 return Ok(warnings);
220 }
221
222 let has_comments = Self::has_comments(&frontmatter_lines);
223 let sorted_keys = Self::get_sorted_indexed_keys(&keys);
224 let current_order = keys.iter().map(|(_, k)| k.as_str()).collect::<Vec<_>>().join(", ");
225 let expected_order = sorted_keys.join(", ");
226
227 let fix = if has_comments {
228 None
229 } else {
230 Some(Fix {
231 range: 0..0,
232 replacement: String::new(),
233 })
234 };
235
236 let message = if has_comments {
237 format!(
238 "YAML frontmatter keys are not sorted alphabetically. Expected order: [{expected_order}]. Current order: [{current_order}]. Auto-fix unavailable: frontmatter contains comments."
239 )
240 } else {
241 format!(
242 "YAML frontmatter keys are not sorted alphabetically. Expected order: [{expected_order}]. Current order: [{current_order}]"
243 )
244 };
245
246 warnings.push(LintWarning {
247 rule_name: Some(self.name().to_string()),
248 message,
249 line: 2, column: 1,
251 end_line: 2,
252 end_column: 1,
253 severity: Severity::Warning,
254 fix,
255 });
256 }
257 FrontMatterType::Toml => {
258 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
259 if frontmatter_lines.is_empty() {
260 return Ok(warnings);
261 }
262
263 let keys = Self::extract_toml_keys(&frontmatter_lines);
264 if Self::are_indexed_keys_sorted(&keys) {
265 return Ok(warnings);
266 }
267
268 let has_comments = Self::has_comments(&frontmatter_lines);
269 let sorted_keys = Self::get_sorted_indexed_keys(&keys);
270 let current_order = keys.iter().map(|(_, k)| k.as_str()).collect::<Vec<_>>().join(", ");
271 let expected_order = sorted_keys.join(", ");
272
273 let fix = if has_comments {
274 None
275 } else {
276 Some(Fix {
277 range: 0..0,
278 replacement: String::new(),
279 })
280 };
281
282 let message = if has_comments {
283 format!(
284 "TOML frontmatter keys are not sorted alphabetically. Expected order: [{expected_order}]. Current order: [{current_order}]. Auto-fix unavailable: frontmatter contains comments."
285 )
286 } else {
287 format!(
288 "TOML frontmatter keys are not sorted alphabetically. Expected order: [{expected_order}]. Current order: [{current_order}]"
289 )
290 };
291
292 warnings.push(LintWarning {
293 rule_name: Some(self.name().to_string()),
294 message,
295 line: 2, column: 1,
297 end_line: 2,
298 end_column: 1,
299 severity: Severity::Warning,
300 fix,
301 });
302 }
303 FrontMatterType::Json => {
304 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
305 if frontmatter_lines.is_empty() {
306 return Ok(warnings);
307 }
308
309 let keys = Self::extract_json_keys(&frontmatter_lines);
310
311 if keys.is_empty() || Self::are_keys_sorted(&keys) {
312 return Ok(warnings);
313 }
314
315 let sorted_keys = Self::get_sorted_keys(&keys);
316 let current_order = keys.join(", ");
317 let expected_order = sorted_keys.join(", ");
318
319 let fix = Some(Fix {
321 range: 0..0,
322 replacement: String::new(),
323 });
324
325 let message = format!(
326 "JSON frontmatter keys are not sorted alphabetically. Expected order: [{expected_order}]. Current order: [{current_order}]"
327 );
328
329 warnings.push(LintWarning {
330 rule_name: Some(self.name().to_string()),
331 message,
332 line: 2, column: 1,
334 end_line: 2,
335 end_column: 1,
336 severity: Severity::Warning,
337 fix,
338 });
339 }
340 _ => {
341 }
343 }
344
345 Ok(warnings)
346 }
347
348 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
349 if !self.config.enabled {
350 return Ok(ctx.content.to_string());
351 }
352
353 let content = ctx.content;
354
355 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
356
357 match fm_type {
358 FrontMatterType::Yaml => self.fix_yaml(content),
359 FrontMatterType::Toml => self.fix_toml(content),
360 FrontMatterType::Json => self.fix_json(content),
361 _ => Ok(content.to_string()),
362 }
363 }
364
365 fn category(&self) -> RuleCategory {
366 RuleCategory::FrontMatter
367 }
368
369 fn as_any(&self) -> &dyn std::any::Any {
370 self
371 }
372
373 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
374 where
375 Self: Sized,
376 {
377 let rule_config = crate::rule_config_serde::load_rule_config::<MD072Config>(config);
378 Box::new(Self::from_config_struct(rule_config))
379 }
380}
381
382impl MD072FrontmatterKeySort {
383 fn fix_yaml(&self, content: &str) -> Result<String, LintError> {
384 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
385 if frontmatter_lines.is_empty() {
386 return Ok(content.to_string());
387 }
388
389 if Self::has_comments(&frontmatter_lines) {
391 return Ok(content.to_string());
392 }
393
394 let keys = Self::extract_yaml_keys(&frontmatter_lines);
395 if Self::are_indexed_keys_sorted(&keys) {
396 return Ok(content.to_string());
397 }
398
399 let fm_content = frontmatter_lines.join("\n");
401
402 match serde_yml::from_str::<serde_yml::Value>(&fm_content) {
403 Ok(value) => {
404 if let serde_yml::Value::Mapping(map) = value {
405 let mut sorted_map = serde_yml::Mapping::new();
406 let mut keys: Vec<_> = map.keys().cloned().collect();
407 keys.sort_by_key(|a| a.as_str().unwrap_or("").to_lowercase());
408
409 for key in keys {
410 if let Some(value) = map.get(&key) {
411 sorted_map.insert(key, value.clone());
412 }
413 }
414
415 match serde_yml::to_string(&sorted_map) {
416 Ok(sorted_yaml) => {
417 let lines: Vec<&str> = content.lines().collect();
418 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
419
420 let mut result = String::new();
421 result.push_str("---\n");
422 result.push_str(sorted_yaml.trim_end());
423 result.push_str("\n---");
424
425 if fm_end < lines.len() {
426 result.push('\n');
427 result.push_str(&lines[fm_end..].join("\n"));
428 }
429
430 Ok(result)
431 }
432 Err(_) => Ok(content.to_string()),
433 }
434 } else {
435 Ok(content.to_string())
436 }
437 }
438 Err(_) => Ok(content.to_string()),
439 }
440 }
441
442 fn fix_toml(&self, content: &str) -> Result<String, LintError> {
443 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
444 if frontmatter_lines.is_empty() {
445 return Ok(content.to_string());
446 }
447
448 if Self::has_comments(&frontmatter_lines) {
450 return Ok(content.to_string());
451 }
452
453 let keys = Self::extract_toml_keys(&frontmatter_lines);
454 if Self::are_indexed_keys_sorted(&keys) {
455 return Ok(content.to_string());
456 }
457
458 let fm_content = frontmatter_lines.join("\n");
460
461 match toml::from_str::<toml::Value>(&fm_content) {
462 Ok(value) => {
463 if let toml::Value::Table(table) = value {
464 let mut sorted_table = toml::map::Map::new();
467 let mut keys: Vec<_> = table.keys().cloned().collect();
468 keys.sort_by_key(|a| a.to_lowercase());
469
470 for key in keys {
471 if let Some(value) = table.get(&key) {
472 sorted_table.insert(key, value.clone());
473 }
474 }
475
476 match toml::to_string_pretty(&toml::Value::Table(sorted_table)) {
477 Ok(sorted_toml) => {
478 let lines: Vec<&str> = content.lines().collect();
479 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
480
481 let mut result = String::new();
482 result.push_str("+++\n");
483 result.push_str(sorted_toml.trim_end());
484 result.push_str("\n+++");
485
486 if fm_end < lines.len() {
487 result.push('\n');
488 result.push_str(&lines[fm_end..].join("\n"));
489 }
490
491 Ok(result)
492 }
493 Err(_) => Ok(content.to_string()),
494 }
495 } else {
496 Ok(content.to_string())
497 }
498 }
499 Err(_) => Ok(content.to_string()),
500 }
501 }
502
503 fn fix_json(&self, content: &str) -> Result<String, LintError> {
504 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
505 if frontmatter_lines.is_empty() {
506 return Ok(content.to_string());
507 }
508
509 let keys = Self::extract_json_keys(&frontmatter_lines);
510
511 if keys.is_empty() || Self::are_keys_sorted(&keys) {
512 return Ok(content.to_string());
513 }
514
515 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
517
518 match serde_json::from_str::<serde_json::Value>(&json_content) {
520 Ok(serde_json::Value::Object(map)) => {
521 let mut sorted_map = serde_json::Map::new();
523 let mut keys: Vec<_> = map.keys().cloned().collect();
524 keys.sort_by_key(|a| a.to_lowercase());
525
526 for key in keys {
527 if let Some(value) = map.get(&key) {
528 sorted_map.insert(key, value.clone());
529 }
530 }
531
532 match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
533 Ok(sorted_json) => {
534 let lines: Vec<&str> = content.lines().collect();
535 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
536
537 let mut result = String::new();
540 result.push_str(&sorted_json);
541
542 if fm_end < lines.len() {
543 result.push('\n');
544 result.push_str(&lines[fm_end..].join("\n"));
545 }
546
547 Ok(result)
548 }
549 Err(_) => Ok(content.to_string()),
550 }
551 }
552 _ => Ok(content.to_string()),
553 }
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560 use crate::lint_context::LintContext;
561
562 fn create_enabled_rule() -> MD072FrontmatterKeySort {
564 MD072FrontmatterKeySort::from_config_struct(MD072Config { enabled: true })
565 }
566
567 #[test]
570 fn test_disabled_by_default() {
571 let rule = MD072FrontmatterKeySort::new();
572 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574 let result = rule.check(&ctx).unwrap();
575
576 assert!(result.is_empty());
578 }
579
580 #[test]
581 fn test_enabled_via_config() {
582 let rule = create_enabled_rule();
583 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585 let result = rule.check(&ctx).unwrap();
586
587 assert_eq!(result.len(), 1);
589 }
590
591 #[test]
594 fn test_no_frontmatter() {
595 let rule = create_enabled_rule();
596 let content = "# Heading\n\nContent.";
597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598 let result = rule.check(&ctx).unwrap();
599
600 assert!(result.is_empty());
601 }
602
603 #[test]
604 fn test_yaml_sorted_keys() {
605 let rule = create_enabled_rule();
606 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let result = rule.check(&ctx).unwrap();
609
610 assert!(result.is_empty());
611 }
612
613 #[test]
614 fn test_yaml_unsorted_keys() {
615 let rule = create_enabled_rule();
616 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618 let result = rule.check(&ctx).unwrap();
619
620 assert_eq!(result.len(), 1);
621 assert!(result[0].message.contains("YAML"));
622 assert!(result[0].message.contains("not sorted"));
623 assert!(result[0].message.contains("author, date, title"));
624 }
625
626 #[test]
627 fn test_yaml_case_insensitive_sort() {
628 let rule = create_enabled_rule();
629 let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631 let result = rule.check(&ctx).unwrap();
632
633 assert!(result.is_empty());
635 }
636
637 #[test]
638 fn test_yaml_fix_sorts_keys() {
639 let rule = create_enabled_rule();
640 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642 let fixed = rule.fix(&ctx).unwrap();
643
644 let author_pos = fixed.find("author:").unwrap();
646 let title_pos = fixed.find("title:").unwrap();
647 assert!(author_pos < title_pos);
648 }
649
650 #[test]
651 fn test_yaml_no_fix_with_comments() {
652 let rule = create_enabled_rule();
653 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
655 let result = rule.check(&ctx).unwrap();
656
657 assert_eq!(result.len(), 1);
658 assert!(result[0].message.contains("Auto-fix unavailable"));
659 assert!(result[0].fix.is_none());
660
661 let fixed = rule.fix(&ctx).unwrap();
663 assert_eq!(fixed, content);
664 }
665
666 #[test]
667 fn test_yaml_single_key() {
668 let rule = create_enabled_rule();
669 let content = "---\ntitle: Test\n---\n\n# Heading";
670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671 let result = rule.check(&ctx).unwrap();
672
673 assert!(result.is_empty());
675 }
676
677 #[test]
678 fn test_yaml_nested_keys_ignored() {
679 let rule = create_enabled_rule();
680 let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683 let result = rule.check(&ctx).unwrap();
684
685 assert!(result.is_empty());
687 }
688
689 #[test]
690 fn test_yaml_fix_idempotent() {
691 let rule = create_enabled_rule();
692 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694 let fixed_once = rule.fix(&ctx).unwrap();
695
696 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
697 let fixed_twice = rule.fix(&ctx2).unwrap();
698
699 assert_eq!(fixed_once, fixed_twice);
700 }
701
702 #[test]
703 fn test_yaml_complex_values() {
704 let rule = create_enabled_rule();
705 let content =
707 "---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
709 let result = rule.check(&ctx).unwrap();
710
711 assert!(result.is_empty());
713 }
714
715 #[test]
718 fn test_toml_sorted_keys() {
719 let rule = create_enabled_rule();
720 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722 let result = rule.check(&ctx).unwrap();
723
724 assert!(result.is_empty());
725 }
726
727 #[test]
728 fn test_toml_unsorted_keys() {
729 let rule = create_enabled_rule();
730 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732 let result = rule.check(&ctx).unwrap();
733
734 assert_eq!(result.len(), 1);
735 assert!(result[0].message.contains("TOML"));
736 assert!(result[0].message.contains("not sorted"));
737 }
738
739 #[test]
740 fn test_toml_fix_sorts_keys() {
741 let rule = create_enabled_rule();
742 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744 let fixed = rule.fix(&ctx).unwrap();
745
746 let author_pos = fixed.find("author").unwrap();
748 let title_pos = fixed.find("title").unwrap();
749 assert!(author_pos < title_pos);
750 }
751
752 #[test]
753 fn test_toml_no_fix_with_comments() {
754 let rule = create_enabled_rule();
755 let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let result = rule.check(&ctx).unwrap();
758
759 assert_eq!(result.len(), 1);
760 assert!(result[0].message.contains("Auto-fix unavailable"));
761
762 let fixed = rule.fix(&ctx).unwrap();
764 assert_eq!(fixed, content);
765 }
766
767 #[test]
770 fn test_json_sorted_keys() {
771 let rule = create_enabled_rule();
772 let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774 let result = rule.check(&ctx).unwrap();
775
776 assert!(result.is_empty());
777 }
778
779 #[test]
780 fn test_json_unsorted_keys() {
781 let rule = create_enabled_rule();
782 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784 let result = rule.check(&ctx).unwrap();
785
786 assert_eq!(result.len(), 1);
787 assert!(result[0].message.contains("JSON"));
788 assert!(result[0].message.contains("not sorted"));
789 }
790
791 #[test]
792 fn test_json_fix_sorts_keys() {
793 let rule = create_enabled_rule();
794 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796 let fixed = rule.fix(&ctx).unwrap();
797
798 let author_pos = fixed.find("author").unwrap();
800 let title_pos = fixed.find("title").unwrap();
801 assert!(author_pos < title_pos);
802 }
803
804 #[test]
805 fn test_json_always_fixable() {
806 let rule = create_enabled_rule();
807 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
810 let result = rule.check(&ctx).unwrap();
811
812 assert_eq!(result.len(), 1);
813 assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
815 }
816
817 #[test]
820 fn test_empty_content() {
821 let rule = create_enabled_rule();
822 let content = "";
823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
824 let result = rule.check(&ctx).unwrap();
825
826 assert!(result.is_empty());
827 }
828
829 #[test]
830 fn test_empty_frontmatter() {
831 let rule = create_enabled_rule();
832 let content = "---\n---\n\n# Heading";
833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
834 let result = rule.check(&ctx).unwrap();
835
836 assert!(result.is_empty());
837 }
838
839 #[test]
840 fn test_toml_nested_tables_ignored() {
841 let rule = create_enabled_rule();
843 let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
845 let result = rule.check(&ctx).unwrap();
846
847 assert_eq!(result.len(), 1);
849 assert!(result[0].message.contains("sort_by, title"));
850 assert!(!result[0].message.contains("we_have_extra"));
851 }
852
853 #[test]
854 fn test_toml_nested_taxonomies_ignored() {
855 let rule = create_enabled_rule();
857 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859 let result = rule.check(&ctx).unwrap();
860
861 assert_eq!(result.len(), 1);
863 assert!(result[0].message.contains("date, title"));
864 assert!(!result[0].message.contains("categories"));
865 assert!(!result[0].message.contains("tags"));
866 }
867
868 #[test]
871 fn test_yaml_unicode_keys() {
872 let rule = create_enabled_rule();
873 let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876 let result = rule.check(&ctx).unwrap();
877
878 assert_eq!(result.len(), 1);
880 }
881
882 #[test]
883 fn test_yaml_keys_with_special_characters() {
884 let rule = create_enabled_rule();
885 let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888 let result = rule.check(&ctx).unwrap();
889
890 assert!(result.is_empty());
892 }
893
894 #[test]
895 fn test_yaml_keys_with_numbers() {
896 let rule = create_enabled_rule();
897 let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899 let result = rule.check(&ctx).unwrap();
900
901 assert!(result.is_empty());
903 }
904
905 #[test]
906 fn test_yaml_multiline_string_block_literal() {
907 let rule = create_enabled_rule();
908 let content =
909 "---\ndescription: |\n This is a\n multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911 let result = rule.check(&ctx).unwrap();
912
913 assert_eq!(result.len(), 1);
915 assert!(result[0].message.contains("author, description, title"));
916 }
917
918 #[test]
919 fn test_yaml_multiline_string_folded() {
920 let rule = create_enabled_rule();
921 let content = "---\ndescription: >\n This is a\n folded string\nauthor: John\n---\n\n# Heading";
922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923 let result = rule.check(&ctx).unwrap();
924
925 assert_eq!(result.len(), 1);
927 }
928
929 #[test]
930 fn test_yaml_fix_preserves_multiline_values() {
931 let rule = create_enabled_rule();
932 let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
934 let fixed = rule.fix(&ctx).unwrap();
935
936 let desc_pos = fixed.find("description").unwrap();
938 let title_pos = fixed.find("title").unwrap();
939 assert!(desc_pos < title_pos);
940 }
941
942 #[test]
943 fn test_yaml_quoted_keys() {
944 let rule = create_enabled_rule();
945 let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
947 let result = rule.check(&ctx).unwrap();
948
949 assert!(result.is_empty());
951 }
952
953 #[test]
954 fn test_yaml_duplicate_keys() {
955 let rule = create_enabled_rule();
957 let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
959 let result = rule.check(&ctx).unwrap();
960
961 assert_eq!(result.len(), 1);
963 }
964
965 #[test]
966 fn test_toml_inline_table() {
967 let rule = create_enabled_rule();
968 let content =
969 "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
971 let result = rule.check(&ctx).unwrap();
972
973 assert!(result.is_empty());
975 }
976
977 #[test]
978 fn test_toml_array_of_tables() {
979 let rule = create_enabled_rule();
980 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
982 let result = rule.check(&ctx).unwrap();
983
984 assert_eq!(result.len(), 1);
986 assert!(result[0].message.contains("date, title"));
987 }
988
989 #[test]
990 fn test_json_nested_objects() {
991 let rule = create_enabled_rule();
992 let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994 let result = rule.check(&ctx).unwrap();
995
996 assert!(result.is_empty());
998 }
999
1000 #[test]
1001 fn test_json_arrays() {
1002 let rule = create_enabled_rule();
1003 let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1005 let result = rule.check(&ctx).unwrap();
1006
1007 assert_eq!(result.len(), 1);
1009 }
1010
1011 #[test]
1012 fn test_fix_preserves_content_after_frontmatter() {
1013 let rule = create_enabled_rule();
1014 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1015 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1016 let fixed = rule.fix(&ctx).unwrap();
1017
1018 assert!(fixed.contains("# Heading"));
1020 assert!(fixed.contains("Paragraph 1."));
1021 assert!(fixed.contains("- List item"));
1022 assert!(fixed.contains("- Another item"));
1023 }
1024
1025 #[test]
1026 fn test_fix_yaml_produces_valid_yaml() {
1027 let rule = create_enabled_rule();
1028 let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1030 let fixed = rule.fix(&ctx).unwrap();
1031
1032 let lines: Vec<&str> = fixed.lines().collect();
1035 let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1036 let fm_content: String = lines[1..fm_end].join("\n");
1037
1038 let parsed: Result<serde_yml::Value, _> = serde_yml::from_str(&fm_content);
1040 assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1041 }
1042
1043 #[test]
1044 fn test_fix_toml_produces_valid_toml() {
1045 let rule = create_enabled_rule();
1046 let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1048 let fixed = rule.fix(&ctx).unwrap();
1049
1050 let lines: Vec<&str> = fixed.lines().collect();
1052 let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1053 let fm_content: String = lines[1..fm_end].join("\n");
1054
1055 let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1057 assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1058 }
1059
1060 #[test]
1061 fn test_fix_json_produces_valid_json() {
1062 let rule = create_enabled_rule();
1063 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065 let fixed = rule.fix(&ctx).unwrap();
1066
1067 let json_end = fixed.find("\n\n").unwrap();
1069 let json_content = &fixed[..json_end];
1070
1071 let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1073 assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1074 }
1075
1076 #[test]
1077 fn test_many_keys_performance() {
1078 let rule = create_enabled_rule();
1079 let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1081 keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1083
1084 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1085 let result = rule.check(&ctx).unwrap();
1086
1087 assert_eq!(result.len(), 1);
1089 }
1090
1091 #[test]
1092 fn test_yaml_empty_value() {
1093 let rule = create_enabled_rule();
1094 let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1096 let result = rule.check(&ctx).unwrap();
1097
1098 assert_eq!(result.len(), 1);
1100 }
1101
1102 #[test]
1103 fn test_yaml_null_value() {
1104 let rule = create_enabled_rule();
1105 let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1106 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1107 let result = rule.check(&ctx).unwrap();
1108
1109 assert_eq!(result.len(), 1);
1110 }
1111
1112 #[test]
1113 fn test_yaml_boolean_values() {
1114 let rule = create_enabled_rule();
1115 let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117 let result = rule.check(&ctx).unwrap();
1118
1119 assert_eq!(result.len(), 1);
1121 }
1122
1123 #[test]
1124 fn test_toml_boolean_values() {
1125 let rule = create_enabled_rule();
1126 let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1128 let result = rule.check(&ctx).unwrap();
1129
1130 assert_eq!(result.len(), 1);
1131 }
1132
1133 #[test]
1134 fn test_yaml_list_at_top_level() {
1135 let rule = create_enabled_rule();
1136 let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
1137 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1138 let result = rule.check(&ctx).unwrap();
1139
1140 assert_eq!(result.len(), 1);
1142 }
1143
1144 #[test]
1145 fn test_three_keys_all_orderings() {
1146 let rule = create_enabled_rule();
1147
1148 let orderings = [
1150 ("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), ];
1157
1158 for (name, content, should_pass) in orderings {
1159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160 let result = rule.check(&ctx).unwrap();
1161 assert_eq!(
1162 result.is_empty(),
1163 should_pass,
1164 "Ordering {name} should {} pass",
1165 if should_pass { "" } else { "not" }
1166 );
1167 }
1168 }
1169
1170 #[test]
1171 fn test_crlf_line_endings() {
1172 let rule = create_enabled_rule();
1173 let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175 let result = rule.check(&ctx).unwrap();
1176
1177 assert_eq!(result.len(), 1);
1179 }
1180
1181 #[test]
1182 fn test_json_escaped_quotes_in_keys() {
1183 let rule = create_enabled_rule();
1184 let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187 let result = rule.check(&ctx).unwrap();
1188
1189 assert_eq!(result.len(), 1);
1191 }
1192}