diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ015.py b/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ015.py new file mode 100644 index 0000000000..349d312a67 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ015.py @@ -0,0 +1,48 @@ +from django.urls import path +from . import views + +# Errors - leading slash +urlpatterns = [ + path("/help/", views.help_view), # DJ015 + path("/about/", views.about_view), # DJ015 + path("/contact/", views.contact_view), # DJ015 + path("/api/users/", views.users_view), # DJ015 + path("/blog/posts/", views.posts_view), # DJ015 +] + +# OK - no leading slash +urlpatterns_ok = [ + path("help/", views.help_view), + path("about/", views.about_view), + path("contact/", views.contact_view), + path("api/users/", views.users_view), + path("blog/posts/", views.posts_view), +] + +# OK - just root path +urlpatterns_root = [ + path("/", views.index_view), + path("", views.home_view), +] + +# OK - with path parameters +urlpatterns_params = [ + path("users//", views.user_detail), + path("posts//", views.post_detail), +] + +# Mixed cases +urlpatterns_mixed = [ + path("good/", views.good_view), + path("/bad/", views.bad_view), # DJ015 + path("also-good/", views.also_good_view), + path("/also-bad/", views.also_bad_view), # DJ015 +] + +# Edge cases with different quote styles +urlpatterns_quotes = [ + path('/single-quote/', views.single_quote_view), # DJ015 + path("/double-quote/", views.double_quote_view), # DJ015 + path('''/triple-single/''', views.triple_single_view), # DJ015 + path("""/triple-double/""", views.triple_double_view), # DJ015 +] diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs index 78f1fd98f1..8024b7b27f 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs @@ -1184,6 +1184,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) { if checker.is_rule_enabled(Rule::DjangoURLPathWithoutTrailingSlash) { flake8_django::rules::url_path_without_trailing_slash(checker, call); } + if checker.is_rule_enabled(Rule::DjangoURLPathWithLeadingSlash) { + flake8_django::rules::url_path_with_leading_slash(checker, call); + } if checker.is_rule_enabled(Rule::UnsupportedMethodCallOnAll) { flake8_pyi::rules::unsupported_method_call_on_all(checker, func); } diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 44336e1f21..3e25d15f4c 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -1101,6 +1101,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Django, "012") => rules::flake8_django::rules::DjangoUnorderedBodyContentInModel, (Flake8Django, "013") => rules::flake8_django::rules::DjangoNonLeadingReceiverDecorator, (Flake8Django, "014") => rules::flake8_django::rules::DjangoURLPathWithoutTrailingSlash, + (Flake8Django, "015") => rules::flake8_django::rules::DjangoURLPathWithLeadingSlash, // flynt // Reserved: (Flynt, "001") => Rule: :StringConcatenationToFString, diff --git a/crates/ruff_linter/src/rules/flake8_django/mod.rs b/crates/ruff_linter/src/rules/flake8_django/mod.rs index d4c31a041b..43d3e82bce 100644 --- a/crates/ruff_linter/src/rules/flake8_django/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_django/mod.rs @@ -19,6 +19,7 @@ mod tests { #[test_case(Rule::DjangoAllWithModelForm, Path::new("DJ007.py"))] #[test_case(Rule::DjangoModelWithoutDunderStr, Path::new("DJ008.py"))] #[test_case(Rule::DjangoURLPathWithoutTrailingSlash, Path::new("DJ014.py"))] + #[test_case(Rule::DjangoURLPathWithLeadingSlash, Path::new("DJ015.py"))] #[test_case(Rule::DjangoUnorderedBodyContentInModel, Path::new("DJ012.py"))] #[test_case(Rule::DjangoNonLeadingReceiverDecorator, Path::new("DJ013.py"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { diff --git a/crates/ruff_linter/src/rules/flake8_django/rules/mod.rs b/crates/ruff_linter/src/rules/flake8_django/rules/mod.rs index 1b86fb9001..f2a52d7dce 100644 --- a/crates/ruff_linter/src/rules/flake8_django/rules/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_django/rules/mod.rs @@ -5,6 +5,7 @@ pub(crate) use model_without_dunder_str::*; pub(crate) use non_leading_receiver_decorator::*; pub(crate) use nullable_model_string_field::*; pub(crate) use unordered_body_content_in_model::*; +pub(crate) use url_path_with_leading_slash::*; pub(crate) use url_path_without_trailing_slash::*; mod all_with_model_form; @@ -14,4 +15,5 @@ mod model_without_dunder_str; mod non_leading_receiver_decorator; mod nullable_model_string_field; mod unordered_body_content_in_model; +mod url_path_with_leading_slash; mod url_path_without_trailing_slash; diff --git a/crates/ruff_linter/src/rules/flake8_django/rules/url_path_with_leading_slash.rs b/crates/ruff_linter/src/rules/flake8_django/rules/url_path_with_leading_slash.rs new file mode 100644 index 0000000000..df325f9bcf --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_django/rules/url_path_with_leading_slash.rs @@ -0,0 +1,121 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; +use ruff_python_ast::{self as ast, Expr}; +use ruff_python_semantic::Modules; +use ruff_text_size::{Ranged, TextSize}; + +use crate::checkers::ast::Checker; +use crate::{AlwaysFixableViolation, Edit, Fix}; + +/// ## What it does +/// Checks that all Django URL route definitions using `django.urls.path()` +/// do not start with a leading slash. +/// +/// ## Why is this bad? +/// Django's URL patterns should not start with a leading slash. When using +/// `include()` or when patterns are combined, leading slashes can cause +/// issues with URL resolution. The Django documentation recommends that +/// URL patterns should not have leading slashes, as they are not necessary +/// and can lead to unexpected behavior. +/// +/// ## Example +/// ```python +/// from django.urls import path +/// from . import views +/// +/// urlpatterns = [ +/// path("/help/", views.help_view), # Leading slash +/// path("/about/", views.about_view), # Leading slash +/// ] +/// ``` +/// +/// Use instead: +/// ```python +/// from django.urls import path +/// from . import views +/// +/// urlpatterns = [ +/// path("help/", views.help_view), +/// path("about/", views.about_view), +/// ] +/// ``` +/// +/// ## References +/// - [Django documentation: URL dispatcher](https://docs.djangoproject.com/en/stable/topics/http/urls/) +#[derive(ViolationMetadata)] +#[violation_metadata(preview_since = "v0.14.1")] +pub(crate) struct DjangoURLPathWithLeadingSlash { + url_pattern: String, +} + +impl AlwaysFixableViolation for DjangoURLPathWithLeadingSlash { + #[derive_message_formats] + fn message(&self) -> String { + let DjangoURLPathWithLeadingSlash { url_pattern } = self; + format!("URL route `{url_pattern}` has an unnecessary leading slash") + } + + fn fix_title(&self) -> String { + "Remove leading slash".to_string() + } +} + +/// DJ015 +pub(crate) fn url_path_with_leading_slash(checker: &Checker, call: &ast::ExprCall) { + if !checker.semantic().seen_module(Modules::DJANGO) { + return; + } + + // Check if this is a call to django.urls.path + if !checker + .semantic() + .resolve_qualified_name(&call.func) + .is_some_and(|qualified_name| { + matches!(qualified_name.segments(), ["django", "urls", "path"]) + }) + { + return; + } + + // Get the first argument (the route pattern) + let Some(route_arg) = call.arguments.args.first() else { + return; + }; + + // Check if it's a string literal + if let Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) = route_arg { + let route = value.to_str(); + + // Skip empty strings and root path "/" + if route.is_empty() || route == "/" { + return; + } + + // Check if route starts with a leading slash + if route.starts_with('/') { + // Report diagnostic for routes with leading slash + let mut diagnostic = checker.report_diagnostic( + DjangoURLPathWithLeadingSlash { + url_pattern: route.to_string(), + }, + route_arg.range(), + ); + + // Determine the quote style to find the insertion point for removal + let string_content = checker.locator().slice(route_arg.range()); + let quote_len = + if string_content.starts_with("'''") || string_content.starts_with("\"\"\"") { + 3 + } else if string_content.starts_with('\'') || string_content.starts_with('"') { + 1 + } else { + return; // Invalid string format + }; + + // Remove the leading slash (after the opening quote(s)) + let removal_start = route_arg.range().start() + TextSize::new(quote_len); + let removal_end = removal_start + TextSize::new(1); // Remove one character (the slash) + + diagnostic.set_fix(Fix::safe_edit(Edit::deletion(removal_start, removal_end))); + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ015_DJ015.py.snap b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ015_DJ015.py.snap new file mode 100644 index 0000000000..1293b0008b --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ015_DJ015.py.snap @@ -0,0 +1,216 @@ +--- +source: crates/ruff_linter/src/rules/flake8_django/mod.rs +--- +DJ015 [*] URL route `/help/` has an unnecessary leading slash + --> DJ015.py:6:10 + | +4 | # Errors - leading slash +5 | urlpatterns = [ +6 | path("/help/", views.help_view), # DJ015 + | ^^^^^^^^ +7 | path("/about/", views.about_view), # DJ015 +8 | path("/contact/", views.contact_view), # DJ015 + | +help: Remove leading slash +3 | +4 | # Errors - leading slash +5 | urlpatterns = [ + - path("/help/", views.help_view), # DJ015 +6 + path("help/", views.help_view), # DJ015 +7 | path("/about/", views.about_view), # DJ015 +8 | path("/contact/", views.contact_view), # DJ015 +9 | path("/api/users/", views.users_view), # DJ015 + +DJ015 [*] URL route `/about/` has an unnecessary leading slash + --> DJ015.py:7:10 + | +5 | urlpatterns = [ +6 | path("/help/", views.help_view), # DJ015 +7 | path("/about/", views.about_view), # DJ015 + | ^^^^^^^^^ +8 | path("/contact/", views.contact_view), # DJ015 +9 | path("/api/users/", views.users_view), # DJ015 + | +help: Remove leading slash +4 | # Errors - leading slash +5 | urlpatterns = [ +6 | path("/help/", views.help_view), # DJ015 + - path("/about/", views.about_view), # DJ015 +7 + path("about/", views.about_view), # DJ015 +8 | path("/contact/", views.contact_view), # DJ015 +9 | path("/api/users/", views.users_view), # DJ015 +10 | path("/blog/posts/", views.posts_view), # DJ015 + +DJ015 [*] URL route `/contact/` has an unnecessary leading slash + --> DJ015.py:8:10 + | + 6 | path("/help/", views.help_view), # DJ015 + 7 | path("/about/", views.about_view), # DJ015 + 8 | path("/contact/", views.contact_view), # DJ015 + | ^^^^^^^^^^^ + 9 | path("/api/users/", views.users_view), # DJ015 +10 | path("/blog/posts/", views.posts_view), # DJ015 + | +help: Remove leading slash +5 | urlpatterns = [ +6 | path("/help/", views.help_view), # DJ015 +7 | path("/about/", views.about_view), # DJ015 + - path("/contact/", views.contact_view), # DJ015 +8 + path("contact/", views.contact_view), # DJ015 +9 | path("/api/users/", views.users_view), # DJ015 +10 | path("/blog/posts/", views.posts_view), # DJ015 +11 | ] + +DJ015 [*] URL route `/api/users/` has an unnecessary leading slash + --> DJ015.py:9:10 + | + 7 | path("/about/", views.about_view), # DJ015 + 8 | path("/contact/", views.contact_view), # DJ015 + 9 | path("/api/users/", views.users_view), # DJ015 + | ^^^^^^^^^^^^^ +10 | path("/blog/posts/", views.posts_view), # DJ015 +11 | ] + | +help: Remove leading slash +6 | path("/help/", views.help_view), # DJ015 +7 | path("/about/", views.about_view), # DJ015 +8 | path("/contact/", views.contact_view), # DJ015 + - path("/api/users/", views.users_view), # DJ015 +9 + path("api/users/", views.users_view), # DJ015 +10 | path("/blog/posts/", views.posts_view), # DJ015 +11 | ] +12 | + +DJ015 [*] URL route `/blog/posts/` has an unnecessary leading slash + --> DJ015.py:10:10 + | + 8 | path("/contact/", views.contact_view), # DJ015 + 9 | path("/api/users/", views.users_view), # DJ015 +10 | path("/blog/posts/", views.posts_view), # DJ015 + | ^^^^^^^^^^^^^^ +11 | ] + | +help: Remove leading slash +7 | path("/about/", views.about_view), # DJ015 +8 | path("/contact/", views.contact_view), # DJ015 +9 | path("/api/users/", views.users_view), # DJ015 + - path("/blog/posts/", views.posts_view), # DJ015 +10 + path("blog/posts/", views.posts_view), # DJ015 +11 | ] +12 | +13 | # OK - no leading slash + +DJ015 [*] URL route `/bad/` has an unnecessary leading slash + --> DJ015.py:37:10 + | +35 | urlpatterns_mixed = [ +36 | path("good/", views.good_view), +37 | path("/bad/", views.bad_view), # DJ015 + | ^^^^^^^ +38 | path("also-good/", views.also_good_view), +39 | path("/also-bad/", views.also_bad_view), # DJ015 + | +help: Remove leading slash +34 | # Mixed cases +35 | urlpatterns_mixed = [ +36 | path("good/", views.good_view), + - path("/bad/", views.bad_view), # DJ015 +37 + path("bad/", views.bad_view), # DJ015 +38 | path("also-good/", views.also_good_view), +39 | path("/also-bad/", views.also_bad_view), # DJ015 +40 | ] + +DJ015 [*] URL route `/also-bad/` has an unnecessary leading slash + --> DJ015.py:39:10 + | +37 | path("/bad/", views.bad_view), # DJ015 +38 | path("also-good/", views.also_good_view), +39 | path("/also-bad/", views.also_bad_view), # DJ015 + | ^^^^^^^^^^^^ +40 | ] + | +help: Remove leading slash +36 | path("good/", views.good_view), +37 | path("/bad/", views.bad_view), # DJ015 +38 | path("also-good/", views.also_good_view), + - path("/also-bad/", views.also_bad_view), # DJ015 +39 + path("also-bad/", views.also_bad_view), # DJ015 +40 | ] +41 | +42 | # Edge cases with different quote styles + +DJ015 [*] URL route `/single-quote/` has an unnecessary leading slash + --> DJ015.py:44:10 + | +42 | # Edge cases with different quote styles +43 | urlpatterns_quotes = [ +44 | path('/single-quote/', views.single_quote_view), # DJ015 + | ^^^^^^^^^^^^^^^^ +45 | path("/double-quote/", views.double_quote_view), # DJ015 +46 | path('''/triple-single/''', views.triple_single_view), # DJ015 + | +help: Remove leading slash +41 | +42 | # Edge cases with different quote styles +43 | urlpatterns_quotes = [ + - path('/single-quote/', views.single_quote_view), # DJ015 +44 + path('single-quote/', views.single_quote_view), # DJ015 +45 | path("/double-quote/", views.double_quote_view), # DJ015 +46 | path('''/triple-single/''', views.triple_single_view), # DJ015 +47 | path("""/triple-double/""", views.triple_double_view), # DJ015 + +DJ015 [*] URL route `/double-quote/` has an unnecessary leading slash + --> DJ015.py:45:10 + | +43 | urlpatterns_quotes = [ +44 | path('/single-quote/', views.single_quote_view), # DJ015 +45 | path("/double-quote/", views.double_quote_view), # DJ015 + | ^^^^^^^^^^^^^^^^ +46 | path('''/triple-single/''', views.triple_single_view), # DJ015 +47 | path("""/triple-double/""", views.triple_double_view), # DJ015 + | +help: Remove leading slash +42 | # Edge cases with different quote styles +43 | urlpatterns_quotes = [ +44 | path('/single-quote/', views.single_quote_view), # DJ015 + - path("/double-quote/", views.double_quote_view), # DJ015 +45 + path("double-quote/", views.double_quote_view), # DJ015 +46 | path('''/triple-single/''', views.triple_single_view), # DJ015 +47 | path("""/triple-double/""", views.triple_double_view), # DJ015 +48 | ] + +DJ015 [*] URL route `/triple-single/` has an unnecessary leading slash + --> DJ015.py:46:10 + | +44 | path('/single-quote/', views.single_quote_view), # DJ015 +45 | path("/double-quote/", views.double_quote_view), # DJ015 +46 | path('''/triple-single/''', views.triple_single_view), # DJ015 + | ^^^^^^^^^^^^^^^^^^^^^ +47 | path("""/triple-double/""", views.triple_double_view), # DJ015 +48 | ] + | +help: Remove leading slash +43 | urlpatterns_quotes = [ +44 | path('/single-quote/', views.single_quote_view), # DJ015 +45 | path("/double-quote/", views.double_quote_view), # DJ015 + - path('''/triple-single/''', views.triple_single_view), # DJ015 +46 + path('''triple-single/''', views.triple_single_view), # DJ015 +47 | path("""/triple-double/""", views.triple_double_view), # DJ015 +48 | ] + +DJ015 [*] URL route `/triple-double/` has an unnecessary leading slash + --> DJ015.py:47:10 + | +45 | path("/double-quote/", views.double_quote_view), # DJ015 +46 | path('''/triple-single/''', views.triple_single_view), # DJ015 +47 | path("""/triple-double/""", views.triple_double_view), # DJ015 + | ^^^^^^^^^^^^^^^^^^^^^ +48 | ] + | +help: Remove leading slash +44 | path('/single-quote/', views.single_quote_view), # DJ015 +45 | path("/double-quote/", views.double_quote_view), # DJ015 +46 | path('''/triple-single/''', views.triple_single_view), # DJ015 + - path("""/triple-double/""", views.triple_double_view), # DJ015 +47 + path("""triple-double/""", views.triple_double_view), # DJ015 +48 | ] diff --git a/ruff.schema.json b/ruff.schema.json index 8bd0d00cf1..fa709fe329 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -3158,6 +3158,7 @@ "DJ012", "DJ013", "DJ014", + "DJ015", "DOC", "DOC1", "DOC10",