1use super::flavor::{ConfigLoaded, ConfigValidated};
2use super::registry::{RULE_ALIAS_MAP, RuleRegistry, is_valid_rule_name, resolve_rule_name_alias};
3use super::source_tracking::{ConfigValidationWarning, SourcedConfig, SourcedRuleConfig};
4use std::collections::BTreeMap;
5use std::path::Path;
6
7pub fn validate_cli_rule_names(
13 enable: Option<&str>,
14 disable: Option<&str>,
15 extend_enable: Option<&str>,
16 extend_disable: Option<&str>,
17 fixable: Option<&str>,
18 unfixable: Option<&str>,
19) -> Vec<ConfigValidationWarning> {
20 let mut warnings = Vec::new();
21 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
22
23 let validate_list = |input: &str, flag_name: &str, warnings: &mut Vec<ConfigValidationWarning>| {
24 for name in input.split(',').map(str::trim).filter(|s| !s.is_empty()) {
25 if name.eq_ignore_ascii_case("all") {
27 continue;
28 }
29 if resolve_rule_name_alias(name).is_none() {
30 let message = if let Some(suggestion) = suggest_similar_key(name, &all_rule_names) {
31 let formatted = if suggestion.starts_with("MD") {
32 suggestion
33 } else {
34 suggestion.to_lowercase()
35 };
36 format!("Unknown rule in {flag_name}: {name} (did you mean: {formatted}?)")
37 } else {
38 format!("Unknown rule in {flag_name}: {name}")
39 };
40 warnings.push(ConfigValidationWarning {
41 message,
42 rule: Some(name.to_string()),
43 key: None,
44 });
45 }
46 }
47 };
48
49 if let Some(e) = enable {
50 validate_list(e, "--enable", &mut warnings);
51 }
52 if let Some(d) = disable {
53 validate_list(d, "--disable", &mut warnings);
54 }
55 if let Some(ee) = extend_enable {
56 validate_list(ee, "--extend-enable", &mut warnings);
57 }
58 if let Some(ed) = extend_disable {
59 validate_list(ed, "--extend-disable", &mut warnings);
60 }
61 if let Some(f) = fixable {
62 validate_list(f, "--fixable", &mut warnings);
63 }
64 if let Some(u) = unfixable {
65 validate_list(u, "--unfixable", &mut warnings);
66 }
67
68 warnings
69}
70
71pub(super) fn validate_config_sourced_internal<S>(
74 sourced: &SourcedConfig<S>,
75 registry: &RuleRegistry,
76) -> Vec<ConfigValidationWarning> {
77 let mut warnings = validate_config_sourced_impl(&sourced.rules, &sourced.unknown_keys, registry);
78
79 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
81
82 for rule_name in &sourced.global.enable.value {
83 if !is_valid_rule_name(rule_name) {
84 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
85 let formatted = if suggestion.starts_with("MD") {
86 suggestion
87 } else {
88 suggestion.to_lowercase()
89 };
90 format!("Unknown rule in global.enable: {rule_name} (did you mean: {formatted}?)")
91 } else {
92 format!("Unknown rule in global.enable: {rule_name}")
93 };
94 warnings.push(ConfigValidationWarning {
95 message,
96 rule: Some(rule_name.clone()),
97 key: None,
98 });
99 }
100 }
101
102 for rule_name in &sourced.global.disable.value {
103 if !is_valid_rule_name(rule_name) {
104 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
105 let formatted = if suggestion.starts_with("MD") {
106 suggestion
107 } else {
108 suggestion.to_lowercase()
109 };
110 format!("Unknown rule in global.disable: {rule_name} (did you mean: {formatted}?)")
111 } else {
112 format!("Unknown rule in global.disable: {rule_name}")
113 };
114 warnings.push(ConfigValidationWarning {
115 message,
116 rule: Some(rule_name.clone()),
117 key: None,
118 });
119 }
120 }
121
122 for rule_name in &sourced.global.extend_enable.value {
123 if !is_valid_rule_name(rule_name) {
124 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
125 let formatted = if suggestion.starts_with("MD") {
126 suggestion
127 } else {
128 suggestion.to_lowercase()
129 };
130 format!("Unknown rule in global.extend-enable: {rule_name} (did you mean: {formatted}?)")
131 } else {
132 format!("Unknown rule in global.extend-enable: {rule_name}")
133 };
134 warnings.push(ConfigValidationWarning {
135 message,
136 rule: Some(rule_name.clone()),
137 key: None,
138 });
139 }
140 }
141
142 for rule_name in &sourced.global.extend_disable.value {
143 if !is_valid_rule_name(rule_name) {
144 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
145 let formatted = if suggestion.starts_with("MD") {
146 suggestion
147 } else {
148 suggestion.to_lowercase()
149 };
150 format!("Unknown rule in global.extend-disable: {rule_name} (did you mean: {formatted}?)")
151 } else {
152 format!("Unknown rule in global.extend-disable: {rule_name}")
153 };
154 warnings.push(ConfigValidationWarning {
155 message,
156 rule: Some(rule_name.clone()),
157 key: None,
158 });
159 }
160 }
161
162 for rule_name in &sourced.global.fixable.value {
163 if !is_valid_rule_name(rule_name) {
164 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
165 let formatted = if suggestion.starts_with("MD") {
166 suggestion
167 } else {
168 suggestion.to_lowercase()
169 };
170 format!("Unknown rule in global.fixable: {rule_name} (did you mean: {formatted}?)")
171 } else {
172 format!("Unknown rule in global.fixable: {rule_name}")
173 };
174 warnings.push(ConfigValidationWarning {
175 message,
176 rule: Some(rule_name.clone()),
177 key: None,
178 });
179 }
180 }
181
182 for rule_name in &sourced.global.unfixable.value {
183 if !is_valid_rule_name(rule_name) {
184 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
185 let formatted = if suggestion.starts_with("MD") {
186 suggestion
187 } else {
188 suggestion.to_lowercase()
189 };
190 format!("Unknown rule in global.unfixable: {rule_name} (did you mean: {formatted}?)")
191 } else {
192 format!("Unknown rule in global.unfixable: {rule_name}")
193 };
194 warnings.push(ConfigValidationWarning {
195 message,
196 rule: Some(rule_name.clone()),
197 key: None,
198 });
199 }
200 }
201
202 warnings.extend(validate_code_block_tools(&sourced.code_block_tools.value));
203
204 warnings
205}
206
207fn validate_code_block_tools(config: &crate::code_block_tools::CodeBlockToolsConfig) -> Vec<ConfigValidationWarning> {
218 use crate::code_block_tools::{RUMDL_BUILTIN_TOOL, ToolRegistry, ToolSlot};
219
220 let mut warnings = Vec::new();
221 if config.languages.is_empty() {
222 return warnings;
223 }
224
225 let registry = ToolRegistry::new(config.tools.clone());
226 let known_tools: Vec<String> = registry.list_tools().into_iter().map(str::to_string).collect();
229
230 for (lang, lang_config) in &config.languages {
231 for (slot, slot_name, tool_ids) in [
232 (ToolSlot::Lint, "lint", &lang_config.lint),
233 (ToolSlot::Format, "format", &lang_config.format),
234 ] {
235 for tool_id in tool_ids {
236 if tool_id == RUMDL_BUILTIN_TOOL {
238 continue;
239 }
240
241 let message = if registry.resolve_id(tool_id, slot).is_none() {
246 if config.values_withheld {
247 let withheld = crate::config::WITHHELD;
248 format!("Unknown tool in code-block-tools.languages.{withheld}.{slot_name}: {withheld}")
249 } else if let Some(suggestion) = suggest_similar_key(tool_id, &known_tools) {
250 format!(
251 "Unknown tool in code-block-tools.languages.{lang}.{slot_name}: {tool_id} (did you mean: {suggestion}?)"
252 )
253 } else {
254 format!("Unknown tool in code-block-tools.languages.{lang}.{slot_name}: {tool_id}")
255 }
256 } else if slot == ToolSlot::Format && registry.fills_format_slot(tool_id) == Some(false) {
257 if config.values_withheld {
261 let withheld = crate::config::WITHHELD;
262 format!("Tool in code-block-tools.languages.{withheld}.format cannot format: {withheld}")
263 } else {
264 format!(
265 "Tool in code-block-tools.languages.{lang}.format cannot format: {tool_id} is a linter (move it to lint)"
266 )
267 }
268 } else {
269 continue;
270 };
271
272 warnings.push(ConfigValidationWarning {
273 message,
274 rule: None,
275 key: None,
276 });
277 }
278 }
279 }
280
281 warnings
282}
283
284fn validate_config_sourced_impl(
286 rules: &BTreeMap<String, SourcedRuleConfig>,
287 unknown_keys: &[(String, String, Option<String>)],
288 registry: &RuleRegistry,
289) -> Vec<ConfigValidationWarning> {
290 let mut warnings = Vec::new();
291 let known_rules = registry.rule_names();
292 for rule in rules.keys() {
294 if !known_rules.contains(rule) {
295 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
297 let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
298 let formatted_suggestion = if suggestion.starts_with("MD") {
300 suggestion
301 } else {
302 suggestion.to_lowercase()
303 };
304 format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
305 } else {
306 format!("Unknown rule in config: {rule}")
307 };
308 warnings.push(ConfigValidationWarning {
309 message,
310 rule: Some(rule.clone()),
311 key: None,
312 });
313 }
314 }
315 for (rule, rule_cfg) in rules {
317 if let Some(valid_keys) = registry.config_keys_for(rule) {
318 for key in rule_cfg.values.keys() {
319 if !valid_keys.contains(key) {
320 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
321 let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
322 format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
323 } else {
324 format!("Unknown option for rule {rule}: {key}")
325 };
326 warnings.push(ConfigValidationWarning {
327 message,
328 rule: Some(rule.clone()),
329 key: Some(key.clone()),
330 });
331 } else {
332 if let Some(expected) = registry.expected_value_for(rule, key) {
334 let actual = &rule_cfg.values[key].value;
335 if !toml_value_type_matches(expected, actual) {
336 warnings.push(ConfigValidationWarning {
337 message: format!(
338 "Type mismatch for {}.{}: expected {}, got {}",
339 rule,
340 key,
341 toml_type_name(expected),
342 toml_type_name(actual)
343 ),
344 rule: Some(rule.clone()),
345 key: Some(key.clone()),
346 });
347 }
348 }
349 }
350 }
351 }
352 }
353 let known_global_keys: Vec<String> = super::global_keys::GLOBAL_VALUE_KEYS
358 .iter()
359 .map(|k| (*k).to_string())
360 .chain(
361 ["per-file-ignores", "per-file-flavor", "extends"]
362 .into_iter()
363 .map(str::to_string),
364 )
365 .collect();
366
367 for (section, key, display_name) in unknown_keys {
368 let display_path = display_name.as_ref();
370
371 if section.contains("[global]") || section.contains("[tool.rumdl]") {
372 let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
373 if let Some(path) = display_path {
374 format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
375 } else {
376 format!("Unknown global option: {key} (did you mean: {suggestion}?)")
377 }
378 } else if let Some(path) = display_path {
379 format!("Unknown global option in {path}: {key}")
380 } else {
381 format!("Unknown global option: {key}")
382 };
383 warnings.push(ConfigValidationWarning {
384 message,
385 rule: None,
386 key: Some(key.clone()),
387 });
388 } else if !key.is_empty() {
389 let rule_name = section.trim_matches(|c| c == '[' || c == ']');
393 let message = if let Some(path) = display_path {
394 format!("Unknown option for rule {rule_name} in {path}: {key}")
395 } else {
396 format!("Unknown option for rule {rule_name}: {key}")
397 };
398 warnings.push(ConfigValidationWarning {
399 message,
400 rule: Some(rule_name.to_string()),
401 key: Some(key.clone()),
402 });
403 } else {
404 let rule_name = section.trim_matches(|c| c == '[' || c == ']');
406 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
407 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
408 let formatted_suggestion = if suggestion.starts_with("MD") {
410 suggestion
411 } else {
412 suggestion.to_lowercase()
413 };
414 if let Some(path) = display_path {
415 format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
416 } else {
417 format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
418 }
419 } else if let Some(path) = display_path {
420 format!("Unknown rule in {path}: {rule_name}")
421 } else {
422 format!("Unknown rule in config: {rule_name}")
423 };
424 warnings.push(ConfigValidationWarning {
425 message,
426 rule: None,
427 key: None,
428 });
429 }
430 }
431 warnings
432}
433
434pub(super) fn to_relative_display_path(path: &str) -> String {
440 let file_path = Path::new(path);
441
442 if let Ok(cwd) = std::env::current_dir() {
444 if let (Ok(canonical_file), Ok(canonical_cwd)) = (file_path.canonicalize(), cwd.canonicalize())
446 && let Ok(relative) = canonical_file.strip_prefix(&canonical_cwd)
447 {
448 return normalize_separators(relative.to_string_lossy().to_string());
449 }
450
451 if let Ok(relative) = file_path.strip_prefix(&cwd) {
453 return normalize_separators(relative.to_string_lossy().to_string());
454 }
455 }
456
457 normalize_separators(path.to_string())
459}
460
461fn normalize_separators(path: String) -> String {
467 if cfg!(windows) { path.replace('\\', "/") } else { path }
468}
469
470pub fn validate_config_sourced(
476 sourced: &SourcedConfig<ConfigLoaded>,
477 registry: &RuleRegistry,
478) -> Vec<ConfigValidationWarning> {
479 validate_config_sourced_internal(sourced, registry)
480}
481
482pub fn validate_config_sourced_validated(
486 sourced: &SourcedConfig<ConfigValidated>,
487 _registry: &RuleRegistry,
488) -> Vec<ConfigValidationWarning> {
489 sourced.validation_warnings.clone()
490}
491
492fn toml_type_name(val: &toml::Value) -> &'static str {
493 match val {
494 toml::Value::String(_) => "string",
495 toml::Value::Integer(_) => "integer",
496 toml::Value::Float(_) => "float",
497 toml::Value::Boolean(_) => "boolean",
498 toml::Value::Array(_) => "array",
499 toml::Value::Table(_) => "table",
500 toml::Value::Datetime(_) => "datetime",
501 }
502}
503
504fn levenshtein_distance(s1: &str, s2: &str) -> usize {
506 let len1 = s1.len();
507 let len2 = s2.len();
508
509 if len1 == 0 {
510 return len2;
511 }
512 if len2 == 0 {
513 return len1;
514 }
515
516 let s1_chars: Vec<char> = s1.chars().collect();
517 let s2_chars: Vec<char> = s2.chars().collect();
518
519 let mut prev_row: Vec<usize> = (0..=len2).collect();
520 let mut curr_row = vec![0; len2 + 1];
521
522 for i in 1..=len1 {
523 curr_row[0] = i;
524 for j in 1..=len2 {
525 let cost = usize::from(s1_chars[i - 1] != s2_chars[j - 1]);
526 curr_row[j] = (prev_row[j] + 1) .min(curr_row[j - 1] + 1) .min(prev_row[j - 1] + cost); }
530 std::mem::swap(&mut prev_row, &mut curr_row);
531 }
532
533 prev_row[len2]
534}
535
536pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
543 let unknown_lower = unknown.to_lowercase();
544 let max_distance = 2.max(unknown.len() / 3); let mut best_match: Option<(&String, usize)> = None;
547
548 for valid in valid_keys {
549 let valid_lower = valid.to_lowercase();
550 let distance = levenshtein_distance(&unknown_lower, &valid_lower);
551
552 if distance > max_distance {
553 continue;
554 }
555 let is_better = match &best_match {
556 Some((best_key, best_dist)) => distance < *best_dist || (distance == *best_dist && valid < *best_key),
557 None => true,
558 };
559 if is_better {
560 best_match = Some((valid, distance));
561 }
562 }
563
564 best_match.map(|(key, _)| key.clone())
565}
566
567fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
568 use toml::Value::{Array, Boolean, Datetime, Float, Integer, String, Table};
569 match (expected, actual) {
570 (String(_), String(_)) => true,
571 (Integer(_), Integer(_)) => true,
572 (Float(_), Float(_)) => true,
573 (Boolean(_), Boolean(_)) => true,
574 (Array(_), Array(_)) => true,
575 (Table(_), Table(_)) => true,
576 (Datetime(_), Datetime(_)) => true,
577 (Float(_), Integer(_)) => true,
579 _ => false,
580 }
581}
582
583#[cfg(test)]
584mod suggestion_tests {
585 use super::*;
586
587 fn keys(names: &[&str]) -> Vec<String> {
588 names.iter().map(|s| (*s).to_string()).collect()
589 }
590
591 #[test]
592 fn a_closer_key_wins_over_an_earlier_one() {
593 let candidates = keys(&["MD049", "MD013"]);
594 assert_eq!(suggest_similar_key("MD01", &candidates), Some("MD013".to_string()));
595 }
596
597 #[test]
598 fn equally_close_keys_resolve_to_the_smaller_name() {
599 for order in [["MD049", "MD009"], ["MD009", "MD049"]] {
602 assert_eq!(
603 suggest_similar_key("MD999", &keys(&order)),
604 Some("MD009".to_string()),
605 "suggestion changed with the caller's key order: {order:?}"
606 );
607 }
608 }
609
610 #[test]
611 fn a_key_beyond_the_edit_budget_is_no_suggestion() {
612 assert_eq!(suggest_similar_key("MD999", &keys(&["line-length"])), None);
613 }
614}
615
616#[cfg(test)]
617mod code_block_tool_tests {
618 use crate::code_block_tools::{CodeBlockToolsConfig, LanguageToolConfig, ToolDefinition};
619
620 fn config_with(lang: &str, lint: &[&str], format: &[&str]) -> CodeBlockToolsConfig {
621 let mut config = CodeBlockToolsConfig {
622 enabled: true,
623 ..Default::default()
624 };
625 config.languages.insert(
626 lang.to_string(),
627 LanguageToolConfig {
628 lint: lint.iter().map(|s| (*s).to_string()).collect(),
629 format: format.iter().map(|s| (*s).to_string()).collect(),
630 ..Default::default()
631 },
632 );
633 config
634 }
635
636 fn messages(config: &CodeBlockToolsConfig) -> Vec<String> {
637 super::validate_code_block_tools(config)
638 .into_iter()
639 .map(|w| w.message)
640 .collect()
641 }
642
643 #[test]
644 fn an_unknown_tool_id_is_reported_with_a_suggestion() {
645 let messages = messages(&config_with("python", &[], &["blackk"]));
646 assert_eq!(
647 messages,
648 vec!["Unknown tool in code-block-tools.languages.python.format: blackk (did you mean: black?)"]
649 );
650 }
651
652 #[test]
653 fn a_resolvable_tool_id_is_not_reported() {
654 assert!(messages(&config_with("python", &["ruff:check"], &["black"])).is_empty());
656 }
657
658 #[test]
659 fn a_bare_name_resolving_through_a_variant_is_not_reported() {
660 assert!(messages(&config_with("terraform", &["terraform"], &["terraform"])).is_empty());
663 }
664
665 #[test]
666 fn a_linter_in_a_format_slot_is_reported() {
667 let messages = messages(&config_with("python", &[], &["ruff:check"]));
668 assert_eq!(
669 messages,
670 vec![
671 "Tool in code-block-tools.languages.python.format cannot format: ruff:check is a linter (move it to lint)"
672 ]
673 );
674 }
675
676 #[test]
677 fn a_linter_in_a_lint_slot_is_not_reported() {
678 assert!(messages(&config_with("python", &["ruff:check"], &[])).is_empty());
679 }
680
681 #[test]
682 fn a_user_tool_shadowing_a_builtin_linter_may_format() {
683 let mut config = config_with("python", &[], &["ruff:check"]);
686 config.tools.insert(
687 "ruff:check".to_string(),
688 ToolDefinition {
689 command: vec!["my-formatter".to_string(), "-".to_string()],
690 stdin: true,
691 stdout: true,
692 lint_args: vec![],
693 format_args: vec![],
694 },
695 );
696 assert!(messages(&config).is_empty());
697 }
698
699 #[test]
700 fn a_user_tool_is_a_suggestion_candidate() {
701 let mut config = config_with("python", &[], &["my-formater"]);
702 config.tools.insert(
703 "my-formatter".to_string(),
704 ToolDefinition {
705 command: vec!["my-formatter".to_string(), "-".to_string()],
706 stdin: true,
707 stdout: true,
708 lint_args: vec![],
709 format_args: vec![],
710 },
711 );
712 assert_eq!(
713 messages(&config),
714 vec!["Unknown tool in code-block-tools.languages.python.format: my-formater (did you mean: my-formatter?)"]
715 );
716 }
717
718 #[test]
719 fn rumdls_own_markdown_linting_is_not_an_unknown_tool() {
720 assert!(messages(&config_with("markdown", &["rumdl"], &["rumdl"])).is_empty());
721 }
722
723 #[test]
724 fn a_withheld_section_names_neither_the_tool_nor_the_language() {
725 let mut config = config_with("python", &["blackk"], &["ruff:check"]);
726 config.values_withheld = true;
727 let messages = messages(&config);
728 assert_eq!(
729 messages,
730 vec![
731 "Unknown tool in code-block-tools.languages.<withheld>.lint: <withheld>",
732 "Tool in code-block-tools.languages.<withheld>.format cannot format: <withheld>",
733 ]
734 );
735 for message in &messages {
737 assert!(!message.contains("black"), "withheld text is inferable from: {message}");
738 assert!(!message.contains("ruff"), "withheld text is inferable from: {message}");
739 assert!(
740 !message.contains("python"),
741 "withheld text is inferable from: {message}"
742 );
743 }
744 }
745}