use dictator_decree_abi::{BoxDecree, Decree, Diagnostic, Diagnostics, Span};
use dictator_supreme::SupremeConfig;
use memchr::memchr_iter;
#[derive(Debug, Clone)]
pub struct RubyConfig {
pub max_lines: usize,
pub ignore_comments: bool,
}
impl Default for RubyConfig {
fn default() -> Self {
Self {
max_lines: 300,
ignore_comments: false,
}
}
}
#[must_use]
pub fn lint_source(source: &str) -> Diagnostics {
lint_source_with_configs(source, &RubyConfig::default(), &SupremeConfig::default())
}
#[must_use]
pub fn lint_source_with_config(source: &str, config: &RubyConfig) -> Diagnostics {
let mut diags = Diagnostics::new();
diags.extend(dictator_supreme::lint_source_with_owner(
source,
&SupremeConfig::default(),
"ruby",
));
diags.extend(lint_ruby_specific(source, config));
diags
}
#[must_use]
pub fn lint_source_with_configs(
source: &str,
ruby_config: &RubyConfig,
supreme_config: &SupremeConfig,
) -> Diagnostics {
let mut diags = Diagnostics::new();
let supreme_diags = dictator_supreme::lint_source_with_owner(source, supreme_config, "ruby");
if ruby_config.ignore_comments {
let lines: Vec<&str> = source.lines().collect();
diags.extend(supreme_diags.into_iter().filter(|d| {
if d.rule == "ruby/line-too-long" {
let line_idx = source[..d.span.start].matches('\n').count();
!lines
.get(line_idx)
.is_some_and(|line| line.trim_start().starts_with('#'))
} else {
true
}
}));
} else {
diags.extend(supreme_diags);
}
diags.extend(lint_ruby_specific(source, ruby_config));
diags
}
fn lint_ruby_specific(source: &str, config: &RubyConfig) -> Diagnostics {
let mut diags = Diagnostics::new();
check_file_line_count(source, config.max_lines, &mut diags);
let bytes = source.as_bytes();
let mut line_start: usize = 0;
let mut line_idx: usize = 0;
for nl in memchr_iter(b'\n', bytes) {
process_line(source, line_start, nl, line_idx, &mut diags);
line_start = nl + 1;
line_idx += 1;
}
if line_start < bytes.len() {
process_line(source, line_start, bytes.len(), line_idx, &mut diags);
}
diags
}
fn check_file_line_count(source: &str, max_lines: usize, diags: &mut Diagnostics) {
let mut code_lines = 0;
let bytes = source.as_bytes();
let mut line_start = 0;
for nl in memchr_iter(b'\n', bytes) {
let line = &source[line_start..nl];
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') {
code_lines += 1;
}
line_start = nl + 1;
}
if line_start < bytes.len() {
let line = &source[line_start..];
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') {
code_lines += 1;
}
}
if code_lines > max_lines {
diags.push(Diagnostic {
rule: "ruby/file-too-long".to_string(),
message: format!("{code_lines} code lines (max {max_lines})"),
enforced: false,
span: Span::new(0, source.len().min(100)),
});
}
}
#[derive(Default)]
pub struct RubyHygiene {
config: RubyConfig,
supreme: SupremeConfig,
}
impl RubyHygiene {
#[must_use]
pub const fn new(config: RubyConfig, supreme: SupremeConfig) -> Self {
Self { config, supreme }
}
}
impl Decree for RubyHygiene {
fn name(&self) -> &'static str {
"ruby"
}
fn lint(&self, _path: &str, source: &str) -> Diagnostics {
lint_source_with_configs(source, &self.config, &self.supreme)
}
fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
dictator_decree_abi::DecreeMetadata {
abi_version: dictator_decree_abi::ABI_VERSION.to_string(),
decree_version: env!("CARGO_PKG_VERSION").to_string(),
description: "Ruby code structure and hygiene".to_string(),
dectauthors: Some(env!("CARGO_PKG_AUTHORS").to_string()),
supported_extensions: vec!["rb".to_string(), "rake".to_string(), "gemspec".to_string()],
supported_filenames: vec![
"Gemfile".to_string(),
"Rakefile".to_string(),
"Guardfile".to_string(),
"Capfile".to_string(),
"Brewfile".to_string(),
"Dangerfile".to_string(),
"Podfile".to_string(),
"Fastfile".to_string(),
"Appfile".to_string(),
"Matchfile".to_string(),
"Berksfile".to_string(),
"Thorfile".to_string(),
"Vagrantfile".to_string(),
".pryrc".to_string(),
".irbrc".to_string(),
],
skip_filenames: vec!["Gemfile.lock".to_string(), "Podfile.lock".to_string()],
capabilities: vec![dictator_decree_abi::Capability::Lint],
}
}
}
#[must_use]
pub fn init_decree() -> BoxDecree {
Box::new(RubyHygiene::default())
}
#[must_use]
pub fn init_decree_with_config(config: RubyConfig) -> BoxDecree {
Box::new(RubyHygiene::new(config, SupremeConfig::default()))
}
#[must_use]
pub fn init_decree_with_configs(config: RubyConfig, supreme: SupremeConfig) -> BoxDecree {
Box::new(RubyHygiene::new(config, supreme))
}
#[must_use]
pub fn config_from_decree_settings(settings: &dictator_core::DecreeSettings) -> RubyConfig {
RubyConfig {
max_lines: settings.max_lines.unwrap_or(300),
ignore_comments: settings.ignore_comments.unwrap_or(false),
}
}
fn process_line(source: &str, start: usize, end: usize, line_idx: usize, diags: &mut Diagnostics) {
let line = &source[start..end];
let trimmed = line.trim_start_matches(' ');
if let Some(stripped) = trimmed.strip_prefix('#')
&& !is_comment_directive(stripped, line_idx)
&& !stripped.starts_with(' ')
&& !stripped.is_empty()
{
let hash_offset = start + (line.len() - trimmed.len());
diags.push(Diagnostic {
rule: "ruby/comment-space".to_string(),
message: "Comments should start with '# '".to_string(),
enforced: true,
span: Span::new(hash_offset, hash_offset + 1),
});
}
}
fn is_comment_directive(rest: &str, line_idx: usize) -> bool {
let rest = rest.trim_start();
rest.starts_with('!') || rest.starts_with("encoding")
|| rest.starts_with("frozen_string_literal")
|| rest.starts_with("rubocop")
|| rest.starts_with("typed")
|| (line_idx == 0 && rest.starts_with(" language"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_trailing_whitespace_and_tab() {
let src = "def foo\n bar \t\nend\n";
let diags = lint_source(src);
assert!(diags.iter().any(|d| d.rule == "ruby/trailing-whitespace"));
assert!(diags.iter().any(|d| d.rule == "ruby/tab-character"));
}
#[test]
fn detects_missing_final_newline() {
let src = "class Foo\nend";
let diags = lint_source(src);
assert!(diags.iter().any(|d| d.rule == "ruby/missing-final-newline"));
}
#[test]
fn enforces_comment_space() {
let src = "#bad\n# good\n";
let diags = lint_source(src);
assert!(diags.iter().any(|d| d.rule == "ruby/comment-space"));
}
#[test]
fn ignores_long_comment_lines_when_configured() {
let long_comment = format!("# {}\n", "x".repeat(150));
let src = format!("def foo\n{long_comment}end\n");
let config = RubyConfig {
ignore_comments: true,
..Default::default()
};
let supreme = SupremeConfig {
max_line_length: Some(120),
..Default::default()
};
let diags = lint_source_with_configs(&src, &config, &supreme);
assert!(!diags.iter().any(|d| d.rule == "ruby/line-too-long"));
}
#[test]
fn detects_long_comment_lines_when_not_configured() {
let long_comment = format!("# {}\n", "x".repeat(150));
let src = format!("def foo\n{long_comment}end\n");
let config = RubyConfig::default(); let supreme = SupremeConfig {
max_line_length: Some(120),
..Default::default()
};
let diags = lint_source_with_configs(&src, &config, &supreme);
assert!(diags.iter().any(|d| d.rule == "ruby/line-too-long"));
}
#[test]
fn still_detects_long_code_lines_with_ignore_comments() {
let long_code = format!(" x = \"{}\"\n", "a".repeat(150));
let src = format!("def foo\n{long_code}end\n");
let config = RubyConfig {
ignore_comments: true,
..Default::default()
};
let supreme = SupremeConfig {
max_line_length: Some(120),
..Default::default()
};
let diags = lint_source_with_configs(&src, &config, &supreme);
assert!(diags.iter().any(|d| d.rule == "ruby/line-too-long"));
}
#[test]
fn detects_whitespace_only_blank_line() {
let src = "def foo\n bar\n \nend\n"; let diags = lint_source(src);
assert!(diags.iter().any(|d| d.rule == "ruby/blank-line-whitespace"));
}
}