Skip to main content

code_ranker_plugin_api/
plugin.rs

1//! The [`LanguagePlugin`] trait + [`Options`] + [`Preset`].
2//!
3//! A plugin turns a workspace into nodes + edges at a requested level
4//! ([`analyze`](LanguagePlugin::analyze)) and writes the per-file **complexity
5//! metrics** for its own language onto those nodes ([`metrics`](LanguagePlugin::metrics)).
6//! Metrics are a per-language concern — each plugin parses its own files with its
7//! own grammar and calls the matching `code-ranker-complexity` engine — so there
8//! is no central, by-extension metric dispatcher. The language-agnostic derived
9//! data (cycles, Henry-Kafura, stats) is still filled centrally by the
10//! orchestrator. The CLI holds the registry of plugins; it talks to them ONLY
11//! through this trait and never names a concrete language.
12
13use crate::graph::Graph;
14use crate::level::{AttributeSpec, Level, Thresholds};
15use anyhow::Result;
16use serde::{Deserialize, Serialize};
17use std::collections::BTreeMap;
18use std::path::Path;
19
20/// Return `true` when `workspace` contains the given marker file. A generic,
21/// language-agnostic detection helper for marker-based plugins (e.g. JS →
22/// `"package.json"`, TS → `"tsconfig.json"`). Lives here, not in any one language
23/// plugin, so every plugin can reuse it without depending on a sibling plugin.
24pub fn detect_with_marker(workspace: &Path, marker: &str) -> bool {
25    workspace.join(marker).exists()
26}
27
28/// Free-form key/value options passed from the CLI (future `--plugin-opt k=v`).
29/// `BTreeMap` for deterministic iteration order.
30pub type Options = BTreeMap<String, String>;
31
32/// Everything the orchestrator feeds a plugin from config + CLI input.
33#[derive(Debug, Clone, Default)]
34pub struct PluginInput {
35    /// Glob patterns for paths to skip during analysis (config + CLI).
36    pub ignore: Vec<String>,
37    /// When `true`, the plugin must skip its own **test files** during the walk
38    /// (mirrors `[ignore] tests`). What counts as a test is language-specific —
39    /// see [`LanguagePlugin::is_test_path`] — so the detection lives in the
40    /// plugin, not the CLI.
41    pub ignore_tests: bool,
42    /// Free-form key/value options. A plugin reads its own keys, ignores the rest.
43    pub options: Options,
44}
45
46/// A Prompt-Generator preset (a refactoring principle): a ready-to-paste AI
47/// instruction plus how the UI seeds the node selection for it. The orchestrator
48/// builds a generic default set and hands it to [`LanguagePlugin::presets`],
49/// which may pass it through, edit, drop or extend per language.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct Preset {
52    /// Stable id / short code shown on the button (e.g. `"ADP"`).
53    pub id: String,
54    /// Button label (usually the id).
55    pub label: String,
56    /// Full principle title (first heading of the generated prompt).
57    pub title: String,
58    /// The prompt body (Markdown, language-neutral by default).
59    pub prompt: String,
60    /// Link to the full principle doc, if any.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub doc_url: Option<String>,
63    /// The metric the recommended-node list sorts by (an attribute key, or the
64    /// pseudo-metric `"cycle"`).
65    pub sort_metric: String,
66    /// Which connection sets the preset pre-selects: any of `"in"`/`"out"`/`"common"`.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub connections: Vec<String>,
69}
70
71pub trait LanguagePlugin {
72    /// Canonical name, e.g. `"rust"`. Used by `--plugin` and recorded in the
73    /// snapshot. Each plugin has exactly one name (js and ts are separate).
74    fn name(&self) -> &str;
75
76    /// Can this plugin parse `workspace` (honoring `input`)?
77    fn detect(&self, workspace: &Path, input: &PluginInput) -> bool;
78
79    /// Levels this plugin can produce, each carrying its edge-kind / attribute /
80    /// node-kind / cycle-kind semantics.
81    fn levels(&self) -> Vec<Level>;
82
83    /// Parse the workspace into a graph AT `level` (by name). **Structure only**:
84    /// nodes (with their structural attributes) + edges. Metrics are added
85    /// downstream. When `input.ignore_tests` is set, the plugin must drop its
86    /// own test files here (it knows the language's conventions; see
87    /// [`is_test_path`](Self::is_test_path)).
88    fn analyze(&self, workspace: &Path, level: &str, input: &PluginInput) -> Result<Graph>;
89
90    /// Write this language's per-file complexity metrics (cyclomatic, cognitive,
91    /// Halstead, MI, LOC, …) onto the graph's `file` nodes, in place. The plugin
92    /// parses each of its own files (by `node.id`, an absolute path) with its own
93    /// grammar and calls the matching `code-ranker-complexity` engine. Returns the
94    /// number of file nodes annotated. Default: none (a plugin that ships no
95    /// metric engine).
96    fn metrics(&self, _graph: &mut Graph) -> usize {
97        0
98    }
99
100    /// Does this workspace-relative path (forward-slashed, no leading `./`) name
101    /// a **test** file in this language? Used to drop tests during the walk when
102    /// `PluginInput::ignore_tests` is set. Default: nothing is a test.
103    fn is_test_path(&self, _rel_path: &str) -> bool {
104        false
105    }
106
107    /// Toolchain versions to record in the snapshot, e.g. `[("rustc", "1.88.0")]`.
108    fn versions(&self, _workspace: &Path, _input: &PluginInput) -> Vec<(String, String)> {
109        Vec::new()
110    }
111
112    /// Named external-path roots for this language, as `(name, absolute_path)`
113    /// pairs, used to shorten node ids in the snapshot (a path under a root is
114    /// rewritten to `{name}/…`). These are **language-specific** — e.g. Rust
115    /// returns `cargo` / `registry` / `rustup` / `rust-src`; a Python plugin would
116    /// return its virtualenv / site-packages; JS/TS would return `node_modules`.
117    /// The orchestrator always adds the generic `target` root itself, so a plugin
118    /// returns only its own toolchain/dependency locations. Default: none.
119    ///
120    /// This keeps language/toolchain knowledge inside the plugin instead of the
121    /// language-agnostic orchestrator (mirrors [`versions`](Self::versions)).
122    fn roots(&self, _workspace: &Path) -> Vec<(String, String)> {
123        Vec::new()
124    }
125
126    /// Transform the orchestrator's generic default presets for this language.
127    /// Default: pass them through unchanged. A plugin may reword a `prompt`,
128    /// change a `sort_metric`, drop a preset, or add language-specific ones.
129    fn presets(&self, defaults: Vec<Preset>, _input: &PluginInput) -> Vec<Preset> {
130        defaults
131    }
132
133    /// Transform the orchestrator's **language-neutral** default complexity metric
134    /// specs (key → [`AttributeSpec`], from `code-ranker-graph`'s `metric_specs`)
135    /// for this language. Default: pass them through unchanged. A plugin may reword
136    /// a `description` to add language-specific nuance (e.g. Rust noting that
137    /// `sloc` / `lloc` / `cloc` / `blank` exclude inline `#[cfg(test)]` items) — so
138    /// the shared catalog stays neutral and each language refines only what differs.
139    fn metric_specs(
140        &self,
141        defaults: BTreeMap<String, AttributeSpec>,
142    ) -> BTreeMap<String, AttributeSpec> {
143        defaults
144    }
145
146    /// Language-calibrated per-metric thresholds (attribute key → tiers). The
147    /// orchestrator overlays these onto the attribute specs. Default: none.
148    fn thresholds(&self) -> BTreeMap<String, Thresholds> {
149        BTreeMap::new()
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::graph::Graph;
157
158    /// A minimal plugin that implements only the required methods, so the trait's
159    /// default hooks (`is_test_path` / `versions` / `roots` / `presets` /
160    /// `metric_specs` / `thresholds` / `metrics`) are exercised as-is.
161    struct Dummy;
162    impl LanguagePlugin for Dummy {
163        fn name(&self) -> &str {
164            "dummy"
165        }
166        fn detect(&self, _w: &Path, _i: &PluginInput) -> bool {
167            false
168        }
169        fn levels(&self) -> Vec<crate::level::Level> {
170            Vec::new()
171        }
172        fn analyze(&self, _w: &Path, _l: &str, _i: &PluginInput) -> Result<Graph> {
173            Ok(Graph {
174                nodes: Vec::new(),
175                edges: Vec::new(),
176            })
177        }
178    }
179
180    #[test]
181    fn trait_default_hooks_are_noops() {
182        let p = Dummy;
183        let ws = Path::new("/tmp");
184        let input = PluginInput::default();
185
186        // Exercise the required methods too, so the dummy carries no dead code.
187        assert_eq!(p.name(), "dummy");
188        assert!(!p.detect(ws, &input));
189        assert!(p.levels().is_empty());
190        let g = p.analyze(ws, "files", &input).expect("dummy analyze ok");
191        assert!(g.nodes.is_empty() && g.edges.is_empty());
192
193        assert!(!p.is_test_path("anything"), "default: nothing is a test");
194        assert!(p.versions(ws, &input).is_empty(), "default: no versions");
195        assert!(p.roots(ws).is_empty(), "default: no roots");
196        assert!(p.thresholds().is_empty(), "default: no thresholds");
197
198        // presets / metric_specs default to pass-through (return input unchanged).
199        assert!(p.presets(Vec::new(), &input).is_empty());
200        let specs: BTreeMap<String, AttributeSpec> = BTreeMap::new();
201        assert!(p.metric_specs(specs).is_empty());
202
203        // metrics default: annotates nothing.
204        let mut g = Graph {
205            nodes: Vec::new(),
206            edges: Vec::new(),
207        };
208        assert_eq!(p.metrics(&mut g), 0);
209    }
210
211    #[test]
212    fn detect_with_marker_checks_file_presence() {
213        let dir = std::env::temp_dir();
214        // a marker that (almost certainly) does not exist
215        assert!(!detect_with_marker(&dir, "code-ranker-no-such-marker.xyz"));
216    }
217}