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 if !self.config.enabled {
230 return Ok(Vec::new());
231 }
232
233 let content = ctx.content;
234 let mut warnings = Vec::new();
235
236 if content.is_empty() {
237 return Ok(warnings);
238 }
239
240 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
241
242 match fm_type {
243 FrontMatterType::Yaml => {
244 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
245 if frontmatter_lines.is_empty() {
246 return Ok(warnings);
247 }
248
249 let keys = Self::extract_yaml_keys(&frontmatter_lines);
250 let key_order = self.config.key_order.as_deref();
251 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_indexed_pair(&keys, key_order)
252 else {
253 return Ok(warnings);
254 };
255
256 let has_comments = Self::has_comments(&frontmatter_lines);
257
258 let fix = if has_comments {
259 None
260 } else {
261 match self.fix_yaml(content) {
263 Ok(fixed_content) if fixed_content != content => Some(Fix {
264 range: 0..content.len(),
265 replacement: fixed_content,
266 }),
267 _ => None,
268 }
269 };
270
271 let message = if has_comments {
272 format!(
273 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
274 )
275 } else {
276 format!(
277 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
278 )
279 };
280
281 warnings.push(LintWarning {
282 rule_name: Some(self.name().to_string()),
283 message,
284 line: 2, column: 1,
286 end_line: 2,
287 end_column: 1,
288 severity: Severity::Warning,
289 fix,
290 });
291 }
292 FrontMatterType::Toml => {
293 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
294 if frontmatter_lines.is_empty() {
295 return Ok(warnings);
296 }
297
298 let keys = Self::extract_toml_keys(&frontmatter_lines);
299 let key_order = self.config.key_order.as_deref();
300 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_indexed_pair(&keys, key_order)
301 else {
302 return Ok(warnings);
303 };
304
305 let has_comments = Self::has_comments(&frontmatter_lines);
306
307 let fix = if has_comments {
308 None
309 } else {
310 match self.fix_toml(content) {
312 Ok(fixed_content) if fixed_content != content => Some(Fix {
313 range: 0..content.len(),
314 replacement: fixed_content,
315 }),
316 _ => None,
317 }
318 };
319
320 let message = if has_comments {
321 format!(
322 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
323 )
324 } else {
325 format!(
326 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
327 )
328 };
329
330 warnings.push(LintWarning {
331 rule_name: Some(self.name().to_string()),
332 message,
333 line: 2, column: 1,
335 end_line: 2,
336 end_column: 1,
337 severity: Severity::Warning,
338 fix,
339 });
340 }
341 FrontMatterType::Json => {
342 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
343 if frontmatter_lines.is_empty() {
344 return Ok(warnings);
345 }
346
347 let keys = Self::extract_json_keys(&frontmatter_lines);
348 let key_order = self.config.key_order.as_deref();
349 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_pair(&keys, key_order) else {
350 return Ok(warnings);
351 };
352
353 let fix = match self.fix_json(content) {
355 Ok(fixed_content) if fixed_content != content => Some(Fix {
356 range: 0..content.len(),
357 replacement: fixed_content,
358 }),
359 _ => None,
360 };
361
362 let message = format!(
363 "JSON frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
364 );
365
366 warnings.push(LintWarning {
367 rule_name: Some(self.name().to_string()),
368 message,
369 line: 2, column: 1,
371 end_line: 2,
372 end_column: 1,
373 severity: Severity::Warning,
374 fix,
375 });
376 }
377 _ => {
378 }
380 }
381
382 Ok(warnings)
383 }
384
385 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
386 if !self.config.enabled {
387 return Ok(ctx.content.to_string());
388 }
389
390 let content = ctx.content;
391
392 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
393
394 match fm_type {
395 FrontMatterType::Yaml => self.fix_yaml(content),
396 FrontMatterType::Toml => self.fix_toml(content),
397 FrontMatterType::Json => self.fix_json(content),
398 _ => Ok(content.to_string()),
399 }
400 }
401
402 fn category(&self) -> RuleCategory {
403 RuleCategory::FrontMatter
404 }
405
406 fn as_any(&self) -> &dyn std::any::Any {
407 self
408 }
409
410 fn default_config_section(&self) -> Option<(String, toml::Value)> {
411 let default_config = MD072Config::default();
412 let json_value = serde_json::to_value(&default_config).ok()?;
413 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
414
415 if let toml::Value::Table(table) = toml_value {
416 if !table.is_empty() {
417 Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
418 } else {
419 let mut table = toml::map::Map::new();
421 table.insert("enabled".to_string(), toml::Value::Boolean(false));
422 Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
423 }
424 } else {
425 None
426 }
427 }
428
429 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
430 where
431 Self: Sized,
432 {
433 let rule_config = crate::rule_config_serde::load_rule_config::<MD072Config>(config);
434 Box::new(Self::from_config_struct(rule_config))
435 }
436}
437
438impl MD072FrontmatterKeySort {
439 fn fix_yaml(&self, content: &str) -> Result<String, LintError> {
440 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
441 if frontmatter_lines.is_empty() {
442 return Ok(content.to_string());
443 }
444
445 if Self::has_comments(&frontmatter_lines) {
447 return Ok(content.to_string());
448 }
449
450 let keys = Self::extract_yaml_keys(&frontmatter_lines);
451 let key_order = self.config.key_order.as_deref();
452 if Self::are_indexed_keys_sorted(&keys, key_order) {
453 return Ok(content.to_string());
454 }
455
456 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
459
460 for (i, (line_idx, key)) in keys.iter().enumerate() {
461 let start = *line_idx;
462 let end = if i + 1 < keys.len() {
463 keys[i + 1].0
464 } else {
465 frontmatter_lines.len()
466 };
467
468 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
469 key_blocks.push((key.clone(), block_lines));
470 }
471
472 Self::sort_keys_by_order(&mut key_blocks, key_order);
474
475 let content_lines: Vec<&str> = content.lines().collect();
477 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
478
479 let mut result = String::new();
480 result.push_str("---\n");
481 for (_, lines) in &key_blocks {
482 for line in lines {
483 result.push_str(line);
484 result.push('\n');
485 }
486 }
487 result.push_str("---");
488
489 if fm_end < content_lines.len() {
490 result.push('\n');
491 result.push_str(&content_lines[fm_end..].join("\n"));
492 }
493
494 Ok(result)
495 }
496
497 fn fix_toml(&self, content: &str) -> Result<String, LintError> {
498 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
499 if frontmatter_lines.is_empty() {
500 return Ok(content.to_string());
501 }
502
503 if Self::has_comments(&frontmatter_lines) {
505 return Ok(content.to_string());
506 }
507
508 let keys = Self::extract_toml_keys(&frontmatter_lines);
509 let key_order = self.config.key_order.as_deref();
510 if Self::are_indexed_keys_sorted(&keys, key_order) {
511 return Ok(content.to_string());
512 }
513
514 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
517
518 for (i, (line_idx, key)) in keys.iter().enumerate() {
519 let start = *line_idx;
520 let end = if i + 1 < keys.len() {
521 keys[i + 1].0
522 } else {
523 frontmatter_lines.len()
524 };
525
526 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
527 key_blocks.push((key.clone(), block_lines));
528 }
529
530 Self::sort_keys_by_order(&mut key_blocks, key_order);
532
533 let content_lines: Vec<&str> = content.lines().collect();
535 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
536
537 let mut result = String::new();
538 result.push_str("+++\n");
539 for (_, lines) in &key_blocks {
540 for line in lines {
541 result.push_str(line);
542 result.push('\n');
543 }
544 }
545 result.push_str("+++");
546
547 if fm_end < content_lines.len() {
548 result.push('\n');
549 result.push_str(&content_lines[fm_end..].join("\n"));
550 }
551
552 Ok(result)
553 }
554
555 fn fix_json(&self, content: &str) -> Result<String, LintError> {
556 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
557 if frontmatter_lines.is_empty() {
558 return Ok(content.to_string());
559 }
560
561 let keys = Self::extract_json_keys(&frontmatter_lines);
562 let key_order = self.config.key_order.as_deref();
563
564 if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
565 return Ok(content.to_string());
566 }
567
568 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
570
571 match serde_json::from_str::<serde_json::Value>(&json_content) {
573 Ok(serde_json::Value::Object(map)) => {
574 let mut sorted_map = serde_json::Map::new();
576 let mut keys: Vec<_> = map.keys().cloned().collect();
577 keys.sort_by(|a, b| {
578 let pos_a = Self::key_sort_position(a, key_order);
579 let pos_b = Self::key_sort_position(b, key_order);
580 pos_a.cmp(&pos_b)
581 });
582
583 for key in keys {
584 if let Some(value) = map.get(&key) {
585 sorted_map.insert(key, value.clone());
586 }
587 }
588
589 match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
590 Ok(sorted_json) => {
591 let lines: Vec<&str> = content.lines().collect();
592 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
593
594 let mut result = String::new();
597 result.push_str(&sorted_json);
598
599 if fm_end < lines.len() {
600 result.push('\n');
601 result.push_str(&lines[fm_end..].join("\n"));
602 }
603
604 Ok(result)
605 }
606 Err(_) => Ok(content.to_string()),
607 }
608 }
609 _ => Ok(content.to_string()),
610 }
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::lint_context::LintContext;
618
619 fn create_enabled_rule() -> MD072FrontmatterKeySort {
621 MD072FrontmatterKeySort::from_config_struct(MD072Config {
622 enabled: true,
623 key_order: None,
624 })
625 }
626
627 fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
629 MD072FrontmatterKeySort::from_config_struct(MD072Config {
630 enabled: true,
631 key_order: Some(keys.into_iter().map(String::from).collect()),
632 })
633 }
634
635 #[test]
638 fn test_disabled_by_default() {
639 let rule = MD072FrontmatterKeySort::new();
640 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642 let result = rule.check(&ctx).unwrap();
643
644 assert!(result.is_empty());
646 }
647
648 #[test]
649 fn test_enabled_via_config() {
650 let rule = create_enabled_rule();
651 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653 let result = rule.check(&ctx).unwrap();
654
655 assert_eq!(result.len(), 1);
657 }
658
659 #[test]
662 fn test_no_frontmatter() {
663 let rule = create_enabled_rule();
664 let content = "# Heading\n\nContent.";
665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666 let result = rule.check(&ctx).unwrap();
667
668 assert!(result.is_empty());
669 }
670
671 #[test]
672 fn test_yaml_sorted_keys() {
673 let rule = create_enabled_rule();
674 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
676 let result = rule.check(&ctx).unwrap();
677
678 assert!(result.is_empty());
679 }
680
681 #[test]
682 fn test_yaml_unsorted_keys() {
683 let rule = create_enabled_rule();
684 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
686 let result = rule.check(&ctx).unwrap();
687
688 assert_eq!(result.len(), 1);
689 assert!(result[0].message.contains("YAML"));
690 assert!(result[0].message.contains("not sorted"));
691 assert!(result[0].message.contains("'author' should come before 'title'"));
693 }
694
695 #[test]
696 fn test_yaml_case_insensitive_sort() {
697 let rule = create_enabled_rule();
698 let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let result = rule.check(&ctx).unwrap();
701
702 assert!(result.is_empty());
704 }
705
706 #[test]
707 fn test_yaml_fix_sorts_keys() {
708 let rule = create_enabled_rule();
709 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711 let fixed = rule.fix(&ctx).unwrap();
712
713 let author_pos = fixed.find("author:").unwrap();
715 let title_pos = fixed.find("title:").unwrap();
716 assert!(author_pos < title_pos);
717 }
718
719 #[test]
720 fn test_yaml_no_fix_with_comments() {
721 let rule = create_enabled_rule();
722 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724 let result = rule.check(&ctx).unwrap();
725
726 assert_eq!(result.len(), 1);
727 assert!(result[0].message.contains("auto-fix unavailable"));
728 assert!(result[0].fix.is_none());
729
730 let fixed = rule.fix(&ctx).unwrap();
732 assert_eq!(fixed, content);
733 }
734
735 #[test]
736 fn test_yaml_single_key() {
737 let rule = create_enabled_rule();
738 let content = "---\ntitle: Test\n---\n\n# Heading";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740 let result = rule.check(&ctx).unwrap();
741
742 assert!(result.is_empty());
744 }
745
746 #[test]
747 fn test_yaml_nested_keys_ignored() {
748 let rule = create_enabled_rule();
749 let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
752 let result = rule.check(&ctx).unwrap();
753
754 assert!(result.is_empty());
756 }
757
758 #[test]
759 fn test_yaml_fix_idempotent() {
760 let rule = create_enabled_rule();
761 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763 let fixed_once = rule.fix(&ctx).unwrap();
764
765 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
766 let fixed_twice = rule.fix(&ctx2).unwrap();
767
768 assert_eq!(fixed_once, fixed_twice);
769 }
770
771 #[test]
772 fn test_yaml_complex_values() {
773 let rule = create_enabled_rule();
774 let content =
776 "---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
778 let result = rule.check(&ctx).unwrap();
779
780 assert!(result.is_empty());
782 }
783
784 #[test]
787 fn test_toml_sorted_keys() {
788 let rule = create_enabled_rule();
789 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
791 let result = rule.check(&ctx).unwrap();
792
793 assert!(result.is_empty());
794 }
795
796 #[test]
797 fn test_toml_unsorted_keys() {
798 let rule = create_enabled_rule();
799 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801 let result = rule.check(&ctx).unwrap();
802
803 assert_eq!(result.len(), 1);
804 assert!(result[0].message.contains("TOML"));
805 assert!(result[0].message.contains("not sorted"));
806 }
807
808 #[test]
809 fn test_toml_fix_sorts_keys() {
810 let rule = create_enabled_rule();
811 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
813 let fixed = rule.fix(&ctx).unwrap();
814
815 let author_pos = fixed.find("author").unwrap();
817 let title_pos = fixed.find("title").unwrap();
818 assert!(author_pos < title_pos);
819 }
820
821 #[test]
822 fn test_toml_no_fix_with_comments() {
823 let rule = create_enabled_rule();
824 let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826 let result = rule.check(&ctx).unwrap();
827
828 assert_eq!(result.len(), 1);
829 assert!(result[0].message.contains("auto-fix unavailable"));
830
831 let fixed = rule.fix(&ctx).unwrap();
833 assert_eq!(fixed, content);
834 }
835
836 #[test]
839 fn test_json_sorted_keys() {
840 let rule = create_enabled_rule();
841 let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843 let result = rule.check(&ctx).unwrap();
844
845 assert!(result.is_empty());
846 }
847
848 #[test]
849 fn test_json_unsorted_keys() {
850 let rule = create_enabled_rule();
851 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853 let result = rule.check(&ctx).unwrap();
854
855 assert_eq!(result.len(), 1);
856 assert!(result[0].message.contains("JSON"));
857 assert!(result[0].message.contains("not sorted"));
858 }
859
860 #[test]
861 fn test_json_fix_sorts_keys() {
862 let rule = create_enabled_rule();
863 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865 let fixed = rule.fix(&ctx).unwrap();
866
867 let author_pos = fixed.find("author").unwrap();
869 let title_pos = fixed.find("title").unwrap();
870 assert!(author_pos < title_pos);
871 }
872
873 #[test]
874 fn test_json_always_fixable() {
875 let rule = create_enabled_rule();
876 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879 let result = rule.check(&ctx).unwrap();
880
881 assert_eq!(result.len(), 1);
882 assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
884 }
885
886 #[test]
889 fn test_empty_content() {
890 let rule = create_enabled_rule();
891 let content = "";
892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893 let result = rule.check(&ctx).unwrap();
894
895 assert!(result.is_empty());
896 }
897
898 #[test]
899 fn test_empty_frontmatter() {
900 let rule = create_enabled_rule();
901 let content = "---\n---\n\n# Heading";
902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
903 let result = rule.check(&ctx).unwrap();
904
905 assert!(result.is_empty());
906 }
907
908 #[test]
909 fn test_toml_nested_tables_ignored() {
910 let rule = create_enabled_rule();
912 let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
914 let result = rule.check(&ctx).unwrap();
915
916 assert_eq!(result.len(), 1);
918 assert!(result[0].message.contains("'sort_by' should come before 'title'"));
920 assert!(!result[0].message.contains("we_have_extra"));
921 }
922
923 #[test]
924 fn test_toml_nested_taxonomies_ignored() {
925 let rule = create_enabled_rule();
927 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
929 let result = rule.check(&ctx).unwrap();
930
931 assert_eq!(result.len(), 1);
933 assert!(result[0].message.contains("'date' should come before 'title'"));
935 assert!(!result[0].message.contains("categories"));
936 assert!(!result[0].message.contains("tags"));
937 }
938
939 #[test]
942 fn test_yaml_unicode_keys() {
943 let rule = create_enabled_rule();
944 let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\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_eq!(result.len(), 1);
951 }
952
953 #[test]
954 fn test_yaml_keys_with_special_characters() {
955 let rule = create_enabled_rule();
956 let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\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!(result.is_empty());
963 }
964
965 #[test]
966 fn test_yaml_keys_with_numbers() {
967 let rule = create_enabled_rule();
968 let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970 let result = rule.check(&ctx).unwrap();
971
972 assert!(result.is_empty());
974 }
975
976 #[test]
977 fn test_yaml_multiline_string_block_literal() {
978 let rule = create_enabled_rule();
979 let content =
980 "---\ndescription: |\n This is a\n multiline literal\ntitle: Test\nauthor: John\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("'author' should come before 'title'"));
987 }
988
989 #[test]
990 fn test_yaml_multiline_string_folded() {
991 let rule = create_enabled_rule();
992 let content = "---\ndescription: >\n This is a\n folded string\nauthor: John\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_eq!(result.len(), 1);
998 }
999
1000 #[test]
1001 fn test_yaml_fix_preserves_multiline_values() {
1002 let rule = create_enabled_rule();
1003 let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
1004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1005 let fixed = rule.fix(&ctx).unwrap();
1006
1007 let desc_pos = fixed.find("description").unwrap();
1009 let title_pos = fixed.find("title").unwrap();
1010 assert!(desc_pos < title_pos);
1011 }
1012
1013 #[test]
1014 fn test_yaml_quoted_keys() {
1015 let rule = create_enabled_rule();
1016 let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
1017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018 let result = rule.check(&ctx).unwrap();
1019
1020 assert!(result.is_empty());
1022 }
1023
1024 #[test]
1025 fn test_yaml_duplicate_keys() {
1026 let rule = create_enabled_rule();
1028 let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
1029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1030 let result = rule.check(&ctx).unwrap();
1031
1032 assert_eq!(result.len(), 1);
1034 }
1035
1036 #[test]
1037 fn test_toml_inline_table() {
1038 let rule = create_enabled_rule();
1039 let content =
1040 "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
1041 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1042 let result = rule.check(&ctx).unwrap();
1043
1044 assert!(result.is_empty());
1046 }
1047
1048 #[test]
1049 fn test_toml_array_of_tables() {
1050 let rule = create_enabled_rule();
1051 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
1052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053 let result = rule.check(&ctx).unwrap();
1054
1055 assert_eq!(result.len(), 1);
1057 assert!(result[0].message.contains("'date' should come before 'title'"));
1059 }
1060
1061 #[test]
1062 fn test_json_nested_objects() {
1063 let rule = create_enabled_rule();
1064 let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066 let result = rule.check(&ctx).unwrap();
1067
1068 assert!(result.is_empty());
1070 }
1071
1072 #[test]
1073 fn test_json_arrays() {
1074 let rule = create_enabled_rule();
1075 let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1076 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1077 let result = rule.check(&ctx).unwrap();
1078
1079 assert_eq!(result.len(), 1);
1081 }
1082
1083 #[test]
1084 fn test_fix_preserves_content_after_frontmatter() {
1085 let rule = create_enabled_rule();
1086 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1088 let fixed = rule.fix(&ctx).unwrap();
1089
1090 assert!(fixed.contains("# Heading"));
1092 assert!(fixed.contains("Paragraph 1."));
1093 assert!(fixed.contains("- List item"));
1094 assert!(fixed.contains("- Another item"));
1095 }
1096
1097 #[test]
1098 fn test_fix_yaml_produces_valid_yaml() {
1099 let rule = create_enabled_rule();
1100 let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1102 let fixed = rule.fix(&ctx).unwrap();
1103
1104 let lines: Vec<&str> = fixed.lines().collect();
1107 let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1108 let fm_content: String = lines[1..fm_end].join("\n");
1109
1110 let parsed: Result<serde_yml::Value, _> = serde_yml::from_str(&fm_content);
1112 assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1113 }
1114
1115 #[test]
1116 fn test_fix_toml_produces_valid_toml() {
1117 let rule = create_enabled_rule();
1118 let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1120 let fixed = rule.fix(&ctx).unwrap();
1121
1122 let lines: Vec<&str> = fixed.lines().collect();
1124 let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1125 let fm_content: String = lines[1..fm_end].join("\n");
1126
1127 let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1129 assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1130 }
1131
1132 #[test]
1133 fn test_fix_json_produces_valid_json() {
1134 let rule = create_enabled_rule();
1135 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1137 let fixed = rule.fix(&ctx).unwrap();
1138
1139 let json_end = fixed.find("\n\n").unwrap();
1141 let json_content = &fixed[..json_end];
1142
1143 let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1145 assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1146 }
1147
1148 #[test]
1149 fn test_many_keys_performance() {
1150 let rule = create_enabled_rule();
1151 let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1153 keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1155
1156 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1157 let result = rule.check(&ctx).unwrap();
1158
1159 assert_eq!(result.len(), 1);
1161 }
1162
1163 #[test]
1164 fn test_yaml_empty_value() {
1165 let rule = create_enabled_rule();
1166 let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1168 let result = rule.check(&ctx).unwrap();
1169
1170 assert_eq!(result.len(), 1);
1172 }
1173
1174 #[test]
1175 fn test_yaml_null_value() {
1176 let rule = create_enabled_rule();
1177 let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1179 let result = rule.check(&ctx).unwrap();
1180
1181 assert_eq!(result.len(), 1);
1182 }
1183
1184 #[test]
1185 fn test_yaml_boolean_values() {
1186 let rule = create_enabled_rule();
1187 let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1188 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1189 let result = rule.check(&ctx).unwrap();
1190
1191 assert_eq!(result.len(), 1);
1193 }
1194
1195 #[test]
1196 fn test_toml_boolean_values() {
1197 let rule = create_enabled_rule();
1198 let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200 let result = rule.check(&ctx).unwrap();
1201
1202 assert_eq!(result.len(), 1);
1203 }
1204
1205 #[test]
1206 fn test_yaml_list_at_top_level() {
1207 let rule = create_enabled_rule();
1208 let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210 let result = rule.check(&ctx).unwrap();
1211
1212 assert_eq!(result.len(), 1);
1214 }
1215
1216 #[test]
1217 fn test_three_keys_all_orderings() {
1218 let rule = create_enabled_rule();
1219
1220 let orderings = [
1222 ("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), ];
1229
1230 for (name, content, should_pass) in orderings {
1231 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1232 let result = rule.check(&ctx).unwrap();
1233 assert_eq!(
1234 result.is_empty(),
1235 should_pass,
1236 "Ordering {name} should {} pass",
1237 if should_pass { "" } else { "not" }
1238 );
1239 }
1240 }
1241
1242 #[test]
1243 fn test_crlf_line_endings() {
1244 let rule = create_enabled_rule();
1245 let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1247 let result = rule.check(&ctx).unwrap();
1248
1249 assert_eq!(result.len(), 1);
1251 }
1252
1253 #[test]
1254 fn test_json_escaped_quotes_in_keys() {
1255 let rule = create_enabled_rule();
1256 let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1259 let result = rule.check(&ctx).unwrap();
1260
1261 assert_eq!(result.len(), 1);
1263 }
1264
1265 #[test]
1268 fn test_warning_fix_yaml_sorts_keys() {
1269 let rule = create_enabled_rule();
1270 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272 let warnings = rule.check(&ctx).unwrap();
1273
1274 assert_eq!(warnings.len(), 1);
1275 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1276
1277 let fix = warnings[0].fix.as_ref().unwrap();
1278 assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1279
1280 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1282
1283 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1285 let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1286 assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1287 }
1288
1289 #[test]
1290 fn test_warning_fix_preserves_yaml_list_indentation() {
1291 let rule = create_enabled_rule();
1292 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294 let warnings = rule.check(&ctx).unwrap();
1295
1296 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1297
1298 assert!(
1300 fixed.contains(" - hello"),
1301 "List indentation should be preserved: {fixed}"
1302 );
1303 assert!(
1304 fixed.contains(" - world"),
1305 "List indentation should be preserved: {fixed}"
1306 );
1307 }
1308
1309 #[test]
1310 fn test_warning_fix_preserves_nested_object_indentation() {
1311 let rule = create_enabled_rule();
1312 let content = "---\nzzzz: value\naaaa:\n nested_key: nested_value\n another: 123\n---\n\n# Heading\n";
1313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1314 let warnings = rule.check(&ctx).unwrap();
1315
1316 assert_eq!(warnings.len(), 1);
1317 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1318
1319 let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1321 let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1322 assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1323
1324 assert!(
1326 fixed.contains(" nested_key: nested_value"),
1327 "Nested object indentation should be preserved: {fixed}"
1328 );
1329 assert!(
1330 fixed.contains(" another: 123"),
1331 "Nested object indentation should be preserved: {fixed}"
1332 );
1333 }
1334
1335 #[test]
1336 fn test_warning_fix_preserves_deeply_nested_structure() {
1337 let rule = create_enabled_rule();
1338 let content = "---\nzzz: top\naaa:\n level1:\n level2:\n - item1\n - item2\n---\n\n# Content\n";
1339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340 let warnings = rule.check(&ctx).unwrap();
1341
1342 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1343
1344 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1346 let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1347 assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1348
1349 assert!(fixed.contains(" level1:"), "2-space indent should be preserved");
1351 assert!(fixed.contains(" level2:"), "4-space indent should be preserved");
1352 assert!(fixed.contains(" - item1"), "6-space indent should be preserved");
1353 assert!(fixed.contains(" - item2"), "6-space indent should be preserved");
1354 }
1355
1356 #[test]
1357 fn test_warning_fix_toml_sorts_keys() {
1358 let rule = create_enabled_rule();
1359 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1361 let warnings = rule.check(&ctx).unwrap();
1362
1363 assert_eq!(warnings.len(), 1);
1364 assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1365
1366 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1367
1368 let author_pos = fixed.find("author").expect("author should exist");
1370 let title_pos = fixed.find("title").expect("title should exist");
1371 assert!(author_pos < title_pos, "author should come before title");
1372 }
1373
1374 #[test]
1375 fn test_warning_fix_json_sorts_keys() {
1376 let rule = create_enabled_rule();
1377 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1379 let warnings = rule.check(&ctx).unwrap();
1380
1381 assert_eq!(warnings.len(), 1);
1382 assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1383
1384 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1385
1386 let author_pos = fixed.find("author").expect("author should exist");
1388 let title_pos = fixed.find("title").expect("title should exist");
1389 assert!(author_pos < title_pos, "author should come before title");
1390 }
1391
1392 #[test]
1393 fn test_warning_fix_no_fix_when_comments_present() {
1394 let rule = create_enabled_rule();
1395 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1397 let warnings = rule.check(&ctx).unwrap();
1398
1399 assert_eq!(warnings.len(), 1);
1400 assert!(
1401 warnings[0].fix.is_none(),
1402 "Warning should NOT have a fix when comments are present"
1403 );
1404 assert!(
1405 warnings[0].message.contains("auto-fix unavailable"),
1406 "Message should indicate auto-fix is unavailable"
1407 );
1408 }
1409
1410 #[test]
1411 fn test_warning_fix_preserves_content_after_frontmatter() {
1412 let rule = create_enabled_rule();
1413 let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1415 let warnings = rule.check(&ctx).unwrap();
1416
1417 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1418
1419 assert!(fixed.contains("# Heading"), "Heading should be preserved");
1421 assert!(
1422 fixed.contains("Paragraph with content."),
1423 "Paragraph should be preserved"
1424 );
1425 assert!(fixed.contains("- List item"), "List item should be preserved");
1426 }
1427
1428 #[test]
1429 fn test_warning_fix_idempotent() {
1430 let rule = create_enabled_rule();
1431 let content = "---\nbbb: 2\naaa: 1\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_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1436
1437 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1439 let warnings2 = rule.check(&ctx2).unwrap();
1440
1441 assert!(
1442 warnings2.is_empty(),
1443 "After fixing, no more warnings should be produced"
1444 );
1445 }
1446
1447 #[test]
1448 fn test_warning_fix_preserves_multiline_block_literal() {
1449 let rule = create_enabled_rule();
1450 let content = "---\nzzz: simple\naaa: |\n Line 1 of block\n Line 2 of block\n---\n\n# Heading\n";
1451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1452 let warnings = rule.check(&ctx).unwrap();
1453
1454 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1455
1456 assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1458 assert!(
1459 fixed.contains(" Line 1 of block"),
1460 "Block literal line 1 should be preserved with indent"
1461 );
1462 assert!(
1463 fixed.contains(" Line 2 of block"),
1464 "Block literal line 2 should be preserved with indent"
1465 );
1466 }
1467
1468 #[test]
1469 fn test_warning_fix_preserves_folded_string() {
1470 let rule = create_enabled_rule();
1471 let content = "---\nzzz: simple\naaa: >\n Folded line 1\n Folded line 2\n---\n\n# Content\n";
1472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1473 let warnings = rule.check(&ctx).unwrap();
1474
1475 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1476
1477 assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1479 assert!(
1480 fixed.contains(" Folded line 1"),
1481 "Folded line 1 should be preserved with indent"
1482 );
1483 assert!(
1484 fixed.contains(" Folded line 2"),
1485 "Folded line 2 should be preserved with indent"
1486 );
1487 }
1488
1489 #[test]
1490 fn test_warning_fix_preserves_4_space_indentation() {
1491 let rule = create_enabled_rule();
1492 let content = "---\nzzz: value\naaa:\n nested: with_4_spaces\n another: value\n---\n\n# Heading\n";
1494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1495 let warnings = rule.check(&ctx).unwrap();
1496
1497 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1498
1499 assert!(
1501 fixed.contains(" nested: with_4_spaces"),
1502 "4-space indentation should be preserved: {fixed}"
1503 );
1504 assert!(
1505 fixed.contains(" another: value"),
1506 "4-space indentation should be preserved: {fixed}"
1507 );
1508 }
1509
1510 #[test]
1511 fn test_warning_fix_preserves_tab_indentation() {
1512 let rule = create_enabled_rule();
1513 let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1516 let warnings = rule.check(&ctx).unwrap();
1517
1518 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1519
1520 assert!(
1522 fixed.contains("\tnested: with_tab"),
1523 "Tab indentation should be preserved: {fixed}"
1524 );
1525 assert!(
1526 fixed.contains("\tanother: value"),
1527 "Tab indentation should be preserved: {fixed}"
1528 );
1529 }
1530
1531 #[test]
1532 fn test_warning_fix_preserves_inline_list() {
1533 let rule = create_enabled_rule();
1534 let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1537 let warnings = rule.check(&ctx).unwrap();
1538
1539 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1540
1541 assert!(
1543 fixed.contains("aaa: [one, two, three]"),
1544 "Inline list should be preserved exactly: {fixed}"
1545 );
1546 }
1547
1548 #[test]
1549 fn test_warning_fix_preserves_quoted_strings() {
1550 let rule = create_enabled_rule();
1551 let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1554 let warnings = rule.check(&ctx).unwrap();
1555
1556 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1557
1558 assert!(
1560 fixed.contains("aaa: \"value with: colon\""),
1561 "Double-quoted string should be preserved: {fixed}"
1562 );
1563 assert!(
1564 fixed.contains("bbb: 'single quotes'"),
1565 "Single-quoted string should be preserved: {fixed}"
1566 );
1567 }
1568
1569 #[test]
1572 fn test_yaml_custom_key_order_sorted() {
1573 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1575 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1577 let result = rule.check(&ctx).unwrap();
1578
1579 assert!(result.is_empty());
1581 }
1582
1583 #[test]
1584 fn test_yaml_custom_key_order_unsorted() {
1585 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1587 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1589 let result = rule.check(&ctx).unwrap();
1590
1591 assert_eq!(result.len(), 1);
1592 assert!(result[0].message.contains("'date' should come before 'author'"));
1594 }
1595
1596 #[test]
1597 fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1598 let rule = create_rule_with_key_order(vec!["title"]);
1600 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1602 let result = rule.check(&ctx).unwrap();
1603
1604 assert!(result.is_empty());
1607 }
1608
1609 #[test]
1610 fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1611 let rule = create_rule_with_key_order(vec!["title"]);
1613 let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1615 let result = rule.check(&ctx).unwrap();
1616
1617 assert_eq!(result.len(), 1);
1619 assert!(result[0].message.contains("'author' should come before 'zebra'"));
1620 }
1621
1622 #[test]
1623 fn test_yaml_custom_key_order_fix() {
1624 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1625 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1627 let fixed = rule.fix(&ctx).unwrap();
1628
1629 let title_pos = fixed.find("title:").unwrap();
1631 let date_pos = fixed.find("date:").unwrap();
1632 let author_pos = fixed.find("author:").unwrap();
1633 assert!(
1634 title_pos < date_pos && date_pos < author_pos,
1635 "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1636 );
1637 }
1638
1639 #[test]
1640 fn test_yaml_custom_key_order_fix_with_unlisted() {
1641 let rule = create_rule_with_key_order(vec!["title", "author"]);
1643 let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
1644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1645 let fixed = rule.fix(&ctx).unwrap();
1646
1647 let title_pos = fixed.find("title:").unwrap();
1649 let author_pos = fixed.find("author:").unwrap();
1650 let aardvark_pos = fixed.find("aardvark:").unwrap();
1651 let zebra_pos = fixed.find("zebra:").unwrap();
1652
1653 assert!(
1654 title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1655 "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1656 );
1657 }
1658
1659 #[test]
1660 fn test_toml_custom_key_order_sorted() {
1661 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1662 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
1663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1664 let result = rule.check(&ctx).unwrap();
1665
1666 assert!(result.is_empty());
1667 }
1668
1669 #[test]
1670 fn test_toml_custom_key_order_unsorted() {
1671 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1672 let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
1673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1674 let result = rule.check(&ctx).unwrap();
1675
1676 assert_eq!(result.len(), 1);
1677 assert!(result[0].message.contains("TOML"));
1678 }
1679
1680 #[test]
1681 fn test_json_custom_key_order_sorted() {
1682 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1683 let content = "{\n \"title\": \"Test\",\n \"date\": \"2024-01-01\",\n \"author\": \"John\"\n}\n\n# Heading";
1684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1685 let result = rule.check(&ctx).unwrap();
1686
1687 assert!(result.is_empty());
1688 }
1689
1690 #[test]
1691 fn test_json_custom_key_order_unsorted() {
1692 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1693 let content = "{\n \"author\": \"John\",\n \"title\": \"Test\",\n \"date\": \"2024-01-01\"\n}\n\n# Heading";
1694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1695 let result = rule.check(&ctx).unwrap();
1696
1697 assert_eq!(result.len(), 1);
1698 assert!(result[0].message.contains("JSON"));
1699 }
1700
1701 #[test]
1702 fn test_key_order_case_insensitive_match() {
1703 let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
1705 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1707 let result = rule.check(&ctx).unwrap();
1708
1709 assert!(result.is_empty());
1711 }
1712
1713 #[test]
1714 fn test_key_order_partial_match() {
1715 let rule = create_rule_with_key_order(vec!["title"]);
1717 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1719 let result = rule.check(&ctx).unwrap();
1720
1721 assert_eq!(result.len(), 1);
1732 assert!(result[0].message.contains("'author' should come before 'date'"));
1733 }
1734
1735 #[test]
1738 fn test_key_order_empty_array_falls_back_to_alphabetical() {
1739 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1741 enabled: true,
1742 key_order: Some(vec![]),
1743 });
1744 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1746 let result = rule.check(&ctx).unwrap();
1747
1748 assert_eq!(result.len(), 1);
1751 assert!(result[0].message.contains("'author' should come before 'title'"));
1752 }
1753
1754 #[test]
1755 fn test_key_order_single_key() {
1756 let rule = create_rule_with_key_order(vec!["title"]);
1758 let content = "---\ntitle: Test\n---\n\n# Heading";
1759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1760 let result = rule.check(&ctx).unwrap();
1761
1762 assert!(result.is_empty());
1763 }
1764
1765 #[test]
1766 fn test_key_order_all_keys_specified() {
1767 let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
1769 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1771 let result = rule.check(&ctx).unwrap();
1772
1773 assert!(result.is_empty());
1774 }
1775
1776 #[test]
1777 fn test_key_order_no_keys_match() {
1778 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1780 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1782 let result = rule.check(&ctx).unwrap();
1783
1784 assert!(result.is_empty());
1787 }
1788
1789 #[test]
1790 fn test_key_order_no_keys_match_unsorted() {
1791 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1793 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1795 let result = rule.check(&ctx).unwrap();
1796
1797 assert_eq!(result.len(), 1);
1800 }
1801
1802 #[test]
1803 fn test_key_order_duplicate_keys_in_config() {
1804 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1806 enabled: true,
1807 key_order: Some(vec![
1808 "title".to_string(),
1809 "author".to_string(),
1810 "title".to_string(), ]),
1812 });
1813 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1815 let result = rule.check(&ctx).unwrap();
1816
1817 assert!(result.is_empty());
1819 }
1820
1821 #[test]
1822 fn test_key_order_with_comments_still_skips_fix() {
1823 let rule = create_rule_with_key_order(vec!["title", "author"]);
1825 let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
1826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1827 let result = rule.check(&ctx).unwrap();
1828
1829 assert_eq!(result.len(), 1);
1831 assert!(result[0].message.contains("auto-fix unavailable"));
1832 assert!(result[0].fix.is_none());
1833 }
1834
1835 #[test]
1836 fn test_toml_custom_key_order_fix() {
1837 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1838 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
1839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1840 let fixed = rule.fix(&ctx).unwrap();
1841
1842 let title_pos = fixed.find("title").unwrap();
1844 let date_pos = fixed.find("date").unwrap();
1845 let author_pos = fixed.find("author").unwrap();
1846 assert!(
1847 title_pos < date_pos && date_pos < author_pos,
1848 "Fixed TOML should have keys in custom order. Got:\n{fixed}"
1849 );
1850 }
1851
1852 #[test]
1853 fn test_json_custom_key_order_fix() {
1854 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1855 let content = "{\n \"author\": \"John\",\n \"date\": \"2024-01-01\",\n \"title\": \"Test\"\n}\n\n# Heading";
1856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1857 let fixed = rule.fix(&ctx).unwrap();
1858
1859 let title_pos = fixed.find("\"title\"").unwrap();
1861 let date_pos = fixed.find("\"date\"").unwrap();
1862 let author_pos = fixed.find("\"author\"").unwrap();
1863 assert!(
1864 title_pos < date_pos && date_pos < author_pos,
1865 "Fixed JSON should have keys in custom order. Got:\n{fixed}"
1866 );
1867 }
1868
1869 #[test]
1870 fn test_key_order_unicode_keys() {
1871 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1873 enabled: true,
1874 key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
1875 });
1876 let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
1877 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1878 let result = rule.check(&ctx).unwrap();
1879
1880 assert!(result.is_empty());
1882 }
1883
1884 #[test]
1885 fn test_key_order_mixed_specified_and_unlisted_boundary() {
1886 let rule = create_rule_with_key_order(vec!["z_last_specified"]);
1888 let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
1889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890 let result = rule.check(&ctx).unwrap();
1891
1892 assert!(result.is_empty());
1895 }
1896
1897 #[test]
1898 fn test_key_order_fix_preserves_values() {
1899 let rule = create_rule_with_key_order(vec!["title", "tags"]);
1901 let content = "---\ntags:\n - rust\n - markdown\ntitle: Test\n---\n\n# Heading";
1902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1903 let fixed = rule.fix(&ctx).unwrap();
1904
1905 let title_pos = fixed.find("title:").unwrap();
1907 let tags_pos = fixed.find("tags:").unwrap();
1908 assert!(title_pos < tags_pos, "title should come before tags");
1909
1910 assert!(fixed.contains("- rust"), "List items should be preserved");
1912 assert!(fixed.contains("- markdown"), "List items should be preserved");
1913 }
1914
1915 #[test]
1916 fn test_key_order_idempotent_fix() {
1917 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1919 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1921
1922 let fixed_once = rule.fix(&ctx).unwrap();
1923 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1924 let fixed_twice = rule.fix(&ctx2).unwrap();
1925
1926 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1927 }
1928
1929 #[test]
1930 fn test_key_order_respects_later_position_over_alphabetical() {
1931 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
1933 let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
1934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1935 let result = rule.check(&ctx).unwrap();
1936
1937 assert!(result.is_empty());
1939 }
1940
1941 #[test]
1942 fn test_key_order_detects_wrong_custom_order() {
1943 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
1945 let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
1946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1947 let result = rule.check(&ctx).unwrap();
1948
1949 assert_eq!(result.len(), 1);
1950 assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
1951 }
1952}