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