From 4d3b7f60bae9c25643e5524d8a8bd3283934245a Mon Sep 17 00:00:00 2001 From: insign <1113045+insign@users.noreply.github.com> Date: Tue, 10 Mar 2026 03:26:11 +0000 Subject: [PATCH] feat(python): add rye detector and script parsing support --- src/detectors/mod.rs | 30 ++++++++++++++ src/detectors/python.rs | 90 ++++++++++++++++++++++++++++++++++++++++- src/main.rs | 14 +++---- 3 files changed, 124 insertions(+), 10 deletions(-) diff --git a/src/detectors/mod.rs b/src/detectors/mod.rs index c252dd5..a8f1860 100644 --- a/src/detectors/mod.rs +++ b/src/detectors/mod.rs @@ -193,6 +193,29 @@ const PIP_BUILTINS: &[&str] = &[ "help", ]; +const RYE_BUILTINS: &[&str] = &[ + "add", + "build", + "config", + "fetch", + "fmt", + "init", + "install", + "lint", + "lock", + "make-req", + "pin", + "publish", + "remove", + "run", + "show", + "sync", + "test", + "toolchain", + "uninstall", + "version", +]; + /// Indicates if a command is supported by a runner #[derive(Debug, Clone, Copy, PartialEq)] pub enum CommandSupport { @@ -384,6 +407,13 @@ impl DetectedRunner { } // Python ecosystem + "rye" => { + if RYE_BUILTINS.contains(&task) { + vec!["rye".to_string(), task.to_string()] + } else { + vec!["rye".to_string(), "run".to_string(), task.to_string()] + } + } "uv" => vec!["uv".to_string(), "run".to_string(), task.to_string()], "poetry" => vec!["poetry".to_string(), "run".to_string(), task.to_string()], "pipenv" => vec!["pipenv".to_string(), "run".to_string(), task.to_string()], diff --git a/src/detectors/python.rs b/src/detectors/python.rs index 95e041d..abf9200 100644 --- a/src/detectors/python.rs +++ b/src/detectors/python.rs @@ -56,6 +56,18 @@ impl CommandValidator for PythonValidator { } } + // Check [tool.rye.scripts] (Rye custom scripts) + if let Some(scripts) = toml_value + .get("tool") + .and_then(|t| t.get("rye")) + .and_then(|r| r.get("scripts")) + .and_then(|s| s.as_table()) + { + if scripts.contains_key(command) { + return CommandSupport::Supported; + } + } + // Python is extensible - uv run / poetry run can also execute // commands from the virtual environment (pytest, mypy, etc.) // So we return Unknown to allow fallback behavior @@ -64,13 +76,36 @@ impl CommandValidator for PythonValidator { } /// Detect Python package managers -/// Priority: UV (5) > Poetry (6) > Pipenv (7) > Pip (8) +/// Priority: Rye (5) > UV (5) > Poetry (6) > Pipenv (7) > Pip (8) pub fn detect(dir: &Path) -> Vec { let mut runners = Vec::new(); - let has_pyproject = dir.join("pyproject.toml").exists(); + let pyproject = dir.join("pyproject.toml"); + let has_pyproject = pyproject.exists(); let validator: Arc = Arc::new(PythonValidator); + // Check for Rye (priority 5) + if has_pyproject { + if let Ok(content) = fs::read_to_string(&pyproject) { + if let Ok(toml_value) = toml::from_str::(&content) { + if toml_value.get("tool").and_then(|t| t.get("rye")).is_some() { + let lockfile = if dir.join("requirements.lock").exists() { + "requirements.lock" + } else { + "pyproject.toml" + }; + runners.push(DetectedRunner::with_validator( + "rye", + lockfile, + Ecosystem::Python, + 5, + Arc::clone(&validator), + )); + } + } + } + } + // Check for UV (priority 5) let uv_lock = dir.join("uv.lock"); if uv_lock.exists() && has_pyproject { @@ -139,6 +174,25 @@ mod tests { use std::io::Write; use tempfile::tempdir; + #[test] + fn test_detect_rye() { + let dir = tempdir().unwrap(); + let mut file = File::create(dir.path().join("pyproject.toml")).unwrap(); + writeln!( + file, + r#" +[tool.rye] +managed = true +"# + ) + .unwrap(); + + let runners = detect(dir.path()); + assert_eq!(runners.len(), 1); + assert_eq!(runners[0].name, "rye"); + assert_eq!(runners[0].detected_file, "pyproject.toml"); + } + #[test] fn test_detect_uv() { let dir = tempdir().unwrap(); @@ -239,6 +293,38 @@ serve = "example.server:run" ); } + #[test] + fn test_python_validator_rye_scripts() { + let dir = tempdir().unwrap(); + let mut file = File::create(dir.path().join("pyproject.toml")).unwrap(); + writeln!( + file, + r#" +[tool.rye] +managed = true + +[tool.rye.scripts] +fmt = "rye run black ." +serve = "python -m http.server" +"# + ) + .unwrap(); + + let validator = PythonValidator; + assert_eq!( + validator.supports_command(dir.path(), "fmt"), + CommandSupport::Supported + ); + assert_eq!( + validator.supports_command(dir.path(), "serve"), + CommandSupport::Supported + ); + assert_eq!( + validator.supports_command(dir.path(), "unknown"), + CommandSupport::Unknown + ); + } + #[test] fn test_python_validator_poetry_scripts() { let dir = tempdir().unwrap(); diff --git a/src/main.rs b/src/main.rs index ee23013..842f4a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -96,12 +96,7 @@ fn main() { }; // Search for runners - let search_result = search_runners( - ¤t_dir, - max_levels, - &ignore_list, - verbose, - ); + let search_result = search_runners(¤t_dir, max_levels, &ignore_list, verbose); // Prepare to inject custom commands // Filter empty commands @@ -115,7 +110,7 @@ fn main() { let has_valid_commands = valid_config_commands .as_ref() - .map_or(false, |c| !c.is_empty()); + .is_some_and(|c| !c.is_empty()); let (mut runners, working_dir) = match search_result { Ok(result) => result, @@ -135,7 +130,10 @@ fn main() { if let Some(valid_config_commands) = valid_config_commands { if !valid_config_commands.is_empty() { // Check if we already have a custom runner - if let Some(idx) = runners.iter().position(|r| r.ecosystem == Ecosystem::Custom) { + if let Some(idx) = runners + .iter() + .position(|r| r.ecosystem == Ecosystem::Custom) + { // Merge config commands into existing runner (local overrides global) let mut merged_commands = valid_config_commands.clone(); if let Some(existing_cmds) = &runners[idx].custom_commands {