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::{ToolRegistry, ToolSlot, is_rumdl_builtin};
219
220 let mut warnings = Vec::new();
221
222 if config.on_missing_language_definition == crate::code_block_tools::OnMissing::Warn {
227 warnings.push(ConfigValidationWarning {
228 message: "code-block-tools.on-missing-language-definition: \"warn\" behaves as \"ignore\"; \
229 use \"fail\" to report a language with no tools"
230 .to_string(),
231 rule: None,
232 key: None,
233 });
234 }
235
236 if config.languages.is_empty() {
237 return warnings;
238 }
239
240 let registry = ToolRegistry::new(config.tools.clone());
241 let known_tools: Vec<String> = registry.list_tools().into_iter().map(str::to_string).collect();
244
245 for (lang, lang_config) in &config.languages {
246 for (slot, slot_name, tool_ids) in [
247 (ToolSlot::Lint, "lint", &lang_config.lint),
248 (ToolSlot::Format, "format", &lang_config.format),
249 ] {
250 for tool_id in tool_ids {
251 if is_rumdl_builtin(tool_id) && !(slot == ToolSlot::Format && tool_id == "rumdl:lint") {
253 continue;
254 }
255
256 let message = if !is_rumdl_builtin(tool_id) && registry.resolve_id(tool_id, slot).is_none() {
261 if config.values_withheld {
262 let withheld = crate::config::WITHHELD;
263 format!("Unknown tool in code-block-tools.languages.{withheld}.{slot_name}: {withheld}")
264 } else if let Some(suggestion) = suggest_similar_key(tool_id, &known_tools) {
265 format!(
266 "Unknown tool in code-block-tools.languages.{lang}.{slot_name}: {tool_id} (did you mean: {suggestion}?)"
267 )
268 } else {
269 format!("Unknown tool in code-block-tools.languages.{lang}.{slot_name}: {tool_id}")
270 }
271 } else if slot == ToolSlot::Format
272 && (tool_id == "rumdl:lint" || registry.fills_format_slot(tool_id) == Some(false))
273 {
274 if config.values_withheld {
278 let withheld = crate::config::WITHHELD;
279 format!("Tool in code-block-tools.languages.{withheld}.format cannot format: {withheld}")
280 } else {
281 format!(
282 "Tool in code-block-tools.languages.{lang}.format cannot format: {tool_id} is a linter (move it to lint)"
283 )
284 }
285 } else {
286 continue;
287 };
288
289 warnings.push(ConfigValidationWarning {
290 message,
291 rule: None,
292 key: None,
293 });
294 }
295 }
296 }
297
298 warnings
299}
300
301fn validate_config_sourced_impl(
303 rules: &BTreeMap<String, SourcedRuleConfig>,
304 unknown_keys: &[(String, String, Option<String>)],
305 registry: &RuleRegistry,
306) -> Vec<ConfigValidationWarning> {
307 let mut warnings = Vec::new();
308 let known_rules = registry.rule_names();
309 for rule in rules.keys() {
311 if !known_rules.contains(rule) {
312 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
314 let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
315 let formatted_suggestion = if suggestion.starts_with("MD") {
317 suggestion
318 } else {
319 suggestion.to_lowercase()
320 };
321 format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
322 } else {
323 format!("Unknown rule in config: {rule}")
324 };
325 warnings.push(ConfigValidationWarning {
326 message,
327 rule: Some(rule.clone()),
328 key: None,
329 });
330 }
331 }
332 for (rule, rule_cfg) in rules {
334 if let Some(valid_keys) = registry.config_keys_for(rule) {
335 for key in rule_cfg.values.keys() {
336 if !valid_keys.contains(key) {
337 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
338 let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
339 format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
340 } else {
341 format!("Unknown option for rule {rule}: {key}")
342 };
343 warnings.push(ConfigValidationWarning {
344 message,
345 rule: Some(rule.clone()),
346 key: Some(key.clone()),
347 });
348 } else {
349 if let Some(expected) = registry.expected_value_for(rule, key) {
351 let actual = &rule_cfg.values[key].value;
352 if !toml_value_type_matches(expected, actual) {
353 warnings.push(ConfigValidationWarning {
354 message: format!(
355 "Type mismatch for {}.{}: expected {}, got {}",
356 rule,
357 key,
358 toml_type_name(expected),
359 toml_type_name(actual)
360 ),
361 rule: Some(rule.clone()),
362 key: Some(key.clone()),
363 });
364 }
365 }
366 }
367 }
368 }
369 }
370 let known_global_keys: Vec<String> = super::global_keys::GLOBAL_VALUE_KEYS
375 .iter()
376 .map(|k| (*k).to_string())
377 .chain(
378 ["per-file-ignores", "per-file-flavor", "extends"]
379 .into_iter()
380 .map(str::to_string),
381 )
382 .collect();
383
384 for (section, key, display_name) in unknown_keys {
385 let display_path = display_name.as_ref();
387
388 if section.contains("[global]") || section.contains("[tool.rumdl]") {
389 let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
390 if let Some(path) = display_path {
391 format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
392 } else {
393 format!("Unknown global option: {key} (did you mean: {suggestion}?)")
394 }
395 } else if let Some(path) = display_path {
396 format!("Unknown global option in {path}: {key}")
397 } else {
398 format!("Unknown global option: {key}")
399 };
400 warnings.push(ConfigValidationWarning {
401 message,
402 rule: None,
403 key: Some(key.clone()),
404 });
405 } else if !key.is_empty() {
406 let rule_name = section.trim_matches(|c| c == '[' || c == ']');
410 let message = if let Some(path) = display_path {
411 format!("Unknown option for rule {rule_name} in {path}: {key}")
412 } else {
413 format!("Unknown option for rule {rule_name}: {key}")
414 };
415 warnings.push(ConfigValidationWarning {
416 message,
417 rule: Some(rule_name.to_string()),
418 key: Some(key.clone()),
419 });
420 } else {
421 let rule_name = section.trim_matches(|c| c == '[' || c == ']');
423 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
424 let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
425 let formatted_suggestion = if suggestion.starts_with("MD") {
427 suggestion
428 } else {
429 suggestion.to_lowercase()
430 };
431 if let Some(path) = display_path {
432 format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
433 } else {
434 format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
435 }
436 } else if let Some(path) = display_path {
437 format!("Unknown rule in {path}: {rule_name}")
438 } else {
439 format!("Unknown rule in config: {rule_name}")
440 };
441 warnings.push(ConfigValidationWarning {
442 message,
443 rule: None,
444 key: None,
445 });
446 }
447 }
448 warnings
449}
450
451pub(super) fn to_relative_display_path(path: &str) -> String {
457 let file_path = Path::new(path);
458
459 if let Ok(cwd) = std::env::current_dir() {
461 if let (Ok(canonical_file), Ok(canonical_cwd)) = (file_path.canonicalize(), cwd.canonicalize())
463 && let Ok(relative) = canonical_file.strip_prefix(&canonical_cwd)
464 {
465 return normalize_for_display(relative.to_string_lossy().to_string());
466 }
467
468 if let Ok(relative) = file_path.strip_prefix(&cwd) {
470 return normalize_for_display(relative.to_string_lossy().to_string());
471 }
472 }
473
474 normalize_for_display(path.to_string())
476}
477
478fn normalize_for_display(path: String) -> String {
486 if cfg!(windows) {
487 windows_display_path(&path)
488 } else {
489 path
490 }
491}
492
493pub(super) fn windows_display_path(path: &str) -> String {
496 crate::discovery::strip_verbatim_prefix(path).replace('\\', "/")
497}
498
499pub fn validate_config_sourced(
505 sourced: &SourcedConfig<ConfigLoaded>,
506 registry: &RuleRegistry,
507) -> Vec<ConfigValidationWarning> {
508 validate_config_sourced_internal(sourced, registry)
509}
510
511pub fn validate_config_sourced_validated(
515 sourced: &SourcedConfig<ConfigValidated>,
516 _registry: &RuleRegistry,
517) -> Vec<ConfigValidationWarning> {
518 sourced.validation_warnings.clone()
519}
520
521fn toml_type_name(val: &toml::Value) -> &'static str {
522 match val {
523 toml::Value::String(_) => "string",
524 toml::Value::Integer(_) => "integer",
525 toml::Value::Float(_) => "float",
526 toml::Value::Boolean(_) => "boolean",
527 toml::Value::Array(_) => "array",
528 toml::Value::Table(_) => "table",
529 toml::Value::Datetime(_) => "datetime",
530 }
531}
532
533fn levenshtein_distance(s1: &str, s2: &str) -> usize {
540 let s1_chars: Vec<char> = s1.chars().collect();
541 let s2_chars: Vec<char> = s2.chars().collect();
542
543 let len1 = s1_chars.len();
544 let len2 = s2_chars.len();
545
546 if len1 == 0 {
547 return len2;
548 }
549 if len2 == 0 {
550 return len1;
551 }
552
553 let mut prev_row: Vec<usize> = (0..=len2).collect();
554 let mut curr_row = vec![0; len2 + 1];
555
556 for i in 1..=len1 {
557 curr_row[0] = i;
558 for j in 1..=len2 {
559 let cost = usize::from(s1_chars[i - 1] != s2_chars[j - 1]);
560 curr_row[j] = (prev_row[j] + 1) .min(curr_row[j - 1] + 1) .min(prev_row[j - 1] + cost); }
564 std::mem::swap(&mut prev_row, &mut curr_row);
565 }
566
567 prev_row[len2]
568}
569
570pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
577 let unknown_lower = unknown.to_lowercase();
578 let max_distance = 2.max(unknown.chars().count() / 3);
583
584 let mut best_match: Option<(&String, usize)> = None;
585
586 for valid in valid_keys {
587 let valid_lower = valid.to_lowercase();
588 let distance = levenshtein_distance(&unknown_lower, &valid_lower);
589
590 if distance > max_distance {
591 continue;
592 }
593 let is_better = match &best_match {
594 Some((best_key, best_dist)) => distance < *best_dist || (distance == *best_dist && valid < *best_key),
595 None => true,
596 };
597 if is_better {
598 best_match = Some((valid, distance));
599 }
600 }
601
602 best_match.map(|(key, _)| key.clone())
603}
604
605fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
606 use toml::Value::{Array, Boolean, Datetime, Float, Integer, String, Table};
607 match (expected, actual) {
608 (String(_), String(_)) => true,
609 (Integer(_), Integer(_)) => true,
610 (Float(_), Float(_)) => true,
611 (Boolean(_), Boolean(_)) => true,
612 (Array(_), Array(_)) => true,
613 (Table(_), Table(_)) => true,
614 (Datetime(_), Datetime(_)) => true,
615 (Float(_), Integer(_)) => true,
617 _ => false,
618 }
619}
620
621#[cfg(test)]
622mod suggestion_tests {
623 use super::*;
624
625 fn keys(names: &[&str]) -> Vec<String> {
626 names.iter().map(|s| (*s).to_string()).collect()
627 }
628
629 #[test]
630 fn a_closer_key_wins_over_an_earlier_one() {
631 let candidates = keys(&["MD049", "MD013"]);
632 assert_eq!(suggest_similar_key("MD01", &candidates), Some("MD013".to_string()));
633 }
634
635 #[test]
636 fn equally_close_keys_resolve_to_the_smaller_name() {
637 for order in [["MD049", "MD009"], ["MD009", "MD049"]] {
640 assert_eq!(
641 suggest_similar_key("MD999", &keys(&order)),
642 Some("MD009".to_string()),
643 "suggestion changed with the caller's key order: {order:?}"
644 );
645 }
646 }
647
648 #[test]
649 fn a_key_beyond_the_edit_budget_is_no_suggestion() {
650 assert_eq!(suggest_similar_key("MD999", &keys(&["line-length"])), None);
651 }
652
653 #[test]
654 fn multibyte_character_no_panic() {
655 assert_eq!(suggest_similar_key("β", &keys(&["MD049", "MD009"])), None);
656 }
657
658 #[test]
662 fn distance_to_a_multibyte_key_is_not_zero() {
663 assert_eq!(levenshtein_distance("md013", "β"), 5);
664 assert_eq!(levenshtein_distance("md013", "ππ"), 5);
665 assert_eq!(levenshtein_distance("", "β"), 1);
666 }
667
668 #[test]
671 fn distance_is_symmetric_across_encodings() {
672 for (a, b) in [("md013", "β"), ("cafΓ©", "cafe"), ("β", "MD049"), ("ζ₯ζ¬θͺ", "md013")] {
673 assert_eq!(
674 levenshtein_distance(a, b),
675 levenshtein_distance(b, a),
676 "distance between {a:?} and {b:?} depends on argument order"
677 );
678 }
679 }
680
681 #[test]
684 fn a_multibyte_key_gets_no_wider_edit_budget() {
685 assert_eq!(suggest_similar_key("βββββ", &keys(&["MD001", "MD049"])), None);
686 assert_eq!(suggest_similar_key("πππ", &keys(&["MD001", "MD049"])), None);
687 }
688
689 #[test]
691 fn ascii_suggestions_are_unchanged() {
692 assert_eq!(
693 suggest_similar_key("line-lenght", &keys(&["line-length"])),
694 Some("line-length".to_string())
695 );
696 assert_eq!(levenshtein_distance("md013", "md009"), 2);
697 assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
698 }
699
700 #[test]
703 fn a_non_ascii_typo_of_a_real_key_still_resolves() {
704 assert_eq!(
705 suggest_similar_key("lineβlength", &keys(&["line-length"])),
706 Some("line-length".to_string()),
707 "one em dash for one hyphen is a single substitution"
708 );
709 }
710}
711
712#[cfg(test)]
713mod code_block_tool_tests {
714 use crate::code_block_tools::{CodeBlockToolsConfig, LanguageToolConfig, ToolDefinition};
715
716 fn config_with(lang: &str, lint: &[&str], format: &[&str]) -> CodeBlockToolsConfig {
717 let mut config = CodeBlockToolsConfig {
718 enabled: true,
719 ..Default::default()
720 };
721 config.languages.insert(
722 lang.to_string(),
723 LanguageToolConfig {
724 lint: lint.iter().map(|s| (*s).to_string()).collect(),
725 format: format.iter().map(|s| (*s).to_string()).collect(),
726 ..Default::default()
727 },
728 );
729 config
730 }
731
732 fn messages(config: &CodeBlockToolsConfig) -> Vec<String> {
733 super::validate_code_block_tools(config)
734 .into_iter()
735 .map(|w| w.message)
736 .collect()
737 }
738
739 #[test]
740 fn an_unknown_tool_id_is_reported_with_a_suggestion() {
741 let messages = messages(&config_with("python", &[], &["blackk"]));
742 assert_eq!(
743 messages,
744 vec!["Unknown tool in code-block-tools.languages.python.format: blackk (did you mean: black?)"]
745 );
746 }
747
748 #[test]
749 fn a_resolvable_tool_id_is_not_reported() {
750 assert!(messages(&config_with("python", &["ruff:check"], &["black"])).is_empty());
752 }
753
754 #[test]
755 fn a_bare_name_resolving_through_a_variant_is_not_reported() {
756 assert!(messages(&config_with("terraform", &["terraform"], &["terraform"])).is_empty());
759 }
760
761 #[test]
762 fn a_linter_in_a_format_slot_is_reported() {
763 let messages = messages(&config_with("python", &[], &["ruff:check"]));
764 assert_eq!(
765 messages,
766 vec![
767 "Tool in code-block-tools.languages.python.format cannot format: ruff:check is a linter (move it to lint)"
768 ]
769 );
770 }
771
772 #[test]
773 fn a_linter_in_a_lint_slot_is_not_reported() {
774 assert!(messages(&config_with("python", &["ruff:check"], &[])).is_empty());
775 }
776
777 #[test]
778 fn a_user_tool_shadowing_a_builtin_linter_may_format() {
779 let mut config = config_with("python", &[], &["ruff:check"]);
782 config.tools.insert(
783 "ruff:check".to_string(),
784 ToolDefinition {
785 command: vec!["my-formatter".to_string(), "-".to_string()],
786 stdin: true,
787 stdout: true,
788 lint_args: vec![],
789 format_args: vec![],
790 },
791 );
792 assert!(messages(&config).is_empty());
793 }
794
795 #[test]
796 fn a_user_tool_is_a_suggestion_candidate() {
797 let mut config = config_with("python", &[], &["my-formater"]);
798 config.tools.insert(
799 "my-formatter".to_string(),
800 ToolDefinition {
801 command: vec!["my-formatter".to_string(), "-".to_string()],
802 stdin: true,
803 stdout: true,
804 lint_args: vec![],
805 format_args: vec![],
806 },
807 );
808 assert_eq!(
809 messages(&config),
810 vec!["Unknown tool in code-block-tools.languages.python.format: my-formater (did you mean: my-formatter?)"]
811 );
812 }
813
814 #[test]
815 fn rumdls_own_markdown_linting_is_not_an_unknown_tool() {
816 assert!(messages(&config_with("markdown", &["rumdl"], &["rumdl"])).is_empty());
817 }
818
819 #[test]
820 fn explicit_tool_variants_are_validated_in_their_slots() {
821 for (language, lint, format) in [
822 ("markdown", vec!["rumdl:lint"], vec!["rumdl:format"]),
823 ("javascript", vec!["oxfmt:lint"], vec!["oxfmt:format"]),
824 (
825 "html",
826 vec!["djlint:html:lint", "djlint:html:format-check"],
827 vec!["djlint:html:format"],
828 ),
829 (
830 "jinja",
831 vec!["djlint:jinja:lint", "djlint:jinja:format-check"],
832 vec!["djlint:jinja:format"],
833 ),
834 (
835 "shell",
836 vec!["shuck:lint", "shuck:format-check"],
837 vec!["shuck:lint-fix", "shuck:format"],
838 ),
839 ] {
840 assert!(messages(&config_with(language, &lint, &format)).is_empty());
841 }
842 for id in [
843 "rumdl:lint",
844 "oxfmt:lint",
845 "djlint:html:format-check",
846 "djlint:jinja:format-check",
847 "shuck:format-check",
848 ] {
849 let warnings = messages(&config_with("markdown", &[], &[id]));
850 assert_eq!(warnings.len(), 1);
851 assert!(warnings[0].contains("cannot format"), "{warnings:?}");
852 }
853 }
854
855 #[test]
856 fn a_withheld_section_names_neither_the_tool_nor_the_language() {
857 let mut config = config_with("python", &["blackk"], &["ruff:check"]);
858 config.values_withheld = true;
859 let messages = messages(&config);
860 assert_eq!(
861 messages,
862 vec![
863 "Unknown tool in code-block-tools.languages.<withheld>.lint: <withheld>",
864 "Tool in code-block-tools.languages.<withheld>.format cannot format: <withheld>",
865 ]
866 );
867 for message in &messages {
869 assert!(!message.contains("black"), "withheld text is inferable from: {message}");
870 assert!(!message.contains("ruff"), "withheld text is inferable from: {message}");
871 assert!(
872 !message.contains("python"),
873 "withheld text is inferable from: {message}"
874 );
875 }
876 }
877}