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 fn default_config_section(&self) -> Option<(String, toml::Value)> {
445 let table = crate::rule_config_serde::config_schema_table(&MD072Config::default())?;
446 Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
447 }
448
449 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
450 where
451 Self: Sized,
452 {
453 let rule_config = crate::rule_config_serde::load_rule_config::<MD072Config>(config);
454 Box::new(Self::from_config_struct(rule_config))
455 }
456}
457
458impl MD072FrontmatterKeySort {
459 fn preserve_trailing_newline(original: &str, mut result: String) -> String {
464 if original.ends_with('\n') && !result.ends_with('\n') {
465 result.push('\n');
466 }
467 result
468 }
469
470 fn fix_yaml(&self, content: &str, fm_end: usize) -> String {
471 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
472 if frontmatter_lines.is_empty() {
473 return content.to_string();
474 }
475
476 if Self::has_comments(&frontmatter_lines) {
478 return content.to_string();
479 }
480
481 let keys = Self::extract_yaml_keys(&frontmatter_lines);
482 let key_order = self.config.key_order.as_deref();
483 if Self::are_indexed_keys_sorted(&keys, key_order) {
484 return content.to_string();
485 }
486
487 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
490
491 for (i, (line_idx, key)) in keys.iter().enumerate() {
492 let start = *line_idx;
493 let end = if i + 1 < keys.len() {
494 keys[i + 1].0
495 } else {
496 frontmatter_lines.len()
497 };
498
499 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
500 key_blocks.push((key.clone(), block_lines));
501 }
502
503 Self::sort_keys_by_order(&mut key_blocks, key_order);
505
506 let content_lines: Vec<&str> = content.lines().collect();
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, fm_end: usize) -> 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
566 let mut result = String::new();
567 result.push_str("+++\n");
568 for (_, lines) in &key_blocks {
569 for line in lines {
570 result.push_str(line);
571 result.push('\n');
572 }
573 }
574 result.push_str("+++");
575
576 if fm_end < content_lines.len() {
577 result.push('\n');
578 result.push_str(&content_lines[fm_end..].join("\n"));
579 }
580
581 Self::preserve_trailing_newline(content, result)
582 }
583
584 fn fix_json(&self, content: &str, fm_end: usize) -> String {
585 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
586 if frontmatter_lines.is_empty() {
587 return content.to_string();
588 }
589
590 let keys = Self::extract_json_keys(&frontmatter_lines);
591 let key_order = self.config.key_order.as_deref();
592
593 if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
594 return content.to_string();
595 }
596
597 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
599
600 match serde_json::from_str::<serde_json::Value>(&json_content) {
602 Ok(serde_json::Value::Object(map)) => {
603 let mut sorted_map = serde_json::Map::new();
605 let mut keys: Vec<_> = map.keys().cloned().collect();
606 keys.sort_by(|a, b| {
607 let pos_a = Self::key_sort_position(a, key_order);
608 let pos_b = Self::key_sort_position(b, key_order);
609 pos_a.cmp(&pos_b)
610 });
611
612 for key in keys {
613 if let Some(value) = map.get(&key) {
614 sorted_map.insert(key, value.clone());
615 }
616 }
617
618 match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
619 Ok(sorted_json) => {
620 let lines: Vec<&str> = content.lines().collect();
621
622 let mut result = String::new();
625 result.push_str(&sorted_json);
626
627 if fm_end < lines.len() {
628 result.push('\n');
629 result.push_str(&lines[fm_end..].join("\n"));
630 }
631
632 Self::preserve_trailing_newline(content, result)
633 }
634 Err(_) => content.to_string(),
635 }
636 }
637 _ => content.to_string(),
638 }
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use crate::lint_context::LintContext;
646
647 fn create_enabled_rule() -> MD072FrontmatterKeySort {
649 MD072FrontmatterKeySort::from_config_struct(MD072Config {
650 enabled: true,
651 key_order: None,
652 })
653 }
654
655 fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
657 MD072FrontmatterKeySort::from_config_struct(MD072Config {
658 enabled: true,
659 key_order: Some(keys.into_iter().map(String::from).collect()),
660 })
661 }
662
663 #[test]
666 fn test_enabled_via_config() {
667 let rule = create_enabled_rule();
668 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670 let result = rule.check(&ctx).unwrap();
671
672 assert_eq!(result.len(), 1);
674 }
675
676 #[test]
679 fn test_no_frontmatter() {
680 let rule = create_enabled_rule();
681 let content = "# Heading\n\nContent.";
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683 let result = rule.check(&ctx).unwrap();
684
685 assert!(result.is_empty());
686 }
687
688 #[test]
689 fn test_yaml_sorted_keys() {
690 let rule = create_enabled_rule();
691 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693 let result = rule.check(&ctx).unwrap();
694
695 assert!(result.is_empty());
696 }
697
698 #[test]
699 fn test_yaml_unsorted_keys() {
700 let rule = create_enabled_rule();
701 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703 let result = rule.check(&ctx).unwrap();
704
705 assert_eq!(result.len(), 1);
706 assert!(result[0].message.contains("YAML"));
707 assert!(result[0].message.contains("not sorted"));
708 assert!(result[0].message.contains("'author' should come before 'title'"));
710 }
711
712 #[test]
713 fn test_yaml_case_insensitive_sort() {
714 let rule = create_enabled_rule();
715 let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717 let result = rule.check(&ctx).unwrap();
718
719 assert!(result.is_empty());
721 }
722
723 #[test]
724 fn test_yaml_fix_sorts_keys() {
725 let rule = create_enabled_rule();
726 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728 let fixed = rule.fix(&ctx).unwrap();
729
730 let author_pos = fixed.find("author:").unwrap();
732 let title_pos = fixed.find("title:").unwrap();
733 assert!(author_pos < title_pos);
734 }
735
736 #[test]
737 fn test_yaml_no_fix_with_comments() {
738 let rule = create_enabled_rule();
739 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741 let result = rule.check(&ctx).unwrap();
742
743 assert_eq!(result.len(), 1);
744 assert!(result[0].message.contains("auto-fix unavailable"));
745 assert!(result[0].fix.is_none());
746
747 let fixed = rule.fix(&ctx).unwrap();
749 assert_eq!(fixed, content);
750 }
751
752 #[test]
753 fn test_yaml_single_key() {
754 let rule = create_enabled_rule();
755 let content = "---\ntitle: Test\n---\n\n# Heading";
756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let result = rule.check(&ctx).unwrap();
758
759 assert!(result.is_empty());
761 }
762
763 #[test]
764 fn test_yaml_nested_keys_ignored() {
765 let rule = create_enabled_rule();
766 let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let result = rule.check(&ctx).unwrap();
770
771 assert!(result.is_empty());
773 }
774
775 #[test]
776 fn test_yaml_fix_idempotent() {
777 let rule = create_enabled_rule();
778 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780 let fixed_once = rule.fix(&ctx).unwrap();
781
782 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
783 let fixed_twice = rule.fix(&ctx2).unwrap();
784
785 assert_eq!(fixed_once, fixed_twice);
786 }
787
788 #[test]
789 fn test_yaml_fix_preserves_trailing_newline() {
790 let rule = create_enabled_rule();
791 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n";
793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794 let fixed = rule.fix(&ctx).unwrap();
795 assert!(
796 fixed.ends_with('\n'),
797 "trailing newline must be preserved, got {fixed:?}"
798 );
799
800 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
802 let fixed_twice = rule.fix(&ctx2).unwrap();
803 assert_eq!(fixed, fixed_twice);
804 }
805
806 #[test]
807 fn test_yaml_fix_whole_file_frontmatter_preserves_trailing_newline() {
808 let rule = create_enabled_rule();
809 let content = "---\ntitle: Test\nauthor: John\n---\n";
811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812 let fixed = rule.fix(&ctx).unwrap();
813 assert!(
814 fixed.ends_with('\n'),
815 "trailing newline must be preserved, got {fixed:?}"
816 );
817 }
818
819 #[test]
820 fn test_yaml_quoted_keys_sort_by_content() {
821 let rule = create_enabled_rule();
822 let content = "---\n\"zebra\": 1\napple: 2\n---\n\n# Heading";
825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826 let result = rule.check(&ctx).unwrap();
827
828 assert_eq!(result.len(), 1, "quoted key out of order must be flagged");
829 assert!(result[0].message.contains("'apple' should come before 'zebra'"));
830 }
831
832 #[test]
833 fn test_yaml_quoted_key_warning_span_covers_quotes() {
834 let rule = create_enabled_rule();
835 let content = "---\nbanana: 1\n\"apple\": 2\n---\n";
839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
840 let result = rule.check(&ctx).unwrap();
841
842 assert_eq!(result.len(), 1);
843 let w = &result[0];
844 assert_eq!(w.line, 3);
845 assert_eq!(w.column, 1);
846 assert_eq!(w.end_column, 8, "diagnostic span must cover the quoted key");
848 }
849
850 #[test]
851 fn test_yaml_complex_values() {
852 let rule = create_enabled_rule();
853 let content =
855 "---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857 let result = rule.check(&ctx).unwrap();
858
859 assert!(result.is_empty());
861 }
862
863 #[test]
866 fn test_toml_sorted_keys() {
867 let rule = create_enabled_rule();
868 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
870 let result = rule.check(&ctx).unwrap();
871
872 assert!(result.is_empty());
873 }
874
875 #[test]
876 fn test_toml_unsorted_keys() {
877 let rule = create_enabled_rule();
878 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880 let result = rule.check(&ctx).unwrap();
881
882 assert_eq!(result.len(), 1);
883 assert!(result[0].message.contains("TOML"));
884 assert!(result[0].message.contains("not sorted"));
885 }
886
887 #[test]
888 fn test_toml_fix_sorts_keys() {
889 let rule = create_enabled_rule();
890 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
892 let fixed = rule.fix(&ctx).unwrap();
893
894 let author_pos = fixed.find("author").unwrap();
896 let title_pos = fixed.find("title").unwrap();
897 assert!(author_pos < title_pos);
898 }
899
900 #[test]
901 fn test_toml_no_fix_with_comments() {
902 let rule = create_enabled_rule();
903 let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905 let result = rule.check(&ctx).unwrap();
906
907 assert_eq!(result.len(), 1);
908 assert!(result[0].message.contains("auto-fix unavailable"));
909
910 let fixed = rule.fix(&ctx).unwrap();
912 assert_eq!(fixed, content);
913 }
914
915 #[test]
918 fn test_json_sorted_keys() {
919 let rule = create_enabled_rule();
920 let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
922 let result = rule.check(&ctx).unwrap();
923
924 assert!(result.is_empty());
925 }
926
927 #[test]
928 fn test_json_unsorted_keys() {
929 let rule = create_enabled_rule();
930 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
932 let result = rule.check(&ctx).unwrap();
933
934 assert_eq!(result.len(), 1);
935 assert!(result[0].message.contains("JSON"));
936 assert!(result[0].message.contains("not sorted"));
937 }
938
939 #[test]
940 fn test_json_fix_sorts_keys() {
941 let rule = create_enabled_rule();
942 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
944 let fixed = rule.fix(&ctx).unwrap();
945
946 let author_pos = fixed.find("author").unwrap();
948 let title_pos = fixed.find("title").unwrap();
949 assert!(author_pos < title_pos);
950 }
951
952 #[test]
953 fn test_json_always_fixable() {
954 let rule = create_enabled_rule();
955 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
958 let result = rule.check(&ctx).unwrap();
959
960 assert_eq!(result.len(), 1);
961 assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
963 }
964
965 #[test]
968 fn test_empty_content() {
969 let rule = create_enabled_rule();
970 let content = "";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972 let result = rule.check(&ctx).unwrap();
973
974 assert!(result.is_empty());
975 }
976
977 #[test]
978 fn test_empty_frontmatter() {
979 let rule = create_enabled_rule();
980 let content = "---\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!(result.is_empty());
985 }
986
987 #[test]
988 fn test_toml_nested_tables_ignored() {
989 let rule = create_enabled_rule();
991 let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993 let result = rule.check(&ctx).unwrap();
994
995 assert_eq!(result.len(), 1);
997 assert!(result[0].message.contains("'sort_by' should come before 'title'"));
999 assert!(!result[0].message.contains("we_have_extra"));
1000 }
1001
1002 #[test]
1003 fn test_toml_nested_taxonomies_ignored() {
1004 let rule = create_enabled_rule();
1006 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
1007 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1008 let result = rule.check(&ctx).unwrap();
1009
1010 assert_eq!(result.len(), 1);
1012 assert!(result[0].message.contains("'date' should come before 'title'"));
1014 assert!(!result[0].message.contains("categories"));
1015 assert!(!result[0].message.contains("tags"));
1016 }
1017
1018 #[test]
1021 fn test_yaml_unicode_keys() {
1022 let rule = create_enabled_rule();
1023 let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
1025 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026 let result = rule.check(&ctx).unwrap();
1027
1028 assert_eq!(result.len(), 1);
1030 }
1031
1032 #[test]
1033 fn test_yaml_keys_with_special_characters() {
1034 let rule = create_enabled_rule();
1035 let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\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_keys_with_numbers() {
1046 let rule = create_enabled_rule();
1047 let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
1048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049 let result = rule.check(&ctx).unwrap();
1050
1051 assert!(result.is_empty());
1053 }
1054
1055 #[test]
1056 fn test_yaml_multiline_string_block_literal() {
1057 let rule = create_enabled_rule();
1058 let content =
1059 "---\ndescription: |\n This is a\n multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
1060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1061 let result = rule.check(&ctx).unwrap();
1062
1063 assert_eq!(result.len(), 1);
1065 assert!(result[0].message.contains("'author' should come before 'title'"));
1066 }
1067
1068 #[test]
1069 fn test_yaml_multiline_string_folded() {
1070 let rule = create_enabled_rule();
1071 let content = "---\ndescription: >\n This is a\n folded string\nauthor: John\n---\n\n# Heading";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073 let result = rule.check(&ctx).unwrap();
1074
1075 assert_eq!(result.len(), 1);
1077 }
1078
1079 #[test]
1080 fn test_yaml_fix_preserves_multiline_values() {
1081 let rule = create_enabled_rule();
1082 let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
1083 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1084 let fixed = rule.fix(&ctx).unwrap();
1085
1086 let desc_pos = fixed.find("description").unwrap();
1088 let title_pos = fixed.find("title").unwrap();
1089 assert!(desc_pos < title_pos);
1090 }
1091
1092 #[test]
1093 fn test_yaml_quoted_keys() {
1094 let rule = create_enabled_rule();
1095 let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
1096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1097 let result = rule.check(&ctx).unwrap();
1098
1099 assert!(result.is_empty());
1101 }
1102
1103 #[test]
1104 fn test_yaml_duplicate_keys() {
1105 let rule = create_enabled_rule();
1107 let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
1108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1109 let result = rule.check(&ctx).unwrap();
1110
1111 assert_eq!(result.len(), 1);
1113 }
1114
1115 #[test]
1116 fn test_toml_inline_table() {
1117 let rule = create_enabled_rule();
1118 let content =
1119 "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\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!(result.is_empty());
1125 }
1126
1127 #[test]
1128 fn test_toml_array_of_tables() {
1129 let rule = create_enabled_rule();
1130 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
1131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1132 let result = rule.check(&ctx).unwrap();
1133
1134 assert_eq!(result.len(), 1);
1136 assert!(result[0].message.contains("'date' should come before 'title'"));
1138 }
1139
1140 #[test]
1141 fn test_json_nested_objects() {
1142 let rule = create_enabled_rule();
1143 let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\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!(result.is_empty());
1149 }
1150
1151 #[test]
1152 fn test_json_arrays() {
1153 let rule = create_enabled_rule();
1154 let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1155 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1156 let result = rule.check(&ctx).unwrap();
1157
1158 assert_eq!(result.len(), 1);
1160 }
1161
1162 #[test]
1163 fn test_fix_preserves_content_after_frontmatter() {
1164 let rule = create_enabled_rule();
1165 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167 let fixed = rule.fix(&ctx).unwrap();
1168
1169 assert!(fixed.contains("# Heading"));
1171 assert!(fixed.contains("Paragraph 1."));
1172 assert!(fixed.contains("- List item"));
1173 assert!(fixed.contains("- Another item"));
1174 }
1175
1176 #[test]
1177 fn test_fix_yaml_produces_valid_yaml() {
1178 let rule = create_enabled_rule();
1179 let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181 let fixed = rule.fix(&ctx).unwrap();
1182
1183 let lines: Vec<&str> = fixed.lines().collect();
1186 let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1187 let fm_content: String = lines[1..fm_end].join("\n");
1188
1189 let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&fm_content);
1191 assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1192 }
1193
1194 #[test]
1195 fn test_fix_toml_produces_valid_toml() {
1196 let rule = create_enabled_rule();
1197 let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1199 let fixed = rule.fix(&ctx).unwrap();
1200
1201 let lines: Vec<&str> = fixed.lines().collect();
1203 let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1204 let fm_content: String = lines[1..fm_end].join("\n");
1205
1206 let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1208 assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1209 }
1210
1211 #[test]
1212 fn test_fix_json_produces_valid_json() {
1213 let rule = create_enabled_rule();
1214 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1216 let fixed = rule.fix(&ctx).unwrap();
1217
1218 let json_end = fixed.find("\n\n").unwrap();
1220 let json_content = &fixed[..json_end];
1221
1222 let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1224 assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1225 }
1226
1227 #[test]
1228 fn test_many_keys_performance() {
1229 let rule = create_enabled_rule();
1230 let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1232 keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1234
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_empty_value() {
1244 let rule = create_enabled_rule();
1245 let content = "---\ntitle:\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);
1251 }
1252
1253 #[test]
1254 fn test_yaml_null_value() {
1255 let rule = create_enabled_rule();
1256 let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let result = rule.check(&ctx).unwrap();
1259
1260 assert_eq!(result.len(), 1);
1261 }
1262
1263 #[test]
1264 fn test_yaml_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);
1272 }
1273
1274 #[test]
1275 fn test_toml_boolean_values() {
1276 let rule = create_enabled_rule();
1277 let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1279 let result = rule.check(&ctx).unwrap();
1280
1281 assert_eq!(result.len(), 1);
1282 }
1283
1284 #[test]
1285 fn test_yaml_list_at_top_level() {
1286 let rule = create_enabled_rule();
1287 let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
1288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1289 let result = rule.check(&ctx).unwrap();
1290
1291 assert_eq!(result.len(), 1);
1293 }
1294
1295 #[test]
1296 fn test_three_keys_all_orderings() {
1297 let rule = create_enabled_rule();
1298
1299 let orderings = [
1301 ("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), ];
1308
1309 for (name, content, should_pass) in orderings {
1310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1311 let result = rule.check(&ctx).unwrap();
1312 assert_eq!(
1313 result.is_empty(),
1314 should_pass,
1315 "Ordering {name} should {} pass",
1316 if should_pass { "" } else { "not" }
1317 );
1318 }
1319 }
1320
1321 #[test]
1322 fn test_crlf_line_endings() {
1323 let rule = create_enabled_rule();
1324 let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1325 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1326 let result = rule.check(&ctx).unwrap();
1327
1328 assert_eq!(result.len(), 1);
1330 }
1331
1332 #[test]
1333 fn test_json_escaped_quotes_in_keys() {
1334 let rule = create_enabled_rule();
1335 let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1338 let result = rule.check(&ctx).unwrap();
1339
1340 assert_eq!(result.len(), 1);
1342 }
1343
1344 #[test]
1347 fn test_warning_fix_yaml_sorts_keys() {
1348 let rule = create_enabled_rule();
1349 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351 let warnings = rule.check(&ctx).unwrap();
1352
1353 assert_eq!(warnings.len(), 1);
1354 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1355
1356 let fix = warnings[0].fix.as_ref().unwrap();
1357 assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1358
1359 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1361
1362 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1364 let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1365 assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1366 }
1367
1368 #[test]
1369 fn test_warning_fix_preserves_yaml_list_indentation() {
1370 let rule = create_enabled_rule();
1371 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1373 let warnings = rule.check(&ctx).unwrap();
1374
1375 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1376
1377 assert!(
1379 fixed.contains(" - hello"),
1380 "List indentation should be preserved: {fixed}"
1381 );
1382 assert!(
1383 fixed.contains(" - world"),
1384 "List indentation should be preserved: {fixed}"
1385 );
1386 }
1387
1388 #[test]
1389 fn test_warning_fix_preserves_nested_object_indentation() {
1390 let rule = create_enabled_rule();
1391 let content = "---\nzzzz: value\naaaa:\n nested_key: nested_value\n another: 123\n---\n\n# Heading\n";
1392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1393 let warnings = rule.check(&ctx).unwrap();
1394
1395 assert_eq!(warnings.len(), 1);
1396 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1397
1398 let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1400 let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1401 assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1402
1403 assert!(
1405 fixed.contains(" nested_key: nested_value"),
1406 "Nested object indentation should be preserved: {fixed}"
1407 );
1408 assert!(
1409 fixed.contains(" another: 123"),
1410 "Nested object indentation should be preserved: {fixed}"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_warning_fix_preserves_deeply_nested_structure() {
1416 let rule = create_enabled_rule();
1417 let content = "---\nzzz: top\naaa:\n level1:\n level2:\n - item1\n - item2\n---\n\n# Content\n";
1418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1419 let warnings = rule.check(&ctx).unwrap();
1420
1421 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1422
1423 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1425 let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1426 assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1427
1428 assert!(fixed.contains(" level1:"), "2-space indent should be preserved");
1430 assert!(fixed.contains(" level2:"), "4-space indent should be preserved");
1431 assert!(fixed.contains(" - item1"), "6-space indent should be preserved");
1432 assert!(fixed.contains(" - item2"), "6-space indent should be preserved");
1433 }
1434
1435 #[test]
1436 fn test_warning_fix_toml_sorts_keys() {
1437 let rule = create_enabled_rule();
1438 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1440 let warnings = rule.check(&ctx).unwrap();
1441
1442 assert_eq!(warnings.len(), 1);
1443 assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1444
1445 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1446
1447 let author_pos = fixed.find("author").expect("author should exist");
1449 let title_pos = fixed.find("title").expect("title should exist");
1450 assert!(author_pos < title_pos, "author should come before title");
1451 }
1452
1453 #[test]
1454 fn test_warning_fix_json_sorts_keys() {
1455 let rule = create_enabled_rule();
1456 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1458 let warnings = rule.check(&ctx).unwrap();
1459
1460 assert_eq!(warnings.len(), 1);
1461 assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1462
1463 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1464
1465 let author_pos = fixed.find("author").expect("author should exist");
1467 let title_pos = fixed.find("title").expect("title should exist");
1468 assert!(author_pos < title_pos, "author should come before title");
1469 }
1470
1471 #[test]
1472 fn test_warning_fix_no_fix_when_comments_present() {
1473 let rule = create_enabled_rule();
1474 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476 let warnings = rule.check(&ctx).unwrap();
1477
1478 assert_eq!(warnings.len(), 1);
1479 assert!(
1480 warnings[0].fix.is_none(),
1481 "Warning should NOT have a fix when comments are present"
1482 );
1483 assert!(
1484 warnings[0].message.contains("auto-fix unavailable"),
1485 "Message should indicate auto-fix is unavailable"
1486 );
1487 }
1488
1489 #[test]
1490 fn test_warning_fix_preserves_content_after_frontmatter() {
1491 let rule = create_enabled_rule();
1492 let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1494 let warnings = rule.check(&ctx).unwrap();
1495
1496 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1497
1498 assert!(fixed.contains("# Heading"), "Heading should be preserved");
1500 assert!(
1501 fixed.contains("Paragraph with content."),
1502 "Paragraph should be preserved"
1503 );
1504 assert!(fixed.contains("- List item"), "List item should be preserved");
1505 }
1506
1507 #[test]
1508 fn test_warning_fix_idempotent() {
1509 let rule = create_enabled_rule();
1510 let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512 let warnings = rule.check(&ctx).unwrap();
1513
1514 let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1515
1516 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1518 let warnings2 = rule.check(&ctx2).unwrap();
1519
1520 assert!(
1521 warnings2.is_empty(),
1522 "After fixing, no more warnings should be produced"
1523 );
1524 }
1525
1526 #[test]
1527 fn test_warning_fix_preserves_multiline_block_literal() {
1528 let rule = create_enabled_rule();
1529 let content = "---\nzzz: simple\naaa: |\n Line 1 of block\n Line 2 of block\n---\n\n# Heading\n";
1530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531 let warnings = rule.check(&ctx).unwrap();
1532
1533 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1534
1535 assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1537 assert!(
1538 fixed.contains(" Line 1 of block"),
1539 "Block literal line 1 should be preserved with indent"
1540 );
1541 assert!(
1542 fixed.contains(" Line 2 of block"),
1543 "Block literal line 2 should be preserved with indent"
1544 );
1545 }
1546
1547 #[test]
1548 fn test_warning_fix_preserves_folded_string() {
1549 let rule = create_enabled_rule();
1550 let content = "---\nzzz: simple\naaa: >\n Folded line 1\n Folded line 2\n---\n\n# Content\n";
1551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552 let warnings = rule.check(&ctx).unwrap();
1553
1554 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1555
1556 assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1558 assert!(
1559 fixed.contains(" Folded line 1"),
1560 "Folded line 1 should be preserved with indent"
1561 );
1562 assert!(
1563 fixed.contains(" Folded line 2"),
1564 "Folded line 2 should be preserved with indent"
1565 );
1566 }
1567
1568 #[test]
1569 fn test_warning_fix_preserves_4_space_indentation() {
1570 let rule = create_enabled_rule();
1571 let content = "---\nzzz: value\naaa:\n nested: with_4_spaces\n another: value\n---\n\n# Heading\n";
1573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1574 let warnings = rule.check(&ctx).unwrap();
1575
1576 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1577
1578 assert!(
1580 fixed.contains(" nested: with_4_spaces"),
1581 "4-space indentation should be preserved: {fixed}"
1582 );
1583 assert!(
1584 fixed.contains(" another: value"),
1585 "4-space indentation should be preserved: {fixed}"
1586 );
1587 }
1588
1589 #[test]
1590 fn test_warning_fix_preserves_tab_indentation() {
1591 let rule = create_enabled_rule();
1592 let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1595 let warnings = rule.check(&ctx).unwrap();
1596
1597 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1598
1599 assert!(
1601 fixed.contains("\tnested: with_tab"),
1602 "Tab indentation should be preserved: {fixed}"
1603 );
1604 assert!(
1605 fixed.contains("\tanother: value"),
1606 "Tab indentation should be preserved: {fixed}"
1607 );
1608 }
1609
1610 #[test]
1611 fn test_warning_fix_preserves_inline_list() {
1612 let rule = create_enabled_rule();
1613 let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1616 let warnings = rule.check(&ctx).unwrap();
1617
1618 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1619
1620 assert!(
1622 fixed.contains("aaa: [one, two, three]"),
1623 "Inline list should be preserved exactly: {fixed}"
1624 );
1625 }
1626
1627 #[test]
1628 fn test_warning_fix_preserves_quoted_strings() {
1629 let rule = create_enabled_rule();
1630 let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1633 let warnings = rule.check(&ctx).unwrap();
1634
1635 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1636
1637 assert!(
1639 fixed.contains("aaa: \"value with: colon\""),
1640 "Double-quoted string should be preserved: {fixed}"
1641 );
1642 assert!(
1643 fixed.contains("bbb: 'single quotes'"),
1644 "Single-quoted string should be preserved: {fixed}"
1645 );
1646 }
1647
1648 #[test]
1651 fn test_yaml_custom_key_order_sorted() {
1652 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1654 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1656 let result = rule.check(&ctx).unwrap();
1657
1658 assert!(result.is_empty());
1660 }
1661
1662 #[test]
1663 fn test_yaml_custom_key_order_unsorted() {
1664 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1666 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1668 let result = rule.check(&ctx).unwrap();
1669
1670 assert_eq!(result.len(), 1);
1671 assert!(result[0].message.contains("'date' should come before 'author'"));
1673 }
1674
1675 #[test]
1676 fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1677 let rule = create_rule_with_key_order(vec!["title"]);
1679 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1681 let result = rule.check(&ctx).unwrap();
1682
1683 assert!(result.is_empty());
1686 }
1687
1688 #[test]
1689 fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1690 let rule = create_rule_with_key_order(vec!["title"]);
1692 let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1694 let result = rule.check(&ctx).unwrap();
1695
1696 assert_eq!(result.len(), 1);
1698 assert!(result[0].message.contains("'author' should come before 'zebra'"));
1699 }
1700
1701 #[test]
1702 fn test_yaml_custom_key_order_fix() {
1703 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1704 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1706 let fixed = rule.fix(&ctx).unwrap();
1707
1708 let title_pos = fixed.find("title:").unwrap();
1710 let date_pos = fixed.find("date:").unwrap();
1711 let author_pos = fixed.find("author:").unwrap();
1712 assert!(
1713 title_pos < date_pos && date_pos < author_pos,
1714 "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1715 );
1716 }
1717
1718 #[test]
1719 fn test_yaml_custom_key_order_fix_with_unlisted() {
1720 let rule = create_rule_with_key_order(vec!["title", "author"]);
1722 let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
1723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1724 let fixed = rule.fix(&ctx).unwrap();
1725
1726 let title_pos = fixed.find("title:").unwrap();
1728 let author_pos = fixed.find("author:").unwrap();
1729 let aardvark_pos = fixed.find("aardvark:").unwrap();
1730 let zebra_pos = fixed.find("zebra:").unwrap();
1731
1732 assert!(
1733 title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1734 "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1735 );
1736 }
1737
1738 #[test]
1739 fn test_toml_custom_key_order_sorted() {
1740 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1741 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
1742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1743 let result = rule.check(&ctx).unwrap();
1744
1745 assert!(result.is_empty());
1746 }
1747
1748 #[test]
1749 fn test_toml_custom_key_order_unsorted() {
1750 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1751 let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\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_eq!(result.len(), 1);
1756 assert!(result[0].message.contains("TOML"));
1757 }
1758
1759 #[test]
1760 fn test_json_custom_key_order_sorted() {
1761 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1762 let content = "{\n \"title\": \"Test\",\n \"date\": \"2024-01-01\",\n \"author\": \"John\"\n}\n\n# Heading";
1763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1764 let result = rule.check(&ctx).unwrap();
1765
1766 assert!(result.is_empty());
1767 }
1768
1769 #[test]
1770 fn test_json_custom_key_order_unsorted() {
1771 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1772 let content = "{\n \"author\": \"John\",\n \"title\": \"Test\",\n \"date\": \"2024-01-01\"\n}\n\n# Heading";
1773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1774 let result = rule.check(&ctx).unwrap();
1775
1776 assert_eq!(result.len(), 1);
1777 assert!(result[0].message.contains("JSON"));
1778 }
1779
1780 #[test]
1781 fn test_key_order_case_insensitive_match() {
1782 let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
1784 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1786 let result = rule.check(&ctx).unwrap();
1787
1788 assert!(result.is_empty());
1790 }
1791
1792 #[test]
1793 fn test_key_order_partial_match() {
1794 let rule = create_rule_with_key_order(vec!["title"]);
1796 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1798 let result = rule.check(&ctx).unwrap();
1799
1800 assert_eq!(result.len(), 1);
1811 assert!(result[0].message.contains("'author' should come before 'date'"));
1812 }
1813
1814 #[test]
1817 fn test_key_order_empty_array_falls_back_to_alphabetical() {
1818 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1820 enabled: true,
1821 key_order: Some(vec![]),
1822 });
1823 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1825 let result = rule.check(&ctx).unwrap();
1826
1827 assert_eq!(result.len(), 1);
1830 assert!(result[0].message.contains("'author' should come before 'title'"));
1831 }
1832
1833 #[test]
1834 fn test_key_order_single_key() {
1835 let rule = create_rule_with_key_order(vec!["title"]);
1837 let content = "---\ntitle: Test\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_all_keys_specified() {
1846 let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
1848 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\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());
1853 }
1854
1855 #[test]
1856 fn test_key_order_no_keys_match() {
1857 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1859 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1861 let result = rule.check(&ctx).unwrap();
1862
1863 assert!(result.is_empty());
1866 }
1867
1868 #[test]
1869 fn test_key_order_no_keys_match_unsorted() {
1870 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1872 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1874 let result = rule.check(&ctx).unwrap();
1875
1876 assert_eq!(result.len(), 1);
1879 }
1880
1881 #[test]
1882 fn test_key_order_duplicate_keys_in_config() {
1883 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1885 enabled: true,
1886 key_order: Some(vec![
1887 "title".to_string(),
1888 "author".to_string(),
1889 "title".to_string(), ]),
1891 });
1892 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1894 let result = rule.check(&ctx).unwrap();
1895
1896 assert!(result.is_empty());
1898 }
1899
1900 #[test]
1901 fn test_key_order_with_comments_still_skips_fix() {
1902 let rule = create_rule_with_key_order(vec!["title", "author"]);
1904 let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
1905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1906 let result = rule.check(&ctx).unwrap();
1907
1908 assert_eq!(result.len(), 1);
1910 assert!(result[0].message.contains("auto-fix unavailable"));
1911 assert!(result[0].fix.is_none());
1912 }
1913
1914 #[test]
1915 fn test_toml_custom_key_order_fix() {
1916 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1917 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
1918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1919 let fixed = rule.fix(&ctx).unwrap();
1920
1921 let title_pos = fixed.find("title").unwrap();
1923 let date_pos = fixed.find("date").unwrap();
1924 let author_pos = fixed.find("author").unwrap();
1925 assert!(
1926 title_pos < date_pos && date_pos < author_pos,
1927 "Fixed TOML should have keys in custom order. Got:\n{fixed}"
1928 );
1929 }
1930
1931 #[test]
1932 fn test_json_custom_key_order_fix() {
1933 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1934 let content = "{\n \"author\": \"John\",\n \"date\": \"2024-01-01\",\n \"title\": \"Test\"\n}\n\n# Heading";
1935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936 let fixed = rule.fix(&ctx).unwrap();
1937
1938 let title_pos = fixed.find("\"title\"").unwrap();
1940 let date_pos = fixed.find("\"date\"").unwrap();
1941 let author_pos = fixed.find("\"author\"").unwrap();
1942 assert!(
1943 title_pos < date_pos && date_pos < author_pos,
1944 "Fixed JSON should have keys in custom order. Got:\n{fixed}"
1945 );
1946 }
1947
1948 #[test]
1949 fn test_key_order_unicode_keys() {
1950 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1952 enabled: true,
1953 key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
1954 });
1955 let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
1956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1957 let result = rule.check(&ctx).unwrap();
1958
1959 assert!(result.is_empty());
1961 }
1962
1963 #[test]
1964 fn test_key_order_mixed_specified_and_unlisted_boundary() {
1965 let rule = create_rule_with_key_order(vec!["z_last_specified"]);
1967 let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
1968 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1969 let result = rule.check(&ctx).unwrap();
1970
1971 assert!(result.is_empty());
1974 }
1975
1976 #[test]
1977 fn test_key_order_fix_preserves_values() {
1978 let rule = create_rule_with_key_order(vec!["title", "tags"]);
1980 let content = "---\ntags:\n - rust\n - markdown\ntitle: Test\n---\n\n# Heading";
1981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1982 let fixed = rule.fix(&ctx).unwrap();
1983
1984 let title_pos = fixed.find("title:").unwrap();
1986 let tags_pos = fixed.find("tags:").unwrap();
1987 assert!(title_pos < tags_pos, "title should come before tags");
1988
1989 assert!(fixed.contains("- rust"), "List items should be preserved");
1991 assert!(fixed.contains("- markdown"), "List items should be preserved");
1992 }
1993
1994 #[test]
1995 fn test_key_order_idempotent_fix() {
1996 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1998 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2000
2001 let fixed_once = rule.fix(&ctx).unwrap();
2002 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2003 let fixed_twice = rule.fix(&ctx2).unwrap();
2004
2005 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2006 }
2007
2008 #[test]
2009 fn test_key_order_respects_later_position_over_alphabetical() {
2010 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2012 let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
2013 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014 let result = rule.check(&ctx).unwrap();
2015
2016 assert!(result.is_empty());
2018 }
2019
2020 #[test]
2023 fn test_json_braces_in_string_values_extracts_all_keys() {
2024 let rule = create_enabled_rule();
2028 let content = "{\n\"author\": \"Someone\",\n\"description\": \"Use { to open\",\n\"tags\": [\"a\"],\n\"title\": \"My Post\"\n}\n\nContent here.\n";
2029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2030 let result = rule.check(&ctx).unwrap();
2031
2032 assert!(
2034 result.is_empty(),
2035 "All keys should be extracted and recognized as sorted. Got: {result:?}"
2036 );
2037 }
2038
2039 #[test]
2040 fn test_json_braces_in_string_key_after_brace_value_detected() {
2041 let rule = create_enabled_rule();
2043 let content = "{\n\"description\": \"Use { to open\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2047 let result = rule.check(&ctx).unwrap();
2048
2049 assert_eq!(
2052 result.len(),
2053 1,
2054 "Should detect unsorted keys after brace-containing string value"
2055 );
2056 assert!(
2057 result[0].message.contains("'author' should come before 'description'"),
2058 "Should report author before description. Got: {}",
2059 result[0].message
2060 );
2061 }
2062
2063 #[test]
2064 fn test_json_brackets_in_string_values() {
2065 let rule = create_enabled_rule();
2067 let content = "{\n\"description\": \"My [Post]\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2068 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2069 let result = rule.check(&ctx).unwrap();
2070
2071 assert_eq!(
2073 result.len(),
2074 1,
2075 "Should detect unsorted keys despite brackets in string values"
2076 );
2077 assert!(
2078 result[0].message.contains("'author' should come before 'description'"),
2079 "Got: {}",
2080 result[0].message
2081 );
2082 }
2083
2084 #[test]
2085 fn test_json_escaped_quotes_in_values() {
2086 let rule = create_enabled_rule();
2088 let content = "{\n\"title\": \"He said \\\"hello {world}\\\"\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2090 let result = rule.check(&ctx).unwrap();
2091
2092 assert_eq!(result.len(), 1, "Should handle escaped quotes with braces in values");
2094 assert!(
2095 result[0].message.contains("'author' should come before 'title'"),
2096 "Got: {}",
2097 result[0].message
2098 );
2099 }
2100
2101 #[test]
2102 fn test_json_multiple_braces_in_string() {
2103 let rule = create_enabled_rule();
2105 let content = "{\n\"pattern\": \"{{{}}\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2106 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2107 let result = rule.check(&ctx).unwrap();
2108
2109 assert_eq!(result.len(), 1, "Should handle multiple braces in string values");
2111 assert!(
2112 result[0].message.contains("'author' should come before 'pattern'"),
2113 "Got: {}",
2114 result[0].message
2115 );
2116 }
2117
2118 #[test]
2119 fn test_key_order_detects_wrong_custom_order() {
2120 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2122 let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
2123 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2124 let result = rule.check(&ctx).unwrap();
2125
2126 assert_eq!(result.len(), 1);
2127 assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
2128 }
2129}