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()],
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#[must_use]
88pub fn init_decree_with_config(config: PythonConfig) -> BoxDecree {
89 Box::new(Python::new(config, SupremeConfig::default()))
90}
91
92#[must_use]
94pub fn init_decree_with_configs(config: PythonConfig, supreme: SupremeConfig) -> BoxDecree {
95 Box::new(Python::new(config, supreme))
96}
97
98#[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}