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);
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);
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);
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 Ok(match fm_type {
423 FrontMatterType::Yaml => self.fix_yaml(content),
424 FrontMatterType::Toml => self.fix_toml(content),
425 FrontMatterType::Json => self.fix_json(content),
426 _ => content.to_string(),
427 })
428 }
429
430 fn category(&self) -> RuleCategory {
431 RuleCategory::FrontMatter
432 }
433
434 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
435 ctx.content.is_empty()
436 || !ctx.content.starts_with("---") && !ctx.content.starts_with("+++") && !ctx.content.starts_with('{')
437 }
438
439 fn as_any(&self) -> &dyn std::any::Any {
440 self
441 }
442
443 fn default_config_section(&self) -> Option<(String, toml::Value)> {
444 let table = crate::rule_config_serde::config_schema_table(&MD072Config::default())?;
445 Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
446 }
447
448 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
449 where
450 Self: Sized,
451 {
452 let rule_config = crate::rule_config_serde::load_rule_config::<MD072Config>(config);
453 Box::new(Self::from_config_struct(rule_config))
454 }
455}
456
457impl MD072FrontmatterKeySort {
458 fn preserve_trailing_newline(original: &str, mut result: String) -> String {
463 if original.ends_with('\n') && !result.ends_with('\n') {
464 result.push('\n');
465 }
466 result
467 }
468
469 fn fix_yaml(&self, content: &str) -> String {
470 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
471 if frontmatter_lines.is_empty() {
472 return content.to_string();
473 }
474
475 if Self::has_comments(&frontmatter_lines) {
477 return content.to_string();
478 }
479
480 let keys = Self::extract_yaml_keys(&frontmatter_lines);
481 let key_order = self.config.key_order.as_deref();
482 if Self::are_indexed_keys_sorted(&keys, key_order) {
483 return content.to_string();
484 }
485
486 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
489
490 for (i, (line_idx, key)) in keys.iter().enumerate() {
491 let start = *line_idx;
492 let end = if i + 1 < keys.len() {
493 keys[i + 1].0
494 } else {
495 frontmatter_lines.len()
496 };
497
498 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
499 key_blocks.push((key.clone(), block_lines));
500 }
501
502 Self::sort_keys_by_order(&mut key_blocks, key_order);
504
505 let content_lines: Vec<&str> = content.lines().collect();
507 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
508
509 let mut result = String::new();
510 result.push_str("---\n");
511 for (_, lines) in &key_blocks {
512 for line in lines {
513 result.push_str(line);
514 result.push('\n');
515 }
516 }
517 result.push_str("---");
518
519 if fm_end < content_lines.len() {
520 result.push('\n');
521 result.push_str(&content_lines[fm_end..].join("\n"));
522 }
523
524 Self::preserve_trailing_newline(content, result)
525 }
526
527 fn fix_toml(&self, content: &str) -> String {
528 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
529 if frontmatter_lines.is_empty() {
530 return content.to_string();
531 }
532
533 if Self::has_comments(&frontmatter_lines) {
535 return content.to_string();
536 }
537
538 let keys = Self::extract_toml_keys(&frontmatter_lines);
539 let key_order = self.config.key_order.as_deref();
540 if Self::are_indexed_keys_sorted(&keys, key_order) {
541 return content.to_string();
542 }
543
544 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
547
548 for (i, (line_idx, key)) in keys.iter().enumerate() {
549 let start = *line_idx;
550 let end = if i + 1 < keys.len() {
551 keys[i + 1].0
552 } else {
553 frontmatter_lines.len()
554 };
555
556 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
557 key_blocks.push((key.clone(), block_lines));
558 }
559
560 Self::sort_keys_by_order(&mut key_blocks, key_order);
562
563 let content_lines: Vec<&str> = content.lines().collect();
565 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
566
567 let mut result = String::new();
568 result.push_str("+++\n");
569 for (_, lines) in &key_blocks {
570 for line in lines {
571 result.push_str(line);
572 result.push('\n');
573 }
574 }
575 result.push_str("+++");
576
577 if fm_end < content_lines.len() {
578 result.push('\n');
579 result.push_str(&content_lines[fm_end..].join("\n"));
580 }
581
582 Self::preserve_trailing_newline(content, result)
583 }
584
585 fn fix_json(&self, content: &str) -> String {
586 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
587 if frontmatter_lines.is_empty() {
588 return content.to_string();
589 }
590
591 let keys = Self::extract_json_keys(&frontmatter_lines);
592 let key_order = self.config.key_order.as_deref();
593
594 if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
595 return content.to_string();
596 }
597
598 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
600
601 match serde_json::from_str::<serde_json::Value>(&json_content) {
603 Ok(serde_json::Value::Object(map)) => {
604 let mut sorted_map = serde_json::Map::new();
606 let mut keys: Vec<_> = map.keys().cloned().collect();
607 keys.sort_by(|a, b| {
608 let pos_a = Self::key_sort_position(a, key_order);
609 let pos_b = Self::key_sort_position(b, key_order);
610 pos_a.cmp(&pos_b)
611 });
612
613 for key in keys {
614 if let Some(value) = map.get(&key) {
615 sorted_map.insert(key, value.clone());
616 }
617 }
618
619 match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
620 Ok(sorted_json) => {
621 let lines: Vec<&str> = content.lines().collect();
622 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
623
624 let mut result = String::new();
627 result.push_str(&sorted_json);
628
629 if fm_end < lines.len() {
630 result.push('\n');
631 result.push_str(&lines[fm_end..].join("\n"));
632 }
633
634 Self::preserve_trailing_newline(content, result)
635 }
636 Err(_) => content.to_string(),
637 }
638 }
639 _ => content.to_string(),
640 }
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647 use crate::lint_context::LintContext;
648
649 fn create_enabled_rule() -> MD072FrontmatterKeySort {
651 MD072FrontmatterKeySort::from_config_struct(MD072Config {
652 enabled: true,
653 key_order: None,
654 })
655 }
656
657 fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
659 MD072FrontmatterKeySort::from_config_struct(MD072Config {
660 enabled: true,
661 key_order: Some(keys.into_iter().map(String::from).collect()),
662 })
663 }
664
665 #[test]
668 fn test_enabled_via_config() {
669 let rule = create_enabled_rule();
670 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672 let result = rule.check(&ctx).unwrap();
673
674 assert_eq!(result.len(), 1);
676 }
677
678 #[test]
681 fn test_no_frontmatter() {
682 let rule = create_enabled_rule();
683 let content = "# Heading\n\nContent.";
684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685 let result = rule.check(&ctx).unwrap();
686
687 assert!(result.is_empty());
688 }
689
690 #[test]
691 fn test_yaml_sorted_keys() {
692 let rule = create_enabled_rule();
693 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let result = rule.check(&ctx).unwrap();
696
697 assert!(result.is_empty());
698 }
699
700 #[test]
701 fn test_yaml_unsorted_keys() {
702 let rule = create_enabled_rule();
703 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
705 let result = rule.check(&ctx).unwrap();
706
707 assert_eq!(result.len(), 1);
708 assert!(result[0].message.contains("YAML"));
709 assert!(result[0].message.contains("not sorted"));
710 assert!(result[0].message.contains("'author' should come before 'title'"));
712 }
713
714 #[test]
715 fn test_yaml_case_insensitive_sort() {
716 let rule = create_enabled_rule();
717 let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719 let result = rule.check(&ctx).unwrap();
720
721 assert!(result.is_empty());
723 }
724
725 #[test]
726 fn test_yaml_fix_sorts_keys() {
727 let rule = create_enabled_rule();
728 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730 let fixed = rule.fix(&ctx).unwrap();
731
732 let author_pos = fixed.find("author:").unwrap();
734 let title_pos = fixed.find("title:").unwrap();
735 assert!(author_pos < title_pos);
736 }
737
738 #[test]
739 fn test_yaml_no_fix_with_comments() {
740 let rule = create_enabled_rule();
741 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743 let result = rule.check(&ctx).unwrap();
744
745 assert_eq!(result.len(), 1);
746 assert!(result[0].message.contains("auto-fix unavailable"));
747 assert!(result[0].fix.is_none());
748
749 let fixed = rule.fix(&ctx).unwrap();
751 assert_eq!(fixed, content);
752 }
753
754 #[test]
755 fn test_yaml_single_key() {
756 let rule = create_enabled_rule();
757 let content = "---\ntitle: Test\n---\n\n# Heading";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let result = rule.check(&ctx).unwrap();
760
761 assert!(result.is_empty());
763 }
764
765 #[test]
766 fn test_yaml_nested_keys_ignored() {
767 let rule = create_enabled_rule();
768 let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let result = rule.check(&ctx).unwrap();
772
773 assert!(result.is_empty());
775 }
776
777 #[test]
778 fn test_yaml_fix_idempotent() {
779 let rule = create_enabled_rule();
780 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
782 let fixed_once = rule.fix(&ctx).unwrap();
783
784 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
785 let fixed_twice = rule.fix(&ctx2).unwrap();
786
787 assert_eq!(fixed_once, fixed_twice);
788 }
789
790 #[test]
791 fn test_yaml_fix_preserves_trailing_newline() {
792 let rule = create_enabled_rule();
793 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n";
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796 let fixed = rule.fix(&ctx).unwrap();
797 assert!(
798 fixed.ends_with('\n'),
799 "trailing newline must be preserved, got {fixed:?}"
800 );
801
802 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
804 let fixed_twice = rule.fix(&ctx2).unwrap();
805 assert_eq!(fixed, fixed_twice);
806 }
807
808 #[test]
809 fn test_yaml_fix_whole_file_frontmatter_preserves_trailing_newline() {
810 let rule = create_enabled_rule();
811 let content = "---\ntitle: Test\nauthor: John\n---\n";
813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
814 let fixed = rule.fix(&ctx).unwrap();
815 assert!(
816 fixed.ends_with('\n'),
817 "trailing newline must be preserved, got {fixed:?}"
818 );
819 }
820
821 #[test]
822 fn test_yaml_quoted_keys_sort_by_content() {
823 let rule = create_enabled_rule();
824 let content = "---\n\"zebra\": 1\napple: 2\n---\n\n# Heading";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let result = rule.check(&ctx).unwrap();
829
830 assert_eq!(result.len(), 1, "quoted key out of order must be flagged");
831 assert!(result[0].message.contains("'apple' should come before 'zebra'"));
832 }
833
834 #[test]
835 fn test_yaml_quoted_key_warning_span_covers_quotes() {
836 let rule = create_enabled_rule();
837 let content = "---\nbanana: 1\n\"apple\": 2\n---\n";
841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842 let result = rule.check(&ctx).unwrap();
843
844 assert_eq!(result.len(), 1);
845 let w = &result[0];
846 assert_eq!(w.line, 3);
847 assert_eq!(w.column, 1);
848 assert_eq!(w.end_column, 8, "diagnostic span must cover the quoted key");
850 }
851
852 #[test]
853 fn test_yaml_complex_values() {
854 let rule = create_enabled_rule();
855 let content =
857 "---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\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());
863 }
864
865 #[test]
868 fn test_toml_sorted_keys() {
869 let rule = create_enabled_rule();
870 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
872 let result = rule.check(&ctx).unwrap();
873
874 assert!(result.is_empty());
875 }
876
877 #[test]
878 fn test_toml_unsorted_keys() {
879 let rule = create_enabled_rule();
880 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
882 let result = rule.check(&ctx).unwrap();
883
884 assert_eq!(result.len(), 1);
885 assert!(result[0].message.contains("TOML"));
886 assert!(result[0].message.contains("not sorted"));
887 }
888
889 #[test]
890 fn test_toml_fix_sorts_keys() {
891 let rule = create_enabled_rule();
892 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894 let fixed = rule.fix(&ctx).unwrap();
895
896 let author_pos = fixed.find("author").unwrap();
898 let title_pos = fixed.find("title").unwrap();
899 assert!(author_pos < title_pos);
900 }
901
902 #[test]
903 fn test_toml_no_fix_with_comments() {
904 let rule = create_enabled_rule();
905 let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907 let result = rule.check(&ctx).unwrap();
908
909 assert_eq!(result.len(), 1);
910 assert!(result[0].message.contains("auto-fix unavailable"));
911
912 let fixed = rule.fix(&ctx).unwrap();
914 assert_eq!(fixed, content);
915 }
916
917 #[test]
920 fn test_json_sorted_keys() {
921 let rule = create_enabled_rule();
922 let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924 let result = rule.check(&ctx).unwrap();
925
926 assert!(result.is_empty());
927 }
928
929 #[test]
930 fn test_json_unsorted_keys() {
931 let rule = create_enabled_rule();
932 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
934 let result = rule.check(&ctx).unwrap();
935
936 assert_eq!(result.len(), 1);
937 assert!(result[0].message.contains("JSON"));
938 assert!(result[0].message.contains("not sorted"));
939 }
940
941 #[test]
942 fn test_json_fix_sorts_keys() {
943 let rule = create_enabled_rule();
944 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946 let fixed = rule.fix(&ctx).unwrap();
947
948 let author_pos = fixed.find("author").unwrap();
950 let title_pos = fixed.find("title").unwrap();
951 assert!(author_pos < title_pos);
952 }
953
954 #[test]
955 fn test_json_always_fixable() {
956 let rule = create_enabled_rule();
957 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960 let result = rule.check(&ctx).unwrap();
961
962 assert_eq!(result.len(), 1);
963 assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
965 }
966
967 #[test]
970 fn test_empty_content() {
971 let rule = create_enabled_rule();
972 let content = "";
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974 let result = rule.check(&ctx).unwrap();
975
976 assert!(result.is_empty());
977 }
978
979 #[test]
980 fn test_empty_frontmatter() {
981 let rule = create_enabled_rule();
982 let content = "---\n---\n\n# Heading";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984 let result = rule.check(&ctx).unwrap();
985
986 assert!(result.is_empty());
987 }
988
989 #[test]
990 fn test_toml_nested_tables_ignored() {
991 let rule = create_enabled_rule();
993 let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.check(&ctx).unwrap();
996
997 assert_eq!(result.len(), 1);
999 assert!(result[0].message.contains("'sort_by' should come before 'title'"));
1001 assert!(!result[0].message.contains("we_have_extra"));
1002 }
1003
1004 #[test]
1005 fn test_toml_nested_taxonomies_ignored() {
1006 let rule = create_enabled_rule();
1008 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010 let result = rule.check(&ctx).unwrap();
1011
1012 assert_eq!(result.len(), 1);
1014 assert!(result[0].message.contains("'date' should come before 'title'"));
1016 assert!(!result[0].message.contains("categories"));
1017 assert!(!result[0].message.contains("tags"));
1018 }
1019
1020 #[test]
1023 fn test_yaml_unicode_keys() {
1024 let rule = create_enabled_rule();
1025 let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
1027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1028 let result = rule.check(&ctx).unwrap();
1029
1030 assert_eq!(result.len(), 1);
1032 }
1033
1034 #[test]
1035 fn test_yaml_keys_with_special_characters() {
1036 let rule = create_enabled_rule();
1037 let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
1039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1040 let result = rule.check(&ctx).unwrap();
1041
1042 assert!(result.is_empty());
1044 }
1045
1046 #[test]
1047 fn test_yaml_keys_with_numbers() {
1048 let rule = create_enabled_rule();
1049 let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
1050 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1051 let result = rule.check(&ctx).unwrap();
1052
1053 assert!(result.is_empty());
1055 }
1056
1057 #[test]
1058 fn test_yaml_multiline_string_block_literal() {
1059 let rule = create_enabled_rule();
1060 let content =
1061 "---\ndescription: |\n This is a\n multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
1062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1063 let result = rule.check(&ctx).unwrap();
1064
1065 assert_eq!(result.len(), 1);
1067 assert!(result[0].message.contains("'author' should come before 'title'"));
1068 }
1069
1070 #[test]
1071 fn test_yaml_multiline_string_folded() {
1072 let rule = create_enabled_rule();
1073 let content = "---\ndescription: >\n This is a\n folded string\nauthor: John\n---\n\n# Heading";
1074 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1075 let result = rule.check(&ctx).unwrap();
1076
1077 assert_eq!(result.len(), 1);
1079 }
1080
1081 #[test]
1082 fn test_yaml_fix_preserves_multiline_values() {
1083 let rule = create_enabled_rule();
1084 let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
1085 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1086 let fixed = rule.fix(&ctx).unwrap();
1087
1088 let desc_pos = fixed.find("description").unwrap();
1090 let title_pos = fixed.find("title").unwrap();
1091 assert!(desc_pos < title_pos);
1092 }
1093
1094 #[test]
1095 fn test_yaml_quoted_keys() {
1096 let rule = create_enabled_rule();
1097 let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
1098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1099 let result = rule.check(&ctx).unwrap();
1100
1101 assert!(result.is_empty());
1103 }
1104
1105 #[test]
1106 fn test_yaml_duplicate_keys() {
1107 let rule = create_enabled_rule();
1109 let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
1110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1111 let result = rule.check(&ctx).unwrap();
1112
1113 assert_eq!(result.len(), 1);
1115 }
1116
1117 #[test]
1118 fn test_toml_inline_table() {
1119 let rule = create_enabled_rule();
1120 let content =
1121 "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
1122 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1123 let result = rule.check(&ctx).unwrap();
1124
1125 assert!(result.is_empty());
1127 }
1128
1129 #[test]
1130 fn test_toml_array_of_tables() {
1131 let rule = create_enabled_rule();
1132 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\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_eq!(result.len(), 1);
1138 assert!(result[0].message.contains("'date' should come before 'title'"));
1140 }
1141
1142 #[test]
1143 fn test_json_nested_objects() {
1144 let rule = create_enabled_rule();
1145 let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147 let result = rule.check(&ctx).unwrap();
1148
1149 assert!(result.is_empty());
1151 }
1152
1153 #[test]
1154 fn test_json_arrays() {
1155 let rule = create_enabled_rule();
1156 let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158 let result = rule.check(&ctx).unwrap();
1159
1160 assert_eq!(result.len(), 1);
1162 }
1163
1164 #[test]
1165 fn test_fix_preserves_content_after_frontmatter() {
1166 let rule = create_enabled_rule();
1167 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1168 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1169 let fixed = rule.fix(&ctx).unwrap();
1170
1171 assert!(fixed.contains("# Heading"));
1173 assert!(fixed.contains("Paragraph 1."));
1174 assert!(fixed.contains("- List item"));
1175 assert!(fixed.contains("- Another item"));
1176 }
1177
1178 #[test]
1179 fn test_fix_yaml_produces_valid_yaml() {
1180 let rule = create_enabled_rule();
1181 let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183 let fixed = rule.fix(&ctx).unwrap();
1184
1185 let lines: Vec<&str> = fixed.lines().collect();
1188 let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1189 let fm_content: String = lines[1..fm_end].join("\n");
1190
1191 let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&fm_content);
1193 assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1194 }
1195
1196 #[test]
1197 fn test_fix_toml_produces_valid_toml() {
1198 let rule = create_enabled_rule();
1199 let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1201 let fixed = rule.fix(&ctx).unwrap();
1202
1203 let lines: Vec<&str> = fixed.lines().collect();
1205 let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1206 let fm_content: String = lines[1..fm_end].join("\n");
1207
1208 let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1210 assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1211 }
1212
1213 #[test]
1214 fn test_fix_json_produces_valid_json() {
1215 let rule = create_enabled_rule();
1216 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218 let fixed = rule.fix(&ctx).unwrap();
1219
1220 let json_end = fixed.find("\n\n").unwrap();
1222 let json_content = &fixed[..json_end];
1223
1224 let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1226 assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1227 }
1228
1229 #[test]
1230 fn test_many_keys_performance() {
1231 let rule = create_enabled_rule();
1232 let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1234 keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1236
1237 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1238 let result = rule.check(&ctx).unwrap();
1239
1240 assert_eq!(result.len(), 1);
1242 }
1243
1244 #[test]
1245 fn test_yaml_empty_value() {
1246 let rule = create_enabled_rule();
1247 let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249 let result = rule.check(&ctx).unwrap();
1250
1251 assert_eq!(result.len(), 1);
1253 }
1254
1255 #[test]
1256 fn test_yaml_null_value() {
1257 let rule = create_enabled_rule();
1258 let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1260 let result = rule.check(&ctx).unwrap();
1261
1262 assert_eq!(result.len(), 1);
1263 }
1264
1265 #[test]
1266 fn test_yaml_boolean_values() {
1267 let rule = create_enabled_rule();
1268 let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1269 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1270 let result = rule.check(&ctx).unwrap();
1271
1272 assert_eq!(result.len(), 1);
1274 }
1275
1276 #[test]
1277 fn test_toml_boolean_values() {
1278 let rule = create_enabled_rule();
1279 let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1281 let result = rule.check(&ctx).unwrap();
1282
1283 assert_eq!(result.len(), 1);
1284 }
1285
1286 #[test]
1287 fn test_yaml_list_at_top_level() {
1288 let rule = create_enabled_rule();
1289 let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
1290 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1291 let result = rule.check(&ctx).unwrap();
1292
1293 assert_eq!(result.len(), 1);
1295 }
1296
1297 #[test]
1298 fn test_three_keys_all_orderings() {
1299 let rule = create_enabled_rule();
1300
1301 let orderings = [
1303 ("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), ];
1310
1311 for (name, content, should_pass) in orderings {
1312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313 let result = rule.check(&ctx).unwrap();
1314 assert_eq!(
1315 result.is_empty(),
1316 should_pass,
1317 "Ordering {name} should {} pass",
1318 if should_pass { "" } else { "not" }
1319 );
1320 }
1321 }
1322
1323 #[test]
1324 fn test_crlf_line_endings() {
1325 let rule = create_enabled_rule();
1326 let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328 let result = rule.check(&ctx).unwrap();
1329
1330 assert_eq!(result.len(), 1);
1332 }
1333
1334 #[test]
1335 fn test_json_escaped_quotes_in_keys() {
1336 let rule = create_enabled_rule();
1337 let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340 let result = rule.check(&ctx).unwrap();
1341
1342 assert_eq!(result.len(), 1);
1344 }
1345
1346 #[test]
1349 fn test_warning_fix_yaml_sorts_keys() {
1350 let rule = create_enabled_rule();
1351 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1353 let warnings = rule.check(&ctx).unwrap();
1354
1355 assert_eq!(warnings.len(), 1);
1356 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1357
1358 let fix = warnings[0].fix.as_ref().unwrap();
1359 assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1360
1361 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1363
1364 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1366 let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1367 assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1368 }
1369
1370 #[test]
1371 fn test_warning_fix_preserves_yaml_list_indentation() {
1372 let rule = create_enabled_rule();
1373 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1375 let warnings = rule.check(&ctx).unwrap();
1376
1377 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1378
1379 assert!(
1381 fixed.contains(" - hello"),
1382 "List indentation should be preserved: {fixed}"
1383 );
1384 assert!(
1385 fixed.contains(" - world"),
1386 "List indentation should be preserved: {fixed}"
1387 );
1388 }
1389
1390 #[test]
1391 fn test_warning_fix_preserves_nested_object_indentation() {
1392 let rule = create_enabled_rule();
1393 let content = "---\nzzzz: value\naaaa:\n nested_key: nested_value\n another: 123\n---\n\n# Heading\n";
1394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1395 let warnings = rule.check(&ctx).unwrap();
1396
1397 assert_eq!(warnings.len(), 1);
1398 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1399
1400 let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1402 let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1403 assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1404
1405 assert!(
1407 fixed.contains(" nested_key: nested_value"),
1408 "Nested object indentation should be preserved: {fixed}"
1409 );
1410 assert!(
1411 fixed.contains(" another: 123"),
1412 "Nested object indentation should be preserved: {fixed}"
1413 );
1414 }
1415
1416 #[test]
1417 fn test_warning_fix_preserves_deeply_nested_structure() {
1418 let rule = create_enabled_rule();
1419 let content = "---\nzzz: top\naaa:\n level1:\n level2:\n - item1\n - item2\n---\n\n# Content\n";
1420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1421 let warnings = rule.check(&ctx).unwrap();
1422
1423 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1424
1425 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1427 let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1428 assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1429
1430 assert!(fixed.contains(" level1:"), "2-space indent should be preserved");
1432 assert!(fixed.contains(" level2:"), "4-space indent should be preserved");
1433 assert!(fixed.contains(" - item1"), "6-space indent should be preserved");
1434 assert!(fixed.contains(" - item2"), "6-space indent should be preserved");
1435 }
1436
1437 #[test]
1438 fn test_warning_fix_toml_sorts_keys() {
1439 let rule = create_enabled_rule();
1440 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1442 let warnings = rule.check(&ctx).unwrap();
1443
1444 assert_eq!(warnings.len(), 1);
1445 assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1446
1447 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1448
1449 let author_pos = fixed.find("author").expect("author should exist");
1451 let title_pos = fixed.find("title").expect("title should exist");
1452 assert!(author_pos < title_pos, "author should come before title");
1453 }
1454
1455 #[test]
1456 fn test_warning_fix_json_sorts_keys() {
1457 let rule = create_enabled_rule();
1458 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1460 let warnings = rule.check(&ctx).unwrap();
1461
1462 assert_eq!(warnings.len(), 1);
1463 assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1464
1465 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1466
1467 let author_pos = fixed.find("author").expect("author should exist");
1469 let title_pos = fixed.find("title").expect("title should exist");
1470 assert!(author_pos < title_pos, "author should come before title");
1471 }
1472
1473 #[test]
1474 fn test_warning_fix_no_fix_when_comments_present() {
1475 let rule = create_enabled_rule();
1476 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1478 let warnings = rule.check(&ctx).unwrap();
1479
1480 assert_eq!(warnings.len(), 1);
1481 assert!(
1482 warnings[0].fix.is_none(),
1483 "Warning should NOT have a fix when comments are present"
1484 );
1485 assert!(
1486 warnings[0].message.contains("auto-fix unavailable"),
1487 "Message should indicate auto-fix is unavailable"
1488 );
1489 }
1490
1491 #[test]
1492 fn test_warning_fix_preserves_content_after_frontmatter() {
1493 let rule = create_enabled_rule();
1494 let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1496 let warnings = rule.check(&ctx).unwrap();
1497
1498 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1499
1500 assert!(fixed.contains("# Heading"), "Heading should be preserved");
1502 assert!(
1503 fixed.contains("Paragraph with content."),
1504 "Paragraph should be preserved"
1505 );
1506 assert!(fixed.contains("- List item"), "List item should be preserved");
1507 }
1508
1509 #[test]
1510 fn test_warning_fix_idempotent() {
1511 let rule = create_enabled_rule();
1512 let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1514 let warnings = rule.check(&ctx).unwrap();
1515
1516 let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1517
1518 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1520 let warnings2 = rule.check(&ctx2).unwrap();
1521
1522 assert!(
1523 warnings2.is_empty(),
1524 "After fixing, no more warnings should be produced"
1525 );
1526 }
1527
1528 #[test]
1529 fn test_warning_fix_preserves_multiline_block_literal() {
1530 let rule = create_enabled_rule();
1531 let content = "---\nzzz: simple\naaa: |\n Line 1 of block\n Line 2 of block\n---\n\n# Heading\n";
1532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1533 let warnings = rule.check(&ctx).unwrap();
1534
1535 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1536
1537 assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1539 assert!(
1540 fixed.contains(" Line 1 of block"),
1541 "Block literal line 1 should be preserved with indent"
1542 );
1543 assert!(
1544 fixed.contains(" Line 2 of block"),
1545 "Block literal line 2 should be preserved with indent"
1546 );
1547 }
1548
1549 #[test]
1550 fn test_warning_fix_preserves_folded_string() {
1551 let rule = create_enabled_rule();
1552 let content = "---\nzzz: simple\naaa: >\n Folded line 1\n Folded line 2\n---\n\n# Content\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!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1560 assert!(
1561 fixed.contains(" Folded line 1"),
1562 "Folded line 1 should be preserved with indent"
1563 );
1564 assert!(
1565 fixed.contains(" Folded line 2"),
1566 "Folded line 2 should be preserved with indent"
1567 );
1568 }
1569
1570 #[test]
1571 fn test_warning_fix_preserves_4_space_indentation() {
1572 let rule = create_enabled_rule();
1573 let content = "---\nzzz: value\naaa:\n nested: with_4_spaces\n another: value\n---\n\n# Heading\n";
1575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1576 let warnings = rule.check(&ctx).unwrap();
1577
1578 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1579
1580 assert!(
1582 fixed.contains(" nested: with_4_spaces"),
1583 "4-space indentation should be preserved: {fixed}"
1584 );
1585 assert!(
1586 fixed.contains(" another: value"),
1587 "4-space indentation should be preserved: {fixed}"
1588 );
1589 }
1590
1591 #[test]
1592 fn test_warning_fix_preserves_tab_indentation() {
1593 let rule = create_enabled_rule();
1594 let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1597 let warnings = rule.check(&ctx).unwrap();
1598
1599 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1600
1601 assert!(
1603 fixed.contains("\tnested: with_tab"),
1604 "Tab indentation should be preserved: {fixed}"
1605 );
1606 assert!(
1607 fixed.contains("\tanother: value"),
1608 "Tab indentation should be preserved: {fixed}"
1609 );
1610 }
1611
1612 #[test]
1613 fn test_warning_fix_preserves_inline_list() {
1614 let rule = create_enabled_rule();
1615 let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1618 let warnings = rule.check(&ctx).unwrap();
1619
1620 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1621
1622 assert!(
1624 fixed.contains("aaa: [one, two, three]"),
1625 "Inline list should be preserved exactly: {fixed}"
1626 );
1627 }
1628
1629 #[test]
1630 fn test_warning_fix_preserves_quoted_strings() {
1631 let rule = create_enabled_rule();
1632 let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1635 let warnings = rule.check(&ctx).unwrap();
1636
1637 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1638
1639 assert!(
1641 fixed.contains("aaa: \"value with: colon\""),
1642 "Double-quoted string should be preserved: {fixed}"
1643 );
1644 assert!(
1645 fixed.contains("bbb: 'single quotes'"),
1646 "Single-quoted string should be preserved: {fixed}"
1647 );
1648 }
1649
1650 #[test]
1653 fn test_yaml_custom_key_order_sorted() {
1654 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1656 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1658 let result = rule.check(&ctx).unwrap();
1659
1660 assert!(result.is_empty());
1662 }
1663
1664 #[test]
1665 fn test_yaml_custom_key_order_unsorted() {
1666 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
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_eq!(result.len(), 1);
1673 assert!(result[0].message.contains("'date' should come before 'author'"));
1675 }
1676
1677 #[test]
1678 fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1679 let rule = create_rule_with_key_order(vec!["title"]);
1681 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\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!(result.is_empty());
1688 }
1689
1690 #[test]
1691 fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1692 let rule = create_rule_with_key_order(vec!["title"]);
1694 let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1696 let result = rule.check(&ctx).unwrap();
1697
1698 assert_eq!(result.len(), 1);
1700 assert!(result[0].message.contains("'author' should come before 'zebra'"));
1701 }
1702
1703 #[test]
1704 fn test_yaml_custom_key_order_fix() {
1705 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1706 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1708 let fixed = rule.fix(&ctx).unwrap();
1709
1710 let title_pos = fixed.find("title:").unwrap();
1712 let date_pos = fixed.find("date:").unwrap();
1713 let author_pos = fixed.find("author:").unwrap();
1714 assert!(
1715 title_pos < date_pos && date_pos < author_pos,
1716 "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1717 );
1718 }
1719
1720 #[test]
1721 fn test_yaml_custom_key_order_fix_with_unlisted() {
1722 let rule = create_rule_with_key_order(vec!["title", "author"]);
1724 let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
1725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1726 let fixed = rule.fix(&ctx).unwrap();
1727
1728 let title_pos = fixed.find("title:").unwrap();
1730 let author_pos = fixed.find("author:").unwrap();
1731 let aardvark_pos = fixed.find("aardvark:").unwrap();
1732 let zebra_pos = fixed.find("zebra:").unwrap();
1733
1734 assert!(
1735 title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1736 "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1737 );
1738 }
1739
1740 #[test]
1741 fn test_toml_custom_key_order_sorted() {
1742 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1743 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
1744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1745 let result = rule.check(&ctx).unwrap();
1746
1747 assert!(result.is_empty());
1748 }
1749
1750 #[test]
1751 fn test_toml_custom_key_order_unsorted() {
1752 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1753 let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
1754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1755 let result = rule.check(&ctx).unwrap();
1756
1757 assert_eq!(result.len(), 1);
1758 assert!(result[0].message.contains("TOML"));
1759 }
1760
1761 #[test]
1762 fn test_json_custom_key_order_sorted() {
1763 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1764 let content = "{\n \"title\": \"Test\",\n \"date\": \"2024-01-01\",\n \"author\": \"John\"\n}\n\n# Heading";
1765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1766 let result = rule.check(&ctx).unwrap();
1767
1768 assert!(result.is_empty());
1769 }
1770
1771 #[test]
1772 fn test_json_custom_key_order_unsorted() {
1773 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1774 let content = "{\n \"author\": \"John\",\n \"title\": \"Test\",\n \"date\": \"2024-01-01\"\n}\n\n# Heading";
1775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1776 let result = rule.check(&ctx).unwrap();
1777
1778 assert_eq!(result.len(), 1);
1779 assert!(result[0].message.contains("JSON"));
1780 }
1781
1782 #[test]
1783 fn test_key_order_case_insensitive_match() {
1784 let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
1786 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1788 let result = rule.check(&ctx).unwrap();
1789
1790 assert!(result.is_empty());
1792 }
1793
1794 #[test]
1795 fn test_key_order_partial_match() {
1796 let rule = create_rule_with_key_order(vec!["title"]);
1798 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1800 let result = rule.check(&ctx).unwrap();
1801
1802 assert_eq!(result.len(), 1);
1813 assert!(result[0].message.contains("'author' should come before 'date'"));
1814 }
1815
1816 #[test]
1819 fn test_key_order_empty_array_falls_back_to_alphabetical() {
1820 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1822 enabled: true,
1823 key_order: Some(vec![]),
1824 });
1825 let content = "---\ntitle: Test\nauthor: John\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);
1832 assert!(result[0].message.contains("'author' should come before 'title'"));
1833 }
1834
1835 #[test]
1836 fn test_key_order_single_key() {
1837 let rule = create_rule_with_key_order(vec!["title"]);
1839 let content = "---\ntitle: Test\n---\n\n# Heading";
1840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1841 let result = rule.check(&ctx).unwrap();
1842
1843 assert!(result.is_empty());
1844 }
1845
1846 #[test]
1847 fn test_key_order_all_keys_specified() {
1848 let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
1850 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1852 let result = rule.check(&ctx).unwrap();
1853
1854 assert!(result.is_empty());
1855 }
1856
1857 #[test]
1858 fn test_key_order_no_keys_match() {
1859 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1861 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\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!(result.is_empty());
1868 }
1869
1870 #[test]
1871 fn test_key_order_no_keys_match_unsorted() {
1872 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1874 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1876 let result = rule.check(&ctx).unwrap();
1877
1878 assert_eq!(result.len(), 1);
1881 }
1882
1883 #[test]
1884 fn test_key_order_duplicate_keys_in_config() {
1885 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1887 enabled: true,
1888 key_order: Some(vec![
1889 "title".to_string(),
1890 "author".to_string(),
1891 "title".to_string(), ]),
1893 });
1894 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1896 let result = rule.check(&ctx).unwrap();
1897
1898 assert!(result.is_empty());
1900 }
1901
1902 #[test]
1903 fn test_key_order_with_comments_still_skips_fix() {
1904 let rule = create_rule_with_key_order(vec!["title", "author"]);
1906 let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
1907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1908 let result = rule.check(&ctx).unwrap();
1909
1910 assert_eq!(result.len(), 1);
1912 assert!(result[0].message.contains("auto-fix unavailable"));
1913 assert!(result[0].fix.is_none());
1914 }
1915
1916 #[test]
1917 fn test_toml_custom_key_order_fix() {
1918 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 let fixed = rule.fix(&ctx).unwrap();
1922
1923 let title_pos = fixed.find("title").unwrap();
1925 let date_pos = fixed.find("date").unwrap();
1926 let author_pos = fixed.find("author").unwrap();
1927 assert!(
1928 title_pos < date_pos && date_pos < author_pos,
1929 "Fixed TOML should have keys in custom order. Got:\n{fixed}"
1930 );
1931 }
1932
1933 #[test]
1934 fn test_json_custom_key_order_fix() {
1935 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1936 let content = "{\n \"author\": \"John\",\n \"date\": \"2024-01-01\",\n \"title\": \"Test\"\n}\n\n# Heading";
1937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1938 let fixed = rule.fix(&ctx).unwrap();
1939
1940 let title_pos = fixed.find("\"title\"").unwrap();
1942 let date_pos = fixed.find("\"date\"").unwrap();
1943 let author_pos = fixed.find("\"author\"").unwrap();
1944 assert!(
1945 title_pos < date_pos && date_pos < author_pos,
1946 "Fixed JSON should have keys in custom order. Got:\n{fixed}"
1947 );
1948 }
1949
1950 #[test]
1951 fn test_key_order_unicode_keys() {
1952 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1954 enabled: true,
1955 key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
1956 });
1957 let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
1958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1959 let result = rule.check(&ctx).unwrap();
1960
1961 assert!(result.is_empty());
1963 }
1964
1965 #[test]
1966 fn test_key_order_mixed_specified_and_unlisted_boundary() {
1967 let rule = create_rule_with_key_order(vec!["z_last_specified"]);
1969 let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
1970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1971 let result = rule.check(&ctx).unwrap();
1972
1973 assert!(result.is_empty());
1976 }
1977
1978 #[test]
1979 fn test_key_order_fix_preserves_values() {
1980 let rule = create_rule_with_key_order(vec!["title", "tags"]);
1982 let content = "---\ntags:\n - rust\n - markdown\ntitle: Test\n---\n\n# Heading";
1983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1984 let fixed = rule.fix(&ctx).unwrap();
1985
1986 let title_pos = fixed.find("title:").unwrap();
1988 let tags_pos = fixed.find("tags:").unwrap();
1989 assert!(title_pos < tags_pos, "title should come before tags");
1990
1991 assert!(fixed.contains("- rust"), "List items should be preserved");
1993 assert!(fixed.contains("- markdown"), "List items should be preserved");
1994 }
1995
1996 #[test]
1997 fn test_key_order_idempotent_fix() {
1998 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
2000 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
2001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2002
2003 let fixed_once = rule.fix(&ctx).unwrap();
2004 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2005 let fixed_twice = rule.fix(&ctx2).unwrap();
2006
2007 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2008 }
2009
2010 #[test]
2011 fn test_key_order_respects_later_position_over_alphabetical() {
2012 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2014 let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
2015 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2016 let result = rule.check(&ctx).unwrap();
2017
2018 assert!(result.is_empty());
2020 }
2021
2022 #[test]
2025 fn test_json_braces_in_string_values_extracts_all_keys() {
2026 let rule = create_enabled_rule();
2030 let content = "{\n\"author\": \"Someone\",\n\"description\": \"Use { to open\",\n\"tags\": [\"a\"],\n\"title\": \"My Post\"\n}\n\nContent here.\n";
2031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2032 let result = rule.check(&ctx).unwrap();
2033
2034 assert!(
2036 result.is_empty(),
2037 "All keys should be extracted and recognized as sorted. Got: {result:?}"
2038 );
2039 }
2040
2041 #[test]
2042 fn test_json_braces_in_string_key_after_brace_value_detected() {
2043 let rule = create_enabled_rule();
2045 let content = "{\n\"description\": \"Use { to open\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2049 let result = rule.check(&ctx).unwrap();
2050
2051 assert_eq!(
2054 result.len(),
2055 1,
2056 "Should detect unsorted keys after brace-containing string value"
2057 );
2058 assert!(
2059 result[0].message.contains("'author' should come before 'description'"),
2060 "Should report author before description. Got: {}",
2061 result[0].message
2062 );
2063 }
2064
2065 #[test]
2066 fn test_json_brackets_in_string_values() {
2067 let rule = create_enabled_rule();
2069 let content = "{\n\"description\": \"My [Post]\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2071 let result = rule.check(&ctx).unwrap();
2072
2073 assert_eq!(
2075 result.len(),
2076 1,
2077 "Should detect unsorted keys despite brackets in string values"
2078 );
2079 assert!(
2080 result[0].message.contains("'author' should come before 'description'"),
2081 "Got: {}",
2082 result[0].message
2083 );
2084 }
2085
2086 #[test]
2087 fn test_json_escaped_quotes_in_values() {
2088 let rule = create_enabled_rule();
2090 let content = "{\n\"title\": \"He said \\\"hello {world}\\\"\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2091 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2092 let result = rule.check(&ctx).unwrap();
2093
2094 assert_eq!(result.len(), 1, "Should handle escaped quotes with braces in values");
2096 assert!(
2097 result[0].message.contains("'author' should come before 'title'"),
2098 "Got: {}",
2099 result[0].message
2100 );
2101 }
2102
2103 #[test]
2104 fn test_json_multiple_braces_in_string() {
2105 let rule = create_enabled_rule();
2107 let content = "{\n\"pattern\": \"{{{}}\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2109 let result = rule.check(&ctx).unwrap();
2110
2111 assert_eq!(result.len(), 1, "Should handle multiple braces in string values");
2113 assert!(
2114 result[0].message.contains("'author' should come before 'pattern'"),
2115 "Got: {}",
2116 result[0].message
2117 );
2118 }
2119
2120 #[test]
2121 fn test_key_order_detects_wrong_custom_order() {
2122 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2124 let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
2125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2126 let result = rule.check(&ctx).unwrap();
2127
2128 assert_eq!(result.len(), 1);
2129 assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
2130 }
2131}