dictator_ruby/
lib.rs

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