1#![warn(rust_2024_compatibility, clippy::all)]
2
3mod 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#[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#[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#[must_use]
106pub fn init_decree_with_config(config: PythonConfig) -> BoxDecree {
107 Box::new(Python::new(config, SupremeConfig::default()))
108}
109
110#[must_use]
112pub fn init_decree_with_configs(config: PythonConfig, supreme: SupremeConfig) -> BoxDecree {
113 Box::new(Python::new(config, supreme))
114}
115
116#[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}