Skip to main content

rumdl_lib/code_block_tools/
registry.rs

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