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