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/// Configuration for rust decree
9#[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/// Lint Rust source for structural violations.
21#[must_use]
22pub fn lint_source(source: &str) -> Diagnostics {
23    lint_source_with_config(source, &RustConfig::default())
24}
25
26/// Lint with custom configuration
27#[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
37/// Rule 1: File line count (ignoring comments and blank lines)
38fn 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        // Count line if it's not blank and not a comment-only line
48        if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
49            code_lines += 1;
50        }
51
52        line_start = nl + 1;
53    }
54
55    // Handle last line without newline
56    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
76/// Check if a line is comment-only (// or /* */ style)
77fn is_comment_only_line(trimmed: &str) -> bool {
78    trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
79}
80
81/// Rule 2: Visibility ordering - pub items should come before private items
82fn 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        // Track raw string literals (r" or r#")
95        // Simple heuristic: line starting with `let ... = r"` or ending with `";` for multiline
96        if !in_raw_string && (trimmed.contains("= r\"") || trimmed.contains("= r#\"")) {
97            // Check if the raw string closes on the same line
98            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 there's no closing quote on this line, we're in a multiline raw string
103            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        // Skip lines inside raw string literals
114        if in_raw_string {
115            line_start = nl + 1;
116            continue;
117        }
118
119        // Track struct/impl blocks
120        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        // Check visibility within struct/impl
133        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
157/// Check if line is a struct field or impl method/associated function
158fn is_struct_field_or_impl_item(trimmed: &str) -> bool {
159    // Struct fields typically have pattern: [pub] name: Type [,]
160    // Impl items typically have pattern: [pub] fn name(...) or [pub] const/type
161    // Exclude closing braces, empty lines, attributes, and comments
162    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    // Check for impl items (fn, const, type, unsafe, etc.)
172    // These are more specific patterns, check them first
173    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    // Check for struct field pattern: identifier followed by colon and type
188    // Must start with a valid Rust identifier (letter or underscore, optionally prefixed with pub)
189    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        // Valid field name: starts with letter/underscore, contains only alphanumeric/underscore
193        !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/// Create decree with custom config
243#[must_use]
244pub fn init_decree_with_config(config: RustConfig) -> BoxDecree {
245    Box::new(RustDecree::new(config))
246}
247
248/// Convert `DecreeSettings` to `RustConfig`
249#[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        // 390 code lines + 60 comment lines = 450 total, but only 390 counted
278        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        // 390 code lines + 60 blank lines = 450 total, but only 390 counted
296        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}