diff --git a/README.md b/README.md index 5859d10..ec7e680 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Inspired by [antfu/ni](https://github.com/antfu/ni). - make: `Makefile` - just: `justfile` / `Justfile` - task: `Taskfile.yml` / `Taskfile.yaml` ... +- vite+: `vite.config.ts` / `vite.config.js` ... - cargo-make: `Makefile.toml` - mise: `mise.toml` - mask: `maskfile.md` diff --git a/src/detect.rs b/src/detect.rs index 9d2d994..90219fb 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use crate::RtError; -const RUNNER_CANDIDATES: [(&str, Runner); 15] = [ +const RUNNER_CANDIDATES: [(&str, Runner); 22] = [ ("Justfile", Runner::Justfile), ("justfile", Runner::Justfile), ("Taskfile.yml", Runner::Taskfile), @@ -15,6 +15,13 @@ const RUNNER_CANDIDATES: [(&str, Runner); 15] = [ ("taskfile.dist.yaml", Runner::Taskfile), ("maskfile.md", Runner::Maskfile), ("Maskfile.md", Runner::Maskfile), + ("vite.config.ts", Runner::VitePlus), + ("vite.config.mts", Runner::VitePlus), + ("vite.config.cts", Runner::VitePlus), + ("vite.config.js", Runner::VitePlus), + ("vite.config.mjs", Runner::VitePlus), + ("vite.config.cjs", Runner::VitePlus), + ("package.json", Runner::VitePlus), ("mise.toml", Runner::Mise), ("Makefile.toml", Runner::CargoMake), ("Makefile", Runner::Makefile), @@ -25,6 +32,7 @@ pub enum Runner { Justfile, Taskfile, Maskfile, + VitePlus, Mise, CargoMake, Makefile, @@ -40,7 +48,7 @@ pub struct Detection { pub fn detect_runner(dir_path: &Path) -> Result { for (name, runner) in RUNNER_CANDIDATES { let path = dir_path.join(name); - if path.is_file() { + if candidate_matches(runner, &path) { return Ok(Detection { runner, runner_file: path, @@ -63,7 +71,7 @@ pub fn detect_runners(dir_path: &Path) -> Result, RtError> { continue; } let path = dir_path.join(name); - if path.is_file() { + if candidate_matches(runner, &path) { seen.insert(runner); detections.push(Detection { runner, @@ -81,12 +89,68 @@ pub fn detect_runners(dir_path: &Path) -> Result, RtError> { } } +fn candidate_matches(runner: Runner, path: &Path) -> bool { + if !path.is_file() { + return false; + } + + match runner { + Runner::VitePlus => vite_plus_marker_matches(path), + _ => true, + } +} + +fn vite_plus_marker_matches(path: &Path) -> bool { + match path.file_name().and_then(|name| name.to_str()) { + Some("package.json") => package_json_declares_vite_plus(path), + Some(name) if name.starts_with("vite.config.") => vite_config_declares_vite_plus(path), + _ => true, + } +} + +fn vite_config_declares_vite_plus(path: &Path) -> bool { + std::fs::read_to_string(path) + .map(|content| content.contains("vite-plus")) + .unwrap_or(false) +} + +fn package_json_declares_vite_plus(path: &Path) -> bool { + #[derive(serde::Deserialize)] + struct PackageJson { + #[serde(default)] + dependencies: std::collections::BTreeMap, + #[serde(default, rename = "devDependencies")] + dev_dependencies: std::collections::BTreeMap, + #[serde(default, rename = "peerDependencies")] + peer_dependencies: std::collections::BTreeMap, + #[serde(default, rename = "optionalDependencies")] + optional_dependencies: std::collections::BTreeMap, + } + + let Ok(content) = std::fs::read_to_string(path) else { + return false; + }; + let Ok(package_json) = serde_json::from_str::(&content) else { + return false; + }; + + [ + &package_json.dependencies, + &package_json.dev_dependencies, + &package_json.peer_dependencies, + &package_json.optional_dependencies, + ] + .into_iter() + .any(|deps| deps.contains_key("vite-plus")) +} + /// Returns the command name for the given runner. pub fn runner_command(runner: Runner) -> &'static str { match runner { Runner::Justfile => "just", Runner::Taskfile => "task", Runner::Maskfile => "mask", + Runner::VitePlus => "vp", Runner::Mise => "mise", // cargo-make is a subcommand of cargo, so we need to check cargo Runner::CargoMake => "cargo", @@ -105,6 +169,12 @@ mod tests { path } + fn write(dir: &Path, name: &str, contents: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, contents).unwrap(); + path + } + #[test] fn detect_none_returns_error() { let dir = tempdir().unwrap(); @@ -152,11 +222,42 @@ mod tests { assert_eq!(detection.runner_file, yml); } + #[test] + fn detect_ignores_plain_vite_config_without_vite_plus_marker() { + let dir = tempdir().unwrap(); + write( + dir.path(), + "vite.config.ts", + "import { defineConfig } from 'vite'; export default defineConfig({});", + ); + + let err = detect_runner(dir.path()).unwrap_err(); + match err { + RtError::NoRunnerFound { .. } => {} + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn detect_package_json_with_vite_plus_dependency() { + let dir = tempdir().unwrap(); + let package_json = write( + dir.path(), + "package.json", + r#"{"devDependencies":{"vite-plus":"^1.0.0"}}"#, + ); + + let detection = detect_runner(dir.path()).unwrap(); + assert_eq!(detection.runner, Runner::VitePlus); + assert_eq!(detection.runner_file, package_json); + } + #[test] fn runner_command_mapping() { assert_eq!(runner_command(Runner::Justfile), "just"); assert_eq!(runner_command(Runner::Taskfile), "task"); assert_eq!(runner_command(Runner::Maskfile), "mask"); + assert_eq!(runner_command(Runner::VitePlus), "vp"); assert_eq!(runner_command(Runner::Mise), "mise"); assert_eq!(runner_command(Runner::CargoMake), "cargo"); assert_eq!(runner_command(Runner::Makefile), "make"); @@ -168,6 +269,11 @@ mod tests { touch(dir.path(), "Makefile"); touch(dir.path(), "Makefile.toml"); touch(dir.path(), "mise.toml"); + write( + dir.path(), + "vite.config.ts", + "import { defineConfig } from 'vite-plus'; export default defineConfig({});", + ); touch(dir.path(), "maskfile.md"); touch(dir.path(), "Taskfile.yml"); touch(dir.path(), "justfile"); @@ -181,6 +287,7 @@ mod tests { Runner::Justfile, Runner::Taskfile, Runner::Maskfile, + Runner::VitePlus, Runner::Mise, Runner::CargoMake, Runner::Makefile, @@ -188,6 +295,26 @@ mod tests { ); } + #[test] + fn detect_runners_deduplicates_vite_plus_config_variants() { + let dir = tempdir().unwrap(); + write( + dir.path(), + "vite.config.ts", + "import { defineConfig } from 'vite-plus'; export default defineConfig({});", + ); + write( + dir.path(), + "vite.config.mjs", + "import { defineConfig } from 'vite-plus'; export default defineConfig({});", + ); + + let detections = detect_runners(dir.path()).unwrap(); + let runners: Vec = detections.into_iter().map(|d| d.runner).collect(); + + assert_eq!(runners, vec![Runner::VitePlus]); + } + #[test] fn detect_runners_deduplicates_case_variants() { let dir = tempdir().unwrap(); diff --git a/src/exec.rs b/src/exec.rs index e47b4ce..20b17ec 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -18,13 +18,7 @@ pub fn run( cwd: &Path, ) -> Result { let program = runner_command(runner).to_string(); - let mut args = Vec::new(); - if runner == Runner::CargoMake { - args.push("make".to_string()); - } - if runner == Runner::Mise { - args.push("run".to_string()); - } + let mut args = runner_prefix_args(runner); args.push(task.to_string()); args.extend(passthrough.iter().cloned()); @@ -67,8 +61,8 @@ pub fn base_command(runner: Runner) -> Result { let program = runner_command(runner); ensure_tool(program)?; let mut command = Command::new(program); - if runner == Runner::CargoMake { - command.arg("make"); + for arg in runner_prefix_args(runner) { + command.arg(arg); } Ok(command) } @@ -81,20 +75,22 @@ pub fn ensure_tool(tool: &'static str) -> Result<(), RtError> { } pub fn preview_command(runner: Runner, task: &str, passthrough: &[String]) -> String { - let mut parts = Vec::new(); let program = runner_command(runner); - if runner == Runner::CargoMake { - parts.push("make".to_string()); - } - if runner == Runner::Mise { - parts.push("run".to_string()); - } + let mut parts = runner_prefix_args(runner); parts.push(task.to_string()); parts.extend(passthrough.iter().cloned()); format_program_args(program, &parts) } +fn runner_prefix_args(runner: Runner) -> Vec { + match runner { + Runner::CargoMake => vec!["make".to_string()], + Runner::VitePlus | Runner::Mise => vec!["run".to_string()], + _ => Vec::new(), + } +} + pub fn format_program_args(program: &str, args: &[String]) -> String { let mut parts = Vec::new(); parts.push(program.to_string()); @@ -137,6 +133,24 @@ mod tests { assert_eq!(args, vec!["make".to_string()]); } + #[test] + fn runner_prefix_args_include_vite_plus_run_subcommand() { + assert_eq!( + runner_prefix_args(Runner::VitePlus), + vec!["run".to_string()] + ); + } + + #[test] + fn runner_prefix_args_include_runner_specific_prefixes() { + assert_eq!( + runner_prefix_args(Runner::CargoMake), + vec!["make".to_string()] + ); + assert_eq!(runner_prefix_args(Runner::Mise), vec!["run".to_string()]); + assert!(runner_prefix_args(Runner::Justfile).is_empty()); + } + #[test] fn ensure_tool_returns_error_for_missing_binary() { let err = ensure_tool("__rt_missing_tool_for_test__").unwrap_err(); @@ -172,6 +186,10 @@ mod tests { preview_command(Runner::Mise, "build", &[]), "mise run build" ); + assert_eq!( + preview_command(Runner::VitePlus, "build", &[]), + "vp run build" + ); assert_eq!( preview_command(Runner::CargoMake, "build", &[]), "cargo make build" diff --git a/src/parser.rs b/src/parser.rs index 09616f5..75f8163 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -7,6 +7,7 @@ mod makefile; mod mask; mod mise; mod taskfile; +mod vite_plus; /// Returns parsed tasks from the output of the given runner's list command. pub fn parse_tasks(runner: Runner, output: &str) -> Vec { @@ -14,6 +15,7 @@ pub fn parse_tasks(runner: Runner, output: &str) -> Vec { Runner::Justfile => justfile::parse(output), Runner::Taskfile => taskfile::parse(output), Runner::Maskfile => mask::parse(output), + Runner::VitePlus => vite_plus::parse(output), Runner::Mise => mise::parse(output), Runner::CargoMake => cargo_make::parse(output), Runner::Makefile => makefile::parse(output), diff --git a/src/parser/vite_plus.rs b/src/parser/vite_plus.rs new file mode 100644 index 0000000..f0cae53 --- /dev/null +++ b/src/parser/vite_plus.rs @@ -0,0 +1,60 @@ +use crate::tasks::TaskItem; + +pub(super) fn parse(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + return None; + } + + let (name, description) = line.split_once(':')?; + let name = name.trim(); + if name.is_empty() { + return None; + } + + let description = description.trim(); + Some(TaskItem { + name: name.to_string(), + description: (!description.is_empty()).then(|| description.to_string()), + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_vite_plus_task_list() { + let output = "\ + check: echo check root + app#build: echo build app +"; + let tasks = parse(output); + assert_eq!( + tasks, + vec![ + TaskItem { + name: "check".to_string(), + description: Some("echo check root".to_string()), + }, + TaskItem { + name: "app#build".to_string(), + description: Some("echo build app".to_string()), + }, + ] + ); + } + + #[test] + fn parse_vite_plus_ignores_blank_lines() { + let output = "\n\n build: echo build\n"; + let tasks = parse(output); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].name, "build"); + } +} diff --git a/src/tasks.rs b/src/tasks.rs index 456fc52..6d78e5a 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -142,6 +142,7 @@ fn list_command_variants(runner: Runner) -> Vec> { Runner::Justfile => vec![vec!["--list", "--unsorted"]], Runner::Taskfile => vec![vec!["--list-all"]], Runner::Maskfile => vec![vec!["--introspect"]], + Runner::VitePlus => vec![vec!["run"]], Runner::Mise => vec![vec!["tasks", "ls", "--json"]], Runner::CargoMake => vec![ vec!["make", "--list-all-steps"], @@ -190,4 +191,9 @@ mod tests { assert!(exact.is_some()); assert!(fuzzy.is_none()); } + + #[test] + fn list_command_variants_for_vite_plus_uses_run() { + assert_eq!(list_command_variants(Runner::VitePlus), vec![vec!["run"]]); + } }