1use super::config::ToolDefinition;
7use super::processor::RUMDL_BUILTIN_TOOL;
8use std::collections::BTreeMap;
9use std::collections::HashMap;
10use std::sync::LazyLock;
11
12pub struct ToolRegistry {
14 user_tools: BTreeMap<String, ToolDefinition>,
16}
17
18#[derive(Debug, Copy, Clone, PartialEq, Eq)]
20pub enum ToolSlot {
21 Lint,
23 Format,
25}
26
27impl ToolRegistry {
28 pub fn new(user_tools: BTreeMap<String, ToolDefinition>) -> Self {
30 Self { user_tools }
31 }
32
33 pub fn get(&self, tool_id: &str) -> Option<&ToolDefinition> {
37 self.user_tools.get(tool_id).or_else(|| BUILTIN_TOOLS.get(tool_id))
38 }
39
40 pub fn contains(&self, tool_id: &str) -> bool {
42 self.user_tools.contains_key(tool_id) || BUILTIN_TOOLS.contains_key(tool_id)
43 }
44
45 pub fn lint_mode(&self, tool_id: &str) -> Option<BuiltinLintMode> {
50 if self.user_tools.contains_key(tool_id) {
51 return None;
52 }
53 builtin_lint_mode(tool_id)
54 }
55
56 pub fn resolve_id(&self, tool_id: &str, slot: ToolSlot) -> Option<String> {
62 if tool_id.contains(':') {
64 return self.contains(tool_id).then(|| tool_id.to_string());
65 }
66
67 let suffixes = match slot {
68 ToolSlot::Format => &["format", "fmt", "fix", "reformat"][..],
69 ToolSlot::Lint => &["lint", "check"][..],
70 };
71 for suffix in suffixes {
72 let qualified = format!("{tool_id}:{suffix}");
73 if self.contains(&qualified) {
74 return Some(qualified);
75 }
76 }
77
78 if self.contains(tool_id) {
79 return Some(tool_id.to_string());
80 }
81
82 if slot == ToolSlot::Lint {
86 for suffix in ["format", "fmt"] {
87 let qualified = format!("{tool_id}:{suffix}");
88 if self.contains(&qualified) {
89 return Some(qualified);
90 }
91 }
92 }
93
94 None
95 }
96
97 pub fn resolve(&self, tool_id: &str, slot: ToolSlot) -> Option<&ToolDefinition> {
99 self.resolve_id(tool_id, slot).and_then(|id| self.get(&id))
100 }
101
102 pub fn fills_format_slot(&self, tool_id: &str) -> Option<bool> {
109 let resolved = self.resolve_id(tool_id, ToolSlot::Format)?;
110 Some(self.user_tools.contains_key(&resolved) || builtin_tool_formats(&resolved) == Some(true))
111 }
112
113 pub fn list_tools(&self) -> Vec<&str> {
115 let mut tools: Vec<&str> = self.user_tools.keys().map(std::string::String::as_str).collect();
116 for key in BUILTIN_TOOLS.keys() {
117 if !self.user_tools.contains_key(*key) {
118 tools.push(key);
119 }
120 }
121 tools.sort_unstable();
122 tools
123 }
124}
125
126pub fn builtin_tool_ids() -> Vec<&'static str> {
133 let mut ids: Vec<&'static str> = BUILTIN_TOOLS.keys().copied().collect();
134 ids.sort_unstable();
135 ids
136}
137
138impl Default for ToolRegistry {
139 fn default() -> Self {
140 Self::new(BTreeMap::new())
141 }
142}
143
144static BUILTIN_TOOLS: LazyLock<HashMap<&'static str, ToolDefinition>> = LazyLock::new(|| {
148 let mut m = HashMap::new();
149
150 m.insert(
152 "ruff:check",
153 ToolDefinition {
154 command: vec![
155 "ruff".to_string(),
156 "check".to_string(),
157 "--output-format=concise".to_string(),
158 "--stdin-filename=_.py".to_string(),
159 "-".to_string(),
160 ],
161 stdin: true,
162 stdout: true,
163 lint_args: vec![],
164 format_args: vec![],
165 },
166 );
167
168 m.insert(
169 "ruff:format",
170 ToolDefinition {
171 command: vec![
172 "ruff".to_string(),
173 "format".to_string(),
174 "--stdin-filename=_.py".to_string(),
175 "-".to_string(),
176 ],
177 stdin: true,
178 stdout: true,
179 lint_args: vec![],
180 format_args: vec![],
181 },
182 );
183
184 m.insert(
186 "black",
187 ToolDefinition {
188 command: vec!["black".to_string(), "--quiet".to_string(), "-".to_string()],
189 stdin: true,
190 stdout: true,
191 lint_args: vec![],
192 format_args: vec![],
193 },
194 );
195
196 m.insert(
198 "prettier",
199 ToolDefinition {
200 command: vec!["prettier".to_string(), "--stdin-filepath=_.js".to_string()],
201 stdin: true,
202 stdout: true,
203 lint_args: vec![],
204 format_args: vec![],
205 },
206 );
207
208 m.insert(
209 "prettier:json",
210 ToolDefinition {
211 command: vec!["prettier".to_string(), "--stdin-filepath=_.json".to_string()],
212 stdin: true,
213 stdout: true,
214 lint_args: vec![],
215 format_args: vec![],
216 },
217 );
218
219 m.insert(
220 "prettier:yaml",
221 ToolDefinition {
222 command: vec!["prettier".to_string(), "--stdin-filepath=_.yaml".to_string()],
223 stdin: true,
224 stdout: true,
225 lint_args: vec![],
226 format_args: vec![],
227 },
228 );
229
230 m.insert(
231 "prettier:html",
232 ToolDefinition {
233 command: vec!["prettier".to_string(), "--stdin-filepath=_.html".to_string()],
234 stdin: true,
235 stdout: true,
236 lint_args: vec![],
237 format_args: vec![],
238 },
239 );
240
241 m.insert(
242 "prettier:css",
243 ToolDefinition {
244 command: vec!["prettier".to_string(), "--stdin-filepath=_.css".to_string()],
245 stdin: true,
246 stdout: true,
247 lint_args: vec![],
248 format_args: vec![],
249 },
250 );
251
252 m.insert(
253 "prettier:markdown",
254 ToolDefinition {
255 command: vec!["prettier".to_string(), "--stdin-filepath=_.md".to_string()],
256 stdin: true,
257 stdout: true,
258 lint_args: vec![],
259 format_args: vec![],
260 },
261 );
262
263 m.insert(
268 "shellcheck",
269 ToolDefinition {
270 command: vec!["shellcheck".to_string(), "--shell=bash".to_string(), "-".to_string()],
271 stdin: true,
272 stdout: true,
273 lint_args: vec![],
274 format_args: vec![],
275 },
276 );
277
278 m.insert(
280 "shfmt",
281 ToolDefinition {
282 command: vec!["shfmt".to_string()],
283 stdin: true,
284 stdout: true,
285 lint_args: vec![],
286 format_args: vec![],
287 },
288 );
289
290 m.insert(
299 "shuck",
300 ToolDefinition {
301 command: vec![
302 "shuck".to_string(),
303 "check".to_string(),
304 "--output-format".to_string(),
305 "concise".to_string(),
306 "-".to_string(),
307 ],
308 stdin: true,
309 stdout: true,
310 lint_args: vec![],
311 format_args: vec![],
312 },
313 );
314
315 m.insert(
316 "shuck:format",
317 ToolDefinition {
318 command: vec!["shuck".to_string(), "format".to_string(), "-".to_string()],
319 stdin: true,
320 stdout: true,
321 lint_args: vec![],
322 format_args: vec![],
323 },
324 );
325
326 m.insert(
328 "rustfmt",
329 ToolDefinition {
330 command: vec!["rustfmt".to_string()],
331 stdin: true,
332 stdout: true,
333 lint_args: vec![],
334 format_args: vec![],
335 },
336 );
337
338 m.insert(
340 "gofmt",
341 ToolDefinition {
342 command: vec!["gofmt".to_string()],
343 stdin: true,
344 stdout: true,
345 lint_args: vec![],
346 format_args: vec![],
347 },
348 );
349
350 m.insert(
352 "goimports",
353 ToolDefinition {
354 command: vec!["goimports".to_string()],
355 stdin: true,
356 stdout: true,
357 lint_args: vec![],
358 format_args: vec![],
359 },
360 );
361
362 m.insert(
364 "clang-format",
365 ToolDefinition {
366 command: vec!["clang-format".to_string()],
367 stdin: true,
368 stdout: true,
369 lint_args: vec![],
370 format_args: vec![],
371 },
372 );
373
374 m.insert(
380 "sqlfluff:lint",
381 ToolDefinition {
382 command: vec![
383 "sqlfluff".to_string(),
384 "lint".to_string(),
385 "--dialect".to_string(),
386 "ansi".to_string(),
387 "--format".to_string(),
388 "github-annotation-native".to_string(),
389 "-".to_string(),
390 ],
391 stdin: true,
392 stdout: true,
393 lint_args: vec![],
394 format_args: vec![],
395 },
396 );
397
398 m.insert(
399 "sqlfluff:fix",
400 ToolDefinition {
401 command: vec![
402 "sqlfluff".to_string(),
403 "fix".to_string(),
404 "--dialect".to_string(),
405 "ansi".to_string(),
406 "-".to_string(),
407 ],
408 stdin: true,
409 stdout: true,
410 lint_args: vec![],
411 format_args: vec![],
412 },
413 );
414
415 m.insert(
417 "jq",
418 ToolDefinition {
419 command: vec!["jq".to_string(), ".".to_string()],
420 stdin: true,
421 stdout: true,
422 lint_args: vec![],
423 format_args: vec![],
424 },
425 );
426
427 m.insert(
429 "yamlfmt",
430 ToolDefinition {
431 command: vec!["yamlfmt".to_string()],
432 stdin: true,
433 stdout: true,
434 lint_args: vec![],
435 format_args: vec!["-".to_string()],
436 },
437 );
438
439 m.insert(
441 "taplo",
442 ToolDefinition {
443 command: vec!["taplo".to_string(), "fmt".to_string(), "-".to_string()],
444 stdin: true,
445 stdout: true,
446 lint_args: vec![],
447 format_args: vec![],
448 },
449 );
450
451 let terraform_fmt = || ToolDefinition {
456 command: vec!["terraform".to_string(), "fmt".to_string(), "-".to_string()],
457 stdin: true,
458 stdout: true,
459 lint_args: vec![],
460 format_args: vec![],
461 };
462 m.insert("terraform:format", terraform_fmt());
463 m.insert("terraform-fmt", terraform_fmt());
464
465 m.insert(
467 "nixfmt",
468 ToolDefinition {
469 command: vec!["nixfmt".to_string(), "-".to_string()],
470 stdin: true,
471 stdout: true,
472 lint_args: vec![],
473 format_args: vec![],
474 },
475 );
476
477 m.insert(
479 "stylua",
480 ToolDefinition {
481 command: vec!["stylua".to_string(), "-".to_string()],
482 stdin: true,
483 stdout: true,
484 lint_args: vec![],
485 format_args: vec![],
486 },
487 );
488
489 m.insert(
491 "ormolu",
492 ToolDefinition {
493 command: vec!["ormolu".to_string(), "--stdin-input-file=_.hs".to_string()],
494 stdin: true,
495 stdout: true,
496 lint_args: vec![],
497 format_args: vec![],
498 },
499 );
500
501 m.insert(
503 "elm-format",
504 ToolDefinition {
505 command: vec!["elm-format".to_string(), "--stdin".to_string()],
506 stdin: true,
507 stdout: true,
508 lint_args: vec![],
509 format_args: vec![],
510 },
511 );
512
513 m.insert(
515 "swift-format",
516 ToolDefinition {
517 command: vec!["swift-format".to_string(), "format".to_string(), "-".to_string()],
518 stdin: true,
519 stdout: true,
520 lint_args: vec![],
521 format_args: vec![],
522 },
523 );
524
525 m.insert(
527 "ktfmt",
528 ToolDefinition {
529 command: vec!["ktfmt".to_string(), "-".to_string()],
530 stdin: true,
531 stdout: true,
532 lint_args: vec![],
533 format_args: vec![],
534 },
535 );
536
537 const DJLINT_OUTPUT_FORMAT: &str = "{filename}:{line}: {code} {message}";
545
546 m.insert(
547 "djlint",
548 ToolDefinition {
549 command: vec![
550 "djlint".to_string(),
551 "-".to_string(),
552 "--linter-output-format".to_string(),
553 DJLINT_OUTPUT_FORMAT.to_string(),
554 ],
555 stdin: true,
556 stdout: true,
557 lint_args: vec![],
558 format_args: vec!["--reformat".to_string()],
559 },
560 );
561
562 m.insert(
563 "djlint:lint",
564 ToolDefinition {
565 command: vec![
566 "djlint".to_string(),
567 "-".to_string(),
568 "--linter-output-format".to_string(),
569 DJLINT_OUTPUT_FORMAT.to_string(),
570 ],
571 stdin: true,
572 stdout: true,
573 lint_args: vec![],
574 format_args: vec![],
575 },
576 );
577
578 m.insert(
579 "djlint:reformat",
580 ToolDefinition {
581 command: vec!["djlint".to_string(), "-".to_string(), "--reformat".to_string()],
582 stdin: true,
583 stdout: true,
584 lint_args: vec![],
585 format_args: vec![],
586 },
587 );
588
589 m.insert(
591 "beautysh",
592 ToolDefinition {
593 command: vec!["beautysh".to_string(), "-".to_string()],
594 stdin: true,
595 stdout: true,
596 lint_args: vec![],
597 format_args: vec![],
598 },
599 );
600
601 m.insert(
603 "tombi",
604 ToolDefinition {
605 command: vec!["tombi".to_string(), "lint".to_string(), "-".to_string()],
606 stdin: true,
607 stdout: true,
608 lint_args: vec![],
609 format_args: vec![],
610 },
611 );
612
613 m.insert(
614 "tombi:format",
615 ToolDefinition {
616 command: vec!["tombi".to_string(), "format".to_string(), "-".to_string()],
617 stdin: true,
618 stdout: true,
619 lint_args: vec![],
620 format_args: vec![],
621 },
622 );
623
624 m.insert(
625 "tombi:lint",
626 ToolDefinition {
627 command: vec!["tombi".to_string(), "lint".to_string(), "-".to_string()],
628 stdin: true,
629 stdout: true,
630 lint_args: vec![],
631 format_args: vec![],
632 },
633 );
634
635 m.insert(
637 "oxfmt",
638 ToolDefinition {
639 command: vec!["oxfmt".to_string(), "--stdin-filepath=_.js".to_string()],
640 stdin: true,
641 stdout: true,
642 lint_args: vec![],
643 format_args: vec![],
644 },
645 );
646
647 m.insert(
648 "oxfmt:js",
649 ToolDefinition {
650 command: vec!["oxfmt".to_string(), "--stdin-filepath=_.js".to_string()],
651 stdin: true,
652 stdout: true,
653 lint_args: vec![],
654 format_args: vec![],
655 },
656 );
657
658 m.insert(
659 "oxfmt:ts",
660 ToolDefinition {
661 command: vec!["oxfmt".to_string(), "--stdin-filepath=_.ts".to_string()],
662 stdin: true,
663 stdout: true,
664 lint_args: vec![],
665 format_args: vec![],
666 },
667 );
668
669 m.insert(
670 "oxfmt:jsx",
671 ToolDefinition {
672 command: vec!["oxfmt".to_string(), "--stdin-filepath=_.jsx".to_string()],
673 stdin: true,
674 stdout: true,
675 lint_args: vec![],
676 format_args: vec![],
677 },
678 );
679
680 m.insert(
681 "oxfmt:tsx",
682 ToolDefinition {
683 command: vec!["oxfmt".to_string(), "--stdin-filepath=_.tsx".to_string()],
684 stdin: true,
685 stdout: true,
686 lint_args: vec![],
687 format_args: vec![],
688 },
689 );
690
691 m.insert(
692 "oxfmt:json",
693 ToolDefinition {
694 command: vec!["oxfmt".to_string(), "--stdin-filepath=_.json".to_string()],
695 stdin: true,
696 stdout: true,
697 lint_args: vec![],
698 format_args: vec![],
699 },
700 );
701
702 m.insert(
703 "oxfmt:css",
704 ToolDefinition {
705 command: vec!["oxfmt".to_string(), "--stdin-filepath=_.css".to_string()],
706 stdin: true,
707 stdout: true,
708 lint_args: vec![],
709 format_args: vec![],
710 },
711 );
712
713 let deno_fmt = |ext: &str| ToolDefinition {
716 command: vec![
717 "deno".to_string(),
718 "fmt".to_string(),
719 format!("--ext={ext}"),
720 "-".to_string(),
721 ],
722 stdin: true,
723 stdout: true,
724 lint_args: vec![],
725 format_args: vec![],
726 };
727 m.insert("deno-fmt", deno_fmt("ts"));
728 m.insert("deno-fmt:ts", deno_fmt("ts"));
729 m.insert("deno-fmt:js", deno_fmt("js"));
730 m.insert("deno-fmt:json", deno_fmt("json"));
731 m.insert("deno-fmt:jsonc", deno_fmt("jsonc"));
732 m.insert("deno-fmt:md", deno_fmt("md"));
733
734 m
735});
736
737#[derive(Debug, Clone, Copy, PartialEq, Eq)]
739enum ToolKind {
740 Lint,
741 Format,
742 Both,
743}
744
745impl ToolKind {
746 const fn label(self) -> &'static str {
747 match self {
748 ToolKind::Lint => "Lint",
749 ToolKind::Format => "Format",
750 ToolKind::Both => "Both",
751 }
752 }
753}
754
755#[derive(Debug, Clone, Copy, PartialEq, Eq)]
757pub enum BuiltinLintMode {
758 Diagnostics,
760 FormatCheck,
770}
771
772pub fn builtin_lint_mode(tool_id: &str) -> Option<BuiltinLintMode> {
774 BUILTIN_TOOLS_DOCS
775 .iter()
776 .find(|m| m.id == tool_id && m.runtime)
777 .map(|m| match m.kind {
778 ToolKind::Lint | ToolKind::Both => BuiltinLintMode::Diagnostics,
779 ToolKind::Format => BuiltinLintMode::FormatCheck,
780 })
781}
782
783pub fn builtin_tool_formats(tool_id: &str) -> Option<bool> {
789 BUILTIN_TOOLS_DOCS
790 .iter()
791 .find(|m| m.id == tool_id && m.runtime)
792 .map(|m| matches!(m.kind, ToolKind::Format | ToolKind::Both))
793}
794
795struct ToolDocMeta {
809 id: &'static str,
810 language: &'static str,
811 kind: ToolKind,
812 doc_group: &'static str,
814 display_command: Option<&'static str>,
816 runtime: bool,
818}
819
820const BUILTIN_TOOLS_DOCS: &[ToolDocMeta] = &[
822 ToolDocMeta {
823 id: "ruff:check",
824 language: "Python",
825 kind: ToolKind::Lint,
826 doc_group: "ruff:check",
827 display_command: Some("ruff check --output-format=concise -"),
828 runtime: true,
829 },
830 ToolDocMeta {
831 id: "ruff:format",
832 language: "Python",
833 kind: ToolKind::Format,
834 doc_group: "ruff:format",
835 display_command: Some("ruff format -"),
836 runtime: true,
837 },
838 ToolDocMeta {
839 id: "black",
840 language: "Python",
841 kind: ToolKind::Format,
842 doc_group: "black",
843 display_command: None,
844 runtime: true,
845 },
846 ToolDocMeta {
847 id: "prettier",
848 language: "Multi",
849 kind: ToolKind::Format,
850 doc_group: "prettier",
851 display_command: Some("prettier --stdin-filepath=_.EXT"),
852 runtime: true,
853 },
854 ToolDocMeta {
855 id: "prettier:json",
856 language: "Multi",
857 kind: ToolKind::Format,
858 doc_group: "prettier",
859 display_command: Some("prettier --stdin-filepath=_.EXT"),
860 runtime: true,
861 },
862 ToolDocMeta {
863 id: "prettier:yaml",
864 language: "Multi",
865 kind: ToolKind::Format,
866 doc_group: "prettier",
867 display_command: Some("prettier --stdin-filepath=_.EXT"),
868 runtime: true,
869 },
870 ToolDocMeta {
871 id: "prettier:html",
872 language: "Multi",
873 kind: ToolKind::Format,
874 doc_group: "prettier",
875 display_command: Some("prettier --stdin-filepath=_.EXT"),
876 runtime: true,
877 },
878 ToolDocMeta {
879 id: "prettier:css",
880 language: "Multi",
881 kind: ToolKind::Format,
882 doc_group: "prettier",
883 display_command: Some("prettier --stdin-filepath=_.EXT"),
884 runtime: true,
885 },
886 ToolDocMeta {
887 id: "prettier:markdown",
888 language: "Multi",
889 kind: ToolKind::Format,
890 doc_group: "prettier",
891 display_command: Some("prettier --stdin-filepath=_.EXT"),
892 runtime: true,
893 },
894 ToolDocMeta {
895 id: "shellcheck",
896 language: "Shell",
897 kind: ToolKind::Lint,
898 doc_group: "shellcheck",
899 display_command: None,
900 runtime: true,
901 },
902 ToolDocMeta {
903 id: "shfmt",
904 language: "Shell",
905 kind: ToolKind::Format,
906 doc_group: "shfmt",
907 display_command: None,
908 runtime: true,
909 },
910 ToolDocMeta {
911 id: "shuck",
912 language: "Shell",
913 kind: ToolKind::Lint,
914 doc_group: "shuck",
915 display_command: None,
916 runtime: true,
917 },
918 ToolDocMeta {
919 id: "shuck:format",
920 language: "Shell",
921 kind: ToolKind::Format,
922 doc_group: "shuck:format",
923 display_command: None,
924 runtime: true,
925 },
926 ToolDocMeta {
927 id: "rustfmt",
928 language: "Rust",
929 kind: ToolKind::Format,
930 doc_group: "rustfmt",
931 display_command: None,
932 runtime: true,
933 },
934 ToolDocMeta {
935 id: "gofmt",
936 language: "Go",
937 kind: ToolKind::Format,
938 doc_group: "gofmt",
939 display_command: None,
940 runtime: true,
941 },
942 ToolDocMeta {
943 id: "goimports",
944 language: "Go",
945 kind: ToolKind::Format,
946 doc_group: "goimports",
947 display_command: None,
948 runtime: true,
949 },
950 ToolDocMeta {
951 id: "clang-format",
952 language: "C/C++",
953 kind: ToolKind::Format,
954 doc_group: "clang-format",
955 display_command: None,
956 runtime: true,
957 },
958 ToolDocMeta {
959 id: "sqlfluff:lint",
960 language: "SQL",
961 kind: ToolKind::Lint,
962 doc_group: "sqlfluff:lint",
963 display_command: None,
964 runtime: true,
965 },
966 ToolDocMeta {
967 id: "sqlfluff:fix",
968 language: "SQL",
969 kind: ToolKind::Format,
970 doc_group: "sqlfluff:fix",
971 display_command: None,
972 runtime: true,
973 },
974 ToolDocMeta {
975 id: "jq",
976 language: "JSON",
977 kind: ToolKind::Both,
978 doc_group: "jq",
979 display_command: None,
980 runtime: true,
981 },
982 ToolDocMeta {
983 id: "yamlfmt",
984 language: "YAML",
985 kind: ToolKind::Format,
986 doc_group: "yamlfmt",
987 display_command: None,
988 runtime: true,
989 },
990 ToolDocMeta {
991 id: "taplo",
992 language: "TOML",
993 kind: ToolKind::Format,
994 doc_group: "taplo",
995 display_command: None,
996 runtime: true,
997 },
998 ToolDocMeta {
999 id: "terraform:format",
1000 language: "Terraform",
1001 kind: ToolKind::Format,
1002 doc_group: "terraform:format",
1003 display_command: Some("terraform fmt -"),
1004 runtime: true,
1005 },
1006 ToolDocMeta {
1007 id: "terraform-fmt",
1008 language: "Terraform",
1009 kind: ToolKind::Format,
1010 doc_group: "terraform:format",
1011 display_command: Some("terraform fmt -"),
1012 runtime: true,
1013 },
1014 ToolDocMeta {
1015 id: "nixfmt",
1016 language: "Nix",
1017 kind: ToolKind::Format,
1018 doc_group: "nixfmt",
1019 display_command: None,
1020 runtime: true,
1021 },
1022 ToolDocMeta {
1023 id: "stylua",
1024 language: "Lua",
1025 kind: ToolKind::Format,
1026 doc_group: "stylua",
1027 display_command: None,
1028 runtime: true,
1029 },
1030 ToolDocMeta {
1031 id: "ormolu",
1032 language: "Haskell",
1033 kind: ToolKind::Format,
1034 doc_group: "ormolu",
1035 display_command: None,
1036 runtime: true,
1037 },
1038 ToolDocMeta {
1039 id: "elm-format",
1040 language: "Elm",
1041 kind: ToolKind::Format,
1042 doc_group: "elm-format",
1043 display_command: None,
1044 runtime: true,
1045 },
1046 ToolDocMeta {
1047 id: "swift-format",
1048 language: "Swift",
1049 kind: ToolKind::Format,
1050 doc_group: "swift-format",
1051 display_command: None,
1052 runtime: true,
1053 },
1054 ToolDocMeta {
1055 id: "ktfmt",
1056 language: "Kotlin",
1057 kind: ToolKind::Format,
1058 doc_group: "ktfmt",
1059 display_command: None,
1060 runtime: true,
1061 },
1062 ToolDocMeta {
1065 id: "djlint",
1066 language: "Jinja/HTML",
1067 kind: ToolKind::Both,
1068 doc_group: "djlint",
1069 display_command: Some("djlint - / djlint - --reformat"),
1070 runtime: true,
1071 },
1072 ToolDocMeta {
1073 id: "djlint:lint",
1074 language: "Jinja/HTML",
1075 kind: ToolKind::Lint,
1076 doc_group: "djlint:lint",
1077 display_command: Some("djlint -"),
1078 runtime: true,
1079 },
1080 ToolDocMeta {
1081 id: "djlint:reformat",
1082 language: "Jinja/HTML",
1083 kind: ToolKind::Format,
1084 doc_group: "djlint:reformat",
1085 display_command: None,
1086 runtime: true,
1087 },
1088 ToolDocMeta {
1089 id: "beautysh",
1090 language: "Shell",
1091 kind: ToolKind::Format,
1092 doc_group: "beautysh",
1093 display_command: None,
1094 runtime: true,
1095 },
1096 ToolDocMeta {
1097 id: "tombi",
1098 language: "TOML",
1099 kind: ToolKind::Lint,
1100 doc_group: "tombi",
1101 display_command: None,
1102 runtime: true,
1103 },
1104 ToolDocMeta {
1105 id: "tombi:format",
1106 language: "TOML",
1107 kind: ToolKind::Format,
1108 doc_group: "tombi:format",
1109 display_command: None,
1110 runtime: true,
1111 },
1112 ToolDocMeta {
1113 id: "tombi:lint",
1114 language: "TOML",
1115 kind: ToolKind::Lint,
1116 doc_group: "tombi:lint",
1117 display_command: None,
1118 runtime: true,
1119 },
1120 ToolDocMeta {
1121 id: "oxfmt",
1122 language: "Multi",
1123 kind: ToolKind::Format,
1124 doc_group: "oxfmt",
1125 display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1126 runtime: true,
1127 },
1128 ToolDocMeta {
1129 id: "oxfmt:js",
1130 language: "Multi",
1131 kind: ToolKind::Format,
1132 doc_group: "oxfmt",
1133 display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1134 runtime: true,
1135 },
1136 ToolDocMeta {
1137 id: "oxfmt:ts",
1138 language: "Multi",
1139 kind: ToolKind::Format,
1140 doc_group: "oxfmt",
1141 display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1142 runtime: true,
1143 },
1144 ToolDocMeta {
1145 id: "oxfmt:jsx",
1146 language: "Multi",
1147 kind: ToolKind::Format,
1148 doc_group: "oxfmt",
1149 display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1150 runtime: true,
1151 },
1152 ToolDocMeta {
1153 id: "oxfmt:tsx",
1154 language: "Multi",
1155 kind: ToolKind::Format,
1156 doc_group: "oxfmt",
1157 display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1158 runtime: true,
1159 },
1160 ToolDocMeta {
1161 id: "oxfmt:json",
1162 language: "Multi",
1163 kind: ToolKind::Format,
1164 doc_group: "oxfmt",
1165 display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1166 runtime: true,
1167 },
1168 ToolDocMeta {
1169 id: "oxfmt:css",
1170 language: "Multi",
1171 kind: ToolKind::Format,
1172 doc_group: "oxfmt",
1173 display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1174 runtime: true,
1175 },
1176 ToolDocMeta {
1177 id: "deno-fmt",
1178 language: "Multi",
1179 kind: ToolKind::Format,
1180 doc_group: "deno-fmt",
1181 display_command: Some("deno fmt --ext=EXT -"),
1182 runtime: true,
1183 },
1184 ToolDocMeta {
1185 id: "deno-fmt:ts",
1186 language: "Multi",
1187 kind: ToolKind::Format,
1188 doc_group: "deno-fmt",
1189 display_command: Some("deno fmt --ext=EXT -"),
1190 runtime: true,
1191 },
1192 ToolDocMeta {
1193 id: "deno-fmt:js",
1194 language: "Multi",
1195 kind: ToolKind::Format,
1196 doc_group: "deno-fmt",
1197 display_command: Some("deno fmt --ext=EXT -"),
1198 runtime: true,
1199 },
1200 ToolDocMeta {
1201 id: "deno-fmt:json",
1202 language: "Multi",
1203 kind: ToolKind::Format,
1204 doc_group: "deno-fmt",
1205 display_command: Some("deno fmt --ext=EXT -"),
1206 runtime: true,
1207 },
1208 ToolDocMeta {
1209 id: "deno-fmt:jsonc",
1210 language: "Multi",
1211 kind: ToolKind::Format,
1212 doc_group: "deno-fmt",
1213 display_command: Some("deno fmt --ext=EXT -"),
1214 runtime: true,
1215 },
1216 ToolDocMeta {
1217 id: "deno-fmt:md",
1218 language: "Multi",
1219 kind: ToolKind::Format,
1220 doc_group: "deno-fmt",
1221 display_command: Some("deno fmt --ext=EXT -"),
1222 runtime: true,
1223 },
1224 ToolDocMeta {
1227 id: RUMDL_BUILTIN_TOOL,
1228 language: "Markdown",
1229 kind: ToolKind::Lint,
1230 doc_group: RUMDL_BUILTIN_TOOL,
1231 display_command: Some("built-in markdown linting"),
1232 runtime: false,
1233 },
1234];
1235
1236const TABLE_BEGIN: &str = "<!-- BEGIN builtin-tools (generated) -->";
1238const TABLE_END: &str = "<!-- END builtin-tools (generated) -->";
1239
1240#[derive(Debug, Clone, PartialEq, Eq)]
1242pub enum DocsError {
1243 MissingMarker,
1245 DuplicateMarker,
1247 MarkerOrder,
1249 CountRowMissing,
1251 CountRowAmbiguous,
1253 CountRowMalformed,
1255}
1256
1257impl std::fmt::Display for DocsError {
1258 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1259 let msg = match self {
1260 DocsError::MissingMarker => "missing `<!-- BEGIN/END builtin-tools (generated) -->` marker pair",
1261 DocsError::DuplicateMarker => "duplicate builtin-tools marker",
1262 DocsError::MarkerOrder => "END builtin-tools marker precedes BEGIN",
1263 DocsError::CountRowMissing => "`| Built-in tools` count row not found",
1264 DocsError::CountRowAmbiguous => "multiple `| Built-in tools` count rows found",
1265 DocsError::CountRowMalformed => "`| Built-in tools` count row has an unexpected layout",
1266 };
1267 f.write_str(msg)
1268 }
1269}
1270
1271impl std::error::Error for DocsError {}
1272
1273fn builtin_tools_group_count() -> usize {
1275 let mut seen: Vec<&str> = Vec::new();
1276 for m in BUILTIN_TOOLS_DOCS {
1277 if !seen.contains(&m.doc_group) {
1278 seen.push(m.doc_group);
1279 }
1280 }
1281 seen.len()
1282}
1283
1284pub fn render_builtin_tools_table() -> String {
1290 let headers = ["Tool ID", "Language", "Type", "Command"];
1291 let mut rows: Vec<[String; 4]> = Vec::new();
1292
1293 let mut seen: Vec<&str> = Vec::new();
1294 for m in BUILTIN_TOOLS_DOCS {
1295 if seen.contains(&m.doc_group) {
1296 continue;
1297 }
1298 seen.push(m.doc_group);
1299
1300 let command = match m.display_command {
1303 Some(cmd) => cmd.to_string(),
1304 None if m.runtime => runtime_command_for_kind(m.id, m.kind),
1305 None => String::new(),
1306 };
1307
1308 rows.push([
1309 format!("`{}`", m.doc_group),
1310 m.language.to_string(),
1311 m.kind.label().to_string(),
1312 format!("`{command}`"),
1313 ]);
1314 }
1315
1316 let mut widths = headers.map(str::chars).map(Iterator::count);
1318 for row in &rows {
1319 for (i, cell) in row.iter().enumerate() {
1320 widths[i] = widths[i].max(cell.chars().count());
1321 }
1322 }
1323
1324 let mut out = String::new();
1325 push_table_row(&mut out, &headers.map(String::from), &widths);
1326 let separators = std::array::from_fn(|i| "-".repeat(widths[i]));
1327 push_table_row(&mut out, &separators, &widths);
1328 for row in &rows {
1329 push_table_row(&mut out, row, &widths);
1330 }
1331 out
1332}
1333
1334fn runtime_command_for_kind(id: &str, kind: ToolKind) -> String {
1339 let Some(def) = BUILTIN_TOOLS.get(id) else {
1340 return String::new();
1341 };
1342 let invocation =
1343 |extra: &[String]| -> String { def.command.iter().chain(extra).cloned().collect::<Vec<_>>().join(" ") };
1344 match kind {
1345 ToolKind::Lint => invocation(&def.lint_args),
1346 ToolKind::Format => invocation(&def.format_args),
1347 ToolKind::Both => {
1348 let lint = invocation(&def.lint_args);
1349 let format = invocation(&def.format_args);
1350 if lint == format {
1351 lint
1352 } else {
1353 format!("{lint} / {format}")
1354 }
1355 }
1356 }
1357}
1358
1359fn push_table_row(out: &mut String, cells: &[String; 4], widths: &[usize; 4]) {
1361 out.push('|');
1362 for (i, cell) in cells.iter().enumerate() {
1363 out.push(' ');
1364 out.push_str(cell);
1365 for _ in cell.chars().count()..widths[i] {
1366 out.push(' ');
1367 }
1368 out.push_str(" |");
1369 }
1370 out.push('\n');
1371}
1372
1373pub fn splice_builtin_tools_docs(existing: &str) -> Result<String, DocsError> {
1379 if existing.matches(TABLE_BEGIN).count() == 0 || existing.matches(TABLE_END).count() == 0 {
1380 return Err(DocsError::MissingMarker);
1381 }
1382 if existing.matches(TABLE_BEGIN).count() > 1 || existing.matches(TABLE_END).count() > 1 {
1383 return Err(DocsError::DuplicateMarker);
1384 }
1385 let begin_pos = existing.find(TABLE_BEGIN).unwrap();
1386 let end_pos = existing.find(TABLE_END).unwrap();
1387 if end_pos < begin_pos {
1388 return Err(DocsError::MarkerOrder);
1389 }
1390
1391 let table = render_builtin_tools_table();
1392 let replacement = format!("{TABLE_BEGIN}\n\n{table}\n{TABLE_END}");
1393
1394 let mut result = String::with_capacity(existing.len() + replacement.len());
1395 result.push_str(&existing[..begin_pos]);
1396 result.push_str(&replacement);
1397 result.push_str(&existing[end_pos + TABLE_END.len()..]);
1398
1399 update_builtin_tools_count(&result, builtin_tools_group_count())
1400}
1401
1402fn update_builtin_tools_count(text: &str, count: usize) -> Result<String, DocsError> {
1405 let lines: Vec<&str> = text.lines().collect();
1406 let mut target: Option<usize> = None;
1407 for (i, line) in lines.iter().enumerate() {
1408 if line.trim_start().starts_with("| Built-in tools") {
1409 if target.is_some() {
1410 return Err(DocsError::CountRowAmbiguous);
1411 }
1412 target = Some(i);
1413 }
1414 }
1415 let i = target.ok_or(DocsError::CountRowMissing)?;
1416 let new_line = replace_count_cell(lines[i], count).ok_or(DocsError::CountRowMalformed)?;
1417
1418 let mut out = String::with_capacity(text.len());
1419 for (j, line) in lines.iter().enumerate() {
1420 out.push_str(if j == i { &new_line } else { line });
1421 out.push('\n');
1422 }
1423 if !text.ends_with('\n') {
1424 out.pop();
1425 }
1426 Ok(out)
1427}
1428
1429fn replace_count_cell(line: &str, count: usize) -> Option<String> {
1431 let pipes: Vec<usize> = line.match_indices('|').map(|(i, _)| i).collect();
1432 if pipes.len() < 3 {
1433 return None;
1434 }
1435 let start = pipes[1] + 1;
1436 let end = pipes[2];
1437 let cell = line.get(start..end)?;
1438 let width = cell.len();
1439 let num = count.to_string();
1440
1441 let mut new_cell = String::with_capacity(width.max(num.len() + 2));
1442 new_cell.push(' ');
1443 new_cell.push_str(&num);
1444 while new_cell.len() < width {
1445 new_cell.push(' ');
1446 }
1447 if new_cell.len() > width {
1448 new_cell = format!(" {num} ");
1450 }
1451
1452 Some(format!("{}{}{}", &line[..start], new_cell, &line[end..]))
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457 use super::*;
1458
1459 #[test]
1460 fn test_get_builtin_tool() {
1461 let registry = ToolRegistry::default();
1462
1463 let tool = registry.get("ruff:check").expect("Should find ruff:check");
1464 assert!(tool.command.contains(&"ruff".to_string()));
1465 assert!(tool.stdin);
1466 assert!(tool.stdout);
1467
1468 let tool = registry.get("shellcheck").expect("Should find shellcheck");
1469 assert!(tool.command.contains(&"shellcheck".to_string()));
1470 }
1471
1472 #[test]
1473 fn test_builtin_yamlfmt_answers_lint_by_formatting() {
1474 let registry = ToolRegistry::default();
1475
1476 assert_eq!(registry.lint_mode("yamlfmt"), Some(BuiltinLintMode::FormatCheck));
1477
1478 let tool = registry.get("yamlfmt").expect("Should find yamlfmt");
1480 let mut argv = tool.command.clone();
1481 argv.extend(tool.format_args.clone());
1482
1483 assert_eq!(argv, vec!["yamlfmt", "-"]);
1484 assert!(tool.lint_args.is_empty());
1485 }
1486
1487 #[test]
1488 fn test_get_user_tool_overrides_builtin() {
1489 let mut user_tools = BTreeMap::new();
1490 user_tools.insert(
1491 "ruff:check".to_string(),
1492 ToolDefinition {
1493 command: vec!["custom-ruff".to_string()],
1494 stdin: false,
1495 stdout: false,
1496 lint_args: vec![],
1497 format_args: vec![],
1498 },
1499 );
1500
1501 let registry = ToolRegistry::new(user_tools);
1502
1503 let tool = registry.get("ruff:check").expect("Should find ruff:check");
1504 assert_eq!(tool.command, vec!["custom-ruff"]);
1505 assert!(!tool.stdin); }
1507
1508 #[test]
1509 fn test_contains() {
1510 let registry = ToolRegistry::default();
1511
1512 assert!(registry.contains("ruff:check"));
1513 assert!(registry.contains("prettier"));
1514 assert!(registry.contains("shellcheck"));
1515 assert!(!registry.contains("nonexistent-tool"));
1516 }
1517
1518 #[test]
1519 fn test_list_tools() {
1520 let registry = ToolRegistry::default();
1521 let tools = registry.list_tools();
1522
1523 assert!(tools.contains(&"ruff:check"));
1524 assert!(tools.contains(&"ruff:format"));
1525 assert!(tools.contains(&"prettier"));
1526 assert!(tools.contains(&"shellcheck"));
1527 assert!(tools.contains(&"shfmt"));
1528 assert!(tools.contains(&"rustfmt"));
1529 assert!(tools.contains(&"gofmt"));
1530 }
1531
1532 #[test]
1533 fn test_user_tools_in_list() {
1534 let mut user_tools = BTreeMap::new();
1535 user_tools.insert("my-custom-tool".to_string(), ToolDefinition::default());
1536
1537 let registry = ToolRegistry::new(user_tools);
1538 let tools = registry.list_tools();
1539
1540 assert!(tools.contains(&"my-custom-tool"));
1541 assert!(tools.contains(&"ruff:check")); }
1543
1544 #[test]
1545 fn test_new_builtin_tools() {
1546 let registry = ToolRegistry::default();
1547
1548 let tool = registry.get("djlint").expect("Should find djlint");
1550 assert!(tool.command.contains(&"djlint".to_string()));
1551 assert!(tool.stdin);
1552
1553 let tool = registry.get("beautysh").expect("Should find beautysh");
1555 assert!(tool.command.contains(&"beautysh".to_string()));
1556 assert!(tool.stdin);
1557
1558 let tool = registry.get("tombi").expect("Should find tombi");
1560 assert!(tool.command.contains(&"tombi".to_string()));
1561 assert!(tool.stdin);
1562
1563 let tool = registry.get("tombi:lint").expect("Should find tombi:lint");
1564 assert!(tool.command.contains(&"lint".to_string()));
1565
1566 let tool = registry.get("tombi:format").expect("Should find tombi:format");
1567 assert!(
1568 tool.command.contains(&"format".to_string()),
1569 "tombi:format should use 'format' subcommand, got: {:?}",
1570 tool.command
1571 );
1572
1573 let tool = registry.get("oxfmt").expect("Should find oxfmt");
1575 assert!(tool.command.contains(&"oxfmt".to_string()));
1576 assert!(tool.stdin);
1577
1578 let tool = registry.get("oxfmt:ts").expect("Should find oxfmt:ts");
1579 assert!(tool.command.iter().any(|s| s.contains("_.ts")));
1580 }
1581
1582 #[test]
1590 fn test_bare_tombi_resolves_to_lint_not_format() {
1591 let registry = ToolRegistry::default();
1592
1593 let bare = registry.get("tombi").expect("Should find bare tombi");
1594 let format = registry.get("tombi:format").expect("Should find tombi:format");
1595
1596 assert!(
1598 bare.command.contains(&"lint".to_string()),
1599 "Bare 'tombi' uses lint subcommand: {:?}",
1600 bare.command
1601 );
1602
1603 assert!(
1605 format.command.contains(&"format".to_string()),
1606 "tombi:format uses format subcommand: {:?}",
1607 format.command
1608 );
1609
1610 assert_ne!(
1612 bare.command, format.command,
1613 "Bare 'tombi' and 'tombi:format' should have different commands (this is the root cause of #527)"
1614 );
1615 }
1616
1617 #[test]
1620 fn test_tools_with_lint_format_variants_are_distinct() {
1621 let registry = ToolRegistry::default();
1622
1623 let ruff_check = registry.get("ruff:check").expect("ruff:check");
1625 let ruff_format = registry.get("ruff:format").expect("ruff:format");
1626 assert_ne!(
1627 ruff_check.command, ruff_format.command,
1628 "ruff:check and ruff:format should be distinct"
1629 );
1630
1631 let tombi_lint = registry.get("tombi:lint").expect("tombi:lint");
1633 let tombi_format = registry.get("tombi:format").expect("tombi:format");
1634 assert_ne!(
1635 tombi_lint.command, tombi_format.command,
1636 "tombi:lint and tombi:format should be distinct"
1637 );
1638 }
1639
1640 #[test]
1641 fn test_deno_fmt_has_per_extension_variants() {
1642 let registry = ToolRegistry::default();
1645
1646 let deno_json = registry.get("deno-fmt:json").expect("deno-fmt:json");
1647 assert!(deno_json.command.iter().any(|a| a == "--ext=json"));
1648 let deno_md = registry.get("deno-fmt:md").expect("deno-fmt:md");
1649 assert!(deno_md.command.iter().any(|a| a == "--ext=md"));
1650
1651 let deno = registry.get("deno-fmt").expect("deno-fmt");
1653 assert!(deno.command.iter().any(|a| a == "--ext=ts"));
1654 }
1655
1656 #[test]
1665 fn no_builtin_declares_lint_args() {
1666 let offenders: Vec<&str> = BUILTIN_TOOLS
1667 .iter()
1668 .filter(|(_, def)| !def.lint_args.is_empty())
1669 .map(|(id, _)| *id)
1670 .collect();
1671
1672 assert!(
1673 offenders.is_empty(),
1674 "built-in tools must not declare lint_args (a formatter is lint-checked by \
1675 formatting and comparing; a linter's args belong in `command`): {offenders:?}"
1676 );
1677 }
1678
1679 #[test]
1683 fn builtin_lint_mode_follows_documented_kind() {
1684 let registry = ToolRegistry::default();
1685
1686 for meta in BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime) {
1687 let def = registry.get(meta.id).expect("runtime metadata matches the registry");
1688 let mode = registry.lint_mode(meta.id);
1689
1690 match meta.kind {
1691 ToolKind::Format => {
1692 assert_eq!(
1693 mode,
1694 Some(BuiltinLintMode::FormatCheck),
1695 "{} is documented as a formatter",
1696 meta.id
1697 );
1698 assert!(
1699 def.stdin && def.stdout,
1700 "{} is lint-checked by comparing its output, so it must read stdin \
1701 and write stdout",
1702 meta.id
1703 );
1704 }
1705 ToolKind::Lint | ToolKind::Both => {
1706 assert_eq!(
1707 mode,
1708 Some(BuiltinLintMode::Diagnostics),
1709 "{} is documented as a linter",
1710 meta.id
1711 );
1712 }
1713 }
1714 }
1715 }
1716
1717 #[test]
1727 fn format_slot_never_resolves_to_a_linter() {
1728 let registry = ToolRegistry::default();
1729 let documented_kind = |id: &str| {
1730 BUILTIN_TOOLS_DOCS
1731 .iter()
1732 .find(|m| m.id == id && m.runtime)
1733 .map(|m| m.kind)
1734 };
1735
1736 for meta in BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime) {
1737 let fills = registry.fills_format_slot(meta.id);
1738 let resolved = registry.resolve_id(meta.id, ToolSlot::Format);
1739
1740 match fills {
1741 Some(true) => {
1742 let resolved = resolved.expect("a tool that fills the slot resolves in it");
1743 assert!(
1744 !matches!(documented_kind(&resolved), Some(ToolKind::Lint)),
1745 "{} fills a format slot by running {resolved}, which only lints",
1746 meta.id
1747 );
1748 }
1749 Some(false) => assert_eq!(
1750 documented_kind(meta.id),
1751 Some(ToolKind::Lint),
1752 "{} declines the format slot, so it must be documented as a linter",
1753 meta.id
1754 ),
1755 None => panic!("{} is in the registry, so it resolves somewhere", meta.id),
1756 }
1757 }
1758
1759 assert_eq!(registry.fills_format_slot("ruff:check"), Some(false));
1762 assert_eq!(registry.fills_format_slot("shellcheck"), Some(false));
1763 assert_eq!(registry.fills_format_slot("sqlfluff:lint"), Some(false));
1764 assert_eq!(registry.fills_format_slot("tombi"), Some(true));
1765 assert_eq!(
1766 registry.resolve_id("tombi", ToolSlot::Format).as_deref(),
1767 Some("tombi:format")
1768 );
1769 }
1770
1771 #[test]
1773 fn user_tool_is_never_lint_checked_by_formatting() {
1774 let mut user_tools = BTreeMap::new();
1775 user_tools.insert(
1776 "yamlfmt".to_string(),
1777 ToolDefinition {
1778 command: vec!["my-yaml-linter".to_string()],
1779 stdin: true,
1780 stdout: true,
1781 lint_args: vec!["--check".to_string()],
1782 format_args: vec![],
1783 },
1784 );
1785 let registry = ToolRegistry::new(user_tools);
1786
1787 assert_eq!(registry.lint_mode("yamlfmt"), None);
1788 assert_eq!(
1790 ToolRegistry::default().lint_mode("yamlfmt"),
1791 Some(BuiltinLintMode::FormatCheck)
1792 );
1793 }
1794
1795 #[test]
1800 fn test_docs_metadata_ids_unique() {
1801 let mut seen = std::collections::BTreeSet::new();
1802 for m in BUILTIN_TOOLS_DOCS {
1803 assert!(seen.insert(m.id), "duplicate metadata id: {}", m.id);
1804 }
1805 }
1806
1807 #[test]
1808 fn test_runtime_metadata_matches_registry_keys() {
1809 let runtime_meta: std::collections::BTreeSet<&str> =
1810 BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime).map(|m| m.id).collect();
1811 let map_keys: std::collections::BTreeSet<&str> = BUILTIN_TOOLS.keys().copied().collect();
1812 assert_eq!(
1813 runtime_meta, map_keys,
1814 "BUILTIN_TOOLS_DOCS runtime ids must exactly match BUILTIN_TOOLS keys (add/remove the doc entry alongside the registry entry)"
1815 );
1816 }
1817
1818 #[test]
1819 fn test_docs_only_ids_absent_from_registry() {
1820 for m in BUILTIN_TOOLS_DOCS.iter().filter(|m| !m.runtime) {
1821 assert!(
1822 !BUILTIN_TOOLS.contains_key(m.id),
1823 "docs-only id {} must not be a runtime registry tool",
1824 m.id
1825 );
1826 }
1827 assert!(
1829 BUILTIN_TOOLS_DOCS
1830 .iter()
1831 .any(|m| !m.runtime && m.id == RUMDL_BUILTIN_TOOL),
1832 "rumdl must be present as a docs-only entry"
1833 );
1834 }
1835
1836 #[test]
1837 fn test_docs_only_requires_display_command() {
1838 for m in BUILTIN_TOOLS_DOCS.iter().filter(|m| !m.runtime) {
1839 assert!(
1840 m.display_command.is_some(),
1841 "docs-only id {} needs a display_command (no runtime command to derive)",
1842 m.id
1843 );
1844 }
1845 }
1846
1847 #[test]
1848 fn test_multi_runtime_group_requires_display_command() {
1849 let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
1850 for m in BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime) {
1851 *counts.entry(m.doc_group).or_default() += 1;
1852 }
1853 for m in BUILTIN_TOOLS_DOCS {
1854 if counts.get(m.doc_group).copied().unwrap_or(0) > 1 {
1855 assert!(
1856 m.display_command.is_some(),
1857 "doc_group `{}` has multiple runtime entries; `{}` needs an explicit display_command",
1858 m.doc_group,
1859 m.id
1860 );
1861 }
1862 }
1863 }
1864
1865 #[test]
1866 fn test_doc_group_language_and_kind_consistent() {
1867 let mut groups: std::collections::BTreeMap<&str, (&str, ToolKind)> = std::collections::BTreeMap::new();
1868 for m in BUILTIN_TOOLS_DOCS {
1869 match groups.get(m.doc_group) {
1870 None => {
1871 groups.insert(m.doc_group, (m.language, m.kind));
1872 }
1873 Some((lang, kind)) => {
1874 assert_eq!(
1875 *lang, m.language,
1876 "doc_group `{}` has mismatched languages",
1877 m.doc_group
1878 );
1879 assert_eq!(*kind, m.kind, "doc_group `{}` has mismatched kinds", m.doc_group);
1880 }
1881 }
1882 }
1883 }
1884
1885 fn rendered_rows(table: &str) -> Vec<(String, String, String, String)> {
1892 table
1893 .lines()
1894 .skip(2) .filter(|l| l.starts_with('|'))
1896 .map(|l| {
1897 let cells: Vec<String> = l.trim().trim_matches('|').split('|').map(|c| c.trim().to_string()).collect();
1898 (cells[0].clone(), cells[1].clone(), cells[2].clone(), cells[3].clone())
1899 })
1900 .collect()
1901 }
1902
1903 #[test]
1904 fn test_render_table_golden_rows() {
1905 let table = render_builtin_tools_table();
1906 let rows = rendered_rows(&table);
1907 let has = |id: &str, lang: &str, kind: &str, cmd: &str| {
1908 rows.iter()
1909 .any(|(i, l, k, c)| i == id && l == lang && k == kind && c == cmd)
1910 };
1911 assert!(has("`shellcheck`", "Shell", "Lint", "`shellcheck --shell=bash -`"));
1913 assert!(has("`rustfmt`", "Rust", "Format", "`rustfmt`"));
1914 assert!(has("`jq`", "JSON", "Both", "`jq .`"));
1915 assert!(has(
1916 "`prettier`",
1917 "Multi",
1918 "Format",
1919 "`prettier --stdin-filepath=_.EXT`"
1920 ));
1921 assert!(has("`rumdl`", "Markdown", "Lint", "`built-in markdown linting`"));
1922 assert!(
1924 !table.contains("prettier:json"),
1925 "prettier variants must collapse into one row"
1926 );
1927 assert!(!table.contains("oxfmt:ts"), "oxfmt variants must collapse into one row");
1928 }
1929
1930 #[test]
1931 fn test_render_table_one_row_per_group() {
1932 let table = render_builtin_tools_table();
1933 assert_eq!(rendered_rows(&table).len(), builtin_tools_group_count());
1934 }
1935
1936 #[test]
1937 fn test_render_table_command_fallback_from_registry() {
1938 let table = render_builtin_tools_table();
1940 assert!(
1941 rendered_rows(&table)
1942 .iter()
1943 .any(|(i, _, _, c)| i == "`tombi`" && c == "`tombi lint -`")
1944 );
1945 }
1946
1947 #[test]
1948 fn test_render_command_reflects_mode_args() {
1949 let table = render_builtin_tools_table();
1950 let rows = rendered_rows(&table);
1951 let cmd = |id: &str| {
1952 let row = rows
1953 .iter()
1954 .find(|(i, _, _, _)| i == id)
1955 .unwrap_or_else(|| panic!("row {id} not found"));
1956 row.3.clone()
1957 };
1958 assert_eq!(cmd("`yamlfmt`"), "`yamlfmt -`");
1960 assert_eq!(
1962 cmd("`sqlfluff:lint`"),
1963 "`sqlfluff lint --dialect ansi --format github-annotation-native -`"
1964 );
1965 }
1966
1967 #[test]
1968 fn test_runtime_command_for_kind_both_collapses_when_equal() {
1969 assert_eq!(runtime_command_for_kind("jq", ToolKind::Both), "jq .");
1971 }
1972
1973 #[test]
1978 fn test_runtime_command_for_kind_both_shows_both_invocations() {
1979 let rendered = runtime_command_for_kind("djlint", ToolKind::Both);
1980 let (lint, format) = rendered
1981 .split_once(" / ")
1982 .expect("differing invocations render as a pair");
1983 assert_eq!(
1984 lint,
1985 "djlint - --linter-output-format {filename}:{line}: {code} {message}"
1986 );
1987 assert_eq!(format, format!("{lint} --reformat"));
1988 }
1989
1990 #[test]
1996 fn a_curated_display_command_names_the_program_that_runs() {
1997 for meta in BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime) {
1998 let Some(display) = meta.display_command else {
1999 continue;
2000 };
2001 let def = BUILTIN_TOOLS
2002 .get(meta.id)
2003 .expect("runtime metadata matches the registry");
2004 let program = def.command.first().expect("a built-in command names a program");
2005
2006 for invocation in display.split(" / ") {
2007 assert_eq!(
2008 invocation.split_whitespace().next(),
2009 Some(program.as_str()),
2010 "display_command for `{}` starts with a different program than it runs ({program})",
2011 meta.id
2012 );
2013 }
2014 }
2015 }
2016
2017 #[test]
2022 fn test_splice_replaces_region_and_preserves_prose() {
2023 let doc = format!(
2024 "# Title\n\nIntro.\n\n{TABLE_BEGIN}\n\nstale\n\n{TABLE_END}\n\nAfter.\n\n| Built-in tools | 31 | 339 |\n"
2025 );
2026 let out = splice_builtin_tools_docs(&doc).expect("splice");
2027 assert!(out.starts_with("# Title\n\nIntro.\n\n"));
2028 assert!(out.contains("After.\n"));
2029 assert!(!out.contains("stale"));
2030 assert!(
2031 out.contains(&render_builtin_tools_table()),
2032 "generated table is spliced in verbatim"
2033 );
2034 assert!(out.contains(&format!("| Built-in tools | {} | 339 |", builtin_tools_group_count())));
2036 }
2037
2038 #[test]
2039 fn test_splice_missing_marker_errors() {
2040 let doc = "# Title\n\nno markers\n\n| Built-in tools | 31 | 339 |\n";
2041 assert_eq!(splice_builtin_tools_docs(doc), Err(DocsError::MissingMarker));
2042 }
2043
2044 #[test]
2045 fn test_splice_duplicate_marker_errors() {
2046 let doc = format!("{TABLE_BEGIN}\n\nx\n\n{TABLE_END}\n{TABLE_BEGIN}\n\ny\n\n{TABLE_END}\n");
2047 assert_eq!(splice_builtin_tools_docs(&doc), Err(DocsError::DuplicateMarker));
2048 }
2049
2050 #[test]
2051 fn test_splice_marker_order_errors() {
2052 let doc = format!("{TABLE_END}\n\nx\n\n{TABLE_BEGIN}\n\n| Built-in tools | 31 | 339 |\n");
2053 assert_eq!(splice_builtin_tools_docs(&doc), Err(DocsError::MarkerOrder));
2054 }
2055
2056 #[test]
2057 fn test_splice_missing_count_row_errors() {
2058 let doc = format!("{TABLE_BEGIN}\n\nx\n\n{TABLE_END}\n\nno count row here\n");
2059 assert_eq!(splice_builtin_tools_docs(&doc), Err(DocsError::CountRowMissing));
2060 }
2061
2062 #[test]
2063 fn test_splice_is_idempotent() {
2064 let doc = format!("{TABLE_BEGIN}\n\nstale\n\n{TABLE_END}\n\n| Built-in tools | 31 | 339 |\n");
2065 let once = splice_builtin_tools_docs(&doc).expect("first");
2066 let twice = splice_builtin_tools_docs(&once).expect("second");
2067 assert_eq!(once, twice, "splice must be idempotent");
2068 }
2069}