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 #[serde(default, alias = "required-keys")]
41 pub required_keys: Vec<String>,
42}
43
44impl RuleConfig for MD072Config {
45 const RULE_NAME: &'static str = "MD072";
46}
47
48#[derive(Clone, Default)]
61pub struct MD072FrontmatterKeySort {
62 config: MD072Config,
63}
64
65impl MD072FrontmatterKeySort {
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 pub fn from_config_struct(config: MD072Config) -> Self {
72 Self { config }
73 }
74
75 fn has_comments(frontmatter_lines: &[&str]) -> bool {
77 frontmatter_lines.iter().any(|line| line.trim_start().starts_with('#'))
78 }
79
80 fn extract_yaml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
82 let mut keys = Vec::new();
83
84 for (idx, line) in frontmatter_lines.iter().enumerate() {
85 if !line.starts_with(' ')
89 && !line.starts_with('\t')
90 && let Some(colon_pos) = Self::separator_pos_outside_quoted_key(line, ':')
91 {
92 let raw = line[..colon_pos].trim();
93 if !raw.is_empty() && !raw.starts_with('#') {
94 let key = raw
99 .strip_prefix('"')
100 .and_then(|k| k.strip_suffix('"'))
101 .or_else(|| raw.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
102 .unwrap_or(raw);
103 keys.push((idx, key.to_string()));
104 }
105 }
106 }
107
108 keys
109 }
110
111 fn extract_toml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
113 let mut keys = Vec::new();
114
115 for (idx, line) in frontmatter_lines.iter().enumerate() {
116 let trimmed = line.trim();
117 if trimmed.is_empty() || trimmed.starts_with('#') {
119 continue;
120 }
121 if trimmed.starts_with('[') {
123 break;
124 }
125 if !line.starts_with(' ')
129 && !line.starts_with('\t')
130 && let Some(eq_pos) = Self::separator_pos_outside_quoted_key(line, '=')
131 {
132 let raw = line[..eq_pos].trim();
133 if !raw.is_empty() {
134 let key = raw
139 .strip_prefix('"')
140 .and_then(|k| k.strip_suffix('"'))
141 .or_else(|| raw.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
142 .unwrap_or(raw);
143 keys.push((idx, key.to_string()));
144 }
145 }
146 }
147
148 keys
149 }
150
151 fn extract_json_keys(frontmatter_lines: &[&str]) -> Vec<String> {
153 let mut keys = Vec::new();
158 let mut depth: usize = 0;
159
160 for line in frontmatter_lines {
161 let line_start_depth = depth;
163
164 let mut in_string = false;
166 let mut prev_backslash = false;
167 for ch in line.chars() {
168 if in_string {
169 if ch == '"' && !prev_backslash {
170 in_string = false;
171 }
172 prev_backslash = ch == '\\' && !prev_backslash;
173 } else {
174 match ch {
175 '"' => in_string = true,
176 '{' | '[' => depth += 1,
177 '}' | ']' => depth = depth.saturating_sub(1),
178 _ => {}
179 }
180 prev_backslash = false;
181 }
182 }
183
184 if line_start_depth == 0
186 && let Some(captures) = JSON_KEY_PATTERN.captures(line)
187 && let Some(key_match) = captures.get(1)
188 {
189 keys.push(key_match.as_str().to_string());
190 }
191 }
192
193 keys
194 }
195
196 fn key_sort_position(key: &str, key_order: Option<&[String]>) -> (usize, String) {
200 if let Some(order) = key_order {
201 let key_lower = key.to_lowercase();
203 for (idx, ordered_key) in order.iter().enumerate() {
204 if ordered_key.to_lowercase() == key_lower {
205 return (idx, key_lower);
206 }
207 }
208 (usize::MAX, key_lower)
210 } else {
211 (0, key.to_lowercase())
213 }
214 }
215
216 fn find_first_unsorted_pair<'a>(keys: &'a [String], key_order: Option<&[String]>) -> Option<(&'a str, &'a str)> {
219 for i in 1..keys.len() {
220 let pos_curr = Self::key_sort_position(&keys[i], key_order);
221 let pos_prev = Self::key_sort_position(&keys[i - 1], key_order);
222 if pos_curr < pos_prev {
223 return Some((&keys[i], &keys[i - 1]));
224 }
225 }
226 None
227 }
228
229 fn find_first_unsorted_indexed_pair<'a>(
232 keys: &'a [(usize, String)],
233 key_order: Option<&[String]>,
234 ) -> Option<(usize, &'a str, &'a str)> {
235 for i in 1..keys.len() {
236 let pos_curr = Self::key_sort_position(&keys[i].1, key_order);
237 let pos_prev = Self::key_sort_position(&keys[i - 1].1, key_order);
238 if pos_curr < pos_prev {
239 return Some((keys[i].0, &keys[i].1, &keys[i - 1].1));
240 }
241 }
242 None
243 }
244
245 fn are_keys_sorted(keys: &[String], key_order: Option<&[String]>) -> bool {
247 Self::find_first_unsorted_pair(keys, key_order).is_none()
248 }
249
250 fn are_indexed_keys_sorted(keys: &[(usize, String)], key_order: Option<&[String]>) -> bool {
252 Self::find_first_unsorted_indexed_pair(keys, key_order).is_none()
253 }
254
255 fn sort_keys_by_order(keys: &mut [(String, Vec<&str>)], key_order: Option<&[String]>) {
257 keys.sort_by(|a, b| {
258 let pos_a = Self::key_sort_position(&a.0, key_order);
259 let pos_b = Self::key_sort_position(&b.0, key_order);
260 pos_a.cmp(&pos_b)
261 });
262 }
263
264 fn separator_pos_outside_quoted_key(line: &str, separator: char) -> Option<usize> {
268 let after_quote = if let Some(rest) = line.strip_prefix('"') {
269 rest.find('"').map(|i| i + 2)
270 } else if let Some(rest) = line.strip_prefix('\'') {
271 rest.find('\'').map(|i| i + 2)
272 } else {
273 None
274 };
275 match after_quote {
276 Some(start) => line[start..].find(separator).map(|i| start + i),
277 None => line.find(separator),
278 }
279 }
280
281 fn toml_root_key(raw: &str) -> &str {
285 if let Some(rest) = raw.strip_prefix('"') {
286 if let Some(end) = rest.find('"') {
287 return &rest[..end];
288 }
289 } else if let Some(rest) = raw.strip_prefix('\'')
290 && let Some(end) = rest.find('\'')
291 {
292 return &rest[..end];
293 }
294 raw.split('.').next().unwrap_or(raw).trim()
295 }
296
297 fn extract_toml_presence_keys(frontmatter_lines: &[&str]) -> Vec<String> {
305 let mut keys = Vec::new();
306 let mut in_tables = false;
307
308 for line in frontmatter_lines {
309 let trimmed = line.trim();
310 if trimmed.is_empty() || trimmed.starts_with('#') {
311 continue;
312 }
313 if trimmed.starts_with('[') {
314 in_tables = true;
315 let inner = trimmed
318 .strip_prefix("[[")
319 .and_then(|s| s.split_once("]]").map(|(inner, _)| inner))
320 .or_else(|| {
321 trimmed
322 .strip_prefix('[')
323 .and_then(|s| s.split_once(']').map(|(inner, _)| inner))
324 });
325 if let Some(inner) = inner {
326 let key = Self::toml_root_key(inner.trim());
327 if !key.is_empty() {
328 keys.push(key.to_string());
329 }
330 }
331 continue;
332 }
333 if !in_tables
334 && !line.starts_with(' ')
335 && !line.starts_with('\t')
336 && let Some(eq_pos) = Self::separator_pos_outside_quoted_key(line, '=')
337 {
338 let key = Self::toml_root_key(line[..eq_pos].trim());
339 if !key.is_empty() {
340 keys.push(key.to_string());
341 }
342 }
343 }
344
345 keys
346 }
347
348 fn parse_json_top_level_keys(frontmatter_lines: &[&str]) -> Option<Vec<String>> {
352 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
353 match serde_json::from_str::<serde_json::Value>(&json_content) {
354 Ok(serde_json::Value::Object(map)) => Some(map.keys().cloned().collect()),
355 _ => None,
356 }
357 }
358
359 fn missing_required_key_warnings(
369 &self,
370 present_keys: &[String],
371 format: &str,
372 fence_len: usize,
373 fm_end_line: usize,
374 ) -> Vec<LintWarning> {
375 if self.config.required_keys.is_empty() {
376 return Vec::new();
377 }
378
379 let present: Vec<String> = present_keys.iter().map(|k| k.to_lowercase()).collect();
380 self.config
381 .required_keys
382 .iter()
383 .filter(|required| !present.contains(&required.to_lowercase()))
384 .map(|required| LintWarning {
385 rule_name: Some(self.name().to_string()),
386 message: format!("{format} frontmatter is missing required key '{required}'"),
387 line: 1,
388 column: 1,
389 end_line: fm_end_line.max(1),
390 end_column: fence_len + 1,
391 severity: Severity::Warning,
392 fix: None,
393 })
394 .collect()
395 }
396}
397
398impl Rule for MD072FrontmatterKeySort {
399 fn name(&self) -> &'static str {
400 "MD072"
401 }
402
403 fn description(&self) -> &'static str {
404 "Frontmatter keys should be sorted alphabetically"
405 }
406
407 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
408 let content = ctx.content;
409 let mut warnings = Vec::new();
410
411 if content.is_empty() {
412 return Ok(warnings);
413 }
414
415 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
416
417 match fm_type {
418 FrontMatterType::Yaml => {
419 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
420 let keys = Self::extract_yaml_keys(&frontmatter_lines);
421
422 let key_names: Vec<String> = keys.iter().map(|(_, key)| key.clone()).collect();
423 warnings.extend(self.missing_required_key_warnings(&key_names, "YAML", 3, ctx.front_matter_end_line()));
424
425 if frontmatter_lines.is_empty() {
426 return Ok(warnings);
427 }
428
429 let key_order = self.config.key_order.as_deref();
430 let Some((key_idx, out_of_place, should_come_after)) =
431 Self::find_first_unsorted_indexed_pair(&keys, key_order)
432 else {
433 return Ok(warnings);
434 };
435 let key_line = key_idx + 2;
437
438 let has_comments = Self::has_comments(&frontmatter_lines);
439
440 let fix = if has_comments {
441 None
442 } else {
443 let fixed_content = self.fix_yaml(content, ctx.front_matter_end_line());
445 if fixed_content != content {
446 Some(Fix::new(0..content.len(), fixed_content))
447 } else {
448 None
449 }
450 };
451
452 let message = if has_comments {
453 format!(
454 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
455 )
456 } else {
457 format!(
458 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
459 )
460 };
461
462 let end_column = frontmatter_lines
465 .get(key_idx)
466 .and_then(|line| {
467 Self::separator_pos_outside_quoted_key(line, ':')
468 .map(|pos| line[..pos].trim().chars().count() + 1)
469 })
470 .unwrap_or(out_of_place.chars().count() + 1);
471
472 warnings.push(LintWarning {
473 rule_name: Some(self.name().to_string()),
474 message,
475 line: key_line,
476 column: 1,
477 end_line: key_line,
478 end_column,
479 severity: Severity::Warning,
480 fix,
481 });
482 }
483 FrontMatterType::Toml => {
484 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
485 let keys = Self::extract_toml_keys(&frontmatter_lines);
486
487 let key_names = Self::extract_toml_presence_keys(&frontmatter_lines);
491 warnings.extend(self.missing_required_key_warnings(&key_names, "TOML", 3, ctx.front_matter_end_line()));
492
493 if frontmatter_lines.is_empty() {
494 return Ok(warnings);
495 }
496
497 let key_order = self.config.key_order.as_deref();
498 let Some((key_idx, out_of_place, should_come_after)) =
499 Self::find_first_unsorted_indexed_pair(&keys, key_order)
500 else {
501 return Ok(warnings);
502 };
503 let key_line = key_idx + 2;
504
505 let has_comments = Self::has_comments(&frontmatter_lines);
506
507 let fix = if has_comments {
508 None
509 } else {
510 let fixed_content = self.fix_toml(content, ctx.front_matter_end_line());
512 if fixed_content != content {
513 Some(Fix::new(0..content.len(), fixed_content))
514 } else {
515 None
516 }
517 };
518
519 let message = if has_comments {
520 format!(
521 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
522 )
523 } else {
524 format!(
525 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
526 )
527 };
528
529 let end_column = frontmatter_lines
532 .get(key_idx)
533 .and_then(|line| {
534 Self::separator_pos_outside_quoted_key(line, '=')
535 .map(|pos| line[..pos].trim().chars().count() + 1)
536 })
537 .unwrap_or(out_of_place.chars().count() + 1);
538
539 warnings.push(LintWarning {
540 rule_name: Some(self.name().to_string()),
541 message,
542 line: key_line,
543 column: 1,
544 end_line: key_line,
545 end_column,
546 severity: Severity::Warning,
547 fix,
548 });
549 }
550 FrontMatterType::Json => {
551 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
552 let keys = Self::extract_json_keys(&frontmatter_lines);
553
554 let parsed_keys = Self::parse_json_top_level_keys(&frontmatter_lines);
561 warnings.extend(self.missing_required_key_warnings(
562 parsed_keys.as_deref().unwrap_or(&keys),
563 "JSON",
564 1,
565 ctx.front_matter_end_line(),
566 ));
567
568 if frontmatter_lines.is_empty() {
569 return Ok(warnings);
570 }
571
572 let key_order = self.config.key_order.as_deref();
573 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_pair(&keys, key_order) else {
574 return Ok(warnings);
575 };
576
577 let fixed_content = self.fix_json(content, ctx.front_matter_end_line());
579 let fix = if fixed_content != content {
580 Some(Fix::new(0..content.len(), fixed_content))
581 } else {
582 None
583 };
584
585 let message = format!(
586 "JSON frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
587 );
588
589 warnings.push(LintWarning {
590 rule_name: Some(self.name().to_string()),
591 message,
592 line: 2,
593 column: 1,
594 end_line: 2,
595 end_column: out_of_place.len() + 1,
596 severity: Severity::Warning,
597 fix,
598 });
599 }
600 _ => {
601 }
603 }
604
605 Ok(warnings)
606 }
607
608 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
609 let content = ctx.content;
610
611 if ctx.is_rule_disabled(self.name(), 2) {
613 return Ok(content.to_string());
614 }
615
616 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
617
618 let fm_end = ctx.front_matter_end_line();
619 Ok(match fm_type {
620 FrontMatterType::Yaml => self.fix_yaml(content, fm_end),
621 FrontMatterType::Toml => self.fix_toml(content, fm_end),
622 FrontMatterType::Json => self.fix_json(content, fm_end),
623 _ => content.to_string(),
624 })
625 }
626
627 fn category(&self) -> RuleCategory {
628 RuleCategory::FrontMatter
629 }
630
631 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
632 ctx.content.is_empty()
633 || !ctx.content.starts_with("---") && !ctx.content.starts_with("+++") && !ctx.content.starts_with('{')
634 }
635
636 fn as_any(&self) -> &dyn std::any::Any {
637 self
638 }
639
640 crate::impl_rule_config_methods!(MD072Config, nullable);
641}
642
643impl MD072FrontmatterKeySort {
644 fn preserve_trailing_newline(original: &str, mut result: String) -> String {
649 if original.ends_with('\n') && !result.ends_with('\n') {
650 result.push('\n');
651 }
652 result
653 }
654
655 fn fix_yaml(&self, content: &str, fm_end: usize) -> String {
656 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
657 if frontmatter_lines.is_empty() {
658 return content.to_string();
659 }
660
661 if Self::has_comments(&frontmatter_lines) {
663 return content.to_string();
664 }
665
666 let keys = Self::extract_yaml_keys(&frontmatter_lines);
667 let key_order = self.config.key_order.as_deref();
668 if Self::are_indexed_keys_sorted(&keys, key_order) {
669 return content.to_string();
670 }
671
672 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
675
676 for (i, (line_idx, key)) in keys.iter().enumerate() {
677 let start = *line_idx;
678 let end = if i + 1 < keys.len() {
679 keys[i + 1].0
680 } else {
681 frontmatter_lines.len()
682 };
683
684 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
685 key_blocks.push((key.clone(), block_lines));
686 }
687
688 Self::sort_keys_by_order(&mut key_blocks, key_order);
690
691 let content_lines: Vec<&str> = content.lines().collect();
693
694 let mut result = String::new();
695 result.push_str("---\n");
696 for (_, lines) in &key_blocks {
697 for line in lines {
698 result.push_str(line);
699 result.push('\n');
700 }
701 }
702 result.push_str("---");
703
704 if fm_end < content_lines.len() {
705 result.push('\n');
706 result.push_str(&content_lines[fm_end..].join("\n"));
707 }
708
709 Self::preserve_trailing_newline(content, result)
710 }
711
712 fn fix_toml(&self, content: &str, fm_end: usize) -> String {
713 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
714 if frontmatter_lines.is_empty() {
715 return content.to_string();
716 }
717
718 if Self::has_comments(&frontmatter_lines) {
720 return content.to_string();
721 }
722
723 let keys = Self::extract_toml_keys(&frontmatter_lines);
724 let key_order = self.config.key_order.as_deref();
725 if Self::are_indexed_keys_sorted(&keys, key_order) {
726 return content.to_string();
727 }
728
729 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
732
733 for (i, (line_idx, key)) in keys.iter().enumerate() {
734 let start = *line_idx;
735 let end = if i + 1 < keys.len() {
736 keys[i + 1].0
737 } else {
738 frontmatter_lines.len()
739 };
740
741 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
742 key_blocks.push((key.clone(), block_lines));
743 }
744
745 Self::sort_keys_by_order(&mut key_blocks, key_order);
747
748 let content_lines: Vec<&str> = content.lines().collect();
750
751 let mut result = String::new();
752 result.push_str("+++\n");
753 for (_, lines) in &key_blocks {
754 for line in lines {
755 result.push_str(line);
756 result.push('\n');
757 }
758 }
759 result.push_str("+++");
760
761 if fm_end < content_lines.len() {
762 result.push('\n');
763 result.push_str(&content_lines[fm_end..].join("\n"));
764 }
765
766 Self::preserve_trailing_newline(content, result)
767 }
768
769 fn fix_json(&self, content: &str, fm_end: usize) -> String {
770 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
771 if frontmatter_lines.is_empty() {
772 return content.to_string();
773 }
774
775 let keys = Self::extract_json_keys(&frontmatter_lines);
776 let key_order = self.config.key_order.as_deref();
777
778 if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
779 return content.to_string();
780 }
781
782 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
784
785 match serde_json::from_str::<serde_json::Value>(&json_content) {
787 Ok(serde_json::Value::Object(map)) => {
788 let mut sorted_map = serde_json::Map::new();
790 let mut keys: Vec<_> = map.keys().cloned().collect();
791 keys.sort_by(|a, b| {
792 let pos_a = Self::key_sort_position(a, key_order);
793 let pos_b = Self::key_sort_position(b, key_order);
794 pos_a.cmp(&pos_b)
795 });
796
797 for key in keys {
798 if let Some(value) = map.get(&key) {
799 sorted_map.insert(key, value.clone());
800 }
801 }
802
803 match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
804 Ok(sorted_json) => {
805 let lines: Vec<&str> = content.lines().collect();
806
807 let mut result = String::new();
810 result.push_str(&sorted_json);
811
812 if fm_end < lines.len() {
813 result.push('\n');
814 result.push_str(&lines[fm_end..].join("\n"));
815 }
816
817 Self::preserve_trailing_newline(content, result)
818 }
819 Err(_) => content.to_string(),
820 }
821 }
822 _ => content.to_string(),
823 }
824 }
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830 use crate::lint_context::LintContext;
831
832 fn create_enabled_rule() -> MD072FrontmatterKeySort {
834 MD072FrontmatterKeySort::from_config_struct(MD072Config {
835 enabled: true,
836 ..Default::default()
837 })
838 }
839
840 fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
842 MD072FrontmatterKeySort::from_config_struct(MD072Config {
843 enabled: true,
844 key_order: Some(keys.into_iter().map(String::from).collect()),
845 ..Default::default()
846 })
847 }
848
849 fn create_rule_with_required_keys(keys: Vec<&str>) -> MD072FrontmatterKeySort {
851 MD072FrontmatterKeySort::from_config_struct(MD072Config {
852 enabled: true,
853 required_keys: keys.into_iter().map(String::from).collect(),
854 ..Default::default()
855 })
856 }
857
858 #[test]
861 fn test_enabled_via_config() {
862 let rule = create_enabled_rule();
863 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865 let result = rule.check(&ctx).unwrap();
866
867 assert_eq!(result.len(), 1);
869 }
870
871 #[test]
874 fn test_no_frontmatter() {
875 let rule = create_enabled_rule();
876 let content = "# Heading\n\nContent.";
877 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
878 let result = rule.check(&ctx).unwrap();
879
880 assert!(result.is_empty());
881 }
882
883 #[test]
884 fn test_yaml_sorted_keys() {
885 let rule = create_enabled_rule();
886 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888 let result = rule.check(&ctx).unwrap();
889
890 assert!(result.is_empty());
891 }
892
893 #[test]
894 fn test_yaml_unsorted_keys() {
895 let rule = create_enabled_rule();
896 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 let result = rule.check(&ctx).unwrap();
899
900 assert_eq!(result.len(), 1);
901 assert!(result[0].message.contains("YAML"));
902 assert!(result[0].message.contains("not sorted"));
903 assert!(result[0].message.contains("'author' should come before 'title'"));
905 }
906
907 #[test]
908 fn test_yaml_case_insensitive_sort() {
909 let rule = create_enabled_rule();
910 let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
912 let result = rule.check(&ctx).unwrap();
913
914 assert!(result.is_empty());
916 }
917
918 #[test]
919 fn test_yaml_fix_sorts_keys() {
920 let rule = create_enabled_rule();
921 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923 let fixed = rule.fix(&ctx).unwrap();
924
925 let author_pos = fixed.find("author:").unwrap();
927 let title_pos = fixed.find("title:").unwrap();
928 assert!(author_pos < title_pos);
929 }
930
931 #[test]
932 fn test_yaml_no_fix_with_comments() {
933 let rule = create_enabled_rule();
934 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936 let result = rule.check(&ctx).unwrap();
937
938 assert_eq!(result.len(), 1);
939 assert!(result[0].message.contains("auto-fix unavailable"));
940 assert!(result[0].fix.is_none());
941
942 let fixed = rule.fix(&ctx).unwrap();
944 assert_eq!(fixed, content);
945 }
946
947 #[test]
948 fn test_yaml_single_key() {
949 let rule = create_enabled_rule();
950 let content = "---\ntitle: Test\n---\n\n# Heading";
951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952 let result = rule.check(&ctx).unwrap();
953
954 assert!(result.is_empty());
956 }
957
958 #[test]
959 fn test_yaml_nested_keys_ignored() {
960 let rule = create_enabled_rule();
961 let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
964 let result = rule.check(&ctx).unwrap();
965
966 assert!(result.is_empty());
968 }
969
970 #[test]
971 fn test_yaml_fix_idempotent() {
972 let rule = create_enabled_rule();
973 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975 let fixed_once = rule.fix(&ctx).unwrap();
976
977 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
978 let fixed_twice = rule.fix(&ctx2).unwrap();
979
980 assert_eq!(fixed_once, fixed_twice);
981 }
982
983 #[test]
984 fn test_yaml_fix_preserves_trailing_newline() {
985 let rule = create_enabled_rule();
986 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n";
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989 let fixed = rule.fix(&ctx).unwrap();
990 assert!(
991 fixed.ends_with('\n'),
992 "trailing newline must be preserved, got {fixed:?}"
993 );
994
995 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
997 let fixed_twice = rule.fix(&ctx2).unwrap();
998 assert_eq!(fixed, fixed_twice);
999 }
1000
1001 #[test]
1002 fn test_yaml_fix_whole_file_frontmatter_preserves_trailing_newline() {
1003 let rule = create_enabled_rule();
1004 let content = "---\ntitle: Test\nauthor: John\n---\n";
1006 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1007 let fixed = rule.fix(&ctx).unwrap();
1008 assert!(
1009 fixed.ends_with('\n'),
1010 "trailing newline must be preserved, got {fixed:?}"
1011 );
1012 }
1013
1014 #[test]
1015 fn test_yaml_quoted_keys_sort_by_content() {
1016 let rule = create_enabled_rule();
1017 let content = "---\n\"zebra\": 1\napple: 2\n---\n\n# Heading";
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021 let result = rule.check(&ctx).unwrap();
1022
1023 assert_eq!(result.len(), 1, "quoted key out of order must be flagged");
1024 assert!(result[0].message.contains("'apple' should come before 'zebra'"));
1025 }
1026
1027 #[test]
1028 fn test_yaml_quoted_key_warning_span_covers_quotes() {
1029 let rule = create_enabled_rule();
1030 let content = "---\nbanana: 1\n\"apple\": 2\n---\n";
1034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1035 let result = rule.check(&ctx).unwrap();
1036
1037 assert_eq!(result.len(), 1);
1038 let w = &result[0];
1039 assert_eq!(w.line, 3);
1040 assert_eq!(w.column, 1);
1041 assert_eq!(w.end_column, 8, "diagnostic span must cover the quoted key");
1043 }
1044
1045 #[test]
1046 fn test_yaml_complex_values() {
1047 let rule = create_enabled_rule();
1048 let content =
1050 "---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
1051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052 let result = rule.check(&ctx).unwrap();
1053
1054 assert!(result.is_empty());
1056 }
1057
1058 #[test]
1061 fn test_toml_sorted_keys() {
1062 let rule = create_enabled_rule();
1063 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065 let result = rule.check(&ctx).unwrap();
1066
1067 assert!(result.is_empty());
1068 }
1069
1070 #[test]
1071 fn test_toml_unsorted_keys() {
1072 let rule = create_enabled_rule();
1073 let content = "+++\ntitle = \"Test\"\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);
1078 assert!(result[0].message.contains("TOML"));
1079 assert!(result[0].message.contains("not sorted"));
1080 }
1081
1082 #[test]
1083 fn test_toml_fix_sorts_keys() {
1084 let rule = create_enabled_rule();
1085 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
1086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1087 let fixed = rule.fix(&ctx).unwrap();
1088
1089 let author_pos = fixed.find("author").unwrap();
1091 let title_pos = fixed.find("title").unwrap();
1092 assert!(author_pos < title_pos);
1093 }
1094
1095 #[test]
1096 fn test_toml_no_fix_with_comments() {
1097 let rule = create_enabled_rule();
1098 let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
1099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100 let result = rule.check(&ctx).unwrap();
1101
1102 assert_eq!(result.len(), 1);
1103 assert!(result[0].message.contains("auto-fix unavailable"));
1104
1105 let fixed = rule.fix(&ctx).unwrap();
1107 assert_eq!(fixed, content);
1108 }
1109
1110 #[test]
1113 fn test_json_sorted_keys() {
1114 let rule = create_enabled_rule();
1115 let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
1116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117 let result = rule.check(&ctx).unwrap();
1118
1119 assert!(result.is_empty());
1120 }
1121
1122 #[test]
1123 fn test_json_unsorted_keys() {
1124 let rule = create_enabled_rule();
1125 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1127 let result = rule.check(&ctx).unwrap();
1128
1129 assert_eq!(result.len(), 1);
1130 assert!(result[0].message.contains("JSON"));
1131 assert!(result[0].message.contains("not sorted"));
1132 }
1133
1134 #[test]
1135 fn test_json_fix_sorts_keys() {
1136 let rule = create_enabled_rule();
1137 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1138 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1139 let fixed = rule.fix(&ctx).unwrap();
1140
1141 let author_pos = fixed.find("author").unwrap();
1143 let title_pos = fixed.find("title").unwrap();
1144 assert!(author_pos < title_pos);
1145 }
1146
1147 #[test]
1148 fn test_json_always_fixable() {
1149 let rule = create_enabled_rule();
1150 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1153 let result = rule.check(&ctx).unwrap();
1154
1155 assert_eq!(result.len(), 1);
1156 assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
1158 }
1159
1160 #[test]
1163 fn test_empty_content() {
1164 let rule = create_enabled_rule();
1165 let content = "";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167 let result = rule.check(&ctx).unwrap();
1168
1169 assert!(result.is_empty());
1170 }
1171
1172 #[test]
1173 fn test_empty_frontmatter() {
1174 let rule = create_enabled_rule();
1175 let content = "---\n---\n\n# Heading";
1176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1177 let result = rule.check(&ctx).unwrap();
1178
1179 assert!(result.is_empty());
1180 }
1181
1182 #[test]
1183 fn test_toml_nested_tables_ignored() {
1184 let rule = create_enabled_rule();
1186 let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188 let result = rule.check(&ctx).unwrap();
1189
1190 assert_eq!(result.len(), 1);
1192 assert!(result[0].message.contains("'sort_by' should come before 'title'"));
1194 assert!(!result[0].message.contains("we_have_extra"));
1195 }
1196
1197 #[test]
1198 fn test_toml_nested_taxonomies_ignored() {
1199 let rule = create_enabled_rule();
1201 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
1202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203 let result = rule.check(&ctx).unwrap();
1204
1205 assert_eq!(result.len(), 1);
1207 assert!(result[0].message.contains("'date' should come before 'title'"));
1209 assert!(!result[0].message.contains("categories"));
1210 assert!(!result[0].message.contains("tags"));
1211 }
1212
1213 #[test]
1216 fn test_yaml_unicode_keys() {
1217 let rule = create_enabled_rule();
1218 let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221 let result = rule.check(&ctx).unwrap();
1222
1223 assert_eq!(result.len(), 1);
1225 }
1226
1227 #[test]
1228 fn test_yaml_keys_with_special_characters() {
1229 let rule = create_enabled_rule();
1230 let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
1232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1233 let result = rule.check(&ctx).unwrap();
1234
1235 assert!(result.is_empty());
1237 }
1238
1239 #[test]
1240 fn test_yaml_keys_with_numbers() {
1241 let rule = create_enabled_rule();
1242 let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
1243 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244 let result = rule.check(&ctx).unwrap();
1245
1246 assert!(result.is_empty());
1248 }
1249
1250 #[test]
1251 fn test_yaml_multiline_string_block_literal() {
1252 let rule = create_enabled_rule();
1253 let content =
1254 "---\ndescription: |\n This is a\n multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
1255 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1256 let result = rule.check(&ctx).unwrap();
1257
1258 assert_eq!(result.len(), 1);
1260 assert!(result[0].message.contains("'author' should come before 'title'"));
1261 }
1262
1263 #[test]
1264 fn test_yaml_multiline_string_folded() {
1265 let rule = create_enabled_rule();
1266 let content = "---\ndescription: >\n This is a\n folded string\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_yaml_fix_preserves_multiline_values() {
1276 let rule = create_enabled_rule();
1277 let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
1278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1279 let fixed = rule.fix(&ctx).unwrap();
1280
1281 let desc_pos = fixed.find("description").unwrap();
1283 let title_pos = fixed.find("title").unwrap();
1284 assert!(desc_pos < title_pos);
1285 }
1286
1287 #[test]
1288 fn test_yaml_quoted_keys() {
1289 let rule = create_enabled_rule();
1290 let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
1291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1292 let result = rule.check(&ctx).unwrap();
1293
1294 assert!(result.is_empty());
1296 }
1297
1298 #[test]
1299 fn test_yaml_duplicate_keys() {
1300 let rule = create_enabled_rule();
1302 let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
1303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1304 let result = rule.check(&ctx).unwrap();
1305
1306 assert_eq!(result.len(), 1);
1308 }
1309
1310 #[test]
1311 fn test_toml_inline_table() {
1312 let rule = create_enabled_rule();
1313 let content =
1314 "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
1315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1316 let result = rule.check(&ctx).unwrap();
1317
1318 assert!(result.is_empty());
1320 }
1321
1322 #[test]
1323 fn test_toml_array_of_tables() {
1324 let rule = create_enabled_rule();
1325 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
1326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1327 let result = rule.check(&ctx).unwrap();
1328
1329 assert_eq!(result.len(), 1);
1331 assert!(result[0].message.contains("'date' should come before 'title'"));
1333 }
1334
1335 #[test]
1336 fn test_json_nested_objects() {
1337 let rule = create_enabled_rule();
1338 let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\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!(result.is_empty());
1344 }
1345
1346 #[test]
1347 fn test_json_arrays() {
1348 let rule = create_enabled_rule();
1349 let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351 let result = rule.check(&ctx).unwrap();
1352
1353 assert_eq!(result.len(), 1);
1355 }
1356
1357 #[test]
1358 fn test_fix_preserves_content_after_frontmatter() {
1359 let rule = create_enabled_rule();
1360 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1362 let fixed = rule.fix(&ctx).unwrap();
1363
1364 assert!(fixed.contains("# Heading"));
1366 assert!(fixed.contains("Paragraph 1."));
1367 assert!(fixed.contains("- List item"));
1368 assert!(fixed.contains("- Another item"));
1369 }
1370
1371 #[test]
1372 fn test_fix_yaml_produces_valid_yaml() {
1373 let rule = create_enabled_rule();
1374 let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1376 let fixed = rule.fix(&ctx).unwrap();
1377
1378 let lines: Vec<&str> = fixed.lines().collect();
1381 let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1382 let fm_content: String = lines[1..fm_end].join("\n");
1383
1384 let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&fm_content);
1386 assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1387 }
1388
1389 #[test]
1390 fn test_fix_toml_produces_valid_toml() {
1391 let rule = create_enabled_rule();
1392 let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394 let fixed = rule.fix(&ctx).unwrap();
1395
1396 let lines: Vec<&str> = fixed.lines().collect();
1398 let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1399 let fm_content: String = lines[1..fm_end].join("\n");
1400
1401 let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1403 assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1404 }
1405
1406 #[test]
1407 fn test_fix_json_produces_valid_json() {
1408 let rule = create_enabled_rule();
1409 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1411 let fixed = rule.fix(&ctx).unwrap();
1412
1413 let json_end = fixed.find("\n\n").unwrap();
1415 let json_content = &fixed[..json_end];
1416
1417 let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1419 assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1420 }
1421
1422 #[test]
1423 fn test_many_keys_performance() {
1424 let rule = create_enabled_rule();
1425 let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1427 keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1429
1430 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1431 let result = rule.check(&ctx).unwrap();
1432
1433 assert_eq!(result.len(), 1);
1435 }
1436
1437 #[test]
1438 fn test_yaml_empty_value() {
1439 let rule = create_enabled_rule();
1440 let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1442 let result = rule.check(&ctx).unwrap();
1443
1444 assert_eq!(result.len(), 1);
1446 }
1447
1448 #[test]
1449 fn test_yaml_null_value() {
1450 let rule = create_enabled_rule();
1451 let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1453 let result = rule.check(&ctx).unwrap();
1454
1455 assert_eq!(result.len(), 1);
1456 }
1457
1458 #[test]
1459 fn test_yaml_boolean_values() {
1460 let rule = create_enabled_rule();
1461 let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1463 let result = rule.check(&ctx).unwrap();
1464
1465 assert_eq!(result.len(), 1);
1467 }
1468
1469 #[test]
1470 fn test_toml_boolean_values() {
1471 let rule = create_enabled_rule();
1472 let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474 let result = rule.check(&ctx).unwrap();
1475
1476 assert_eq!(result.len(), 1);
1477 }
1478
1479 #[test]
1480 fn test_yaml_list_at_top_level() {
1481 let rule = create_enabled_rule();
1482 let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
1483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1484 let result = rule.check(&ctx).unwrap();
1485
1486 assert_eq!(result.len(), 1);
1488 }
1489
1490 #[test]
1491 fn test_three_keys_all_orderings() {
1492 let rule = create_enabled_rule();
1493
1494 let orderings = [
1496 ("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), ];
1503
1504 for (name, content, should_pass) in orderings {
1505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1506 let result = rule.check(&ctx).unwrap();
1507 assert_eq!(
1508 result.is_empty(),
1509 should_pass,
1510 "Ordering {name} should {} pass",
1511 if should_pass { "" } else { "not" }
1512 );
1513 }
1514 }
1515
1516 #[test]
1517 fn test_crlf_line_endings() {
1518 let rule = create_enabled_rule();
1519 let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1521 let result = rule.check(&ctx).unwrap();
1522
1523 assert_eq!(result.len(), 1);
1525 }
1526
1527 #[test]
1528 fn test_json_escaped_quotes_in_keys() {
1529 let rule = create_enabled_rule();
1530 let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1533 let result = rule.check(&ctx).unwrap();
1534
1535 assert_eq!(result.len(), 1);
1537 }
1538
1539 #[test]
1542 fn test_warning_fix_yaml_sorts_keys() {
1543 let rule = create_enabled_rule();
1544 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1546 let warnings = rule.check(&ctx).unwrap();
1547
1548 assert_eq!(warnings.len(), 1);
1549 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1550
1551 let fix = warnings[0].fix.as_ref().unwrap();
1552 assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1553
1554 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1556
1557 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1559 let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1560 assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1561 }
1562
1563 #[test]
1564 fn test_warning_fix_preserves_yaml_list_indentation() {
1565 let rule = create_enabled_rule();
1566 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568 let warnings = rule.check(&ctx).unwrap();
1569
1570 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1571
1572 assert!(
1574 fixed.contains(" - hello"),
1575 "List indentation should be preserved: {fixed}"
1576 );
1577 assert!(
1578 fixed.contains(" - world"),
1579 "List indentation should be preserved: {fixed}"
1580 );
1581 }
1582
1583 #[test]
1584 fn test_warning_fix_preserves_nested_object_indentation() {
1585 let rule = create_enabled_rule();
1586 let content = "---\nzzzz: value\naaaa:\n nested_key: nested_value\n another: 123\n---\n\n# Heading\n";
1587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1588 let warnings = rule.check(&ctx).unwrap();
1589
1590 assert_eq!(warnings.len(), 1);
1591 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1592
1593 let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1595 let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1596 assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1597
1598 assert!(
1600 fixed.contains(" nested_key: nested_value"),
1601 "Nested object indentation should be preserved: {fixed}"
1602 );
1603 assert!(
1604 fixed.contains(" another: 123"),
1605 "Nested object indentation should be preserved: {fixed}"
1606 );
1607 }
1608
1609 #[test]
1610 fn test_warning_fix_preserves_deeply_nested_structure() {
1611 let rule = create_enabled_rule();
1612 let content = "---\nzzz: top\naaa:\n level1:\n level2:\n - item1\n - item2\n---\n\n# Content\n";
1613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1614 let warnings = rule.check(&ctx).unwrap();
1615
1616 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1617
1618 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1620 let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1621 assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1622
1623 assert!(fixed.contains(" level1:"), "2-space indent should be preserved");
1625 assert!(fixed.contains(" level2:"), "4-space indent should be preserved");
1626 assert!(fixed.contains(" - item1"), "6-space indent should be preserved");
1627 assert!(fixed.contains(" - item2"), "6-space indent should be preserved");
1628 }
1629
1630 #[test]
1631 fn test_warning_fix_toml_sorts_keys() {
1632 let rule = create_enabled_rule();
1633 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\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 assert_eq!(warnings.len(), 1);
1638 assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1639
1640 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1641
1642 let author_pos = fixed.find("author").expect("author should exist");
1644 let title_pos = fixed.find("title").expect("title should exist");
1645 assert!(author_pos < title_pos, "author should come before title");
1646 }
1647
1648 #[test]
1649 fn test_warning_fix_json_sorts_keys() {
1650 let rule = create_enabled_rule();
1651 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1653 let warnings = rule.check(&ctx).unwrap();
1654
1655 assert_eq!(warnings.len(), 1);
1656 assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1657
1658 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1659
1660 let author_pos = fixed.find("author").expect("author should exist");
1662 let title_pos = fixed.find("title").expect("title should exist");
1663 assert!(author_pos < title_pos, "author should come before title");
1664 }
1665
1666 #[test]
1667 fn test_warning_fix_no_fix_when_comments_present() {
1668 let rule = create_enabled_rule();
1669 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1671 let warnings = rule.check(&ctx).unwrap();
1672
1673 assert_eq!(warnings.len(), 1);
1674 assert!(
1675 warnings[0].fix.is_none(),
1676 "Warning should NOT have a fix when comments are present"
1677 );
1678 assert!(
1679 warnings[0].message.contains("auto-fix unavailable"),
1680 "Message should indicate auto-fix is unavailable"
1681 );
1682 }
1683
1684 #[test]
1685 fn test_warning_fix_preserves_content_after_frontmatter() {
1686 let rule = create_enabled_rule();
1687 let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1689 let warnings = rule.check(&ctx).unwrap();
1690
1691 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1692
1693 assert!(fixed.contains("# Heading"), "Heading should be preserved");
1695 assert!(
1696 fixed.contains("Paragraph with content."),
1697 "Paragraph should be preserved"
1698 );
1699 assert!(fixed.contains("- List item"), "List item should be preserved");
1700 }
1701
1702 #[test]
1703 fn test_warning_fix_idempotent() {
1704 let rule = create_enabled_rule();
1705 let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1707 let warnings = rule.check(&ctx).unwrap();
1708
1709 let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1710
1711 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1713 let warnings2 = rule.check(&ctx2).unwrap();
1714
1715 assert!(
1716 warnings2.is_empty(),
1717 "After fixing, no more warnings should be produced"
1718 );
1719 }
1720
1721 #[test]
1722 fn test_warning_fix_preserves_multiline_block_literal() {
1723 let rule = create_enabled_rule();
1724 let content = "---\nzzz: simple\naaa: |\n Line 1 of block\n Line 2 of block\n---\n\n# Heading\n";
1725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1726 let warnings = rule.check(&ctx).unwrap();
1727
1728 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1729
1730 assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1732 assert!(
1733 fixed.contains(" Line 1 of block"),
1734 "Block literal line 1 should be preserved with indent"
1735 );
1736 assert!(
1737 fixed.contains(" Line 2 of block"),
1738 "Block literal line 2 should be preserved with indent"
1739 );
1740 }
1741
1742 #[test]
1743 fn test_warning_fix_preserves_folded_string() {
1744 let rule = create_enabled_rule();
1745 let content = "---\nzzz: simple\naaa: >\n Folded line 1\n Folded line 2\n---\n\n# Content\n";
1746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1747 let warnings = rule.check(&ctx).unwrap();
1748
1749 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1750
1751 assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1753 assert!(
1754 fixed.contains(" Folded line 1"),
1755 "Folded line 1 should be preserved with indent"
1756 );
1757 assert!(
1758 fixed.contains(" Folded line 2"),
1759 "Folded line 2 should be preserved with indent"
1760 );
1761 }
1762
1763 #[test]
1764 fn test_warning_fix_preserves_4_space_indentation() {
1765 let rule = create_enabled_rule();
1766 let content = "---\nzzz: value\naaa:\n nested: with_4_spaces\n another: value\n---\n\n# Heading\n";
1768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1769 let warnings = rule.check(&ctx).unwrap();
1770
1771 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1772
1773 assert!(
1775 fixed.contains(" nested: with_4_spaces"),
1776 "4-space indentation should be preserved: {fixed}"
1777 );
1778 assert!(
1779 fixed.contains(" another: value"),
1780 "4-space indentation should be preserved: {fixed}"
1781 );
1782 }
1783
1784 #[test]
1785 fn test_warning_fix_preserves_tab_indentation() {
1786 let rule = create_enabled_rule();
1787 let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1790 let warnings = rule.check(&ctx).unwrap();
1791
1792 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1793
1794 assert!(
1796 fixed.contains("\tnested: with_tab"),
1797 "Tab indentation should be preserved: {fixed}"
1798 );
1799 assert!(
1800 fixed.contains("\tanother: value"),
1801 "Tab indentation should be preserved: {fixed}"
1802 );
1803 }
1804
1805 #[test]
1806 fn test_warning_fix_preserves_inline_list() {
1807 let rule = create_enabled_rule();
1808 let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1811 let warnings = rule.check(&ctx).unwrap();
1812
1813 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1814
1815 assert!(
1817 fixed.contains("aaa: [one, two, three]"),
1818 "Inline list should be preserved exactly: {fixed}"
1819 );
1820 }
1821
1822 #[test]
1823 fn test_warning_fix_preserves_quoted_strings() {
1824 let rule = create_enabled_rule();
1825 let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1828 let warnings = rule.check(&ctx).unwrap();
1829
1830 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1831
1832 assert!(
1834 fixed.contains("aaa: \"value with: colon\""),
1835 "Double-quoted string should be preserved: {fixed}"
1836 );
1837 assert!(
1838 fixed.contains("bbb: 'single quotes'"),
1839 "Single-quoted string should be preserved: {fixed}"
1840 );
1841 }
1842
1843 #[test]
1846 fn test_yaml_custom_key_order_sorted() {
1847 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1849 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1850 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1851 let result = rule.check(&ctx).unwrap();
1852
1853 assert!(result.is_empty());
1855 }
1856
1857 #[test]
1858 fn test_yaml_custom_key_order_unsorted() {
1859 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1861 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1862 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1863 let result = rule.check(&ctx).unwrap();
1864
1865 assert_eq!(result.len(), 1);
1866 assert!(result[0].message.contains("'date' should come before 'author'"));
1868 }
1869
1870 #[test]
1871 fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1872 let rule = create_rule_with_key_order(vec!["title"]);
1874 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\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!(result.is_empty());
1881 }
1882
1883 #[test]
1884 fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1885 let rule = create_rule_with_key_order(vec!["title"]);
1887 let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1889 let result = rule.check(&ctx).unwrap();
1890
1891 assert_eq!(result.len(), 1);
1893 assert!(result[0].message.contains("'author' should come before 'zebra'"));
1894 }
1895
1896 #[test]
1897 fn test_yaml_custom_key_order_fix() {
1898 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1899 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1901 let fixed = rule.fix(&ctx).unwrap();
1902
1903 let title_pos = fixed.find("title:").unwrap();
1905 let date_pos = fixed.find("date:").unwrap();
1906 let author_pos = fixed.find("author:").unwrap();
1907 assert!(
1908 title_pos < date_pos && date_pos < author_pos,
1909 "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1910 );
1911 }
1912
1913 #[test]
1914 fn test_yaml_custom_key_order_fix_with_unlisted() {
1915 let rule = create_rule_with_key_order(vec!["title", "author"]);
1917 let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\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 author_pos = fixed.find("author:").unwrap();
1924 let aardvark_pos = fixed.find("aardvark:").unwrap();
1925 let zebra_pos = fixed.find("zebra:").unwrap();
1926
1927 assert!(
1928 title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1929 "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1930 );
1931 }
1932
1933 #[test]
1934 fn test_toml_custom_key_order_sorted() {
1935 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1936 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
1937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1938 let result = rule.check(&ctx).unwrap();
1939
1940 assert!(result.is_empty());
1941 }
1942
1943 #[test]
1944 fn test_toml_custom_key_order_unsorted() {
1945 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1946 let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
1947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1948 let result = rule.check(&ctx).unwrap();
1949
1950 assert_eq!(result.len(), 1);
1951 assert!(result[0].message.contains("TOML"));
1952 }
1953
1954 #[test]
1955 fn test_json_custom_key_order_sorted() {
1956 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1957 let content = "{\n \"title\": \"Test\",\n \"date\": \"2024-01-01\",\n \"author\": \"John\"\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());
1962 }
1963
1964 #[test]
1965 fn test_json_custom_key_order_unsorted() {
1966 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1967 let content = "{\n \"author\": \"John\",\n \"title\": \"Test\",\n \"date\": \"2024-01-01\"\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_eq!(result.len(), 1);
1972 assert!(result[0].message.contains("JSON"));
1973 }
1974
1975 #[test]
1976 fn test_key_order_case_insensitive_match() {
1977 let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
1979 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1980 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1981 let result = rule.check(&ctx).unwrap();
1982
1983 assert!(result.is_empty());
1985 }
1986
1987 #[test]
1988 fn test_key_order_partial_match() {
1989 let rule = create_rule_with_key_order(vec!["title"]);
1991 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1993 let result = rule.check(&ctx).unwrap();
1994
1995 assert_eq!(result.len(), 1);
2006 assert!(result[0].message.contains("'author' should come before 'date'"));
2007 }
2008
2009 #[test]
2012 fn test_key_order_empty_array_falls_back_to_alphabetical() {
2013 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2015 enabled: true,
2016 key_order: Some(vec![]),
2017 ..Default::default()
2018 });
2019 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2021 let result = rule.check(&ctx).unwrap();
2022
2023 assert_eq!(result.len(), 1);
2026 assert!(result[0].message.contains("'author' should come before 'title'"));
2027 }
2028
2029 #[test]
2030 fn test_key_order_single_key() {
2031 let rule = create_rule_with_key_order(vec!["title"]);
2033 let content = "---\ntitle: Test\n---\n\n# Heading";
2034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2035 let result = rule.check(&ctx).unwrap();
2036
2037 assert!(result.is_empty());
2038 }
2039
2040 #[test]
2041 fn test_key_order_all_keys_specified() {
2042 let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
2044 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
2045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2046 let result = rule.check(&ctx).unwrap();
2047
2048 assert!(result.is_empty());
2049 }
2050
2051 #[test]
2052 fn test_key_order_no_keys_match() {
2053 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
2055 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
2056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2057 let result = rule.check(&ctx).unwrap();
2058
2059 assert!(result.is_empty());
2062 }
2063
2064 #[test]
2065 fn test_key_order_no_keys_match_unsorted() {
2066 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
2068 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
2069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2070 let result = rule.check(&ctx).unwrap();
2071
2072 assert_eq!(result.len(), 1);
2075 }
2076
2077 #[test]
2078 fn test_key_order_duplicate_keys_in_config() {
2079 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2081 enabled: true,
2082 key_order: Some(vec![
2083 "title".to_string(),
2084 "author".to_string(),
2085 "title".to_string(), ]),
2087 ..Default::default()
2088 });
2089 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2091 let result = rule.check(&ctx).unwrap();
2092
2093 assert!(result.is_empty());
2095 }
2096
2097 #[test]
2098 fn test_key_order_with_comments_still_skips_fix() {
2099 let rule = create_rule_with_key_order(vec!["title", "author"]);
2101 let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
2102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2103 let result = rule.check(&ctx).unwrap();
2104
2105 assert_eq!(result.len(), 1);
2107 assert!(result[0].message.contains("auto-fix unavailable"));
2108 assert!(result[0].fix.is_none());
2109 }
2110
2111 #[test]
2112 fn test_toml_custom_key_order_fix() {
2113 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
2114 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
2115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2116 let fixed = rule.fix(&ctx).unwrap();
2117
2118 let title_pos = fixed.find("title").unwrap();
2120 let date_pos = fixed.find("date").unwrap();
2121 let author_pos = fixed.find("author").unwrap();
2122 assert!(
2123 title_pos < date_pos && date_pos < author_pos,
2124 "Fixed TOML should have keys in custom order. Got:\n{fixed}"
2125 );
2126 }
2127
2128 #[test]
2129 fn test_json_custom_key_order_fix() {
2130 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
2131 let content = "{\n \"author\": \"John\",\n \"date\": \"2024-01-01\",\n \"title\": \"Test\"\n}\n\n# Heading";
2132 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2133 let fixed = rule.fix(&ctx).unwrap();
2134
2135 let title_pos = fixed.find("\"title\"").unwrap();
2137 let date_pos = fixed.find("\"date\"").unwrap();
2138 let author_pos = fixed.find("\"author\"").unwrap();
2139 assert!(
2140 title_pos < date_pos && date_pos < author_pos,
2141 "Fixed JSON should have keys in custom order. Got:\n{fixed}"
2142 );
2143 }
2144
2145 #[test]
2146 fn test_key_order_unicode_keys() {
2147 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2149 enabled: true,
2150 key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
2151 ..Default::default()
2152 });
2153 let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
2154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2155 let result = rule.check(&ctx).unwrap();
2156
2157 assert!(result.is_empty());
2159 }
2160
2161 #[test]
2162 fn test_key_order_mixed_specified_and_unlisted_boundary() {
2163 let rule = create_rule_with_key_order(vec!["z_last_specified"]);
2165 let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
2166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2167 let result = rule.check(&ctx).unwrap();
2168
2169 assert!(result.is_empty());
2172 }
2173
2174 #[test]
2175 fn test_key_order_fix_preserves_values() {
2176 let rule = create_rule_with_key_order(vec!["title", "tags"]);
2178 let content = "---\ntags:\n - rust\n - markdown\ntitle: Test\n---\n\n# Heading";
2179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2180 let fixed = rule.fix(&ctx).unwrap();
2181
2182 let title_pos = fixed.find("title:").unwrap();
2184 let tags_pos = fixed.find("tags:").unwrap();
2185 assert!(title_pos < tags_pos, "title should come before tags");
2186
2187 assert!(fixed.contains("- rust"), "List items should be preserved");
2189 assert!(fixed.contains("- markdown"), "List items should be preserved");
2190 }
2191
2192 #[test]
2193 fn test_key_order_idempotent_fix() {
2194 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
2196 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
2197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2198
2199 let fixed_once = rule.fix(&ctx).unwrap();
2200 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2201 let fixed_twice = rule.fix(&ctx2).unwrap();
2202
2203 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2204 }
2205
2206 #[test]
2207 fn test_key_order_respects_later_position_over_alphabetical() {
2208 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2210 let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
2211 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2212 let result = rule.check(&ctx).unwrap();
2213
2214 assert!(result.is_empty());
2216 }
2217
2218 #[test]
2221 fn test_json_braces_in_string_values_extracts_all_keys() {
2222 let rule = create_enabled_rule();
2226 let content = "{\n\"author\": \"Someone\",\n\"description\": \"Use { to open\",\n\"tags\": [\"a\"],\n\"title\": \"My Post\"\n}\n\nContent here.\n";
2227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2228 let result = rule.check(&ctx).unwrap();
2229
2230 assert!(
2232 result.is_empty(),
2233 "All keys should be extracted and recognized as sorted. Got: {result:?}"
2234 );
2235 }
2236
2237 #[test]
2238 fn test_json_braces_in_string_key_after_brace_value_detected() {
2239 let rule = create_enabled_rule();
2241 let content = "{\n\"description\": \"Use { to open\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2244 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2245 let result = rule.check(&ctx).unwrap();
2246
2247 assert_eq!(
2250 result.len(),
2251 1,
2252 "Should detect unsorted keys after brace-containing string value"
2253 );
2254 assert!(
2255 result[0].message.contains("'author' should come before 'description'"),
2256 "Should report author before description. Got: {}",
2257 result[0].message
2258 );
2259 }
2260
2261 #[test]
2262 fn test_json_brackets_in_string_values() {
2263 let rule = create_enabled_rule();
2265 let content = "{\n\"description\": \"My [Post]\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2266 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2267 let result = rule.check(&ctx).unwrap();
2268
2269 assert_eq!(
2271 result.len(),
2272 1,
2273 "Should detect unsorted keys despite brackets in string values"
2274 );
2275 assert!(
2276 result[0].message.contains("'author' should come before 'description'"),
2277 "Got: {}",
2278 result[0].message
2279 );
2280 }
2281
2282 #[test]
2283 fn test_json_escaped_quotes_in_values() {
2284 let rule = create_enabled_rule();
2286 let content = "{\n\"title\": \"He said \\\"hello {world}\\\"\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2287 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2288 let result = rule.check(&ctx).unwrap();
2289
2290 assert_eq!(result.len(), 1, "Should handle escaped quotes with braces in values");
2292 assert!(
2293 result[0].message.contains("'author' should come before 'title'"),
2294 "Got: {}",
2295 result[0].message
2296 );
2297 }
2298
2299 #[test]
2300 fn test_json_multiple_braces_in_string() {
2301 let rule = create_enabled_rule();
2303 let content = "{\n\"pattern\": \"{{{}}\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2304 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2305 let result = rule.check(&ctx).unwrap();
2306
2307 assert_eq!(result.len(), 1, "Should handle multiple braces in string values");
2309 assert!(
2310 result[0].message.contains("'author' should come before 'pattern'"),
2311 "Got: {}",
2312 result[0].message
2313 );
2314 }
2315
2316 #[test]
2317 fn test_key_order_detects_wrong_custom_order() {
2318 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2320 let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
2321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2322 let result = rule.check(&ctx).unwrap();
2323
2324 assert_eq!(result.len(), 1);
2325 assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
2326 }
2327
2328 #[test]
2331 fn test_required_keys_yaml_missing_key_warns_without_fix() {
2332 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2333 let content = "---\ntitle: Test\n---\n\n# Heading";
2334 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2335 let result = rule.check(&ctx).unwrap();
2336
2337 assert_eq!(result.len(), 1);
2338 assert!(result[0].message.contains("missing required key 'date'"));
2339 assert!(result[0].message.contains("YAML"));
2340 assert!(result[0].fix.is_none(), "missing keys must not be auto-fixable");
2341 assert_eq!(result[0].line, 1);
2343 assert_eq!(result[0].column, 1);
2344 assert_eq!(result[0].end_column, 4);
2345 }
2346
2347 #[test]
2348 fn test_required_keys_all_present_no_warning() {
2349 let rule = create_rule_with_required_keys(vec!["author", "title"]);
2350 let content = "---\nauthor: John\ntitle: Test\n---\n\n# Heading";
2351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2352 let result = rule.check(&ctx).unwrap();
2353
2354 assert!(result.is_empty());
2355 }
2356
2357 #[test]
2358 fn test_required_keys_one_warning_per_missing_key() {
2359 let rule = create_rule_with_required_keys(vec!["title", "date", "author"]);
2360 let content = "---\ntags: [a, b]\n---\n\n# Heading";
2361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2362 let result = rule.check(&ctx).unwrap();
2363
2364 assert_eq!(result.len(), 3);
2365 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
2366 assert!(messages.iter().any(|m| m.contains("'title'")));
2367 assert!(messages.iter().any(|m| m.contains("'date'")));
2368 assert!(messages.iter().any(|m| m.contains("'author'")));
2369 }
2370
2371 #[test]
2372 fn test_required_keys_case_insensitive_match() {
2373 let rule = create_rule_with_required_keys(vec!["Title"]);
2375 let content = "---\ntitle: Test\n---\n\n# Heading";
2376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2377 let result = rule.check(&ctx).unwrap();
2378
2379 assert!(result.is_empty());
2380 }
2381
2382 #[test]
2383 fn test_required_keys_missing_and_unsorted_both_reported() {
2384 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2385 enabled: true,
2386 required_keys: vec!["date".to_string()],
2387 ..Default::default()
2388 });
2389 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2391 let result = rule.check(&ctx).unwrap();
2392
2393 assert_eq!(result.len(), 2);
2394 assert!(result[0].message.contains("missing required key 'date'"));
2395 assert!(result[1].message.contains("'author' should come before 'title'"));
2396 }
2397
2398 #[test]
2399 fn test_required_keys_toml_missing_key() {
2400 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2401 let content = "+++\ntitle = \"Test\"\n+++\n\n# Heading";
2402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2403 let result = rule.check(&ctx).unwrap();
2404
2405 assert_eq!(result.len(), 1);
2406 assert!(
2407 result[0]
2408 .message
2409 .contains("TOML frontmatter is missing required key 'date'")
2410 );
2411 assert!(result[0].fix.is_none());
2412 }
2413
2414 #[test]
2415 fn test_required_keys_json_missing_key() {
2416 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2417 let content = "{\n\"title\": \"Test\"\n}\n\n# Heading";
2418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2419 let result = rule.check(&ctx).unwrap();
2420
2421 assert_eq!(result.len(), 1);
2422 assert!(
2423 result[0]
2424 .message
2425 .contains("JSON frontmatter is missing required key 'date'")
2426 );
2427 assert_eq!(result[0].end_column, 2);
2429 }
2430
2431 #[test]
2432 fn test_required_keys_no_frontmatter_no_warning() {
2433 let rule = create_rule_with_required_keys(vec!["title"]);
2436 let content = "# Heading\n\nContent.";
2437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2438 let result = rule.check(&ctx).unwrap();
2439
2440 assert!(result.is_empty());
2441 }
2442
2443 #[test]
2444 fn test_required_keys_empty_frontmatter_warns() {
2445 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2447 let content = "---\n---\n\n# Heading";
2448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2449 let result = rule.check(&ctx).unwrap();
2450
2451 assert_eq!(result.len(), 2);
2452 assert!(result.iter().all(|w| w.message.contains("missing required key")));
2453 }
2454
2455 #[test]
2456 fn test_required_keys_nested_key_does_not_satisfy() {
2457 let rule = create_rule_with_required_keys(vec!["title"]);
2459 let content = "---\nmeta:\n title: Nested\n---\n\n# Heading";
2460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2461 let result = rule.check(&ctx).unwrap();
2462
2463 assert_eq!(result.len(), 1);
2464 assert!(result[0].message.contains("missing required key 'title'"));
2465 }
2466
2467 #[test]
2468 fn test_required_keys_quoted_yaml_key_satisfies() {
2469 let rule = create_rule_with_required_keys(vec!["title"]);
2471 let content = "---\n\"title\": Test\n---\n\n# Heading";
2472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2473 let result = rule.check(&ctx).unwrap();
2474
2475 assert!(result.is_empty());
2476 }
2477
2478 #[test]
2479 fn test_required_keys_fix_does_not_insert_keys() {
2480 let rule = create_rule_with_required_keys(vec!["date"]);
2483 let content = "---\nauthor: John\ntitle: Test\n---\n\n# Heading";
2484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2485 let fixed = rule.fix(&ctx).unwrap();
2486
2487 assert_eq!(fixed, content);
2488 }
2489
2490 #[test]
2491 fn test_required_keys_with_key_order_subset() {
2492 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2495 enabled: true,
2496 key_order: Some(vec![
2497 "title".to_string(),
2498 "date".to_string(),
2499 "author".to_string(),
2500 "tags".to_string(),
2501 ]),
2502 required_keys: vec!["title".to_string(), "date".to_string()],
2503 });
2504
2505 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2508 let result = rule.check(&ctx).unwrap();
2509 assert_eq!(result.len(), 1);
2510 assert!(result[0].message.contains("missing required key 'date'"));
2511
2512 let content = "---\ntitle: Test\ndate: 2024-01-01\ntags: [a]\n---\n\n# Heading";
2514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2515 let result = rule.check(&ctx).unwrap();
2516 assert!(result.is_empty());
2517 }
2518
2519 #[test]
2520 fn test_required_keys_unsorted_fix_still_applies_without_inserting() {
2521 let rule = create_rule_with_required_keys(vec!["date"]);
2524 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2526 let fixed = rule.fix(&ctx).unwrap();
2527
2528 let author_pos = fixed.find("author:").unwrap();
2529 let title_pos = fixed.find("title:").unwrap();
2530 assert!(author_pos < title_pos, "sort fix must still apply");
2531 assert!(!fixed.contains("date"), "fix must not insert the missing key");
2532
2533 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2534 let result = rule.check(&ctx2).unwrap();
2535 assert_eq!(result.len(), 1);
2536 assert!(result[0].message.contains("missing required key 'date'"));
2537 }
2538
2539 #[test]
2540 fn test_required_keys_warning_spans_the_frontmatter_block() {
2541 let rule = create_rule_with_required_keys(vec!["date"]);
2545 let content = "---\ntitle: Test\n---\n\n# Heading";
2546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2547 let result = rule.check(&ctx).unwrap();
2548
2549 assert_eq!(result.len(), 1);
2550 assert_eq!(result[0].line, 1);
2551 assert_eq!(result[0].column, 1);
2552 assert_eq!(result[0].end_line, 3, "span must reach the closing fence line");
2553 assert_eq!(result[0].end_column, 4);
2554 }
2555
2556 #[test]
2557 fn test_required_keys_suppressed_by_inline_disable_in_frontmatter() {
2558 let rule = create_rule_with_required_keys(vec!["date"]);
2563 let content = "---\n# <!-- rumdl-disable MD072 -->\ntitle: Test\n---\n\n# Heading\n";
2564 let warnings = crate::lint(
2565 content,
2566 &[Box::new(rule) as Box<dyn Rule>],
2567 false,
2568 crate::config::MarkdownFlavor::Standard,
2569 None,
2570 None,
2571 )
2572 .unwrap();
2573
2574 assert!(
2575 warnings.is_empty(),
2576 "inline disable inside the frontmatter must suppress missing-key warnings, got: {warnings:?}"
2577 );
2578 }
2579
2580 #[test]
2581 fn test_required_keys_reported_through_lint_without_disable() {
2582 let rule = create_rule_with_required_keys(vec!["date"]);
2585 let content = "---\ntitle: Test\n---\n\n# Heading\n";
2586 let warnings = crate::lint(
2587 content,
2588 &[Box::new(rule) as Box<dyn Rule>],
2589 false,
2590 crate::config::MarkdownFlavor::Standard,
2591 None,
2592 None,
2593 )
2594 .unwrap();
2595
2596 assert_eq!(warnings.len(), 1);
2597 assert!(warnings[0].message.contains("missing required key 'date'"));
2598 }
2599
2600 #[test]
2601 fn test_required_keys_quoted_toml_key_satisfies() {
2602 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2604 let content = "+++\n\"date\" = \"2024-01-01\"\n'title' = \"Test\"\n+++\n\n# Heading";
2605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2606 let result = rule.check(&ctx).unwrap();
2607
2608 assert!(
2609 result.is_empty(),
2610 "quoted TOML keys must satisfy required-keys, got: {result:?}"
2611 );
2612 }
2613
2614 #[test]
2615 fn test_toml_quoted_keys_sort_by_content() {
2616 let rule = create_enabled_rule();
2620 let content = "+++\n\"zebra\" = 1\napple = 2\n+++\n\n# Heading";
2621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2622 let result = rule.check(&ctx).unwrap();
2623
2624 assert_eq!(result.len(), 1, "quoted TOML key out of order must be flagged");
2625 assert!(result[0].message.contains("'apple' should come before 'zebra'"));
2626 }
2627
2628 #[test]
2629 fn test_toml_quoted_key_warning_span_covers_quotes() {
2630 let rule = create_enabled_rule();
2633 let content = "+++\nbanana = 1\n\"apple\" = 2\n+++\n";
2634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2635 let result = rule.check(&ctx).unwrap();
2636
2637 assert_eq!(result.len(), 1);
2638 let w = &result[0];
2639 assert_eq!(w.line, 3);
2640 assert_eq!(w.column, 1);
2641 assert_eq!(w.end_column, 8, "diagnostic span must cover the quoted key");
2643 }
2644
2645 #[test]
2646 fn test_required_keys_json_multiple_keys_on_one_line() {
2647 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2651 let content = "{\n\"title\": \"Test\", \"date\": \"2024-01-01\"\n}\n\n# Heading";
2652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2653 let result = rule.check(&ctx).unwrap();
2654
2655 assert!(
2656 result.is_empty(),
2657 "all keys on one JSON line must satisfy required-keys, got: {result:?}"
2658 );
2659 }
2660
2661 #[test]
2662 fn test_required_keys_json_multiple_keys_on_one_line_missing_still_reported() {
2663 let rule = create_rule_with_required_keys(vec!["title", "date", "author"]);
2665 let content = "{\n\"title\": \"Test\", \"date\": \"2024-01-01\"\n}\n\n# Heading";
2666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2667 let result = rule.check(&ctx).unwrap();
2668
2669 assert_eq!(result.len(), 1);
2670 assert!(result[0].message.contains("missing required key 'author'"));
2671 }
2672
2673 #[test]
2674 fn test_required_keys_json_invalid_falls_back_to_line_based_keys() {
2675 let rule = create_rule_with_required_keys(vec!["title"]);
2678 let content = "{\n\"title\": unquoted-invalid\n}\n\n# Heading";
2679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2680 let result = rule.check(&ctx).unwrap();
2681
2682 assert!(
2683 result.is_empty(),
2684 "invalid JSON must fall back to line-based key extraction, got: {result:?}"
2685 );
2686 }
2687
2688 #[test]
2689 fn test_required_keys_toml_table_header_satisfies() {
2690 let rule = create_rule_with_required_keys(vec!["title", "taxonomies"]);
2694 let content = "+++\ntitle = \"Test\"\n\n[taxonomies]\ntags = [\"a\"]\n+++\n\n# Heading";
2695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2696 let result = rule.check(&ctx).unwrap();
2697
2698 assert!(
2699 result.is_empty(),
2700 "a TOML table header must satisfy required-keys, got: {result:?}"
2701 );
2702 }
2703
2704 #[test]
2705 fn test_required_keys_toml_array_of_tables_satisfies() {
2706 let rule = create_rule_with_required_keys(vec!["authors"]);
2708 let content = "+++\ntitle = \"Test\"\n\n[[authors]]\nname = \"John\"\n+++\n\n# Heading";
2709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2710 let result = rule.check(&ctx).unwrap();
2711
2712 assert!(
2713 result.is_empty(),
2714 "a TOML array-of-tables header must satisfy required-keys, got: {result:?}"
2715 );
2716 }
2717
2718 #[test]
2719 fn test_required_keys_toml_dotted_table_header_satisfies_root() {
2720 let rule = create_rule_with_required_keys(vec!["params"]);
2722 let content = "+++\ntitle = \"Test\"\n\n[params.seo]\nnoindex = true\n+++\n\n# Heading";
2723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2724 let result = rule.check(&ctx).unwrap();
2725
2726 assert!(
2727 result.is_empty(),
2728 "a dotted TOML table header must satisfy its root key, got: {result:?}"
2729 );
2730 }
2731
2732 #[test]
2733 fn test_required_keys_toml_missing_despite_other_tables() {
2734 let rule = create_rule_with_required_keys(vec!["date"]);
2736 let content = "+++\ntitle = \"Test\"\n\n[taxonomies]\ntags = [\"a\"]\n+++\n\n# Heading";
2737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2738 let result = rule.check(&ctx).unwrap();
2739
2740 assert_eq!(result.len(), 1);
2741 assert!(result[0].message.contains("missing required key 'date'"));
2742 }
2743
2744 #[test]
2745 fn test_required_keys_toml_dotted_assignment_satisfies_root() {
2746 let rule = create_rule_with_required_keys(vec!["params"]);
2748 let content = "+++\nparams.seo = true\ntitle = \"Test\"\n+++\n\n# Heading";
2749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2750 let result = rule.check(&ctx).unwrap();
2751
2752 assert!(
2753 result.is_empty(),
2754 "a dotted TOML assignment must satisfy its root key, got: {result:?}"
2755 );
2756 }
2757
2758 #[test]
2759 fn test_required_keys_toml_quoted_dotted_key_is_atomic() {
2760 let rule = create_rule_with_required_keys(vec!["a.b"]);
2762 let content = "+++\n\"a.b\" = 1\n+++\n\n# Heading";
2763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2764 let result = rule.check(&ctx).unwrap();
2765 assert!(
2766 result.is_empty(),
2767 "quoted dotted key must match literally, got: {result:?}"
2768 );
2769
2770 let rule = create_rule_with_required_keys(vec!["a"]);
2771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2772 let result = rule.check(&ctx).unwrap();
2773 assert_eq!(result.len(), 1, "quoted dotted key must NOT satisfy its first segment");
2774 assert!(result[0].message.contains("missing required key 'a'"));
2775 }
2776
2777 #[test]
2778 fn test_required_keys_toml_table_header_with_inline_comment() {
2779 let rule = create_rule_with_required_keys(vec!["taxonomies"]);
2781 let content = "+++\ntitle = \"Test\"\n\n[taxonomies] # used by Hugo\ntags = [\"a\"]\n+++\n\n# Heading";
2782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2783 let result = rule.check(&ctx).unwrap();
2784
2785 assert!(
2786 result.is_empty(),
2787 "a table header with an inline comment must satisfy required-keys, got: {result:?}"
2788 );
2789 }
2790
2791 #[test]
2792 fn test_required_keys_toml_assignment_inside_table_does_not_satisfy() {
2793 let rule = create_rule_with_required_keys(vec!["date"]);
2795 let content = "+++\ntitle = \"Test\"\n\n[params]\ndate = \"2024-01-01\"\n+++\n\n# Heading";
2796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2797 let result = rule.check(&ctx).unwrap();
2798
2799 assert_eq!(result.len(), 1);
2800 assert!(result[0].message.contains("missing required key 'date'"));
2801 }
2802
2803 #[test]
2804 fn test_required_keys_yaml_quoted_key_with_colon_satisfies() {
2805 let rule = create_rule_with_required_keys(vec!["og:title"]);
2808 let content = "---\n\"og:title\": My post\n---\n\n# Heading";
2809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2810 let result = rule.check(&ctx).unwrap();
2811
2812 assert!(
2813 result.is_empty(),
2814 "a quoted YAML key containing a colon must satisfy required-keys, got: {result:?}"
2815 );
2816 }
2817
2818 #[test]
2819 fn test_yaml_quoted_key_with_colon_sorts_by_full_content() {
2820 let rule = create_enabled_rule();
2822 let content = "---\n\"og:title\": My post\nalpha: 1\n---\n\n# Heading";
2823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2824 let result = rule.check(&ctx).unwrap();
2825
2826 assert_eq!(result.len(), 1);
2827 assert!(
2828 result[0].message.contains("'alpha' should come before 'og:title'"),
2829 "sorting must use the full quoted key, got: {}",
2830 result[0].message
2831 );
2832 }
2833
2834 #[test]
2835 fn test_required_keys_toml_quoted_key_with_equals_satisfies() {
2836 let rule = create_rule_with_required_keys(vec!["a=b"]);
2839 let content = "+++\n\"a=b\" = 1\n+++\n\n# Heading";
2840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2841 let result = rule.check(&ctx).unwrap();
2842
2843 assert!(
2844 result.is_empty(),
2845 "a quoted TOML key containing '=' must satisfy required-keys, got: {result:?}"
2846 );
2847 }
2848}