Skip to main content

code_ranker_plugin_api/
plugin.rs

1//! The [`LanguagePlugin`] trait + its [`PluginInput`].
2//!
3//! A plugin turns a workspace into nodes + edges at a requested level
4//! ([`analyze`](LanguagePlugin::analyze)) and **measures** the per-file **complexity
5//! metrics** for its own language ([`metrics`](LanguagePlugin::metrics)), returning
6//! the raw [`MetricInputs`](crate::metrics::MetricInputs) for the orchestrator to
7//! write. Measuring is a per-language concern — each plugin parses its own files
8//! with its own grammar and engine — so there is no central, by-extension metric
9//! dispatcher; but the *writing* (tier-2 derivation + node enrichment) and the
10//! language-agnostic derived data (cycles, Henry-Kafura, stats) are filled
11//! centrally by the orchestrator, so a plugin needs no dependency on the
12//! graph/enrichment crate. Plugins SELF-REGISTER via [`inventory::submit!`] into
13//! the [`registry`]; the CLI works only with that array through this trait and
14//! never names a concrete language.
15
16use crate::graph::Graph;
17use crate::level::{AttributeSpec, Level};
18use crate::metrics::MetricInputs;
19use crate::node::Node;
20use crate::preset::Preset;
21use crate::report::ReportOverride;
22use anyhow::Result;
23use std::collections::BTreeMap;
24use std::path::Path;
25
26/// Everything the orchestrator feeds a plugin from config + CLI input.
27#[derive(Debug, Clone, Default)]
28pub struct PluginInput {
29    /// Glob patterns for paths to skip during analysis (config + CLI).
30    pub ignore: Vec<String>,
31    /// When `true`, the plugin must skip its own **test files** during the walk
32    /// (mirrors `[ignore] tests`). What counts as a test is language-specific, so
33    /// the detection lives in the plugin (during
34    /// [`analyze`](LanguagePlugin::analyze)), not the CLI.
35    pub ignore_tests: bool,
36    /// When `true`, a directory-walking plugin honours `.gitignore` (+ global
37    /// gitignore + `.git/info/exclude`) while collecting source files, scoped to
38    /// the analyzed root (mirrors `[ignore] gitignore`). The Rust plugin resolves
39    /// files via `cargo metadata`, not a walk, so it ignores this.
40    pub gitignore: bool,
41    /// When `true`, a directory-walking plugin honours `.ignore` files while
42    /// collecting source files (mirrors `[ignore] ignore_files`).
43    pub ignore_files: bool,
44    /// When `true`, a directory-walking plugin skips hidden files / directories
45    /// (dotfiles) while collecting source files (mirrors `[ignore] hidden`).
46    pub hidden: bool,
47}
48
49pub trait LanguagePlugin: Sync {
50    /// Canonical name, e.g. `"rust"`. Used by `--plugin` and recorded in the
51    /// snapshot. Each plugin has exactly one name (js and ts are separate).
52    fn name(&self) -> &str;
53
54    /// The plugin's fully-merged config table (its inheritance chain
55    /// `defaults.toml ⊕ [base] ⊕ <lang>.toml`). Surfaced for `--export-full-config`
56    /// so a user can inspect every effective parameter. Default: empty (a stub /
57    /// test plugin with no config file).
58    fn config(&self) -> toml::Table {
59        toml::Table::new()
60    }
61
62    /// Can this plugin parse `workspace` (honoring `input`)?
63    fn detect(&self, workspace: &Path, input: &PluginInput) -> bool;
64
65    /// Levels this plugin can produce, each carrying its edge-kind / attribute /
66    /// node-kind / cycle-kind semantics.
67    fn levels(&self) -> Vec<Level>;
68
69    /// Parse the workspace into the file-level graph. **Structure only**: nodes
70    /// (with their structural attributes) + edges. Metrics are added downstream.
71    /// When `input.ignore_tests` is set, the plugin must drop its own test files
72    /// here (it knows the language's conventions).
73    fn analyze(&self, workspace: &Path, input: &PluginInput) -> Result<Graph>;
74
75    /// **Measure** this language's per-file complexity tier-1 counts and return
76    /// them keyed by `file` node id (an absolute path). The plugin parses each of
77    /// its own files (by `node.id`) with its own grammar and engine, returning a
78    /// [`MetricInputs`] per file; it does **not** write them. The orchestrator
79    /// runs the tier-2 registry and writes every metric onto the node — so the
80    /// plugin needs no dependency on the graph/enrichment crate. Default: none (a
81    /// plugin that ships no metric engine).
82    fn metrics(&self, _graph: &Graph) -> Vec<(String, MetricInputs)> {
83        Vec::new()
84    }
85
86    /// Function-level metric units — one per sub-file unit (function / method /
87    /// closure) — for the optional `functions` graph level. Each returned pair is
88    /// the unit's [`Node`] (its per-language `kind`, `name`, and `parent` = its
89    /// **file node's id**, but **no metrics yet**) plus the unit's measured
90    /// [`MetricInputs`]; the orchestrator writes the metrics onto the node. `graph`
91    /// is the just-parsed file graph with **absolute** file-path ids, so a plugin
92    /// reads each file by `node.id`. Only called when the level is enabled;
93    /// default: none (a plugin that ships no function-level support).
94    fn function_units(&self, _graph: &Graph) -> Vec<(Node, MetricInputs)> {
95        Vec::new()
96    }
97
98    /// Toolchain versions to record in the snapshot, e.g. `[("rustc", "1.88.0")]`.
99    fn versions(&self, _workspace: &Path, _input: &PluginInput) -> Vec<(String, String)> {
100        Vec::new()
101    }
102
103    /// Named external-path roots for this language, as `(name, absolute_path)`
104    /// pairs, used to shorten node ids in the snapshot (a path under a root is
105    /// rewritten to `{name}/…`). These are **language-specific** — e.g. Rust
106    /// returns `cargo` / `registry` / `rustup` / `rust-src`; a Python plugin would
107    /// return its virtualenv / site-packages; JS/TS would return `node_modules`.
108    /// The orchestrator always adds the generic `target` root itself, so a plugin
109    /// returns only its own toolchain/dependency locations. Default: none.
110    ///
111    /// This keeps language/toolchain knowledge inside the plugin instead of the
112    /// language-agnostic orchestrator (mirrors [`versions`](Self::versions)).
113    fn roots(&self, _workspace: &Path) -> Vec<(String, String)> {
114        Vec::new()
115    }
116
117    /// The Prompt-Generator presets for this language. A plugin builds them from
118    /// its own config (the common catalog in `defaults.toml` merged with the
119    /// language's `<lang>.toml`, with each `doc_url` resolved). Default: none (a
120    /// plugin that ships no presets).
121    fn presets(&self, _input: &PluginInput) -> Vec<Preset> {
122        Vec::new()
123    }
124
125    /// Transform the orchestrator's **language-neutral** default complexity metric
126    /// specs (key → [`AttributeSpec`], from `code-ranker-graph`'s `metric_specs`)
127    /// for this language. Default: pass them through unchanged. A plugin may reword
128    /// a `description` to add language-specific nuance (e.g. Rust noting that
129    /// `sloc` / `lloc` / `cloc` / `blank` exclude inline `#[cfg(test)]` items) — so
130    /// the shared catalog stays neutral and each language refines only what differs.
131    fn metric_specs(
132        &self,
133        defaults: BTreeMap<String, AttributeSpec>,
134    ) -> BTreeMap<String, AttributeSpec> {
135        defaults
136    }
137
138    /// Per-language patches over the global report lists — the table `columns`,
139    /// the card-featured metrics, and the JSON `stats` keys (all inherited from
140    /// the metric catalog). A language adds its own metric (e.g. Rust `unsafe`),
141    /// drops some, or reorders, via its `<lang>.toml` `[report]` section. The
142    /// orchestrator applies the patch over the catalog defaults, then prunes to
143    /// keys present. Default: no override (use the catalog lists as-is).
144    fn report_overrides(&self) -> ReportOverride {
145        ReportOverride::default()
146    }
147}
148
149/// A self-registered language plugin. Each plugin in the plugins crate submits one
150/// via [`inventory::submit!`]; the binary's registry is assembled by the linker, so
151/// NO central code lists the plugins and no caller (the CLI) ever names a language.
152/// Plugins are zero-sized unit structs, so a `&'static` reference is free.
153pub struct PluginRegistration(pub &'static dyn LanguagePlugin);
154
155inventory::collect!(PluginRegistration);
156
157/// Every self-registered language plugin. The CLI works only through this array
158/// and the [`LanguagePlugin`] trait — it never names a concrete language.
159///
160/// Order is link order and is NOT significant: auto-detection treats multiple
161/// matches as an error (it never picks by position), and any user-facing listing
162/// sorts by [`LanguagePlugin::name`].
163pub fn registry() -> Vec<&'static dyn LanguagePlugin> {
164    inventory::iter::<PluginRegistration>()
165        .map(|entry| entry.0)
166        .collect()
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::graph::Graph;
173
174    /// A minimal plugin that implements only the required methods, so the trait's
175    /// default hooks (`versions` / `roots` / `presets` / `metric_specs` /
176    /// `thresholds` / `metrics`) are exercised as-is.
177    struct Dummy;
178    impl LanguagePlugin for Dummy {
179        fn name(&self) -> &str {
180            "dummy"
181        }
182        fn detect(&self, _w: &Path, _i: &PluginInput) -> bool {
183            false
184        }
185        fn levels(&self) -> Vec<crate::level::Level> {
186            Vec::new()
187        }
188        fn analyze(&self, _w: &Path, _i: &PluginInput) -> Result<Graph> {
189            Ok(Graph {
190                nodes: Vec::new(),
191                edges: Vec::new(),
192            })
193        }
194    }
195
196    #[test]
197    fn trait_default_hooks_are_noops() {
198        let p = Dummy;
199        let ws = Path::new("/tmp");
200        let input = PluginInput::default();
201
202        // Exercise the required methods too, so the dummy carries no dead code.
203        assert_eq!(p.name(), "dummy");
204        assert!(!p.detect(ws, &input));
205        assert!(p.levels().is_empty());
206        let g = p.analyze(ws, &input).expect("dummy analyze ok");
207        assert!(g.nodes.is_empty() && g.edges.is_empty());
208
209        let empty_graph = Graph {
210            nodes: Vec::new(),
211            edges: Vec::new(),
212        };
213        assert!(
214            p.function_units(&empty_graph).is_empty(),
215            "default: no function units"
216        );
217        assert!(p.metrics(&empty_graph).is_empty(), "default: no metrics");
218        assert!(p.versions(ws, &input).is_empty(), "default: no versions");
219        assert!(p.roots(ws).is_empty(), "default: no roots");
220
221        // config defaults to an empty table (a stub with no config file).
222        assert!(p.config().is_empty(), "default: empty config table");
223
224        // presets defaults to none; metric_specs defaults to pass-through.
225        assert!(p.presets(&input).is_empty());
226        let specs: BTreeMap<String, AttributeSpec> = BTreeMap::new();
227        assert!(p.metric_specs(specs).is_empty());
228
229        // report_overrides defaults to a no-op (catalog lists kept as-is).
230        let ro = p.report_overrides();
231        assert!(ro.columns.is_noop() && ro.card.is_noop() && ro.stats.is_noop());
232    }
233}