1use super::*;
2
3pub 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 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 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 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 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}