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}
12
13impl Default for RubyConfig {
14 fn default() -> Self {
15 Self { max_lines: 300 }
16 }
17}
18
19#[must_use]
21pub fn lint_source(source: &str) -> Diagnostics {
22 lint_source_with_configs(source, &RubyConfig::default(), &SupremeConfig::default())
23}
24
25#[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 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 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(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 process_line(source, line_start, bytes.len(), line_idx, &mut diags);
81 }
82
83 diags
84}
85
86fn 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 if !trimmed.is_empty() && !trimmed.starts_with('#') {
98 code_lines += 1;
99 }
100
101 line_start = nl + 1;
102 }
103
104 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#[must_use]
177pub fn init_decree() -> BoxDecree {
178 Box::new(RubyHygiene::default())
179}
180
181#[must_use]
183pub fn init_decree_with_config(config: RubyConfig) -> BoxDecree {
184 Box::new(RubyHygiene::new(config, SupremeConfig::default()))
185}
186
187#[must_use]
189pub fn init_decree_with_configs(config: RubyConfig, supreme: SupremeConfig) -> BoxDecree {
190 Box::new(RubyHygiene::new(config, supreme))
191}
192
193#[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 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 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('!') || 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"; let diags = lint_source(src);
263 assert!(diags.iter().any(|d| d.rule == "ruby/blank-line-whitespace"));
264 }
265}