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#[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}