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}
19
20impl Default for PythonConfig {
21    fn default() -> Self {
22        Self {
23            max_lines: file_length::DEFAULT_MAX_LINES,
24        }
25    }
26}
27
28#[must_use]
29pub fn lint_source(source: &str) -> Diagnostics {
30    lint_source_with_config(source, &PythonConfig::default())
31}
32
33/// Lint with custom configuration
34#[must_use]
35pub fn lint_source_with_config(source: &str, config: &PythonConfig) -> Diagnostics {
36    let mut diags = Diagnostics::new();
37
38    file_length::check_file_line_count(source, config.max_lines, &mut diags);
39    imports::check_import_ordering(source, &mut diags);
40    indentation::check_indentation_consistency(source, &mut diags);
41
42    diags
43}
44
45#[derive(Default)]
46pub struct Python {
47    config: PythonConfig,
48    supreme: SupremeConfig,
49}
50
51impl Python {
52    #[must_use]
53    pub const fn new(config: PythonConfig, supreme: SupremeConfig) -> Self {
54        Self { config, supreme }
55    }
56}
57
58impl Decree for Python {
59    fn name(&self) -> &'static str {
60        "python"
61    }
62
63    fn lint(&self, _path: &str, source: &str) -> Diagnostics {
64        let mut diags = dictator_supreme::lint_source_with_owner(source, &self.supreme, "python");
65        diags.extend(lint_source_with_config(source, &self.config));
66        diags
67    }
68
69    fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
70        dictator_decree_abi::DecreeMetadata {
71            abi_version: dictator_decree_abi::ABI_VERSION.to_string(),
72            decree_version: env!("CARGO_PKG_VERSION").to_string(),
73            description: "Python structural rules".to_string(),
74            dectauthors: Some(env!("CARGO_PKG_AUTHORS").to_string()),
75            supported_extensions: vec!["py".to_string(), "pyi".to_string(), "pyw".to_string()],
76            supported_filenames: vec![
77                "pyproject.toml".to_string(),
78                "setup.py".to_string(),
79                "setup.cfg".to_string(),
80                "Pipfile".to_string(),
81                "requirements.txt".to_string(),
82                "requirements-dev.txt".to_string(),
83                "constraints.txt".to_string(),
84                ".python-version".to_string(),
85                "pyrightconfig.json".to_string(),
86                "mypy.ini".to_string(),
87            ],
88            skip_filenames: vec![
89                "Pipfile.lock".to_string(),
90                "poetry.lock".to_string(),
91                "uv.lock".to_string(),
92                "pdm.lock".to_string(),
93            ],
94            capabilities: vec![dictator_decree_abi::Capability::Lint],
95        }
96    }
97}
98
99#[must_use]
100pub fn init_decree() -> BoxDecree {
101    Box::new(Python::default())
102}
103
104/// Create decree with custom config
105#[must_use]
106pub fn init_decree_with_config(config: PythonConfig) -> BoxDecree {
107    Box::new(Python::new(config, SupremeConfig::default()))
108}
109
110/// Create decree with custom config + supreme config (merged from decree.supreme + decree.python)
111#[must_use]
112pub fn init_decree_with_configs(config: PythonConfig, supreme: SupremeConfig) -> BoxDecree {
113    Box::new(Python::new(config, supreme))
114}
115
116/// Convert `DecreeSettings` to `PythonConfig`
117#[must_use]
118pub fn config_from_decree_settings(settings: &dictator_core::DecreeSettings) -> PythonConfig {
119    PythonConfig {
120        max_lines: settings.max_lines.unwrap_or(file_length::DEFAULT_MAX_LINES),
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn detects_file_too_long() {
130        use std::fmt::Write;
131        let mut src = String::new();
132        for i in 0..400 {
133            let _ = writeln!(src, "x = {i}");
134        }
135        let diags = lint_source(&src);
136        assert!(
137            diags.iter().any(|d| d.rule == "python/file-too-long"),
138            "Should detect file with >380 code lines"
139        );
140    }
141
142    #[test]
143    fn ignores_comments_in_line_count() {
144        use std::fmt::Write;
145        let mut src = String::new();
146        for i in 0..380 {
147            let _ = writeln!(src, "x = {i}");
148        }
149        for i in 0..60 {
150            let _ = writeln!(src, "# Comment {i}");
151        }
152        let diags = lint_source(&src);
153        assert!(
154            !diags.iter().any(|d| d.rule == "python/file-too-long"),
155            "Should not count comment-only lines"
156        );
157    }
158
159    #[test]
160    fn ignores_blank_lines_in_count() {
161        use std::fmt::Write;
162        let mut src = String::new();
163        for i in 0..380 {
164            let _ = writeln!(src, "x = {i}");
165        }
166        for _ in 0..60 {
167            src.push('\n');
168        }
169        let diags = lint_source(&src);
170        assert!(
171            !diags.iter().any(|d| d.rule == "python/file-too-long"),
172            "Should not count blank lines"
173        );
174    }
175
176    #[test]
177    fn detects_wrong_import_order_stdlib_after_third_party() {
178        let src = r"
179import requests
180import os
181import sys
182";
183        let diags = lint_source(src);
184        assert!(
185            diags.iter().any(|d| d.rule == "python/import-order"),
186            "Should detect stdlib import after third-party import"
187        );
188    }
189
190    #[test]
191    fn detects_wrong_import_order_local_before_third_party() {
192        let src = r"
193from . import config
194import requests
195import os
196";
197        let diags = lint_source(src);
198        assert!(
199            diags.iter().any(|d| d.rule == "python/import-order"),
200            "Should detect wrong import order"
201        );
202    }
203
204    #[test]
205    fn accepts_correct_import_order() {
206        let src = r"
207import os
208import sys
209import json
210from typing import Dict, List
211import requests
212import django
213from . import config
214from .utils import helper
215";
216        let diags = lint_source(src);
217        assert!(
218            !diags.iter().any(|d| d.rule == "python/import-order"),
219            "Should accept correct import order"
220        );
221    }
222
223    #[test]
224    fn detects_mixed_tabs_and_spaces() {
225        let src = "def test():\n\tx = 1\n  y = 2\n";
226        let diags = lint_source(src);
227        assert!(
228            diags.iter().any(|d| d.rule == "python/mixed-indentation"),
229            "Should detect mixed tabs and spaces"
230        );
231    }
232
233    #[test]
234    fn detects_inconsistent_indentation_depth() {
235        let src = r"
236def test():
237  if True:
238     x = 1
239  y = 2
240";
241        let diags = lint_source(src);
242        assert!(
243            diags
244                .iter()
245                .any(|d| d.rule == "python/inconsistent-indentation"),
246            "Should detect inconsistent indentation depth (3 spaces instead of 2 or 4)"
247        );
248    }
249
250    #[test]
251    fn accepts_consistent_indentation() {
252        let src = r"
253def test():
254    if True:
255        x = 1
256        y = 2
257    z = 3
258";
259        let diags = lint_source(src);
260        assert!(
261            !diags.iter().any(|d| d.rule == "python/mixed-indentation"
262                || d.rule == "python/inconsistent-indentation"),
263            "Should accept consistent indentation"
264        );
265    }
266
267    #[test]
268    fn handles_empty_file() {
269        let src = "";
270        let diags = lint_source(src);
271        assert!(diags.is_empty(), "Empty file should have no violations");
272    }
273
274    #[test]
275    fn handles_file_with_only_comments() {
276        let src = "# Comment 1\n# Comment 2\n# Comment 3\n";
277        let diags = lint_source(src);
278        assert!(
279            !diags.iter().any(|d| d.rule == "python/file-too-long"),
280            "File with only comments should not trigger line count"
281        );
282    }
283
284    #[test]
285    fn detects_stdlib_correctly() {
286        assert!(is_python_stdlib("os"));
287        assert!(is_python_stdlib("sys"));
288        assert!(is_python_stdlib("json"));
289        assert!(is_python_stdlib("typing"));
290        assert!(is_python_stdlib("collections"));
291        assert!(!is_python_stdlib("requests"));
292        assert!(!is_python_stdlib("django"));
293        assert!(!is_python_stdlib("numpy"));
294    }
295
296    #[test]
297    fn classifies_modules_correctly() {
298        assert_eq!(classify_module("os"), ImportType::Stdlib);
299        assert_eq!(classify_module("sys"), ImportType::Stdlib);
300        assert_eq!(classify_module("json"), ImportType::Stdlib);
301        assert_eq!(classify_module("requests"), ImportType::ThirdParty);
302        assert_eq!(classify_module("django.conf"), ImportType::ThirdParty);
303        assert_eq!(classify_module(".config"), ImportType::Local);
304        assert_eq!(classify_module("..utils"), ImportType::Local);
305    }
306}