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()],
76            capabilities: vec![dictator_decree_abi::Capability::Lint],
77        }
78    }
79}
80
81#[must_use]
82pub fn init_decree() -> BoxDecree {
83    Box::new(Python::default())
84}
85
86/// Create decree with custom config
87#[must_use]
88pub fn init_decree_with_config(config: PythonConfig) -> BoxDecree {
89    Box::new(Python::new(config, SupremeConfig::default()))
90}
91
92/// Create decree with custom config + supreme config (merged from decree.supreme + decree.python)
93#[must_use]
94pub fn init_decree_with_configs(config: PythonConfig, supreme: SupremeConfig) -> BoxDecree {
95    Box::new(Python::new(config, supreme))
96}
97
98/// Convert `DecreeSettings` to `PythonConfig`
99#[must_use]
100pub fn config_from_decree_settings(settings: &dictator_core::DecreeSettings) -> PythonConfig {
101    PythonConfig {
102        max_lines: settings.max_lines.unwrap_or(file_length::DEFAULT_MAX_LINES),
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn detects_file_too_long() {
112        use std::fmt::Write;
113        let mut src = String::new();
114        for i in 0..400 {
115            let _ = writeln!(src, "x = {i}");
116        }
117        let diags = lint_source(&src);
118        assert!(
119            diags.iter().any(|d| d.rule == "python/file-too-long"),
120            "Should detect file with >380 code lines"
121        );
122    }
123
124    #[test]
125    fn ignores_comments_in_line_count() {
126        use std::fmt::Write;
127        let mut src = String::new();
128        for i in 0..380 {
129            let _ = writeln!(src, "x = {i}");
130        }
131        for i in 0..60 {
132            let _ = writeln!(src, "# Comment {i}");
133        }
134        let diags = lint_source(&src);
135        assert!(
136            !diags.iter().any(|d| d.rule == "python/file-too-long"),
137            "Should not count comment-only lines"
138        );
139    }
140
141    #[test]
142    fn ignores_blank_lines_in_count() {
143        use std::fmt::Write;
144        let mut src = String::new();
145        for i in 0..380 {
146            let _ = writeln!(src, "x = {i}");
147        }
148        for _ in 0..60 {
149            src.push('\n');
150        }
151        let diags = lint_source(&src);
152        assert!(
153            !diags.iter().any(|d| d.rule == "python/file-too-long"),
154            "Should not count blank lines"
155        );
156    }
157
158    #[test]
159    fn detects_wrong_import_order_stdlib_after_third_party() {
160        let src = r"
161import requests
162import os
163import sys
164";
165        let diags = lint_source(src);
166        assert!(
167            diags.iter().any(|d| d.rule == "python/import-order"),
168            "Should detect stdlib import after third-party import"
169        );
170    }
171
172    #[test]
173    fn detects_wrong_import_order_local_before_third_party() {
174        let src = r"
175from . import config
176import requests
177import os
178";
179        let diags = lint_source(src);
180        assert!(
181            diags.iter().any(|d| d.rule == "python/import-order"),
182            "Should detect wrong import order"
183        );
184    }
185
186    #[test]
187    fn accepts_correct_import_order() {
188        let src = r"
189import os
190import sys
191import json
192from typing import Dict, List
193import requests
194import django
195from . import config
196from .utils import helper
197";
198        let diags = lint_source(src);
199        assert!(
200            !diags.iter().any(|d| d.rule == "python/import-order"),
201            "Should accept correct import order"
202        );
203    }
204
205    #[test]
206    fn detects_mixed_tabs_and_spaces() {
207        let src = "def test():\n\tx = 1\n  y = 2\n";
208        let diags = lint_source(src);
209        assert!(
210            diags.iter().any(|d| d.rule == "python/mixed-indentation"),
211            "Should detect mixed tabs and spaces"
212        );
213    }
214
215    #[test]
216    fn detects_inconsistent_indentation_depth() {
217        let src = r"
218def test():
219  if True:
220     x = 1
221  y = 2
222";
223        let diags = lint_source(src);
224        assert!(
225            diags
226                .iter()
227                .any(|d| d.rule == "python/inconsistent-indentation"),
228            "Should detect inconsistent indentation depth (3 spaces instead of 2 or 4)"
229        );
230    }
231
232    #[test]
233    fn accepts_consistent_indentation() {
234        let src = r"
235def test():
236    if True:
237        x = 1
238        y = 2
239    z = 3
240";
241        let diags = lint_source(src);
242        assert!(
243            !diags.iter().any(|d| d.rule == "python/mixed-indentation"
244                || d.rule == "python/inconsistent-indentation"),
245            "Should accept consistent indentation"
246        );
247    }
248
249    #[test]
250    fn handles_empty_file() {
251        let src = "";
252        let diags = lint_source(src);
253        assert!(diags.is_empty(), "Empty file should have no violations");
254    }
255
256    #[test]
257    fn handles_file_with_only_comments() {
258        let src = "# Comment 1\n# Comment 2\n# Comment 3\n";
259        let diags = lint_source(src);
260        assert!(
261            !diags.iter().any(|d| d.rule == "python/file-too-long"),
262            "File with only comments should not trigger line count"
263        );
264    }
265
266    #[test]
267    fn detects_stdlib_correctly() {
268        assert!(is_python_stdlib("os"));
269        assert!(is_python_stdlib("sys"));
270        assert!(is_python_stdlib("json"));
271        assert!(is_python_stdlib("typing"));
272        assert!(is_python_stdlib("collections"));
273        assert!(!is_python_stdlib("requests"));
274        assert!(!is_python_stdlib("django"));
275        assert!(!is_python_stdlib("numpy"));
276    }
277
278    #[test]
279    fn classifies_modules_correctly() {
280        assert_eq!(classify_module("os"), ImportType::Stdlib);
281        assert_eq!(classify_module("sys"), ImportType::Stdlib);
282        assert_eq!(classify_module("json"), ImportType::Stdlib);
283        assert_eq!(classify_module("requests"), ImportType::ThirdParty);
284        assert_eq!(classify_module("django.conf"), ImportType::ThirdParty);
285        assert_eq!(classify_module(".config"), ImportType::Local);
286        assert_eq!(classify_module("..utils"), ImportType::Local);
287    }
288}