1#![warn(rust_2024_compatibility, clippy::all)]
2
3use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
6use memchr::memchr_iter;
7
8#[must_use]
10pub fn lint_source(source: &str) -> Diagnostics {
11 let mut diags = Diagnostics::new();
12
13 check_file_line_count(source, &mut diags);
14 check_visibility_ordering(source, &mut diags);
15
16 diags
17}
18
19fn check_file_line_count(source: &str, diags: &mut Diagnostics) {
21 let mut code_lines = 0;
22 let bytes = source.as_bytes();
23 let mut line_start = 0;
24
25 for nl in memchr_iter(b'\n', bytes) {
26 let line = &source[line_start..nl];
27 let trimmed = line.trim();
28
29 if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
31 code_lines += 1;
32 }
33
34 line_start = nl + 1;
35 }
36
37 if line_start < bytes.len() {
39 let line = &source[line_start..];
40 let trimmed = line.trim();
41 if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
42 code_lines += 1;
43 }
44 }
45
46 if code_lines > 400 {
47 diags.push(Diagnostic {
48 rule: "rust/file-too-long".to_string(),
49 message: format!(
50 "File has {code_lines} code lines (max 400, excluding comments and blank lines)"
51 ),
52 enforced: false,
53 span: Span::new(0, source.len().min(100)),
54 });
55 }
56}
57
58fn is_comment_only_line(trimmed: &str) -> bool {
60 trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
61}
62
63fn check_visibility_ordering(source: &str, diags: &mut Diagnostics) {
65 let bytes = source.as_bytes();
66 let mut line_start = 0;
67 let mut in_struct = false;
68 let mut in_impl = false;
69 let mut has_private = false;
70 let mut in_raw_string = false;
71
72 for nl in memchr_iter(b'\n', bytes) {
73 let line = &source[line_start..nl];
74 let trimmed = line.trim();
75
76 if !in_raw_string && (trimmed.contains("= r\"") || trimmed.contains("= r#\"")) {
79 let after_open = trimmed.find("= r\"").map_or_else(
81 || trimmed.find("= r#\"").map_or("", |pos| &trimmed[pos + 5..]),
82 |pos| &trimmed[pos + 4..],
83 );
84 if !after_open.contains('"') {
86 in_raw_string = true;
87 }
88 } else if in_raw_string && (trimmed.ends_with("\";") || trimmed == "\";" || trimmed == "\"")
89 {
90 in_raw_string = false;
91 line_start = nl + 1;
92 continue;
93 }
94
95 if in_raw_string {
97 line_start = nl + 1;
98 continue;
99 }
100
101 if trimmed.contains("struct ") && trimmed.contains('{') {
103 in_struct = true;
104 has_private = false;
105 } else if trimmed.contains("impl ") && trimmed.contains('{') {
106 in_impl = true;
107 has_private = false;
108 } else if trimmed == "}" || trimmed.starts_with("}\n") {
109 in_struct = false;
110 in_impl = false;
111 has_private = false;
112 }
113
114 if (in_struct || in_impl) && !trimmed.is_empty() && !is_comment_only_line(trimmed) {
116 let is_pub = trimmed.starts_with("pub ");
117 let is_field_or_method = is_struct_field_or_impl_item(trimmed);
118
119 if is_field_or_method {
120 if !is_pub && !has_private {
121 has_private = true;
122 } else if is_pub && has_private {
123 diags.push(Diagnostic {
124 rule: "rust/visibility-order".to_string(),
125 message:
126 "Public item found after private item. Expected all public items first"
127 .to_string(),
128 enforced: false,
129 span: Span::new(line_start, nl),
130 });
131 }
132 }
133 }
134
135 line_start = nl + 1;
136 }
137}
138
139fn is_struct_field_or_impl_item(trimmed: &str) -> bool {
141 if trimmed.is_empty()
145 || trimmed == "}"
146 || trimmed.starts_with('}')
147 || trimmed.starts_with('#')
148 || trimmed.starts_with("//")
149 {
150 return false;
151 }
152
153 if trimmed.starts_with("fn ")
156 || trimmed.starts_with("pub fn ")
157 || trimmed.starts_with("const ")
158 || trimmed.starts_with("pub const ")
159 || trimmed.starts_with("type ")
160 || trimmed.starts_with("pub type ")
161 || trimmed.starts_with("unsafe fn ")
162 || trimmed.starts_with("pub unsafe fn ")
163 || trimmed.starts_with("async fn ")
164 || trimmed.starts_with("pub async fn ")
165 {
166 return true;
167 }
168
169 let field_part = trimmed.strip_prefix("pub ").unwrap_or(trimmed);
172 field_part.find(':').is_some_and(|colon_pos| {
173 let before_colon = field_part[..colon_pos].trim();
174 !before_colon.is_empty()
176 && before_colon
177 .chars()
178 .next()
179 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
180 && before_colon
181 .chars()
182 .all(|c| c.is_ascii_alphanumeric() || c == '_')
183 })
184}
185
186#[derive(Default)]
187pub struct RustDecree;
188
189impl Decree for RustDecree {
190 fn name(&self) -> &'static str {
191 "rust"
192 }
193
194 fn lint(&self, _path: &str, source: &str) -> Diagnostics {
195 lint_source(source)
196 }
197
198 fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
199 dictator_decree_abi::DecreeMetadata {
200 abi_version: dictator_decree_abi::ABI_VERSION.to_string(),
201 decree_version: env!("CARGO_PKG_VERSION").to_string(),
202 description: "Rust structural rules".to_string(),
203 dectauthors: Some(env!("CARGO_PKG_AUTHORS").to_string()),
204 supported_extensions: vec!["rs".to_string()],
205 capabilities: vec![dictator_decree_abi::Capability::Lint],
206 }
207 }
208}
209
210#[must_use]
211pub fn init_decree() -> BoxDecree {
212 Box::new(RustDecree)
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn detects_file_too_long() {
221 use std::fmt::Write;
222 let mut src = String::new();
223 for i in 0..410 {
224 let _ = writeln!(src, "let x{i} = {i};");
225 }
226 let diags = lint_source(&src);
227 assert!(
228 diags.iter().any(|d| d.rule == "rust/file-too-long"),
229 "Should detect file with >400 code lines"
230 );
231 }
232
233 #[test]
234 fn ignores_comments_in_line_count() {
235 use std::fmt::Write;
236 let mut src = String::new();
238 for i in 0..390 {
239 let _ = writeln!(src, "let x{i} = {i};");
240 }
241 for i in 0..60 {
242 let _ = writeln!(src, "// Comment {i}");
243 }
244 let diags = lint_source(&src);
245 assert!(
246 !diags.iter().any(|d| d.rule == "rust/file-too-long"),
247 "Should not count comment-only lines"
248 );
249 }
250
251 #[test]
252 fn ignores_blank_lines_in_count() {
253 use std::fmt::Write;
254 let mut src = String::new();
256 for i in 0..390 {
257 let _ = writeln!(src, "let x{i} = {i};");
258 }
259 for _ in 0..60 {
260 src.push('\n');
261 }
262 let diags = lint_source(&src);
263 assert!(
264 !diags.iter().any(|d| d.rule == "rust/file-too-long"),
265 "Should not count blank lines"
266 );
267 }
268
269 #[test]
270 fn detects_pub_after_private_in_struct() {
271 let src = r"
272struct User {
273 name: String,
274 age: u32,
275 pub email: String,
276}
277";
278 let diags = lint_source(src);
279 assert!(
280 diags.iter().any(|d| d.rule == "rust/visibility-order"),
281 "Should detect pub field after private fields in struct"
282 );
283 }
284
285 #[test]
286 fn detects_pub_after_private_in_impl() {
287 let src = r"
288impl User {
289 fn private_method(&self) {}
290 pub fn public_method(&self) {}
291}
292";
293 let diags = lint_source(src);
294 assert!(
295 diags.iter().any(|d| d.rule == "rust/visibility-order"),
296 "Should detect pub method after private method in impl"
297 );
298 }
299
300 #[test]
301 fn accepts_pub_before_private() {
302 let src = r"
303struct User {
304 pub id: u32,
305 pub name: String,
306 email: String,
307}
308";
309 let diags = lint_source(src);
310 assert!(
311 !diags.iter().any(|d| d.rule == "rust/visibility-order"),
312 "Should accept public fields before private fields"
313 );
314 }
315
316 #[test]
317 fn accepts_impl_with_correct_order() {
318 let src = r"
319impl User {
320 pub fn new(name: String) -> Self {
321 User { name }
322 }
323
324 pub fn get_name(&self) -> &str {
325 &self.name
326 }
327
328 fn validate(&self) -> bool {
329 true
330 }
331}
332";
333 let diags = lint_source(src);
334 assert!(
335 !diags.iter().any(|d| d.rule == "rust/visibility-order"),
336 "Should accept impl with public methods before private"
337 );
338 }
339
340 #[test]
341 fn handles_empty_file() {
342 let src = "";
343 let diags = lint_source(src);
344 assert!(diags.is_empty(), "Empty file should have no violations");
345 }
346
347 #[test]
348 fn handles_file_with_only_comments() {
349 let src = "// Comment 1\n// Comment 2\n/* Block comment */\n";
350 let diags = lint_source(src);
351 assert!(
352 !diags.iter().any(|d| d.rule == "rust/file-too-long"),
353 "File with only comments should not trigger line count"
354 );
355 }
356}