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 dictator_supreme::SupremeConfig;
7use memchr::memchr_iter;
8
9/// Configuration for rust decree
10#[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/// Lint Rust source for structural violations.
22#[must_use]
23pub fn lint_source(source: &str) -> Diagnostics {
24    lint_source_with_config(source, &RustConfig::default())
25}
26
27/// Lint with custom configuration
28#[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
38/// Rule 1: File line count (ignoring comments and blank lines)
39fn 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        // Count line if it's not blank and not a comment-only line
49        if !trimmed.is_empty() && !is_comment_only_line(trimmed) {
50            code_lines += 1;
51        }
52
53        line_start = nl + 1;
54    }
55
56    // Handle last line without newline
57    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
77/// Check if a line is comment-only (// or /* */ style)
78fn is_comment_only_line(trimmed: &str) -> bool {
79    trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
80}
81
82/// Rule 2: Visibility ordering - pub items should come before private items
83fn 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        // Track raw string literals (r" or r#")
96        // Simple heuristic: line starting with `let ... = r"` or ending with `";` for multiline
97        if !in_raw_string && (trimmed.contains("= r\"") || trimmed.contains("= r#\"")) {
98            // Check if the raw string closes on the same line
99            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 there's no closing quote on this line, we're in a multiline raw string
104            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        // Skip lines inside raw string literals
115        if in_raw_string {
116            line_start = nl + 1;
117            continue;
118        }
119
120        // Track struct/impl blocks
121        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        // Check visibility within struct/impl
134        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
158/// Check if line is a struct field or impl method/associated function
159fn is_struct_field_or_impl_item(trimmed: &str) -> bool {
160    // Struct fields typically have pattern: [pub] name: Type [,]
161    // Impl items typically have pattern: [pub] fn name(...) or [pub] const/type
162    // Exclude closing braces, empty lines, attributes, and comments
163    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    // Check for impl items (fn, const, type, unsafe, etc.)
173    // These are more specific patterns, check them first
174    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    // Check for struct field pattern: identifier followed by colon and type
189    // Must start with a valid Rust identifier (letter or underscore, optionally prefixed with pub)
190    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        // Valid field name: starts with letter/underscore, contains only alphanumeric/underscore
194        !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/// Create decree with custom config
258#[must_use]
259pub fn init_decree_with_config(config: RustConfig) -> BoxDecree {
260    Box::new(RustDecree::new(config, SupremeConfig::default()))
261}
262
263/// Create decree with custom config + supreme config (merged from decree.supreme + decree.rust)
264#[must_use]
265pub fn init_decree_with_configs(config: RustConfig, supreme: SupremeConfig) -> BoxDecree {
266    Box::new(RustDecree::new(config, supreme))
267}
268
269/// Convert `DecreeSettings` to `RustConfig`
270#[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        // 390 code lines + 60 comment lines = 450 total, but only 390 counted
299        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        // 390 code lines + 60 blank lines = 450 total, but only 390 counted
317        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}