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`, `uvx 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`, `uvx 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", "Cargo.toml"]
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("uvx 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", "pyproject.toml", "setup.py", "requirements.txt"]
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("uvx 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::Build,
757                // Python has no separate build step; declare the capability
758                // so the sensor doesn't appear as Unavailable/degraded.
759                command: None,
760                available: true,
761                fallback_command: None,
762                fallback_available: false,
763            },
764            VerifierCapability {
765                stage: VerifierStage::Test,
766                command: Some("uv run pytest".to_string()),
767                available: uv,
768                // bare pytest fallback
769                fallback_command: Some("python -m pytest".to_string()),
770                fallback_available: host_binary_available("python3")
771                    || host_binary_available("python"),
772            },
773            VerifierCapability {
774                stage: VerifierStage::Lint,
775                command: Some("uv run ruff check .".to_string()),
776                available: uv,
777                fallback_command: Some("ruff check .".to_string()),
778                fallback_available: host_binary_available("ruff"),
779            },
780        ];
781
782        let primary = self.get_lsp_config();
783        let primary_available = host_binary_available("uvx");
784        let fallback = self.lsp_fallback();
785        let fallback_available = fallback
786            .as_ref()
787            .map(|f| host_binary_available(&f.server_binary))
788            .unwrap_or(false);
789
790        VerifierProfile {
791            plugin_name: self.name().to_string(),
792            capabilities,
793            lsp: LspCapability {
794                primary,
795                primary_available,
796                fallback,
797                fallback_available,
798            },
799        }
800    }
801}
802
803/// JavaScript/TypeScript language plugin
804pub struct JsPlugin;
805
806impl LanguagePlugin for JsPlugin {
807    fn name(&self) -> &str {
808        "javascript"
809    }
810
811    fn extensions(&self) -> &[&str] {
812        &["js", "ts", "jsx", "tsx"]
813    }
814
815    fn key_files(&self) -> &[&str] {
816        &["package.json", "tsconfig.json"]
817    }
818
819    fn required_binaries(&self) -> Vec<(&str, &str, &str)> {
820        vec![
821            (
822                "node",
823                "runtime",
824                "Install Node.js from https://nodejs.org or via nvm",
825            ),
826            (
827                "npm",
828                "package manager",
829                "Included with Node.js — install from https://nodejs.org",
830            ),
831            (
832                "typescript-language-server",
833                "language server",
834                "npm install -g typescript-language-server typescript",
835            ),
836        ]
837    }
838
839    fn get_lsp_config(&self) -> LspConfig {
840        LspConfig {
841            server_binary: "typescript-language-server".to_string(),
842            args: vec!["--stdio".to_string()],
843            language_id: "typescript".to_string(),
844        }
845    }
846
847    fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
848        let command = match opts.package_manager.as_deref() {
849            Some("pnpm") => {
850                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
851                    "pnpm init".to_string()
852                } else {
853                    format!("mkdir -p {} && cd {} && pnpm init", opts.name, opts.name)
854                }
855            }
856            Some("yarn") => {
857                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
858                    "yarn init -y".to_string()
859                } else {
860                    format!("mkdir -p {} && cd {} && yarn init -y", opts.name, opts.name)
861                }
862            }
863            _ => {
864                // Default to npm
865                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
866                    "npm init -y".to_string()
867                } else {
868                    format!("mkdir -p {} && cd {} && npm init -y", opts.name, opts.name)
869                }
870            }
871        };
872        let description = match opts.package_manager.as_deref() {
873            Some("pnpm") => "Initialize JavaScript project with pnpm",
874            Some("yarn") => "Initialize JavaScript project with Yarn",
875            _ => "Initialize JavaScript project with npm",
876        };
877        ProjectAction::ExecCommand {
878            command,
879            description: description.to_string(),
880        }
881    }
882
883    fn check_tooling_action(&self, path: &Path) -> ProjectAction {
884        // Check for package.json but missing node_modules
885        let has_package_json = path.join("package.json").exists();
886        let has_node_modules = path.join("node_modules").exists();
887
888        if has_package_json && !has_node_modules {
889            ProjectAction::ExecCommand {
890                command: "npm install".to_string(),
891                description: "Install Node.js dependencies".to_string(),
892            }
893        } else {
894            ProjectAction::NoAction
895        }
896    }
897
898    fn init_command(&self, opts: &InitOptions) -> String {
899        format!("npm init -y && mv package.json {}/", opts.name)
900    }
901
902    fn test_command(&self) -> String {
903        "npm test".to_string()
904    }
905
906    fn run_command(&self) -> String {
907        "npm start".to_string()
908    }
909
910    // PSP-5 capability methods
911
912    fn syntax_check_command(&self) -> Option<String> {
913        Some("npx tsc --noEmit".to_string())
914    }
915
916    fn build_command(&self) -> Option<String> {
917        Some("npm run build".to_string())
918    }
919
920    fn lint_command(&self) -> Option<String> {
921        Some("npx eslint .".to_string())
922    }
923
924    fn file_ownership_patterns(&self) -> &[&str] {
925        &["js", "ts", "jsx", "tsx", "package.json", "tsconfig.json"]
926    }
927
928    fn host_tool_available(&self) -> bool {
929        host_binary_available("node")
930    }
931
932    fn verifier_profile(&self) -> VerifierProfile {
933        let node = host_binary_available("node");
934        let npx = host_binary_available("npx");
935
936        let capabilities = vec![
937            VerifierCapability {
938                stage: VerifierStage::SyntaxCheck,
939                command: Some("npx tsc --noEmit".to_string()),
940                available: npx,
941                fallback_command: None,
942                fallback_available: false,
943            },
944            VerifierCapability {
945                stage: VerifierStage::Build,
946                command: Some("npm run build".to_string()),
947                available: node,
948                fallback_command: None,
949                fallback_available: false,
950            },
951            VerifierCapability {
952                stage: VerifierStage::Test,
953                command: Some("npm test".to_string()),
954                available: node,
955                fallback_command: None,
956                fallback_available: false,
957            },
958            VerifierCapability {
959                stage: VerifierStage::Lint,
960                command: Some("npx eslint .".to_string()),
961                available: npx,
962                fallback_command: None,
963                fallback_available: false,
964            },
965        ];
966
967        let primary = self.get_lsp_config();
968        let primary_available = host_binary_available(&primary.server_binary);
969
970        VerifierProfile {
971            plugin_name: self.name().to_string(),
972            capabilities,
973            lsp: LspCapability {
974                primary,
975                primary_available,
976                fallback: None,
977                fallback_available: false,
978            },
979        }
980    }
981}
982
983/// Plugin registry for dynamic language detection
984pub struct PluginRegistry {
985    plugins: Vec<Box<dyn LanguagePlugin>>,
986}
987
988impl PluginRegistry {
989    /// Create a new registry with all built-in plugins
990    pub fn new() -> Self {
991        Self {
992            plugins: vec![
993                Box::new(RustPlugin),
994                Box::new(PythonPlugin),
995                Box::new(JsPlugin),
996            ],
997        }
998    }
999
1000    /// Detect which plugin should handle the given path (first match)
1001    pub fn detect(&self, path: &Path) -> Option<&dyn LanguagePlugin> {
1002        self.plugins
1003            .iter()
1004            .find(|p| p.detect(path))
1005            .map(|p| p.as_ref())
1006    }
1007
1008    /// PSP-5: Detect ALL plugins that match the given path (polyglot support)
1009    ///
1010    /// Returns all matching plugins instead of just the first, enabling
1011    /// multi-language verification in polyglot repositories.
1012    pub fn detect_all(&self, path: &Path) -> Vec<&dyn LanguagePlugin> {
1013        self.plugins
1014            .iter()
1015            .filter(|p| p.detect(path))
1016            .map(|p| p.as_ref())
1017            .collect()
1018    }
1019
1020    /// Get a plugin by name
1021    pub fn get(&self, name: &str) -> Option<&dyn LanguagePlugin> {
1022        self.plugins
1023            .iter()
1024            .find(|p| p.name() == name)
1025            .map(|p| p.as_ref())
1026    }
1027
1028    /// Get all registered plugins
1029    pub fn all(&self) -> &[Box<dyn LanguagePlugin>] {
1030        &self.plugins
1031    }
1032}
1033
1034impl Default for PluginRegistry {
1035    fn default() -> Self {
1036        Self::new()
1037    }
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042    use super::*;
1043
1044    #[test]
1045    fn test_plugin_owns_file() {
1046        let rust = RustPlugin;
1047        assert!(rust.owns_file("src/main.rs"));
1048        assert!(rust.owns_file("crates/core/src/lib.rs"));
1049        assert!(!rust.owns_file("main.py"));
1050        assert!(!rust.owns_file("index.js"));
1051
1052        let python = PythonPlugin;
1053        assert!(python.owns_file("main.py"));
1054        assert!(python.owns_file("tests/test_main.py"));
1055        assert!(!python.owns_file("src/main.rs"));
1056
1057        let js = JsPlugin;
1058        assert!(js.owns_file("index.js"));
1059        assert!(js.owns_file("src/app.ts"));
1060        assert!(!js.owns_file("main.py"));
1061        assert!(!js.owns_file("src/main.rs"));
1062    }
1063
1064    // =========================================================================
1065    // Verifier Capability & Profile Tests
1066    // =========================================================================
1067
1068    #[test]
1069    fn test_verifier_capability_effective_command() {
1070        // Primary available → primary wins
1071        let cap = VerifierCapability {
1072            stage: VerifierStage::SyntaxCheck,
1073            command: Some("cargo check".to_string()),
1074            available: true,
1075            fallback_command: Some("rustc --edition 2021".to_string()),
1076            fallback_available: true,
1077        };
1078        assert_eq!(cap.effective_command(), Some("cargo check"));
1079        assert!(cap.any_available());
1080
1081        // Primary unavailable, fallback available → fallback wins
1082        let cap2 = VerifierCapability {
1083            stage: VerifierStage::Lint,
1084            command: Some("uv run ruff check .".to_string()),
1085            available: false,
1086            fallback_command: Some("ruff check .".to_string()),
1087            fallback_available: true,
1088        };
1089        assert_eq!(cap2.effective_command(), Some("ruff check ."));
1090        assert!(cap2.any_available());
1091
1092        // Both unavailable → None
1093        let cap3 = VerifierCapability {
1094            stage: VerifierStage::Build,
1095            command: Some("cargo build".to_string()),
1096            available: false,
1097            fallback_command: None,
1098            fallback_available: false,
1099        };
1100        assert_eq!(cap3.effective_command(), None);
1101        assert!(!cap3.any_available());
1102    }
1103
1104    #[test]
1105    fn test_verifier_profile_get_and_available_stages() {
1106        let profile = VerifierProfile {
1107            plugin_name: "test".to_string(),
1108            capabilities: vec![
1109                VerifierCapability {
1110                    stage: VerifierStage::SyntaxCheck,
1111                    command: Some("check".to_string()),
1112                    available: true,
1113                    fallback_command: None,
1114                    fallback_available: false,
1115                },
1116                VerifierCapability {
1117                    stage: VerifierStage::Build,
1118                    command: Some("build".to_string()),
1119                    available: false,
1120                    fallback_command: None,
1121                    fallback_available: false,
1122                },
1123                VerifierCapability {
1124                    stage: VerifierStage::Test,
1125                    command: Some("test".to_string()),
1126                    available: true,
1127                    fallback_command: None,
1128                    fallback_available: false,
1129                },
1130            ],
1131            lsp: LspCapability {
1132                primary: LspConfig {
1133                    server_binary: "test-ls".to_string(),
1134                    args: vec![],
1135                    language_id: "test".to_string(),
1136                },
1137                primary_available: false,
1138                fallback: None,
1139                fallback_available: false,
1140            },
1141        };
1142
1143        assert!(profile.get(VerifierStage::SyntaxCheck).is_some());
1144        assert!(profile.get(VerifierStage::Lint).is_none());
1145
1146        let available = profile.available_stages();
1147        assert_eq!(available.len(), 2);
1148        assert!(available.contains(&VerifierStage::SyntaxCheck));
1149        assert!(available.contains(&VerifierStage::Test));
1150        assert!(!available.contains(&VerifierStage::Build));
1151        assert!(!profile.fully_degraded());
1152    }
1153
1154    #[test]
1155    fn test_verifier_profile_fully_degraded() {
1156        let profile = VerifierProfile {
1157            plugin_name: "empty".to_string(),
1158            capabilities: vec![VerifierCapability {
1159                stage: VerifierStage::Build,
1160                command: Some("build".to_string()),
1161                available: false,
1162                fallback_command: None,
1163                fallback_available: false,
1164            }],
1165            lsp: LspCapability {
1166                primary: LspConfig {
1167                    server_binary: "none".to_string(),
1168                    args: vec![],
1169                    language_id: "none".to_string(),
1170                },
1171                primary_available: false,
1172                fallback: None,
1173                fallback_available: false,
1174            },
1175        };
1176        assert!(profile.fully_degraded());
1177        assert!(profile.available_stages().is_empty());
1178    }
1179
1180    #[test]
1181    fn test_lsp_capability_effective_config() {
1182        let lsp = LspCapability {
1183            primary: LspConfig {
1184                server_binary: "rust-analyzer".to_string(),
1185                args: vec![],
1186                language_id: "rust".to_string(),
1187            },
1188            primary_available: true,
1189            fallback: None,
1190            fallback_available: false,
1191        };
1192        assert_eq!(
1193            lsp.effective_config().unwrap().server_binary,
1194            "rust-analyzer"
1195        );
1196
1197        // Primary unavailable, fallback available
1198        let lsp2 = LspCapability {
1199            primary: LspConfig {
1200                server_binary: "uvx".to_string(),
1201                args: vec![],
1202                language_id: "python".to_string(),
1203            },
1204            primary_available: false,
1205            fallback: Some(LspConfig {
1206                server_binary: "pyright-langserver".to_string(),
1207                args: vec!["--stdio".to_string()],
1208                language_id: "python".to_string(),
1209            }),
1210            fallback_available: true,
1211        };
1212        assert_eq!(
1213            lsp2.effective_config().unwrap().server_binary,
1214            "pyright-langserver"
1215        );
1216
1217        // Both unavailable
1218        let lsp3 = LspCapability {
1219            primary: LspConfig {
1220                server_binary: "nope".to_string(),
1221                args: vec![],
1222                language_id: "none".to_string(),
1223            },
1224            primary_available: false,
1225            fallback: None,
1226            fallback_available: false,
1227        };
1228        assert!(lsp3.effective_config().is_none());
1229    }
1230
1231    #[test]
1232    fn test_rust_plugin_verifier_profile_shape() {
1233        let rust = RustPlugin;
1234        let profile = rust.verifier_profile();
1235        assert_eq!(profile.plugin_name, "rust");
1236        // Rust should declare all 4 stages
1237        assert_eq!(profile.capabilities.len(), 4);
1238        let stages: Vec<_> = profile.capabilities.iter().map(|c| c.stage).collect();
1239        assert!(stages.contains(&VerifierStage::SyntaxCheck));
1240        assert!(stages.contains(&VerifierStage::Build));
1241        assert!(stages.contains(&VerifierStage::Test));
1242        assert!(stages.contains(&VerifierStage::Lint));
1243    }
1244
1245    #[test]
1246    fn test_python_plugin_verifier_profile_shape() {
1247        let py = PythonPlugin;
1248        let profile = py.verifier_profile();
1249        assert_eq!(profile.plugin_name, "python");
1250        // Python: syntax_check, build (no-op), test, lint
1251        assert_eq!(profile.capabilities.len(), 4);
1252        let stages: Vec<_> = profile.capabilities.iter().map(|c| c.stage).collect();
1253        assert!(stages.contains(&VerifierStage::SyntaxCheck));
1254        assert!(stages.contains(&VerifierStage::Build));
1255        assert!(stages.contains(&VerifierStage::Test));
1256        assert!(stages.contains(&VerifierStage::Lint));
1257        // Python has an LSP fallback declared
1258        assert!(profile.lsp.fallback.is_some());
1259    }
1260
1261    #[test]
1262    fn test_js_plugin_verifier_profile_shape() {
1263        let js = JsPlugin;
1264        let profile = js.verifier_profile();
1265        assert_eq!(profile.plugin_name, "javascript");
1266        // JS: all 4 stages
1267        assert_eq!(profile.capabilities.len(), 4);
1268    }
1269
1270    #[test]
1271    fn test_verifier_stage_display() {
1272        assert_eq!(format!("{}", VerifierStage::SyntaxCheck), "syntax_check");
1273        assert_eq!(format!("{}", VerifierStage::Build), "build");
1274        assert_eq!(format!("{}", VerifierStage::Test), "test");
1275        assert_eq!(format!("{}", VerifierStage::Lint), "lint");
1276    }
1277
1278    #[test]
1279    fn test_python_run_command_for_dir_src_layout() {
1280        let dir =
1281            std::env::temp_dir().join(format!("perspt_test_pyrun_src_{}", uuid::Uuid::new_v4()));
1282        std::fs::create_dir_all(dir.join("src/myapp")).unwrap();
1283        std::fs::write(dir.join("src/myapp/__init__.py"), "").unwrap();
1284
1285        let plugin = PythonPlugin;
1286        let cmd = plugin.run_command_for_dir(&dir);
1287        assert_eq!(cmd, "uv run python -m myapp");
1288
1289        let _ = std::fs::remove_dir_all(&dir);
1290    }
1291
1292    #[test]
1293    fn test_python_run_command_for_dir_scripts() {
1294        let dir = std::env::temp_dir().join(format!(
1295            "perspt_test_pyrun_scripts_{}",
1296            uuid::Uuid::new_v4()
1297        ));
1298        std::fs::create_dir_all(&dir).unwrap();
1299        std::fs::write(
1300            dir.join("pyproject.toml"),
1301            "[project]\nname = \"myapp\"\n\n[project.scripts]\nmyapp = \"myapp:main\"\n",
1302        )
1303        .unwrap();
1304
1305        let plugin = PythonPlugin;
1306        let cmd = plugin.run_command_for_dir(&dir);
1307        assert_eq!(cmd, "uv run myapp");
1308
1309        let _ = std::fs::remove_dir_all(&dir);
1310    }
1311
1312    #[test]
1313    fn test_python_run_command_for_dir_default() {
1314        let dir = std::env::temp_dir().join(format!(
1315            "perspt_test_pyrun_default_{}",
1316            uuid::Uuid::new_v4()
1317        ));
1318        std::fs::create_dir_all(&dir).unwrap();
1319        std::fs::write(dir.join("pyproject.toml"), "[project]\nname = \"myapp\"\n").unwrap();
1320
1321        let plugin = PythonPlugin;
1322        let cmd = plugin.run_command_for_dir(&dir);
1323        assert_eq!(cmd, "uv run python -m main");
1324
1325        let _ = std::fs::remove_dir_all(&dir);
1326    }
1327}