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