Skip to main content

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//!
6//! PSP-000005 expands plugins from init-only to full runtime verification contracts.
7
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11/// LSP Configuration for a language
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct LspConfig {
14    /// LSP server binary name
15    pub server_binary: String,
16    /// Arguments to pass to the server
17    pub args: Vec<String>,
18    /// Language ID for textDocument/didOpen
19    pub language_id: String,
20}
21
22// =============================================================================
23// PSP-5 Phase 4: Verifier Capability Declarations
24// =============================================================================
25
26/// Verification stage in the plugin-driven pipeline.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub enum VerifierStage {
29    /// Syntax / type check (e.g. `cargo check`, `uv run ty check .`)
30    SyntaxCheck,
31    /// Build step (e.g. `cargo build`, `npm run build`)
32    Build,
33    /// Test execution (e.g. `cargo test`, `uv run pytest`)
34    Test,
35    /// Lint pass (e.g. `cargo clippy`, `uv run ruff check .`)
36    Lint,
37}
38
39impl std::fmt::Display for VerifierStage {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            VerifierStage::SyntaxCheck => write!(f, "syntax_check"),
43            VerifierStage::Build => write!(f, "build"),
44            VerifierStage::Test => write!(f, "test"),
45            VerifierStage::Lint => write!(f, "lint"),
46        }
47    }
48}
49
50/// A single verifier sensor: one stage of the verification pipeline.
51///
52/// Each capability independently declares its command, host-tool availability,
53/// and optional fallback. This replaces the coarse single `host_tool_available()`
54/// check with per-sensor probing.
55#[derive(Debug, Clone)]
56pub struct VerifierCapability {
57    /// Which stage this capability covers.
58    pub stage: VerifierStage,
59    /// Primary command to execute (None if this stage is not supported).
60    pub command: Option<String>,
61    /// Whether the primary command's host tool is available on this machine.
62    pub available: bool,
63    /// Fallback command when the primary tool is unavailable.
64    pub fallback_command: Option<String>,
65    /// Whether the fallback tool is available.
66    pub fallback_available: bool,
67}
68
69impl VerifierCapability {
70    /// True if either the primary or fallback tool is available.
71    pub fn any_available(&self) -> bool {
72        self.available || self.fallback_available
73    }
74
75    /// The best available command, preferring primary over fallback.
76    pub fn effective_command(&self) -> Option<&str> {
77        if self.available {
78            self.command.as_deref()
79        } else if self.fallback_available {
80            self.fallback_command.as_deref()
81        } else {
82            None
83        }
84    }
85}
86
87/// LSP availability and fallback for a plugin.
88#[derive(Debug, Clone)]
89pub struct LspCapability {
90    /// Primary LSP configuration.
91    pub primary: LspConfig,
92    /// Whether the primary LSP binary is available on the host.
93    pub primary_available: bool,
94    /// Fallback LSP configuration (if any).
95    pub fallback: Option<LspConfig>,
96    /// Whether the fallback binary is available.
97    pub fallback_available: bool,
98}
99
100impl LspCapability {
101    /// Return the best available LSP config, preferring primary.
102    pub fn effective_config(&self) -> Option<&LspConfig> {
103        if self.primary_available {
104            Some(&self.primary)
105        } else if self.fallback_available {
106            self.fallback.as_ref()
107        } else {
108            None
109        }
110    }
111}
112
113/// Complete verifier profile for a plugin.
114///
115/// Bundles all per-sensor capabilities and LSP availability into one
116/// inspectable structure. Built by `LanguagePlugin::verifier_profile()`.
117#[derive(Debug, Clone)]
118pub struct VerifierProfile {
119    /// Name of the plugin that produced this profile.
120    pub plugin_name: String,
121    /// Per-stage verifier capabilities.
122    pub capabilities: Vec<VerifierCapability>,
123    /// LSP availability and fallback.
124    pub lsp: LspCapability,
125}
126
127impl VerifierProfile {
128    /// Get the capability for a given stage, if declared.
129    pub fn get(&self, stage: VerifierStage) -> Option<&VerifierCapability> {
130        self.capabilities.iter().find(|c| c.stage == stage)
131    }
132
133    /// Stages that have at least one available tool (primary or fallback).
134    pub fn available_stages(&self) -> Vec<VerifierStage> {
135        self.capabilities
136            .iter()
137            .filter(|c| c.any_available())
138            .map(|c| c.stage)
139            .collect()
140    }
141
142    /// True when every declared stage has zero available tools.
143    pub fn fully_degraded(&self) -> bool {
144        self.capabilities.iter().all(|c| !c.any_available())
145    }
146}
147
148// =============================================================================
149// Utility: host binary probe
150// =============================================================================
151
152/// Check whether a given binary name is available on the host PATH.
153///
154/// Runs `<binary> --version` silently; returns `true` if the process exits
155/// successfully. Used by plugins for per-sensor host-tool probing.
156pub fn host_binary_available(binary: &str) -> bool {
157    std::process::Command::new(binary)
158        .arg("--version")
159        .stdout(std::process::Stdio::null())
160        .stderr(std::process::Stdio::null())
161        .status()
162        .map(|s| s.success())
163        .unwrap_or(false)
164}
165
166/// Options for project initialization
167#[derive(Debug, Clone, Default)]
168pub struct InitOptions {
169    /// Project name
170    pub name: String,
171    /// Whether to use a specific package manager (e.g., "poetry", "pdm", "npm", "pnpm")
172    pub package_manager: Option<String>,
173    /// Additional flags
174    pub flags: Vec<String>,
175    /// Whether the target directory is empty
176    pub is_empty_dir: bool,
177}
178
179/// Action to take for project initialization or tooling sync
180#[derive(Debug, Clone)]
181pub enum ProjectAction {
182    /// Execute a shell command
183    ExecCommand {
184        /// The command to run
185        command: String,
186        /// Human-readable description of what this command does
187        description: String,
188    },
189    /// No action needed
190    NoAction,
191}
192
193/// A plugin for a specific programming language
194///
195/// PSP-5 expands this trait beyond init/test/run to a full capability-based
196/// runtime contract that governs detection, verification, LSP, and ownership.
197pub trait LanguagePlugin: Send + Sync {
198    /// Name of the language
199    fn name(&self) -> &str;
200
201    /// File extensions this plugin handles
202    fn extensions(&self) -> &[&str];
203
204    /// Key files that identify this language (e.g., Cargo.toml, pyproject.toml)
205    fn key_files(&self) -> &[&str];
206
207    /// Detect if this plugin should handle the given project directory
208    fn detect(&self, path: &Path) -> bool {
209        // Check for key files
210        for key_file in self.key_files() {
211            if path.join(key_file).exists() {
212                return true;
213            }
214        }
215
216        // Check for files with handled extensions
217        if let Ok(entries) = std::fs::read_dir(path) {
218            for entry in entries.flatten() {
219                if let Some(ext) = entry.path().extension() {
220                    let ext_str = ext.to_string_lossy();
221                    if self.extensions().iter().any(|e| *e == ext_str) {
222                        return true;
223                    }
224                }
225            }
226        }
227
228        false
229    }
230
231    /// Get the LSP configuration for this language
232    fn get_lsp_config(&self) -> LspConfig;
233
234    /// Get the action to initialize a new project (greenfield)
235    fn get_init_action(&self, opts: &InitOptions) -> ProjectAction;
236
237    /// Check if an existing project needs tooling sync (e.g., uv sync, cargo fetch)
238    fn check_tooling_action(&self, path: &Path) -> ProjectAction;
239
240    /// Get the command to initialize a new project
241    /// DEPRECATED: Use get_init_action instead
242    fn init_command(&self, opts: &InitOptions) -> String;
243
244    /// Get the command to run tests
245    fn test_command(&self) -> String;
246
247    /// Get the command to run the project (for verification)
248    fn run_command(&self) -> String;
249
250    /// Get the command to run the project in a specific directory.
251    ///
252    /// Override this to inspect pyproject.toml, Cargo.toml, etc. and return a
253    /// more appropriate run command than the generic default.
254    fn run_command_for_dir(&self, _path: &Path) -> String {
255        self.run_command()
256    }
257
258    // =========================================================================
259    // PSP-5: Capability-Based Runtime Contract
260    // =========================================================================
261
262    /// Get the syntax/type check command (e.g., `cargo check`, `uv run ty check .`)
263    ///
264    /// Returns None if the plugin has no syntax check command (uses LSP only).
265    fn syntax_check_command(&self) -> Option<String> {
266        None
267    }
268
269    /// Get the build command (e.g., `cargo build`, `npm run build`)
270    ///
271    /// Returns None if the language doesn't have a separate build step.
272    fn build_command(&self) -> Option<String> {
273        None
274    }
275
276    /// Get the lint command (e.g., `cargo clippy -- -D warnings`)
277    ///
278    /// Used only in VerifierStrictness::Strict mode.
279    fn lint_command(&self) -> Option<String> {
280        None
281    }
282
283    /// File glob patterns this plugin owns (e.g., `["*.rs", "Cargo.toml"]`)
284    ///
285    /// Used for node ownership matching in multi-language repos.
286    fn file_ownership_patterns(&self) -> &[&str] {
287        self.extensions()
288    }
289
290    /// PSP-5 Phase 2: Check if a file path belongs to this plugin's ownership domain
291    ///
292    /// Uses `file_ownership_patterns()` for suffix/extension matching.
293    fn owns_file(&self, path: &str) -> bool {
294        let path_lower = path.to_lowercase();
295        self.file_ownership_patterns().iter().any(|pattern| {
296            let pattern = pattern.trim_start_matches('*');
297            path_lower.ends_with(pattern)
298        })
299    }
300
301    /// Check if the host has the required build tools available
302    ///
303    /// Returns true if the plugin's primary toolchain is installed and callable.
304    /// When false, the runtime enters degraded-validation mode.
305    fn host_tool_available(&self) -> bool {
306        true
307    }
308
309    /// Required host binaries for this plugin, grouped by role.
310    ///
311    /// Each entry is `(binary_name, role_description, install_hint)`.
312    /// The orchestrator checks these before init and emits install directions
313    /// for any that are missing.
314    fn required_binaries(&self) -> Vec<(&str, &str, &str)> {
315        Vec::new()
316    }
317
318    /// Get fallback LSP config when primary is unavailable
319    fn lsp_fallback(&self) -> Option<LspConfig> {
320        None
321    }
322
323    // =========================================================================
324    // PSP-5 Phase 4: Verifier Profile Assembly
325    // =========================================================================
326
327    /// Build a complete verifier profile by probing each capability.
328    ///
329    /// The default implementation auto-assembles from the existing
330    /// `syntax_check_command()`, `build_command()`, `test_command()`,
331    /// `lint_command()`, and `host_tool_available()` methods.
332    ///
333    /// Plugins override this method to provide per-sensor probing
334    /// with distinct fallback commands and independent availability checks.
335    fn verifier_profile(&self) -> VerifierProfile {
336        let tool_available = self.host_tool_available();
337
338        let mut capabilities = Vec::new();
339
340        if let Some(cmd) = self.syntax_check_command() {
341            capabilities.push(VerifierCapability {
342                stage: VerifierStage::SyntaxCheck,
343                command: Some(cmd),
344                available: tool_available,
345                fallback_command: None,
346                fallback_available: false,
347            });
348        }
349
350        if let Some(cmd) = self.build_command() {
351            capabilities.push(VerifierCapability {
352                stage: VerifierStage::Build,
353                command: Some(cmd),
354                available: tool_available,
355                fallback_command: None,
356                fallback_available: false,
357            });
358        }
359
360        // Test always has a command (test_command is required)
361        capabilities.push(VerifierCapability {
362            stage: VerifierStage::Test,
363            command: Some(self.test_command()),
364            available: tool_available,
365            fallback_command: None,
366            fallback_available: false,
367        });
368
369        if let Some(cmd) = self.lint_command() {
370            capabilities.push(VerifierCapability {
371                stage: VerifierStage::Lint,
372                command: Some(cmd),
373                available: tool_available,
374                fallback_command: None,
375                fallback_available: false,
376            });
377        }
378
379        let primary_config = self.get_lsp_config();
380        let primary_available = host_binary_available(&primary_config.server_binary);
381        let fallback = self.lsp_fallback();
382        let fallback_available = fallback
383            .as_ref()
384            .map(|f| host_binary_available(&f.server_binary))
385            .unwrap_or(false);
386
387        VerifierProfile {
388            plugin_name: self.name().to_string(),
389            capabilities,
390            lsp: LspCapability {
391                primary: primary_config,
392                primary_available,
393                fallback,
394                fallback_available,
395            },
396        }
397    }
398}
399
400/// Rust language plugin
401pub struct RustPlugin;
402
403impl LanguagePlugin for RustPlugin {
404    fn name(&self) -> &str {
405        "rust"
406    }
407
408    fn extensions(&self) -> &[&str] {
409        &["rs"]
410    }
411
412    fn key_files(&self) -> &[&str] {
413        &["Cargo.toml", "Cargo.lock"]
414    }
415
416    fn required_binaries(&self) -> Vec<(&str, &str, &str)> {
417        vec![
418            ("cargo", "build/init", "Install Rust via https://rustup.rs"),
419            ("rustc", "compiler", "Install Rust via https://rustup.rs"),
420            (
421                "rust-analyzer",
422                "language server",
423                "rustup component add rust-analyzer",
424            ),
425        ]
426    }
427
428    fn get_lsp_config(&self) -> LspConfig {
429        LspConfig {
430            server_binary: "rust-analyzer".to_string(),
431            args: vec![],
432            language_id: "rust".to_string(),
433        }
434    }
435
436    fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
437        let command = if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
438            "cargo init .".to_string()
439        } else {
440            format!("cargo new {}", opts.name)
441        };
442        ProjectAction::ExecCommand {
443            command,
444            description: "Initialize Rust project with Cargo".to_string(),
445        }
446    }
447
448    fn check_tooling_action(&self, path: &Path) -> ProjectAction {
449        // Check if Cargo.lock exists; if not, suggest cargo fetch
450        if !path.join("Cargo.lock").exists() && path.join("Cargo.toml").exists() {
451            ProjectAction::ExecCommand {
452                command: "cargo fetch".to_string(),
453                description: "Fetch Rust dependencies".to_string(),
454            }
455        } else {
456            ProjectAction::NoAction
457        }
458    }
459
460    fn init_command(&self, opts: &InitOptions) -> String {
461        if opts.name == "." || opts.name == "./" {
462            "cargo init .".to_string()
463        } else {
464            format!("cargo new {}", opts.name)
465        }
466    }
467
468    fn test_command(&self) -> String {
469        "cargo test".to_string()
470    }
471
472    fn run_command(&self) -> String {
473        "cargo run".to_string()
474    }
475
476    // PSP-5 capability methods
477
478    fn syntax_check_command(&self) -> Option<String> {
479        Some("cargo check".to_string())
480    }
481
482    fn build_command(&self) -> Option<String> {
483        Some("cargo build".to_string())
484    }
485
486    fn lint_command(&self) -> Option<String> {
487        Some("cargo clippy -- -D warnings".to_string())
488    }
489
490    fn file_ownership_patterns(&self) -> &[&str] {
491        &["rs"]
492    }
493
494    fn host_tool_available(&self) -> bool {
495        host_binary_available("cargo")
496    }
497
498    fn verifier_profile(&self) -> VerifierProfile {
499        let cargo = host_binary_available("cargo");
500        let clippy = cargo; // clippy is a cargo subcommand, same binary
501
502        let capabilities = vec![
503            VerifierCapability {
504                stage: VerifierStage::SyntaxCheck,
505                command: Some("cargo check".to_string()),
506                available: cargo,
507                fallback_command: None,
508                fallback_available: false,
509            },
510            VerifierCapability {
511                stage: VerifierStage::Build,
512                command: Some("cargo build".to_string()),
513                available: cargo,
514                fallback_command: None,
515                fallback_available: false,
516            },
517            VerifierCapability {
518                stage: VerifierStage::Test,
519                command: Some("cargo test".to_string()),
520                available: cargo,
521                fallback_command: None,
522                fallback_available: false,
523            },
524            VerifierCapability {
525                stage: VerifierStage::Lint,
526                command: Some("cargo clippy -- -D warnings".to_string()),
527                available: clippy,
528                fallback_command: None,
529                fallback_available: false,
530            },
531        ];
532
533        let primary = self.get_lsp_config();
534        let primary_available = host_binary_available(&primary.server_binary);
535
536        VerifierProfile {
537            plugin_name: self.name().to_string(),
538            capabilities,
539            lsp: LspCapability {
540                primary,
541                primary_available,
542                fallback: None,
543                fallback_available: false,
544            },
545        }
546    }
547}
548
549/// Python language plugin (uses ty via uvx)
550pub struct PythonPlugin;
551
552impl LanguagePlugin for PythonPlugin {
553    fn name(&self) -> &str {
554        "python"
555    }
556
557    fn extensions(&self) -> &[&str] {
558        &["py"]
559    }
560
561    fn key_files(&self) -> &[&str] {
562        &["pyproject.toml", "setup.py", "requirements.txt", "uv.lock"]
563    }
564
565    fn required_binaries(&self) -> Vec<(&str, &str, &str)> {
566        vec![
567            (
568                "uv",
569                "package manager",
570                "curl -LsSf https://astral.sh/uv/install.sh | sh",
571            ),
572            (
573                "python3",
574                "interpreter",
575                "uv python install (or install from https://python.org)",
576            ),
577            (
578                "uvx",
579                "tool runner/LSP",
580                "Installed with uv — curl -LsSf https://astral.sh/uv/install.sh | sh",
581            ),
582        ]
583    }
584
585    fn get_lsp_config(&self) -> LspConfig {
586        // Prefer ty (via uvx) as the native Python support
587        // Falls back to pyright if ty is not available
588        LspConfig {
589            server_binary: "uvx".to_string(),
590            args: vec!["ty".to_string(), "server".to_string()],
591            language_id: "python".to_string(),
592        }
593    }
594
595    fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
596        let command = match opts.package_manager.as_deref() {
597            Some("poetry") => {
598                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
599                    "poetry init --no-interaction".to_string()
600                } else {
601                    format!("poetry new {}", opts.name)
602                }
603            }
604            Some("pdm") => {
605                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
606                    "pdm init --non-interactive".to_string()
607                } else {
608                    format!(
609                        "mkdir -p {} && cd {} && pdm init --non-interactive",
610                        opts.name, opts.name
611                    )
612                }
613            }
614            _ => {
615                // Default to uv --lib for src-layout with build-system
616                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
617                    "uv init --lib".to_string()
618                } else {
619                    format!("uv init --lib {}", opts.name)
620                }
621            }
622        };
623        let description = match opts.package_manager.as_deref() {
624            Some("poetry") => "Initialize Python project with Poetry",
625            Some("pdm") => "Initialize Python project with PDM",
626            _ => "Initialize Python project with uv",
627        };
628        ProjectAction::ExecCommand {
629            command,
630            description: description.to_string(),
631        }
632    }
633
634    fn check_tooling_action(&self, path: &Path) -> ProjectAction {
635        // Check for pyproject.toml but missing .venv or uv.lock
636        let has_pyproject = path.join("pyproject.toml").exists();
637        let has_venv = path.join(".venv").exists();
638        let has_uv_lock = path.join("uv.lock").exists();
639
640        if has_pyproject && (!has_venv || !has_uv_lock) {
641            ProjectAction::ExecCommand {
642                command: "uv sync".to_string(),
643                description: "Sync Python dependencies with uv".to_string(),
644            }
645        } else {
646            ProjectAction::NoAction
647        }
648    }
649
650    fn init_command(&self, opts: &InitOptions) -> String {
651        if opts.package_manager.as_deref() == Some("poetry") {
652            if opts.name == "." || opts.name == "./" {
653                "poetry init".to_string()
654            } else {
655                format!("poetry new {}", opts.name)
656            }
657        } else {
658            // uv init --lib for src-layout with build-system
659            format!("uv init --lib {}", opts.name)
660        }
661    }
662
663    fn test_command(&self) -> String {
664        "uv run pytest".to_string()
665    }
666
667    fn run_command(&self) -> String {
668        "uv run python -m main".to_string()
669    }
670
671    /// Detect the package name from pyproject.toml or src layout and return
672    /// an appropriate run command.
673    fn run_command_for_dir(&self, path: &Path) -> String {
674        // Check src/<pkg>/__main__.py first
675        if let Ok(entries) = std::fs::read_dir(path.join("src")) {
676            for entry in entries.flatten() {
677                if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
678                    let name = entry.file_name().to_string_lossy().to_string();
679                    if !name.starts_with('.') && !name.starts_with('_') {
680                        return format!("uv run python -m {}", name);
681                    }
682                }
683            }
684        }
685
686        // Check for [project.scripts] in pyproject.toml
687        if let Ok(content) = std::fs::read_to_string(path.join("pyproject.toml")) {
688            if content.contains("[project.scripts]") {
689                // Parse the first script name
690                let mut in_scripts = false;
691                for raw_line in content.lines() {
692                    let line = raw_line.trim();
693                    if line == "[project.scripts]" {
694                        in_scripts = true;
695                        continue;
696                    }
697                    if in_scripts {
698                        if line.starts_with('[') {
699                            break;
700                        }
701                        if let Some((name, _)) = line.split_once('=') {
702                            let script = name.trim().trim_matches('"');
703                            if !script.is_empty() {
704                                return format!("uv run {}", script);
705                            }
706                        }
707                    }
708                }
709            }
710        }
711
712        // Default: run main module
713        "uv run python -m main".to_string()
714    }
715
716    // PSP-5 capability methods
717
718    fn syntax_check_command(&self) -> Option<String> {
719        Some("uv run ty check .".to_string())
720    }
721
722    fn lint_command(&self) -> Option<String> {
723        Some("uv run ruff check .".to_string())
724    }
725
726    fn file_ownership_patterns(&self) -> &[&str] {
727        &["py"]
728    }
729
730    fn host_tool_available(&self) -> bool {
731        host_binary_available("uv")
732    }
733
734    fn lsp_fallback(&self) -> Option<LspConfig> {
735        Some(LspConfig {
736            server_binary: "pyright-langserver".to_string(),
737            args: vec!["--stdio".to_string()],
738            language_id: "python".to_string(),
739        })
740    }
741
742    fn verifier_profile(&self) -> VerifierProfile {
743        let uv = host_binary_available("uv");
744        let pyright = host_binary_available("pyright");
745
746        let capabilities = vec![
747            VerifierCapability {
748                stage: VerifierStage::SyntaxCheck,
749                command: Some("uv run ty check .".to_string()),
750                available: uv,
751                // pyright as CLI fallback for syntax checking
752                fallback_command: Some("pyright .".to_string()),
753                fallback_available: pyright,
754            },
755            VerifierCapability {
756                stage: VerifierStage::Test,
757                command: Some("uv run pytest".to_string()),
758                available: uv,
759                // bare pytest fallback
760                fallback_command: Some("python -m pytest".to_string()),
761                fallback_available: host_binary_available("python3")
762                    || host_binary_available("python"),
763            },
764            VerifierCapability {
765                stage: VerifierStage::Lint,
766                command: Some("uv run ruff check .".to_string()),
767                available: uv,
768                fallback_command: Some("ruff check .".to_string()),
769                fallback_available: host_binary_available("ruff"),
770            },
771        ];
772
773        let primary = self.get_lsp_config();
774        let primary_available = host_binary_available("uvx");
775        let fallback = self.lsp_fallback();
776        let fallback_available = fallback
777            .as_ref()
778            .map(|f| host_binary_available(&f.server_binary))
779            .unwrap_or(false);
780
781        VerifierProfile {
782            plugin_name: self.name().to_string(),
783            capabilities,
784            lsp: LspCapability {
785                primary,
786                primary_available,
787                fallback,
788                fallback_available,
789            },
790        }
791    }
792}
793
794/// JavaScript/TypeScript language plugin
795pub struct JsPlugin;
796
797impl LanguagePlugin for JsPlugin {
798    fn name(&self) -> &str {
799        "javascript"
800    }
801
802    fn extensions(&self) -> &[&str] {
803        &["js", "ts", "jsx", "tsx"]
804    }
805
806    fn key_files(&self) -> &[&str] {
807        &["package.json", "tsconfig.json"]
808    }
809
810    fn required_binaries(&self) -> Vec<(&str, &str, &str)> {
811        vec![
812            (
813                "node",
814                "runtime",
815                "Install Node.js from https://nodejs.org or via nvm",
816            ),
817            (
818                "npm",
819                "package manager",
820                "Included with Node.js — install from https://nodejs.org",
821            ),
822            (
823                "typescript-language-server",
824                "language server",
825                "npm install -g typescript-language-server typescript",
826            ),
827        ]
828    }
829
830    fn get_lsp_config(&self) -> LspConfig {
831        LspConfig {
832            server_binary: "typescript-language-server".to_string(),
833            args: vec!["--stdio".to_string()],
834            language_id: "typescript".to_string(),
835        }
836    }
837
838    fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
839        let command = match opts.package_manager.as_deref() {
840            Some("pnpm") => {
841                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
842                    "pnpm init".to_string()
843                } else {
844                    format!("mkdir -p {} && cd {} && pnpm init", opts.name, opts.name)
845                }
846            }
847            Some("yarn") => {
848                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
849                    "yarn init -y".to_string()
850                } else {
851                    format!("mkdir -p {} && cd {} && yarn init -y", opts.name, opts.name)
852                }
853            }
854            _ => {
855                // Default to npm
856                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
857                    "npm init -y".to_string()
858                } else {
859                    format!("mkdir -p {} && cd {} && npm init -y", opts.name, opts.name)
860                }
861            }
862        };
863        let description = match opts.package_manager.as_deref() {
864            Some("pnpm") => "Initialize JavaScript project with pnpm",
865            Some("yarn") => "Initialize JavaScript project with Yarn",
866            _ => "Initialize JavaScript project with npm",
867        };
868        ProjectAction::ExecCommand {
869            command,
870            description: description.to_string(),
871        }
872    }
873
874    fn check_tooling_action(&self, path: &Path) -> ProjectAction {
875        // Check for package.json but missing node_modules
876        let has_package_json = path.join("package.json").exists();
877        let has_node_modules = path.join("node_modules").exists();
878
879        if has_package_json && !has_node_modules {
880            ProjectAction::ExecCommand {
881                command: "npm install".to_string(),
882                description: "Install Node.js dependencies".to_string(),
883            }
884        } else {
885            ProjectAction::NoAction
886        }
887    }
888
889    fn init_command(&self, opts: &InitOptions) -> String {
890        format!("npm init -y && mv package.json {}/", opts.name)
891    }
892
893    fn test_command(&self) -> String {
894        "npm test".to_string()
895    }
896
897    fn run_command(&self) -> String {
898        "npm start".to_string()
899    }
900
901    // PSP-5 capability methods
902
903    fn syntax_check_command(&self) -> Option<String> {
904        Some("npx tsc --noEmit".to_string())
905    }
906
907    fn build_command(&self) -> Option<String> {
908        Some("npm run build".to_string())
909    }
910
911    fn lint_command(&self) -> Option<String> {
912        Some("npx eslint .".to_string())
913    }
914
915    fn file_ownership_patterns(&self) -> &[&str] {
916        &["js", "ts", "jsx", "tsx"]
917    }
918
919    fn host_tool_available(&self) -> bool {
920        host_binary_available("node")
921    }
922
923    fn verifier_profile(&self) -> VerifierProfile {
924        let node = host_binary_available("node");
925        let npx = host_binary_available("npx");
926
927        let capabilities = vec![
928            VerifierCapability {
929                stage: VerifierStage::SyntaxCheck,
930                command: Some("npx tsc --noEmit".to_string()),
931                available: npx,
932                fallback_command: None,
933                fallback_available: false,
934            },
935            VerifierCapability {
936                stage: VerifierStage::Build,
937                command: Some("npm run build".to_string()),
938                available: node,
939                fallback_command: None,
940                fallback_available: false,
941            },
942            VerifierCapability {
943                stage: VerifierStage::Test,
944                command: Some("npm test".to_string()),
945                available: node,
946                fallback_command: None,
947                fallback_available: false,
948            },
949            VerifierCapability {
950                stage: VerifierStage::Lint,
951                command: Some("npx eslint .".to_string()),
952                available: npx,
953                fallback_command: None,
954                fallback_available: false,
955            },
956        ];
957
958        let primary = self.get_lsp_config();
959        let primary_available = host_binary_available(&primary.server_binary);
960
961        VerifierProfile {
962            plugin_name: self.name().to_string(),
963            capabilities,
964            lsp: LspCapability {
965                primary,
966                primary_available,
967                fallback: None,
968                fallback_available: false,
969            },
970        }
971    }
972}
973
974/// Plugin registry for dynamic language detection
975pub struct PluginRegistry {
976    plugins: Vec<Box<dyn LanguagePlugin>>,
977}
978
979impl PluginRegistry {
980    /// Create a new registry with all built-in plugins
981    pub fn new() -> Self {
982        Self {
983            plugins: vec![
984                Box::new(RustPlugin),
985                Box::new(PythonPlugin),
986                Box::new(JsPlugin),
987            ],
988        }
989    }
990
991    /// Detect which plugin should handle the given path (first match)
992    pub fn detect(&self, path: &Path) -> Option<&dyn LanguagePlugin> {
993        self.plugins
994            .iter()
995            .find(|p| p.detect(path))
996            .map(|p| p.as_ref())
997    }
998
999    /// PSP-5: Detect ALL plugins that match the given path (polyglot support)
1000    ///
1001    /// Returns all matching plugins instead of just the first, enabling
1002    /// multi-language verification in polyglot repositories.
1003    pub fn detect_all(&self, path: &Path) -> Vec<&dyn LanguagePlugin> {
1004        self.plugins
1005            .iter()
1006            .filter(|p| p.detect(path))
1007            .map(|p| p.as_ref())
1008            .collect()
1009    }
1010
1011    /// Get a plugin by name
1012    pub fn get(&self, name: &str) -> Option<&dyn LanguagePlugin> {
1013        self.plugins
1014            .iter()
1015            .find(|p| p.name() == name)
1016            .map(|p| p.as_ref())
1017    }
1018
1019    /// Get all registered plugins
1020    pub fn all(&self) -> &[Box<dyn LanguagePlugin>] {
1021        &self.plugins
1022    }
1023}
1024
1025impl Default for PluginRegistry {
1026    fn default() -> Self {
1027        Self::new()
1028    }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::*;
1034
1035    #[test]
1036    fn test_plugin_owns_file() {
1037        let rust = RustPlugin;
1038        assert!(rust.owns_file("src/main.rs"));
1039        assert!(rust.owns_file("crates/core/src/lib.rs"));
1040        assert!(!rust.owns_file("main.py"));
1041        assert!(!rust.owns_file("index.js"));
1042
1043        let python = PythonPlugin;
1044        assert!(python.owns_file("main.py"));
1045        assert!(python.owns_file("tests/test_main.py"));
1046        assert!(!python.owns_file("src/main.rs"));
1047
1048        let js = JsPlugin;
1049        assert!(js.owns_file("index.js"));
1050        assert!(js.owns_file("src/app.ts"));
1051        assert!(!js.owns_file("main.py"));
1052        assert!(!js.owns_file("src/main.rs"));
1053    }
1054
1055    // =========================================================================
1056    // Verifier Capability & Profile Tests
1057    // =========================================================================
1058
1059    #[test]
1060    fn test_verifier_capability_effective_command() {
1061        // Primary available → primary wins
1062        let cap = VerifierCapability {
1063            stage: VerifierStage::SyntaxCheck,
1064            command: Some("cargo check".to_string()),
1065            available: true,
1066            fallback_command: Some("rustc --edition 2021".to_string()),
1067            fallback_available: true,
1068        };
1069        assert_eq!(cap.effective_command(), Some("cargo check"));
1070        assert!(cap.any_available());
1071
1072        // Primary unavailable, fallback available → fallback wins
1073        let cap2 = VerifierCapability {
1074            stage: VerifierStage::Lint,
1075            command: Some("uv run ruff check .".to_string()),
1076            available: false,
1077            fallback_command: Some("ruff check .".to_string()),
1078            fallback_available: true,
1079        };
1080        assert_eq!(cap2.effective_command(), Some("ruff check ."));
1081        assert!(cap2.any_available());
1082
1083        // Both unavailable → None
1084        let cap3 = VerifierCapability {
1085            stage: VerifierStage::Build,
1086            command: Some("cargo build".to_string()),
1087            available: false,
1088            fallback_command: None,
1089            fallback_available: false,
1090        };
1091        assert_eq!(cap3.effective_command(), None);
1092        assert!(!cap3.any_available());
1093    }
1094
1095    #[test]
1096    fn test_verifier_profile_get_and_available_stages() {
1097        let profile = VerifierProfile {
1098            plugin_name: "test".to_string(),
1099            capabilities: vec![
1100                VerifierCapability {
1101                    stage: VerifierStage::SyntaxCheck,
1102                    command: Some("check".to_string()),
1103                    available: true,
1104                    fallback_command: None,
1105                    fallback_available: false,
1106                },
1107                VerifierCapability {
1108                    stage: VerifierStage::Build,
1109                    command: Some("build".to_string()),
1110                    available: false,
1111                    fallback_command: None,
1112                    fallback_available: false,
1113                },
1114                VerifierCapability {
1115                    stage: VerifierStage::Test,
1116                    command: Some("test".to_string()),
1117                    available: true,
1118                    fallback_command: None,
1119                    fallback_available: false,
1120                },
1121            ],
1122            lsp: LspCapability {
1123                primary: LspConfig {
1124                    server_binary: "test-ls".to_string(),
1125                    args: vec![],
1126                    language_id: "test".to_string(),
1127                },
1128                primary_available: false,
1129                fallback: None,
1130                fallback_available: false,
1131            },
1132        };
1133
1134        assert!(profile.get(VerifierStage::SyntaxCheck).is_some());
1135        assert!(profile.get(VerifierStage::Lint).is_none());
1136
1137        let available = profile.available_stages();
1138        assert_eq!(available.len(), 2);
1139        assert!(available.contains(&VerifierStage::SyntaxCheck));
1140        assert!(available.contains(&VerifierStage::Test));
1141        assert!(!available.contains(&VerifierStage::Build));
1142        assert!(!profile.fully_degraded());
1143    }
1144
1145    #[test]
1146    fn test_verifier_profile_fully_degraded() {
1147        let profile = VerifierProfile {
1148            plugin_name: "empty".to_string(),
1149            capabilities: vec![VerifierCapability {
1150                stage: VerifierStage::Build,
1151                command: Some("build".to_string()),
1152                available: false,
1153                fallback_command: None,
1154                fallback_available: false,
1155            }],
1156            lsp: LspCapability {
1157                primary: LspConfig {
1158                    server_binary: "none".to_string(),
1159                    args: vec![],
1160                    language_id: "none".to_string(),
1161                },
1162                primary_available: false,
1163                fallback: None,
1164                fallback_available: false,
1165            },
1166        };
1167        assert!(profile.fully_degraded());
1168        assert!(profile.available_stages().is_empty());
1169    }
1170
1171    #[test]
1172    fn test_lsp_capability_effective_config() {
1173        let lsp = LspCapability {
1174            primary: LspConfig {
1175                server_binary: "rust-analyzer".to_string(),
1176                args: vec![],
1177                language_id: "rust".to_string(),
1178            },
1179            primary_available: true,
1180            fallback: None,
1181            fallback_available: false,
1182        };
1183        assert_eq!(
1184            lsp.effective_config().unwrap().server_binary,
1185            "rust-analyzer"
1186        );
1187
1188        // Primary unavailable, fallback available
1189        let lsp2 = LspCapability {
1190            primary: LspConfig {
1191                server_binary: "uvx".to_string(),
1192                args: vec![],
1193                language_id: "python".to_string(),
1194            },
1195            primary_available: false,
1196            fallback: Some(LspConfig {
1197                server_binary: "pyright-langserver".to_string(),
1198                args: vec!["--stdio".to_string()],
1199                language_id: "python".to_string(),
1200            }),
1201            fallback_available: true,
1202        };
1203        assert_eq!(
1204            lsp2.effective_config().unwrap().server_binary,
1205            "pyright-langserver"
1206        );
1207
1208        // Both unavailable
1209        let lsp3 = LspCapability {
1210            primary: LspConfig {
1211                server_binary: "nope".to_string(),
1212                args: vec![],
1213                language_id: "none".to_string(),
1214            },
1215            primary_available: false,
1216            fallback: None,
1217            fallback_available: false,
1218        };
1219        assert!(lsp3.effective_config().is_none());
1220    }
1221
1222    #[test]
1223    fn test_rust_plugin_verifier_profile_shape() {
1224        let rust = RustPlugin;
1225        let profile = rust.verifier_profile();
1226        assert_eq!(profile.plugin_name, "rust");
1227        // Rust should declare all 4 stages
1228        assert_eq!(profile.capabilities.len(), 4);
1229        let stages: Vec<_> = profile.capabilities.iter().map(|c| c.stage).collect();
1230        assert!(stages.contains(&VerifierStage::SyntaxCheck));
1231        assert!(stages.contains(&VerifierStage::Build));
1232        assert!(stages.contains(&VerifierStage::Test));
1233        assert!(stages.contains(&VerifierStage::Lint));
1234    }
1235
1236    #[test]
1237    fn test_python_plugin_verifier_profile_shape() {
1238        let py = PythonPlugin;
1239        let profile = py.verifier_profile();
1240        assert_eq!(profile.plugin_name, "python");
1241        // Python: syntax_check, test, lint (no build)
1242        assert_eq!(profile.capabilities.len(), 3);
1243        let stages: Vec<_> = profile.capabilities.iter().map(|c| c.stage).collect();
1244        assert!(stages.contains(&VerifierStage::SyntaxCheck));
1245        assert!(stages.contains(&VerifierStage::Test));
1246        assert!(stages.contains(&VerifierStage::Lint));
1247        assert!(!stages.contains(&VerifierStage::Build));
1248        // Python has an LSP fallback declared
1249        assert!(profile.lsp.fallback.is_some());
1250    }
1251
1252    #[test]
1253    fn test_js_plugin_verifier_profile_shape() {
1254        let js = JsPlugin;
1255        let profile = js.verifier_profile();
1256        assert_eq!(profile.plugin_name, "javascript");
1257        // JS: all 4 stages
1258        assert_eq!(profile.capabilities.len(), 4);
1259    }
1260
1261    #[test]
1262    fn test_verifier_stage_display() {
1263        assert_eq!(format!("{}", VerifierStage::SyntaxCheck), "syntax_check");
1264        assert_eq!(format!("{}", VerifierStage::Build), "build");
1265        assert_eq!(format!("{}", VerifierStage::Test), "test");
1266        assert_eq!(format!("{}", VerifierStage::Lint), "lint");
1267    }
1268
1269    #[test]
1270    fn test_python_run_command_for_dir_src_layout() {
1271        let dir =
1272            std::env::temp_dir().join(format!("perspt_test_pyrun_src_{}", uuid::Uuid::new_v4()));
1273        std::fs::create_dir_all(dir.join("src/myapp")).unwrap();
1274        std::fs::write(dir.join("src/myapp/__init__.py"), "").unwrap();
1275
1276        let plugin = PythonPlugin;
1277        let cmd = plugin.run_command_for_dir(&dir);
1278        assert_eq!(cmd, "uv run python -m myapp");
1279
1280        let _ = std::fs::remove_dir_all(&dir);
1281    }
1282
1283    #[test]
1284    fn test_python_run_command_for_dir_scripts() {
1285        let dir = std::env::temp_dir().join(format!(
1286            "perspt_test_pyrun_scripts_{}",
1287            uuid::Uuid::new_v4()
1288        ));
1289        std::fs::create_dir_all(&dir).unwrap();
1290        std::fs::write(
1291            dir.join("pyproject.toml"),
1292            "[project]\nname = \"myapp\"\n\n[project.scripts]\nmyapp = \"myapp:main\"\n",
1293        )
1294        .unwrap();
1295
1296        let plugin = PythonPlugin;
1297        let cmd = plugin.run_command_for_dir(&dir);
1298        assert_eq!(cmd, "uv run myapp");
1299
1300        let _ = std::fs::remove_dir_all(&dir);
1301    }
1302
1303    #[test]
1304    fn test_python_run_command_for_dir_default() {
1305        let dir = std::env::temp_dir().join(format!(
1306            "perspt_test_pyrun_default_{}",
1307            uuid::Uuid::new_v4()
1308        ));
1309        std::fs::create_dir_all(&dir).unwrap();
1310        std::fs::write(dir.join("pyproject.toml"), "[project]\nname = \"myapp\"\n").unwrap();
1311
1312        let plugin = PythonPlugin;
1313        let cmd = plugin.run_command_for_dir(&dir);
1314        assert_eq!(cmd, "uv run python -m main");
1315
1316        let _ = std::fs::remove_dir_all(&dir);
1317    }
1318}