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