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