1use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
4use dictator_supreme::SupremeConfig;
5use memchr::memchr_iter;
6
7#[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#[must_use]
25pub fn lint_source(source: &str) -> Diagnostics {
26 lint_source_with_configs(source, &RubyConfig::default(), &SupremeConfig::default())
27}
28
29#[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 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 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 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(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 process_line(source, line_start, bytes.len(), line_idx, &mut diags);
98 }
99
100 diags
101}
102
103fn 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 if !trimmed.is_empty() && !trimmed.starts_with('#') {
115 code_lines += 1;
116 }
117
118 line_start = nl + 1;
119 }
120
121 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#[must_use]
194pub fn init_decree() -> BoxDecree {
195 Box::new(RubyHygiene::default())
196}
197
198#[must_use]
200pub fn init_decree_with_config(config: RubyConfig) -> BoxDecree {
201 Box::new(RubyHygiene::new(config, SupremeConfig::default()))
202}
203
204#[must_use]
206pub fn init_decree_with_configs(config: RubyConfig, supreme: SupremeConfig) -> BoxDecree {
207 Box::new(RubyHygiene::new(config, supreme))
208}
209
210#[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 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 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('!') || 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(); 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"; let diags = lint_source(src);
326 assert!(diags.iter().any(|d| d.rule == "ruby/blank-line-whitespace"));
327 }
328}