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 #[serde(default, alias = "key-order")]
28 pub key_order: Option<Vec<String>>,
29}
30
31impl RuleConfig for MD072Config {
32 const RULE_NAME: &'static str = "MD072";
33}
34
35#[derive(Clone, Default)]
48pub struct MD072FrontmatterKeySort {
49 config: MD072Config,
50}
51
52impl MD072FrontmatterKeySort {
53 pub fn new() -> Self {
54 Self::default()
55 }
56
57 pub fn from_config_struct(config: MD072Config) -> Self {
59 Self { config }
60 }
61
62 fn has_comments(frontmatter_lines: &[&str]) -> bool {
64 frontmatter_lines.iter().any(|line| line.trim_start().starts_with('#'))
65 }
66
67 fn extract_yaml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
69 let mut keys = Vec::new();
70
71 for (idx, line) in frontmatter_lines.iter().enumerate() {
72 if !line.starts_with(' ')
74 && !line.starts_with('\t')
75 && let Some(colon_pos) = line.find(':')
76 {
77 let key = line[..colon_pos].trim();
78 if !key.is_empty() && !key.starts_with('#') {
79 keys.push((idx, key.to_string()));
80 }
81 }
82 }
83
84 keys
85 }
86
87 fn extract_toml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
89 let mut keys = Vec::new();
90
91 for (idx, line) in frontmatter_lines.iter().enumerate() {
92 let trimmed = line.trim();
93 if trimmed.is_empty() || trimmed.starts_with('#') {
95 continue;
96 }
97 if trimmed.starts_with('[') {
99 break;
100 }
101 if !line.starts_with(' ')
103 && !line.starts_with('\t')
104 && let Some(eq_pos) = line.find('=')
105 {
106 let key = line[..eq_pos].trim();
107 if !key.is_empty() {
108 keys.push((idx, key.to_string()));
109 }
110 }
111 }
112
113 keys
114 }
115
116 fn extract_json_keys(frontmatter_lines: &[&str]) -> Vec<String> {
118 let mut keys = Vec::new();
123 let mut depth: usize = 0;
124
125 for line in frontmatter_lines {
126 let line_start_depth = depth;
128
129 for ch in line.chars() {
131 match ch {
132 '{' | '[' => depth += 1,
133 '}' | ']' => depth = depth.saturating_sub(1),
134 _ => {}
135 }
136 }
137
138 if line_start_depth == 0
140 && let Some(captures) = JSON_KEY_PATTERN.captures(line)
141 && let Some(key_match) = captures.get(1)
142 {
143 keys.push(key_match.as_str().to_string());
144 }
145 }
146
147 keys
148 }
149
150 fn key_sort_position(key: &str, key_order: Option<&[String]>) -> (usize, String) {
154 if let Some(order) = key_order {
155 let key_lower = key.to_lowercase();
157 for (idx, ordered_key) in order.iter().enumerate() {
158 if ordered_key.to_lowercase() == key_lower {
159 return (idx, key_lower);
160 }
161 }
162 (usize::MAX, key_lower)
164 } else {
165 (0, key.to_lowercase())
167 }
168 }
169
170 fn find_first_unsorted_pair<'a>(keys: &'a [String], key_order: Option<&[String]>) -> Option<(&'a str, &'a str)> {
173 for i in 1..keys.len() {
174 let pos_curr = Self::key_sort_position(&keys[i], key_order);
175 let pos_prev = Self::key_sort_position(&keys[i - 1], key_order);
176 if pos_curr < pos_prev {
177 return Some((&keys[i], &keys[i - 1]));
178 }
179 }
180 None
181 }
182
183 fn find_first_unsorted_indexed_pair<'a>(
186 keys: &'a [(usize, String)],
187 key_order: Option<&[String]>,
188 ) -> Option<(&'a str, &'a str)> {
189 for i in 1..keys.len() {
190 let pos_curr = Self::key_sort_position(&keys[i].1, key_order);
191 let pos_prev = Self::key_sort_position(&keys[i - 1].1, key_order);
192 if pos_curr < pos_prev {
193 return Some((&keys[i].1, &keys[i - 1].1));
194 }
195 }
196 None
197 }
198
199 fn are_keys_sorted(keys: &[String], key_order: Option<&[String]>) -> bool {
201 Self::find_first_unsorted_pair(keys, key_order).is_none()
202 }
203
204 fn are_indexed_keys_sorted(keys: &[(usize, String)], key_order: Option<&[String]>) -> bool {
206 Self::find_first_unsorted_indexed_pair(keys, key_order).is_none()
207 }
208
209 fn sort_keys_by_order(keys: &mut [(String, Vec<&str>)], key_order: Option<&[String]>) {
211 keys.sort_by(|a, b| {
212 let pos_a = Self::key_sort_position(&a.0, key_order);
213 let pos_b = Self::key_sort_position(&b.0, key_order);
214 pos_a.cmp(&pos_b)
215 });
216 }
217}
218
219impl Rule for MD072FrontmatterKeySort {
220 fn name(&self) -> &'static str {
221 "MD072"
222 }
223
224 fn description(&self) -> &'static str {
225 "Frontmatter keys should be sorted alphabetically"
226 }
227
228 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
229 let content = ctx.content;
230 let mut warnings = Vec::new();
231
232 if content.is_empty() {
233 return Ok(warnings);
234 }
235
236 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
237
238 match fm_type {
239 FrontMatterType::Yaml => {
240 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
241 if frontmatter_lines.is_empty() {
242 return Ok(warnings);
243 }
244
245 let keys = Self::extract_yaml_keys(&frontmatter_lines);
246 let key_order = self.config.key_order.as_deref();
247 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_indexed_pair(&keys, key_order)
248 else {
249 return Ok(warnings);
250 };
251
252 let has_comments = Self::has_comments(&frontmatter_lines);
253
254 let fix = if has_comments {
255 None
256 } else {
257 match self.fix_yaml(content) {
259 Ok(fixed_content) if fixed_content != content => Some(Fix {
260 range: 0..content.len(),
261 replacement: fixed_content,
262 }),
263 _ => None,
264 }
265 };
266
267 let message = if has_comments {
268 format!(
269 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
270 )
271 } else {
272 format!(
273 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
274 )
275 };
276
277 warnings.push(LintWarning {
278 rule_name: Some(self.name().to_string()),
279 message,
280 line: 2, column: 1,
282 end_line: 2,
283 end_column: 1,
284 severity: Severity::Warning,
285 fix,
286 });
287 }
288 FrontMatterType::Toml => {
289 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
290 if frontmatter_lines.is_empty() {
291 return Ok(warnings);
292 }
293
294 let keys = Self::extract_toml_keys(&frontmatter_lines);
295 let key_order = self.config.key_order.as_deref();
296 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_indexed_pair(&keys, key_order)
297 else {
298 return Ok(warnings);
299 };
300
301 let has_comments = Self::has_comments(&frontmatter_lines);
302
303 let fix = if has_comments {
304 None
305 } else {
306 match self.fix_toml(content) {
308 Ok(fixed_content) if fixed_content != content => Some(Fix {
309 range: 0..content.len(),
310 replacement: fixed_content,
311 }),
312 _ => None,
313 }
314 };
315
316 let message = if has_comments {
317 format!(
318 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
319 )
320 } else {
321 format!(
322 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
323 )
324 };
325
326 warnings.push(LintWarning {
327 rule_name: Some(self.name().to_string()),
328 message,
329 line: 2, column: 1,
331 end_line: 2,
332 end_column: 1,
333 severity: Severity::Warning,
334 fix,
335 });
336 }
337 FrontMatterType::Json => {
338 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
339 if frontmatter_lines.is_empty() {
340 return Ok(warnings);
341 }
342
343 let keys = Self::extract_json_keys(&frontmatter_lines);
344 let key_order = self.config.key_order.as_deref();
345 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_pair(&keys, key_order) else {
346 return Ok(warnings);
347 };
348
349 let fix = match self.fix_json(content) {
351 Ok(fixed_content) if fixed_content != content => Some(Fix {
352 range: 0..content.len(),
353 replacement: fixed_content,
354 }),
355 _ => None,
356 };
357
358 let message = format!(
359 "JSON frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
360 );
361
362 warnings.push(LintWarning {
363 rule_name: Some(self.name().to_string()),
364 message,
365 line: 2, column: 1,
367 end_line: 2,
368 end_column: 1,
369 severity: Severity::Warning,
370 fix,
371 });
372 }
373 _ => {
374 }
376 }
377
378 Ok(warnings)
379 }
380
381 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
382 let content = ctx.content;
383
384 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
385
386 match fm_type {
387 FrontMatterType::Yaml => self.fix_yaml(content),
388 FrontMatterType::Toml => self.fix_toml(content),
389 FrontMatterType::Json => self.fix_json(content),
390 _ => Ok(content.to_string()),
391 }
392 }
393
394 fn category(&self) -> RuleCategory {
395 RuleCategory::FrontMatter
396 }
397
398 fn as_any(&self) -> &dyn std::any::Any {
399 self
400 }
401
402 fn default_config_section(&self) -> Option<(String, toml::Value)> {
403 let default_config = MD072Config::default();
404 let json_value = serde_json::to_value(&default_config).ok()?;
405 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
406
407 if let toml::Value::Table(table) = toml_value {
408 if !table.is_empty() {
409 Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
410 } else {
411 let mut table = toml::map::Map::new();
413 table.insert("enabled".to_string(), toml::Value::Boolean(false));
414 Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
415 }
416 } else {
417 None
418 }
419 }
420
421 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
422 where
423 Self: Sized,
424 {
425 let rule_config = crate::rule_config_serde::load_rule_config::<MD072Config>(config);
426 Box::new(Self::from_config_struct(rule_config))
427 }
428}
429
430impl MD072FrontmatterKeySort {
431 fn fix_yaml(&self, content: &str) -> Result<String, LintError> {
432 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
433 if frontmatter_lines.is_empty() {
434 return Ok(content.to_string());
435 }
436
437 if Self::has_comments(&frontmatter_lines) {
439 return Ok(content.to_string());
440 }
441
442 let keys = Self::extract_yaml_keys(&frontmatter_lines);
443 let key_order = self.config.key_order.as_deref();
444 if Self::are_indexed_keys_sorted(&keys, key_order) {
445 return Ok(content.to_string());
446 }
447
448 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
451
452 for (i, (line_idx, key)) in keys.iter().enumerate() {
453 let start = *line_idx;
454 let end = if i + 1 < keys.len() {
455 keys[i + 1].0
456 } else {
457 frontmatter_lines.len()
458 };
459
460 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
461 key_blocks.push((key.clone(), block_lines));
462 }
463
464 Self::sort_keys_by_order(&mut key_blocks, key_order);
466
467 let content_lines: Vec<&str> = content.lines().collect();
469 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
470
471 let mut result = String::new();
472 result.push_str("---\n");
473 for (_, lines) in &key_blocks {
474 for line in lines {
475 result.push_str(line);
476 result.push('\n');
477 }
478 }
479 result.push_str("---");
480
481 if fm_end < content_lines.len() {
482 result.push('\n');
483 result.push_str(&content_lines[fm_end..].join("\n"));
484 }
485
486 Ok(result)
487 }
488
489 fn fix_toml(&self, content: &str) -> Result<String, LintError> {
490 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
491 if frontmatter_lines.is_empty() {
492 return Ok(content.to_string());
493 }
494
495 if Self::has_comments(&frontmatter_lines) {
497 return Ok(content.to_string());
498 }
499
500 let keys = Self::extract_toml_keys(&frontmatter_lines);
501 let key_order = self.config.key_order.as_deref();
502 if Self::are_indexed_keys_sorted(&keys, key_order) {
503 return Ok(content.to_string());
504 }
505
506 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
509
510 for (i, (line_idx, key)) in keys.iter().enumerate() {
511 let start = *line_idx;
512 let end = if i + 1 < keys.len() {
513 keys[i + 1].0
514 } else {
515 frontmatter_lines.len()
516 };
517
518 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
519 key_blocks.push((key.clone(), block_lines));
520 }
521
522 Self::sort_keys_by_order(&mut key_blocks, key_order);
524
525 let content_lines: Vec<&str> = content.lines().collect();
527 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
528
529 let mut result = String::new();
530 result.push_str("+++\n");
531 for (_, lines) in &key_blocks {
532 for line in lines {
533 result.push_str(line);
534 result.push('\n');
535 }
536 }
537 result.push_str("+++");
538
539 if fm_end < content_lines.len() {
540 result.push('\n');
541 result.push_str(&content_lines[fm_end..].join("\n"));
542 }
543
544 Ok(result)
545 }
546
547 fn fix_json(&self, content: &str) -> Result<String, LintError> {
548 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
549 if frontmatter_lines.is_empty() {
550 return Ok(content.to_string());
551 }
552
553 let keys = Self::extract_json_keys(&frontmatter_lines);
554 let key_order = self.config.key_order.as_deref();
555
556 if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
557 return Ok(content.to_string());
558 }
559
560 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
562
563 match serde_json::from_str::<serde_json::Value>(&json_content) {
565 Ok(serde_json::Value::Object(map)) => {
566 let mut sorted_map = serde_json::Map::new();
568 let mut keys: Vec<_> = map.keys().cloned().collect();
569 keys.sort_by(|a, b| {
570 let pos_a = Self::key_sort_position(a, key_order);
571 let pos_b = Self::key_sort_position(b, key_order);
572 pos_a.cmp(&pos_b)
573 });
574
575 for key in keys {
576 if let Some(value) = map.get(&key) {
577 sorted_map.insert(key, value.clone());
578 }
579 }
580
581 match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
582 Ok(sorted_json) => {
583 let lines: Vec<&str> = content.lines().collect();
584 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
585
586 let mut result = String::new();
589 result.push_str(&sorted_json);
590
591 if fm_end < lines.len() {
592 result.push('\n');
593 result.push_str(&lines[fm_end..].join("\n"));
594 }
595
596 Ok(result)
597 }
598 Err(_) => Ok(content.to_string()),
599 }
600 }
601 _ => Ok(content.to_string()),
602 }
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609 use crate::lint_context::LintContext;
610
611 fn create_enabled_rule() -> MD072FrontmatterKeySort {
613 MD072FrontmatterKeySort::from_config_struct(MD072Config {
614 enabled: true,
615 key_order: None,
616 })
617 }
618
619 fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
621 MD072FrontmatterKeySort::from_config_struct(MD072Config {
622 enabled: true,
623 key_order: Some(keys.into_iter().map(String::from).collect()),
624 })
625 }
626
627 #[test]
630 fn test_enabled_via_config() {
631 let rule = create_enabled_rule();
632 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
634 let result = rule.check(&ctx).unwrap();
635
636 assert_eq!(result.len(), 1);
638 }
639
640 #[test]
643 fn test_no_frontmatter() {
644 let rule = create_enabled_rule();
645 let content = "# Heading\n\nContent.";
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
647 let result = rule.check(&ctx).unwrap();
648
649 assert!(result.is_empty());
650 }
651
652 #[test]
653 fn test_yaml_sorted_keys() {
654 let rule = create_enabled_rule();
655 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657 let result = rule.check(&ctx).unwrap();
658
659 assert!(result.is_empty());
660 }
661
662 #[test]
663 fn test_yaml_unsorted_keys() {
664 let rule = create_enabled_rule();
665 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667 let result = rule.check(&ctx).unwrap();
668
669 assert_eq!(result.len(), 1);
670 assert!(result[0].message.contains("YAML"));
671 assert!(result[0].message.contains("not sorted"));
672 assert!(result[0].message.contains("'author' should come before 'title'"));
674 }
675
676 #[test]
677 fn test_yaml_case_insensitive_sort() {
678 let rule = create_enabled_rule();
679 let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681 let result = rule.check(&ctx).unwrap();
682
683 assert!(result.is_empty());
685 }
686
687 #[test]
688 fn test_yaml_fix_sorts_keys() {
689 let rule = create_enabled_rule();
690 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692 let fixed = rule.fix(&ctx).unwrap();
693
694 let author_pos = fixed.find("author:").unwrap();
696 let title_pos = fixed.find("title:").unwrap();
697 assert!(author_pos < title_pos);
698 }
699
700 #[test]
701 fn test_yaml_no_fix_with_comments() {
702 let rule = create_enabled_rule();
703 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
705 let result = rule.check(&ctx).unwrap();
706
707 assert_eq!(result.len(), 1);
708 assert!(result[0].message.contains("auto-fix unavailable"));
709 assert!(result[0].fix.is_none());
710
711 let fixed = rule.fix(&ctx).unwrap();
713 assert_eq!(fixed, content);
714 }
715
716 #[test]
717 fn test_yaml_single_key() {
718 let rule = create_enabled_rule();
719 let content = "---\ntitle: Test\n---\n\n# Heading";
720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
721 let result = rule.check(&ctx).unwrap();
722
723 assert!(result.is_empty());
725 }
726
727 #[test]
728 fn test_yaml_nested_keys_ignored() {
729 let rule = create_enabled_rule();
730 let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733 let result = rule.check(&ctx).unwrap();
734
735 assert!(result.is_empty());
737 }
738
739 #[test]
740 fn test_yaml_fix_idempotent() {
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_once = rule.fix(&ctx).unwrap();
745
746 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
747 let fixed_twice = rule.fix(&ctx2).unwrap();
748
749 assert_eq!(fixed_once, fixed_twice);
750 }
751
752 #[test]
753 fn test_yaml_complex_values() {
754 let rule = create_enabled_rule();
755 let content =
757 "---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let result = rule.check(&ctx).unwrap();
760
761 assert!(result.is_empty());
763 }
764
765 #[test]
768 fn test_toml_sorted_keys() {
769 let rule = create_enabled_rule();
770 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
772 let result = rule.check(&ctx).unwrap();
773
774 assert!(result.is_empty());
775 }
776
777 #[test]
778 fn test_toml_unsorted_keys() {
779 let rule = create_enabled_rule();
780 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
782 let result = rule.check(&ctx).unwrap();
783
784 assert_eq!(result.len(), 1);
785 assert!(result[0].message.contains("TOML"));
786 assert!(result[0].message.contains("not sorted"));
787 }
788
789 #[test]
790 fn test_toml_fix_sorts_keys() {
791 let rule = create_enabled_rule();
792 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794 let fixed = rule.fix(&ctx).unwrap();
795
796 let author_pos = fixed.find("author").unwrap();
798 let title_pos = fixed.find("title").unwrap();
799 assert!(author_pos < title_pos);
800 }
801
802 #[test]
803 fn test_toml_no_fix_with_comments() {
804 let rule = create_enabled_rule();
805 let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
807 let result = rule.check(&ctx).unwrap();
808
809 assert_eq!(result.len(), 1);
810 assert!(result[0].message.contains("auto-fix unavailable"));
811
812 let fixed = rule.fix(&ctx).unwrap();
814 assert_eq!(fixed, content);
815 }
816
817 #[test]
820 fn test_json_sorted_keys() {
821 let rule = create_enabled_rule();
822 let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
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_json_unsorted_keys() {
831 let rule = create_enabled_rule();
832 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\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_eq!(result.len(), 1);
837 assert!(result[0].message.contains("JSON"));
838 assert!(result[0].message.contains("not sorted"));
839 }
840
841 #[test]
842 fn test_json_fix_sorts_keys() {
843 let rule = create_enabled_rule();
844 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846 let fixed = rule.fix(&ctx).unwrap();
847
848 let author_pos = fixed.find("author").unwrap();
850 let title_pos = fixed.find("title").unwrap();
851 assert!(author_pos < title_pos);
852 }
853
854 #[test]
855 fn test_json_always_fixable() {
856 let rule = create_enabled_rule();
857 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
860 let result = rule.check(&ctx).unwrap();
861
862 assert_eq!(result.len(), 1);
863 assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
865 }
866
867 #[test]
870 fn test_empty_content() {
871 let rule = create_enabled_rule();
872 let content = "";
873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874 let result = rule.check(&ctx).unwrap();
875
876 assert!(result.is_empty());
877 }
878
879 #[test]
880 fn test_empty_frontmatter() {
881 let rule = create_enabled_rule();
882 let 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!(result.is_empty());
887 }
888
889 #[test]
890 fn test_toml_nested_tables_ignored() {
891 let rule = create_enabled_rule();
893 let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
895 let result = rule.check(&ctx).unwrap();
896
897 assert_eq!(result.len(), 1);
899 assert!(result[0].message.contains("'sort_by' should come before 'title'"));
901 assert!(!result[0].message.contains("we_have_extra"));
902 }
903
904 #[test]
905 fn test_toml_nested_taxonomies_ignored() {
906 let rule = create_enabled_rule();
908 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
910 let result = rule.check(&ctx).unwrap();
911
912 assert_eq!(result.len(), 1);
914 assert!(result[0].message.contains("'date' should come before 'title'"));
916 assert!(!result[0].message.contains("categories"));
917 assert!(!result[0].message.contains("tags"));
918 }
919
920 #[test]
923 fn test_yaml_unicode_keys() {
924 let rule = create_enabled_rule();
925 let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
928 let result = rule.check(&ctx).unwrap();
929
930 assert_eq!(result.len(), 1);
932 }
933
934 #[test]
935 fn test_yaml_keys_with_special_characters() {
936 let rule = create_enabled_rule();
937 let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940 let result = rule.check(&ctx).unwrap();
941
942 assert!(result.is_empty());
944 }
945
946 #[test]
947 fn test_yaml_keys_with_numbers() {
948 let rule = create_enabled_rule();
949 let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
951 let result = rule.check(&ctx).unwrap();
952
953 assert!(result.is_empty());
955 }
956
957 #[test]
958 fn test_yaml_multiline_string_block_literal() {
959 let rule = create_enabled_rule();
960 let content =
961 "---\ndescription: |\n This is a\n multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963 let result = rule.check(&ctx).unwrap();
964
965 assert_eq!(result.len(), 1);
967 assert!(result[0].message.contains("'author' should come before 'title'"));
968 }
969
970 #[test]
971 fn test_yaml_multiline_string_folded() {
972 let rule = create_enabled_rule();
973 let content = "---\ndescription: >\n This is a\n folded string\nauthor: John\n---\n\n# Heading";
974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975 let result = rule.check(&ctx).unwrap();
976
977 assert_eq!(result.len(), 1);
979 }
980
981 #[test]
982 fn test_yaml_fix_preserves_multiline_values() {
983 let rule = create_enabled_rule();
984 let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
985 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
986 let fixed = rule.fix(&ctx).unwrap();
987
988 let desc_pos = fixed.find("description").unwrap();
990 let title_pos = fixed.find("title").unwrap();
991 assert!(desc_pos < title_pos);
992 }
993
994 #[test]
995 fn test_yaml_quoted_keys() {
996 let rule = create_enabled_rule();
997 let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
999 let result = rule.check(&ctx).unwrap();
1000
1001 assert!(result.is_empty());
1003 }
1004
1005 #[test]
1006 fn test_yaml_duplicate_keys() {
1007 let rule = create_enabled_rule();
1009 let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
1010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011 let result = rule.check(&ctx).unwrap();
1012
1013 assert_eq!(result.len(), 1);
1015 }
1016
1017 #[test]
1018 fn test_toml_inline_table() {
1019 let rule = create_enabled_rule();
1020 let content =
1021 "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
1022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1023 let result = rule.check(&ctx).unwrap();
1024
1025 assert!(result.is_empty());
1027 }
1028
1029 #[test]
1030 fn test_toml_array_of_tables() {
1031 let rule = create_enabled_rule();
1032 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1034 let result = rule.check(&ctx).unwrap();
1035
1036 assert_eq!(result.len(), 1);
1038 assert!(result[0].message.contains("'date' should come before 'title'"));
1040 }
1041
1042 #[test]
1043 fn test_json_nested_objects() {
1044 let rule = create_enabled_rule();
1045 let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1047 let result = rule.check(&ctx).unwrap();
1048
1049 assert!(result.is_empty());
1051 }
1052
1053 #[test]
1054 fn test_json_arrays() {
1055 let rule = create_enabled_rule();
1056 let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1058 let result = rule.check(&ctx).unwrap();
1059
1060 assert_eq!(result.len(), 1);
1062 }
1063
1064 #[test]
1065 fn test_fix_preserves_content_after_frontmatter() {
1066 let rule = create_enabled_rule();
1067 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1068 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1069 let fixed = rule.fix(&ctx).unwrap();
1070
1071 assert!(fixed.contains("# Heading"));
1073 assert!(fixed.contains("Paragraph 1."));
1074 assert!(fixed.contains("- List item"));
1075 assert!(fixed.contains("- Another item"));
1076 }
1077
1078 #[test]
1079 fn test_fix_yaml_produces_valid_yaml() {
1080 let rule = create_enabled_rule();
1081 let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1083 let fixed = rule.fix(&ctx).unwrap();
1084
1085 let lines: Vec<&str> = fixed.lines().collect();
1088 let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1089 let fm_content: String = lines[1..fm_end].join("\n");
1090
1091 let parsed: Result<serde_yml::Value, _> = serde_yml::from_str(&fm_content);
1093 assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1094 }
1095
1096 #[test]
1097 fn test_fix_toml_produces_valid_toml() {
1098 let rule = create_enabled_rule();
1099 let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1100 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1101 let fixed = rule.fix(&ctx).unwrap();
1102
1103 let lines: Vec<&str> = fixed.lines().collect();
1105 let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1106 let fm_content: String = lines[1..fm_end].join("\n");
1107
1108 let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1110 assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1111 }
1112
1113 #[test]
1114 fn test_fix_json_produces_valid_json() {
1115 let rule = create_enabled_rule();
1116 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1118 let fixed = rule.fix(&ctx).unwrap();
1119
1120 let json_end = fixed.find("\n\n").unwrap();
1122 let json_content = &fixed[..json_end];
1123
1124 let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1126 assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1127 }
1128
1129 #[test]
1130 fn test_many_keys_performance() {
1131 let rule = create_enabled_rule();
1132 let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1134 keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1136
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_yaml_empty_value() {
1146 let rule = create_enabled_rule();
1147 let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1149 let result = rule.check(&ctx).unwrap();
1150
1151 assert_eq!(result.len(), 1);
1153 }
1154
1155 #[test]
1156 fn test_yaml_null_value() {
1157 let rule = create_enabled_rule();
1158 let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160 let result = rule.check(&ctx).unwrap();
1161
1162 assert_eq!(result.len(), 1);
1163 }
1164
1165 #[test]
1166 fn test_yaml_boolean_values() {
1167 let rule = create_enabled_rule();
1168 let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170 let result = rule.check(&ctx).unwrap();
1171
1172 assert_eq!(result.len(), 1);
1174 }
1175
1176 #[test]
1177 fn test_toml_boolean_values() {
1178 let rule = create_enabled_rule();
1179 let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181 let result = rule.check(&ctx).unwrap();
1182
1183 assert_eq!(result.len(), 1);
1184 }
1185
1186 #[test]
1187 fn test_yaml_list_at_top_level() {
1188 let rule = create_enabled_rule();
1189 let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
1190 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1191 let result = rule.check(&ctx).unwrap();
1192
1193 assert_eq!(result.len(), 1);
1195 }
1196
1197 #[test]
1198 fn test_three_keys_all_orderings() {
1199 let rule = create_enabled_rule();
1200
1201 let orderings = [
1203 ("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), ];
1210
1211 for (name, content, should_pass) in orderings {
1212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213 let result = rule.check(&ctx).unwrap();
1214 assert_eq!(
1215 result.is_empty(),
1216 should_pass,
1217 "Ordering {name} should {} pass",
1218 if should_pass { "" } else { "not" }
1219 );
1220 }
1221 }
1222
1223 #[test]
1224 fn test_crlf_line_endings() {
1225 let rule = create_enabled_rule();
1226 let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1228 let result = rule.check(&ctx).unwrap();
1229
1230 assert_eq!(result.len(), 1);
1232 }
1233
1234 #[test]
1235 fn test_json_escaped_quotes_in_keys() {
1236 let rule = create_enabled_rule();
1237 let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240 let result = rule.check(&ctx).unwrap();
1241
1242 assert_eq!(result.len(), 1);
1244 }
1245
1246 #[test]
1249 fn test_warning_fix_yaml_sorts_keys() {
1250 let rule = create_enabled_rule();
1251 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1253 let warnings = rule.check(&ctx).unwrap();
1254
1255 assert_eq!(warnings.len(), 1);
1256 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1257
1258 let fix = warnings[0].fix.as_ref().unwrap();
1259 assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1260
1261 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1263
1264 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1266 let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1267 assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1268 }
1269
1270 #[test]
1271 fn test_warning_fix_preserves_yaml_list_indentation() {
1272 let rule = create_enabled_rule();
1273 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1274 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1275 let warnings = rule.check(&ctx).unwrap();
1276
1277 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1278
1279 assert!(
1281 fixed.contains(" - hello"),
1282 "List indentation should be preserved: {fixed}"
1283 );
1284 assert!(
1285 fixed.contains(" - world"),
1286 "List indentation should be preserved: {fixed}"
1287 );
1288 }
1289
1290 #[test]
1291 fn test_warning_fix_preserves_nested_object_indentation() {
1292 let rule = create_enabled_rule();
1293 let content = "---\nzzzz: value\naaaa:\n nested_key: nested_value\n another: 123\n---\n\n# Heading\n";
1294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1295 let warnings = rule.check(&ctx).unwrap();
1296
1297 assert_eq!(warnings.len(), 1);
1298 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1299
1300 let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1302 let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1303 assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1304
1305 assert!(
1307 fixed.contains(" nested_key: nested_value"),
1308 "Nested object indentation should be preserved: {fixed}"
1309 );
1310 assert!(
1311 fixed.contains(" another: 123"),
1312 "Nested object indentation should be preserved: {fixed}"
1313 );
1314 }
1315
1316 #[test]
1317 fn test_warning_fix_preserves_deeply_nested_structure() {
1318 let rule = create_enabled_rule();
1319 let content = "---\nzzz: top\naaa:\n level1:\n level2:\n - item1\n - item2\n---\n\n# Content\n";
1320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1321 let warnings = rule.check(&ctx).unwrap();
1322
1323 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1324
1325 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1327 let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1328 assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1329
1330 assert!(fixed.contains(" level1:"), "2-space indent should be preserved");
1332 assert!(fixed.contains(" level2:"), "4-space indent should be preserved");
1333 assert!(fixed.contains(" - item1"), "6-space indent should be preserved");
1334 assert!(fixed.contains(" - item2"), "6-space indent should be preserved");
1335 }
1336
1337 #[test]
1338 fn test_warning_fix_toml_sorts_keys() {
1339 let rule = create_enabled_rule();
1340 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1342 let warnings = rule.check(&ctx).unwrap();
1343
1344 assert_eq!(warnings.len(), 1);
1345 assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1346
1347 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1348
1349 let author_pos = fixed.find("author").expect("author should exist");
1351 let title_pos = fixed.find("title").expect("title should exist");
1352 assert!(author_pos < title_pos, "author should come before title");
1353 }
1354
1355 #[test]
1356 fn test_warning_fix_json_sorts_keys() {
1357 let rule = create_enabled_rule();
1358 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1360 let warnings = rule.check(&ctx).unwrap();
1361
1362 assert_eq!(warnings.len(), 1);
1363 assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1364
1365 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1366
1367 let author_pos = fixed.find("author").expect("author should exist");
1369 let title_pos = fixed.find("title").expect("title should exist");
1370 assert!(author_pos < title_pos, "author should come before title");
1371 }
1372
1373 #[test]
1374 fn test_warning_fix_no_fix_when_comments_present() {
1375 let rule = create_enabled_rule();
1376 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378 let warnings = rule.check(&ctx).unwrap();
1379
1380 assert_eq!(warnings.len(), 1);
1381 assert!(
1382 warnings[0].fix.is_none(),
1383 "Warning should NOT have a fix when comments are present"
1384 );
1385 assert!(
1386 warnings[0].message.contains("auto-fix unavailable"),
1387 "Message should indicate auto-fix is unavailable"
1388 );
1389 }
1390
1391 #[test]
1392 fn test_warning_fix_preserves_content_after_frontmatter() {
1393 let rule = create_enabled_rule();
1394 let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1396 let warnings = rule.check(&ctx).unwrap();
1397
1398 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1399
1400 assert!(fixed.contains("# Heading"), "Heading should be preserved");
1402 assert!(
1403 fixed.contains("Paragraph with content."),
1404 "Paragraph should be preserved"
1405 );
1406 assert!(fixed.contains("- List item"), "List item should be preserved");
1407 }
1408
1409 #[test]
1410 fn test_warning_fix_idempotent() {
1411 let rule = create_enabled_rule();
1412 let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1414 let warnings = rule.check(&ctx).unwrap();
1415
1416 let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1417
1418 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1420 let warnings2 = rule.check(&ctx2).unwrap();
1421
1422 assert!(
1423 warnings2.is_empty(),
1424 "After fixing, no more warnings should be produced"
1425 );
1426 }
1427
1428 #[test]
1429 fn test_warning_fix_preserves_multiline_block_literal() {
1430 let rule = create_enabled_rule();
1431 let content = "---\nzzz: simple\naaa: |\n Line 1 of block\n Line 2 of block\n---\n\n# Heading\n";
1432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1433 let warnings = rule.check(&ctx).unwrap();
1434
1435 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1436
1437 assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1439 assert!(
1440 fixed.contains(" Line 1 of block"),
1441 "Block literal line 1 should be preserved with indent"
1442 );
1443 assert!(
1444 fixed.contains(" Line 2 of block"),
1445 "Block literal line 2 should be preserved with indent"
1446 );
1447 }
1448
1449 #[test]
1450 fn test_warning_fix_preserves_folded_string() {
1451 let rule = create_enabled_rule();
1452 let content = "---\nzzz: simple\naaa: >\n Folded line 1\n Folded line 2\n---\n\n# Content\n";
1453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1454 let warnings = rule.check(&ctx).unwrap();
1455
1456 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1457
1458 assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1460 assert!(
1461 fixed.contains(" Folded line 1"),
1462 "Folded line 1 should be preserved with indent"
1463 );
1464 assert!(
1465 fixed.contains(" Folded line 2"),
1466 "Folded line 2 should be preserved with indent"
1467 );
1468 }
1469
1470 #[test]
1471 fn test_warning_fix_preserves_4_space_indentation() {
1472 let rule = create_enabled_rule();
1473 let content = "---\nzzz: value\naaa:\n nested: with_4_spaces\n another: value\n---\n\n# Heading\n";
1475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476 let warnings = rule.check(&ctx).unwrap();
1477
1478 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1479
1480 assert!(
1482 fixed.contains(" nested: with_4_spaces"),
1483 "4-space indentation should be preserved: {fixed}"
1484 );
1485 assert!(
1486 fixed.contains(" another: value"),
1487 "4-space indentation should be preserved: {fixed}"
1488 );
1489 }
1490
1491 #[test]
1492 fn test_warning_fix_preserves_tab_indentation() {
1493 let rule = create_enabled_rule();
1494 let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497 let warnings = rule.check(&ctx).unwrap();
1498
1499 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1500
1501 assert!(
1503 fixed.contains("\tnested: with_tab"),
1504 "Tab indentation should be preserved: {fixed}"
1505 );
1506 assert!(
1507 fixed.contains("\tanother: value"),
1508 "Tab indentation should be preserved: {fixed}"
1509 );
1510 }
1511
1512 #[test]
1513 fn test_warning_fix_preserves_inline_list() {
1514 let rule = create_enabled_rule();
1515 let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1518 let warnings = rule.check(&ctx).unwrap();
1519
1520 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1521
1522 assert!(
1524 fixed.contains("aaa: [one, two, three]"),
1525 "Inline list should be preserved exactly: {fixed}"
1526 );
1527 }
1528
1529 #[test]
1530 fn test_warning_fix_preserves_quoted_strings() {
1531 let rule = create_enabled_rule();
1532 let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1535 let warnings = rule.check(&ctx).unwrap();
1536
1537 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1538
1539 assert!(
1541 fixed.contains("aaa: \"value with: colon\""),
1542 "Double-quoted string should be preserved: {fixed}"
1543 );
1544 assert!(
1545 fixed.contains("bbb: 'single quotes'"),
1546 "Single-quoted string should be preserved: {fixed}"
1547 );
1548 }
1549
1550 #[test]
1553 fn test_yaml_custom_key_order_sorted() {
1554 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1556 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1558 let result = rule.check(&ctx).unwrap();
1559
1560 assert!(result.is_empty());
1562 }
1563
1564 #[test]
1565 fn test_yaml_custom_key_order_unsorted() {
1566 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1568 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1570 let result = rule.check(&ctx).unwrap();
1571
1572 assert_eq!(result.len(), 1);
1573 assert!(result[0].message.contains("'date' should come before 'author'"));
1575 }
1576
1577 #[test]
1578 fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1579 let rule = create_rule_with_key_order(vec!["title"]);
1581 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1583 let result = rule.check(&ctx).unwrap();
1584
1585 assert!(result.is_empty());
1588 }
1589
1590 #[test]
1591 fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1592 let rule = create_rule_with_key_order(vec!["title"]);
1594 let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596 let result = rule.check(&ctx).unwrap();
1597
1598 assert_eq!(result.len(), 1);
1600 assert!(result[0].message.contains("'author' should come before 'zebra'"));
1601 }
1602
1603 #[test]
1604 fn test_yaml_custom_key_order_fix() {
1605 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1606 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1608 let fixed = rule.fix(&ctx).unwrap();
1609
1610 let title_pos = fixed.find("title:").unwrap();
1612 let date_pos = fixed.find("date:").unwrap();
1613 let author_pos = fixed.find("author:").unwrap();
1614 assert!(
1615 title_pos < date_pos && date_pos < author_pos,
1616 "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1617 );
1618 }
1619
1620 #[test]
1621 fn test_yaml_custom_key_order_fix_with_unlisted() {
1622 let rule = create_rule_with_key_order(vec!["title", "author"]);
1624 let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
1625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626 let fixed = rule.fix(&ctx).unwrap();
1627
1628 let title_pos = fixed.find("title:").unwrap();
1630 let author_pos = fixed.find("author:").unwrap();
1631 let aardvark_pos = fixed.find("aardvark:").unwrap();
1632 let zebra_pos = fixed.find("zebra:").unwrap();
1633
1634 assert!(
1635 title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1636 "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1637 );
1638 }
1639
1640 #[test]
1641 fn test_toml_custom_key_order_sorted() {
1642 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1643 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
1644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1645 let result = rule.check(&ctx).unwrap();
1646
1647 assert!(result.is_empty());
1648 }
1649
1650 #[test]
1651 fn test_toml_custom_key_order_unsorted() {
1652 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1653 let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
1654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1655 let result = rule.check(&ctx).unwrap();
1656
1657 assert_eq!(result.len(), 1);
1658 assert!(result[0].message.contains("TOML"));
1659 }
1660
1661 #[test]
1662 fn test_json_custom_key_order_sorted() {
1663 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1664 let content = "{\n \"title\": \"Test\",\n \"date\": \"2024-01-01\",\n \"author\": \"John\"\n}\n\n# Heading";
1665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1666 let result = rule.check(&ctx).unwrap();
1667
1668 assert!(result.is_empty());
1669 }
1670
1671 #[test]
1672 fn test_json_custom_key_order_unsorted() {
1673 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1674 let content = "{\n \"author\": \"John\",\n \"title\": \"Test\",\n \"date\": \"2024-01-01\"\n}\n\n# Heading";
1675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1676 let result = rule.check(&ctx).unwrap();
1677
1678 assert_eq!(result.len(), 1);
1679 assert!(result[0].message.contains("JSON"));
1680 }
1681
1682 #[test]
1683 fn test_key_order_case_insensitive_match() {
1684 let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
1686 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1688 let result = rule.check(&ctx).unwrap();
1689
1690 assert!(result.is_empty());
1692 }
1693
1694 #[test]
1695 fn test_key_order_partial_match() {
1696 let rule = create_rule_with_key_order(vec!["title"]);
1698 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1700 let result = rule.check(&ctx).unwrap();
1701
1702 assert_eq!(result.len(), 1);
1713 assert!(result[0].message.contains("'author' should come before 'date'"));
1714 }
1715
1716 #[test]
1719 fn test_key_order_empty_array_falls_back_to_alphabetical() {
1720 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1722 enabled: true,
1723 key_order: Some(vec![]),
1724 });
1725 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1727 let result = rule.check(&ctx).unwrap();
1728
1729 assert_eq!(result.len(), 1);
1732 assert!(result[0].message.contains("'author' should come before 'title'"));
1733 }
1734
1735 #[test]
1736 fn test_key_order_single_key() {
1737 let rule = create_rule_with_key_order(vec!["title"]);
1739 let content = "---\ntitle: Test\n---\n\n# Heading";
1740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1741 let result = rule.check(&ctx).unwrap();
1742
1743 assert!(result.is_empty());
1744 }
1745
1746 #[test]
1747 fn test_key_order_all_keys_specified() {
1748 let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
1750 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1752 let result = rule.check(&ctx).unwrap();
1753
1754 assert!(result.is_empty());
1755 }
1756
1757 #[test]
1758 fn test_key_order_no_keys_match() {
1759 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1761 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1763 let result = rule.check(&ctx).unwrap();
1764
1765 assert!(result.is_empty());
1768 }
1769
1770 #[test]
1771 fn test_key_order_no_keys_match_unsorted() {
1772 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1774 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1776 let result = rule.check(&ctx).unwrap();
1777
1778 assert_eq!(result.len(), 1);
1781 }
1782
1783 #[test]
1784 fn test_key_order_duplicate_keys_in_config() {
1785 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1787 enabled: true,
1788 key_order: Some(vec![
1789 "title".to_string(),
1790 "author".to_string(),
1791 "title".to_string(), ]),
1793 });
1794 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1796 let result = rule.check(&ctx).unwrap();
1797
1798 assert!(result.is_empty());
1800 }
1801
1802 #[test]
1803 fn test_key_order_with_comments_still_skips_fix() {
1804 let rule = create_rule_with_key_order(vec!["title", "author"]);
1806 let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
1807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1808 let result = rule.check(&ctx).unwrap();
1809
1810 assert_eq!(result.len(), 1);
1812 assert!(result[0].message.contains("auto-fix unavailable"));
1813 assert!(result[0].fix.is_none());
1814 }
1815
1816 #[test]
1817 fn test_toml_custom_key_order_fix() {
1818 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1819 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
1820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1821 let fixed = rule.fix(&ctx).unwrap();
1822
1823 let title_pos = fixed.find("title").unwrap();
1825 let date_pos = fixed.find("date").unwrap();
1826 let author_pos = fixed.find("author").unwrap();
1827 assert!(
1828 title_pos < date_pos && date_pos < author_pos,
1829 "Fixed TOML should have keys in custom order. Got:\n{fixed}"
1830 );
1831 }
1832
1833 #[test]
1834 fn test_json_custom_key_order_fix() {
1835 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1836 let content = "{\n \"author\": \"John\",\n \"date\": \"2024-01-01\",\n \"title\": \"Test\"\n}\n\n# Heading";
1837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1838 let fixed = rule.fix(&ctx).unwrap();
1839
1840 let title_pos = fixed.find("\"title\"").unwrap();
1842 let date_pos = fixed.find("\"date\"").unwrap();
1843 let author_pos = fixed.find("\"author\"").unwrap();
1844 assert!(
1845 title_pos < date_pos && date_pos < author_pos,
1846 "Fixed JSON should have keys in custom order. Got:\n{fixed}"
1847 );
1848 }
1849
1850 #[test]
1851 fn test_key_order_unicode_keys() {
1852 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1854 enabled: true,
1855 key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
1856 });
1857 let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
1858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1859 let result = rule.check(&ctx).unwrap();
1860
1861 assert!(result.is_empty());
1863 }
1864
1865 #[test]
1866 fn test_key_order_mixed_specified_and_unlisted_boundary() {
1867 let rule = create_rule_with_key_order(vec!["z_last_specified"]);
1869 let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
1870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1871 let result = rule.check(&ctx).unwrap();
1872
1873 assert!(result.is_empty());
1876 }
1877
1878 #[test]
1879 fn test_key_order_fix_preserves_values() {
1880 let rule = create_rule_with_key_order(vec!["title", "tags"]);
1882 let content = "---\ntags:\n - rust\n - markdown\ntitle: Test\n---\n\n# Heading";
1883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1884 let fixed = rule.fix(&ctx).unwrap();
1885
1886 let title_pos = fixed.find("title:").unwrap();
1888 let tags_pos = fixed.find("tags:").unwrap();
1889 assert!(title_pos < tags_pos, "title should come before tags");
1890
1891 assert!(fixed.contains("- rust"), "List items should be preserved");
1893 assert!(fixed.contains("- markdown"), "List items should be preserved");
1894 }
1895
1896 #[test]
1897 fn test_key_order_idempotent_fix() {
1898 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1900 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1902
1903 let fixed_once = rule.fix(&ctx).unwrap();
1904 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1905 let fixed_twice = rule.fix(&ctx2).unwrap();
1906
1907 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1908 }
1909
1910 #[test]
1911 fn test_key_order_respects_later_position_over_alphabetical() {
1912 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
1914 let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
1915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1916 let result = rule.check(&ctx).unwrap();
1917
1918 assert!(result.is_empty());
1920 }
1921
1922 #[test]
1923 fn test_key_order_detects_wrong_custom_order() {
1924 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
1926 let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
1927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1928 let result = rule.check(&ctx).unwrap();
1929
1930 assert_eq!(result.len(), 1);
1931 assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
1932 }
1933}