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 raw = line[..colon_pos].trim();
78 if !raw.is_empty() && !raw.starts_with('#') {
79 let key = raw
84 .strip_prefix('"')
85 .and_then(|k| k.strip_suffix('"'))
86 .or_else(|| raw.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
87 .unwrap_or(raw);
88 keys.push((idx, key.to_string()));
89 }
90 }
91 }
92
93 keys
94 }
95
96 fn extract_toml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
98 let mut keys = Vec::new();
99
100 for (idx, line) in frontmatter_lines.iter().enumerate() {
101 let trimmed = line.trim();
102 if trimmed.is_empty() || trimmed.starts_with('#') {
104 continue;
105 }
106 if trimmed.starts_with('[') {
108 break;
109 }
110 if !line.starts_with(' ')
112 && !line.starts_with('\t')
113 && let Some(eq_pos) = line.find('=')
114 {
115 let key = line[..eq_pos].trim();
116 if !key.is_empty() {
117 keys.push((idx, key.to_string()));
118 }
119 }
120 }
121
122 keys
123 }
124
125 fn extract_json_keys(frontmatter_lines: &[&str]) -> Vec<String> {
127 let mut keys = Vec::new();
132 let mut depth: usize = 0;
133
134 for line in frontmatter_lines {
135 let line_start_depth = depth;
137
138 let mut in_string = false;
140 let mut prev_backslash = false;
141 for ch in line.chars() {
142 if in_string {
143 if ch == '"' && !prev_backslash {
144 in_string = false;
145 }
146 prev_backslash = ch == '\\' && !prev_backslash;
147 } else {
148 match ch {
149 '"' => in_string = true,
150 '{' | '[' => depth += 1,
151 '}' | ']' => depth = depth.saturating_sub(1),
152 _ => {}
153 }
154 prev_backslash = false;
155 }
156 }
157
158 if line_start_depth == 0
160 && let Some(captures) = JSON_KEY_PATTERN.captures(line)
161 && let Some(key_match) = captures.get(1)
162 {
163 keys.push(key_match.as_str().to_string());
164 }
165 }
166
167 keys
168 }
169
170 fn key_sort_position(key: &str, key_order: Option<&[String]>) -> (usize, String) {
174 if let Some(order) = key_order {
175 let key_lower = key.to_lowercase();
177 for (idx, ordered_key) in order.iter().enumerate() {
178 if ordered_key.to_lowercase() == key_lower {
179 return (idx, key_lower);
180 }
181 }
182 (usize::MAX, key_lower)
184 } else {
185 (0, key.to_lowercase())
187 }
188 }
189
190 fn find_first_unsorted_pair<'a>(keys: &'a [String], key_order: Option<&[String]>) -> Option<(&'a str, &'a str)> {
193 for i in 1..keys.len() {
194 let pos_curr = Self::key_sort_position(&keys[i], key_order);
195 let pos_prev = Self::key_sort_position(&keys[i - 1], key_order);
196 if pos_curr < pos_prev {
197 return Some((&keys[i], &keys[i - 1]));
198 }
199 }
200 None
201 }
202
203 fn find_first_unsorted_indexed_pair<'a>(
206 keys: &'a [(usize, String)],
207 key_order: Option<&[String]>,
208 ) -> Option<(usize, &'a str, &'a str)> {
209 for i in 1..keys.len() {
210 let pos_curr = Self::key_sort_position(&keys[i].1, key_order);
211 let pos_prev = Self::key_sort_position(&keys[i - 1].1, key_order);
212 if pos_curr < pos_prev {
213 return Some((keys[i].0, &keys[i].1, &keys[i - 1].1));
214 }
215 }
216 None
217 }
218
219 fn are_keys_sorted(keys: &[String], key_order: Option<&[String]>) -> bool {
221 Self::find_first_unsorted_pair(keys, key_order).is_none()
222 }
223
224 fn are_indexed_keys_sorted(keys: &[(usize, String)], key_order: Option<&[String]>) -> bool {
226 Self::find_first_unsorted_indexed_pair(keys, key_order).is_none()
227 }
228
229 fn sort_keys_by_order(keys: &mut [(String, Vec<&str>)], key_order: Option<&[String]>) {
231 keys.sort_by(|a, b| {
232 let pos_a = Self::key_sort_position(&a.0, key_order);
233 let pos_b = Self::key_sort_position(&b.0, key_order);
234 pos_a.cmp(&pos_b)
235 });
236 }
237}
238
239impl Rule for MD072FrontmatterKeySort {
240 fn name(&self) -> &'static str {
241 "MD072"
242 }
243
244 fn description(&self) -> &'static str {
245 "Frontmatter keys should be sorted alphabetically"
246 }
247
248 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
249 let content = ctx.content;
250 let mut warnings = Vec::new();
251
252 if content.is_empty() {
253 return Ok(warnings);
254 }
255
256 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
257
258 match fm_type {
259 FrontMatterType::Yaml => {
260 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
261 if frontmatter_lines.is_empty() {
262 return Ok(warnings);
263 }
264
265 let keys = Self::extract_yaml_keys(&frontmatter_lines);
266 let key_order = self.config.key_order.as_deref();
267 let Some((key_idx, out_of_place, should_come_after)) =
268 Self::find_first_unsorted_indexed_pair(&keys, key_order)
269 else {
270 return Ok(warnings);
271 };
272 let key_line = key_idx + 2;
274
275 let has_comments = Self::has_comments(&frontmatter_lines);
276
277 let fix = if has_comments {
278 None
279 } else {
280 let fixed_content = self.fix_yaml(content, ctx.front_matter_end_line());
282 if fixed_content != content {
283 Some(Fix::new(0..content.len(), fixed_content))
284 } else {
285 None
286 }
287 };
288
289 let message = if has_comments {
290 format!(
291 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
292 )
293 } else {
294 format!(
295 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
296 )
297 };
298
299 let end_column = frontmatter_lines
302 .get(key_idx)
303 .and_then(|line| line.split_once(':'))
304 .map_or(out_of_place.chars().count() + 1, |(key, _)| {
305 key.trim().chars().count() + 1
306 });
307
308 warnings.push(LintWarning {
309 rule_name: Some(self.name().to_string()),
310 message,
311 line: key_line,
312 column: 1,
313 end_line: key_line,
314 end_column,
315 severity: Severity::Warning,
316 fix,
317 });
318 }
319 FrontMatterType::Toml => {
320 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
321 if frontmatter_lines.is_empty() {
322 return Ok(warnings);
323 }
324
325 let keys = Self::extract_toml_keys(&frontmatter_lines);
326 let key_order = self.config.key_order.as_deref();
327 let Some((key_idx, out_of_place, should_come_after)) =
328 Self::find_first_unsorted_indexed_pair(&keys, key_order)
329 else {
330 return Ok(warnings);
331 };
332 let key_line = key_idx + 2;
333
334 let has_comments = Self::has_comments(&frontmatter_lines);
335
336 let fix = if has_comments {
337 None
338 } else {
339 let fixed_content = self.fix_toml(content, ctx.front_matter_end_line());
341 if fixed_content != content {
342 Some(Fix::new(0..content.len(), fixed_content))
343 } else {
344 None
345 }
346 };
347
348 let message = if has_comments {
349 format!(
350 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
351 )
352 } else {
353 format!(
354 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
355 )
356 };
357
358 warnings.push(LintWarning {
359 rule_name: Some(self.name().to_string()),
360 message,
361 line: key_line,
362 column: 1,
363 end_line: key_line,
364 end_column: out_of_place.len() + 1,
365 severity: Severity::Warning,
366 fix,
367 });
368 }
369 FrontMatterType::Json => {
370 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
371 if frontmatter_lines.is_empty() {
372 return Ok(warnings);
373 }
374
375 let keys = Self::extract_json_keys(&frontmatter_lines);
376 let key_order = self.config.key_order.as_deref();
377 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_pair(&keys, key_order) else {
378 return Ok(warnings);
379 };
380
381 let fixed_content = self.fix_json(content, ctx.front_matter_end_line());
383 let fix = if fixed_content != content {
384 Some(Fix::new(0..content.len(), fixed_content))
385 } else {
386 None
387 };
388
389 let message = format!(
390 "JSON frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
391 );
392
393 warnings.push(LintWarning {
394 rule_name: Some(self.name().to_string()),
395 message,
396 line: 2,
397 column: 1,
398 end_line: 2,
399 end_column: out_of_place.len() + 1,
400 severity: Severity::Warning,
401 fix,
402 });
403 }
404 _ => {
405 }
407 }
408
409 Ok(warnings)
410 }
411
412 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
413 let content = ctx.content;
414
415 if ctx.is_rule_disabled(self.name(), 2) {
417 return Ok(content.to_string());
418 }
419
420 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
421
422 let fm_end = ctx.front_matter_end_line();
423 Ok(match fm_type {
424 FrontMatterType::Yaml => self.fix_yaml(content, fm_end),
425 FrontMatterType::Toml => self.fix_toml(content, fm_end),
426 FrontMatterType::Json => self.fix_json(content, fm_end),
427 _ => content.to_string(),
428 })
429 }
430
431 fn category(&self) -> RuleCategory {
432 RuleCategory::FrontMatter
433 }
434
435 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
436 ctx.content.is_empty()
437 || !ctx.content.starts_with("---") && !ctx.content.starts_with("+++") && !ctx.content.starts_with('{')
438 }
439
440 fn as_any(&self) -> &dyn std::any::Any {
441 self
442 }
443
444 crate::impl_rule_config_methods!(MD072Config, nullable);
445}
446
447impl MD072FrontmatterKeySort {
448 fn preserve_trailing_newline(original: &str, mut result: String) -> String {
453 if original.ends_with('\n') && !result.ends_with('\n') {
454 result.push('\n');
455 }
456 result
457 }
458
459 fn fix_yaml(&self, content: &str, fm_end: usize) -> String {
460 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
461 if frontmatter_lines.is_empty() {
462 return content.to_string();
463 }
464
465 if Self::has_comments(&frontmatter_lines) {
467 return content.to_string();
468 }
469
470 let keys = Self::extract_yaml_keys(&frontmatter_lines);
471 let key_order = self.config.key_order.as_deref();
472 if Self::are_indexed_keys_sorted(&keys, key_order) {
473 return content.to_string();
474 }
475
476 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
479
480 for (i, (line_idx, key)) in keys.iter().enumerate() {
481 let start = *line_idx;
482 let end = if i + 1 < keys.len() {
483 keys[i + 1].0
484 } else {
485 frontmatter_lines.len()
486 };
487
488 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
489 key_blocks.push((key.clone(), block_lines));
490 }
491
492 Self::sort_keys_by_order(&mut key_blocks, key_order);
494
495 let content_lines: Vec<&str> = content.lines().collect();
497
498 let mut result = String::new();
499 result.push_str("---\n");
500 for (_, lines) in &key_blocks {
501 for line in lines {
502 result.push_str(line);
503 result.push('\n');
504 }
505 }
506 result.push_str("---");
507
508 if fm_end < content_lines.len() {
509 result.push('\n');
510 result.push_str(&content_lines[fm_end..].join("\n"));
511 }
512
513 Self::preserve_trailing_newline(content, result)
514 }
515
516 fn fix_toml(&self, content: &str, fm_end: usize) -> String {
517 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
518 if frontmatter_lines.is_empty() {
519 return content.to_string();
520 }
521
522 if Self::has_comments(&frontmatter_lines) {
524 return content.to_string();
525 }
526
527 let keys = Self::extract_toml_keys(&frontmatter_lines);
528 let key_order = self.config.key_order.as_deref();
529 if Self::are_indexed_keys_sorted(&keys, key_order) {
530 return content.to_string();
531 }
532
533 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
536
537 for (i, (line_idx, key)) in keys.iter().enumerate() {
538 let start = *line_idx;
539 let end = if i + 1 < keys.len() {
540 keys[i + 1].0
541 } else {
542 frontmatter_lines.len()
543 };
544
545 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
546 key_blocks.push((key.clone(), block_lines));
547 }
548
549 Self::sort_keys_by_order(&mut key_blocks, key_order);
551
552 let content_lines: Vec<&str> = content.lines().collect();
554
555 let mut result = String::new();
556 result.push_str("+++\n");
557 for (_, lines) in &key_blocks {
558 for line in lines {
559 result.push_str(line);
560 result.push('\n');
561 }
562 }
563 result.push_str("+++");
564
565 if fm_end < content_lines.len() {
566 result.push('\n');
567 result.push_str(&content_lines[fm_end..].join("\n"));
568 }
569
570 Self::preserve_trailing_newline(content, result)
571 }
572
573 fn fix_json(&self, content: &str, fm_end: usize) -> String {
574 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
575 if frontmatter_lines.is_empty() {
576 return content.to_string();
577 }
578
579 let keys = Self::extract_json_keys(&frontmatter_lines);
580 let key_order = self.config.key_order.as_deref();
581
582 if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
583 return content.to_string();
584 }
585
586 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
588
589 match serde_json::from_str::<serde_json::Value>(&json_content) {
591 Ok(serde_json::Value::Object(map)) => {
592 let mut sorted_map = serde_json::Map::new();
594 let mut keys: Vec<_> = map.keys().cloned().collect();
595 keys.sort_by(|a, b| {
596 let pos_a = Self::key_sort_position(a, key_order);
597 let pos_b = Self::key_sort_position(b, key_order);
598 pos_a.cmp(&pos_b)
599 });
600
601 for key in keys {
602 if let Some(value) = map.get(&key) {
603 sorted_map.insert(key, value.clone());
604 }
605 }
606
607 match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
608 Ok(sorted_json) => {
609 let lines: Vec<&str> = content.lines().collect();
610
611 let mut result = String::new();
614 result.push_str(&sorted_json);
615
616 if fm_end < lines.len() {
617 result.push('\n');
618 result.push_str(&lines[fm_end..].join("\n"));
619 }
620
621 Self::preserve_trailing_newline(content, result)
622 }
623 Err(_) => content.to_string(),
624 }
625 }
626 _ => content.to_string(),
627 }
628 }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634 use crate::lint_context::LintContext;
635
636 fn create_enabled_rule() -> MD072FrontmatterKeySort {
638 MD072FrontmatterKeySort::from_config_struct(MD072Config {
639 enabled: true,
640 key_order: None,
641 })
642 }
643
644 fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
646 MD072FrontmatterKeySort::from_config_struct(MD072Config {
647 enabled: true,
648 key_order: Some(keys.into_iter().map(String::from).collect()),
649 })
650 }
651
652 #[test]
655 fn test_enabled_via_config() {
656 let rule = create_enabled_rule();
657 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659 let result = rule.check(&ctx).unwrap();
660
661 assert_eq!(result.len(), 1);
663 }
664
665 #[test]
668 fn test_no_frontmatter() {
669 let rule = create_enabled_rule();
670 let content = "# Heading\n\nContent.";
671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672 let result = rule.check(&ctx).unwrap();
673
674 assert!(result.is_empty());
675 }
676
677 #[test]
678 fn test_yaml_sorted_keys() {
679 let rule = create_enabled_rule();
680 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
681 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
682 let result = rule.check(&ctx).unwrap();
683
684 assert!(result.is_empty());
685 }
686
687 #[test]
688 fn test_yaml_unsorted_keys() {
689 let rule = create_enabled_rule();
690 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692 let result = rule.check(&ctx).unwrap();
693
694 assert_eq!(result.len(), 1);
695 assert!(result[0].message.contains("YAML"));
696 assert!(result[0].message.contains("not sorted"));
697 assert!(result[0].message.contains("'author' should come before 'title'"));
699 }
700
701 #[test]
702 fn test_yaml_case_insensitive_sort() {
703 let rule = create_enabled_rule();
704 let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706 let result = rule.check(&ctx).unwrap();
707
708 assert!(result.is_empty());
710 }
711
712 #[test]
713 fn test_yaml_fix_sorts_keys() {
714 let rule = create_enabled_rule();
715 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717 let fixed = rule.fix(&ctx).unwrap();
718
719 let author_pos = fixed.find("author:").unwrap();
721 let title_pos = fixed.find("title:").unwrap();
722 assert!(author_pos < title_pos);
723 }
724
725 #[test]
726 fn test_yaml_no_fix_with_comments() {
727 let rule = create_enabled_rule();
728 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730 let result = rule.check(&ctx).unwrap();
731
732 assert_eq!(result.len(), 1);
733 assert!(result[0].message.contains("auto-fix unavailable"));
734 assert!(result[0].fix.is_none());
735
736 let fixed = rule.fix(&ctx).unwrap();
738 assert_eq!(fixed, content);
739 }
740
741 #[test]
742 fn test_yaml_single_key() {
743 let rule = create_enabled_rule();
744 let content = "---\ntitle: Test\n---\n\n# Heading";
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let result = rule.check(&ctx).unwrap();
747
748 assert!(result.is_empty());
750 }
751
752 #[test]
753 fn test_yaml_nested_keys_ignored() {
754 let rule = create_enabled_rule();
755 let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let result = rule.check(&ctx).unwrap();
759
760 assert!(result.is_empty());
762 }
763
764 #[test]
765 fn test_yaml_fix_idempotent() {
766 let rule = create_enabled_rule();
767 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let fixed_once = rule.fix(&ctx).unwrap();
770
771 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
772 let fixed_twice = rule.fix(&ctx2).unwrap();
773
774 assert_eq!(fixed_once, fixed_twice);
775 }
776
777 #[test]
778 fn test_yaml_fix_preserves_trailing_newline() {
779 let rule = create_enabled_rule();
780 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let fixed = rule.fix(&ctx).unwrap();
784 assert!(
785 fixed.ends_with('\n'),
786 "trailing newline must be preserved, got {fixed:?}"
787 );
788
789 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
791 let fixed_twice = rule.fix(&ctx2).unwrap();
792 assert_eq!(fixed, fixed_twice);
793 }
794
795 #[test]
796 fn test_yaml_fix_whole_file_frontmatter_preserves_trailing_newline() {
797 let rule = create_enabled_rule();
798 let content = "---\ntitle: Test\nauthor: John\n---\n";
800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801 let fixed = rule.fix(&ctx).unwrap();
802 assert!(
803 fixed.ends_with('\n'),
804 "trailing newline must be preserved, got {fixed:?}"
805 );
806 }
807
808 #[test]
809 fn test_yaml_quoted_keys_sort_by_content() {
810 let rule = create_enabled_rule();
811 let content = "---\n\"zebra\": 1\napple: 2\n---\n\n# Heading";
814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815 let result = rule.check(&ctx).unwrap();
816
817 assert_eq!(result.len(), 1, "quoted key out of order must be flagged");
818 assert!(result[0].message.contains("'apple' should come before 'zebra'"));
819 }
820
821 #[test]
822 fn test_yaml_quoted_key_warning_span_covers_quotes() {
823 let rule = create_enabled_rule();
824 let content = "---\nbanana: 1\n\"apple\": 2\n---\n";
828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
829 let result = rule.check(&ctx).unwrap();
830
831 assert_eq!(result.len(), 1);
832 let w = &result[0];
833 assert_eq!(w.line, 3);
834 assert_eq!(w.column, 1);
835 assert_eq!(w.end_column, 8, "diagnostic span must cover the quoted key");
837 }
838
839 #[test]
840 fn test_yaml_complex_values() {
841 let rule = create_enabled_rule();
842 let content =
844 "---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846 let result = rule.check(&ctx).unwrap();
847
848 assert!(result.is_empty());
850 }
851
852 #[test]
855 fn test_toml_sorted_keys() {
856 let rule = create_enabled_rule();
857 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859 let result = rule.check(&ctx).unwrap();
860
861 assert!(result.is_empty());
862 }
863
864 #[test]
865 fn test_toml_unsorted_keys() {
866 let rule = create_enabled_rule();
867 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
869 let result = rule.check(&ctx).unwrap();
870
871 assert_eq!(result.len(), 1);
872 assert!(result[0].message.contains("TOML"));
873 assert!(result[0].message.contains("not sorted"));
874 }
875
876 #[test]
877 fn test_toml_fix_sorts_keys() {
878 let rule = create_enabled_rule();
879 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881 let fixed = rule.fix(&ctx).unwrap();
882
883 let author_pos = fixed.find("author").unwrap();
885 let title_pos = fixed.find("title").unwrap();
886 assert!(author_pos < title_pos);
887 }
888
889 #[test]
890 fn test_toml_no_fix_with_comments() {
891 let rule = create_enabled_rule();
892 let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894 let result = rule.check(&ctx).unwrap();
895
896 assert_eq!(result.len(), 1);
897 assert!(result[0].message.contains("auto-fix unavailable"));
898
899 let fixed = rule.fix(&ctx).unwrap();
901 assert_eq!(fixed, content);
902 }
903
904 #[test]
907 fn test_json_sorted_keys() {
908 let rule = create_enabled_rule();
909 let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911 let result = rule.check(&ctx).unwrap();
912
913 assert!(result.is_empty());
914 }
915
916 #[test]
917 fn test_json_unsorted_keys() {
918 let rule = create_enabled_rule();
919 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921 let result = rule.check(&ctx).unwrap();
922
923 assert_eq!(result.len(), 1);
924 assert!(result[0].message.contains("JSON"));
925 assert!(result[0].message.contains("not sorted"));
926 }
927
928 #[test]
929 fn test_json_fix_sorts_keys() {
930 let rule = create_enabled_rule();
931 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let fixed = rule.fix(&ctx).unwrap();
934
935 let author_pos = fixed.find("author").unwrap();
937 let title_pos = fixed.find("title").unwrap();
938 assert!(author_pos < title_pos);
939 }
940
941 #[test]
942 fn test_json_always_fixable() {
943 let rule = create_enabled_rule();
944 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\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);
950 assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
952 }
953
954 #[test]
957 fn test_empty_content() {
958 let rule = create_enabled_rule();
959 let content = "";
960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
961 let result = rule.check(&ctx).unwrap();
962
963 assert!(result.is_empty());
964 }
965
966 #[test]
967 fn test_empty_frontmatter() {
968 let rule = create_enabled_rule();
969 let content = "---\n---\n\n# Heading";
970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
971 let result = rule.check(&ctx).unwrap();
972
973 assert!(result.is_empty());
974 }
975
976 #[test]
977 fn test_toml_nested_tables_ignored() {
978 let rule = create_enabled_rule();
980 let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\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("'sort_by' should come before 'title'"));
988 assert!(!result[0].message.contains("we_have_extra"));
989 }
990
991 #[test]
992 fn test_toml_nested_taxonomies_ignored() {
993 let rule = create_enabled_rule();
995 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997 let result = rule.check(&ctx).unwrap();
998
999 assert_eq!(result.len(), 1);
1001 assert!(result[0].message.contains("'date' should come before 'title'"));
1003 assert!(!result[0].message.contains("categories"));
1004 assert!(!result[0].message.contains("tags"));
1005 }
1006
1007 #[test]
1010 fn test_yaml_unicode_keys() {
1011 let rule = create_enabled_rule();
1012 let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
1014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015 let result = rule.check(&ctx).unwrap();
1016
1017 assert_eq!(result.len(), 1);
1019 }
1020
1021 #[test]
1022 fn test_yaml_keys_with_special_characters() {
1023 let rule = create_enabled_rule();
1024 let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
1026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1027 let result = rule.check(&ctx).unwrap();
1028
1029 assert!(result.is_empty());
1031 }
1032
1033 #[test]
1034 fn test_yaml_keys_with_numbers() {
1035 let rule = create_enabled_rule();
1036 let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
1037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1038 let result = rule.check(&ctx).unwrap();
1039
1040 assert!(result.is_empty());
1042 }
1043
1044 #[test]
1045 fn test_yaml_multiline_string_block_literal() {
1046 let rule = create_enabled_rule();
1047 let content =
1048 "---\ndescription: |\n This is a\n multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
1049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050 let result = rule.check(&ctx).unwrap();
1051
1052 assert_eq!(result.len(), 1);
1054 assert!(result[0].message.contains("'author' should come before 'title'"));
1055 }
1056
1057 #[test]
1058 fn test_yaml_multiline_string_folded() {
1059 let rule = create_enabled_rule();
1060 let content = "---\ndescription: >\n This is a\n folded string\nauthor: John\n---\n\n# Heading";
1061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1062 let result = rule.check(&ctx).unwrap();
1063
1064 assert_eq!(result.len(), 1);
1066 }
1067
1068 #[test]
1069 fn test_yaml_fix_preserves_multiline_values() {
1070 let rule = create_enabled_rule();
1071 let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073 let fixed = rule.fix(&ctx).unwrap();
1074
1075 let desc_pos = fixed.find("description").unwrap();
1077 let title_pos = fixed.find("title").unwrap();
1078 assert!(desc_pos < title_pos);
1079 }
1080
1081 #[test]
1082 fn test_yaml_quoted_keys() {
1083 let rule = create_enabled_rule();
1084 let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
1085 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1086 let result = rule.check(&ctx).unwrap();
1087
1088 assert!(result.is_empty());
1090 }
1091
1092 #[test]
1093 fn test_yaml_duplicate_keys() {
1094 let rule = create_enabled_rule();
1096 let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
1097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1098 let result = rule.check(&ctx).unwrap();
1099
1100 assert_eq!(result.len(), 1);
1102 }
1103
1104 #[test]
1105 fn test_toml_inline_table() {
1106 let rule = create_enabled_rule();
1107 let content =
1108 "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let result = rule.check(&ctx).unwrap();
1111
1112 assert!(result.is_empty());
1114 }
1115
1116 #[test]
1117 fn test_toml_array_of_tables() {
1118 let rule = create_enabled_rule();
1119 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
1120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1121 let result = rule.check(&ctx).unwrap();
1122
1123 assert_eq!(result.len(), 1);
1125 assert!(result[0].message.contains("'date' should come before 'title'"));
1127 }
1128
1129 #[test]
1130 fn test_json_nested_objects() {
1131 let rule = create_enabled_rule();
1132 let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1134 let result = rule.check(&ctx).unwrap();
1135
1136 assert!(result.is_empty());
1138 }
1139
1140 #[test]
1141 fn test_json_arrays() {
1142 let rule = create_enabled_rule();
1143 let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145 let result = rule.check(&ctx).unwrap();
1146
1147 assert_eq!(result.len(), 1);
1149 }
1150
1151 #[test]
1152 fn test_fix_preserves_content_after_frontmatter() {
1153 let rule = create_enabled_rule();
1154 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1155 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1156 let fixed = rule.fix(&ctx).unwrap();
1157
1158 assert!(fixed.contains("# Heading"));
1160 assert!(fixed.contains("Paragraph 1."));
1161 assert!(fixed.contains("- List item"));
1162 assert!(fixed.contains("- Another item"));
1163 }
1164
1165 #[test]
1166 fn test_fix_yaml_produces_valid_yaml() {
1167 let rule = create_enabled_rule();
1168 let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170 let fixed = rule.fix(&ctx).unwrap();
1171
1172 let lines: Vec<&str> = fixed.lines().collect();
1175 let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1176 let fm_content: String = lines[1..fm_end].join("\n");
1177
1178 let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&fm_content);
1180 assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1181 }
1182
1183 #[test]
1184 fn test_fix_toml_produces_valid_toml() {
1185 let rule = create_enabled_rule();
1186 let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188 let fixed = rule.fix(&ctx).unwrap();
1189
1190 let lines: Vec<&str> = fixed.lines().collect();
1192 let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1193 let fm_content: String = lines[1..fm_end].join("\n");
1194
1195 let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1197 assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1198 }
1199
1200 #[test]
1201 fn test_fix_json_produces_valid_json() {
1202 let rule = create_enabled_rule();
1203 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1204 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1205 let fixed = rule.fix(&ctx).unwrap();
1206
1207 let json_end = fixed.find("\n\n").unwrap();
1209 let json_content = &fixed[..json_end];
1210
1211 let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1213 assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1214 }
1215
1216 #[test]
1217 fn test_many_keys_performance() {
1218 let rule = create_enabled_rule();
1219 let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1221 keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1223
1224 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226
1227 assert_eq!(result.len(), 1);
1229 }
1230
1231 #[test]
1232 fn test_yaml_empty_value() {
1233 let rule = create_enabled_rule();
1234 let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1236 let result = rule.check(&ctx).unwrap();
1237
1238 assert_eq!(result.len(), 1);
1240 }
1241
1242 #[test]
1243 fn test_yaml_null_value() {
1244 let rule = create_enabled_rule();
1245 let content = "---\ntitle: null\nauthor: John\n---\n\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);
1250 }
1251
1252 #[test]
1253 fn test_yaml_boolean_values() {
1254 let rule = create_enabled_rule();
1255 let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1256 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1257 let result = rule.check(&ctx).unwrap();
1258
1259 assert_eq!(result.len(), 1);
1261 }
1262
1263 #[test]
1264 fn test_toml_boolean_values() {
1265 let rule = create_enabled_rule();
1266 let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1268 let result = rule.check(&ctx).unwrap();
1269
1270 assert_eq!(result.len(), 1);
1271 }
1272
1273 #[test]
1274 fn test_yaml_list_at_top_level() {
1275 let rule = create_enabled_rule();
1276 let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
1277 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1278 let result = rule.check(&ctx).unwrap();
1279
1280 assert_eq!(result.len(), 1);
1282 }
1283
1284 #[test]
1285 fn test_three_keys_all_orderings() {
1286 let rule = create_enabled_rule();
1287
1288 let orderings = [
1290 ("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), ];
1297
1298 for (name, content, should_pass) in orderings {
1299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1300 let result = rule.check(&ctx).unwrap();
1301 assert_eq!(
1302 result.is_empty(),
1303 should_pass,
1304 "Ordering {name} should {} pass",
1305 if should_pass { "" } else { "not" }
1306 );
1307 }
1308 }
1309
1310 #[test]
1311 fn test_crlf_line_endings() {
1312 let rule = create_enabled_rule();
1313 let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1314 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1315 let result = rule.check(&ctx).unwrap();
1316
1317 assert_eq!(result.len(), 1);
1319 }
1320
1321 #[test]
1322 fn test_json_escaped_quotes_in_keys() {
1323 let rule = create_enabled_rule();
1324 let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1327 let result = rule.check(&ctx).unwrap();
1328
1329 assert_eq!(result.len(), 1);
1331 }
1332
1333 #[test]
1336 fn test_warning_fix_yaml_sorts_keys() {
1337 let rule = create_enabled_rule();
1338 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340 let warnings = rule.check(&ctx).unwrap();
1341
1342 assert_eq!(warnings.len(), 1);
1343 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1344
1345 let fix = warnings[0].fix.as_ref().unwrap();
1346 assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1347
1348 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1350
1351 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1353 let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1354 assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1355 }
1356
1357 #[test]
1358 fn test_warning_fix_preserves_yaml_list_indentation() {
1359 let rule = create_enabled_rule();
1360 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1362 let warnings = rule.check(&ctx).unwrap();
1363
1364 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1365
1366 assert!(
1368 fixed.contains(" - hello"),
1369 "List indentation should be preserved: {fixed}"
1370 );
1371 assert!(
1372 fixed.contains(" - world"),
1373 "List indentation should be preserved: {fixed}"
1374 );
1375 }
1376
1377 #[test]
1378 fn test_warning_fix_preserves_nested_object_indentation() {
1379 let rule = create_enabled_rule();
1380 let content = "---\nzzzz: value\naaaa:\n nested_key: nested_value\n another: 123\n---\n\n# Heading\n";
1381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1382 let warnings = rule.check(&ctx).unwrap();
1383
1384 assert_eq!(warnings.len(), 1);
1385 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1386
1387 let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1389 let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1390 assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1391
1392 assert!(
1394 fixed.contains(" nested_key: nested_value"),
1395 "Nested object indentation should be preserved: {fixed}"
1396 );
1397 assert!(
1398 fixed.contains(" another: 123"),
1399 "Nested object indentation should be preserved: {fixed}"
1400 );
1401 }
1402
1403 #[test]
1404 fn test_warning_fix_preserves_deeply_nested_structure() {
1405 let rule = create_enabled_rule();
1406 let content = "---\nzzz: top\naaa:\n level1:\n level2:\n - item1\n - item2\n---\n\n# Content\n";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 let warnings = rule.check(&ctx).unwrap();
1409
1410 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1411
1412 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1414 let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1415 assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1416
1417 assert!(fixed.contains(" level1:"), "2-space indent should be preserved");
1419 assert!(fixed.contains(" level2:"), "4-space indent should be preserved");
1420 assert!(fixed.contains(" - item1"), "6-space indent should be preserved");
1421 assert!(fixed.contains(" - item2"), "6-space indent should be preserved");
1422 }
1423
1424 #[test]
1425 fn test_warning_fix_toml_sorts_keys() {
1426 let rule = create_enabled_rule();
1427 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1429 let warnings = rule.check(&ctx).unwrap();
1430
1431 assert_eq!(warnings.len(), 1);
1432 assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1433
1434 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1435
1436 let author_pos = fixed.find("author").expect("author should exist");
1438 let title_pos = fixed.find("title").expect("title should exist");
1439 assert!(author_pos < title_pos, "author should come before title");
1440 }
1441
1442 #[test]
1443 fn test_warning_fix_json_sorts_keys() {
1444 let rule = create_enabled_rule();
1445 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1447 let warnings = rule.check(&ctx).unwrap();
1448
1449 assert_eq!(warnings.len(), 1);
1450 assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1451
1452 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1453
1454 let author_pos = fixed.find("author").expect("author should exist");
1456 let title_pos = fixed.find("title").expect("title should exist");
1457 assert!(author_pos < title_pos, "author should come before title");
1458 }
1459
1460 #[test]
1461 fn test_warning_fix_no_fix_when_comments_present() {
1462 let rule = create_enabled_rule();
1463 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1465 let warnings = rule.check(&ctx).unwrap();
1466
1467 assert_eq!(warnings.len(), 1);
1468 assert!(
1469 warnings[0].fix.is_none(),
1470 "Warning should NOT have a fix when comments are present"
1471 );
1472 assert!(
1473 warnings[0].message.contains("auto-fix unavailable"),
1474 "Message should indicate auto-fix is unavailable"
1475 );
1476 }
1477
1478 #[test]
1479 fn test_warning_fix_preserves_content_after_frontmatter() {
1480 let rule = create_enabled_rule();
1481 let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1482 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1483 let warnings = rule.check(&ctx).unwrap();
1484
1485 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1486
1487 assert!(fixed.contains("# Heading"), "Heading should be preserved");
1489 assert!(
1490 fixed.contains("Paragraph with content."),
1491 "Paragraph should be preserved"
1492 );
1493 assert!(fixed.contains("- List item"), "List item should be preserved");
1494 }
1495
1496 #[test]
1497 fn test_warning_fix_idempotent() {
1498 let rule = create_enabled_rule();
1499 let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1501 let warnings = rule.check(&ctx).unwrap();
1502
1503 let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1504
1505 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1507 let warnings2 = rule.check(&ctx2).unwrap();
1508
1509 assert!(
1510 warnings2.is_empty(),
1511 "After fixing, no more warnings should be produced"
1512 );
1513 }
1514
1515 #[test]
1516 fn test_warning_fix_preserves_multiline_block_literal() {
1517 let rule = create_enabled_rule();
1518 let content = "---\nzzz: simple\naaa: |\n Line 1 of block\n Line 2 of block\n---\n\n# Heading\n";
1519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1520 let warnings = rule.check(&ctx).unwrap();
1521
1522 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1523
1524 assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1526 assert!(
1527 fixed.contains(" Line 1 of block"),
1528 "Block literal line 1 should be preserved with indent"
1529 );
1530 assert!(
1531 fixed.contains(" Line 2 of block"),
1532 "Block literal line 2 should be preserved with indent"
1533 );
1534 }
1535
1536 #[test]
1537 fn test_warning_fix_preserves_folded_string() {
1538 let rule = create_enabled_rule();
1539 let content = "---\nzzz: simple\naaa: >\n Folded line 1\n Folded line 2\n---\n\n# Content\n";
1540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1541 let warnings = rule.check(&ctx).unwrap();
1542
1543 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1544
1545 assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1547 assert!(
1548 fixed.contains(" Folded line 1"),
1549 "Folded line 1 should be preserved with indent"
1550 );
1551 assert!(
1552 fixed.contains(" Folded line 2"),
1553 "Folded line 2 should be preserved with indent"
1554 );
1555 }
1556
1557 #[test]
1558 fn test_warning_fix_preserves_4_space_indentation() {
1559 let rule = create_enabled_rule();
1560 let content = "---\nzzz: value\naaa:\n nested: with_4_spaces\n another: value\n---\n\n# Heading\n";
1562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1563 let warnings = rule.check(&ctx).unwrap();
1564
1565 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1566
1567 assert!(
1569 fixed.contains(" nested: with_4_spaces"),
1570 "4-space indentation should be preserved: {fixed}"
1571 );
1572 assert!(
1573 fixed.contains(" another: value"),
1574 "4-space indentation should be preserved: {fixed}"
1575 );
1576 }
1577
1578 #[test]
1579 fn test_warning_fix_preserves_tab_indentation() {
1580 let rule = create_enabled_rule();
1581 let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1584 let warnings = rule.check(&ctx).unwrap();
1585
1586 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1587
1588 assert!(
1590 fixed.contains("\tnested: with_tab"),
1591 "Tab indentation should be preserved: {fixed}"
1592 );
1593 assert!(
1594 fixed.contains("\tanother: value"),
1595 "Tab indentation should be preserved: {fixed}"
1596 );
1597 }
1598
1599 #[test]
1600 fn test_warning_fix_preserves_inline_list() {
1601 let rule = create_enabled_rule();
1602 let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1605 let warnings = rule.check(&ctx).unwrap();
1606
1607 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1608
1609 assert!(
1611 fixed.contains("aaa: [one, two, three]"),
1612 "Inline list should be preserved exactly: {fixed}"
1613 );
1614 }
1615
1616 #[test]
1617 fn test_warning_fix_preserves_quoted_strings() {
1618 let rule = create_enabled_rule();
1619 let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1622 let warnings = rule.check(&ctx).unwrap();
1623
1624 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1625
1626 assert!(
1628 fixed.contains("aaa: \"value with: colon\""),
1629 "Double-quoted string should be preserved: {fixed}"
1630 );
1631 assert!(
1632 fixed.contains("bbb: 'single quotes'"),
1633 "Single-quoted string should be preserved: {fixed}"
1634 );
1635 }
1636
1637 #[test]
1640 fn test_yaml_custom_key_order_sorted() {
1641 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());
1649 }
1650
1651 #[test]
1652 fn test_yaml_custom_key_order_unsorted() {
1653 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1655 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1657 let result = rule.check(&ctx).unwrap();
1658
1659 assert_eq!(result.len(), 1);
1660 assert!(result[0].message.contains("'date' should come before 'author'"));
1662 }
1663
1664 #[test]
1665 fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1666 let rule = create_rule_with_key_order(vec!["title"]);
1668 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1670 let result = rule.check(&ctx).unwrap();
1671
1672 assert!(result.is_empty());
1675 }
1676
1677 #[test]
1678 fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1679 let rule = create_rule_with_key_order(vec!["title"]);
1681 let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683 let result = rule.check(&ctx).unwrap();
1684
1685 assert_eq!(result.len(), 1);
1687 assert!(result[0].message.contains("'author' should come before 'zebra'"));
1688 }
1689
1690 #[test]
1691 fn test_yaml_custom_key_order_fix() {
1692 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1693 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1695 let fixed = rule.fix(&ctx).unwrap();
1696
1697 let title_pos = fixed.find("title:").unwrap();
1699 let date_pos = fixed.find("date:").unwrap();
1700 let author_pos = fixed.find("author:").unwrap();
1701 assert!(
1702 title_pos < date_pos && date_pos < author_pos,
1703 "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1704 );
1705 }
1706
1707 #[test]
1708 fn test_yaml_custom_key_order_fix_with_unlisted() {
1709 let rule = create_rule_with_key_order(vec!["title", "author"]);
1711 let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
1712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1713 let fixed = rule.fix(&ctx).unwrap();
1714
1715 let title_pos = fixed.find("title:").unwrap();
1717 let author_pos = fixed.find("author:").unwrap();
1718 let aardvark_pos = fixed.find("aardvark:").unwrap();
1719 let zebra_pos = fixed.find("zebra:").unwrap();
1720
1721 assert!(
1722 title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1723 "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1724 );
1725 }
1726
1727 #[test]
1728 fn test_toml_custom_key_order_sorted() {
1729 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1730 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
1731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1732 let result = rule.check(&ctx).unwrap();
1733
1734 assert!(result.is_empty());
1735 }
1736
1737 #[test]
1738 fn test_toml_custom_key_order_unsorted() {
1739 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1740 let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
1741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1742 let result = rule.check(&ctx).unwrap();
1743
1744 assert_eq!(result.len(), 1);
1745 assert!(result[0].message.contains("TOML"));
1746 }
1747
1748 #[test]
1749 fn test_json_custom_key_order_sorted() {
1750 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1751 let content = "{\n \"title\": \"Test\",\n \"date\": \"2024-01-01\",\n \"author\": \"John\"\n}\n\n# Heading";
1752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1753 let result = rule.check(&ctx).unwrap();
1754
1755 assert!(result.is_empty());
1756 }
1757
1758 #[test]
1759 fn test_json_custom_key_order_unsorted() {
1760 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1761 let content = "{\n \"author\": \"John\",\n \"title\": \"Test\",\n \"date\": \"2024-01-01\"\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_eq!(result.len(), 1);
1766 assert!(result[0].message.contains("JSON"));
1767 }
1768
1769 #[test]
1770 fn test_key_order_case_insensitive_match() {
1771 let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
1773 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1775 let result = rule.check(&ctx).unwrap();
1776
1777 assert!(result.is_empty());
1779 }
1780
1781 #[test]
1782 fn test_key_order_partial_match() {
1783 let rule = create_rule_with_key_order(vec!["title"]);
1785 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1787 let result = rule.check(&ctx).unwrap();
1788
1789 assert_eq!(result.len(), 1);
1800 assert!(result[0].message.contains("'author' should come before 'date'"));
1801 }
1802
1803 #[test]
1806 fn test_key_order_empty_array_falls_back_to_alphabetical() {
1807 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1809 enabled: true,
1810 key_order: Some(vec![]),
1811 });
1812 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1814 let result = rule.check(&ctx).unwrap();
1815
1816 assert_eq!(result.len(), 1);
1819 assert!(result[0].message.contains("'author' should come before 'title'"));
1820 }
1821
1822 #[test]
1823 fn test_key_order_single_key() {
1824 let rule = create_rule_with_key_order(vec!["title"]);
1826 let content = "---\ntitle: Test\n---\n\n# Heading";
1827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1828 let result = rule.check(&ctx).unwrap();
1829
1830 assert!(result.is_empty());
1831 }
1832
1833 #[test]
1834 fn test_key_order_all_keys_specified() {
1835 let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
1837 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1839 let result = rule.check(&ctx).unwrap();
1840
1841 assert!(result.is_empty());
1842 }
1843
1844 #[test]
1845 fn test_key_order_no_keys_match() {
1846 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1848 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1850 let result = rule.check(&ctx).unwrap();
1851
1852 assert!(result.is_empty());
1855 }
1856
1857 #[test]
1858 fn test_key_order_no_keys_match_unsorted() {
1859 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1861 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1862 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1863 let result = rule.check(&ctx).unwrap();
1864
1865 assert_eq!(result.len(), 1);
1868 }
1869
1870 #[test]
1871 fn test_key_order_duplicate_keys_in_config() {
1872 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1874 enabled: true,
1875 key_order: Some(vec![
1876 "title".to_string(),
1877 "author".to_string(),
1878 "title".to_string(), ]),
1880 });
1881 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1883 let result = rule.check(&ctx).unwrap();
1884
1885 assert!(result.is_empty());
1887 }
1888
1889 #[test]
1890 fn test_key_order_with_comments_still_skips_fix() {
1891 let rule = create_rule_with_key_order(vec!["title", "author"]);
1893 let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
1894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895 let result = rule.check(&ctx).unwrap();
1896
1897 assert_eq!(result.len(), 1);
1899 assert!(result[0].message.contains("auto-fix unavailable"));
1900 assert!(result[0].fix.is_none());
1901 }
1902
1903 #[test]
1904 fn test_toml_custom_key_order_fix() {
1905 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1906 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
1907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1908 let fixed = rule.fix(&ctx).unwrap();
1909
1910 let title_pos = fixed.find("title").unwrap();
1912 let date_pos = fixed.find("date").unwrap();
1913 let author_pos = fixed.find("author").unwrap();
1914 assert!(
1915 title_pos < date_pos && date_pos < author_pos,
1916 "Fixed TOML should have keys in custom order. Got:\n{fixed}"
1917 );
1918 }
1919
1920 #[test]
1921 fn test_json_custom_key_order_fix() {
1922 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1923 let content = "{\n \"author\": \"John\",\n \"date\": \"2024-01-01\",\n \"title\": \"Test\"\n}\n\n# Heading";
1924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1925 let fixed = rule.fix(&ctx).unwrap();
1926
1927 let title_pos = fixed.find("\"title\"").unwrap();
1929 let date_pos = fixed.find("\"date\"").unwrap();
1930 let author_pos = fixed.find("\"author\"").unwrap();
1931 assert!(
1932 title_pos < date_pos && date_pos < author_pos,
1933 "Fixed JSON should have keys in custom order. Got:\n{fixed}"
1934 );
1935 }
1936
1937 #[test]
1938 fn test_key_order_unicode_keys() {
1939 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1941 enabled: true,
1942 key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
1943 });
1944 let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
1945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1946 let result = rule.check(&ctx).unwrap();
1947
1948 assert!(result.is_empty());
1950 }
1951
1952 #[test]
1953 fn test_key_order_mixed_specified_and_unlisted_boundary() {
1954 let rule = create_rule_with_key_order(vec!["z_last_specified"]);
1956 let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
1957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1958 let result = rule.check(&ctx).unwrap();
1959
1960 assert!(result.is_empty());
1963 }
1964
1965 #[test]
1966 fn test_key_order_fix_preserves_values() {
1967 let rule = create_rule_with_key_order(vec!["title", "tags"]);
1969 let content = "---\ntags:\n - rust\n - markdown\ntitle: Test\n---\n\n# Heading";
1970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1971 let fixed = rule.fix(&ctx).unwrap();
1972
1973 let title_pos = fixed.find("title:").unwrap();
1975 let tags_pos = fixed.find("tags:").unwrap();
1976 assert!(title_pos < tags_pos, "title should come before tags");
1977
1978 assert!(fixed.contains("- rust"), "List items should be preserved");
1980 assert!(fixed.contains("- markdown"), "List items should be preserved");
1981 }
1982
1983 #[test]
1984 fn test_key_order_idempotent_fix() {
1985 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1987 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1989
1990 let fixed_once = rule.fix(&ctx).unwrap();
1991 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1992 let fixed_twice = rule.fix(&ctx2).unwrap();
1993
1994 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1995 }
1996
1997 #[test]
1998 fn test_key_order_respects_later_position_over_alphabetical() {
1999 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2001 let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
2002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2003 let result = rule.check(&ctx).unwrap();
2004
2005 assert!(result.is_empty());
2007 }
2008
2009 #[test]
2012 fn test_json_braces_in_string_values_extracts_all_keys() {
2013 let rule = create_enabled_rule();
2017 let content = "{\n\"author\": \"Someone\",\n\"description\": \"Use { to open\",\n\"tags\": [\"a\"],\n\"title\": \"My Post\"\n}\n\nContent here.\n";
2018 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2019 let result = rule.check(&ctx).unwrap();
2020
2021 assert!(
2023 result.is_empty(),
2024 "All keys should be extracted and recognized as sorted. Got: {result:?}"
2025 );
2026 }
2027
2028 #[test]
2029 fn test_json_braces_in_string_key_after_brace_value_detected() {
2030 let rule = create_enabled_rule();
2032 let content = "{\n\"description\": \"Use { to open\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2036 let result = rule.check(&ctx).unwrap();
2037
2038 assert_eq!(
2041 result.len(),
2042 1,
2043 "Should detect unsorted keys after brace-containing string value"
2044 );
2045 assert!(
2046 result[0].message.contains("'author' should come before 'description'"),
2047 "Should report author before description. Got: {}",
2048 result[0].message
2049 );
2050 }
2051
2052 #[test]
2053 fn test_json_brackets_in_string_values() {
2054 let rule = create_enabled_rule();
2056 let content = "{\n\"description\": \"My [Post]\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2058 let result = rule.check(&ctx).unwrap();
2059
2060 assert_eq!(
2062 result.len(),
2063 1,
2064 "Should detect unsorted keys despite brackets in string values"
2065 );
2066 assert!(
2067 result[0].message.contains("'author' should come before 'description'"),
2068 "Got: {}",
2069 result[0].message
2070 );
2071 }
2072
2073 #[test]
2074 fn test_json_escaped_quotes_in_values() {
2075 let rule = create_enabled_rule();
2077 let content = "{\n\"title\": \"He said \\\"hello {world}\\\"\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2079 let result = rule.check(&ctx).unwrap();
2080
2081 assert_eq!(result.len(), 1, "Should handle escaped quotes with braces in values");
2083 assert!(
2084 result[0].message.contains("'author' should come before 'title'"),
2085 "Got: {}",
2086 result[0].message
2087 );
2088 }
2089
2090 #[test]
2091 fn test_json_multiple_braces_in_string() {
2092 let rule = create_enabled_rule();
2094 let content = "{\n\"pattern\": \"{{{}}\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2096 let result = rule.check(&ctx).unwrap();
2097
2098 assert_eq!(result.len(), 1, "Should handle multiple braces in string values");
2100 assert!(
2101 result[0].message.contains("'author' should come before 'pattern'"),
2102 "Got: {}",
2103 result[0].message
2104 );
2105 }
2106
2107 #[test]
2108 fn test_key_order_detects_wrong_custom_order() {
2109 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2111 let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
2112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2113 let result = rule.check(&ctx).unwrap();
2114
2115 assert_eq!(result.len(), 1);
2116 assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
2117 }
2118}