From a2fcf0eb3ffa558ac156efd9838415375696e804 Mon Sep 17 00:00:00 2001 From: Jonas Vacek Date: Wed, 29 Oct 2025 19:55:24 +0100 Subject: [PATCH] implement DJ014 --- .../test/fixtures/flake8_django/DJ014.py | 40 +++++++ .../src/checkers/ast/analyze/expression.rs | 3 + crates/ruff_linter/src/codes.rs | 1 + .../src/rules/flake8_django/mod.rs | 1 + .../src/rules/flake8_django/rules/mod.rs | 2 + .../rules/url_path_without_trailing_slash.rs | 104 ++++++++++++++++++ ..._flake8_django__tests__DJ014_DJ014.py.snap | 77 +++++++++++++ ruff.schema.json | 1 + 8 files changed, 229 insertions(+) create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_django/DJ014.py create mode 100644 crates/ruff_linter/src/rules/flake8_django/rules/url_path_without_trailing_slash.rs create mode 100644 crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ014_DJ014.py.snap diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ014.py b/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ014.py new file mode 100644 index 0000000000..d66e64f4b8 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ014.py @@ -0,0 +1,40 @@ +from django.urls import path +from . import views + +# Errors - missing trailing slash +urlpatterns = [ + path("help", views.help_view), # DJ014 + path("about", views.about_view), # DJ014 + path("contact", views.contact_view), # DJ014 + path("api/users", views.users_view), # DJ014 + path("blog/posts", views.posts_view), # DJ014 +] + +# OK - has trailing 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), # DJ014 + path("also-good/", views.also_good_view), + path("also-bad", views.also_bad_view), # DJ014 +] diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs index 53081e3681..78f1fd98f1 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs @@ -1181,6 +1181,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) { if checker.is_rule_enabled(Rule::DjangoLocalsInRenderFunction) { flake8_django::rules::locals_in_render_function(checker, call); } + if checker.is_rule_enabled(Rule::DjangoURLPathWithoutTrailingSlash) { + flake8_django::rules::url_path_without_trailing_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 172841dc7c..44336e1f21 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -1100,6 +1100,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Django, "008") => rules::flake8_django::rules::DjangoModelWithoutDunderStr, (Flake8Django, "012") => rules::flake8_django::rules::DjangoUnorderedBodyContentInModel, (Flake8Django, "013") => rules::flake8_django::rules::DjangoNonLeadingReceiverDecorator, + (Flake8Django, "014") => rules::flake8_django::rules::DjangoURLPathWithoutTrailingSlash, // 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 8be904a8e0..d4c31a041b 100644 --- a/crates/ruff_linter/src/rules/flake8_django/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_django/mod.rs @@ -18,6 +18,7 @@ mod tests { #[test_case(Rule::DjangoExcludeWithModelForm, Path::new("DJ006.py"))] #[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::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 afdb7bdc18..1b86fb9001 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_without_trailing_slash::*; mod all_with_model_form; mod exclude_with_model_form; @@ -13,3 +14,4 @@ mod model_without_dunder_str; mod non_leading_receiver_decorator; mod nullable_model_string_field; mod unordered_body_content_in_model; +mod url_path_without_trailing_slash; diff --git a/crates/ruff_linter/src/rules/flake8_django/rules/url_path_without_trailing_slash.rs b/crates/ruff_linter/src/rules/flake8_django/rules/url_path_without_trailing_slash.rs new file mode 100644 index 0000000000..01f409f472 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_django/rules/url_path_without_trailing_slash.rs @@ -0,0 +1,104 @@ +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; + +use crate::Violation; +use crate::checkers::ast::Checker; + +/// ## What it does +/// Checks that all Django URL route definitions using `django.urls.path()` +/// end with a trailing slash. +/// +/// ## Why is this bad? +/// Django's convention is to use trailing slashes in URL patterns. This is +/// enforced by the `APPEND_SLASH` setting (enabled by default), which +/// redirects requests without trailing slashes to URLs with them. Omitting +/// the trailing slash can lead to unnecessary redirects and inconsistent URL +/// patterns throughout your application. +/// +/// ## Example +/// ```python +/// from django.urls import path +/// from . import views +/// +/// urlpatterns = [ +/// path("help", views.help_view), # Missing trailing slash +/// path("about", views.about_view), # Missing trailing 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 DjangoURLPathWithoutTrailingSlash { + url_pattern: String, +} + +impl Violation for DjangoURLPathWithoutTrailingSlash { + #[derive_message_formats] + fn message(&self) -> String { + let DjangoURLPathWithoutTrailingSlash { url_pattern } = self; + format!("URL route `{url_pattern}` is missing a trailing slash") + } +} + +/// DJ014 +pub(crate) fn url_path_without_trailing_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 + if route.is_empty() { + return; + } + + // Skip route parameters (routes with angle brackets like "") + // These are often at the end and shouldn't require trailing slashes + // Also skip routes that are just "/" or already end with "/" + if route == "/" || route.ends_with('/') { + return; + } + + // Report diagnostic for routes without trailing slash + checker.report_diagnostic( + DjangoURLPathWithoutTrailingSlash { + url_pattern: route.to_string(), + }, + route_arg.range(), + ); + } +} diff --git a/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ014_DJ014.py.snap b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ014_DJ014.py.snap new file mode 100644 index 0000000000..560bcd78ec --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ014_DJ014.py.snap @@ -0,0 +1,77 @@ +--- +source: crates/ruff_linter/src/rules/flake8_django/mod.rs +--- +DJ014 URL route `help` is missing a trailing slash + --> DJ014.py:6:10 + | +4 | # Errors - missing trailing slash +5 | urlpatterns = [ +6 | path("help", views.help_view), # DJ014 + | ^^^^^^ +7 | path("about", views.about_view), # DJ014 +8 | path("contact", views.contact_view), # DJ014 + | + +DJ014 URL route `about` is missing a trailing slash + --> DJ014.py:7:10 + | +5 | urlpatterns = [ +6 | path("help", views.help_view), # DJ014 +7 | path("about", views.about_view), # DJ014 + | ^^^^^^^ +8 | path("contact", views.contact_view), # DJ014 +9 | path("api/users", views.users_view), # DJ014 + | + +DJ014 URL route `contact` is missing a trailing slash + --> DJ014.py:8:10 + | + 6 | path("help", views.help_view), # DJ014 + 7 | path("about", views.about_view), # DJ014 + 8 | path("contact", views.contact_view), # DJ014 + | ^^^^^^^^^ + 9 | path("api/users", views.users_view), # DJ014 +10 | path("blog/posts", views.posts_view), # DJ014 + | + +DJ014 URL route `api/users` is missing a trailing slash + --> DJ014.py:9:10 + | + 7 | path("about", views.about_view), # DJ014 + 8 | path("contact", views.contact_view), # DJ014 + 9 | path("api/users", views.users_view), # DJ014 + | ^^^^^^^^^^^ +10 | path("blog/posts", views.posts_view), # DJ014 +11 | ] + | + +DJ014 URL route `blog/posts` is missing a trailing slash + --> DJ014.py:10:10 + | + 8 | path("contact", views.contact_view), # DJ014 + 9 | path("api/users", views.users_view), # DJ014 +10 | path("blog/posts", views.posts_view), # DJ014 + | ^^^^^^^^^^^^ +11 | ] + | + +DJ014 URL route `bad` is missing a trailing slash + --> DJ014.py:37:10 + | +35 | urlpatterns_mixed = [ +36 | path("good/", views.good_view), +37 | path("bad", views.bad_view), # DJ014 + | ^^^^^ +38 | path("also-good/", views.also_good_view), +39 | path("also-bad", views.also_bad_view), # DJ014 + | + +DJ014 URL route `also-bad` is missing a trailing slash + --> DJ014.py:39:10 + | +37 | path("bad", views.bad_view), # DJ014 +38 | path("also-good/", views.also_good_view), +39 | path("also-bad", views.also_bad_view), # DJ014 + | ^^^^^^^^^^ +40 | ] + | diff --git a/ruff.schema.json b/ruff.schema.json index a16e91fbd7..8bd0d00cf1 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -3157,6 +3157,7 @@ "DJ01", "DJ012", "DJ013", + "DJ014", "DOC", "DOC1", "DOC10",