diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_executable/EXE003_uv.py b/crates/ruff_linter/resources/test/fixtures/flake8_executable/EXE003_uv.py index 30c64dc45b750..7b112690ae3fd 100755 --- a/crates/ruff_linter/resources/test/fixtures/flake8_executable/EXE003_uv.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_executable/EXE003_uv.py @@ -1,3 +1,23 @@ #!/usr/bin/env -S uv run print("hello world") +#!/usr/bin/env uv --offline run +print("offline") + +#!/usr/bin/env uv --color=auto run +print("color") + +#!/usr/bin/env uv --quiet run --script +print("quiet run script") + +#!/usr/bin/env uv tool run +print("uv tool") + +#!/usr/bin/env uvx +print("uvx") + +#!/usr/bin/env uvx --quiet +print("uvx quiet") + +#!/usr/bin/env uv_not_really_run +print("this should fail") diff --git a/crates/ruff_linter/src/rules/flake8_executable/mod.rs b/crates/ruff_linter/src/rules/flake8_executable/mod.rs index 765810d4103b7..76e79aefa58e7 100644 --- a/crates/ruff_linter/src/rules/flake8_executable/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_executable/mod.rs @@ -14,24 +14,24 @@ mod tests { use crate::test::test_path; use crate::{assert_diagnostics, settings}; - #[test_case(Path::new("EXE001_1.py"))] - #[test_case(Path::new("EXE001_2.py"))] - #[test_case(Path::new("EXE001_3.py"))] - #[test_case(Path::new("EXE002_1.py"))] - #[test_case(Path::new("EXE002_2.py"))] - #[test_case(Path::new("EXE002_3.py"))] - #[test_case(Path::new("EXE003.py"))] - #[test_case(Path::new("EXE003_uv.py"))] - #[test_case(Path::new("EXE003_uv_tool.py"))] - #[test_case(Path::new("EXE003_uvx.py"))] - #[test_case(Path::new("EXE004_1.py"))] - #[test_case(Path::new("EXE004_2.py"))] - #[test_case(Path::new("EXE004_3.py"))] - #[test_case(Path::new("EXE004_4.py"))] - #[test_case(Path::new("EXE005_1.py"))] - #[test_case(Path::new("EXE005_2.py"))] - #[test_case(Path::new("EXE005_3.py"))] - fn rules(path: &Path) -> Result<()> { + #[test_case(Rule::ShebangNotExecutable, Path::new("EXE001_1.py"))] + #[test_case(Rule::ShebangNotExecutable, Path::new("EXE001_2.py"))] + #[test_case(Rule::ShebangNotExecutable, Path::new("EXE001_3.py"))] + #[test_case(Rule::ShebangMissingExecutableFile, Path::new("EXE002_1.py"))] + #[test_case(Rule::ShebangMissingExecutableFile, Path::new("EXE002_2.py"))] + #[test_case(Rule::ShebangMissingExecutableFile, Path::new("EXE002_3.py"))] + #[test_case(Rule::ShebangMissingPython, Path::new("EXE003.py"))] + #[test_case(Rule::ShebangMissingPython, Path::new("EXE003_uv.py"))] + #[test_case(Rule::ShebangMissingPython, Path::new("EXE003_uv_tool.py"))] + #[test_case(Rule::ShebangMissingPython, Path::new("EXE003_uvx.py"))] + #[test_case(Rule::ShebangLeadingWhitespace, Path::new("EXE004_1.py"))] + #[test_case(Rule::ShebangLeadingWhitespace, Path::new("EXE004_2.py"))] + #[test_case(Rule::ShebangLeadingWhitespace, Path::new("EXE004_3.py"))] + #[test_case(Rule::ShebangLeadingWhitespace, Path::new("EXE004_4.py"))] + #[test_case(Rule::ShebangNotFirstLine, Path::new("EXE005_1.py"))] + #[test_case(Rule::ShebangNotFirstLine, Path::new("EXE005_2.py"))] + #[test_case(Rule::ShebangNotFirstLine, Path::new("EXE005_3.py"))] + fn rules(rule: Rule, path: &Path) -> Result<()> { if is_wsl::is_wsl() { // these rules are always ignored on WSL, so skip testing them in a WSL environment // see https://github.com/astral-sh/ruff/pull/21724 for latest discussion @@ -41,13 +41,7 @@ mod tests { let snapshot = path.to_string_lossy().into_owned(); let diagnostics = test_path( Path::new("flake8_executable").join(path).as_path(), - &settings::LinterSettings::for_rules(vec![ - Rule::ShebangNotExecutable, - Rule::ShebangMissingExecutableFile, - Rule::ShebangLeadingWhitespace, - Rule::ShebangNotFirstLine, - Rule::ShebangMissingPython, - ]), + &settings::LinterSettings::for_rule(rule), )?; assert_diagnostics!(snapshot, diagnostics); Ok(()) diff --git a/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_missing_python.rs b/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_missing_python.rs index 968e5340cae9d..b3666c21c6413 100644 --- a/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_missing_python.rs +++ b/crates/ruff_linter/src/rules/flake8_executable/rules/shebang_missing_python.rs @@ -1,11 +1,30 @@ -use ruff_text_size::TextRange; +use std::sync::LazyLock; +use regex::Regex; use ruff_macros::{ViolationMetadata, derive_message_formats}; +use ruff_text_size::TextRange; use crate::Violation; use crate::checkers::ast::LintContext; use crate::comments::shebang::ShebangDirective; +static UV_RUN_REGEX: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?x) + \b + (?: + # Part A: uv or uv tool (these MUST be followed by run) + (?:uv|uv\s+tool) \s+ (?:--?[a-zA-Z][\w-]*(?:[=\s]\S+)?\s+)* run + | + # Part B: uvx (stands alone, run is optional/redundant) + uvx (?: \s+ .* )? + ) + \b + ", + ) + .unwrap() +}); + /// ## What it does /// Checks for a shebang directive in `.py` files that does not contain `python`, /// `pytest`, or `uv run`. @@ -48,12 +67,7 @@ pub(crate) fn shebang_missing_python( shebang: &ShebangDirective, context: &LintContext, ) { - if shebang.contains("python") - || shebang.contains("pytest") - || shebang.contains("uv run") - || shebang.contains("uvx") - || shebang.contains("uv tool run") - { + if shebang.contains("python") || shebang.contains("pytest") || UV_RUN_REGEX.is_match(shebang) { return; } diff --git a/crates/ruff_linter/src/rules/flake8_executable/snapshots/ruff_linter__rules__flake8_executable__tests__EXE003_uv.py.snap b/crates/ruff_linter/src/rules/flake8_executable/snapshots/ruff_linter__rules__flake8_executable__tests__EXE003_uv.py.snap index 26e075ae3eb6e..6ac799eb3f8b7 100644 --- a/crates/ruff_linter/src/rules/flake8_executable/snapshots/ruff_linter__rules__flake8_executable__tests__EXE003_uv.py.snap +++ b/crates/ruff_linter/src/rules/flake8_executable/snapshots/ruff_linter__rules__flake8_executable__tests__EXE003_uv.py.snap @@ -1,4 +1,12 @@ --- source: crates/ruff_linter/src/rules/flake8_executable/mod.rs --- - +EXE003 Shebang should contain `python`, `pytest`, or `uv run` + --> EXE003_uv.py:22:1 + | +20 | print("uvx quiet") +21 | +22 | #!/usr/bin/env uv_not_really_run + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +23 | print("this should fail") + | diff --git a/crates/ruff_linter/src/rules/flake8_executable/snapshots/ruff_linter__rules__flake8_executable__tests__EXE004_3.py.snap b/crates/ruff_linter/src/rules/flake8_executable/snapshots/ruff_linter__rules__flake8_executable__tests__EXE004_3.py.snap index 14bb184162e14..26e075ae3eb6e 100644 --- a/crates/ruff_linter/src/rules/flake8_executable/snapshots/ruff_linter__rules__flake8_executable__tests__EXE004_3.py.snap +++ b/crates/ruff_linter/src/rules/flake8_executable/snapshots/ruff_linter__rules__flake8_executable__tests__EXE004_3.py.snap @@ -1,9 +1,4 @@ --- source: crates/ruff_linter/src/rules/flake8_executable/mod.rs --- -EXE005 Shebang should be at the beginning of the file - --> EXE004_3.py:2:7 - | -2 | pass #!/usr/bin/env python - | ^^^^^^^^^^^^^^^^^^^^^ - | +