perspt_core/
plugin.rs

1//! Language Plugin Architecture
2//!
3//! Provides a trait-based plugin system for polyglot support.
4//! Each language (Rust, Python, JS, etc.) implements this trait.
5
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9/// LSP Configuration for a language
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct LspConfig {
12    /// LSP server binary name
13    pub server_binary: String,
14    /// Arguments to pass to the server
15    pub args: Vec<String>,
16    /// Language ID for textDocument/didOpen
17    pub language_id: String,
18}
19
20/// Options for project initialization
21#[derive(Debug, Clone, Default)]
22pub struct InitOptions {
23    /// Project name
24    pub name: String,
25    /// Whether to use a specific package manager (e.g., "poetry", "pdm", "npm", "pnpm")
26    pub package_manager: Option<String>,
27    /// Additional flags
28    pub flags: Vec<String>,
29    /// Whether the target directory is empty
30    pub is_empty_dir: bool,
31}
32
33/// Action to take for project initialization or tooling sync
34#[derive(Debug, Clone)]
35pub enum ProjectAction {
36    /// Execute a shell command
37    ExecCommand {
38        /// The command to run
39        command: String,
40        /// Human-readable description of what this command does
41        description: String,
42    },
43    /// No action needed
44    NoAction,
45}
46
47/// A plugin for a specific programming language
48pub trait LanguagePlugin: Send + Sync {
49    /// Name of the language
50    fn name(&self) -> &str;
51
52    /// File extensions this plugin handles
53    fn extensions(&self) -> &[&str];
54
55    /// Key files that identify this language (e.g., Cargo.toml, pyproject.toml)
56    fn key_files(&self) -> &[&str];
57
58    /// Detect if this plugin should handle the given project directory
59    fn detect(&self, path: &Path) -> bool {
60        // Check for key files
61        for key_file in self.key_files() {
62            if path.join(key_file).exists() {
63                return true;
64            }
65        }
66
67        // Check for files with handled extensions
68        if let Ok(entries) = std::fs::read_dir(path) {
69            for entry in entries.flatten() {
70                if let Some(ext) = entry.path().extension() {
71                    let ext_str = ext.to_string_lossy();
72                    if self.extensions().iter().any(|e| *e == ext_str) {
73                        return true;
74                    }
75                }
76            }
77        }
78
79        false
80    }
81
82    /// Get the LSP configuration for this language
83    fn get_lsp_config(&self) -> LspConfig;
84
85    /// Get the action to initialize a new project (greenfield)
86    fn get_init_action(&self, opts: &InitOptions) -> ProjectAction;
87
88    /// Check if an existing project needs tooling sync (e.g., uv sync, cargo fetch)
89    fn check_tooling_action(&self, path: &Path) -> ProjectAction;
90
91    /// Get the command to initialize a new project
92    /// DEPRECATED: Use get_init_action instead
93    fn init_command(&self, opts: &InitOptions) -> String;
94
95    /// Get the command to run tests
96    fn test_command(&self) -> String;
97
98    /// Get the command to run the project (for verification)
99    fn run_command(&self) -> String;
100}
101
102/// Rust language plugin
103pub struct RustPlugin;
104
105impl LanguagePlugin for RustPlugin {
106    fn name(&self) -> &str {
107        "rust"
108    }
109
110    fn extensions(&self) -> &[&str] {
111        &["rs"]
112    }
113
114    fn key_files(&self) -> &[&str] {
115        &["Cargo.toml", "Cargo.lock"]
116    }
117
118    fn get_lsp_config(&self) -> LspConfig {
119        LspConfig {
120            server_binary: "rust-analyzer".to_string(),
121            args: vec![],
122            language_id: "rust".to_string(),
123        }
124    }
125
126    fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
127        let command = if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
128            "cargo init .".to_string()
129        } else {
130            format!("cargo new {}", opts.name)
131        };
132        ProjectAction::ExecCommand {
133            command,
134            description: "Initialize Rust project with Cargo".to_string(),
135        }
136    }
137
138    fn check_tooling_action(&self, path: &Path) -> ProjectAction {
139        // Check if Cargo.lock exists; if not, suggest cargo fetch
140        if !path.join("Cargo.lock").exists() && path.join("Cargo.toml").exists() {
141            ProjectAction::ExecCommand {
142                command: "cargo fetch".to_string(),
143                description: "Fetch Rust dependencies".to_string(),
144            }
145        } else {
146            ProjectAction::NoAction
147        }
148    }
149
150    fn init_command(&self, opts: &InitOptions) -> String {
151        if opts.name == "." || opts.name == "./" {
152            "cargo init .".to_string()
153        } else {
154            format!("cargo new {}", opts.name)
155        }
156    }
157
158    fn test_command(&self) -> String {
159        "cargo test".to_string()
160    }
161
162    fn run_command(&self) -> String {
163        "cargo run".to_string()
164    }
165}
166
167/// Python language plugin (uses ty via uvx)
168pub struct PythonPlugin;
169
170impl LanguagePlugin for PythonPlugin {
171    fn name(&self) -> &str {
172        "python"
173    }
174
175    fn extensions(&self) -> &[&str] {
176        &["py"]
177    }
178
179    fn key_files(&self) -> &[&str] {
180        &["pyproject.toml", "setup.py", "requirements.txt", "uv.lock"]
181    }
182
183    fn get_lsp_config(&self) -> LspConfig {
184        // Prefer ty (via uvx) as the native Python support
185        // Falls back to pyright if ty is not available
186        LspConfig {
187            server_binary: "uvx".to_string(),
188            args: vec!["ty".to_string(), "server".to_string()],
189            language_id: "python".to_string(),
190        }
191    }
192
193    fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
194        let command = match opts.package_manager.as_deref() {
195            Some("poetry") => {
196                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
197                    "poetry init --no-interaction".to_string()
198                } else {
199                    format!("poetry new {}", opts.name)
200                }
201            }
202            Some("pdm") => {
203                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
204                    "pdm init --non-interactive".to_string()
205                } else {
206                    format!(
207                        "mkdir -p {} && cd {} && pdm init --non-interactive",
208                        opts.name, opts.name
209                    )
210                }
211            }
212            _ => {
213                // Default to uv
214                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
215                    "uv init".to_string()
216                } else {
217                    format!("uv init {}", opts.name)
218                }
219            }
220        };
221        let description = match opts.package_manager.as_deref() {
222            Some("poetry") => "Initialize Python project with Poetry",
223            Some("pdm") => "Initialize Python project with PDM",
224            _ => "Initialize Python project with uv",
225        };
226        ProjectAction::ExecCommand {
227            command,
228            description: description.to_string(),
229        }
230    }
231
232    fn check_tooling_action(&self, path: &Path) -> ProjectAction {
233        // Check for pyproject.toml but missing .venv or uv.lock
234        let has_pyproject = path.join("pyproject.toml").exists();
235        let has_venv = path.join(".venv").exists();
236        let has_uv_lock = path.join("uv.lock").exists();
237
238        if has_pyproject && (!has_venv || !has_uv_lock) {
239            ProjectAction::ExecCommand {
240                command: "uv sync".to_string(),
241                description: "Sync Python dependencies with uv".to_string(),
242            }
243        } else {
244            ProjectAction::NoAction
245        }
246    }
247
248    fn init_command(&self, opts: &InitOptions) -> String {
249        if opts.package_manager.as_deref() == Some("poetry") {
250            if opts.name == "." || opts.name == "./" {
251                "poetry init".to_string()
252            } else {
253                format!("poetry new {}", opts.name)
254            }
255        } else {
256            // uv init supports "." for current directory
257            format!("uv init {}", opts.name)
258        }
259    }
260
261    fn test_command(&self) -> String {
262        "uv run pytest".to_string()
263    }
264
265    fn run_command(&self) -> String {
266        "uv run python -m main".to_string()
267    }
268}
269
270/// JavaScript/TypeScript language plugin
271pub struct JsPlugin;
272
273impl LanguagePlugin for JsPlugin {
274    fn name(&self) -> &str {
275        "javascript"
276    }
277
278    fn extensions(&self) -> &[&str] {
279        &["js", "ts", "jsx", "tsx"]
280    }
281
282    fn key_files(&self) -> &[&str] {
283        &["package.json", "tsconfig.json"]
284    }
285
286    fn get_lsp_config(&self) -> LspConfig {
287        LspConfig {
288            server_binary: "typescript-language-server".to_string(),
289            args: vec!["--stdio".to_string()],
290            language_id: "typescript".to_string(),
291        }
292    }
293
294    fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
295        let command = match opts.package_manager.as_deref() {
296            Some("pnpm") => {
297                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
298                    "pnpm init".to_string()
299                } else {
300                    format!("mkdir -p {} && cd {} && pnpm init", opts.name, opts.name)
301                }
302            }
303            Some("yarn") => {
304                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
305                    "yarn init -y".to_string()
306                } else {
307                    format!("mkdir -p {} && cd {} && yarn init -y", opts.name, opts.name)
308                }
309            }
310            _ => {
311                // Default to npm
312                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
313                    "npm init -y".to_string()
314                } else {
315                    format!("mkdir -p {} && cd {} && npm init -y", opts.name, opts.name)
316                }
317            }
318        };
319        let description = match opts.package_manager.as_deref() {
320            Some("pnpm") => "Initialize JavaScript project with pnpm",
321            Some("yarn") => "Initialize JavaScript project with Yarn",
322            _ => "Initialize JavaScript project with npm",
323        };
324        ProjectAction::ExecCommand {
325            command,
326            description: description.to_string(),
327        }
328    }
329
330    fn check_tooling_action(&self, path: &Path) -> ProjectAction {
331        // Check for package.json but missing node_modules
332        let has_package_json = path.join("package.json").exists();
333        let has_node_modules = path.join("node_modules").exists();
334
335        if has_package_json && !has_node_modules {
336            ProjectAction::ExecCommand {
337                command: "npm install".to_string(),
338                description: "Install Node.js dependencies".to_string(),
339            }
340        } else {
341            ProjectAction::NoAction
342        }
343    }
344
345    fn init_command(&self, opts: &InitOptions) -> String {
346        format!("npm init -y && mv package.json {}/", opts.name)
347    }
348
349    fn test_command(&self) -> String {
350        "npm test".to_string()
351    }
352
353    fn run_command(&self) -> String {
354        "npm start".to_string()
355    }
356}
357
358/// Plugin registry for dynamic language detection
359pub struct PluginRegistry {
360    plugins: Vec<Box<dyn LanguagePlugin>>,
361}
362
363impl PluginRegistry {
364    /// Create a new registry with all built-in plugins
365    pub fn new() -> Self {
366        Self {
367            plugins: vec![
368                Box::new(RustPlugin),
369                Box::new(PythonPlugin),
370                Box::new(JsPlugin),
371            ],
372        }
373    }
374
375    /// Detect which plugin should handle the given path
376    pub fn detect(&self, path: &Path) -> Option<&dyn LanguagePlugin> {
377        self.plugins
378            .iter()
379            .find(|p| p.detect(path))
380            .map(|p| p.as_ref())
381    }
382
383    /// Get a plugin by name
384    pub fn get(&self, name: &str) -> Option<&dyn LanguagePlugin> {
385        self.plugins
386            .iter()
387            .find(|p| p.name() == name)
388            .map(|p| p.as_ref())
389    }
390
391    /// Get all registered plugins
392    pub fn all(&self) -> &[Box<dyn LanguagePlugin>] {
393        &self.plugins
394    }
395}
396
397impl Default for PluginRegistry {
398    fn default() -> Self {
399        Self::new()
400    }
401}