Skip to main content

dictator_ruby/
lib.rs

1//! Ruby hygiene rules implemented as a Dictator decree.
2
3use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
4use dictator_supreme::SupremeConfig;
5use memchr::memchr_iter;
6
7/// Configuration for ruby decree
8#[derive(Debug, Clone)]
9pub struct RubyConfig {
10    pub max_lines: usize,
11    pub ignore_comments: bool,
12}
13
14impl Default for RubyConfig {
15    fn default() -> Self {
16        Self {
17            max_lines: 300,
18            ignore_comments: false,
19        }
20    }
21}
22
23/// Lint a Ruby source file and emit diagnostics for common hygiene issues.
24#[must_use]
25pub fn lint_source(source: &str) -> Diagnostics {
26    lint_source_with_configs(source, &RubyConfig::default(), &SupremeConfig::default())
27}
28
29/// Lint with custom configuration
30#[must_use]
31pub fn lint_source_with_config(source: &str, config: &RubyConfig) -> Diagnostics {
32    let mut diags = Diagnostics::new();
33
34    diags.extend(dictator_supreme::lint_source_with_owner(
35        source,
36        &SupremeConfig::default(),
37        "ruby",
38    ));
39
40    // Ruby-specific rules
41    diags.extend(lint_ruby_specific(source, config));
42
43    diags
44}
45
46#[must_use]
47pub fn lint_source_with_configs(
48    source: &str,
49    ruby_config: &RubyConfig,
50    supreme_config: &SupremeConfig,
51) -> Diagnostics {
52    let mut diags = Diagnostics::new();
53
54    let supreme_diags = dictator_supreme::lint_source_with_owner(source, supreme_config, "ruby");
55
56    if ruby_config.ignore_comments {
57        // Filter out line-too-long violations on comment lines
58        let lines: Vec<&str> = source.lines().collect();
59        diags.extend(supreme_diags.into_iter().filter(|d| {
60            if d.rule == "ruby/line-too-long" {
61                let line_idx = source[..d.span.start].matches('\n').count();
62                !lines
63                    .get(line_idx)
64                    .is_some_and(|line| line.trim_start().starts_with('#'))
65            } else {
66                true
67            }
68        }));
69    } else {
70        diags.extend(supreme_diags);
71    }
72
73    // Ruby-specific rules
74    diags.extend(lint_ruby_specific(source, ruby_config));
75
76    diags
77}
78
79fn lint_ruby_specific(source: &str, config: &RubyConfig) -> Diagnostics {
80    let mut diags = Diagnostics::new();
81
82    // Check file line count
83    check_file_line_count(source, config.max_lines, &mut diags);
84
85    let bytes = source.as_bytes();
86    let mut line_start: usize = 0;
87    let mut line_idx: usize = 0;
88
89    for nl in memchr_iter(b'\n', bytes) {
90        process_line(source, line_start, nl, line_idx, &mut diags);
91        line_start = nl + 1;
92        line_idx += 1;
93    }
94
95    if line_start < bytes.len() {
96        // Final line without trailing newline.
97        process_line(source, line_start, bytes.len(), line_idx, &mut diags);
98    }
99
100    diags
101}
102
103/// Check file line count (excluding comments and blank lines)
104fn check_file_line_count(source: &str, max_lines: usize, diags: &mut Diagnostics) {
105    let mut code_lines = 0;
106    let bytes = source.as_bytes();
107    let mut line_start = 0;
108
109    for nl in memchr_iter(b'\n', bytes) {
110        let line = &source[line_start..nl];
111        let trimmed = line.trim();
112
113        // Count line if it's not blank and not a comment-only line
114        if !trimmed.is_empty() && !trimmed.starts_with('#') {
115            code_lines += 1;
116        }
117
118        line_start = nl + 1;
119    }
120
121    // Handle last line without newline
122    if line_start < bytes.len() {
123        let line = &source[line_start..];
124        let trimmed = line.trim();
125        if !trimmed.is_empty() && !trimmed.starts_with('#') {
126            code_lines += 1;
127        }
128    }
129
130    if code_lines > max_lines {
131        diags.push(Diagnostic {
132            rule: "ruby/file-too-long".to_string(),
133            message: format!("{code_lines} code lines (max {max_lines})"),
134            enforced: false,
135            span: Span::new(0, source.len().min(100)),
136        });
137    }
138}
139
140#[derive(Default)]
141pub struct RubyHygiene {
142    config: RubyConfig,
143    supreme: SupremeConfig,
144}
145
146impl RubyHygiene {
147    #[must_use]
148    pub const fn new(config: RubyConfig, supreme: SupremeConfig) -> Self {
149        Self { config, supreme }
150    }
151}
152
153impl Decree for RubyHygiene {
154    fn name(&self) -> &'static str {
155        "ruby"
156    }
157
158    fn lint(&self, _path: &str, source: &str) -> Diagnostics {
159        lint_source_with_configs(source, &self.config, &self.supreme)
160    }
161
162    fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
163        dictator_decree_abi::DecreeMetadata {
164            abi_version: dictator_decree_abi::ABI_VERSION.to_string(),
165            decree_version: env!("CARGO_PKG_VERSION").to_string(),
166            description: "Ruby code structure and hygiene".to_string(),
167            dectauthors: Some(env!("CARGO_PKG_AUTHORS").to_string()),
168            supported_extensions: vec!["rb".to_string(), "rake".to_string(), "gemspec".to_string()],
169            supported_filenames: vec![
170                "Gemfile".to_string(),
171                "Rakefile".to_string(),
172                "Guardfile".to_string(),
173                "Capfile".to_string(),
174                "Brewfile".to_string(),
175                "Dangerfile".to_string(),
176                "Podfile".to_string(),
177                "Fastfile".to_string(),
178                "Appfile".to_string(),
179                "Matchfile".to_string(),
180                "Berksfile".to_string(),
181                "Thorfile".to_string(),
182                "Vagrantfile".to_string(),
183                ".pryrc".to_string(),
184                ".irbrc".to_string(),
185            ],
186            skip_filenames: vec!["Gemfile.lock".to_string(), "Podfile.lock".to_string()],
187            capabilities: vec![dictator_decree_abi::Capability::Lint],
188        }
189    }
190}
191
192/// Factory used by host (native or WASM-exported).
193#[must_use]
194pub fn init_decree() -> BoxDecree {
195    Box::new(RubyHygiene::default())
196}
197
198/// Create plugin with custom config
199#[must_use]
200pub fn init_decree_with_config(config: RubyConfig) -> BoxDecree {
201    Box::new(RubyHygiene::new(config, SupremeConfig::default()))
202}
203
204/// Create plugin with custom config + supreme config (merged from decree.supreme + decree.ruby)
205#[must_use]
206pub fn init_decree_with_configs(config: RubyConfig, supreme: SupremeConfig) -> BoxDecree {
207    Box::new(RubyHygiene::new(config, supreme))
208}
209
210/// Convert `DecreeSettings` to `RubyConfig`
211#[must_use]
212pub fn config_from_decree_settings(settings: &dictator_core::DecreeSettings) -> RubyConfig {
213    RubyConfig {
214        max_lines: settings.max_lines.unwrap_or(300),
215        ignore_comments: settings.ignore_comments.unwrap_or(false),
216    }
217}
218
219fn process_line(source: &str, start: usize, end: usize, line_idx: usize, diags: &mut Diagnostics) {
220    let line = &source[start..end];
221
222    // Comment hygiene: ensure space after '#', except for known directives.
223    let trimmed = line.trim_start_matches(' ');
224    if let Some(stripped) = trimmed.strip_prefix('#')
225        && !is_comment_directive(stripped, line_idx)
226        && !stripped.starts_with(' ')
227        && !stripped.is_empty()
228    {
229        // Span of the leading '#'
230        let hash_offset = start + (line.len() - trimmed.len());
231        diags.push(Diagnostic {
232            rule: "ruby/comment-space".to_string(),
233            message: "Comments should start with '# '".to_string(),
234            enforced: true,
235            span: Span::new(hash_offset, hash_offset + 1),
236        });
237    }
238}
239
240fn is_comment_directive(rest: &str, line_idx: usize) -> bool {
241    let rest = rest.trim_start();
242
243    rest.starts_with('!') // shebang
244        || rest.starts_with("encoding")
245        || rest.starts_with("frozen_string_literal")
246        || rest.starts_with("rubocop")
247        || rest.starts_with("typed")
248        || (line_idx == 0 && rest.starts_with(" language"))
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn detects_trailing_whitespace_and_tab() {
257        let src = "def foo\n  bar \t\nend\n";
258        let diags = lint_source(src);
259        assert!(diags.iter().any(|d| d.rule == "ruby/trailing-whitespace"));
260        assert!(diags.iter().any(|d| d.rule == "ruby/tab-character"));
261    }
262
263    #[test]
264    fn detects_missing_final_newline() {
265        let src = "class Foo\nend";
266        let diags = lint_source(src);
267        assert!(diags.iter().any(|d| d.rule == "ruby/missing-final-newline"));
268    }
269
270    #[test]
271    fn enforces_comment_space() {
272        let src = "#bad\n# good\n";
273        let diags = lint_source(src);
274        assert!(diags.iter().any(|d| d.rule == "ruby/comment-space"));
275    }
276
277    #[test]
278    fn ignores_long_comment_lines_when_configured() {
279        let long_comment = format!("# {}\n", "x".repeat(150));
280        let src = format!("def foo\n{long_comment}end\n");
281        let config = RubyConfig {
282            ignore_comments: true,
283            ..Default::default()
284        };
285        let supreme = SupremeConfig {
286            max_line_length: Some(120),
287            ..Default::default()
288        };
289        let diags = lint_source_with_configs(&src, &config, &supreme);
290        assert!(!diags.iter().any(|d| d.rule == "ruby/line-too-long"));
291    }
292
293    #[test]
294    fn detects_long_comment_lines_when_not_configured() {
295        let long_comment = format!("# {}\n", "x".repeat(150));
296        let src = format!("def foo\n{long_comment}end\n");
297        let config = RubyConfig::default(); // ignore_comments = false
298        let supreme = SupremeConfig {
299            max_line_length: Some(120),
300            ..Default::default()
301        };
302        let diags = lint_source_with_configs(&src, &config, &supreme);
303        assert!(diags.iter().any(|d| d.rule == "ruby/line-too-long"));
304    }
305
306    #[test]
307    fn still_detects_long_code_lines_with_ignore_comments() {
308        let long_code = format!("  x = \"{}\"\n", "a".repeat(150));
309        let src = format!("def foo\n{long_code}end\n");
310        let config = RubyConfig {
311            ignore_comments: true,
312            ..Default::default()
313        };
314        let supreme = SupremeConfig {
315            max_line_length: Some(120),
316            ..Default::default()
317        };
318        let diags = lint_source_with_configs(&src, &config, &supreme);
319        assert!(diags.iter().any(|d| d.rule == "ruby/line-too-long"));
320    }
321
322    #[test]
323    fn detects_whitespace_only_blank_line() {
324        let src = "def foo\n  bar\n    \nend\n"; // blank line has spaces
325        let diags = lint_source(src);
326        assert!(diags.iter().any(|d| d.rule == "ruby/blank-line-whitespace"));
327    }
328}