dictator_rust/
lib.rs

1#![warn(rust_2024_compatibility, clippy::all)]
2
3//! decree.rust - Rust structural rules.
4
5use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
6use memchr::memchr_iter;
7
8/// Lint Rust source for structural violations.
9#[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
19/// Rule 1: File line count - max 400 lines (ignoring comments and blank lines)
20fn 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        // Count line if it's not blank and not a comment-only line
30        if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
31            code_lines += 1;
32        }
33
34        line_start = nl + 1;
35    }
36
37    // Handle last line without newline
38    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
58/// Check if a line is comment-only (// or /* */ style)
59fn is_comment_only_line(trimmed: &str) -> bool {
60    trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
61}
62
63/// Rule 2: Visibility ordering - pub items should come before private items
64fn 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        // Track raw string literals (r" or r#")
77        // Simple heuristic: line starting with `let ... = r"` or ending with `";` for multiline
78        if !in_raw_string && (trimmed.contains("= r\"") || trimmed.contains("= r#\"")) {
79            // Check if the raw string closes on the same line
80            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 there's no closing quote on this line, we're in a multiline raw string
85            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        // Skip lines inside raw string literals
96        if in_raw_string {
97            line_start = nl + 1;
98            continue;
99        }
100
101        // Track struct/impl blocks
102        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        // Check visibility within struct/impl
115        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
139/// Check if line is a struct field or impl method/associated function
140fn is_struct_field_or_impl_item(trimmed: &str) -> bool {
141    // Struct fields typically have pattern: [pub] name: Type [,]
142    // Impl items typically have pattern: [pub] fn name(...) or [pub] const/type
143    // Exclude closing braces, empty lines, attributes, and comments
144    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    // Check for impl items (fn, const, type, unsafe, etc.)
154    // These are more specific patterns, check them first
155    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    // Check for struct field pattern: identifier followed by colon and type
170    // Must start with a valid Rust identifier (letter or underscore, optionally prefixed with pub)
171    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        // Valid field name: starts with letter/underscore, contains only alphanumeric/underscore
175        !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        // 390 code lines + 60 comment lines = 450 total, but only 390 counted
237        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        // 390 code lines + 60 blank lines = 450 total, but only 390 counted
255        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}