Skip to main content

perspt_core/plugin/
js.rs

1use super::*;
2
3/// JavaScript/TypeScript language plugin
4pub struct JsPlugin;
5
6impl LanguagePlugin for JsPlugin {
7    fn name(&self) -> &str {
8        "javascript"
9    }
10
11    fn extensions(&self) -> &[&str] {
12        &["js", "ts", "jsx", "tsx"]
13    }
14
15    fn key_files(&self) -> &[&str] {
16        &["package.json", "tsconfig.json"]
17    }
18
19    fn required_binaries(&self) -> Vec<(&str, &str, &str)> {
20        vec![
21            (
22                "node",
23                "runtime",
24                "Install Node.js from https://nodejs.org or via nvm",
25            ),
26            (
27                "npm",
28                "package manager",
29                "Included with Node.js — install from https://nodejs.org",
30            ),
31            (
32                "typescript-language-server",
33                "language server",
34                "npm install -g typescript-language-server typescript",
35            ),
36        ]
37    }
38
39    fn get_lsp_config(&self) -> LspConfig {
40        LspConfig {
41            server_binary: "typescript-language-server".to_string(),
42            args: vec!["--stdio".to_string()],
43            language_id: "typescript".to_string(),
44        }
45    }
46
47    fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
48        let command = match opts.package_manager.as_deref() {
49            Some("pnpm") => {
50                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
51                    "pnpm init".to_string()
52                } else {
53                    format!("mkdir -p {} && cd {} && pnpm init", opts.name, opts.name)
54                }
55            }
56            Some("yarn") => {
57                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
58                    "yarn init -y".to_string()
59                } else {
60                    format!("mkdir -p {} && cd {} && yarn init -y", opts.name, opts.name)
61                }
62            }
63            _ => {
64                // Default to npm
65                if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
66                    "npm init -y".to_string()
67                } else {
68                    format!("mkdir -p {} && cd {} && npm init -y", opts.name, opts.name)
69                }
70            }
71        };
72        let description = match opts.package_manager.as_deref() {
73            Some("pnpm") => "Initialize JavaScript project with pnpm",
74            Some("yarn") => "Initialize JavaScript project with Yarn",
75            _ => "Initialize JavaScript project with npm",
76        };
77        ProjectAction::ExecCommand {
78            command,
79            description: description.to_string(),
80        }
81    }
82
83    fn check_tooling_action(&self, path: &Path) -> ProjectAction {
84        // Check for package.json but missing node_modules
85        let has_package_json = path.join("package.json").exists();
86        let has_node_modules = path.join("node_modules").exists();
87
88        if has_package_json && !has_node_modules {
89            ProjectAction::ExecCommand {
90                command: "npm install".to_string(),
91                description: "Install Node.js dependencies".to_string(),
92            }
93        } else {
94            ProjectAction::NoAction
95        }
96    }
97
98    fn init_command(&self, opts: &InitOptions) -> String {
99        format!("npm init -y && mv package.json {}/", opts.name)
100    }
101
102    fn test_command(&self) -> String {
103        "npm test".to_string()
104    }
105
106    fn run_command(&self) -> String {
107        "npm start".to_string()
108    }
109
110    // PSP-5 capability methods
111
112    fn syntax_check_command(&self) -> Option<String> {
113        Some("npx tsc --noEmit".to_string())
114    }
115
116    fn build_command(&self) -> Option<String> {
117        Some("npm run build".to_string())
118    }
119
120    fn lint_command(&self) -> Option<String> {
121        Some("npx eslint .".to_string())
122    }
123
124    fn file_ownership_patterns(&self) -> &[&str] {
125        &["js", "ts", "jsx", "tsx", "package.json", "tsconfig.json"]
126    }
127
128    fn is_test_file(&self, path: &str) -> bool {
129        let normalized = path.replace('\\', "/");
130        let file = normalized.rsplit('/').next().unwrap_or(&normalized);
131        normalized.starts_with("test/")
132            || normalized.starts_with("tests/")
133            || normalized.contains("/__tests__/")
134            || file.contains(".test.")
135            || file.contains(".spec.")
136    }
137
138    fn host_tool_available(&self) -> bool {
139        host_binary_available("node")
140    }
141
142    fn verifier_profile(&self) -> VerifierProfile {
143        let node = host_binary_available("node");
144        let npx = host_binary_available("npx");
145
146        let capabilities = vec![
147            VerifierCapability {
148                stage: VerifierStage::SyntaxCheck,
149                command: Some("npx tsc --noEmit".to_string()),
150                available: npx,
151                fallback_command: None,
152                fallback_available: false,
153            },
154            VerifierCapability {
155                stage: VerifierStage::Build,
156                command: Some("npm run build".to_string()),
157                available: node,
158                fallback_command: None,
159                fallback_available: false,
160            },
161            VerifierCapability {
162                stage: VerifierStage::Test,
163                command: Some("npm test".to_string()),
164                available: node,
165                fallback_command: None,
166                fallback_available: false,
167            },
168            VerifierCapability {
169                stage: VerifierStage::Lint,
170                command: Some("npx eslint .".to_string()),
171                available: npx,
172                fallback_command: None,
173                fallback_available: false,
174            },
175        ];
176
177        let primary = self.get_lsp_config();
178        let primary_available = host_binary_available(&primary.server_binary);
179
180        VerifierProfile {
181            plugin_name: self.name().to_string(),
182            capabilities,
183            lsp: LspCapability {
184                primary,
185                primary_available,
186                fallback: None,
187                fallback_available: false,
188            },
189        }
190    }
191
192    // PSP-7 correction contract
193
194    fn legal_support_files(&self) -> &[&str] {
195        &["package.json", "tsconfig.json", "package-lock.json"]
196    }
197
198    fn dependency_commands(
199        &self,
200        action: crate::plugin::DependencyAction,
201        packages: &[String],
202        dev: bool,
203    ) -> Vec<String> {
204        use crate::plugin::DependencyAction;
205        let list = packages.join(" ");
206        match action {
207            DependencyAction::Add if dev => vec![format!("npm install --save-dev {list}")],
208            DependencyAction::Add => vec![format!("npm install {list}")],
209            DependencyAction::Remove => vec![format!("npm uninstall {list}")],
210            DependencyAction::Update => vec![format!("npm update {list}")],
211        }
212    }
213
214    fn dependency_files(&self) -> Vec<String> {
215        vec!["package.json".into(), "package-lock.json".into()]
216    }
217
218    fn dependency_command_policy(&self, command: &str) -> crate::types::CommandPolicyDecision {
219        let trimmed = command.trim();
220        if trimmed.starts_with("npm install ")
221            || trimmed.starts_with("npm i ")
222            || trimmed.starts_with("yarn add ")
223            || trimmed.starts_with("pnpm add ")
224            || trimmed.starts_with("pnpm install ")
225            || trimmed.starts_with("npm update ")
226        {
227            crate::types::CommandPolicyDecision::Allow
228        } else if trimmed.starts_with("npm uninstall ")
229            || trimmed.starts_with("yarn remove ")
230            || trimmed.starts_with("pnpm remove ")
231        {
232            crate::types::CommandPolicyDecision::RequireApproval
233        } else {
234            crate::types::CommandPolicyDecision::Deny
235        }
236    }
237
238    fn test_file_patterns(&self) -> &[&str] {
239        &[
240            "**/*.test.js",
241            "**/*.test.ts",
242            "**/*.spec.js",
243            "**/*.spec.ts",
244            "**/*.test.jsx",
245            "**/*.test.tsx",
246            "**/*.spec.jsx",
247            "**/*.spec.tsx",
248        ]
249    }
250}