diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap index ce6ae89c1a..c00b290bd4 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_extend_from_shared_config.snap @@ -179,6 +179,7 @@ linter.flake8_self.ignore_names = [ _name_, _value_, ] +linter.flake8_django.additional_path_functions = [] linter.flake8_tidy_imports.ban_relative_imports = "parents" linter.flake8_tidy_imports.banned_api = {} linter.flake8_tidy_imports.banned_module_level_imports = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap index a1236947bd..f7045b31b6 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool.snap @@ -181,6 +181,7 @@ linter.flake8_self.ignore_names = [ _name_, _value_, ] +linter.flake8_django.additional_path_functions = [] linter.flake8_tidy_imports.ban_relative_imports = "parents" linter.flake8_tidy_imports.banned_api = {} linter.flake8_tidy_imports.banned_module_level_imports = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap index 5868ceb04f..1fb40f7b5d 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_preview_enabled.snap @@ -183,6 +183,7 @@ linter.flake8_self.ignore_names = [ _name_, _value_, ] +linter.flake8_django.additional_path_functions = [] linter.flake8_tidy_imports.ban_relative_imports = "parents" linter.flake8_tidy_imports.banned_api = {} linter.flake8_tidy_imports.banned_module_level_imports = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap index 726abc733e..f2f05ffc9f 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_no_tool_target_version_override.snap @@ -183,6 +183,7 @@ linter.flake8_self.ignore_names = [ _name_, _value_, ] +linter.flake8_django.additional_path_functions = [] linter.flake8_tidy_imports.ban_relative_imports = "parents" linter.flake8_tidy_imports.banned_api = {} linter.flake8_tidy_imports.banned_module_level_imports = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap index db8a289004..c83271706c 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above.snap @@ -180,6 +180,7 @@ linter.flake8_self.ignore_names = [ _name_, _value_, ] +linter.flake8_django.additional_path_functions = [] linter.flake8_tidy_imports.ban_relative_imports = "parents" linter.flake8_tidy_imports.banned_api = {} linter.flake8_tidy_imports.banned_module_level_imports = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap index 193aac85f3..dfcf905e11 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_pyproject_toml_above_with_tool.snap @@ -180,6 +180,7 @@ linter.flake8_self.ignore_names = [ _name_, _value_, ] +linter.flake8_django.additional_path_functions = [] linter.flake8_tidy_imports.ban_relative_imports = "parents" linter.flake8_tidy_imports.banned_api = {} linter.flake8_tidy_imports.banned_module_level_imports = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap index e7914052c3..c98c5e562e 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above-2.snap @@ -179,6 +179,7 @@ linter.flake8_self.ignore_names = [ _name_, _value_, ] +linter.flake8_django.additional_path_functions = [] linter.flake8_tidy_imports.ban_relative_imports = "parents" linter.flake8_tidy_imports.banned_api = {} linter.flake8_tidy_imports.banned_module_level_imports = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap index 76a57bae28..4e082a6592 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_above.snap @@ -179,6 +179,7 @@ linter.flake8_self.ignore_names = [ _name_, _value_, ] +linter.flake8_django.additional_path_functions = [] linter.flake8_tidy_imports.ban_relative_imports = "parents" linter.flake8_tidy_imports.banned_api = {} linter.flake8_tidy_imports.banned_module_level_imports = [] diff --git a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap index ecdc9bfc62..adea7b7ab8 100644 --- a/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap +++ b/crates/ruff/tests/cli/snapshots/cli__lint__requires_python_ruff_toml_no_target_fallback.snap @@ -179,6 +179,7 @@ linter.flake8_self.ignore_names = [ _name_, _value_, ] +linter.flake8_django.additional_path_functions = [] linter.flake8_tidy_imports.ban_relative_imports = "parents" linter.flake8_tidy_imports.banned_api = {} linter.flake8_tidy_imports.banned_module_level_imports = [] diff --git a/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap b/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap index 94d6cbc603..5b327682c2 100644 --- a/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap +++ b/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap @@ -292,6 +292,7 @@ linter.flake8_self.ignore_names = [ _name_, _value_, ] +linter.flake8_django.additional_path_functions = [] linter.flake8_tidy_imports.ban_relative_imports = "parents" linter.flake8_tidy_imports.banned_api = {} linter.flake8_tidy_imports.banned_module_level_imports = [] diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ100.py b/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ100.py new file mode 100644 index 0000000000..3f64e94f90 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ100.py @@ -0,0 +1,46 @@ +from django.urls import path +from . import views + +# Errors - missing trailing slash +urlpatterns = [ + path("help", views.help_view), # DJ100 + path("about", views.about_view), # DJ100 + path("contact", views.contact_view), # DJ100 + path("api/users", views.users_view), # DJ100 + path("blog/posts", views.posts_view), # DJ100 +] + +# 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), # DJ100 + path("also-good/", views.also_good_view), + path("also-bad", views.also_bad_view), # DJ100 +] + +# Error - missing trail slash and argument should stay in message +urlpatterns_params_bad = [ + path("bad/", views.bad_view), # DJ100 + path("", views.bad_view), # DJ100 +] diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ100_custom_paths.py b/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ100_custom_paths.py new file mode 100644 index 0000000000..cc7d9bd1c1 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ100_custom_paths.py @@ -0,0 +1,27 @@ +from mytools import path as mypath +from . import views + +# Test that custom path functions are also checked +urlpatterns_custom = [ + mypath("help", views.help_view), # DJ100 + mypath("about", views.about_view), # DJ100 +] + +# OK - custom path with trailing slash +urlpatterns_custom_ok = [ + mypath("help/", views.help_view), + mypath("about/", views.about_view), +] + +# Test multiple violations in same list +urlpatterns_multiple = [ + mypath("api/users", views.users_view), # DJ100 + mypath("api/posts", views.posts_view), # DJ100 + mypath("api/comments/", views.comments_view), # OK +] + +# OK - root path and empty string +urlpatterns_edge_cases = [ + mypath("/", views.root_view), # OK - root path + mypath("", views.empty_view), # OK - empty string +] diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ101.py b/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ101.py new file mode 100644 index 0000000000..67cdc9f6c3 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ101.py @@ -0,0 +1,54 @@ +from django.urls import path +from . import views + +# Errors - leading slash +urlpatterns = [ + path("/help/", views.help_view), # DJ101 + path("/about/", views.about_view), # DJ101 + path("/contact/", views.contact_view), # DJ101 + path("/api/users/", views.users_view), # DJ101 + path("/blog/posts/", views.posts_view), # DJ101 +] + +# 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), # DJ101 + path("also-good/", views.also_good_view), + path("/also-bad/", views.also_bad_view), # DJ101 +] + +# Edge cases with different quote styles +urlpatterns_quotes = [ + path('/single-quote/', views.single_quote_view), # DJ101 + path("/double-quote/", views.double_quote_view), # DJ101 + path('''/triple-single/''', views.triple_single_view), # DJ101 + path("""/triple-double/""", views.triple_double_view), # DJ101 +] + +# Error - leading trail slash and argument should stay in message +urlpatterns_params_bad = [ + path("/bad//", views.bad_view), # DJ101 + path("/", views.bad_view), # DJ101 +] diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ101_custom_paths.py b/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ101_custom_paths.py new file mode 100644 index 0000000000..7b842c39f2 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_django/DJ101_custom_paths.py @@ -0,0 +1,29 @@ +from mytools import path as mypath +from . import views + +# Test that custom path functions are also checked for leading slashes +urlpatterns_custom = [ + mypath("/help/", views.help_view), # DJ101 + mypath("/about/", views.about_view), # DJ101 +] + +# OK - custom path without leading slash +urlpatterns_custom_ok = [ + mypath("help/", views.help_view), + mypath("about/", views.about_view), +] + +# Test multiple violations in same list +urlpatterns_multiple = [ + mypath("/api/users/", views.users_view), # DJ101 + mypath("/api/posts/", views.posts_view), # DJ101 + mypath("api/comments/", views.comments_view), # OK +] + + +# OK - root path and empty string +urlpatterns_edge_cases = [ + + mypath("/", views.root_view), # OK - root path + mypath("", views.empty_view), # OK - empty string +] diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs index 53081e3681..8024b7b27f 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs @@ -1181,6 +1181,12 @@ 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::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 ebec5f4acc..2f674459bf 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -1101,6 +1101,8 @@ 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, "100") => rules::flake8_django::rules::DjangoURLPathWithoutTrailingSlash, + (Flake8Django, "101") => 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 8be904a8e0..9b2f90b5b1 100644 --- a/crates/ruff_linter/src/rules/flake8_django/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_django/mod.rs @@ -1,6 +1,7 @@ //! Rules from [django-flake8](https://pypi.org/project/flake8-django/) mod helpers; pub(crate) mod rules; +pub mod settings; #[cfg(test)] mod tests { @@ -18,6 +19,8 @@ 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("DJ100.py"))] + #[test_case(Rule::DjangoURLPathWithLeadingSlash, Path::new("DJ101.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<()> { @@ -29,4 +32,25 @@ mod tests { assert_diagnostics!(snapshot, diagnostics); Ok(()) } + + #[test] + fn test_additional_path_functions_dj100() -> Result<()> { + let mut settings = + settings::LinterSettings::for_rule(Rule::DjangoURLPathWithoutTrailingSlash); + settings.flake8_django.additional_path_functions = vec!["mytools.path".to_string()]; + + let diagnostics = test_path(Path::new("flake8_django/DJ100_custom_paths.py"), &settings)?; + assert_diagnostics!("DJ100_custom_paths.py", diagnostics); + Ok(()) + } + + #[test] + fn test_additional_path_functions_dj101() -> Result<()> { + let mut settings = settings::LinterSettings::for_rule(Rule::DjangoURLPathWithLeadingSlash); + settings.flake8_django.additional_path_functions = vec!["mytools.path".to_string()]; + + let diagnostics = test_path(Path::new("flake8_django/DJ101_custom_paths.py"), &settings)?; + assert_diagnostics!("DJ101_custom_paths.py", diagnostics); + Ok(()) + } } 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..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,8 @@ 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; mod exclude_with_model_form; @@ -13,3 +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..7f310df11f --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_django/rules/url_path_with_leading_slash.rs @@ -0,0 +1,131 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; +use ruff_python_ast::{self as ast, Expr}; +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.4")] +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() + } +} + +/// DJ101 +pub(crate) fn url_path_with_leading_slash(checker: &Checker, call: &ast::ExprCall) { + // Check if this is a call to django.urls.path or any additional configured path functions + let is_path_function = checker + .semantic() + .resolve_qualified_name(&call.func) + .is_some_and(|qualified_name| { + let segments = qualified_name.segments(); + + // Check if it's the default django.urls.path + if matches!(segments, ["django", "urls", "path"]) { + return true; + } + + // Check if it matches any additional configured path functions + let qualified_name_str = segments.join("."); + checker + .settings() + .flake8_django + .additional_path_functions + .iter() + .any(|path| path == &qualified_name_str) + }); + + if !is_path_function { + 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/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..5ba6f57dd5 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_django/rules/url_path_without_trailing_slash.rs @@ -0,0 +1,134 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; +use ruff_python_ast::{self as ast, Expr}; +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()` +/// 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.4")] +pub(crate) struct DjangoURLPathWithoutTrailingSlash { + url_pattern: String, +} + +impl AlwaysFixableViolation for DjangoURLPathWithoutTrailingSlash { + #[derive_message_formats] + fn message(&self) -> String { + let DjangoURLPathWithoutTrailingSlash { url_pattern } = self; + format!("URL route `{url_pattern}` is missing a trailing slash") + } + + fn fix_title(&self) -> String { + "Add trailing slash".to_string() + } +} + +/// DJ100 +pub(crate) fn url_path_without_trailing_slash(checker: &Checker, call: &ast::ExprCall) { + // Check if this is a call to django.urls.path or any additional configured path functions + let is_path_function = checker + .semantic() + .resolve_qualified_name(&call.func) + .is_some_and(|qualified_name| { + let segments = qualified_name.segments(); + + // Check if it's the default django.urls.path + if matches!(segments, ["django", "urls", "path"]) { + return true; + } + + // Check if it matches any additional configured path functions + let qualified_name_str = segments.join("."); + checker + .settings() + .flake8_django + .additional_path_functions + .iter() + .any(|path| path == &qualified_name_str) + }); + + if !is_path_function { + 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 routes that are just "/" or already end with "/" + if route == "/" || route.ends_with('/') { + return; + } + + // Report diagnostic for routes without trailing slash + let mut diagnostic = checker.report_diagnostic( + DjangoURLPathWithoutTrailingSlash { + url_pattern: route.to_string(), + }, + route_arg.range(), + ); + + // Determine the quote style to find the insertion point for the slash + // (just before the closing quotes) + let string_content = checker.locator().slice(route_arg.range()); + let quote_len = if string_content.ends_with("'''") || string_content.ends_with("\"\"\"") { + 3 + } else if string_content.ends_with('\'') || string_content.ends_with('"') { + 1 + } else { + return; // Invalid string format + }; + + // Insert "/" just before the closing quote(s) + let insertion_point = route_arg.range().end() - TextSize::new(quote_len); + diagnostic.set_fix(Fix::safe_edit(Edit::insertion( + "/".to_string(), + insertion_point, + ))); + } +} diff --git a/crates/ruff_linter/src/rules/flake8_django/settings.rs b/crates/ruff_linter/src/rules/flake8_django/settings.rs new file mode 100644 index 0000000000..a1e578d4c1 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_django/settings.rs @@ -0,0 +1,23 @@ +//! Settings for the `flake8-django` plugin. + +use crate::display_settings; +use ruff_macros::CacheKey; +use std::fmt::{Display, Formatter}; + +#[derive(Debug, Clone, CacheKey, Default)] +pub struct Settings { + pub additional_path_functions: Vec, +} + +impl Display for Settings { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + display_settings! { + formatter = f, + namespace = "linter.flake8_django", + fields = [ + self.additional_path_functions | array + ] + } + Ok(()) + } +} diff --git a/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ100_DJ100.py.snap b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ100_DJ100.py.snap new file mode 100644 index 0000000000..6ef1ad29c8 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ100_DJ100.py.snap @@ -0,0 +1,176 @@ +--- +source: crates/ruff_linter/src/rules/flake8_django/mod.rs +--- +DJ100 [*] URL route `help` is missing a trailing slash + --> DJ100.py:6:10 + | +4 | # Errors - missing trailing slash +5 | urlpatterns = [ +6 | path("help", views.help_view), # DJ100 + | ^^^^^^ +7 | path("about", views.about_view), # DJ100 +8 | path("contact", views.contact_view), # DJ100 + | +help: Add trailing slash +3 | +4 | # Errors - missing trailing slash +5 | urlpatterns = [ + - path("help", views.help_view), # DJ100 +6 + path("help/", views.help_view), # DJ100 +7 | path("about", views.about_view), # DJ100 +8 | path("contact", views.contact_view), # DJ100 +9 | path("api/users", views.users_view), # DJ100 + +DJ100 [*] URL route `about` is missing a trailing slash + --> DJ100.py:7:10 + | +5 | urlpatterns = [ +6 | path("help", views.help_view), # DJ100 +7 | path("about", views.about_view), # DJ100 + | ^^^^^^^ +8 | path("contact", views.contact_view), # DJ100 +9 | path("api/users", views.users_view), # DJ100 + | +help: Add trailing slash +4 | # Errors - missing trailing slash +5 | urlpatterns = [ +6 | path("help", views.help_view), # DJ100 + - path("about", views.about_view), # DJ100 +7 + path("about/", views.about_view), # DJ100 +8 | path("contact", views.contact_view), # DJ100 +9 | path("api/users", views.users_view), # DJ100 +10 | path("blog/posts", views.posts_view), # DJ100 + +DJ100 [*] URL route `contact` is missing a trailing slash + --> DJ100.py:8:10 + | + 6 | path("help", views.help_view), # DJ100 + 7 | path("about", views.about_view), # DJ100 + 8 | path("contact", views.contact_view), # DJ100 + | ^^^^^^^^^ + 9 | path("api/users", views.users_view), # DJ100 +10 | path("blog/posts", views.posts_view), # DJ100 + | +help: Add trailing slash +5 | urlpatterns = [ +6 | path("help", views.help_view), # DJ100 +7 | path("about", views.about_view), # DJ100 + - path("contact", views.contact_view), # DJ100 +8 + path("contact/", views.contact_view), # DJ100 +9 | path("api/users", views.users_view), # DJ100 +10 | path("blog/posts", views.posts_view), # DJ100 +11 | ] + +DJ100 [*] URL route `api/users` is missing a trailing slash + --> DJ100.py:9:10 + | + 7 | path("about", views.about_view), # DJ100 + 8 | path("contact", views.contact_view), # DJ100 + 9 | path("api/users", views.users_view), # DJ100 + | ^^^^^^^^^^^ +10 | path("blog/posts", views.posts_view), # DJ100 +11 | ] + | +help: Add trailing slash +6 | path("help", views.help_view), # DJ100 +7 | path("about", views.about_view), # DJ100 +8 | path("contact", views.contact_view), # DJ100 + - path("api/users", views.users_view), # DJ100 +9 + path("api/users/", views.users_view), # DJ100 +10 | path("blog/posts", views.posts_view), # DJ100 +11 | ] +12 | + +DJ100 [*] URL route `blog/posts` is missing a trailing slash + --> DJ100.py:10:10 + | + 8 | path("contact", views.contact_view), # DJ100 + 9 | path("api/users", views.users_view), # DJ100 +10 | path("blog/posts", views.posts_view), # DJ100 + | ^^^^^^^^^^^^ +11 | ] + | +help: Add trailing slash +7 | path("about", views.about_view), # DJ100 +8 | path("contact", views.contact_view), # DJ100 +9 | path("api/users", views.users_view), # DJ100 + - path("blog/posts", views.posts_view), # DJ100 +10 + path("blog/posts/", views.posts_view), # DJ100 +11 | ] +12 | +13 | # OK - has trailing slash + +DJ100 [*] URL route `bad` is missing a trailing slash + --> DJ100.py:37:10 + | +35 | urlpatterns_mixed = [ +36 | path("good/", views.good_view), +37 | path("bad", views.bad_view), # DJ100 + | ^^^^^ +38 | path("also-good/", views.also_good_view), +39 | path("also-bad", views.also_bad_view), # DJ100 + | +help: Add trailing slash +34 | # Mixed cases +35 | urlpatterns_mixed = [ +36 | path("good/", views.good_view), + - path("bad", views.bad_view), # DJ100 +37 + path("bad/", views.bad_view), # DJ100 +38 | path("also-good/", views.also_good_view), +39 | path("also-bad", views.also_bad_view), # DJ100 +40 | ] + +DJ100 [*] URL route `also-bad` is missing a trailing slash + --> DJ100.py:39:10 + | +37 | path("bad", views.bad_view), # DJ100 +38 | path("also-good/", views.also_good_view), +39 | path("also-bad", views.also_bad_view), # DJ100 + | ^^^^^^^^^^ +40 | ] + | +help: Add trailing slash +36 | path("good/", views.good_view), +37 | path("bad", views.bad_view), # DJ100 +38 | path("also-good/", views.also_good_view), + - path("also-bad", views.also_bad_view), # DJ100 +39 + path("also-bad/", views.also_bad_view), # DJ100 +40 | ] +41 | +42 | # Error - missing trail slash and argument should stay in message + +DJ100 [*] URL route `bad/` is missing a trailing slash + --> DJ100.py:44:10 + | +42 | # Error - missing trail slash and argument should stay in message +43 | urlpatterns_params_bad = [ +44 | path("bad/", views.bad_view), # DJ100 + | ^^^^^^^^^^^^^^^^^ +45 | path("", views.bad_view), # DJ100 +46 | ] + | +help: Add trailing slash +41 | +42 | # Error - missing trail slash and argument should stay in message +43 | urlpatterns_params_bad = [ + - path("bad/", views.bad_view), # DJ100 +44 + path("bad//", views.bad_view), # DJ100 +45 | path("", views.bad_view), # DJ100 +46 | ] + +DJ100 [*] URL route `` is missing a trailing slash + --> DJ100.py:45:10 + | +43 | urlpatterns_params_bad = [ +44 | path("bad/", views.bad_view), # DJ100 +45 | path("", views.bad_view), # DJ100 + | ^^^^^^^^^^^^^ +46 | ] + | +help: Add trailing slash +42 | # Error - missing trail slash and argument should stay in message +43 | urlpatterns_params_bad = [ +44 | path("bad/", views.bad_view), # DJ100 + - path("", views.bad_view), # DJ100 +45 + path("/", views.bad_view), # DJ100 +46 | ] diff --git a/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ100_custom_paths.py.snap b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ100_custom_paths.py.snap new file mode 100644 index 0000000000..e8d35792b4 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ100_custom_paths.py.snap @@ -0,0 +1,81 @@ +--- +source: crates/ruff_linter/src/rules/flake8_django/mod.rs +--- +DJ100 [*] URL route `help` is missing a trailing slash + --> DJ100_custom_paths.py:6:12 + | +4 | # Test that custom path functions are also checked +5 | urlpatterns_custom = [ +6 | mypath("help", views.help_view), # DJ100 + | ^^^^^^ +7 | mypath("about", views.about_view), # DJ100 +8 | ] + | +help: Add trailing slash +3 | +4 | # Test that custom path functions are also checked +5 | urlpatterns_custom = [ + - mypath("help", views.help_view), # DJ100 +6 + mypath("help/", views.help_view), # DJ100 +7 | mypath("about", views.about_view), # DJ100 +8 | ] +9 | + +DJ100 [*] URL route `about` is missing a trailing slash + --> DJ100_custom_paths.py:7:12 + | +5 | urlpatterns_custom = [ +6 | mypath("help", views.help_view), # DJ100 +7 | mypath("about", views.about_view), # DJ100 + | ^^^^^^^ +8 | ] + | +help: Add trailing slash +4 | # Test that custom path functions are also checked +5 | urlpatterns_custom = [ +6 | mypath("help", views.help_view), # DJ100 + - mypath("about", views.about_view), # DJ100 +7 + mypath("about/", views.about_view), # DJ100 +8 | ] +9 | +10 | # OK - custom path with trailing slash + +DJ100 [*] URL route `api/users` is missing a trailing slash + --> DJ100_custom_paths.py:18:12 + | +16 | # Test multiple violations in same list +17 | urlpatterns_multiple = [ +18 | mypath("api/users", views.users_view), # DJ100 + | ^^^^^^^^^^^ +19 | mypath("api/posts", views.posts_view), # DJ100 +20 | mypath("api/comments/", views.comments_view), # OK + | +help: Add trailing slash +15 | +16 | # Test multiple violations in same list +17 | urlpatterns_multiple = [ + - mypath("api/users", views.users_view), # DJ100 +18 + mypath("api/users/", views.users_view), # DJ100 +19 | mypath("api/posts", views.posts_view), # DJ100 +20 | mypath("api/comments/", views.comments_view), # OK +21 | ] + +DJ100 [*] URL route `api/posts` is missing a trailing slash + --> DJ100_custom_paths.py:19:12 + | +17 | urlpatterns_multiple = [ +18 | mypath("api/users", views.users_view), # DJ100 +19 | mypath("api/posts", views.posts_view), # DJ100 + | ^^^^^^^^^^^ +20 | mypath("api/comments/", views.comments_view), # OK +21 | ] + | +help: Add trailing slash +16 | # Test multiple violations in same list +17 | urlpatterns_multiple = [ +18 | mypath("api/users", views.users_view), # DJ100 + - mypath("api/posts", views.posts_view), # DJ100 +19 + mypath("api/posts/", views.posts_view), # DJ100 +20 | mypath("api/comments/", views.comments_view), # OK +21 | ] +22 | diff --git a/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ101_DJ101.py.snap b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ101_DJ101.py.snap new file mode 100644 index 0000000000..681094e3dd --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ101_DJ101.py.snap @@ -0,0 +1,255 @@ +--- +source: crates/ruff_linter/src/rules/flake8_django/mod.rs +--- +DJ101 [*] URL route `/help/` has an unnecessary leading slash + --> DJ101.py:6:10 + | +4 | # Errors - leading slash +5 | urlpatterns = [ +6 | path("/help/", views.help_view), # DJ101 + | ^^^^^^^^ +7 | path("/about/", views.about_view), # DJ101 +8 | path("/contact/", views.contact_view), # DJ101 + | +help: Remove leading slash +3 | +4 | # Errors - leading slash +5 | urlpatterns = [ + - path("/help/", views.help_view), # DJ101 +6 + path("help/", views.help_view), # DJ101 +7 | path("/about/", views.about_view), # DJ101 +8 | path("/contact/", views.contact_view), # DJ101 +9 | path("/api/users/", views.users_view), # DJ101 + +DJ101 [*] URL route `/about/` has an unnecessary leading slash + --> DJ101.py:7:10 + | +5 | urlpatterns = [ +6 | path("/help/", views.help_view), # DJ101 +7 | path("/about/", views.about_view), # DJ101 + | ^^^^^^^^^ +8 | path("/contact/", views.contact_view), # DJ101 +9 | path("/api/users/", views.users_view), # DJ101 + | +help: Remove leading slash +4 | # Errors - leading slash +5 | urlpatterns = [ +6 | path("/help/", views.help_view), # DJ101 + - path("/about/", views.about_view), # DJ101 +7 + path("about/", views.about_view), # DJ101 +8 | path("/contact/", views.contact_view), # DJ101 +9 | path("/api/users/", views.users_view), # DJ101 +10 | path("/blog/posts/", views.posts_view), # DJ101 + +DJ101 [*] URL route `/contact/` has an unnecessary leading slash + --> DJ101.py:8:10 + | + 6 | path("/help/", views.help_view), # DJ101 + 7 | path("/about/", views.about_view), # DJ101 + 8 | path("/contact/", views.contact_view), # DJ101 + | ^^^^^^^^^^^ + 9 | path("/api/users/", views.users_view), # DJ101 +10 | path("/blog/posts/", views.posts_view), # DJ101 + | +help: Remove leading slash +5 | urlpatterns = [ +6 | path("/help/", views.help_view), # DJ101 +7 | path("/about/", views.about_view), # DJ101 + - path("/contact/", views.contact_view), # DJ101 +8 + path("contact/", views.contact_view), # DJ101 +9 | path("/api/users/", views.users_view), # DJ101 +10 | path("/blog/posts/", views.posts_view), # DJ101 +11 | ] + +DJ101 [*] URL route `/api/users/` has an unnecessary leading slash + --> DJ101.py:9:10 + | + 7 | path("/about/", views.about_view), # DJ101 + 8 | path("/contact/", views.contact_view), # DJ101 + 9 | path("/api/users/", views.users_view), # DJ101 + | ^^^^^^^^^^^^^ +10 | path("/blog/posts/", views.posts_view), # DJ101 +11 | ] + | +help: Remove leading slash +6 | path("/help/", views.help_view), # DJ101 +7 | path("/about/", views.about_view), # DJ101 +8 | path("/contact/", views.contact_view), # DJ101 + - path("/api/users/", views.users_view), # DJ101 +9 + path("api/users/", views.users_view), # DJ101 +10 | path("/blog/posts/", views.posts_view), # DJ101 +11 | ] +12 | + +DJ101 [*] URL route `/blog/posts/` has an unnecessary leading slash + --> DJ101.py:10:10 + | + 8 | path("/contact/", views.contact_view), # DJ101 + 9 | path("/api/users/", views.users_view), # DJ101 +10 | path("/blog/posts/", views.posts_view), # DJ101 + | ^^^^^^^^^^^^^^ +11 | ] + | +help: Remove leading slash +7 | path("/about/", views.about_view), # DJ101 +8 | path("/contact/", views.contact_view), # DJ101 +9 | path("/api/users/", views.users_view), # DJ101 + - path("/blog/posts/", views.posts_view), # DJ101 +10 + path("blog/posts/", views.posts_view), # DJ101 +11 | ] +12 | +13 | # OK - no leading slash + +DJ101 [*] URL route `/bad/` has an unnecessary leading slash + --> DJ101.py:37:10 + | +35 | urlpatterns_mixed = [ +36 | path("good/", views.good_view), +37 | path("/bad/", views.bad_view), # DJ101 + | ^^^^^^^ +38 | path("also-good/", views.also_good_view), +39 | path("/also-bad/", views.also_bad_view), # DJ101 + | +help: Remove leading slash +34 | # Mixed cases +35 | urlpatterns_mixed = [ +36 | path("good/", views.good_view), + - path("/bad/", views.bad_view), # DJ101 +37 + path("bad/", views.bad_view), # DJ101 +38 | path("also-good/", views.also_good_view), +39 | path("/also-bad/", views.also_bad_view), # DJ101 +40 | ] + +DJ101 [*] URL route `/also-bad/` has an unnecessary leading slash + --> DJ101.py:39:10 + | +37 | path("/bad/", views.bad_view), # DJ101 +38 | path("also-good/", views.also_good_view), +39 | path("/also-bad/", views.also_bad_view), # DJ101 + | ^^^^^^^^^^^^ +40 | ] + | +help: Remove leading slash +36 | path("good/", views.good_view), +37 | path("/bad/", views.bad_view), # DJ101 +38 | path("also-good/", views.also_good_view), + - path("/also-bad/", views.also_bad_view), # DJ101 +39 + path("also-bad/", views.also_bad_view), # DJ101 +40 | ] +41 | +42 | # Edge cases with different quote styles + +DJ101 [*] URL route `/single-quote/` has an unnecessary leading slash + --> DJ101.py:44:10 + | +42 | # Edge cases with different quote styles +43 | urlpatterns_quotes = [ +44 | path('/single-quote/', views.single_quote_view), # DJ101 + | ^^^^^^^^^^^^^^^^ +45 | path("/double-quote/", views.double_quote_view), # DJ101 +46 | path('''/triple-single/''', views.triple_single_view), # DJ101 + | +help: Remove leading slash +41 | +42 | # Edge cases with different quote styles +43 | urlpatterns_quotes = [ + - path('/single-quote/', views.single_quote_view), # DJ101 +44 + path('single-quote/', views.single_quote_view), # DJ101 +45 | path("/double-quote/", views.double_quote_view), # DJ101 +46 | path('''/triple-single/''', views.triple_single_view), # DJ101 +47 | path("""/triple-double/""", views.triple_double_view), # DJ101 + +DJ101 [*] URL route `/double-quote/` has an unnecessary leading slash + --> DJ101.py:45:10 + | +43 | urlpatterns_quotes = [ +44 | path('/single-quote/', views.single_quote_view), # DJ101 +45 | path("/double-quote/", views.double_quote_view), # DJ101 + | ^^^^^^^^^^^^^^^^ +46 | path('''/triple-single/''', views.triple_single_view), # DJ101 +47 | path("""/triple-double/""", views.triple_double_view), # DJ101 + | +help: Remove leading slash +42 | # Edge cases with different quote styles +43 | urlpatterns_quotes = [ +44 | path('/single-quote/', views.single_quote_view), # DJ101 + - path("/double-quote/", views.double_quote_view), # DJ101 +45 + path("double-quote/", views.double_quote_view), # DJ101 +46 | path('''/triple-single/''', views.triple_single_view), # DJ101 +47 | path("""/triple-double/""", views.triple_double_view), # DJ101 +48 | ] + +DJ101 [*] URL route `/triple-single/` has an unnecessary leading slash + --> DJ101.py:46:10 + | +44 | path('/single-quote/', views.single_quote_view), # DJ101 +45 | path("/double-quote/", views.double_quote_view), # DJ101 +46 | path('''/triple-single/''', views.triple_single_view), # DJ101 + | ^^^^^^^^^^^^^^^^^^^^^ +47 | path("""/triple-double/""", views.triple_double_view), # DJ101 +48 | ] + | +help: Remove leading slash +43 | urlpatterns_quotes = [ +44 | path('/single-quote/', views.single_quote_view), # DJ101 +45 | path("/double-quote/", views.double_quote_view), # DJ101 + - path('''/triple-single/''', views.triple_single_view), # DJ101 +46 + path('''triple-single/''', views.triple_single_view), # DJ101 +47 | path("""/triple-double/""", views.triple_double_view), # DJ101 +48 | ] +49 | + +DJ101 [*] URL route `/triple-double/` has an unnecessary leading slash + --> DJ101.py:47:10 + | +45 | path("/double-quote/", views.double_quote_view), # DJ101 +46 | path('''/triple-single/''', views.triple_single_view), # DJ101 +47 | path("""/triple-double/""", views.triple_double_view), # DJ101 + | ^^^^^^^^^^^^^^^^^^^^^ +48 | ] + | +help: Remove leading slash +44 | path('/single-quote/', views.single_quote_view), # DJ101 +45 | path("/double-quote/", views.double_quote_view), # DJ101 +46 | path('''/triple-single/''', views.triple_single_view), # DJ101 + - path("""/triple-double/""", views.triple_double_view), # DJ101 +47 + path("""triple-double/""", views.triple_double_view), # DJ101 +48 | ] +49 | +50 | # Error - leading trail slash and argument should stay in message + +DJ101 [*] URL route `/bad//` has an unnecessary leading slash + --> DJ101.py:52:10 + | +50 | # Error - leading trail slash and argument should stay in message +51 | urlpatterns_params_bad = [ +52 | path("/bad//", views.bad_view), # DJ101 + | ^^^^^^^^^^^^^^^^^^^ +53 | path("/", views.bad_view), # DJ101 +54 | ] + | +help: Remove leading slash +49 | +50 | # Error - leading trail slash and argument should stay in message +51 | urlpatterns_params_bad = [ + - path("/bad//", views.bad_view), # DJ101 +52 + path("bad//", views.bad_view), # DJ101 +53 | path("/", views.bad_view), # DJ101 +54 | ] + +DJ101 [*] URL route `/` has an unnecessary leading slash + --> DJ101.py:53:10 + | +51 | urlpatterns_params_bad = [ +52 | path("/bad//", views.bad_view), # DJ101 +53 | path("/", views.bad_view), # DJ101 + | ^^^^^^^^^^^^^^ +54 | ] + | +help: Remove leading slash +50 | # Error - leading trail slash and argument should stay in message +51 | urlpatterns_params_bad = [ +52 | path("/bad//", views.bad_view), # DJ101 + - path("/", views.bad_view), # DJ101 +53 + path("", views.bad_view), # DJ101 +54 | ] diff --git a/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ101_custom_paths.py.snap b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ101_custom_paths.py.snap new file mode 100644 index 0000000000..18f1f30a29 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_django/snapshots/ruff_linter__rules__flake8_django__tests__DJ101_custom_paths.py.snap @@ -0,0 +1,81 @@ +--- +source: crates/ruff_linter/src/rules/flake8_django/mod.rs +--- +DJ101 [*] URL route `/help/` has an unnecessary leading slash + --> DJ101_custom_paths.py:6:12 + | +4 | # Test that custom path functions are also checked for leading slashes +5 | urlpatterns_custom = [ +6 | mypath("/help/", views.help_view), # DJ101 + | ^^^^^^^^ +7 | mypath("/about/", views.about_view), # DJ101 +8 | ] + | +help: Remove leading slash +3 | +4 | # Test that custom path functions are also checked for leading slashes +5 | urlpatterns_custom = [ + - mypath("/help/", views.help_view), # DJ101 +6 + mypath("help/", views.help_view), # DJ101 +7 | mypath("/about/", views.about_view), # DJ101 +8 | ] +9 | + +DJ101 [*] URL route `/about/` has an unnecessary leading slash + --> DJ101_custom_paths.py:7:12 + | +5 | urlpatterns_custom = [ +6 | mypath("/help/", views.help_view), # DJ101 +7 | mypath("/about/", views.about_view), # DJ101 + | ^^^^^^^^^ +8 | ] + | +help: Remove leading slash +4 | # Test that custom path functions are also checked for leading slashes +5 | urlpatterns_custom = [ +6 | mypath("/help/", views.help_view), # DJ101 + - mypath("/about/", views.about_view), # DJ101 +7 + mypath("about/", views.about_view), # DJ101 +8 | ] +9 | +10 | # OK - custom path without leading slash + +DJ101 [*] URL route `/api/users/` has an unnecessary leading slash + --> DJ101_custom_paths.py:18:12 + | +16 | # Test multiple violations in same list +17 | urlpatterns_multiple = [ +18 | mypath("/api/users/", views.users_view), # DJ101 + | ^^^^^^^^^^^^^ +19 | mypath("/api/posts/", views.posts_view), # DJ101 +20 | mypath("api/comments/", views.comments_view), # OK + | +help: Remove leading slash +15 | +16 | # Test multiple violations in same list +17 | urlpatterns_multiple = [ + - mypath("/api/users/", views.users_view), # DJ101 +18 + mypath("api/users/", views.users_view), # DJ101 +19 | mypath("/api/posts/", views.posts_view), # DJ101 +20 | mypath("api/comments/", views.comments_view), # OK +21 | ] + +DJ101 [*] URL route `/api/posts/` has an unnecessary leading slash + --> DJ101_custom_paths.py:19:12 + | +17 | urlpatterns_multiple = [ +18 | mypath("/api/users/", views.users_view), # DJ101 +19 | mypath("/api/posts/", views.posts_view), # DJ101 + | ^^^^^^^^^^^^^ +20 | mypath("api/comments/", views.comments_view), # OK +21 | ] + | +help: Remove leading slash +16 | # Test multiple violations in same list +17 | urlpatterns_multiple = [ +18 | mypath("/api/users/", views.users_view), # DJ101 + - mypath("/api/posts/", views.posts_view), # DJ101 +19 + mypath("api/posts/", views.posts_view), # DJ101 +20 | mypath("api/comments/", views.comments_view), # OK +21 | ] +22 | diff --git a/crates/ruff_linter/src/settings/mod.rs b/crates/ruff_linter/src/settings/mod.rs index 5d5e35aa8d..29f101574b 100644 --- a/crates/ruff_linter/src/settings/mod.rs +++ b/crates/ruff_linter/src/settings/mod.rs @@ -17,7 +17,7 @@ use crate::line_width::LineLength; use crate::registry::{Linter, Rule}; use crate::rules::{ flake8_annotations, flake8_bandit, flake8_boolean_trap, flake8_bugbear, flake8_builtins, - flake8_comprehensions, flake8_copyright, flake8_errmsg, flake8_gettext, + flake8_comprehensions, flake8_copyright, flake8_django, flake8_errmsg, flake8_gettext, flake8_implicit_str_concat, flake8_import_conventions, flake8_pytest_style, flake8_quotes, flake8_self, flake8_tidy_imports, flake8_type_checking, flake8_unused_arguments, isort, mccabe, pep8_naming, pycodestyle, pydoclint, pydocstyle, pyflakes, pylint, pyupgrade, ruff, @@ -262,6 +262,7 @@ pub struct LinterSettings { pub flake8_builtins: flake8_builtins::settings::Settings, pub flake8_comprehensions: flake8_comprehensions::settings::Settings, pub flake8_copyright: flake8_copyright::settings::Settings, + pub flake8_django: flake8_django::settings::Settings, pub flake8_errmsg: flake8_errmsg::settings::Settings, pub flake8_gettext: flake8_gettext::settings::Settings, pub flake8_implicit_str_concat: flake8_implicit_str_concat::settings::Settings, @@ -337,6 +338,7 @@ impl Display for LinterSettings { self.flake8_pytest_style | nested, self.flake8_quotes | nested, self.flake8_self | nested, + self.flake8_django | nested, self.flake8_tidy_imports | nested, self.flake8_type_checking | nested, self.flake8_unused_arguments | nested, @@ -438,6 +440,7 @@ impl LinterSettings { flake8_pytest_style: flake8_pytest_style::settings::Settings::default(), flake8_quotes: flake8_quotes::settings::Settings::default(), flake8_self: flake8_self::settings::Settings::default(), + flake8_django: flake8_django::settings::Settings::default(), flake8_tidy_imports: flake8_tidy_imports::settings::Settings::default(), flake8_type_checking: flake8_type_checking::settings::Settings::default(), flake8_unused_arguments: flake8_unused_arguments::settings::Settings::default(), diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index bf50749a45..ec5f067b3e 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -47,7 +47,7 @@ use ruff_python_formatter::{ use crate::options::{ AnalyzeOptions, Flake8AnnotationsOptions, Flake8BanditOptions, Flake8BooleanTrapOptions, Flake8BugbearOptions, Flake8BuiltinsOptions, Flake8ComprehensionsOptions, - Flake8CopyrightOptions, Flake8ErrMsgOptions, Flake8GetTextOptions, + Flake8CopyrightOptions, Flake8DjangoOptions, Flake8ErrMsgOptions, Flake8GetTextOptions, Flake8ImplicitStrConcatOptions, Flake8ImportConventionsOptions, Flake8PytestStyleOptions, Flake8QuotesOptions, Flake8SelfOptions, Flake8TidyImportsOptions, Flake8TypeCheckingOptions, Flake8UnusedArgumentsOptions, FormatOptions, IsortOptions, LintCommonOptions, LintOptions, @@ -366,6 +366,10 @@ impl Configuration { .map(Flake8CopyrightOptions::try_into_settings) .transpose()? .unwrap_or_default(), + flake8_django: lint + .flake8_django + .map(Flake8DjangoOptions::into_settings) + .unwrap_or_default(), flake8_errmsg: lint .flake8_errmsg .map(Flake8ErrMsgOptions::into_settings) @@ -662,6 +666,7 @@ pub struct LintConfiguration { pub flake8_builtins: Option, pub flake8_comprehensions: Option, pub flake8_copyright: Option, + pub flake8_django: Option, pub flake8_errmsg: Option, pub flake8_gettext: Option, pub flake8_implicit_str_concat: Option, @@ -779,6 +784,7 @@ impl LintConfiguration { flake8_builtins: options.common.flake8_builtins, flake8_comprehensions: options.common.flake8_comprehensions, flake8_copyright: options.common.flake8_copyright, + flake8_django: options.common.flake8_django, flake8_errmsg: options.common.flake8_errmsg, flake8_gettext: options.common.flake8_gettext, flake8_implicit_str_concat: options.common.flake8_implicit_str_concat, @@ -1168,6 +1174,7 @@ impl LintConfiguration { .flake8_comprehensions .combine(config.flake8_comprehensions), flake8_copyright: self.flake8_copyright.combine(config.flake8_copyright), + flake8_django: self.flake8_django.combine(config.flake8_django), flake8_errmsg: self.flake8_errmsg.combine(config.flake8_errmsg), flake8_gettext: self.flake8_gettext.combine(config.flake8_gettext), flake8_implicit_str_concat: self @@ -1394,6 +1401,7 @@ fn warn_about_deprecated_top_level_lint_options( flake8_builtins, flake8_comprehensions, flake8_copyright, + flake8_django, flake8_errmsg, flake8_quotes, flake8_self, @@ -1517,6 +1525,10 @@ fn warn_about_deprecated_top_level_lint_options( used_options.push("flake8-copyright"); } + if flake8_django.is_some() { + used_options.push("flake8-django"); + } + if flake8_errmsg.is_some() { used_options.push("flake8-errmsg"); } diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index 472b0e66f4..15a9fbca40 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -22,7 +22,7 @@ use ruff_linter::rules::pep8_naming::settings::IgnoreNames; use ruff_linter::rules::pydocstyle::settings::Convention; use ruff_linter::rules::pylint::settings::ConstantType; use ruff_linter::rules::{ - flake8_copyright, flake8_errmsg, flake8_gettext, flake8_implicit_str_concat, + flake8_copyright, flake8_django, flake8_errmsg, flake8_gettext, flake8_implicit_str_concat, flake8_import_conventions, flake8_pytest_style, flake8_quotes, flake8_self, flake8_tidy_imports, flake8_type_checking, flake8_unused_arguments, isort, mccabe, pep8_naming, pycodestyle, pydoclint, pydocstyle, pyflakes, pylint, pyupgrade, ruff, @@ -900,6 +900,10 @@ pub struct LintCommonOptions { #[option_group] pub flake8_copyright: Option, + /// Options for the `flake8-django` plugin. + #[option_group] + pub flake8_django: Option, + /// Options for the `flake8-errmsg` plugin. #[option_group] pub flake8_errmsg: Option, @@ -1431,6 +1435,39 @@ impl Flake8CopyrightOptions { } } +/// Options for the `flake8-django` plugin. +#[derive( + Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, OptionsMetadata, CombineOptions, +)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct Flake8DjangoOptions { + /// Additional qualified paths to Django URL path functions beyond + /// the default `django.urls.path`. This allows the rule to check + /// URL patterns defined using custom path functions or re-exported + /// path functions from other modules. + /// + /// For example, if you have a custom module `mytools` that re-exports + /// Django's path function, you can add `"mytools.path"` to this list. + #[option( + default = "[]", + value_type = "list[str]", + example = r#" + # Allow checking URL patterns from custom path functions + additional-path-functions = ["mytools.path", "myapp.urls.custom_path"] + "# + )] + pub additional_path_functions: Option>, +} + +impl Flake8DjangoOptions { + pub fn into_settings(self) -> flake8_django::settings::Settings { + flake8_django::settings::Settings { + additional_path_functions: self.additional_path_functions.unwrap_or_default(), + } + } +} + /// Options for the `flake8-errmsg` plugin. #[derive( Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, OptionsMetadata, CombineOptions, @@ -3937,6 +3974,7 @@ pub struct LintOptionsWire { flake8_builtins: Option, flake8_comprehensions: Option, flake8_copyright: Option, + flake8_django: Option, flake8_errmsg: Option, flake8_quotes: Option, flake8_self: Option, @@ -3994,6 +4032,7 @@ impl From for LintOptions { flake8_builtins, flake8_comprehensions, flake8_copyright, + flake8_django, flake8_errmsg, flake8_quotes, flake8_self, @@ -4050,6 +4089,7 @@ impl From for LintOptions { flake8_builtins, flake8_comprehensions, flake8_copyright, + flake8_django, flake8_errmsg, flake8_quotes, flake8_self, diff --git a/ruff.schema.json b/ruff.schema.json index 1c8a092042..4757e10fb1 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -297,6 +297,18 @@ ], "deprecated": true }, + "flake8-django": { + "description": "Options for the `flake8-django` plugin.", + "anyOf": [ + { + "$ref": "#/definitions/Flake8DjangoOptions" + }, + { + "type": "null" + } + ], + "deprecated": true + }, "flake8-errmsg": { "description": "Options for the `flake8-errmsg` plugin.", "anyOf": [ @@ -1145,6 +1157,23 @@ }, "additionalProperties": false }, + "Flake8DjangoOptions": { + "description": "Options for the `flake8-django` plugin.", + "type": "object", + "properties": { + "additional-path-functions": { + "description": "Additional qualified paths to Django URL path functions beyond\nthe default `django.urls.path`. This allows the rule to check\nURL patterns defined using custom path functions or re-exported\npath functions from other modules.\n\nFor example, if you have a custom module `mytools` that re-exports\nDjango's path function, you can add `\"mytools.path\"` to this list.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "Flake8ErrMsgOptions": { "description": "Options for the `flake8-errmsg` plugin.", "type": "object", @@ -2167,6 +2196,17 @@ } ] }, + "flake8-django": { + "description": "Options for the `flake8-django` plugin.", + "anyOf": [ + { + "$ref": "#/definitions/Flake8DjangoOptions" + }, + { + "type": "null" + } + ] + }, "flake8-errmsg": { "description": "Options for the `flake8-errmsg` plugin.", "anyOf": [ @@ -3164,6 +3204,10 @@ "DJ01", "DJ012", "DJ013", + "DJ1", + "DJ10", + "DJ100", + "DJ101", "DOC", "DOC1", "DOC10",