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