1use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct LspConfig {
12 pub server_binary: String,
14 pub args: Vec<String>,
16 pub language_id: String,
18}
19
20#[derive(Debug, Clone, Default)]
22pub struct InitOptions {
23 pub name: String,
25 pub package_manager: Option<String>,
27 pub flags: Vec<String>,
29 pub is_empty_dir: bool,
31}
32
33#[derive(Debug, Clone)]
35pub enum ProjectAction {
36 ExecCommand {
38 command: String,
40 description: String,
42 },
43 NoAction,
45}
46
47pub trait LanguagePlugin: Send + Sync {
49 fn name(&self) -> &str;
51
52 fn extensions(&self) -> &[&str];
54
55 fn key_files(&self) -> &[&str];
57
58 fn detect(&self, path: &Path) -> bool {
60 for key_file in self.key_files() {
62 if path.join(key_file).exists() {
63 return true;
64 }
65 }
66
67 if let Ok(entries) = std::fs::read_dir(path) {
69 for entry in entries.flatten() {
70 if let Some(ext) = entry.path().extension() {
71 let ext_str = ext.to_string_lossy();
72 if self.extensions().iter().any(|e| *e == ext_str) {
73 return true;
74 }
75 }
76 }
77 }
78
79 false
80 }
81
82 fn get_lsp_config(&self) -> LspConfig;
84
85 fn get_init_action(&self, opts: &InitOptions) -> ProjectAction;
87
88 fn check_tooling_action(&self, path: &Path) -> ProjectAction;
90
91 fn init_command(&self, opts: &InitOptions) -> String;
94
95 fn test_command(&self) -> String;
97
98 fn run_command(&self) -> String;
100}
101
102pub struct RustPlugin;
104
105impl LanguagePlugin for RustPlugin {
106 fn name(&self) -> &str {
107 "rust"
108 }
109
110 fn extensions(&self) -> &[&str] {
111 &["rs"]
112 }
113
114 fn key_files(&self) -> &[&str] {
115 &["Cargo.toml", "Cargo.lock"]
116 }
117
118 fn get_lsp_config(&self) -> LspConfig {
119 LspConfig {
120 server_binary: "rust-analyzer".to_string(),
121 args: vec![],
122 language_id: "rust".to_string(),
123 }
124 }
125
126 fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
127 let command = if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
128 "cargo init .".to_string()
129 } else {
130 format!("cargo new {}", opts.name)
131 };
132 ProjectAction::ExecCommand {
133 command,
134 description: "Initialize Rust project with Cargo".to_string(),
135 }
136 }
137
138 fn check_tooling_action(&self, path: &Path) -> ProjectAction {
139 if !path.join("Cargo.lock").exists() && path.join("Cargo.toml").exists() {
141 ProjectAction::ExecCommand {
142 command: "cargo fetch".to_string(),
143 description: "Fetch Rust dependencies".to_string(),
144 }
145 } else {
146 ProjectAction::NoAction
147 }
148 }
149
150 fn init_command(&self, opts: &InitOptions) -> String {
151 if opts.name == "." || opts.name == "./" {
152 "cargo init .".to_string()
153 } else {
154 format!("cargo new {}", opts.name)
155 }
156 }
157
158 fn test_command(&self) -> String {
159 "cargo test".to_string()
160 }
161
162 fn run_command(&self) -> String {
163 "cargo run".to_string()
164 }
165}
166
167pub struct PythonPlugin;
169
170impl LanguagePlugin for PythonPlugin {
171 fn name(&self) -> &str {
172 "python"
173 }
174
175 fn extensions(&self) -> &[&str] {
176 &["py"]
177 }
178
179 fn key_files(&self) -> &[&str] {
180 &["pyproject.toml", "setup.py", "requirements.txt", "uv.lock"]
181 }
182
183 fn get_lsp_config(&self) -> LspConfig {
184 LspConfig {
187 server_binary: "uvx".to_string(),
188 args: vec!["ty".to_string(), "server".to_string()],
189 language_id: "python".to_string(),
190 }
191 }
192
193 fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
194 let command = match opts.package_manager.as_deref() {
195 Some("poetry") => {
196 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
197 "poetry init --no-interaction".to_string()
198 } else {
199 format!("poetry new {}", opts.name)
200 }
201 }
202 Some("pdm") => {
203 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
204 "pdm init --non-interactive".to_string()
205 } else {
206 format!(
207 "mkdir -p {} && cd {} && pdm init --non-interactive",
208 opts.name, opts.name
209 )
210 }
211 }
212 _ => {
213 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
215 "uv init".to_string()
216 } else {
217 format!("uv init {}", opts.name)
218 }
219 }
220 };
221 let description = match opts.package_manager.as_deref() {
222 Some("poetry") => "Initialize Python project with Poetry",
223 Some("pdm") => "Initialize Python project with PDM",
224 _ => "Initialize Python project with uv",
225 };
226 ProjectAction::ExecCommand {
227 command,
228 description: description.to_string(),
229 }
230 }
231
232 fn check_tooling_action(&self, path: &Path) -> ProjectAction {
233 let has_pyproject = path.join("pyproject.toml").exists();
235 let has_venv = path.join(".venv").exists();
236 let has_uv_lock = path.join("uv.lock").exists();
237
238 if has_pyproject && (!has_venv || !has_uv_lock) {
239 ProjectAction::ExecCommand {
240 command: "uv sync".to_string(),
241 description: "Sync Python dependencies with uv".to_string(),
242 }
243 } else {
244 ProjectAction::NoAction
245 }
246 }
247
248 fn init_command(&self, opts: &InitOptions) -> String {
249 if opts.package_manager.as_deref() == Some("poetry") {
250 if opts.name == "." || opts.name == "./" {
251 "poetry init".to_string()
252 } else {
253 format!("poetry new {}", opts.name)
254 }
255 } else {
256 format!("uv init {}", opts.name)
258 }
259 }
260
261 fn test_command(&self) -> String {
262 "uv run pytest".to_string()
263 }
264
265 fn run_command(&self) -> String {
266 "uv run python -m main".to_string()
267 }
268}
269
270pub struct JsPlugin;
272
273impl LanguagePlugin for JsPlugin {
274 fn name(&self) -> &str {
275 "javascript"
276 }
277
278 fn extensions(&self) -> &[&str] {
279 &["js", "ts", "jsx", "tsx"]
280 }
281
282 fn key_files(&self) -> &[&str] {
283 &["package.json", "tsconfig.json"]
284 }
285
286 fn get_lsp_config(&self) -> LspConfig {
287 LspConfig {
288 server_binary: "typescript-language-server".to_string(),
289 args: vec!["--stdio".to_string()],
290 language_id: "typescript".to_string(),
291 }
292 }
293
294 fn get_init_action(&self, opts: &InitOptions) -> ProjectAction {
295 let command = match opts.package_manager.as_deref() {
296 Some("pnpm") => {
297 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
298 "pnpm init".to_string()
299 } else {
300 format!("mkdir -p {} && cd {} && pnpm init", opts.name, opts.name)
301 }
302 }
303 Some("yarn") => {
304 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
305 "yarn init -y".to_string()
306 } else {
307 format!("mkdir -p {} && cd {} && yarn init -y", opts.name, opts.name)
308 }
309 }
310 _ => {
311 if opts.is_empty_dir || opts.name == "." || opts.name == "./" {
313 "npm init -y".to_string()
314 } else {
315 format!("mkdir -p {} && cd {} && npm init -y", opts.name, opts.name)
316 }
317 }
318 };
319 let description = match opts.package_manager.as_deref() {
320 Some("pnpm") => "Initialize JavaScript project with pnpm",
321 Some("yarn") => "Initialize JavaScript project with Yarn",
322 _ => "Initialize JavaScript project with npm",
323 };
324 ProjectAction::ExecCommand {
325 command,
326 description: description.to_string(),
327 }
328 }
329
330 fn check_tooling_action(&self, path: &Path) -> ProjectAction {
331 let has_package_json = path.join("package.json").exists();
333 let has_node_modules = path.join("node_modules").exists();
334
335 if has_package_json && !has_node_modules {
336 ProjectAction::ExecCommand {
337 command: "npm install".to_string(),
338 description: "Install Node.js dependencies".to_string(),
339 }
340 } else {
341 ProjectAction::NoAction
342 }
343 }
344
345 fn init_command(&self, opts: &InitOptions) -> String {
346 format!("npm init -y && mv package.json {}/", opts.name)
347 }
348
349 fn test_command(&self) -> String {
350 "npm test".to_string()
351 }
352
353 fn run_command(&self) -> String {
354 "npm start".to_string()
355 }
356}
357
358pub struct PluginRegistry {
360 plugins: Vec<Box<dyn LanguagePlugin>>,
361}
362
363impl PluginRegistry {
364 pub fn new() -> Self {
366 Self {
367 plugins: vec![
368 Box::new(RustPlugin),
369 Box::new(PythonPlugin),
370 Box::new(JsPlugin),
371 ],
372 }
373 }
374
375 pub fn detect(&self, path: &Path) -> Option<&dyn LanguagePlugin> {
377 self.plugins
378 .iter()
379 .find(|p| p.detect(path))
380 .map(|p| p.as_ref())
381 }
382
383 pub fn get(&self, name: &str) -> Option<&dyn LanguagePlugin> {
385 self.plugins
386 .iter()
387 .find(|p| p.name() == name)
388 .map(|p| p.as_ref())
389 }
390
391 pub fn all(&self) -> &[Box<dyn LanguagePlugin>] {
393 &self.plugins
394 }
395}
396
397impl Default for PluginRegistry {
398 fn default() -> Self {
399 Self::new()
400 }
401}