1use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
4use memchr::memchr_iter;
5
6#[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#[must_use]
20pub fn lint_source(source: &str) -> Diagnostics {
21 lint_source_with_config(source, &RubyConfig::default())
22}
23
24#[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(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 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
56fn 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 if !trimmed.is_empty() && !trimmed.starts_with('#') {
68 code_lines += 1;
69 }
70
71 line_start = nl + 1;
72 }
73
74 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#[must_use]
128pub fn init_decree() -> BoxDecree {
129 Box::new(RubyHygiene::default())
130}
131
132#[must_use]
134pub fn init_decree_with_config(config: RubyConfig) -> BoxDecree {
135 Box::new(RubyHygiene::new(config))
136}
137
138#[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 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 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 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 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 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 if line.is_empty() && !had_newline {
206 }
208}
209
210fn is_comment_directive(rest: &str, line_idx: usize) -> bool {
211 let rest = rest.trim_start();
212
213 rest.starts_with('!') || 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"; let diags = lint_source(src);
251 assert!(diags.iter().any(|d| d.rule == "ruby/blank-line-whitespace"));
252 }
253}