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