Skip to main content

dictator_python/
lib.rs

1#![warn(rust_2024_compatibility, clippy::all)]
2
3//! decree.python - Python structural rules (PEP 8 compliant).
4
5mod 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/// Configuration for python decree
15#[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/// Lint with custom configuration
36#[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            // Filter out line-too-long violations on comment lines
73            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/// Create decree with custom config
128#[must_use]
129pub fn init_decree_with_config(config: PythonConfig) -> BoxDecree {
130    Box::new(Python::new(config, SupremeConfig::default()))
131}
132
133/// Create decree with custom config + supreme config (merged from decree.supreme + decree.python)
134#[must_use]
135pub fn init_decree_with_configs(config: PythonConfig, supreme: SupremeConfig) -> BoxDecree {
136    Box::new(Python::new(config, supreme))
137}
138
139/// Convert `DecreeSettings` to `PythonConfig`
140#[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(); // ignore_comments = false
356        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}