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 capabilities: vec![dictator_decree_abi::Capability::Lint],
237 }
238 }
239}
240
241#[must_use]
242pub fn init_decree() -> BoxDecree {
243 Box::new(RustDecree::default())
244}
245
246#[must_use]
248pub fn init_decree_with_config(config: RustConfig) -> BoxDecree {
249 Box::new(RustDecree::new(config, SupremeConfig::default()))
250}
251
252#[must_use]
254pub fn init_decree_with_configs(config: RustConfig, supreme: SupremeConfig) -> BoxDecree {
255 Box::new(RustDecree::new(config, supreme))
256}
257
258#[must_use]
260pub fn config_from_decree_settings(settings: &dictator_core::DecreeSettings) -> RustConfig {
261 RustConfig {
262 max_lines: settings.max_lines.unwrap_or(400),
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn detects_file_too_long() {
272 use std::fmt::Write;
273 let mut src = String::new();
274 for i in 0..410 {
275 let _ = writeln!(src, "let x{i} = {i};");
276 }
277 let diags = lint_source(&src);
278 assert!(
279 diags.iter().any(|d| d.rule == "rust/file-too-long"),
280 "Should detect file with >400 code lines"
281 );
282 }
283
284 #[test]
285 fn ignores_comments_in_line_count() {
286 use std::fmt::Write;
287 let mut src = String::new();
289 for i in 0..390 {
290 let _ = writeln!(src, "let x{i} = {i};");
291 }
292 for i in 0..60 {
293 let _ = writeln!(src, "// Comment {i}");
294 }
295 let diags = lint_source(&src);
296 assert!(
297 !diags.iter().any(|d| d.rule == "rust/file-too-long"),
298 "Should not count comment-only lines"
299 );
300 }
301
302 #[test]
303 fn ignores_blank_lines_in_count() {
304 use std::fmt::Write;
305 let mut src = String::new();
307 for i in 0..390 {
308 let _ = writeln!(src, "let x{i} = {i};");
309 }
310 for _ in 0..60 {
311 src.push('\n');
312 }
313 let diags = lint_source(&src);
314 assert!(
315 !diags.iter().any(|d| d.rule == "rust/file-too-long"),
316 "Should not count blank lines"
317 );
318 }
319
320 #[test]
321 fn detects_pub_after_private_in_struct() {
322 let src = r"
323struct User {
324 name: String,
325 age: u32,
326 pub email: String,
327}
328";
329 let diags = lint_source(src);
330 assert!(
331 diags.iter().any(|d| d.rule == "rust/visibility-order"),
332 "Should detect pub field after private fields in struct"
333 );
334 }
335
336 #[test]
337 fn detects_pub_after_private_in_impl() {
338 let src = r"
339impl User {
340 fn private_method(&self) {}
341 pub fn public_method(&self) {}
342}
343";
344 let diags = lint_source(src);
345 assert!(
346 diags.iter().any(|d| d.rule == "rust/visibility-order"),
347 "Should detect pub method after private method in impl"
348 );
349 }
350
351 #[test]
352 fn accepts_pub_before_private() {
353 let src = r"
354struct User {
355 pub id: u32,
356 pub name: String,
357 email: String,
358}
359";
360 let diags = lint_source(src);
361 assert!(
362 !diags.iter().any(|d| d.rule == "rust/visibility-order"),
363 "Should accept public fields before private fields"
364 );
365 }
366
367 #[test]
368 fn accepts_impl_with_correct_order() {
369 let src = r"
370impl User {
371 pub fn new(name: String) -> Self {
372 User { name }
373 }
374
375 pub fn get_name(&self) -> &str {
376 &self.name
377 }
378
379 fn validate(&self) -> bool {
380 true
381 }
382}
383";
384 let diags = lint_source(src);
385 assert!(
386 !diags.iter().any(|d| d.rule == "rust/visibility-order"),
387 "Should accept impl with public methods before private"
388 );
389 }
390
391 #[test]
392 fn handles_empty_file() {
393 let src = "";
394 let diags = lint_source(src);
395 assert!(diags.is_empty(), "Empty file should have no violations");
396 }
397
398 #[test]
399 fn handles_file_with_only_comments() {
400 let src = "// Comment 1\n// Comment 2\n/* Block comment */\n";
401 let diags = lint_source(src);
402 assert!(
403 !diags.iter().any(|d| d.rule == "rust/file-too-long"),
404 "File with only comments should not trigger line count"
405 );
406 }
407}