Skip to main content

rumdl_lib/code_block_tools/
registry.rs

1//! Built-in tool registry with definitions for common formatters and linters.
2//!
3//! This module provides default configurations for popular tools like ruff, prettier,
4//! shellcheck, etc. Users can override these in their configuration.
5
6use super::config::ToolDefinition;
7use super::processor::RUMDL_BUILTIN_TOOL;
8use std::collections::BTreeMap;
9use std::collections::HashMap;
10use std::sync::LazyLock;
11
12/// Registry of built-in tool definitions.
13pub struct ToolRegistry {
14    /// User-defined tools (override built-ins)
15    user_tools: BTreeMap<String, ToolDefinition>,
16}
17
18/// The configuration list a tool id was written in.
19#[derive(Debug, Copy, Clone, PartialEq, Eq)]
20pub enum ToolSlot {
21    /// `[code-block-tools.languages.<lang>] lint = [...]`
22    Lint,
23    /// `[code-block-tools.languages.<lang>] format = [...]`
24    Format,
25}
26
27impl ToolRegistry {
28    /// Create a new registry with user-defined tools.
29    pub fn new(user_tools: BTreeMap<String, ToolDefinition>) -> Self {
30        Self { user_tools }
31    }
32
33    /// Get a tool definition by ID.
34    ///
35    /// Checks user tools first, then falls back to built-in tools.
36    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    /// Check if a tool ID is valid (either user-defined or built-in).
41    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    /// Lint behavior for `tool_id`.
46    ///
47    /// `None` for a user-defined tool: the user wrote its command and `lint_args`, so it
48    /// is run as written and its output parsed, whatever the tool is.
49    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    /// Exact native argv for the verified built-in check modes. User overrides
57    /// retain their own commands; the formatter definition remains the fallback.
58    pub(crate) fn native_format_check(&self, tool_id: &str) -> Option<ToolDefinition> {
59        if self.user_tools.contains_key(tool_id) {
60            return None;
61        }
62        let command = match tool_id {
63            "shuck:format-check" => vec!["shuck", "format", "-", "--check"],
64            "djlint:html:format-check" => vec!["djlint", "-", "--check", "--profile=html"],
65            "djlint:jinja:format-check" => vec!["djlint", "-", "--check", "--profile=jinja"],
66            _ => return None,
67        };
68        Some(ToolDefinition {
69            command: command.into_iter().map(str::to_string).collect(),
70            stdin: true,
71            stdout: true,
72            lint_args: Vec::new(),
73            format_args: Vec::new(),
74        })
75    }
76
77    /// The registry id a configured tool id runs as in `slot`, if any.
78    ///
79    /// User definitions win over built-ins, with a slot-specific user variant
80    /// taking precedence over a bare user name. Built-in bare names try the slot's
81    /// variants first (`tombi` in a format slot runs `tombi:format`), then as written.
82    /// Config validation and execution both
83    /// go through here, so a tool id rumdl accepts at load time is the same one it runs.
84    pub fn resolve_id(&self, tool_id: &str, slot: ToolSlot) -> Option<String> {
85        // An id that already names a variant is used as written.
86        if tool_id.contains(':') {
87            return self.contains(tool_id).then(|| tool_id.to_string());
88        }
89
90        let suffixes = match slot {
91            ToolSlot::Format => &["format", "fmt", "fix", "reformat"][..],
92            ToolSlot::Lint => &["lint", "check"][..],
93        };
94        // Adding built-in aliases must not displace existing custom commands.
95        for suffix in suffixes {
96            let qualified = format!("{tool_id}:{suffix}");
97            if self.user_tools.contains_key(&qualified) {
98                return Some(qualified);
99            }
100        }
101        if self.user_tools.contains_key(tool_id) {
102            return Some(tool_id.to_string());
103        }
104        for suffix in suffixes {
105            let qualified = format!("{tool_id}:{suffix}");
106            if self.contains(&qualified) {
107                return Some(qualified);
108            }
109        }
110
111        if self.contains(tool_id) {
112            return Some(tool_id.to_string());
113        }
114
115        // A formatter answers a lint slot by comparison, so a tool that only registers a
116        // format variant (`terraform:format`) still resolves for `lint = ["terraform"]`.
117        // Tried last so a tool with both variants keeps using its real linter.
118        if slot == ToolSlot::Lint {
119            for suffix in ["format", "fmt"] {
120                let qualified = format!("{tool_id}:{suffix}");
121                if self.contains(&qualified) {
122                    return Some(qualified);
123                }
124            }
125        }
126
127        None
128    }
129
130    /// Invalid custom commands, including definitions not referenced by a language.
131    pub fn invalid_custom_commands(&self) -> Vec<(String, String)> {
132        self.user_tools
133            .iter()
134            .filter_map(|(id, definition)| Self::command_problem(definition).map(|problem| (id.clone(), problem)))
135            .collect()
136    }
137
138    fn command_problem(definition: &ToolDefinition) -> Option<String> {
139        if definition.command.first().is_none_or(|binary| binary.trim().is_empty()) {
140            Some("has an empty or unusable command".into())
141        } else if definition
142            .command
143            .iter()
144            .chain(&definition.lint_args)
145            .chain(&definition.format_args)
146            .any(|arg| arg.contains('\0'))
147        {
148            Some("has a command argument containing a NUL byte".into())
149        } else {
150            None
151        }
152    }
153
154    /// Validate a reference before any external command can be executed.
155    pub fn definition_problem(&self, tool_id: &str, slot: ToolSlot) -> Option<String> {
156        if super::processor::is_rumdl_builtin(tool_id) {
157            return (slot == ToolSlot::Format && tool_id == "rumdl:lint")
158                .then(|| "is a linter (move it to lint)".into());
159        }
160        let Some(definition) = self.resolve(tool_id, slot) else {
161            return Some("is an unknown tool".into());
162        };
163        if let Some(problem) = Self::command_problem(definition) {
164            return Some(problem);
165        }
166        if slot == ToolSlot::Format {
167            if self.fills_format_slot(tool_id) == Some(false) {
168                return Some("is a linter (move it to lint)".into());
169            }
170            if !definition.stdout {
171                return Some("cannot provide formatted output (stdout = false)".into());
172            }
173        }
174        None
175    }
176
177    /// The tool definition a configured tool id runs in `slot`, if any.
178    pub fn resolve(&self, tool_id: &str, slot: ToolSlot) -> Option<&ToolDefinition> {
179        self.resolve_id(tool_id, slot).and_then(|id| self.get(&id))
180    }
181
182    /// Whether the tool `tool_id` resolves to in a `format` slot can format.
183    ///
184    /// `None` when the id resolves to nothing. A user-defined tool always answers
185    /// `true`: the user wrote its command, so rumdl has no opinion about what it does.
186    /// A built-in answers from the formatting capability rumdl records for it.
187    pub fn fills_format_slot(&self, tool_id: &str) -> Option<bool> {
188        let resolved = self.resolve_id(tool_id, ToolSlot::Format)?;
189        Some(self.user_tools.contains_key(&resolved) || builtin_tool_formats(&resolved) == Some(true))
190    }
191
192    /// List all available tool IDs.
193    pub fn list_tools(&self) -> Vec<&str> {
194        let mut tools: Vec<&str> = self.user_tools.keys().map(std::string::String::as_str).collect();
195        for key in BUILTIN_TOOLS.keys() {
196            if !self.user_tools.contains_key(*key) {
197                tools.push(key);
198            }
199        }
200        tools.sort_unstable();
201        tools
202    }
203}
204
205/// IDs of all built-in (registry) tools, sorted.
206///
207/// Exposed so the execution-test harness can assert every built-in is either
208/// verified by an execution test or explicitly exempted (see that harness). When you
209/// add a built-in tool, run it through rumdl and add an execution test; if it cannot
210/// be verified to work over stdin, do not ship it.
211pub fn builtin_tool_ids() -> Vec<&'static str> {
212    let mut ids: Vec<&'static str> = BUILTIN_TOOLS.keys().copied().collect();
213    ids.sort_unstable();
214    ids
215}
216
217impl Default for ToolRegistry {
218    fn default() -> Self {
219        Self::new(BTreeMap::new())
220    }
221}
222
223/// Built-in tool definitions.
224///
225/// These are common formatters and linters that work well with stdin/stdout.
226static BUILTIN_TOOLS: LazyLock<HashMap<&'static str, ToolDefinition>> = LazyLock::new(|| {
227    let mut m = HashMap::new();
228
229    // Python - ruff
230    m.insert(
231        "ruff:check",
232        ToolDefinition {
233            command: vec![
234                "ruff".to_string(),
235                "check".to_string(),
236                "--output-format=concise".to_string(),
237                "--stdin-filename=_.py".to_string(),
238                "-".to_string(),
239            ],
240            stdin: true,
241            stdout: true,
242            lint_args: vec![],
243            format_args: vec![],
244        },
245    );
246
247    m.insert(
248        "ruff:format",
249        ToolDefinition {
250            command: vec![
251                "ruff".to_string(),
252                "format".to_string(),
253                "--stdin-filename=_.py".to_string(),
254                "-".to_string(),
255            ],
256            stdin: true,
257            stdout: true,
258            lint_args: vec![],
259            format_args: vec![],
260        },
261    );
262
263    // Python - black
264    m.insert(
265        "black",
266        ToolDefinition {
267            command: vec!["black".to_string(), "--quiet".to_string(), "-".to_string()],
268            stdin: true,
269            stdout: true,
270            lint_args: vec![],
271            format_args: vec![],
272        },
273    );
274
275    // JavaScript/TypeScript - prettier
276    m.insert(
277        "prettier",
278        ToolDefinition {
279            command: vec!["prettier".to_string(), "--stdin-filepath=_.js".to_string()],
280            stdin: true,
281            stdout: true,
282            lint_args: vec![],
283            format_args: vec![],
284        },
285    );
286
287    m.insert(
288        "prettier:json",
289        ToolDefinition {
290            command: vec!["prettier".to_string(), "--stdin-filepath=_.json".to_string()],
291            stdin: true,
292            stdout: true,
293            lint_args: vec![],
294            format_args: vec![],
295        },
296    );
297
298    m.insert(
299        "prettier:yaml",
300        ToolDefinition {
301            command: vec!["prettier".to_string(), "--stdin-filepath=_.yaml".to_string()],
302            stdin: true,
303            stdout: true,
304            lint_args: vec![],
305            format_args: vec![],
306        },
307    );
308
309    m.insert(
310        "prettier:html",
311        ToolDefinition {
312            command: vec!["prettier".to_string(), "--stdin-filepath=_.html".to_string()],
313            stdin: true,
314            stdout: true,
315            lint_args: vec![],
316            format_args: vec![],
317        },
318    );
319
320    m.insert(
321        "prettier:css",
322        ToolDefinition {
323            command: vec!["prettier".to_string(), "--stdin-filepath=_.css".to_string()],
324            stdin: true,
325            stdout: true,
326            lint_args: vec![],
327            format_args: vec![],
328        },
329    );
330
331    m.insert(
332        "prettier:markdown",
333        ToolDefinition {
334            command: vec!["prettier".to_string(), "--stdin-filepath=_.md".to_string()],
335            stdin: true,
336            stdout: true,
337            lint_args: vec![],
338            format_args: vec![],
339        },
340    );
341
342    // Shell - shellcheck (lint only). `--shell=bash` because code blocks rarely carry
343    // a shebang; without it shellcheck emits a "target shell unknown" tip instead of
344    // real diagnostics. bash is the common, permissive default; override with a custom
345    // tool for sh/ksh/dash.
346    m.insert(
347        "shellcheck",
348        ToolDefinition {
349            command: vec!["shellcheck".to_string(), "--shell=bash".to_string(), "-".to_string()],
350            stdin: true,
351            stdout: true,
352            lint_args: vec![],
353            format_args: vec![],
354        },
355    );
356
357    // Shell - shfmt
358    m.insert(
359        "shfmt",
360        ToolDefinition {
361            command: vec!["shfmt".to_string()],
362            stdin: true,
363            stdout: true,
364            lint_args: vec![],
365            format_args: vec![],
366        },
367    );
368
369    // Shell - shuck (faster shellcheck/shfmt alternative). Bare `shuck` lints via the
370    // `check` subcommand; `shuck:format` formats via `format` (the processor resolves a
371    // bare `shuck` in a format slot to `shuck:format`). `--output-format concise` keeps
372    // shuck's one-line-per-diagnostic output, which parses via the same generic
373    // "file:line:col: message" path as other tools instead of needing a dedicated
374    // parser. Requires shuck >= 0.0.43 for `check -` stdin support (see rvben/rumdl#655
375    // and ewhauser/shuck#1123); `format -` reads stdin and writes the formatted source
376    // to stdout with no report contamination (verified against shuck 0.0.45).
377    m.insert(
378        "shuck",
379        ToolDefinition {
380            command: vec![
381                "shuck".to_string(),
382                "check".to_string(),
383                "--output-format".to_string(),
384                "concise".to_string(),
385                "-".to_string(),
386            ],
387            stdin: true,
388            stdout: true,
389            lint_args: vec![],
390            format_args: vec![],
391        },
392    );
393
394    m.insert(
395        "shuck:format",
396        ToolDefinition {
397            command: vec!["shuck".to_string(), "format".to_string(), "-".to_string()],
398            stdin: true,
399            stdout: true,
400            lint_args: vec![],
401            format_args: vec![],
402        },
403    );
404
405    // Rust - rustfmt
406    m.insert(
407        "rustfmt",
408        ToolDefinition {
409            command: vec!["rustfmt".to_string()],
410            stdin: true,
411            stdout: true,
412            lint_args: vec![],
413            format_args: vec![],
414        },
415    );
416
417    // Go - gofmt
418    m.insert(
419        "gofmt",
420        ToolDefinition {
421            command: vec!["gofmt".to_string()],
422            stdin: true,
423            stdout: true,
424            lint_args: vec![],
425            format_args: vec![],
426        },
427    );
428
429    // Go - goimports
430    m.insert(
431        "goimports",
432        ToolDefinition {
433            command: vec!["goimports".to_string()],
434            stdin: true,
435            stdout: true,
436            lint_args: vec![],
437            format_args: vec![],
438        },
439    );
440
441    // C/C++ - clang-format
442    m.insert(
443        "clang-format",
444        ToolDefinition {
445            command: vec!["clang-format".to_string()],
446            stdin: true,
447            stdout: true,
448            lint_args: vec![],
449            format_args: vec![],
450        },
451    );
452
453    // SQL - sqlfluff. Requires a dialect; without `--dialect` it errors ("No dialect
454    // was specified"). Default to ansi; override with a custom tool for other dialects.
455    // Its human format wraps a finding across two lines and carries no filename, so it
456    // parses into nothing; `github-annotation-native` puts each finding on one line with
457    // its own line and column.
458    m.insert(
459        "sqlfluff:lint",
460        ToolDefinition {
461            command: vec![
462                "sqlfluff".to_string(),
463                "lint".to_string(),
464                "--dialect".to_string(),
465                "ansi".to_string(),
466                "--format".to_string(),
467                "github-annotation-native".to_string(),
468                "-".to_string(),
469            ],
470            stdin: true,
471            stdout: true,
472            lint_args: vec![],
473            format_args: vec![],
474        },
475    );
476
477    m.insert(
478        "sqlfluff:fix",
479        ToolDefinition {
480            command: vec![
481                "sqlfluff".to_string(),
482                "fix".to_string(),
483                "--dialect".to_string(),
484                "ansi".to_string(),
485                "-".to_string(),
486            ],
487            stdin: true,
488            stdout: true,
489            lint_args: vec![],
490            format_args: vec![],
491        },
492    );
493
494    // JSON - jq (format/lint)
495    m.insert(
496        "jq",
497        ToolDefinition {
498            command: vec!["jq".to_string(), ".".to_string()],
499            stdin: true,
500            stdout: true,
501            lint_args: vec![],
502            format_args: vec![],
503        },
504    );
505
506    // YAML - yamlfmt
507    m.insert(
508        "yamlfmt",
509        ToolDefinition {
510            command: vec!["yamlfmt".to_string()],
511            stdin: true,
512            stdout: true,
513            lint_args: vec![],
514            format_args: vec!["-".to_string()],
515        },
516    );
517
518    // TOML - taplo
519    m.insert(
520        "taplo",
521        ToolDefinition {
522            command: vec!["taplo".to_string(), "fmt".to_string(), "-".to_string()],
523            stdin: true,
524            stdout: true,
525            lint_args: vec![],
526            format_args: vec![],
527        },
528    );
529
530    // Terraform - terraform fmt. Registered under both the `tool:mode` convention every
531    // other multi-word tool follows and the original `terraform-fmt` id, so a config
532    // written either way resolves. `format = ["terraform"]` finds `terraform:format`
533    // through the suffix search in `ToolRegistry::resolve_id`.
534    let terraform_fmt = || ToolDefinition {
535        command: vec!["terraform".to_string(), "fmt".to_string(), "-".to_string()],
536        stdin: true,
537        stdout: true,
538        lint_args: vec![],
539        format_args: vec![],
540    };
541    m.insert("terraform:format", terraform_fmt());
542    m.insert("terraform-fmt", terraform_fmt());
543
544    // Nix - nixfmt (bare invocation is deprecated; `-` reads anonymous stdin)
545    m.insert(
546        "nixfmt",
547        ToolDefinition {
548            command: vec!["nixfmt".to_string(), "-".to_string()],
549            stdin: true,
550            stdout: true,
551            lint_args: vec![],
552            format_args: vec![],
553        },
554    );
555
556    // Lua - stylua
557    m.insert(
558        "stylua",
559        ToolDefinition {
560            command: vec!["stylua".to_string(), "-".to_string()],
561            stdin: true,
562            stdout: true,
563            lint_args: vec![],
564            format_args: vec![],
565        },
566    );
567
568    // Haskell - ormolu (stdin needs --stdin-input-file to pick up the .hs dialect)
569    m.insert(
570        "ormolu",
571        ToolDefinition {
572            command: vec!["ormolu".to_string(), "--stdin-input-file=_.hs".to_string()],
573            stdin: true,
574            stdout: true,
575            lint_args: vec![],
576            format_args: vec![],
577        },
578    );
579
580    // Elm - elm-format
581    m.insert(
582        "elm-format",
583        ToolDefinition {
584            command: vec!["elm-format".to_string(), "--stdin".to_string()],
585            stdin: true,
586            stdout: true,
587            lint_args: vec![],
588            format_args: vec![],
589        },
590    );
591
592    // Swift - swift-format (bare invocation is deprecated; `format -` reads stdin)
593    m.insert(
594        "swift-format",
595        ToolDefinition {
596            command: vec!["swift-format".to_string(), "format".to_string(), "-".to_string()],
597            stdin: true,
598            stdout: true,
599            lint_args: vec![],
600            format_args: vec![],
601        },
602    );
603
604    // Kotlin - ktfmt (`-` reads stdin; `--stdin` is not a valid flag)
605    m.insert(
606        "ktfmt",
607        ToolDefinition {
608            command: vec!["ktfmt".to_string(), "-".to_string()],
609            stdin: true,
610            stdout: true,
611            lint_args: vec![],
612            format_args: vec![],
613        },
614    );
615
616    // Jinja/HTML - djlint. Its default report prints the position inside the message
617    // ("H025 2:2 Tag seems to be an orphan."), which parses into nothing, so every finding
618    // lands on the fence. `--linter-output-format` puts it in the standard shape;
619    // `{line}` expands to "line:col", so with the filename in front each finding reads
620    // "path:line:col: CODE message". The flag is inert under `--reformat`, and also
621    // whenever `GITHUB_ACTIONS` is in the environment: djlint then emits
622    // `::warning line=N::message` instead, which rumdl reads as a line with no column.
623    const DJLINT_OUTPUT_FORMAT: &str = "{filename}:{line}: {code} {message}";
624
625    m.insert(
626        "djlint",
627        ToolDefinition {
628            command: vec![
629                "djlint".to_string(),
630                "-".to_string(),
631                "--linter-output-format".to_string(),
632                DJLINT_OUTPUT_FORMAT.to_string(),
633            ],
634            stdin: true,
635            stdout: true,
636            lint_args: vec![],
637            format_args: vec!["--reformat".to_string()],
638        },
639    );
640
641    m.insert(
642        "djlint:lint",
643        ToolDefinition {
644            command: vec![
645                "djlint".to_string(),
646                "-".to_string(),
647                "--linter-output-format".to_string(),
648                DJLINT_OUTPUT_FORMAT.to_string(),
649            ],
650            stdin: true,
651            stdout: true,
652            lint_args: vec![],
653            format_args: vec![],
654        },
655    );
656
657    m.insert(
658        "djlint:reformat",
659        ToolDefinition {
660            command: vec!["djlint".to_string(), "-".to_string(), "--reformat".to_string()],
661            stdin: true,
662            stdout: true,
663            lint_args: vec![],
664            format_args: vec![],
665        },
666    );
667
668    // Shell - beautysh
669    m.insert(
670        "beautysh",
671        ToolDefinition {
672            command: vec!["beautysh".to_string(), "-".to_string()],
673            stdin: true,
674            stdout: true,
675            lint_args: vec![],
676            format_args: vec![],
677        },
678    );
679
680    // TOML - tombi (default runs `tombi lint` since users typically configure it in the lint slot)
681    m.insert(
682        "tombi",
683        ToolDefinition {
684            command: vec!["tombi".to_string(), "lint".to_string(), "-".to_string()],
685            stdin: true,
686            stdout: true,
687            lint_args: vec![],
688            format_args: vec![],
689        },
690    );
691
692    m.insert(
693        "tombi:format",
694        ToolDefinition {
695            command: vec!["tombi".to_string(), "format".to_string(), "-".to_string()],
696            stdin: true,
697            stdout: true,
698            lint_args: vec![],
699            format_args: vec![],
700        },
701    );
702
703    m.insert(
704        "tombi:lint",
705        ToolDefinition {
706            command: vec!["tombi".to_string(), "lint".to_string(), "-".to_string()],
707            stdin: true,
708            stdout: true,
709            lint_args: vec![],
710            format_args: vec![],
711        },
712    );
713
714    // JavaScript/CSS/HTML/JSON - oxfmt (OXC formatter)
715    m.insert(
716        "oxfmt",
717        ToolDefinition {
718            command: vec!["oxfmt".to_string(), "--stdin-filepath=_.js".to_string()],
719            stdin: true,
720            stdout: true,
721            lint_args: vec![],
722            format_args: vec![],
723        },
724    );
725
726    m.insert(
727        "oxfmt:js",
728        ToolDefinition {
729            command: vec!["oxfmt".to_string(), "--stdin-filepath=_.js".to_string()],
730            stdin: true,
731            stdout: true,
732            lint_args: vec![],
733            format_args: vec![],
734        },
735    );
736
737    m.insert(
738        "oxfmt:ts",
739        ToolDefinition {
740            command: vec!["oxfmt".to_string(), "--stdin-filepath=_.ts".to_string()],
741            stdin: true,
742            stdout: true,
743            lint_args: vec![],
744            format_args: vec![],
745        },
746    );
747
748    m.insert(
749        "oxfmt:jsx",
750        ToolDefinition {
751            command: vec!["oxfmt".to_string(), "--stdin-filepath=_.jsx".to_string()],
752            stdin: true,
753            stdout: true,
754            lint_args: vec![],
755            format_args: vec![],
756        },
757    );
758
759    m.insert(
760        "oxfmt:tsx",
761        ToolDefinition {
762            command: vec!["oxfmt".to_string(), "--stdin-filepath=_.tsx".to_string()],
763            stdin: true,
764            stdout: true,
765            lint_args: vec![],
766            format_args: vec![],
767        },
768    );
769
770    m.insert(
771        "oxfmt:json",
772        ToolDefinition {
773            command: vec!["oxfmt".to_string(), "--stdin-filepath=_.json".to_string()],
774            stdin: true,
775            stdout: true,
776            lint_args: vec![],
777            format_args: vec![],
778        },
779    );
780
781    m.insert(
782        "oxfmt:css",
783        ToolDefinition {
784            command: vec!["oxfmt".to_string(), "--stdin-filepath=_.css".to_string()],
785            stdin: true,
786            stdout: true,
787            lint_args: vec![],
788            format_args: vec![],
789        },
790    );
791
792    // Bun's formatter reads stdin and selects its parser from the filename.
793    // Keep this opt-in: Bun versions predating `bun fmt` cannot run it.
794    for (id, ext) in [
795        ("bun-fmt", "js"),
796        ("bun-fmt:js", "js"),
797        ("bun-fmt:ts", "ts"),
798        ("bun-fmt:jsx", "jsx"),
799        ("bun-fmt:tsx", "tsx"),
800        ("bun-fmt:json", "json"),
801        ("bun-fmt:jsonc", "jsonc"),
802        ("bun-fmt:json5", "json5"),
803        ("bun-fmt:css", "css"),
804        ("bun-fmt:scss", "scss"),
805        ("bun-fmt:less", "less"),
806        ("bun-fmt:html", "html"),
807        ("bun-fmt:vue", "vue"),
808        ("bun-fmt:yaml", "yaml"),
809        ("bun-fmt:toml", "toml"),
810        ("bun-fmt:graphql", "graphql"),
811        ("bun-fmt:markdown", "md"),
812        ("bun-fmt:mdx", "mdx"),
813    ] {
814        m.insert(
815            id,
816            ToolDefinition {
817                command: vec![
818                    "bun".to_string(),
819                    "fmt".to_string(),
820                    format!("--stdin-filepath=_.{ext}"),
821                ],
822                stdin: true,
823                stdout: true,
824                lint_args: vec![],
825                format_args: vec![],
826            },
827        );
828    }
829
830    // Multi-language - deno fmt. stdin needs --ext to pick the parser, so one entry
831    // per supported extension. Bare "deno-fmt" defaults to TypeScript.
832    let deno_fmt = |ext: &str| ToolDefinition {
833        command: vec![
834            "deno".to_string(),
835            "fmt".to_string(),
836            format!("--ext={ext}"),
837            "-".to_string(),
838        ],
839        stdin: true,
840        stdout: true,
841        lint_args: vec![],
842        format_args: vec![],
843    };
844    m.insert("deno-fmt", deno_fmt("ts"));
845    m.insert("deno-fmt:ts", deno_fmt("ts"));
846    m.insert("deno-fmt:js", deno_fmt("js"));
847    m.insert("deno-fmt:json", deno_fmt("json"));
848    m.insert("deno-fmt:jsonc", deno_fmt("jsonc"));
849    m.insert("deno-fmt:md", deno_fmt("md"));
850
851    // Explicit mode aliases keep the established stdin commands. Format checks
852    // keep a comparison fallback when native stdin checks are unavailable.
853    for (id, existing) in [
854        ("oxfmt:lint", "oxfmt"),
855        ("oxfmt:format", "oxfmt"),
856        ("shuck:lint", "shuck"),
857        ("shuck:format-check", "shuck:format"),
858    ] {
859        m.insert(id, m[existing].clone());
860    }
861    let mut shuck_fix = m["shuck"].clone();
862    shuck_fix.command.push("--fix".to_string());
863    // A nonzero exit (including remaining lint errors or parse errors) is still
864    // a failed formatting run. Do not mistake diagnostics for replacement code.
865    m.insert("shuck:lint-fix", shuck_fix);
866
867    for (profile, lint_id, check_id, format_id) in [
868        (
869            "html",
870            "djlint:html:lint",
871            "djlint:html:format-check",
872            "djlint:html:format",
873        ),
874        (
875            "jinja",
876            "djlint:jinja:lint",
877            "djlint:jinja:format-check",
878            "djlint:jinja:format",
879        ),
880    ] {
881        let mut lint = m["djlint:lint"].clone();
882        lint.command.push(format!("--profile={profile}"));
883        m.insert(lint_id, lint);
884        let mut format = m["djlint:reformat"].clone();
885        format.command.push(format!("--profile={profile}"));
886        m.insert(check_id, format.clone());
887        m.insert(format_id, format);
888    }
889
890    m
891});
892
893/// Whether a built-in tool lints, formats, or both, for the docs table "Type" column.
894#[derive(Debug, Clone, Copy, PartialEq, Eq)]
895enum ToolKind {
896    Lint,
897    /// Check formatting by comparison, but decline use in a format slot.
898    FormatCheck,
899    Format,
900    Both,
901}
902
903impl ToolKind {
904    const fn label(self) -> &'static str {
905        match self {
906            ToolKind::Lint | ToolKind::FormatCheck => "Lint",
907            ToolKind::Format => "Format",
908            ToolKind::Both => "Both",
909        }
910    }
911}
912
913/// How a built-in tool answers the question "is this code block ok?" in a `lint` slot.
914#[derive(Debug, Clone, Copy, PartialEq, Eq)]
915pub enum BuiltinLintMode {
916    /// The tool is a linter: run its command and parse the diagnostics it prints.
917    Diagnostics,
918    /// The tool is a formatter: run it and compare its output with the block. A
919    /// difference is the finding.
920    ///
921    /// Most tools compare formatted output because native flags vary in stdin
922    /// support and exit semantics. Explicit djLint and Shuck check modes use
923    /// verified native checks on supported versions, with comparison as fallback.
924    FormatCheck,
925}
926
927/// Lint behavior of a built-in tool, or `None` when `tool_id` is not a built-in.
928pub fn builtin_lint_mode(tool_id: &str) -> Option<BuiltinLintMode> {
929    BUILTIN_TOOLS_DOCS
930        .iter()
931        .find(|m| m.id == tool_id && m.runtime)
932        .map(|m| match m.kind {
933            ToolKind::Lint | ToolKind::Both => BuiltinLintMode::Diagnostics,
934            ToolKind::Format | ToolKind::FormatCheck => BuiltinLintMode::FormatCheck,
935        })
936}
937
938/// Whether a built-in tool has a format invocation, i.e. whether it can fill a `format`
939/// slot. `None` when `tool_id` is not a built-in.
940///
941/// There is no matching query for the `lint` slot: every built-in can fill one, a linter
942/// by reporting its diagnostics and a formatter by comparison (see [`BuiltinLintMode`]).
943pub fn builtin_tool_formats(tool_id: &str) -> Option<bool> {
944    BUILTIN_TOOLS_DOCS
945        .iter()
946        .find(|m| m.id == tool_id && m.runtime)
947        .map(|m| matches!(m.kind, ToolKind::Format | ToolKind::Both))
948}
949
950/// Documentation metadata for a built-in tool, paired with [`BUILTIN_TOOLS`] by `id`.
951///
952/// This is the source of the generated table in `docs/code-block-tools.md`. It carries
953/// the display-only columns (`language`, `kind`) that must not live on the user-facing
954/// `ToolDefinition`. Command text is NOT duplicated here: the table pulls it from the
955/// runtime definition unless `display_command` provides a curated override.
956///
957/// Invariants (enforced by tests, so CI fails on any drift):
958/// - every `runtime` id has a matching `BUILTIN_TOOLS` entry, and vice versa;
959/// - `DocsOnly` ids (`runtime == false`, e.g. `rumdl`, handled specially in the
960///   processor) are absent from `BUILTIN_TOOLS` and must set `display_command`;
961/// - a `doc_group` with more than one runtime entry must set `display_command` on each;
962/// - all entries in a `doc_group` share `language` and `kind`.
963struct ToolDocMeta {
964    id: &'static str,
965    language: &'static str,
966    kind: ToolKind,
967    /// Rows sharing a `doc_group` collapse into one table row (extension variants).
968    doc_group: &'static str,
969    /// Curated command for the table; falls back to the runtime command join.
970    display_command: Option<&'static str>,
971    /// True for real external tools (in `BUILTIN_TOOLS`); false for docs-only entries.
972    runtime: bool,
973}
974
975/// Display metadata for the built-in tools table. Order here is the table row order.
976const BUILTIN_TOOLS_DOCS: &[ToolDocMeta] = &[
977    ToolDocMeta {
978        id: "ruff:check",
979        language: "Python",
980        kind: ToolKind::Lint,
981        doc_group: "ruff:check",
982        display_command: Some("ruff check --output-format=concise -"),
983        runtime: true,
984    },
985    ToolDocMeta {
986        id: "ruff:format",
987        language: "Python",
988        kind: ToolKind::Format,
989        doc_group: "ruff:format",
990        display_command: Some("ruff format -"),
991        runtime: true,
992    },
993    ToolDocMeta {
994        id: "black",
995        language: "Python",
996        kind: ToolKind::Format,
997        doc_group: "black",
998        display_command: None,
999        runtime: true,
1000    },
1001    ToolDocMeta {
1002        id: "prettier",
1003        language: "Multi",
1004        kind: ToolKind::Format,
1005        doc_group: "prettier",
1006        display_command: Some("prettier --stdin-filepath=_.EXT"),
1007        runtime: true,
1008    },
1009    ToolDocMeta {
1010        id: "prettier:json",
1011        language: "Multi",
1012        kind: ToolKind::Format,
1013        doc_group: "prettier",
1014        display_command: Some("prettier --stdin-filepath=_.EXT"),
1015        runtime: true,
1016    },
1017    ToolDocMeta {
1018        id: "prettier:yaml",
1019        language: "Multi",
1020        kind: ToolKind::Format,
1021        doc_group: "prettier",
1022        display_command: Some("prettier --stdin-filepath=_.EXT"),
1023        runtime: true,
1024    },
1025    ToolDocMeta {
1026        id: "prettier:html",
1027        language: "Multi",
1028        kind: ToolKind::Format,
1029        doc_group: "prettier",
1030        display_command: Some("prettier --stdin-filepath=_.EXT"),
1031        runtime: true,
1032    },
1033    ToolDocMeta {
1034        id: "prettier:css",
1035        language: "Multi",
1036        kind: ToolKind::Format,
1037        doc_group: "prettier",
1038        display_command: Some("prettier --stdin-filepath=_.EXT"),
1039        runtime: true,
1040    },
1041    ToolDocMeta {
1042        id: "prettier:markdown",
1043        language: "Multi",
1044        kind: ToolKind::Format,
1045        doc_group: "prettier",
1046        display_command: Some("prettier --stdin-filepath=_.EXT"),
1047        runtime: true,
1048    },
1049    ToolDocMeta {
1050        id: "shellcheck",
1051        language: "Shell",
1052        kind: ToolKind::Lint,
1053        doc_group: "shellcheck",
1054        display_command: None,
1055        runtime: true,
1056    },
1057    ToolDocMeta {
1058        id: "shfmt",
1059        language: "Shell",
1060        kind: ToolKind::Format,
1061        doc_group: "shfmt",
1062        display_command: None,
1063        runtime: true,
1064    },
1065    ToolDocMeta {
1066        id: "shuck",
1067        language: "Shell",
1068        kind: ToolKind::Lint,
1069        doc_group: "shuck",
1070        display_command: None,
1071        runtime: true,
1072    },
1073    ToolDocMeta {
1074        id: "shuck:format",
1075        language: "Shell",
1076        kind: ToolKind::Format,
1077        doc_group: "shuck:format",
1078        display_command: None,
1079        runtime: true,
1080    },
1081    ToolDocMeta {
1082        id: "rustfmt",
1083        language: "Rust",
1084        kind: ToolKind::Format,
1085        doc_group: "rustfmt",
1086        display_command: None,
1087        runtime: true,
1088    },
1089    ToolDocMeta {
1090        id: "gofmt",
1091        language: "Go",
1092        kind: ToolKind::Format,
1093        doc_group: "gofmt",
1094        display_command: None,
1095        runtime: true,
1096    },
1097    ToolDocMeta {
1098        id: "goimports",
1099        language: "Go",
1100        kind: ToolKind::Format,
1101        doc_group: "goimports",
1102        display_command: None,
1103        runtime: true,
1104    },
1105    ToolDocMeta {
1106        id: "clang-format",
1107        language: "C/C++",
1108        kind: ToolKind::Format,
1109        doc_group: "clang-format",
1110        display_command: None,
1111        runtime: true,
1112    },
1113    ToolDocMeta {
1114        id: "sqlfluff:lint",
1115        language: "SQL",
1116        kind: ToolKind::Lint,
1117        doc_group: "sqlfluff:lint",
1118        display_command: None,
1119        runtime: true,
1120    },
1121    ToolDocMeta {
1122        id: "sqlfluff:fix",
1123        language: "SQL",
1124        kind: ToolKind::Format,
1125        doc_group: "sqlfluff:fix",
1126        display_command: None,
1127        runtime: true,
1128    },
1129    ToolDocMeta {
1130        id: "jq",
1131        language: "JSON",
1132        kind: ToolKind::Both,
1133        doc_group: "jq",
1134        display_command: None,
1135        runtime: true,
1136    },
1137    ToolDocMeta {
1138        id: "yamlfmt",
1139        language: "YAML",
1140        kind: ToolKind::Format,
1141        doc_group: "yamlfmt",
1142        display_command: None,
1143        runtime: true,
1144    },
1145    ToolDocMeta {
1146        id: "taplo",
1147        language: "TOML",
1148        kind: ToolKind::Format,
1149        doc_group: "taplo",
1150        display_command: None,
1151        runtime: true,
1152    },
1153    ToolDocMeta {
1154        id: "terraform:format",
1155        language: "Terraform",
1156        kind: ToolKind::Format,
1157        doc_group: "terraform:format",
1158        display_command: Some("terraform fmt -"),
1159        runtime: true,
1160    },
1161    ToolDocMeta {
1162        id: "terraform-fmt",
1163        language: "Terraform",
1164        kind: ToolKind::Format,
1165        doc_group: "terraform:format",
1166        display_command: Some("terraform fmt -"),
1167        runtime: true,
1168    },
1169    ToolDocMeta {
1170        id: "nixfmt",
1171        language: "Nix",
1172        kind: ToolKind::Format,
1173        doc_group: "nixfmt",
1174        display_command: None,
1175        runtime: true,
1176    },
1177    ToolDocMeta {
1178        id: "stylua",
1179        language: "Lua",
1180        kind: ToolKind::Format,
1181        doc_group: "stylua",
1182        display_command: None,
1183        runtime: true,
1184    },
1185    ToolDocMeta {
1186        id: "ormolu",
1187        language: "Haskell",
1188        kind: ToolKind::Format,
1189        doc_group: "ormolu",
1190        display_command: None,
1191        runtime: true,
1192    },
1193    ToolDocMeta {
1194        id: "elm-format",
1195        language: "Elm",
1196        kind: ToolKind::Format,
1197        doc_group: "elm-format",
1198        display_command: None,
1199        runtime: true,
1200    },
1201    ToolDocMeta {
1202        id: "swift-format",
1203        language: "Swift",
1204        kind: ToolKind::Format,
1205        doc_group: "swift-format",
1206        display_command: None,
1207        runtime: true,
1208    },
1209    ToolDocMeta {
1210        id: "ktfmt",
1211        language: "Kotlin",
1212        kind: ToolKind::Format,
1213        doc_group: "ktfmt",
1214        display_command: None,
1215        runtime: true,
1216    },
1217    // djlint's `--linter-output-format` is rumdl plumbing (see `BUILTIN_TOOLS`), and
1218    // spelling it out twice would triple the width of every row in the table.
1219    ToolDocMeta {
1220        id: "djlint",
1221        language: "Jinja/HTML",
1222        kind: ToolKind::Both,
1223        doc_group: "djlint",
1224        display_command: Some("djlint - / djlint - --reformat"),
1225        runtime: true,
1226    },
1227    ToolDocMeta {
1228        id: "djlint:lint",
1229        language: "Jinja/HTML",
1230        kind: ToolKind::Lint,
1231        doc_group: "djlint:lint",
1232        display_command: Some("djlint -"),
1233        runtime: true,
1234    },
1235    ToolDocMeta {
1236        id: "djlint:reformat",
1237        language: "Jinja/HTML",
1238        kind: ToolKind::Format,
1239        doc_group: "djlint:reformat",
1240        display_command: None,
1241        runtime: true,
1242    },
1243    ToolDocMeta {
1244        id: "beautysh",
1245        language: "Shell",
1246        kind: ToolKind::Format,
1247        doc_group: "beautysh",
1248        display_command: None,
1249        runtime: true,
1250    },
1251    ToolDocMeta {
1252        id: "tombi",
1253        language: "TOML",
1254        kind: ToolKind::Lint,
1255        doc_group: "tombi",
1256        display_command: None,
1257        runtime: true,
1258    },
1259    ToolDocMeta {
1260        id: "tombi:format",
1261        language: "TOML",
1262        kind: ToolKind::Format,
1263        doc_group: "tombi:format",
1264        display_command: None,
1265        runtime: true,
1266    },
1267    ToolDocMeta {
1268        id: "tombi:lint",
1269        language: "TOML",
1270        kind: ToolKind::Lint,
1271        doc_group: "tombi:lint",
1272        display_command: None,
1273        runtime: true,
1274    },
1275    ToolDocMeta {
1276        id: "oxfmt",
1277        language: "Multi",
1278        kind: ToolKind::Format,
1279        doc_group: "oxfmt",
1280        display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1281        runtime: true,
1282    },
1283    ToolDocMeta {
1284        id: "oxfmt:js",
1285        language: "Multi",
1286        kind: ToolKind::Format,
1287        doc_group: "oxfmt",
1288        display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1289        runtime: true,
1290    },
1291    ToolDocMeta {
1292        id: "oxfmt:ts",
1293        language: "Multi",
1294        kind: ToolKind::Format,
1295        doc_group: "oxfmt",
1296        display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1297        runtime: true,
1298    },
1299    ToolDocMeta {
1300        id: "oxfmt:jsx",
1301        language: "Multi",
1302        kind: ToolKind::Format,
1303        doc_group: "oxfmt",
1304        display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1305        runtime: true,
1306    },
1307    ToolDocMeta {
1308        id: "oxfmt:tsx",
1309        language: "Multi",
1310        kind: ToolKind::Format,
1311        doc_group: "oxfmt",
1312        display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1313        runtime: true,
1314    },
1315    ToolDocMeta {
1316        id: "oxfmt:json",
1317        language: "Multi",
1318        kind: ToolKind::Format,
1319        doc_group: "oxfmt",
1320        display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1321        runtime: true,
1322    },
1323    ToolDocMeta {
1324        id: "oxfmt:css",
1325        language: "Multi",
1326        kind: ToolKind::Format,
1327        doc_group: "oxfmt",
1328        display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1329        runtime: true,
1330    },
1331    ToolDocMeta {
1332        id: "bun-fmt",
1333        language: "Multi",
1334        kind: ToolKind::Format,
1335        doc_group: "bun-fmt",
1336        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1337        runtime: true,
1338    },
1339    ToolDocMeta {
1340        id: "bun-fmt:js",
1341        language: "Multi",
1342        kind: ToolKind::Format,
1343        doc_group: "bun-fmt",
1344        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1345        runtime: true,
1346    },
1347    ToolDocMeta {
1348        id: "bun-fmt:ts",
1349        language: "Multi",
1350        kind: ToolKind::Format,
1351        doc_group: "bun-fmt",
1352        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1353        runtime: true,
1354    },
1355    ToolDocMeta {
1356        id: "bun-fmt:jsx",
1357        language: "Multi",
1358        kind: ToolKind::Format,
1359        doc_group: "bun-fmt",
1360        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1361        runtime: true,
1362    },
1363    ToolDocMeta {
1364        id: "bun-fmt:tsx",
1365        language: "Multi",
1366        kind: ToolKind::Format,
1367        doc_group: "bun-fmt",
1368        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1369        runtime: true,
1370    },
1371    ToolDocMeta {
1372        id: "bun-fmt:json",
1373        language: "Multi",
1374        kind: ToolKind::Format,
1375        doc_group: "bun-fmt",
1376        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1377        runtime: true,
1378    },
1379    ToolDocMeta {
1380        id: "bun-fmt:jsonc",
1381        language: "Multi",
1382        kind: ToolKind::Format,
1383        doc_group: "bun-fmt",
1384        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1385        runtime: true,
1386    },
1387    ToolDocMeta {
1388        id: "bun-fmt:json5",
1389        language: "Multi",
1390        kind: ToolKind::Format,
1391        doc_group: "bun-fmt",
1392        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1393        runtime: true,
1394    },
1395    ToolDocMeta {
1396        id: "bun-fmt:css",
1397        language: "Multi",
1398        kind: ToolKind::Format,
1399        doc_group: "bun-fmt",
1400        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1401        runtime: true,
1402    },
1403    ToolDocMeta {
1404        id: "bun-fmt:scss",
1405        language: "Multi",
1406        kind: ToolKind::Format,
1407        doc_group: "bun-fmt",
1408        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1409        runtime: true,
1410    },
1411    ToolDocMeta {
1412        id: "bun-fmt:less",
1413        language: "Multi",
1414        kind: ToolKind::Format,
1415        doc_group: "bun-fmt",
1416        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1417        runtime: true,
1418    },
1419    ToolDocMeta {
1420        id: "bun-fmt:html",
1421        language: "Multi",
1422        kind: ToolKind::Format,
1423        doc_group: "bun-fmt",
1424        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1425        runtime: true,
1426    },
1427    ToolDocMeta {
1428        id: "bun-fmt:vue",
1429        language: "Multi",
1430        kind: ToolKind::Format,
1431        doc_group: "bun-fmt",
1432        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1433        runtime: true,
1434    },
1435    ToolDocMeta {
1436        id: "bun-fmt:yaml",
1437        language: "Multi",
1438        kind: ToolKind::Format,
1439        doc_group: "bun-fmt",
1440        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1441        runtime: true,
1442    },
1443    ToolDocMeta {
1444        id: "bun-fmt:toml",
1445        language: "Multi",
1446        kind: ToolKind::Format,
1447        doc_group: "bun-fmt",
1448        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1449        runtime: true,
1450    },
1451    ToolDocMeta {
1452        id: "bun-fmt:graphql",
1453        language: "Multi",
1454        kind: ToolKind::Format,
1455        doc_group: "bun-fmt",
1456        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1457        runtime: true,
1458    },
1459    ToolDocMeta {
1460        id: "bun-fmt:markdown",
1461        language: "Multi",
1462        kind: ToolKind::Format,
1463        doc_group: "bun-fmt",
1464        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1465        runtime: true,
1466    },
1467    ToolDocMeta {
1468        id: "bun-fmt:mdx",
1469        language: "Multi",
1470        kind: ToolKind::Format,
1471        doc_group: "bun-fmt",
1472        display_command: Some("bun fmt --stdin-filepath=_.EXT"),
1473        runtime: true,
1474    },
1475    ToolDocMeta {
1476        id: "deno-fmt",
1477        language: "Multi",
1478        kind: ToolKind::Format,
1479        doc_group: "deno-fmt",
1480        display_command: Some("deno fmt --ext=EXT -"),
1481        runtime: true,
1482    },
1483    ToolDocMeta {
1484        id: "deno-fmt:ts",
1485        language: "Multi",
1486        kind: ToolKind::Format,
1487        doc_group: "deno-fmt",
1488        display_command: Some("deno fmt --ext=EXT -"),
1489        runtime: true,
1490    },
1491    ToolDocMeta {
1492        id: "deno-fmt:js",
1493        language: "Multi",
1494        kind: ToolKind::Format,
1495        doc_group: "deno-fmt",
1496        display_command: Some("deno fmt --ext=EXT -"),
1497        runtime: true,
1498    },
1499    ToolDocMeta {
1500        id: "deno-fmt:json",
1501        language: "Multi",
1502        kind: ToolKind::Format,
1503        doc_group: "deno-fmt",
1504        display_command: Some("deno fmt --ext=EXT -"),
1505        runtime: true,
1506    },
1507    ToolDocMeta {
1508        id: "deno-fmt:jsonc",
1509        language: "Multi",
1510        kind: ToolKind::Format,
1511        doc_group: "deno-fmt",
1512        display_command: Some("deno fmt --ext=EXT -"),
1513        runtime: true,
1514    },
1515    ToolDocMeta {
1516        id: "deno-fmt:md",
1517        language: "Multi",
1518        kind: ToolKind::Format,
1519        doc_group: "deno-fmt",
1520        display_command: Some("deno fmt --ext=EXT -"),
1521        runtime: true,
1522    },
1523    ToolDocMeta {
1524        id: "shuck:lint",
1525        language: "Shell",
1526        kind: ToolKind::Lint,
1527        doc_group: "shuck:lint",
1528        display_command: None,
1529        runtime: true,
1530    },
1531    ToolDocMeta {
1532        id: "shuck:lint-fix",
1533        language: "Shell",
1534        kind: ToolKind::Format,
1535        doc_group: "shuck:lint-fix",
1536        display_command: None,
1537        runtime: true,
1538    },
1539    ToolDocMeta {
1540        id: "shuck:format-check",
1541        language: "Shell",
1542        kind: ToolKind::FormatCheck,
1543        doc_group: "shuck:format-check",
1544        display_command: Some("shuck format - --check"),
1545        runtime: true,
1546    },
1547    ToolDocMeta {
1548        id: "oxfmt:lint",
1549        language: "JavaScript",
1550        kind: ToolKind::FormatCheck,
1551        doc_group: "oxfmt:lint",
1552        display_command: None,
1553        runtime: true,
1554    },
1555    ToolDocMeta {
1556        id: "oxfmt:format",
1557        language: "JavaScript",
1558        kind: ToolKind::Format,
1559        doc_group: "oxfmt:format",
1560        display_command: None,
1561        runtime: true,
1562    },
1563    ToolDocMeta {
1564        id: "djlint:html:lint",
1565        language: "HTML",
1566        kind: ToolKind::Lint,
1567        doc_group: "djlint:html:lint",
1568        display_command: Some("djlint - --profile=html"),
1569        runtime: true,
1570    },
1571    ToolDocMeta {
1572        id: "djlint:html:format-check",
1573        language: "HTML",
1574        kind: ToolKind::FormatCheck,
1575        doc_group: "djlint:html:format-check",
1576        display_command: Some("djlint - --check --profile=html"),
1577        runtime: true,
1578    },
1579    ToolDocMeta {
1580        id: "djlint:html:format",
1581        language: "HTML",
1582        kind: ToolKind::Format,
1583        doc_group: "djlint:html:format",
1584        display_command: Some("djlint - --reformat --profile=html"),
1585        runtime: true,
1586    },
1587    ToolDocMeta {
1588        id: "djlint:jinja:lint",
1589        language: "Jinja",
1590        kind: ToolKind::Lint,
1591        doc_group: "djlint:jinja:lint",
1592        display_command: Some("djlint - --profile=jinja"),
1593        runtime: true,
1594    },
1595    ToolDocMeta {
1596        id: "djlint:jinja:format-check",
1597        language: "Jinja",
1598        kind: ToolKind::FormatCheck,
1599        doc_group: "djlint:jinja:format-check",
1600        display_command: Some("djlint - --check --profile=jinja"),
1601        runtime: true,
1602    },
1603    ToolDocMeta {
1604        id: "djlint:jinja:format",
1605        language: "Jinja",
1606        kind: ToolKind::Format,
1607        doc_group: "djlint:jinja:format",
1608        display_command: Some("djlint - --reformat --profile=jinja"),
1609        runtime: true,
1610    },
1611    ToolDocMeta {
1612        id: "rumdl:lint",
1613        language: "Markdown",
1614        kind: ToolKind::Lint,
1615        doc_group: "rumdl:lint",
1616        display_command: Some("built-in markdown linting"),
1617        runtime: false,
1618    },
1619    ToolDocMeta {
1620        id: "rumdl:format",
1621        language: "Markdown",
1622        kind: ToolKind::Format,
1623        doc_group: "rumdl:format",
1624        display_command: Some("built-in markdown formatting"),
1625        runtime: false,
1626    },
1627    // Docs-only: rumdl's own markdown linting, short-circuited in the processor before
1628    // tool resolution (never a registry entry).
1629    ToolDocMeta {
1630        id: RUMDL_BUILTIN_TOOL,
1631        language: "Markdown",
1632        kind: ToolKind::Lint,
1633        doc_group: RUMDL_BUILTIN_TOOL,
1634        display_command: Some("built-in markdown linting"),
1635        runtime: false,
1636    },
1637];
1638
1639/// Markers fencing the generated table in `docs/code-block-tools.md`.
1640const TABLE_BEGIN: &str = "<!-- BEGIN builtin-tools (generated) -->";
1641const TABLE_END: &str = "<!-- END builtin-tools (generated) -->";
1642
1643/// Error from splicing the generated docs into `docs/code-block-tools.md`.
1644#[derive(Debug, Clone, PartialEq, Eq)]
1645pub enum DocsError {
1646    /// A begin/end marker pair is missing.
1647    MissingMarker,
1648    /// A begin or end marker appears more than once.
1649    DuplicateMarker,
1650    /// The end marker precedes the begin marker.
1651    MarkerOrder,
1652    /// The "Built-in tools" count row was not found in the comparison table.
1653    CountRowMissing,
1654    /// More than one "Built-in tools" count row was found.
1655    CountRowAmbiguous,
1656    /// The "Built-in tools" count row does not have the expected cell layout.
1657    CountRowMalformed,
1658}
1659
1660impl std::fmt::Display for DocsError {
1661    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1662        let msg = match self {
1663            DocsError::MissingMarker => "missing `<!-- BEGIN/END builtin-tools (generated) -->` marker pair",
1664            DocsError::DuplicateMarker => "duplicate builtin-tools marker",
1665            DocsError::MarkerOrder => "END builtin-tools marker precedes BEGIN",
1666            DocsError::CountRowMissing => "`| Built-in tools` count row not found",
1667            DocsError::CountRowAmbiguous => "multiple `| Built-in tools` count rows found",
1668            DocsError::CountRowMalformed => "`| Built-in tools` count row has an unexpected layout",
1669        };
1670        f.write_str(msg)
1671    }
1672}
1673
1674impl std::error::Error for DocsError {}
1675
1676/// Number of rows the generated table renders (distinct `doc_group`s).
1677fn builtin_tools_group_count() -> usize {
1678    let mut seen: Vec<&str> = Vec::new();
1679    for m in BUILTIN_TOOLS_DOCS {
1680        if !seen.contains(&m.doc_group) {
1681            seen.push(m.doc_group);
1682        }
1683    }
1684    seen.len()
1685}
1686
1687/// Render the built-in tools table (the markdown fenced by the doc markers).
1688///
1689/// One row per `doc_group`, in list order. Columns are padded to their widest cell so
1690/// the output matches rumdl's own table formatting, keeping the generated docs
1691/// `rumdl fmt --check`-clean (the project config normalizes table column widths).
1692pub fn render_builtin_tools_table() -> String {
1693    let headers = ["Tool ID", "Language", "Type", "Command"];
1694    let mut rows: Vec<[String; 4]> = Vec::new();
1695
1696    let mut seen: Vec<&str> = Vec::new();
1697    for m in BUILTIN_TOOLS_DOCS {
1698        if seen.contains(&m.doc_group) {
1699            continue;
1700        }
1701        seen.push(m.doc_group);
1702
1703        // Docs-only entries have no runtime command to fall back to; runtime entries
1704        // render the exact mode-specific invocation the executor runs.
1705        let command = match m.display_command {
1706            Some(cmd) => cmd.to_string(),
1707            None if m.runtime => runtime_command_for_kind(m.id, m.kind),
1708            None => String::new(),
1709        };
1710
1711        rows.push([
1712            format!("`{}`", m.doc_group),
1713            m.language.to_string(),
1714            m.kind.label().to_string(),
1715            format!("`{command}`"),
1716        ]);
1717    }
1718
1719    // Column widths = widest cell (header included), counted in chars (all ASCII here).
1720    let mut widths = headers.map(str::chars).map(Iterator::count);
1721    for row in &rows {
1722        for (i, cell) in row.iter().enumerate() {
1723            widths[i] = widths[i].max(cell.chars().count());
1724        }
1725    }
1726
1727    let mut out = String::new();
1728    push_table_row(&mut out, &headers.map(String::from), &widths);
1729    let separators = std::array::from_fn(|i| "-".repeat(widths[i]));
1730    push_table_row(&mut out, &separators, &widths);
1731    for row in &rows {
1732        push_table_row(&mut out, row, &widths);
1733    }
1734    out
1735}
1736
1737/// The exact command the executor runs for a built-in tool in its documented mode:
1738/// the base command plus the mode-specific args it appends (`lint_args` for Lint,
1739/// `format_args` for Format). `Both` tools show both invocations when they differ.
1740/// This keeps the table honest about what actually runs, rather than the bare command.
1741fn runtime_command_for_kind(id: &str, kind: ToolKind) -> String {
1742    let Some(def) = BUILTIN_TOOLS.get(id) else {
1743        return String::new();
1744    };
1745    let invocation =
1746        |extra: &[String]| -> String { def.command.iter().chain(extra).cloned().collect::<Vec<_>>().join(" ") };
1747    match kind {
1748        ToolKind::Lint => invocation(&def.lint_args),
1749        ToolKind::Format | ToolKind::FormatCheck => invocation(&def.format_args),
1750        ToolKind::Both => {
1751            let lint = invocation(&def.lint_args);
1752            let format = invocation(&def.format_args);
1753            if lint == format {
1754                lint
1755            } else {
1756                format!("{lint} / {format}")
1757            }
1758        }
1759    }
1760}
1761
1762/// Append one `| cell | cell | ... |` row, left-padding each cell to its column width.
1763fn push_table_row(out: &mut String, cells: &[String; 4], widths: &[usize; 4]) {
1764    out.push('|');
1765    for (i, cell) in cells.iter().enumerate() {
1766        out.push(' ');
1767        out.push_str(cell);
1768        for _ in cell.chars().count()..widths[i] {
1769            out.push(' ');
1770        }
1771        out.push_str(" |");
1772    }
1773    out.push('\n');
1774}
1775
1776/// Splice the generated table and the "Built-in tools" count into the docs file text.
1777///
1778/// Replaces only the content between the marker pair and the single count cell in the
1779/// mdsf comparison table; all other prose is preserved. Fails loudly on malformed or
1780/// missing markers rather than silently corrupting the file.
1781pub fn splice_builtin_tools_docs(existing: &str) -> Result<String, DocsError> {
1782    if existing.matches(TABLE_BEGIN).count() == 0 || existing.matches(TABLE_END).count() == 0 {
1783        return Err(DocsError::MissingMarker);
1784    }
1785    if existing.matches(TABLE_BEGIN).count() > 1 || existing.matches(TABLE_END).count() > 1 {
1786        return Err(DocsError::DuplicateMarker);
1787    }
1788    let begin_pos = existing.find(TABLE_BEGIN).unwrap();
1789    let end_pos = existing.find(TABLE_END).unwrap();
1790    if end_pos < begin_pos {
1791        return Err(DocsError::MarkerOrder);
1792    }
1793
1794    let table = render_builtin_tools_table();
1795    let replacement = format!("{TABLE_BEGIN}\n\n{table}\n{TABLE_END}");
1796
1797    let mut result = String::with_capacity(existing.len() + replacement.len());
1798    result.push_str(&existing[..begin_pos]);
1799    result.push_str(&replacement);
1800    result.push_str(&existing[end_pos + TABLE_END.len()..]);
1801
1802    update_builtin_tools_count(&result, builtin_tools_group_count())
1803}
1804
1805/// Rewrite the count cell in the unique `| Built-in tools | N | ... |` comparison row,
1806/// preserving the cell's width so the hand-aligned comparison table stays tidy.
1807fn update_builtin_tools_count(text: &str, count: usize) -> Result<String, DocsError> {
1808    let lines: Vec<&str> = text.lines().collect();
1809    let mut target: Option<usize> = None;
1810    for (i, line) in lines.iter().enumerate() {
1811        if line.trim_start().starts_with("| Built-in tools") {
1812            if target.is_some() {
1813                return Err(DocsError::CountRowAmbiguous);
1814            }
1815            target = Some(i);
1816        }
1817    }
1818    let i = target.ok_or(DocsError::CountRowMissing)?;
1819    let new_line = replace_count_cell(lines[i], count).ok_or(DocsError::CountRowMalformed)?;
1820
1821    let mut out = String::with_capacity(text.len());
1822    for (j, line) in lines.iter().enumerate() {
1823        out.push_str(if j == i { &new_line } else { line });
1824        out.push('\n');
1825    }
1826    if !text.ends_with('\n') {
1827        out.pop();
1828    }
1829    Ok(out)
1830}
1831
1832/// Replace the second cell (the count) of a markdown table row, keeping its width.
1833fn replace_count_cell(line: &str, count: usize) -> Option<String> {
1834    let pipes: Vec<usize> = line.match_indices('|').map(|(i, _)| i).collect();
1835    if pipes.len() < 3 {
1836        return None;
1837    }
1838    let start = pipes[1] + 1;
1839    let end = pipes[2];
1840    let cell = line.get(start..end)?;
1841    let width = cell.len();
1842    let num = count.to_string();
1843
1844    let mut new_cell = String::with_capacity(width.max(num.len() + 2));
1845    new_cell.push(' ');
1846    new_cell.push_str(&num);
1847    while new_cell.len() < width {
1848        new_cell.push(' ');
1849    }
1850    if new_cell.len() > width {
1851        // Number grew past the original cell width; keep a single padding space.
1852        new_cell = format!(" {num} ");
1853    }
1854
1855    Some(format!("{}{}{}", &line[..start], new_cell, &line[end..]))
1856}
1857
1858#[cfg(test)]
1859mod tests {
1860    use super::*;
1861
1862    #[test]
1863    fn test_get_builtin_tool() {
1864        let registry = ToolRegistry::default();
1865
1866        let tool = registry.get("ruff:check").expect("Should find ruff:check");
1867        assert!(tool.command.contains(&"ruff".to_string()));
1868        assert!(tool.stdin);
1869        assert!(tool.stdout);
1870
1871        let tool = registry.get("shellcheck").expect("Should find shellcheck");
1872        assert!(tool.command.contains(&"shellcheck".to_string()));
1873    }
1874
1875    #[test]
1876    fn test_builtin_yamlfmt_answers_lint_by_formatting() {
1877        let registry = ToolRegistry::default();
1878
1879        assert_eq!(registry.lint_mode("yamlfmt"), Some(BuiltinLintMode::FormatCheck));
1880
1881        // The argv a lint slot runs is therefore the format argv, which must read stdin.
1882        let tool = registry.get("yamlfmt").expect("Should find yamlfmt");
1883        let mut argv = tool.command.clone();
1884        argv.extend(tool.format_args.clone());
1885
1886        assert_eq!(argv, vec!["yamlfmt", "-"]);
1887        assert!(tool.lint_args.is_empty());
1888    }
1889
1890    #[test]
1891    fn test_get_user_tool_overrides_builtin() {
1892        let mut user_tools = BTreeMap::new();
1893        user_tools.insert(
1894            "ruff:check".to_string(),
1895            ToolDefinition {
1896                command: vec!["custom-ruff".to_string()],
1897                stdin: false,
1898                stdout: false,
1899                lint_args: vec![],
1900                format_args: vec![],
1901            },
1902        );
1903
1904        let registry = ToolRegistry::new(user_tools);
1905
1906        let tool = registry.get("ruff:check").expect("Should find ruff:check");
1907        assert_eq!(tool.command, vec!["custom-ruff"]);
1908        assert!(!tool.stdin); // User override
1909    }
1910
1911    #[test]
1912    fn bun_formatter_variants_select_the_stdin_parser_and_check_by_comparison() {
1913        let registry = ToolRegistry::default();
1914        for (id, ext) in [
1915            ("bun-fmt", "js"),
1916            ("bun-fmt:ts", "ts"),
1917            ("bun-fmt:json", "json"),
1918            ("bun-fmt:mdx", "mdx"),
1919        ] {
1920            let tool = registry.get(id).unwrap();
1921            assert_eq!(tool.command, vec!["bun", "fmt", &format!("--stdin-filepath=_.{ext}")]);
1922            assert!(tool.stdin && tool.stdout);
1923            assert_eq!(registry.lint_mode(id), Some(BuiltinLintMode::FormatCheck));
1924            assert_eq!(builtin_tool_formats(id), Some(true));
1925            assert_eq!(registry.resolve_id(id, ToolSlot::Format).as_deref(), Some(id));
1926            assert_eq!(registry.resolve_id(id, ToolSlot::Lint).as_deref(), Some(id));
1927        }
1928    }
1929
1930    #[test]
1931    fn test_contains() {
1932        let registry = ToolRegistry::default();
1933
1934        assert!(registry.contains("ruff:check"));
1935        assert!(registry.contains("prettier"));
1936        assert!(registry.contains("shellcheck"));
1937        assert!(!registry.contains("nonexistent-tool"));
1938    }
1939
1940    #[test]
1941    fn test_list_tools() {
1942        let registry = ToolRegistry::default();
1943        let tools = registry.list_tools();
1944
1945        assert!(tools.contains(&"ruff:check"));
1946        assert!(tools.contains(&"ruff:format"));
1947        assert!(tools.contains(&"prettier"));
1948        assert!(tools.contains(&"shellcheck"));
1949        assert!(tools.contains(&"shfmt"));
1950        assert!(tools.contains(&"rustfmt"));
1951        assert!(tools.contains(&"gofmt"));
1952    }
1953
1954    #[test]
1955    fn test_user_tools_in_list() {
1956        let mut user_tools = BTreeMap::new();
1957        user_tools.insert("my-custom-tool".to_string(), ToolDefinition::default());
1958
1959        let registry = ToolRegistry::new(user_tools);
1960        let tools = registry.list_tools();
1961
1962        assert!(tools.contains(&"my-custom-tool"));
1963        assert!(tools.contains(&"ruff:check")); // Built-in still available
1964    }
1965
1966    #[test]
1967    fn test_new_builtin_tools() {
1968        let registry = ToolRegistry::default();
1969
1970        // djlint
1971        let tool = registry.get("djlint").expect("Should find djlint");
1972        assert!(tool.command.contains(&"djlint".to_string()));
1973        assert!(tool.stdin);
1974
1975        // beautysh
1976        let tool = registry.get("beautysh").expect("Should find beautysh");
1977        assert!(tool.command.contains(&"beautysh".to_string()));
1978        assert!(tool.stdin);
1979
1980        // tombi
1981        let tool = registry.get("tombi").expect("Should find tombi");
1982        assert!(tool.command.contains(&"tombi".to_string()));
1983        assert!(tool.stdin);
1984
1985        let tool = registry.get("tombi:lint").expect("Should find tombi:lint");
1986        assert!(tool.command.contains(&"lint".to_string()));
1987
1988        let tool = registry.get("tombi:format").expect("Should find tombi:format");
1989        assert!(
1990            tool.command.contains(&"format".to_string()),
1991            "tombi:format should use 'format' subcommand, got: {:?}",
1992            tool.command
1993        );
1994
1995        // oxfmt
1996        let tool = registry.get("oxfmt").expect("Should find oxfmt");
1997        assert!(tool.command.contains(&"oxfmt".to_string()));
1998        assert!(tool.stdin);
1999
2000        let tool = registry.get("oxfmt:ts").expect("Should find oxfmt:ts");
2001        assert!(tool.command.iter().any(|s| s.contains("_.ts")));
2002    }
2003
2004    // =========================================================================
2005    // Issue #527: bare "tombi" in format slot resolves to lint command
2006    // =========================================================================
2007
2008    /// The bare "tombi" registry entry defaults to `tombi lint -`.
2009    /// The processor's `resolve_tool` method handles context-aware resolution:
2010    /// in format context, it resolves "tombi" to "tombi:format" automatically.
2011    #[test]
2012    fn test_bare_tombi_resolves_to_lint_not_format() {
2013        let registry = ToolRegistry::default();
2014
2015        let bare = registry.get("tombi").expect("Should find bare tombi");
2016        let format = registry.get("tombi:format").expect("Should find tombi:format");
2017
2018        // The bare entry uses `lint` subcommand
2019        assert!(
2020            bare.command.contains(&"lint".to_string()),
2021            "Bare 'tombi' uses lint subcommand: {:?}",
2022            bare.command
2023        );
2024
2025        // The format entry uses `format` subcommand
2026        assert!(
2027            format.command.contains(&"format".to_string()),
2028            "tombi:format uses format subcommand: {:?}",
2029            format.command
2030        );
2031
2032        // These are different commands — using bare "tombi" in format = [...] is a bug
2033        assert_ne!(
2034            bare.command, format.command,
2035            "Bare 'tombi' and 'tombi:format' should have different commands (this is the root cause of #527)"
2036        );
2037    }
2038
2039    /// Tools that have both lint and format variants should have distinct entries.
2040    /// The processor resolves bare names to context-specific variants automatically.
2041    #[test]
2042    fn test_tools_with_lint_format_variants_are_distinct() {
2043        let registry = ToolRegistry::default();
2044
2045        // ruff has both check and format
2046        let ruff_check = registry.get("ruff:check").expect("ruff:check");
2047        let ruff_format = registry.get("ruff:format").expect("ruff:format");
2048        assert_ne!(
2049            ruff_check.command, ruff_format.command,
2050            "ruff:check and ruff:format should be distinct"
2051        );
2052
2053        // tombi has both lint and format
2054        let tombi_lint = registry.get("tombi:lint").expect("tombi:lint");
2055        let tombi_format = registry.get("tombi:format").expect("tombi:format");
2056        assert_ne!(
2057            tombi_lint.command, tombi_format.command,
2058            "tombi:lint and tombi:format should be distinct"
2059        );
2060    }
2061
2062    #[test]
2063    fn test_deno_fmt_has_per_extension_variants() {
2064        // deno fmt bakes the extension into each variant (the registry does no runtime
2065        // substitution), so a per-language slot picks the right parser.
2066        let registry = ToolRegistry::default();
2067
2068        let deno_json = registry.get("deno-fmt:json").expect("deno-fmt:json");
2069        assert!(deno_json.command.iter().any(|a| a == "--ext=json"));
2070        let deno_md = registry.get("deno-fmt:md").expect("deno-fmt:md");
2071        assert!(deno_md.command.iter().any(|a| a == "--ext=md"));
2072
2073        // Bare entry defaults to TypeScript.
2074        let deno = registry.get("deno-fmt").expect("deno-fmt");
2075        assert!(deno.command.iter().any(|a| a == "--ext=ts"));
2076    }
2077
2078    /// No built-in declares `lint_args`.
2079    ///
2080    /// A built-in linter carries its whole invocation in `command`, and a built-in
2081    /// formatter answers a `lint` slot by formatting and comparing, so neither needs one.
2082    /// Check flags are what this replaces: they are appended after the stdin argument, and
2083    /// tool by tool they were ignored there, printed a diff while exiting 0, or made the
2084    /// tool refuse to run at all. `lint_args` stays in the schema for user-defined tools,
2085    /// whose commands the user writes and owns.
2086    #[test]
2087    fn no_builtin_declares_lint_args() {
2088        let offenders: Vec<&str> = BUILTIN_TOOLS
2089            .iter()
2090            .filter(|(_, def)| !def.lint_args.is_empty())
2091            .map(|(id, _)| *id)
2092            .collect();
2093
2094        assert!(
2095            offenders.is_empty(),
2096            "built-in tools must not declare lint_args (a formatter is lint-checked by \
2097             formatting and comparing; a linter's args belong in `command`): {offenders:?}"
2098        );
2099    }
2100
2101    /// Every built-in's lint behavior follows from its documented kind, and a formatter
2102    /// asked to lint can actually be run: its format invocation reads stdin and writes the
2103    /// formatted source back.
2104    #[test]
2105    fn builtin_lint_mode_follows_documented_kind() {
2106        let registry = ToolRegistry::default();
2107
2108        for meta in BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime) {
2109            let def = registry.get(meta.id).expect("runtime metadata matches the registry");
2110            let mode = registry.lint_mode(meta.id);
2111
2112            match meta.kind {
2113                ToolKind::Format | ToolKind::FormatCheck => {
2114                    assert_eq!(
2115                        mode,
2116                        Some(BuiltinLintMode::FormatCheck),
2117                        "{} is documented as a formatter",
2118                        meta.id
2119                    );
2120                    assert!(
2121                        def.stdin && def.stdout,
2122                        "{} is lint-checked by comparing its output, so it must read stdin \
2123                         and write stdout",
2124                        meta.id
2125                    );
2126                }
2127                ToolKind::Lint | ToolKind::Both => {
2128                    assert_eq!(
2129                        mode,
2130                        Some(BuiltinLintMode::Diagnostics),
2131                        "{} is documented as a linter",
2132                        meta.id
2133                    );
2134                }
2135            }
2136        }
2137    }
2138
2139    /// A `format` slot either runs something that formats, or answers `false` so the run
2140    /// declines it.
2141    ///
2142    /// Both the config check and the format run decline a tool that answers `false`, because
2143    /// a linter run in a format slot replaces the block with its own report. The interesting
2144    /// case is a bare id whose linter and formatter are separate entries (`tombi`, `shuck`):
2145    /// the slot must pick the sibling that formats rather than answering for the linter the
2146    /// bare id names in a lint slot. Stated over the whole table, so a new entry is covered
2147    /// the moment it is added.
2148    #[test]
2149    fn format_slot_never_resolves_to_a_linter() {
2150        let registry = ToolRegistry::default();
2151        let documented_kind = |id: &str| {
2152            BUILTIN_TOOLS_DOCS
2153                .iter()
2154                .find(|m| m.id == id && m.runtime)
2155                .map(|m| m.kind)
2156        };
2157
2158        for meta in BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime) {
2159            let fills = registry.fills_format_slot(meta.id);
2160            let resolved = registry.resolve_id(meta.id, ToolSlot::Format);
2161
2162            match fills {
2163                Some(true) => {
2164                    let resolved = resolved.expect("a tool that fills the slot resolves in it");
2165                    assert!(
2166                        !matches!(documented_kind(&resolved), Some(ToolKind::Lint | ToolKind::FormatCheck)),
2167                        "{} fills a format slot by running {resolved}, which only lints",
2168                        meta.id
2169                    );
2170                }
2171                Some(false) => assert!(
2172                    matches!(documented_kind(meta.id), Some(ToolKind::Lint | ToolKind::FormatCheck)),
2173                    "{} declines the format slot, so it must be documented as a linter",
2174                    meta.id
2175                ),
2176                None => panic!("{} is in the registry, so it resolves somewhere", meta.id),
2177            }
2178        }
2179
2180        // The tools this is really about: each is documented as a linter, and each answers
2181        // for the format slot the way its own entries allow.
2182        assert_eq!(registry.fills_format_slot("ruff:check"), Some(false));
2183        assert_eq!(registry.fills_format_slot("shellcheck"), Some(false));
2184        assert_eq!(registry.fills_format_slot("sqlfluff:lint"), Some(false));
2185        assert_eq!(registry.fills_format_slot("tombi"), Some(true));
2186        assert_eq!(
2187            registry.resolve_id("tombi", ToolSlot::Format).as_deref(),
2188            Some("tombi:format")
2189        );
2190    }
2191
2192    /// A user-defined tool is run exactly as written, even when it shadows a built-in id.
2193    #[test]
2194    fn user_tool_is_never_lint_checked_by_formatting() {
2195        let mut user_tools = BTreeMap::new();
2196        user_tools.insert(
2197            "yamlfmt".to_string(),
2198            ToolDefinition {
2199                command: vec!["my-yaml-linter".to_string()],
2200                stdin: true,
2201                stdout: true,
2202                lint_args: vec!["--check".to_string()],
2203                format_args: vec![],
2204            },
2205        );
2206        let registry = ToolRegistry::new(user_tools);
2207
2208        assert_eq!(registry.lint_mode("yamlfmt"), None);
2209        // The built-in behind the same id is unaffected for anyone who did not override it.
2210        assert_eq!(
2211            ToolRegistry::default().lint_mode("yamlfmt"),
2212            Some(BuiltinLintMode::FormatCheck)
2213        );
2214    }
2215
2216    // =========================================================================
2217    // Docs metadata <-> registry invariants (lock the table to the registry)
2218    // =========================================================================
2219
2220    #[test]
2221    fn explicit_mode_aliases_preserve_custom_bare_definitions() {
2222        for id in ["oxfmt", "shuck"] {
2223            let custom = ToolDefinition {
2224                command: vec!["custom-tool".to_string()],
2225                stdin: true,
2226                stdout: true,
2227                lint_args: vec![],
2228                format_args: vec![],
2229            };
2230            let registry = ToolRegistry::new(BTreeMap::from([(id.to_string(), custom.clone())]));
2231            for slot in [ToolSlot::Lint, ToolSlot::Format] {
2232                assert_eq!(registry.resolve(id, slot), Some(&custom));
2233            }
2234        }
2235    }
2236
2237    #[test]
2238    fn explicit_check_variants_compare_output_but_cannot_format() {
2239        let registry = ToolRegistry::default();
2240        for id in [
2241            "oxfmt:lint",
2242            "djlint:html:format-check",
2243            "djlint:jinja:format-check",
2244            "shuck:format-check",
2245        ] {
2246            assert_eq!(registry.lint_mode(id), Some(BuiltinLintMode::FormatCheck));
2247            assert_eq!(registry.fills_format_slot(id), Some(false));
2248        }
2249        for profile in ["html", "jinja"] {
2250            for mode in ["lint", "format-check", "format"] {
2251                let id = format!("djlint:{profile}:{mode}");
2252                assert!(
2253                    registry
2254                        .get(&id)
2255                        .unwrap()
2256                        .command
2257                        .contains(&format!("--profile={profile}"))
2258                );
2259            }
2260        }
2261    }
2262
2263    #[test]
2264    fn test_docs_metadata_ids_unique() {
2265        let mut seen = std::collections::BTreeSet::new();
2266        for m in BUILTIN_TOOLS_DOCS {
2267            assert!(seen.insert(m.id), "duplicate metadata id: {}", m.id);
2268        }
2269    }
2270
2271    #[test]
2272    fn test_runtime_metadata_matches_registry_keys() {
2273        let runtime_meta: std::collections::BTreeSet<&str> =
2274            BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime).map(|m| m.id).collect();
2275        let map_keys: std::collections::BTreeSet<&str> = BUILTIN_TOOLS.keys().copied().collect();
2276        assert_eq!(
2277            runtime_meta, map_keys,
2278            "BUILTIN_TOOLS_DOCS runtime ids must exactly match BUILTIN_TOOLS keys (add/remove the doc entry alongside the registry entry)"
2279        );
2280    }
2281
2282    #[test]
2283    fn test_docs_only_ids_absent_from_registry() {
2284        for m in BUILTIN_TOOLS_DOCS.iter().filter(|m| !m.runtime) {
2285            assert!(
2286                !BUILTIN_TOOLS.contains_key(m.id),
2287                "docs-only id {} must not be a runtime registry tool",
2288                m.id
2289            );
2290        }
2291        // rumdl is the docs-only entry and tracks the processor constant.
2292        assert!(
2293            BUILTIN_TOOLS_DOCS
2294                .iter()
2295                .any(|m| !m.runtime && m.id == RUMDL_BUILTIN_TOOL),
2296            "rumdl must be present as a docs-only entry"
2297        );
2298    }
2299
2300    #[test]
2301    fn test_docs_only_requires_display_command() {
2302        for m in BUILTIN_TOOLS_DOCS.iter().filter(|m| !m.runtime) {
2303            assert!(
2304                m.display_command.is_some(),
2305                "docs-only id {} needs a display_command (no runtime command to derive)",
2306                m.id
2307            );
2308        }
2309    }
2310
2311    #[test]
2312    fn test_multi_runtime_group_requires_display_command() {
2313        let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
2314        for m in BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime) {
2315            *counts.entry(m.doc_group).or_default() += 1;
2316        }
2317        for m in BUILTIN_TOOLS_DOCS {
2318            if counts.get(m.doc_group).copied().unwrap_or(0) > 1 {
2319                assert!(
2320                    m.display_command.is_some(),
2321                    "doc_group `{}` has multiple runtime entries; `{}` needs an explicit display_command",
2322                    m.doc_group,
2323                    m.id
2324                );
2325            }
2326        }
2327    }
2328
2329    #[test]
2330    fn test_doc_group_language_and_kind_consistent() {
2331        let mut groups: std::collections::BTreeMap<&str, (&str, ToolKind)> = std::collections::BTreeMap::new();
2332        for m in BUILTIN_TOOLS_DOCS {
2333            match groups.get(m.doc_group) {
2334                None => {
2335                    groups.insert(m.doc_group, (m.language, m.kind));
2336                }
2337                Some((lang, kind)) => {
2338                    assert_eq!(
2339                        *lang, m.language,
2340                        "doc_group `{}` has mismatched languages",
2341                        m.doc_group
2342                    );
2343                    assert_eq!(*kind, m.kind, "doc_group `{}` has mismatched kinds", m.doc_group);
2344                }
2345            }
2346        }
2347    }
2348
2349    // =========================================================================
2350    // Generator: golden rows (hand-authored, independent of `generate`)
2351    // =========================================================================
2352
2353    /// Parse the rendered table into trimmed `(id, language, type, command)` tuples,
2354    /// independent of column-padding width.
2355    fn rendered_rows(table: &str) -> Vec<(String, String, String, String)> {
2356        table
2357            .lines()
2358            .skip(2) // header + separator
2359            .filter(|l| l.starts_with('|'))
2360            .map(|l| {
2361                let cells: Vec<String> = l.trim().trim_matches('|').split('|').map(|c| c.trim().to_string()).collect();
2362                (cells[0].clone(), cells[1].clone(), cells[2].clone(), cells[3].clone())
2363            })
2364            .collect()
2365    }
2366
2367    #[test]
2368    fn test_render_table_golden_rows() {
2369        let table = render_builtin_tools_table();
2370        let rows = rendered_rows(&table);
2371        let has = |id: &str, lang: &str, kind: &str, cmd: &str| {
2372            rows.iter()
2373                .any(|(i, l, k, c)| i == id && l == lang && k == kind && c == cmd)
2374        };
2375        // One exact row per kind, plus a collapsed group and the docs-only row.
2376        assert!(has("`shellcheck`", "Shell", "Lint", "`shellcheck --shell=bash -`"));
2377        assert!(has("`rustfmt`", "Rust", "Format", "`rustfmt`"));
2378        assert!(has("`jq`", "JSON", "Both", "`jq .`"));
2379        assert!(has(
2380            "`prettier`",
2381            "Multi",
2382            "Format",
2383            "`prettier --stdin-filepath=_.EXT`"
2384        ));
2385        assert!(has("`rumdl`", "Markdown", "Lint", "`built-in markdown linting`"));
2386        // Extension variants collapse into their group row.
2387        assert!(
2388            !table.contains("prettier:json"),
2389            "prettier variants must collapse into one row"
2390        );
2391        assert!(!table.contains("oxfmt:ts"), "oxfmt variants must collapse into one row");
2392    }
2393
2394    #[test]
2395    fn test_render_table_one_row_per_group() {
2396        let table = render_builtin_tools_table();
2397        assert_eq!(rendered_rows(&table).len(), builtin_tools_group_count());
2398    }
2399
2400    #[test]
2401    fn test_render_table_command_fallback_from_registry() {
2402        // A row with display_command: None pulls its command from BUILTIN_TOOLS.
2403        let table = render_builtin_tools_table();
2404        assert!(
2405            rendered_rows(&table)
2406                .iter()
2407                .any(|(i, _, _, c)| i == "`tombi`" && c == "`tombi lint -`")
2408        );
2409    }
2410
2411    #[test]
2412    fn test_render_command_reflects_mode_args() {
2413        let table = render_builtin_tools_table();
2414        let rows = rendered_rows(&table);
2415        let cmd = |id: &str| {
2416            let row = rows
2417                .iter()
2418                .find(|(i, _, _, _)| i == id)
2419                .unwrap_or_else(|| panic!("row {id} not found"));
2420            row.3.clone()
2421        };
2422        // Format-typed tools include format_args, not just the bare command.
2423        assert_eq!(cmd("`yamlfmt`"), "`yamlfmt -`");
2424        // Lint-typed tools include their subcommand args.
2425        assert_eq!(
2426            cmd("`sqlfluff:lint`"),
2427            "`sqlfluff lint --dialect ansi --format github-annotation-native -`"
2428        );
2429    }
2430
2431    #[test]
2432    fn test_runtime_command_for_kind_both_collapses_when_equal() {
2433        // jq lints and formats with the same invocation, so it renders once.
2434        assert_eq!(runtime_command_for_kind("jq", ToolKind::Both), "jq .");
2435    }
2436
2437    /// A `Both` tool renders both invocations when they differ.
2438    ///
2439    /// No table row exercises this today: `jq` is the only other `Both` tool and its two
2440    /// invocations are equal, while `djlint` renders from a curated `display_command`.
2441    #[test]
2442    fn test_runtime_command_for_kind_both_shows_both_invocations() {
2443        let rendered = runtime_command_for_kind("djlint", ToolKind::Both);
2444        let (lint, format) = rendered
2445            .split_once(" / ")
2446            .expect("differing invocations render as a pair");
2447        assert_eq!(
2448            lint,
2449            "djlint - --linter-output-format {filename}:{line}: {code} {message}"
2450        );
2451        assert_eq!(format, format!("{lint} --reformat"));
2452    }
2453
2454    /// A curated `display_command` runs the same program as the tool actually invoked.
2455    ///
2456    /// An override exists to keep the table narrow (`prettier --stdin-filepath=_.EXT`
2457    /// standing in for six extension variants, djlint without its output-format
2458    /// plumbing), never to name a different tool than the one rumdl executes.
2459    #[test]
2460    fn a_curated_display_command_names_the_program_that_runs() {
2461        for meta in BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime) {
2462            let Some(display) = meta.display_command else {
2463                continue;
2464            };
2465            let def = BUILTIN_TOOLS
2466                .get(meta.id)
2467                .expect("runtime metadata matches the registry");
2468            let program = def.command.first().expect("a built-in command names a program");
2469
2470            for invocation in display.split(" / ") {
2471                assert_eq!(
2472                    invocation.split_whitespace().next(),
2473                    Some(program.as_str()),
2474                    "display_command for `{}` starts with a different program than it runs ({program})",
2475                    meta.id
2476                );
2477            }
2478        }
2479    }
2480
2481    // =========================================================================
2482    // Splice / marker handling (fail-loud)
2483    // =========================================================================
2484
2485    #[test]
2486    fn test_splice_replaces_region_and_preserves_prose() {
2487        let doc = format!(
2488            "# Title\n\nIntro.\n\n{TABLE_BEGIN}\n\nstale\n\n{TABLE_END}\n\nAfter.\n\n| Built-in tools | 31 | 339 |\n"
2489        );
2490        let out = splice_builtin_tools_docs(&doc).expect("splice");
2491        assert!(out.starts_with("# Title\n\nIntro.\n\n"));
2492        assert!(out.contains("After.\n"));
2493        assert!(!out.contains("stale"));
2494        assert!(
2495            out.contains(&render_builtin_tools_table()),
2496            "generated table is spliced in verbatim"
2497        );
2498        // Count updated to the group count, width preserved (2-digit -> 2-digit here).
2499        assert!(out.contains(&format!("| Built-in tools | {} | 339 |", builtin_tools_group_count())));
2500    }
2501
2502    #[test]
2503    fn test_splice_missing_marker_errors() {
2504        let doc = "# Title\n\nno markers\n\n| Built-in tools | 31 | 339 |\n";
2505        assert_eq!(splice_builtin_tools_docs(doc), Err(DocsError::MissingMarker));
2506    }
2507
2508    #[test]
2509    fn test_splice_duplicate_marker_errors() {
2510        let doc = format!("{TABLE_BEGIN}\n\nx\n\n{TABLE_END}\n{TABLE_BEGIN}\n\ny\n\n{TABLE_END}\n");
2511        assert_eq!(splice_builtin_tools_docs(&doc), Err(DocsError::DuplicateMarker));
2512    }
2513
2514    #[test]
2515    fn test_splice_marker_order_errors() {
2516        let doc = format!("{TABLE_END}\n\nx\n\n{TABLE_BEGIN}\n\n| Built-in tools | 31 | 339 |\n");
2517        assert_eq!(splice_builtin_tools_docs(&doc), Err(DocsError::MarkerOrder));
2518    }
2519
2520    #[test]
2521    fn test_splice_missing_count_row_errors() {
2522        let doc = format!("{TABLE_BEGIN}\n\nx\n\n{TABLE_END}\n\nno count row here\n");
2523        assert_eq!(splice_builtin_tools_docs(&doc), Err(DocsError::CountRowMissing));
2524    }
2525
2526    #[test]
2527    fn test_splice_is_idempotent() {
2528        let doc = format!("{TABLE_BEGIN}\n\nstale\n\n{TABLE_END}\n\n| Built-in tools | 31 | 339 |\n");
2529        let once = splice_builtin_tools_docs(&doc).expect("first");
2530        let twice = splice_builtin_tools_docs(&once).expect("second");
2531        assert_eq!(once, twice, "splice must be idempotent");
2532    }
2533}
2534
2535#[cfg(test)]
2536mod native_format_check_tests {
2537    use super::*;
2538
2539    #[test]
2540    fn native_checks_use_exact_argv_only_for_the_explicit_builtin_ids() {
2541        let registry = ToolRegistry::new(BTreeMap::new());
2542        for (id, argv) in [
2543            ("shuck:format-check", vec!["shuck", "format", "-", "--check"]),
2544            (
2545                "djlint:html:format-check",
2546                vec!["djlint", "-", "--check", "--profile=html"],
2547            ),
2548            (
2549                "djlint:jinja:format-check",
2550                vec!["djlint", "-", "--check", "--profile=jinja"],
2551            ),
2552        ] {
2553            assert_eq!(registry.native_format_check(id).unwrap().command, argv);
2554        }
2555        for id in [
2556            "oxfmt:lint",
2557            "oxfmt:format",
2558            "shuck:format",
2559            "shuck:lint",
2560            "shuck:lint-fix",
2561            "djlint:html:format",
2562            "djlint:jinja:format",
2563            "djlint:html:lint",
2564            "djlint:reformat",
2565        ] {
2566            assert!(registry.native_format_check(id).is_none(), "{id}");
2567        }
2568    }
2569}