Skip to main content

drep/languages/
spec.rs

1//! Language support contract shared by every registered language.
2//!
3//! drep analyzes a file in two layers, and this module is what keeps them free of
4//! per-language conditionals:
5//!
6//! - **Deterministic**: the project's own tools (ruff, eslint, gofmt, clippy).
7//!   They are precise, so their findings can gate a commit.
8//! - **Semantic**: the LLM, told which language it is looking at. It reads any
9//!   language without a parser, so it needs no per-language machinery beyond a
10//!   prompt - which is why adding a language here is a data change, not a
11//!   refactor.
12//!
13//! Deliberately free of heavyweight drep imports: the registry is consulted by
14//! file discovery (`drep.core.file_targets`), which analyzer packages import.
15
16pub const DEFAULT_TOOL_TIMEOUT_SECS: u64 = 120;
17
18/// How to parse the tool's diagnostics into findings.
19///
20/// A fieldless enum rather than a string: `parse_output` dispatches on it, so
21/// a misspelling is a compile error at the definition site instead of a
22/// runtime "no parser for output format" surfacing only when the tool runs.
23/// No `#[non_exhaustive]` - this crate is the only consumer, and the
24/// wildcard arm it forces is exactly the silent-fallback hole the enum
25/// exists to close.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum OutputFormat {
28    Lines,
29    Json,
30    Position,
31    Tsc,
32    Cargo,
33    Sarif,
34    Ktlint,
35    Shellcheck,
36    Rubocop,
37    Phpcs,
38    Credo,
39    Sqlfluff,
40    Msbuild,
41}
42
43impl OutputFormat {
44    /// Whether this format's parser *skips* input it does not recognise
45    /// instead of erroring on it.
46    ///
47    /// The skip parsers exist because their tools interleave chatter among
48    /// the diagnostics (Go's `# pkg` headers, MSBuild's restore noise). The
49    /// price is that a run whose every line is chatter of a new shape - an
50    /// SDK error, a rejected invocation - parses as zero findings on a
51    /// non-empty stream, which is why the runner treats that combination as
52    /// `Unavailable` for these formats and no others. Every other format
53    /// errors on input it cannot parse, so the hole cannot open there.
54    pub fn skips_unmatched_input(self) -> bool {
55        matches!(self, Self::Position | Self::Tsc | Self::Msbuild)
56    }
57}
58
59/// Which process stream carries a tool's diagnostics.
60///
61/// `go vet` writes to stderr, so reading only stdout would report every Go
62/// file clean. An enum because the runner compares it exactly once: a typo'd
63/// string silently selected stdout, which is that same failure.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum DiagnosticsStream {
66    Stdout,
67    Stderr,
68}
69
70/// A deterministic checker for one language.
71///
72/// Attributes:
73///     name: Tool name, used in logs and finding provenance.
74///     command: argv to run, minus the files. The first element is resolved
75///         against local_paths before PATH.
76///     local_paths: Repo-relative locations to prefer over PATH, so a project
77///         gets the version its own CI runs (node_modules/.bin/eslint rather
78///         than whatever is installed globally).
79///     config_files: Repo-relative paths that mean "this project has opted
80///         into this tool". A tool with none of them present is skipped: its
81///         defaults are not the project's chosen style, so running it anyway
82///         would invent findings the project never asked for.
83///     config_flag: Flag that hands the discovered config file to the tool,
84///         for the checkers that will not look for it themselves.
85///     output_format: How to parse the tool's diagnostics into findings.
86///     diagnostics_stream: Which stream carries them. `go vet` writes to
87///         stderr, so reading only stdout would report every Go file clean.
88#[derive(Debug, Clone, PartialEq)]
89pub struct ToolSpec {
90    /// Tool name, used in logs and finding provenance.
91    pub name: &'static str,
92    /// argv to run, minus the files. The first element is resolved against `local_paths` before PATH.
93    pub command: &'static [&'static str],
94    /// Repo-relative locations to prefer over PATH, so a project gets the
95    /// version its own CI runs (`node_modules/.bin/eslint` rather than
96    /// whatever is installed globally).
97    pub local_paths: &'static [&'static str],
98    /// Repo-relative paths that mean "this project has opted into this tool".
99    /// A tool with none of them present is skipped: its defaults are not the
100    /// project's chosen style, so running it anyway would invent findings the
101    /// project never asked for.
102    pub config_files: &'static [&'static str],
103    /// Flag that hands the discovered config file to the tool, e.g. `"-c"`.
104    ///
105    /// Most checkers find their own config: ruff reads `pyproject.toml` out of
106    /// the working directory without being told. The JVM linters do not.
107    /// `checkstyle` run bare exits 1 with "Must specify a config XML", so
108    /// without this it could not run at all. When set, the config path
109    /// `config_files` already discovered is appended as
110    /// `[config_flag, <path>]` ahead of the file arguments.
111    pub config_flag: Option<&'static str>,
112    /// How to parse the tool's diagnostics into findings.
113    pub output_format: OutputFormat,
114    /// Which stream carries them.
115    pub diagnostics_stream: DiagnosticsStream,
116    /// Wall-clock ceiling for the process. This includes any tool-owned lock
117    /// wait before analysis starts.
118    pub timeout_secs: u64,
119    /// Optional diagnostic suffix explaining a legitimate long wait.
120    pub timeout_context: Option<&'static str>,
121    /// A zero-exit run proves the checked files compiled/typechecked.
122    pub establishes_compilation: bool,
123    /// Whether invocations of this tool must be serialized within a repo.
124    pub serial_in_repository: bool,
125    /// Whether the tool accepts file paths as arguments.
126    ///
127    /// `cargo clippy` does not: it checks a *crate*, and a path argument is
128    /// rejected outright with "unexpected argument". Appending files to it
129    /// therefore made every run fail, so every Rust file came back
130    /// `Unavailable` and `drep check` exited 2 on any Rust repository - the
131    /// deterministic half for Rust never ran at all.
132    ///
133    /// A tool with `accepts_files: false` is invoked bare and reports on the
134    /// whole project, so its findings are filtered down to the files actually
135    /// being checked. Without that filter a commit gate would block on
136    /// pre-existing issues in code the commit never touched.
137    pub accepts_files: bool,
138}
139
140impl Default for ToolSpec {
141    fn default() -> Self {
142        Self {
143            name: "",
144            command: &[],
145            local_paths: &[],
146            config_files: &[],
147            config_flag: None,
148            output_format: OutputFormat::Json,
149            diagnostics_stream: DiagnosticsStream::Stdout,
150            timeout_secs: DEFAULT_TOOL_TIMEOUT_SECS,
151            timeout_context: None,
152            establishes_compilation: false,
153            serial_in_repository: false,
154            accepts_files: true,
155        }
156    }
157}
158
159/// Everything drep needs to know about one language.
160///
161/// Attributes:
162///     name: Registry key (lowercase, e.g. "typescript").
163///     display_name: How the language is named to the LLM and to users.
164///     extensions: Lowercased suffixes this language owns, including the dot.
165///     filenames: Whole file names this language owns, for extensionless files
166///         such as `Dockerfile` and `Gemfile`.
167///     tools: Deterministic checkers, in the order they should run.
168///     conventions: Language-specific guidance appended to the analysis
169///         prompt - the part that used to be hardcoded as PEP 8.
170///     vendored_dirs: Dependency and build directories this language creates,
171///         never descended into. Declared here rather than in a global list
172///         so adding a language stays a single-file change.
173#[derive(Debug, Default, Clone, PartialEq)]
174pub struct LanguageSupport {
175    /// Registry key (lowercase, e.g. `"typescript"`).
176    pub name: &'static str,
177    /// How the language is named to the LLM and to users.
178    pub display_name: &'static str,
179    /// Lowercased suffixes this language owns, including the dot.
180    pub extensions: &'static [&'static str],
181    /// Whole file names this language owns, for files that carry no extension
182    /// at all.
183    ///
184    /// `Path::extension` returns `None` for `Dockerfile`, `Gemfile` and
185    /// `Rakefile`, so an extension-only registry drops them before either
186    /// analysis layer sees them - the same silent pass that unregistered
187    /// `.java` produced, on files most repositories have. Only names a
188    /// registered language actually claims belong here: `Makefile` and
189    /// `Jenkinsfile` are deliberately absent, because no language claims them
190    /// and a name with no owner would still resolve to nothing.
191    /// Matched only when the extension lookup finds nothing, so a name here
192    /// can never shadow a language that claims the suffix.
193    pub filenames: &'static [&'static str],
194    /// Stems that additionally claim their dotted variants, for families like
195    /// `Dockerfile.dev`.
196    ///
197    /// `filenames` claims the exact canonical name; a stem here claims
198    /// `<stem>.<anything>` as well. Multi-image layouts name per-environment
199    /// Dockerfiles `Dockerfile.dev`, `Dockerfile.prod`, `Dockerfile.web` - an
200    /// unbounded family an exact list cannot cover, and hadolint lints the
201    /// variants the same as the canonical file. The extension lookup runs
202    /// first, so `Dockerfile.ts` remains TypeScript, and the variant must
203    /// carry a non-empty suffix after the dot, so `Dockerfile.` itself is
204    /// not claimed.
205    pub filename_prefixes: &'static [&'static str],
206    /// Deterministic checkers, in the order they should run.
207    pub tools: &'static [&'static ToolSpec],
208    /// Language-specific guidance appended to the analysis prompt - the part
209    /// that used to be hardcoded as PEP 8.
210    pub conventions: &'static [&'static str],
211    /// Dependency and build directories this language creates, never
212    /// descended into. Declared here rather than in a global list so adding
213    /// a language stays a single-file change.
214    pub vendored_dirs: &'static [&'static str],
215}