1#![warn(rust_2024_compatibility, clippy::all)]
2
3mod file_length;
6mod imports;
7mod indentation;
8
9use dictator_decree_abi::{BoxDecree, Decree, Diagnostics};
10use dictator_supreme::SupremeConfig;
11
12pub use imports::{ImportType, classify_module, is_python_stdlib};
13
14#[derive(Debug, Clone)]
16pub struct PythonConfig {
17 pub max_lines: usize,
18 pub ignore_comments: bool,
19}
20
21impl Default for PythonConfig {
22 fn default() -> Self {
23 Self {
24 max_lines: file_length::DEFAULT_MAX_LINES,
25 ignore_comments: false,
26 }
27 }
28}
29
30#[must_use]
31pub fn lint_source(source: &str) -> Diagnostics {
32 lint_source_with_config(source, &PythonConfig::default())
33}
34
35#[must_use]
37pub fn lint_source_with_config(source: &str, config: &PythonConfig) -> Diagnostics {
38 let mut diags = Diagnostics::new();
39
40 file_length::check_file_line_count(source, config.max_lines, &mut diags);
41 imports::check_import_ordering(source, &mut diags);
42 indentation::check_indentation_consistency(source, &mut diags);
43
44 diags
45}
46
47#[derive(Default)]
48pub struct Python {
49 config: PythonConfig,
50 supreme: SupremeConfig,
51}
52
53impl Python {
54 #[must_use]
55 pub const fn new(config: PythonConfig, supreme: SupremeConfig) -> Self {
56 Self { config, supreme }
57 }
58}
59
60impl Decree for Python {
61 fn name(&self) -> &'static str {
62 "python"
63 }
64
65 fn lint(&self, _path: &str, source: &str) -> Diagnostics {
66 let mut diags = Diagnostics::new();
67
68 let supreme_diags =
69 dictator_supreme::lint_source_with_owner(source, &self.supreme, "python");
70
71 if self.config.ignore_comments {
72 let lines: Vec<&str> = source.lines().collect();
74 diags.extend(supreme_diags.into_iter().filter(|d| {
75 if d.rule == "python/line-too-long" {
76 let line_idx = source[..d.span.start].matches('\n').count();
77 !lines
78 .get(line_idx)
79 .is_some_and(|line| line.trim_start().starts_with('#'))
80 } else {
81 true
82 }
83 }));
84 } else {
85 diags.extend(supreme_diags);
86 }
87
88 diags.extend(lint_source_with_config(source, &self.config));
89 diags
90 }
91
92 fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
93 dictator_decree_abi::DecreeMetadata {
94 abi_version: dictator_decree_abi::ABI_VERSION.to_string(),
95 decree_version: env!("CARGO_PKG_VERSION").to_string(),
96 description: "Python structural rules".to_string(),
97 dectauthors: Some(env!("CARGO_PKG_AUTHORS").to_string()),
98 supported_extensions: vec!["py".to_string(), "pyi".to_string(), "pyw".to_string()],
99 supported_filenames: vec![
100 "pyproject.toml".to_string(),
101 "setup.py".to_string(),
102 "setup.cfg".to_string(),
103 "Pipfile".to_string(),
104 "requirements.txt".to_string(),
105 "requirements-dev.txt".to_string(),
106 "constraints.txt".to_string(),
107 ".python-version".to_string(),
108 "pyrightconfig.json".to_string(),
109 "mypy.ini".to_string(),
110 ],
111 skip_filenames: vec![
112 "Pipfile.lock".to_string(),
113 "poetry.lock".to_string(),
114 "uv.lock".to_string(),
115 "pdm.lock".to_string(),
116 ],
117 capabilities: vec![dictator_decree_abi::Capability::Lint],
118 }
119 }
120}
121
122#[must_use]
123pub fn init_decree() -> BoxDecree {
124 Box::new(Python::default())
125}
126
127#[must_use]
129pub fn init_decree_with_config(config: PythonConfig) -> BoxDecree {
130 Box::new(Python::new(config, SupremeConfig::default()))
131}
132
133#[must_use]
135pub fn init_decree_with_configs(config: PythonConfig, supreme: SupremeConfig) -> BoxDecree {
136 Box::new(Python::new(config, supreme))
137}
138
139#[must_use]
141pub fn config_from_decree_settings(settings: &dictator_core::DecreeSettings) -> PythonConfig {
142 PythonConfig {
143 max_lines: settings.max_lines.unwrap_or(file_length::DEFAULT_MAX_LINES),
144 ignore_comments: settings.ignore_comments.unwrap_or(false),
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn detects_file_too_long() {
154 use std::fmt::Write;
155 let mut src = String::new();
156 for i in 0..400 {
157 let _ = writeln!(src, "x = {i}");
158 }
159 let diags = lint_source(&src);
160 assert!(
161 diags.iter().any(|d| d.rule == "python/file-too-long"),
162 "Should detect file with >380 code lines"
163 );
164 }
165
166 #[test]
167 fn ignores_comments_in_line_count() {
168 use std::fmt::Write;
169 let mut src = String::new();
170 for i in 0..380 {
171 let _ = writeln!(src, "x = {i}");
172 }
173 for i in 0..60 {
174 let _ = writeln!(src, "# Comment {i}");
175 }
176 let diags = lint_source(&src);
177 assert!(
178 !diags.iter().any(|d| d.rule == "python/file-too-long"),
179 "Should not count comment-only lines"
180 );
181 }
182
183 #[test]
184 fn ignores_blank_lines_in_count() {
185 use std::fmt::Write;
186 let mut src = String::new();
187 for i in 0..380 {
188 let _ = writeln!(src, "x = {i}");
189 }
190 for _ in 0..60 {
191 src.push('\n');
192 }
193 let diags = lint_source(&src);
194 assert!(
195 !diags.iter().any(|d| d.rule == "python/file-too-long"),
196 "Should not count blank lines"
197 );
198 }
199
200 #[test]
201 fn detects_wrong_import_order_stdlib_after_third_party() {
202 let src = r"
203import requests
204import os
205import sys
206";
207 let diags = lint_source(src);
208 assert!(
209 diags.iter().any(|d| d.rule == "python/import-order"),
210 "Should detect stdlib import after third-party import"
211 );
212 }
213
214 #[test]
215 fn detects_wrong_import_order_local_before_third_party() {
216 let src = r"
217from . import config
218import requests
219import os
220";
221 let diags = lint_source(src);
222 assert!(
223 diags.iter().any(|d| d.rule == "python/import-order"),
224 "Should detect wrong import order"
225 );
226 }
227
228 #[test]
229 fn accepts_correct_import_order() {
230 let src = r"
231import os
232import sys
233import json
234from typing import Dict, List
235import requests
236import django
237from . import config
238from .utils import helper
239";
240 let diags = lint_source(src);
241 assert!(
242 !diags.iter().any(|d| d.rule == "python/import-order"),
243 "Should accept correct import order"
244 );
245 }
246
247 #[test]
248 fn detects_mixed_tabs_and_spaces() {
249 let src = "def test():\n\tx = 1\n y = 2\n";
250 let diags = lint_source(src);
251 assert!(
252 diags.iter().any(|d| d.rule == "python/mixed-indentation"),
253 "Should detect mixed tabs and spaces"
254 );
255 }
256
257 #[test]
258 fn detects_inconsistent_indentation_depth() {
259 let src = r"
260def test():
261 if True:
262 x = 1
263 y = 2
264";
265 let diags = lint_source(src);
266 assert!(
267 diags
268 .iter()
269 .any(|d| d.rule == "python/inconsistent-indentation"),
270 "Should detect inconsistent indentation depth (3 spaces instead of 2 or 4)"
271 );
272 }
273
274 #[test]
275 fn accepts_consistent_indentation() {
276 let src = r"
277def test():
278 if True:
279 x = 1
280 y = 2
281 z = 3
282";
283 let diags = lint_source(src);
284 assert!(
285 !diags.iter().any(|d| d.rule == "python/mixed-indentation"
286 || d.rule == "python/inconsistent-indentation"),
287 "Should accept consistent indentation"
288 );
289 }
290
291 #[test]
292 fn handles_empty_file() {
293 let src = "";
294 let diags = lint_source(src);
295 assert!(diags.is_empty(), "Empty file should have no violations");
296 }
297
298 #[test]
299 fn handles_file_with_only_comments() {
300 let src = "# Comment 1\n# Comment 2\n# Comment 3\n";
301 let diags = lint_source(src);
302 assert!(
303 !diags.iter().any(|d| d.rule == "python/file-too-long"),
304 "File with only comments should not trigger line count"
305 );
306 }
307
308 #[test]
309 fn detects_stdlib_correctly() {
310 assert!(is_python_stdlib("os"));
311 assert!(is_python_stdlib("sys"));
312 assert!(is_python_stdlib("json"));
313 assert!(is_python_stdlib("typing"));
314 assert!(is_python_stdlib("collections"));
315 assert!(!is_python_stdlib("requests"));
316 assert!(!is_python_stdlib("django"));
317 assert!(!is_python_stdlib("numpy"));
318 }
319
320 #[test]
321 fn classifies_modules_correctly() {
322 assert_eq!(classify_module("os"), ImportType::Stdlib);
323 assert_eq!(classify_module("sys"), ImportType::Stdlib);
324 assert_eq!(classify_module("json"), ImportType::Stdlib);
325 assert_eq!(classify_module("requests"), ImportType::ThirdParty);
326 assert_eq!(classify_module("django.conf"), ImportType::ThirdParty);
327 assert_eq!(classify_module(".config"), ImportType::Local);
328 assert_eq!(classify_module("..utils"), ImportType::Local);
329 }
330
331 #[test]
332 fn ignores_long_comment_lines_when_configured() {
333 let long_comment = format!("# {}\n", "x".repeat(150));
334 let src = format!("def foo():\n{long_comment} pass\n");
335 let config = PythonConfig {
336 ignore_comments: true,
337 ..Default::default()
338 };
339 let supreme = SupremeConfig {
340 max_line_length: Some(120),
341 ..Default::default()
342 };
343 let python = Python::new(config, supreme);
344 let diags = python.lint("test.py", &src);
345 assert!(
346 !diags.iter().any(|d| d.rule == "python/line-too-long"),
347 "Should not flag long comment lines when ignore_comments is true"
348 );
349 }
350
351 #[test]
352 fn detects_long_comment_lines_when_not_configured() {
353 let long_comment = format!("# {}\n", "x".repeat(150));
354 let src = format!("def foo():\n{long_comment} pass\n");
355 let config = PythonConfig::default(); let supreme = SupremeConfig {
357 max_line_length: Some(120),
358 ..Default::default()
359 };
360 let python = Python::new(config, supreme);
361 let diags = python.lint("test.py", &src);
362 assert!(
363 diags.iter().any(|d| d.rule == "python/line-too-long"),
364 "Should flag long comment lines when ignore_comments is false"
365 );
366 }
367
368 #[test]
369 fn still_detects_long_code_lines_with_ignore_comments() {
370 let long_code = format!(" x = \"{}\"\n", "a".repeat(150));
371 let src = format!("def foo():\n{long_code} pass\n");
372 let config = PythonConfig {
373 ignore_comments: true,
374 ..Default::default()
375 };
376 let supreme = SupremeConfig {
377 max_line_length: Some(120),
378 ..Default::default()
379 };
380 let python = Python::new(config, supreme);
381 let diags = python.lint("test.py", &src);
382 assert!(
383 diags.iter().any(|d| d.rule == "python/line-too-long"),
384 "Should still flag long code lines even when ignore_comments is true"
385 );
386 }
387}