1#![warn(rust_2024_compatibility, clippy::all)]
2
3mod 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#[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#[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#[must_use]
84pub fn init_decree_with_config(config: PythonConfig) -> BoxDecree {
85 Box::new(Python::new(config))
86}
87
88#[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}