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            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/// Create decree with custom config
247#[must_use]
248pub fn init_decree_with_config(config: RustConfig) -> BoxDecree {
249    Box::new(RustDecree::new(config, SupremeConfig::default()))
250}
251
252/// Create decree with custom config + supreme config (merged from decree.supreme + decree.rust)
253#[must_use]
254pub fn init_decree_with_configs(config: RustConfig, supreme: SupremeConfig) -> BoxDecree {
255    Box::new(RustDecree::new(config, supreme))
256}
257
258/// Convert `DecreeSettings` to `RustConfig`
259#[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        // 390 code lines + 60 comment lines = 450 total, but only 390 counted
288        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        // 390 code lines + 60 blank lines = 450 total, but only 390 counted
306        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}