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