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> {
64 if tool_id.contains(':') {
66 return self.contains(tool_id).then(|| tool_id.to_string());
67 }
68
69 let suffixes = match slot {
70 ToolSlot::Format => &["format", "fmt", "fix", "reformat"][..],
71 ToolSlot::Lint => &["lint", "check"][..],
72 };
73 for suffix in suffixes {
75 let qualified = format!("{tool_id}:{suffix}");
76 if self.user_tools.contains_key(&qualified) {
77 return Some(qualified);
78 }
79 }
80 if self.user_tools.contains_key(tool_id) {
81 return Some(tool_id.to_string());
82 }
83 for suffix in suffixes {
84 let qualified = format!("{tool_id}:{suffix}");
85 if self.contains(&qualified) {
86 return Some(qualified);
87 }
88 }
89
90 if self.contains(tool_id) {
91 return Some(tool_id.to_string());
92 }
93
94 if slot == ToolSlot::Lint {
98 for suffix in ["format", "fmt"] {
99 let qualified = format!("{tool_id}:{suffix}");
100 if self.contains(&qualified) {
101 return Some(qualified);
102 }
103 }
104 }
105
106 None
107 }
108
109 pub fn resolve(&self, tool_id: &str, slot: ToolSlot) -> Option<&ToolDefinition> {
111 self.resolve_id(tool_id, slot).and_then(|id| self.get(&id))
112 }
113
114 pub fn fills_format_slot(&self, tool_id: &str) -> Option<bool> {
120 let resolved = self.resolve_id(tool_id, ToolSlot::Format)?;
121 Some(self.user_tools.contains_key(&resolved) || builtin_tool_formats(&resolved) == Some(true))
122 }
123
124 pub fn list_tools(&self) -> Vec<&str> {
126 let mut tools: Vec<&str> = self.user_tools.keys().map(std::string::String::as_str).collect();
127 for key in BUILTIN_TOOLS.keys() {
128 if !self.user_tools.contains_key(*key) {
129 tools.push(key);
130 }
131 }
132 tools.sort_unstable();
133 tools
134 }
135}
136
137pub fn builtin_tool_ids() -> Vec<&'static str> {
144 let mut ids: Vec<&'static str> = BUILTIN_TOOLS.keys().copied().collect();
145 ids.sort_unstable();
146 ids
147}
148
149impl Default for ToolRegistry {
150 fn default() -> Self {
151 Self::new(BTreeMap::new())
152 }
153}
154
155static BUILTIN_TOOLS: LazyLock<HashMap<&'static str, ToolDefinition>> = LazyLock::new(|| {
159 let mut m = HashMap::new();
160
161 m.insert(
163 "ruff:check",
164 ToolDefinition {
165 command: vec![
166 "ruff".to_string(),
167 "check".to_string(),
168 "--output-format=concise".to_string(),
169 "--stdin-filename=_.py".to_string(),
170 "-".to_string(),
171 ],
172 stdin: true,
173 stdout: true,
174 lint_args: vec![],
175 format_args: vec![],
176 },
177 );
178
179 m.insert(
180 "ruff:format",
181 ToolDefinition {
182 command: vec![
183 "ruff".to_string(),
184 "format".to_string(),
185 "--stdin-filename=_.py".to_string(),
186 "-".to_string(),
187 ],
188 stdin: true,
189 stdout: true,
190 lint_args: vec![],
191 format_args: vec![],
192 },
193 );
194
195 m.insert(
197 "black",
198 ToolDefinition {
199 command: vec!["black".to_string(), "--quiet".to_string(), "-".to_string()],
200 stdin: true,
201 stdout: true,
202 lint_args: vec![],
203 format_args: vec![],
204 },
205 );
206
207 m.insert(
209 "prettier",
210 ToolDefinition {
211 command: vec!["prettier".to_string(), "--stdin-filepath=_.js".to_string()],
212 stdin: true,
213 stdout: true,
214 lint_args: vec![],
215 format_args: vec![],
216 },
217 );
218
219 m.insert(
220 "prettier:json",
221 ToolDefinition {
222 command: vec!["prettier".to_string(), "--stdin-filepath=_.json".to_string()],
223 stdin: true,
224 stdout: true,
225 lint_args: vec![],
226 format_args: vec![],
227 },
228 );
229
230 m.insert(
231 "prettier:yaml",
232 ToolDefinition {
233 command: vec!["prettier".to_string(), "--stdin-filepath=_.yaml".to_string()],
234 stdin: true,
235 stdout: true,
236 lint_args: vec![],
237 format_args: vec![],
238 },
239 );
240
241 m.insert(
242 "prettier:html",
243 ToolDefinition {
244 command: vec!["prettier".to_string(), "--stdin-filepath=_.html".to_string()],
245 stdin: true,
246 stdout: true,
247 lint_args: vec![],
248 format_args: vec![],
249 },
250 );
251
252 m.insert(
253 "prettier:css",
254 ToolDefinition {
255 command: vec!["prettier".to_string(), "--stdin-filepath=_.css".to_string()],
256 stdin: true,
257 stdout: true,
258 lint_args: vec![],
259 format_args: vec![],
260 },
261 );
262
263 m.insert(
264 "prettier:markdown",
265 ToolDefinition {
266 command: vec!["prettier".to_string(), "--stdin-filepath=_.md".to_string()],
267 stdin: true,
268 stdout: true,
269 lint_args: vec![],
270 format_args: vec![],
271 },
272 );
273
274 m.insert(
279 "shellcheck",
280 ToolDefinition {
281 command: vec!["shellcheck".to_string(), "--shell=bash".to_string(), "-".to_string()],
282 stdin: true,
283 stdout: true,
284 lint_args: vec![],
285 format_args: vec![],
286 },
287 );
288
289 m.insert(
291 "shfmt",
292 ToolDefinition {
293 command: vec!["shfmt".to_string()],
294 stdin: true,
295 stdout: true,
296 lint_args: vec![],
297 format_args: vec![],
298 },
299 );
300
301 m.insert(
310 "shuck",
311 ToolDefinition {
312 command: vec![
313 "shuck".to_string(),
314 "check".to_string(),
315 "--output-format".to_string(),
316 "concise".to_string(),
317 "-".to_string(),
318 ],
319 stdin: true,
320 stdout: true,
321 lint_args: vec![],
322 format_args: vec![],
323 },
324 );
325
326 m.insert(
327 "shuck:format",
328 ToolDefinition {
329 command: vec!["shuck".to_string(), "format".to_string(), "-".to_string()],
330 stdin: true,
331 stdout: true,
332 lint_args: vec![],
333 format_args: vec![],
334 },
335 );
336
337 m.insert(
339 "rustfmt",
340 ToolDefinition {
341 command: vec!["rustfmt".to_string()],
342 stdin: true,
343 stdout: true,
344 lint_args: vec![],
345 format_args: vec![],
346 },
347 );
348
349 m.insert(
351 "gofmt",
352 ToolDefinition {
353 command: vec!["gofmt".to_string()],
354 stdin: true,
355 stdout: true,
356 lint_args: vec![],
357 format_args: vec![],
358 },
359 );
360
361 m.insert(
363 "goimports",
364 ToolDefinition {
365 command: vec!["goimports".to_string()],
366 stdin: true,
367 stdout: true,
368 lint_args: vec![],
369 format_args: vec![],
370 },
371 );
372
373 m.insert(
375 "clang-format",
376 ToolDefinition {
377 command: vec!["clang-format".to_string()],
378 stdin: true,
379 stdout: true,
380 lint_args: vec![],
381 format_args: vec![],
382 },
383 );
384
385 m.insert(
391 "sqlfluff:lint",
392 ToolDefinition {
393 command: vec![
394 "sqlfluff".to_string(),
395 "lint".to_string(),
396 "--dialect".to_string(),
397 "ansi".to_string(),
398 "--format".to_string(),
399 "github-annotation-native".to_string(),
400 "-".to_string(),
401 ],
402 stdin: true,
403 stdout: true,
404 lint_args: vec![],
405 format_args: vec![],
406 },
407 );
408
409 m.insert(
410 "sqlfluff:fix",
411 ToolDefinition {
412 command: vec![
413 "sqlfluff".to_string(),
414 "fix".to_string(),
415 "--dialect".to_string(),
416 "ansi".to_string(),
417 "-".to_string(),
418 ],
419 stdin: true,
420 stdout: true,
421 lint_args: vec![],
422 format_args: vec![],
423 },
424 );
425
426 m.insert(
428 "jq",
429 ToolDefinition {
430 command: vec!["jq".to_string(), ".".to_string()],
431 stdin: true,
432 stdout: true,
433 lint_args: vec![],
434 format_args: vec![],
435 },
436 );
437
438 m.insert(
440 "yamlfmt",
441 ToolDefinition {
442 command: vec!["yamlfmt".to_string()],
443 stdin: true,
444 stdout: true,
445 lint_args: vec![],
446 format_args: vec!["-".to_string()],
447 },
448 );
449
450 m.insert(
452 "taplo",
453 ToolDefinition {
454 command: vec!["taplo".to_string(), "fmt".to_string(), "-".to_string()],
455 stdin: true,
456 stdout: true,
457 lint_args: vec![],
458 format_args: vec![],
459 },
460 );
461
462 let terraform_fmt = || ToolDefinition {
467 command: vec!["terraform".to_string(), "fmt".to_string(), "-".to_string()],
468 stdin: true,
469 stdout: true,
470 lint_args: vec![],
471 format_args: vec![],
472 };
473 m.insert("terraform:format", terraform_fmt());
474 m.insert("terraform-fmt", terraform_fmt());
475
476 m.insert(
478 "nixfmt",
479 ToolDefinition {
480 command: vec!["nixfmt".to_string(), "-".to_string()],
481 stdin: true,
482 stdout: true,
483 lint_args: vec![],
484 format_args: vec![],
485 },
486 );
487
488 m.insert(
490 "stylua",
491 ToolDefinition {
492 command: vec!["stylua".to_string(), "-".to_string()],
493 stdin: true,
494 stdout: true,
495 lint_args: vec![],
496 format_args: vec![],
497 },
498 );
499
500 m.insert(
502 "ormolu",
503 ToolDefinition {
504 command: vec!["ormolu".to_string(), "--stdin-input-file=_.hs".to_string()],
505 stdin: true,
506 stdout: true,
507 lint_args: vec![],
508 format_args: vec![],
509 },
510 );
511
512 m.insert(
514 "elm-format",
515 ToolDefinition {
516 command: vec!["elm-format".to_string(), "--stdin".to_string()],
517 stdin: true,
518 stdout: true,
519 lint_args: vec![],
520 format_args: vec![],
521 },
522 );
523
524 m.insert(
526 "swift-format",
527 ToolDefinition {
528 command: vec!["swift-format".to_string(), "format".to_string(), "-".to_string()],
529 stdin: true,
530 stdout: true,
531 lint_args: vec![],
532 format_args: vec![],
533 },
534 );
535
536 m.insert(
538 "ktfmt",
539 ToolDefinition {
540 command: vec!["ktfmt".to_string(), "-".to_string()],
541 stdin: true,
542 stdout: true,
543 lint_args: vec![],
544 format_args: vec![],
545 },
546 );
547
548 const DJLINT_OUTPUT_FORMAT: &str = "{filename}:{line}: {code} {message}";
556
557 m.insert(
558 "djlint",
559 ToolDefinition {
560 command: vec![
561 "djlint".to_string(),
562 "-".to_string(),
563 "--linter-output-format".to_string(),
564 DJLINT_OUTPUT_FORMAT.to_string(),
565 ],
566 stdin: true,
567 stdout: true,
568 lint_args: vec![],
569 format_args: vec!["--reformat".to_string()],
570 },
571 );
572
573 m.insert(
574 "djlint:lint",
575 ToolDefinition {
576 command: vec![
577 "djlint".to_string(),
578 "-".to_string(),
579 "--linter-output-format".to_string(),
580 DJLINT_OUTPUT_FORMAT.to_string(),
581 ],
582 stdin: true,
583 stdout: true,
584 lint_args: vec![],
585 format_args: vec![],
586 },
587 );
588
589 m.insert(
590 "djlint:reformat",
591 ToolDefinition {
592 command: vec!["djlint".to_string(), "-".to_string(), "--reformat".to_string()],
593 stdin: true,
594 stdout: true,
595 lint_args: vec![],
596 format_args: vec![],
597 },
598 );
599
600 m.insert(
602 "beautysh",
603 ToolDefinition {
604 command: vec!["beautysh".to_string(), "-".to_string()],
605 stdin: true,
606 stdout: true,
607 lint_args: vec![],
608 format_args: vec![],
609 },
610 );
611
612 m.insert(
614 "tombi",
615 ToolDefinition {
616 command: vec!["tombi".to_string(), "lint".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:format",
626 ToolDefinition {
627 command: vec!["tombi".to_string(), "format".to_string(), "-".to_string()],
628 stdin: true,
629 stdout: true,
630 lint_args: vec![],
631 format_args: vec![],
632 },
633 );
634
635 m.insert(
636 "tombi:lint",
637 ToolDefinition {
638 command: vec!["tombi".to_string(), "lint".to_string(), "-".to_string()],
639 stdin: true,
640 stdout: true,
641 lint_args: vec![],
642 format_args: vec![],
643 },
644 );
645
646 m.insert(
648 "oxfmt",
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:js",
660 ToolDefinition {
661 command: vec!["oxfmt".to_string(), "--stdin-filepath=_.js".to_string()],
662 stdin: true,
663 stdout: true,
664 lint_args: vec![],
665 format_args: vec![],
666 },
667 );
668
669 m.insert(
670 "oxfmt:ts",
671 ToolDefinition {
672 command: vec!["oxfmt".to_string(), "--stdin-filepath=_.ts".to_string()],
673 stdin: true,
674 stdout: true,
675 lint_args: vec![],
676 format_args: vec![],
677 },
678 );
679
680 m.insert(
681 "oxfmt:jsx",
682 ToolDefinition {
683 command: vec!["oxfmt".to_string(), "--stdin-filepath=_.jsx".to_string()],
684 stdin: true,
685 stdout: true,
686 lint_args: vec![],
687 format_args: vec![],
688 },
689 );
690
691 m.insert(
692 "oxfmt:tsx",
693 ToolDefinition {
694 command: vec!["oxfmt".to_string(), "--stdin-filepath=_.tsx".to_string()],
695 stdin: true,
696 stdout: true,
697 lint_args: vec![],
698 format_args: vec![],
699 },
700 );
701
702 m.insert(
703 "oxfmt:json",
704 ToolDefinition {
705 command: vec!["oxfmt".to_string(), "--stdin-filepath=_.json".to_string()],
706 stdin: true,
707 stdout: true,
708 lint_args: vec![],
709 format_args: vec![],
710 },
711 );
712
713 m.insert(
714 "oxfmt:css",
715 ToolDefinition {
716 command: vec!["oxfmt".to_string(), "--stdin-filepath=_.css".to_string()],
717 stdin: true,
718 stdout: true,
719 lint_args: vec![],
720 format_args: vec![],
721 },
722 );
723
724 let deno_fmt = |ext: &str| ToolDefinition {
727 command: vec![
728 "deno".to_string(),
729 "fmt".to_string(),
730 format!("--ext={ext}"),
731 "-".to_string(),
732 ],
733 stdin: true,
734 stdout: true,
735 lint_args: vec![],
736 format_args: vec![],
737 };
738 m.insert("deno-fmt", deno_fmt("ts"));
739 m.insert("deno-fmt:ts", deno_fmt("ts"));
740 m.insert("deno-fmt:js", deno_fmt("js"));
741 m.insert("deno-fmt:json", deno_fmt("json"));
742 m.insert("deno-fmt:jsonc", deno_fmt("jsonc"));
743 m.insert("deno-fmt:md", deno_fmt("md"));
744
745 for (id, existing) in [
748 ("oxfmt:lint", "oxfmt"),
749 ("oxfmt:format", "oxfmt"),
750 ("shuck:lint", "shuck"),
751 ("shuck:format-check", "shuck:format"),
752 ] {
753 m.insert(id, m[existing].clone());
754 }
755 let mut shuck_fix = m["shuck"].clone();
756 shuck_fix.command.push("--fix".to_string());
757 m.insert("shuck:lint-fix", shuck_fix);
760
761 for (profile, lint_id, check_id, format_id) in [
762 (
763 "html",
764 "djlint:html:lint",
765 "djlint:html:format-check",
766 "djlint:html:format",
767 ),
768 (
769 "jinja",
770 "djlint:jinja:lint",
771 "djlint:jinja:format-check",
772 "djlint:jinja:format",
773 ),
774 ] {
775 let mut lint = m["djlint:lint"].clone();
776 lint.command.push(format!("--profile={profile}"));
777 m.insert(lint_id, lint);
778 let mut format = m["djlint:reformat"].clone();
779 format.command.push(format!("--profile={profile}"));
780 m.insert(check_id, format.clone());
781 m.insert(format_id, format);
782 }
783
784 m
785});
786
787#[derive(Debug, Clone, Copy, PartialEq, Eq)]
789enum ToolKind {
790 Lint,
791 FormatCheck,
793 Format,
794 Both,
795}
796
797impl ToolKind {
798 const fn label(self) -> &'static str {
799 match self {
800 ToolKind::Lint | ToolKind::FormatCheck => "Lint",
801 ToolKind::Format => "Format",
802 ToolKind::Both => "Both",
803 }
804 }
805}
806
807#[derive(Debug, Clone, Copy, PartialEq, Eq)]
809pub enum BuiltinLintMode {
810 Diagnostics,
812 FormatCheck,
822}
823
824pub fn builtin_lint_mode(tool_id: &str) -> Option<BuiltinLintMode> {
826 BUILTIN_TOOLS_DOCS
827 .iter()
828 .find(|m| m.id == tool_id && m.runtime)
829 .map(|m| match m.kind {
830 ToolKind::Lint | ToolKind::Both => BuiltinLintMode::Diagnostics,
831 ToolKind::Format | ToolKind::FormatCheck => BuiltinLintMode::FormatCheck,
832 })
833}
834
835pub fn builtin_tool_formats(tool_id: &str) -> Option<bool> {
841 BUILTIN_TOOLS_DOCS
842 .iter()
843 .find(|m| m.id == tool_id && m.runtime)
844 .map(|m| matches!(m.kind, ToolKind::Format | ToolKind::Both))
845}
846
847struct ToolDocMeta {
861 id: &'static str,
862 language: &'static str,
863 kind: ToolKind,
864 doc_group: &'static str,
866 display_command: Option<&'static str>,
868 runtime: bool,
870}
871
872const BUILTIN_TOOLS_DOCS: &[ToolDocMeta] = &[
874 ToolDocMeta {
875 id: "ruff:check",
876 language: "Python",
877 kind: ToolKind::Lint,
878 doc_group: "ruff:check",
879 display_command: Some("ruff check --output-format=concise -"),
880 runtime: true,
881 },
882 ToolDocMeta {
883 id: "ruff:format",
884 language: "Python",
885 kind: ToolKind::Format,
886 doc_group: "ruff:format",
887 display_command: Some("ruff format -"),
888 runtime: true,
889 },
890 ToolDocMeta {
891 id: "black",
892 language: "Python",
893 kind: ToolKind::Format,
894 doc_group: "black",
895 display_command: None,
896 runtime: true,
897 },
898 ToolDocMeta {
899 id: "prettier",
900 language: "Multi",
901 kind: ToolKind::Format,
902 doc_group: "prettier",
903 display_command: Some("prettier --stdin-filepath=_.EXT"),
904 runtime: true,
905 },
906 ToolDocMeta {
907 id: "prettier:json",
908 language: "Multi",
909 kind: ToolKind::Format,
910 doc_group: "prettier",
911 display_command: Some("prettier --stdin-filepath=_.EXT"),
912 runtime: true,
913 },
914 ToolDocMeta {
915 id: "prettier:yaml",
916 language: "Multi",
917 kind: ToolKind::Format,
918 doc_group: "prettier",
919 display_command: Some("prettier --stdin-filepath=_.EXT"),
920 runtime: true,
921 },
922 ToolDocMeta {
923 id: "prettier:html",
924 language: "Multi",
925 kind: ToolKind::Format,
926 doc_group: "prettier",
927 display_command: Some("prettier --stdin-filepath=_.EXT"),
928 runtime: true,
929 },
930 ToolDocMeta {
931 id: "prettier:css",
932 language: "Multi",
933 kind: ToolKind::Format,
934 doc_group: "prettier",
935 display_command: Some("prettier --stdin-filepath=_.EXT"),
936 runtime: true,
937 },
938 ToolDocMeta {
939 id: "prettier:markdown",
940 language: "Multi",
941 kind: ToolKind::Format,
942 doc_group: "prettier",
943 display_command: Some("prettier --stdin-filepath=_.EXT"),
944 runtime: true,
945 },
946 ToolDocMeta {
947 id: "shellcheck",
948 language: "Shell",
949 kind: ToolKind::Lint,
950 doc_group: "shellcheck",
951 display_command: None,
952 runtime: true,
953 },
954 ToolDocMeta {
955 id: "shfmt",
956 language: "Shell",
957 kind: ToolKind::Format,
958 doc_group: "shfmt",
959 display_command: None,
960 runtime: true,
961 },
962 ToolDocMeta {
963 id: "shuck",
964 language: "Shell",
965 kind: ToolKind::Lint,
966 doc_group: "shuck",
967 display_command: None,
968 runtime: true,
969 },
970 ToolDocMeta {
971 id: "shuck:format",
972 language: "Shell",
973 kind: ToolKind::Format,
974 doc_group: "shuck:format",
975 display_command: None,
976 runtime: true,
977 },
978 ToolDocMeta {
979 id: "rustfmt",
980 language: "Rust",
981 kind: ToolKind::Format,
982 doc_group: "rustfmt",
983 display_command: None,
984 runtime: true,
985 },
986 ToolDocMeta {
987 id: "gofmt",
988 language: "Go",
989 kind: ToolKind::Format,
990 doc_group: "gofmt",
991 display_command: None,
992 runtime: true,
993 },
994 ToolDocMeta {
995 id: "goimports",
996 language: "Go",
997 kind: ToolKind::Format,
998 doc_group: "goimports",
999 display_command: None,
1000 runtime: true,
1001 },
1002 ToolDocMeta {
1003 id: "clang-format",
1004 language: "C/C++",
1005 kind: ToolKind::Format,
1006 doc_group: "clang-format",
1007 display_command: None,
1008 runtime: true,
1009 },
1010 ToolDocMeta {
1011 id: "sqlfluff:lint",
1012 language: "SQL",
1013 kind: ToolKind::Lint,
1014 doc_group: "sqlfluff:lint",
1015 display_command: None,
1016 runtime: true,
1017 },
1018 ToolDocMeta {
1019 id: "sqlfluff:fix",
1020 language: "SQL",
1021 kind: ToolKind::Format,
1022 doc_group: "sqlfluff:fix",
1023 display_command: None,
1024 runtime: true,
1025 },
1026 ToolDocMeta {
1027 id: "jq",
1028 language: "JSON",
1029 kind: ToolKind::Both,
1030 doc_group: "jq",
1031 display_command: None,
1032 runtime: true,
1033 },
1034 ToolDocMeta {
1035 id: "yamlfmt",
1036 language: "YAML",
1037 kind: ToolKind::Format,
1038 doc_group: "yamlfmt",
1039 display_command: None,
1040 runtime: true,
1041 },
1042 ToolDocMeta {
1043 id: "taplo",
1044 language: "TOML",
1045 kind: ToolKind::Format,
1046 doc_group: "taplo",
1047 display_command: None,
1048 runtime: true,
1049 },
1050 ToolDocMeta {
1051 id: "terraform:format",
1052 language: "Terraform",
1053 kind: ToolKind::Format,
1054 doc_group: "terraform:format",
1055 display_command: Some("terraform fmt -"),
1056 runtime: true,
1057 },
1058 ToolDocMeta {
1059 id: "terraform-fmt",
1060 language: "Terraform",
1061 kind: ToolKind::Format,
1062 doc_group: "terraform:format",
1063 display_command: Some("terraform fmt -"),
1064 runtime: true,
1065 },
1066 ToolDocMeta {
1067 id: "nixfmt",
1068 language: "Nix",
1069 kind: ToolKind::Format,
1070 doc_group: "nixfmt",
1071 display_command: None,
1072 runtime: true,
1073 },
1074 ToolDocMeta {
1075 id: "stylua",
1076 language: "Lua",
1077 kind: ToolKind::Format,
1078 doc_group: "stylua",
1079 display_command: None,
1080 runtime: true,
1081 },
1082 ToolDocMeta {
1083 id: "ormolu",
1084 language: "Haskell",
1085 kind: ToolKind::Format,
1086 doc_group: "ormolu",
1087 display_command: None,
1088 runtime: true,
1089 },
1090 ToolDocMeta {
1091 id: "elm-format",
1092 language: "Elm",
1093 kind: ToolKind::Format,
1094 doc_group: "elm-format",
1095 display_command: None,
1096 runtime: true,
1097 },
1098 ToolDocMeta {
1099 id: "swift-format",
1100 language: "Swift",
1101 kind: ToolKind::Format,
1102 doc_group: "swift-format",
1103 display_command: None,
1104 runtime: true,
1105 },
1106 ToolDocMeta {
1107 id: "ktfmt",
1108 language: "Kotlin",
1109 kind: ToolKind::Format,
1110 doc_group: "ktfmt",
1111 display_command: None,
1112 runtime: true,
1113 },
1114 ToolDocMeta {
1117 id: "djlint",
1118 language: "Jinja/HTML",
1119 kind: ToolKind::Both,
1120 doc_group: "djlint",
1121 display_command: Some("djlint - / djlint - --reformat"),
1122 runtime: true,
1123 },
1124 ToolDocMeta {
1125 id: "djlint:lint",
1126 language: "Jinja/HTML",
1127 kind: ToolKind::Lint,
1128 doc_group: "djlint:lint",
1129 display_command: Some("djlint -"),
1130 runtime: true,
1131 },
1132 ToolDocMeta {
1133 id: "djlint:reformat",
1134 language: "Jinja/HTML",
1135 kind: ToolKind::Format,
1136 doc_group: "djlint:reformat",
1137 display_command: None,
1138 runtime: true,
1139 },
1140 ToolDocMeta {
1141 id: "beautysh",
1142 language: "Shell",
1143 kind: ToolKind::Format,
1144 doc_group: "beautysh",
1145 display_command: None,
1146 runtime: true,
1147 },
1148 ToolDocMeta {
1149 id: "tombi",
1150 language: "TOML",
1151 kind: ToolKind::Lint,
1152 doc_group: "tombi",
1153 display_command: None,
1154 runtime: true,
1155 },
1156 ToolDocMeta {
1157 id: "tombi:format",
1158 language: "TOML",
1159 kind: ToolKind::Format,
1160 doc_group: "tombi:format",
1161 display_command: None,
1162 runtime: true,
1163 },
1164 ToolDocMeta {
1165 id: "tombi:lint",
1166 language: "TOML",
1167 kind: ToolKind::Lint,
1168 doc_group: "tombi:lint",
1169 display_command: None,
1170 runtime: true,
1171 },
1172 ToolDocMeta {
1173 id: "oxfmt",
1174 language: "Multi",
1175 kind: ToolKind::Format,
1176 doc_group: "oxfmt",
1177 display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1178 runtime: true,
1179 },
1180 ToolDocMeta {
1181 id: "oxfmt:js",
1182 language: "Multi",
1183 kind: ToolKind::Format,
1184 doc_group: "oxfmt",
1185 display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1186 runtime: true,
1187 },
1188 ToolDocMeta {
1189 id: "oxfmt:ts",
1190 language: "Multi",
1191 kind: ToolKind::Format,
1192 doc_group: "oxfmt",
1193 display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1194 runtime: true,
1195 },
1196 ToolDocMeta {
1197 id: "oxfmt:jsx",
1198 language: "Multi",
1199 kind: ToolKind::Format,
1200 doc_group: "oxfmt",
1201 display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1202 runtime: true,
1203 },
1204 ToolDocMeta {
1205 id: "oxfmt:tsx",
1206 language: "Multi",
1207 kind: ToolKind::Format,
1208 doc_group: "oxfmt",
1209 display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1210 runtime: true,
1211 },
1212 ToolDocMeta {
1213 id: "oxfmt:json",
1214 language: "Multi",
1215 kind: ToolKind::Format,
1216 doc_group: "oxfmt",
1217 display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1218 runtime: true,
1219 },
1220 ToolDocMeta {
1221 id: "oxfmt:css",
1222 language: "Multi",
1223 kind: ToolKind::Format,
1224 doc_group: "oxfmt",
1225 display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1226 runtime: true,
1227 },
1228 ToolDocMeta {
1229 id: "deno-fmt",
1230 language: "Multi",
1231 kind: ToolKind::Format,
1232 doc_group: "deno-fmt",
1233 display_command: Some("deno fmt --ext=EXT -"),
1234 runtime: true,
1235 },
1236 ToolDocMeta {
1237 id: "deno-fmt:ts",
1238 language: "Multi",
1239 kind: ToolKind::Format,
1240 doc_group: "deno-fmt",
1241 display_command: Some("deno fmt --ext=EXT -"),
1242 runtime: true,
1243 },
1244 ToolDocMeta {
1245 id: "deno-fmt:js",
1246 language: "Multi",
1247 kind: ToolKind::Format,
1248 doc_group: "deno-fmt",
1249 display_command: Some("deno fmt --ext=EXT -"),
1250 runtime: true,
1251 },
1252 ToolDocMeta {
1253 id: "deno-fmt:json",
1254 language: "Multi",
1255 kind: ToolKind::Format,
1256 doc_group: "deno-fmt",
1257 display_command: Some("deno fmt --ext=EXT -"),
1258 runtime: true,
1259 },
1260 ToolDocMeta {
1261 id: "deno-fmt:jsonc",
1262 language: "Multi",
1263 kind: ToolKind::Format,
1264 doc_group: "deno-fmt",
1265 display_command: Some("deno fmt --ext=EXT -"),
1266 runtime: true,
1267 },
1268 ToolDocMeta {
1269 id: "deno-fmt:md",
1270 language: "Multi",
1271 kind: ToolKind::Format,
1272 doc_group: "deno-fmt",
1273 display_command: Some("deno fmt --ext=EXT -"),
1274 runtime: true,
1275 },
1276 ToolDocMeta {
1277 id: "shuck:lint",
1278 language: "Shell",
1279 kind: ToolKind::Lint,
1280 doc_group: "shuck:lint",
1281 display_command: None,
1282 runtime: true,
1283 },
1284 ToolDocMeta {
1285 id: "shuck:lint-fix",
1286 language: "Shell",
1287 kind: ToolKind::Format,
1288 doc_group: "shuck:lint-fix",
1289 display_command: None,
1290 runtime: true,
1291 },
1292 ToolDocMeta {
1293 id: "shuck:format-check",
1294 language: "Shell",
1295 kind: ToolKind::FormatCheck,
1296 doc_group: "shuck:format-check",
1297 display_command: None,
1298 runtime: true,
1299 },
1300 ToolDocMeta {
1301 id: "oxfmt:lint",
1302 language: "JavaScript",
1303 kind: ToolKind::FormatCheck,
1304 doc_group: "oxfmt:lint",
1305 display_command: None,
1306 runtime: true,
1307 },
1308 ToolDocMeta {
1309 id: "oxfmt:format",
1310 language: "JavaScript",
1311 kind: ToolKind::Format,
1312 doc_group: "oxfmt:format",
1313 display_command: None,
1314 runtime: true,
1315 },
1316 ToolDocMeta {
1317 id: "djlint:html:lint",
1318 language: "HTML",
1319 kind: ToolKind::Lint,
1320 doc_group: "djlint:html:lint",
1321 display_command: Some("djlint - --profile=html"),
1322 runtime: true,
1323 },
1324 ToolDocMeta {
1325 id: "djlint:html:format-check",
1326 language: "HTML",
1327 kind: ToolKind::FormatCheck,
1328 doc_group: "djlint:html:format-check",
1329 display_command: Some("djlint - --reformat --profile=html"),
1330 runtime: true,
1331 },
1332 ToolDocMeta {
1333 id: "djlint:html:format",
1334 language: "HTML",
1335 kind: ToolKind::Format,
1336 doc_group: "djlint:html:format",
1337 display_command: Some("djlint - --reformat --profile=html"),
1338 runtime: true,
1339 },
1340 ToolDocMeta {
1341 id: "djlint:jinja:lint",
1342 language: "Jinja",
1343 kind: ToolKind::Lint,
1344 doc_group: "djlint:jinja:lint",
1345 display_command: Some("djlint - --profile=jinja"),
1346 runtime: true,
1347 },
1348 ToolDocMeta {
1349 id: "djlint:jinja:format-check",
1350 language: "Jinja",
1351 kind: ToolKind::FormatCheck,
1352 doc_group: "djlint:jinja:format-check",
1353 display_command: Some("djlint - --reformat --profile=jinja"),
1354 runtime: true,
1355 },
1356 ToolDocMeta {
1357 id: "djlint:jinja:format",
1358 language: "Jinja",
1359 kind: ToolKind::Format,
1360 doc_group: "djlint:jinja:format",
1361 display_command: Some("djlint - --reformat --profile=jinja"),
1362 runtime: true,
1363 },
1364 ToolDocMeta {
1365 id: "rumdl:lint",
1366 language: "Markdown",
1367 kind: ToolKind::Lint,
1368 doc_group: "rumdl:lint",
1369 display_command: Some("built-in markdown linting"),
1370 runtime: false,
1371 },
1372 ToolDocMeta {
1373 id: "rumdl:format",
1374 language: "Markdown",
1375 kind: ToolKind::Format,
1376 doc_group: "rumdl:format",
1377 display_command: Some("built-in markdown formatting"),
1378 runtime: false,
1379 },
1380 ToolDocMeta {
1383 id: RUMDL_BUILTIN_TOOL,
1384 language: "Markdown",
1385 kind: ToolKind::Lint,
1386 doc_group: RUMDL_BUILTIN_TOOL,
1387 display_command: Some("built-in markdown linting"),
1388 runtime: false,
1389 },
1390];
1391
1392const TABLE_BEGIN: &str = "<!-- BEGIN builtin-tools (generated) -->";
1394const TABLE_END: &str = "<!-- END builtin-tools (generated) -->";
1395
1396#[derive(Debug, Clone, PartialEq, Eq)]
1398pub enum DocsError {
1399 MissingMarker,
1401 DuplicateMarker,
1403 MarkerOrder,
1405 CountRowMissing,
1407 CountRowAmbiguous,
1409 CountRowMalformed,
1411}
1412
1413impl std::fmt::Display for DocsError {
1414 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1415 let msg = match self {
1416 DocsError::MissingMarker => "missing `<!-- BEGIN/END builtin-tools (generated) -->` marker pair",
1417 DocsError::DuplicateMarker => "duplicate builtin-tools marker",
1418 DocsError::MarkerOrder => "END builtin-tools marker precedes BEGIN",
1419 DocsError::CountRowMissing => "`| Built-in tools` count row not found",
1420 DocsError::CountRowAmbiguous => "multiple `| Built-in tools` count rows found",
1421 DocsError::CountRowMalformed => "`| Built-in tools` count row has an unexpected layout",
1422 };
1423 f.write_str(msg)
1424 }
1425}
1426
1427impl std::error::Error for DocsError {}
1428
1429fn builtin_tools_group_count() -> usize {
1431 let mut seen: Vec<&str> = Vec::new();
1432 for m in BUILTIN_TOOLS_DOCS {
1433 if !seen.contains(&m.doc_group) {
1434 seen.push(m.doc_group);
1435 }
1436 }
1437 seen.len()
1438}
1439
1440pub fn render_builtin_tools_table() -> String {
1446 let headers = ["Tool ID", "Language", "Type", "Command"];
1447 let mut rows: Vec<[String; 4]> = Vec::new();
1448
1449 let mut seen: Vec<&str> = Vec::new();
1450 for m in BUILTIN_TOOLS_DOCS {
1451 if seen.contains(&m.doc_group) {
1452 continue;
1453 }
1454 seen.push(m.doc_group);
1455
1456 let command = match m.display_command {
1459 Some(cmd) => cmd.to_string(),
1460 None if m.runtime => runtime_command_for_kind(m.id, m.kind),
1461 None => String::new(),
1462 };
1463
1464 rows.push([
1465 format!("`{}`", m.doc_group),
1466 m.language.to_string(),
1467 m.kind.label().to_string(),
1468 format!("`{command}`"),
1469 ]);
1470 }
1471
1472 let mut widths = headers.map(str::chars).map(Iterator::count);
1474 for row in &rows {
1475 for (i, cell) in row.iter().enumerate() {
1476 widths[i] = widths[i].max(cell.chars().count());
1477 }
1478 }
1479
1480 let mut out = String::new();
1481 push_table_row(&mut out, &headers.map(String::from), &widths);
1482 let separators = std::array::from_fn(|i| "-".repeat(widths[i]));
1483 push_table_row(&mut out, &separators, &widths);
1484 for row in &rows {
1485 push_table_row(&mut out, row, &widths);
1486 }
1487 out
1488}
1489
1490fn runtime_command_for_kind(id: &str, kind: ToolKind) -> String {
1495 let Some(def) = BUILTIN_TOOLS.get(id) else {
1496 return String::new();
1497 };
1498 let invocation =
1499 |extra: &[String]| -> String { def.command.iter().chain(extra).cloned().collect::<Vec<_>>().join(" ") };
1500 match kind {
1501 ToolKind::Lint => invocation(&def.lint_args),
1502 ToolKind::Format | ToolKind::FormatCheck => invocation(&def.format_args),
1503 ToolKind::Both => {
1504 let lint = invocation(&def.lint_args);
1505 let format = invocation(&def.format_args);
1506 if lint == format {
1507 lint
1508 } else {
1509 format!("{lint} / {format}")
1510 }
1511 }
1512 }
1513}
1514
1515fn push_table_row(out: &mut String, cells: &[String; 4], widths: &[usize; 4]) {
1517 out.push('|');
1518 for (i, cell) in cells.iter().enumerate() {
1519 out.push(' ');
1520 out.push_str(cell);
1521 for _ in cell.chars().count()..widths[i] {
1522 out.push(' ');
1523 }
1524 out.push_str(" |");
1525 }
1526 out.push('\n');
1527}
1528
1529pub fn splice_builtin_tools_docs(existing: &str) -> Result<String, DocsError> {
1535 if existing.matches(TABLE_BEGIN).count() == 0 || existing.matches(TABLE_END).count() == 0 {
1536 return Err(DocsError::MissingMarker);
1537 }
1538 if existing.matches(TABLE_BEGIN).count() > 1 || existing.matches(TABLE_END).count() > 1 {
1539 return Err(DocsError::DuplicateMarker);
1540 }
1541 let begin_pos = existing.find(TABLE_BEGIN).unwrap();
1542 let end_pos = existing.find(TABLE_END).unwrap();
1543 if end_pos < begin_pos {
1544 return Err(DocsError::MarkerOrder);
1545 }
1546
1547 let table = render_builtin_tools_table();
1548 let replacement = format!("{TABLE_BEGIN}\n\n{table}\n{TABLE_END}");
1549
1550 let mut result = String::with_capacity(existing.len() + replacement.len());
1551 result.push_str(&existing[..begin_pos]);
1552 result.push_str(&replacement);
1553 result.push_str(&existing[end_pos + TABLE_END.len()..]);
1554
1555 update_builtin_tools_count(&result, builtin_tools_group_count())
1556}
1557
1558fn update_builtin_tools_count(text: &str, count: usize) -> Result<String, DocsError> {
1561 let lines: Vec<&str> = text.lines().collect();
1562 let mut target: Option<usize> = None;
1563 for (i, line) in lines.iter().enumerate() {
1564 if line.trim_start().starts_with("| Built-in tools") {
1565 if target.is_some() {
1566 return Err(DocsError::CountRowAmbiguous);
1567 }
1568 target = Some(i);
1569 }
1570 }
1571 let i = target.ok_or(DocsError::CountRowMissing)?;
1572 let new_line = replace_count_cell(lines[i], count).ok_or(DocsError::CountRowMalformed)?;
1573
1574 let mut out = String::with_capacity(text.len());
1575 for (j, line) in lines.iter().enumerate() {
1576 out.push_str(if j == i { &new_line } else { line });
1577 out.push('\n');
1578 }
1579 if !text.ends_with('\n') {
1580 out.pop();
1581 }
1582 Ok(out)
1583}
1584
1585fn replace_count_cell(line: &str, count: usize) -> Option<String> {
1587 let pipes: Vec<usize> = line.match_indices('|').map(|(i, _)| i).collect();
1588 if pipes.len() < 3 {
1589 return None;
1590 }
1591 let start = pipes[1] + 1;
1592 let end = pipes[2];
1593 let cell = line.get(start..end)?;
1594 let width = cell.len();
1595 let num = count.to_string();
1596
1597 let mut new_cell = String::with_capacity(width.max(num.len() + 2));
1598 new_cell.push(' ');
1599 new_cell.push_str(&num);
1600 while new_cell.len() < width {
1601 new_cell.push(' ');
1602 }
1603 if new_cell.len() > width {
1604 new_cell = format!(" {num} ");
1606 }
1607
1608 Some(format!("{}{}{}", &line[..start], new_cell, &line[end..]))
1609}
1610
1611#[cfg(test)]
1612mod tests {
1613 use super::*;
1614
1615 #[test]
1616 fn test_get_builtin_tool() {
1617 let registry = ToolRegistry::default();
1618
1619 let tool = registry.get("ruff:check").expect("Should find ruff:check");
1620 assert!(tool.command.contains(&"ruff".to_string()));
1621 assert!(tool.stdin);
1622 assert!(tool.stdout);
1623
1624 let tool = registry.get("shellcheck").expect("Should find shellcheck");
1625 assert!(tool.command.contains(&"shellcheck".to_string()));
1626 }
1627
1628 #[test]
1629 fn test_builtin_yamlfmt_answers_lint_by_formatting() {
1630 let registry = ToolRegistry::default();
1631
1632 assert_eq!(registry.lint_mode("yamlfmt"), Some(BuiltinLintMode::FormatCheck));
1633
1634 let tool = registry.get("yamlfmt").expect("Should find yamlfmt");
1636 let mut argv = tool.command.clone();
1637 argv.extend(tool.format_args.clone());
1638
1639 assert_eq!(argv, vec!["yamlfmt", "-"]);
1640 assert!(tool.lint_args.is_empty());
1641 }
1642
1643 #[test]
1644 fn test_get_user_tool_overrides_builtin() {
1645 let mut user_tools = BTreeMap::new();
1646 user_tools.insert(
1647 "ruff:check".to_string(),
1648 ToolDefinition {
1649 command: vec!["custom-ruff".to_string()],
1650 stdin: false,
1651 stdout: false,
1652 lint_args: vec![],
1653 format_args: vec![],
1654 },
1655 );
1656
1657 let registry = ToolRegistry::new(user_tools);
1658
1659 let tool = registry.get("ruff:check").expect("Should find ruff:check");
1660 assert_eq!(tool.command, vec!["custom-ruff"]);
1661 assert!(!tool.stdin); }
1663
1664 #[test]
1665 fn test_contains() {
1666 let registry = ToolRegistry::default();
1667
1668 assert!(registry.contains("ruff:check"));
1669 assert!(registry.contains("prettier"));
1670 assert!(registry.contains("shellcheck"));
1671 assert!(!registry.contains("nonexistent-tool"));
1672 }
1673
1674 #[test]
1675 fn test_list_tools() {
1676 let registry = ToolRegistry::default();
1677 let tools = registry.list_tools();
1678
1679 assert!(tools.contains(&"ruff:check"));
1680 assert!(tools.contains(&"ruff:format"));
1681 assert!(tools.contains(&"prettier"));
1682 assert!(tools.contains(&"shellcheck"));
1683 assert!(tools.contains(&"shfmt"));
1684 assert!(tools.contains(&"rustfmt"));
1685 assert!(tools.contains(&"gofmt"));
1686 }
1687
1688 #[test]
1689 fn test_user_tools_in_list() {
1690 let mut user_tools = BTreeMap::new();
1691 user_tools.insert("my-custom-tool".to_string(), ToolDefinition::default());
1692
1693 let registry = ToolRegistry::new(user_tools);
1694 let tools = registry.list_tools();
1695
1696 assert!(tools.contains(&"my-custom-tool"));
1697 assert!(tools.contains(&"ruff:check")); }
1699
1700 #[test]
1701 fn test_new_builtin_tools() {
1702 let registry = ToolRegistry::default();
1703
1704 let tool = registry.get("djlint").expect("Should find djlint");
1706 assert!(tool.command.contains(&"djlint".to_string()));
1707 assert!(tool.stdin);
1708
1709 let tool = registry.get("beautysh").expect("Should find beautysh");
1711 assert!(tool.command.contains(&"beautysh".to_string()));
1712 assert!(tool.stdin);
1713
1714 let tool = registry.get("tombi").expect("Should find tombi");
1716 assert!(tool.command.contains(&"tombi".to_string()));
1717 assert!(tool.stdin);
1718
1719 let tool = registry.get("tombi:lint").expect("Should find tombi:lint");
1720 assert!(tool.command.contains(&"lint".to_string()));
1721
1722 let tool = registry.get("tombi:format").expect("Should find tombi:format");
1723 assert!(
1724 tool.command.contains(&"format".to_string()),
1725 "tombi:format should use 'format' subcommand, got: {:?}",
1726 tool.command
1727 );
1728
1729 let tool = registry.get("oxfmt").expect("Should find oxfmt");
1731 assert!(tool.command.contains(&"oxfmt".to_string()));
1732 assert!(tool.stdin);
1733
1734 let tool = registry.get("oxfmt:ts").expect("Should find oxfmt:ts");
1735 assert!(tool.command.iter().any(|s| s.contains("_.ts")));
1736 }
1737
1738 #[test]
1746 fn test_bare_tombi_resolves_to_lint_not_format() {
1747 let registry = ToolRegistry::default();
1748
1749 let bare = registry.get("tombi").expect("Should find bare tombi");
1750 let format = registry.get("tombi:format").expect("Should find tombi:format");
1751
1752 assert!(
1754 bare.command.contains(&"lint".to_string()),
1755 "Bare 'tombi' uses lint subcommand: {:?}",
1756 bare.command
1757 );
1758
1759 assert!(
1761 format.command.contains(&"format".to_string()),
1762 "tombi:format uses format subcommand: {:?}",
1763 format.command
1764 );
1765
1766 assert_ne!(
1768 bare.command, format.command,
1769 "Bare 'tombi' and 'tombi:format' should have different commands (this is the root cause of #527)"
1770 );
1771 }
1772
1773 #[test]
1776 fn test_tools_with_lint_format_variants_are_distinct() {
1777 let registry = ToolRegistry::default();
1778
1779 let ruff_check = registry.get("ruff:check").expect("ruff:check");
1781 let ruff_format = registry.get("ruff:format").expect("ruff:format");
1782 assert_ne!(
1783 ruff_check.command, ruff_format.command,
1784 "ruff:check and ruff:format should be distinct"
1785 );
1786
1787 let tombi_lint = registry.get("tombi:lint").expect("tombi:lint");
1789 let tombi_format = registry.get("tombi:format").expect("tombi:format");
1790 assert_ne!(
1791 tombi_lint.command, tombi_format.command,
1792 "tombi:lint and tombi:format should be distinct"
1793 );
1794 }
1795
1796 #[test]
1797 fn test_deno_fmt_has_per_extension_variants() {
1798 let registry = ToolRegistry::default();
1801
1802 let deno_json = registry.get("deno-fmt:json").expect("deno-fmt:json");
1803 assert!(deno_json.command.iter().any(|a| a == "--ext=json"));
1804 let deno_md = registry.get("deno-fmt:md").expect("deno-fmt:md");
1805 assert!(deno_md.command.iter().any(|a| a == "--ext=md"));
1806
1807 let deno = registry.get("deno-fmt").expect("deno-fmt");
1809 assert!(deno.command.iter().any(|a| a == "--ext=ts"));
1810 }
1811
1812 #[test]
1821 fn no_builtin_declares_lint_args() {
1822 let offenders: Vec<&str> = BUILTIN_TOOLS
1823 .iter()
1824 .filter(|(_, def)| !def.lint_args.is_empty())
1825 .map(|(id, _)| *id)
1826 .collect();
1827
1828 assert!(
1829 offenders.is_empty(),
1830 "built-in tools must not declare lint_args (a formatter is lint-checked by \
1831 formatting and comparing; a linter's args belong in `command`): {offenders:?}"
1832 );
1833 }
1834
1835 #[test]
1839 fn builtin_lint_mode_follows_documented_kind() {
1840 let registry = ToolRegistry::default();
1841
1842 for meta in BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime) {
1843 let def = registry.get(meta.id).expect("runtime metadata matches the registry");
1844 let mode = registry.lint_mode(meta.id);
1845
1846 match meta.kind {
1847 ToolKind::Format | ToolKind::FormatCheck => {
1848 assert_eq!(
1849 mode,
1850 Some(BuiltinLintMode::FormatCheck),
1851 "{} is documented as a formatter",
1852 meta.id
1853 );
1854 assert!(
1855 def.stdin && def.stdout,
1856 "{} is lint-checked by comparing its output, so it must read stdin \
1857 and write stdout",
1858 meta.id
1859 );
1860 }
1861 ToolKind::Lint | ToolKind::Both => {
1862 assert_eq!(
1863 mode,
1864 Some(BuiltinLintMode::Diagnostics),
1865 "{} is documented as a linter",
1866 meta.id
1867 );
1868 }
1869 }
1870 }
1871 }
1872
1873 #[test]
1883 fn format_slot_never_resolves_to_a_linter() {
1884 let registry = ToolRegistry::default();
1885 let documented_kind = |id: &str| {
1886 BUILTIN_TOOLS_DOCS
1887 .iter()
1888 .find(|m| m.id == id && m.runtime)
1889 .map(|m| m.kind)
1890 };
1891
1892 for meta in BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime) {
1893 let fills = registry.fills_format_slot(meta.id);
1894 let resolved = registry.resolve_id(meta.id, ToolSlot::Format);
1895
1896 match fills {
1897 Some(true) => {
1898 let resolved = resolved.expect("a tool that fills the slot resolves in it");
1899 assert!(
1900 !matches!(documented_kind(&resolved), Some(ToolKind::Lint | ToolKind::FormatCheck)),
1901 "{} fills a format slot by running {resolved}, which only lints",
1902 meta.id
1903 );
1904 }
1905 Some(false) => assert!(
1906 matches!(documented_kind(meta.id), Some(ToolKind::Lint | ToolKind::FormatCheck)),
1907 "{} declines the format slot, so it must be documented as a linter",
1908 meta.id
1909 ),
1910 None => panic!("{} is in the registry, so it resolves somewhere", meta.id),
1911 }
1912 }
1913
1914 assert_eq!(registry.fills_format_slot("ruff:check"), Some(false));
1917 assert_eq!(registry.fills_format_slot("shellcheck"), Some(false));
1918 assert_eq!(registry.fills_format_slot("sqlfluff:lint"), Some(false));
1919 assert_eq!(registry.fills_format_slot("tombi"), Some(true));
1920 assert_eq!(
1921 registry.resolve_id("tombi", ToolSlot::Format).as_deref(),
1922 Some("tombi:format")
1923 );
1924 }
1925
1926 #[test]
1928 fn user_tool_is_never_lint_checked_by_formatting() {
1929 let mut user_tools = BTreeMap::new();
1930 user_tools.insert(
1931 "yamlfmt".to_string(),
1932 ToolDefinition {
1933 command: vec!["my-yaml-linter".to_string()],
1934 stdin: true,
1935 stdout: true,
1936 lint_args: vec!["--check".to_string()],
1937 format_args: vec![],
1938 },
1939 );
1940 let registry = ToolRegistry::new(user_tools);
1941
1942 assert_eq!(registry.lint_mode("yamlfmt"), None);
1943 assert_eq!(
1945 ToolRegistry::default().lint_mode("yamlfmt"),
1946 Some(BuiltinLintMode::FormatCheck)
1947 );
1948 }
1949
1950 #[test]
1955 fn explicit_mode_aliases_preserve_custom_bare_definitions() {
1956 for id in ["oxfmt", "shuck"] {
1957 let custom = ToolDefinition {
1958 command: vec!["custom-tool".to_string()],
1959 stdin: true,
1960 stdout: true,
1961 lint_args: vec![],
1962 format_args: vec![],
1963 };
1964 let registry = ToolRegistry::new(BTreeMap::from([(id.to_string(), custom.clone())]));
1965 for slot in [ToolSlot::Lint, ToolSlot::Format] {
1966 assert_eq!(registry.resolve(id, slot), Some(&custom));
1967 }
1968 }
1969 }
1970
1971 #[test]
1972 fn explicit_check_variants_compare_output_but_cannot_format() {
1973 let registry = ToolRegistry::default();
1974 for id in [
1975 "oxfmt:lint",
1976 "djlint:html:format-check",
1977 "djlint:jinja:format-check",
1978 "shuck:format-check",
1979 ] {
1980 assert_eq!(registry.lint_mode(id), Some(BuiltinLintMode::FormatCheck));
1981 assert_eq!(registry.fills_format_slot(id), Some(false));
1982 }
1983 for profile in ["html", "jinja"] {
1984 for mode in ["lint", "format-check", "format"] {
1985 let id = format!("djlint:{profile}:{mode}");
1986 assert!(
1987 registry
1988 .get(&id)
1989 .unwrap()
1990 .command
1991 .contains(&format!("--profile={profile}"))
1992 );
1993 }
1994 }
1995 }
1996
1997 #[test]
1998 fn test_docs_metadata_ids_unique() {
1999 let mut seen = std::collections::BTreeSet::new();
2000 for m in BUILTIN_TOOLS_DOCS {
2001 assert!(seen.insert(m.id), "duplicate metadata id: {}", m.id);
2002 }
2003 }
2004
2005 #[test]
2006 fn test_runtime_metadata_matches_registry_keys() {
2007 let runtime_meta: std::collections::BTreeSet<&str> =
2008 BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime).map(|m| m.id).collect();
2009 let map_keys: std::collections::BTreeSet<&str> = BUILTIN_TOOLS.keys().copied().collect();
2010 assert_eq!(
2011 runtime_meta, map_keys,
2012 "BUILTIN_TOOLS_DOCS runtime ids must exactly match BUILTIN_TOOLS keys (add/remove the doc entry alongside the registry entry)"
2013 );
2014 }
2015
2016 #[test]
2017 fn test_docs_only_ids_absent_from_registry() {
2018 for m in BUILTIN_TOOLS_DOCS.iter().filter(|m| !m.runtime) {
2019 assert!(
2020 !BUILTIN_TOOLS.contains_key(m.id),
2021 "docs-only id {} must not be a runtime registry tool",
2022 m.id
2023 );
2024 }
2025 assert!(
2027 BUILTIN_TOOLS_DOCS
2028 .iter()
2029 .any(|m| !m.runtime && m.id == RUMDL_BUILTIN_TOOL),
2030 "rumdl must be present as a docs-only entry"
2031 );
2032 }
2033
2034 #[test]
2035 fn test_docs_only_requires_display_command() {
2036 for m in BUILTIN_TOOLS_DOCS.iter().filter(|m| !m.runtime) {
2037 assert!(
2038 m.display_command.is_some(),
2039 "docs-only id {} needs a display_command (no runtime command to derive)",
2040 m.id
2041 );
2042 }
2043 }
2044
2045 #[test]
2046 fn test_multi_runtime_group_requires_display_command() {
2047 let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
2048 for m in BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime) {
2049 *counts.entry(m.doc_group).or_default() += 1;
2050 }
2051 for m in BUILTIN_TOOLS_DOCS {
2052 if counts.get(m.doc_group).copied().unwrap_or(0) > 1 {
2053 assert!(
2054 m.display_command.is_some(),
2055 "doc_group `{}` has multiple runtime entries; `{}` needs an explicit display_command",
2056 m.doc_group,
2057 m.id
2058 );
2059 }
2060 }
2061 }
2062
2063 #[test]
2064 fn test_doc_group_language_and_kind_consistent() {
2065 let mut groups: std::collections::BTreeMap<&str, (&str, ToolKind)> = std::collections::BTreeMap::new();
2066 for m in BUILTIN_TOOLS_DOCS {
2067 match groups.get(m.doc_group) {
2068 None => {
2069 groups.insert(m.doc_group, (m.language, m.kind));
2070 }
2071 Some((lang, kind)) => {
2072 assert_eq!(
2073 *lang, m.language,
2074 "doc_group `{}` has mismatched languages",
2075 m.doc_group
2076 );
2077 assert_eq!(*kind, m.kind, "doc_group `{}` has mismatched kinds", m.doc_group);
2078 }
2079 }
2080 }
2081 }
2082
2083 fn rendered_rows(table: &str) -> Vec<(String, String, String, String)> {
2090 table
2091 .lines()
2092 .skip(2) .filter(|l| l.starts_with('|'))
2094 .map(|l| {
2095 let cells: Vec<String> = l.trim().trim_matches('|').split('|').map(|c| c.trim().to_string()).collect();
2096 (cells[0].clone(), cells[1].clone(), cells[2].clone(), cells[3].clone())
2097 })
2098 .collect()
2099 }
2100
2101 #[test]
2102 fn test_render_table_golden_rows() {
2103 let table = render_builtin_tools_table();
2104 let rows = rendered_rows(&table);
2105 let has = |id: &str, lang: &str, kind: &str, cmd: &str| {
2106 rows.iter()
2107 .any(|(i, l, k, c)| i == id && l == lang && k == kind && c == cmd)
2108 };
2109 assert!(has("`shellcheck`", "Shell", "Lint", "`shellcheck --shell=bash -`"));
2111 assert!(has("`rustfmt`", "Rust", "Format", "`rustfmt`"));
2112 assert!(has("`jq`", "JSON", "Both", "`jq .`"));
2113 assert!(has(
2114 "`prettier`",
2115 "Multi",
2116 "Format",
2117 "`prettier --stdin-filepath=_.EXT`"
2118 ));
2119 assert!(has("`rumdl`", "Markdown", "Lint", "`built-in markdown linting`"));
2120 assert!(
2122 !table.contains("prettier:json"),
2123 "prettier variants must collapse into one row"
2124 );
2125 assert!(!table.contains("oxfmt:ts"), "oxfmt variants must collapse into one row");
2126 }
2127
2128 #[test]
2129 fn test_render_table_one_row_per_group() {
2130 let table = render_builtin_tools_table();
2131 assert_eq!(rendered_rows(&table).len(), builtin_tools_group_count());
2132 }
2133
2134 #[test]
2135 fn test_render_table_command_fallback_from_registry() {
2136 let table = render_builtin_tools_table();
2138 assert!(
2139 rendered_rows(&table)
2140 .iter()
2141 .any(|(i, _, _, c)| i == "`tombi`" && c == "`tombi lint -`")
2142 );
2143 }
2144
2145 #[test]
2146 fn test_render_command_reflects_mode_args() {
2147 let table = render_builtin_tools_table();
2148 let rows = rendered_rows(&table);
2149 let cmd = |id: &str| {
2150 let row = rows
2151 .iter()
2152 .find(|(i, _, _, _)| i == id)
2153 .unwrap_or_else(|| panic!("row {id} not found"));
2154 row.3.clone()
2155 };
2156 assert_eq!(cmd("`yamlfmt`"), "`yamlfmt -`");
2158 assert_eq!(
2160 cmd("`sqlfluff:lint`"),
2161 "`sqlfluff lint --dialect ansi --format github-annotation-native -`"
2162 );
2163 }
2164
2165 #[test]
2166 fn test_runtime_command_for_kind_both_collapses_when_equal() {
2167 assert_eq!(runtime_command_for_kind("jq", ToolKind::Both), "jq .");
2169 }
2170
2171 #[test]
2176 fn test_runtime_command_for_kind_both_shows_both_invocations() {
2177 let rendered = runtime_command_for_kind("djlint", ToolKind::Both);
2178 let (lint, format) = rendered
2179 .split_once(" / ")
2180 .expect("differing invocations render as a pair");
2181 assert_eq!(
2182 lint,
2183 "djlint - --linter-output-format {filename}:{line}: {code} {message}"
2184 );
2185 assert_eq!(format, format!("{lint} --reformat"));
2186 }
2187
2188 #[test]
2194 fn a_curated_display_command_names_the_program_that_runs() {
2195 for meta in BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime) {
2196 let Some(display) = meta.display_command else {
2197 continue;
2198 };
2199 let def = BUILTIN_TOOLS
2200 .get(meta.id)
2201 .expect("runtime metadata matches the registry");
2202 let program = def.command.first().expect("a built-in command names a program");
2203
2204 for invocation in display.split(" / ") {
2205 assert_eq!(
2206 invocation.split_whitespace().next(),
2207 Some(program.as_str()),
2208 "display_command for `{}` starts with a different program than it runs ({program})",
2209 meta.id
2210 );
2211 }
2212 }
2213 }
2214
2215 #[test]
2220 fn test_splice_replaces_region_and_preserves_prose() {
2221 let doc = format!(
2222 "# Title\n\nIntro.\n\n{TABLE_BEGIN}\n\nstale\n\n{TABLE_END}\n\nAfter.\n\n| Built-in tools | 31 | 339 |\n"
2223 );
2224 let out = splice_builtin_tools_docs(&doc).expect("splice");
2225 assert!(out.starts_with("# Title\n\nIntro.\n\n"));
2226 assert!(out.contains("After.\n"));
2227 assert!(!out.contains("stale"));
2228 assert!(
2229 out.contains(&render_builtin_tools_table()),
2230 "generated table is spliced in verbatim"
2231 );
2232 assert!(out.contains(&format!("| Built-in tools | {} | 339 |", builtin_tools_group_count())));
2234 }
2235
2236 #[test]
2237 fn test_splice_missing_marker_errors() {
2238 let doc = "# Title\n\nno markers\n\n| Built-in tools | 31 | 339 |\n";
2239 assert_eq!(splice_builtin_tools_docs(doc), Err(DocsError::MissingMarker));
2240 }
2241
2242 #[test]
2243 fn test_splice_duplicate_marker_errors() {
2244 let doc = format!("{TABLE_BEGIN}\n\nx\n\n{TABLE_END}\n{TABLE_BEGIN}\n\ny\n\n{TABLE_END}\n");
2245 assert_eq!(splice_builtin_tools_docs(&doc), Err(DocsError::DuplicateMarker));
2246 }
2247
2248 #[test]
2249 fn test_splice_marker_order_errors() {
2250 let doc = format!("{TABLE_END}\n\nx\n\n{TABLE_BEGIN}\n\n| Built-in tools | 31 | 339 |\n");
2251 assert_eq!(splice_builtin_tools_docs(&doc), Err(DocsError::MarkerOrder));
2252 }
2253
2254 #[test]
2255 fn test_splice_missing_count_row_errors() {
2256 let doc = format!("{TABLE_BEGIN}\n\nx\n\n{TABLE_END}\n\nno count row here\n");
2257 assert_eq!(splice_builtin_tools_docs(&doc), Err(DocsError::CountRowMissing));
2258 }
2259
2260 #[test]
2261 fn test_splice_is_idempotent() {
2262 let doc = format!("{TABLE_BEGIN}\n\nstale\n\n{TABLE_END}\n\n| Built-in tools | 31 | 339 |\n");
2263 let once = splice_builtin_tools_docs(&doc).expect("first");
2264 let twice = splice_builtin_tools_docs(&once).expect("second");
2265 assert_eq!(once, twice, "splice must be idempotent");
2266 }
2267}