Sonicop

A fast, native RuboCop-compatible Ruby linter and formatter written in Rust.

CI Gem Version License

English | 日本語


Overview

Sonicop is a fast Ruby linter and formatter that runs as a native executable without starting a Ruby process. Existing .rubocop.yml files work as-is, including nested configuration, inheritance, file inclusion and exclusion, severity, and autocorrect settings.

It uses the actively maintained owayo/tree-sitter-ruby grammar, inspects files in parallel, and applies corrections atomically. Its RuboCop 1.89-compatible CLI and JSON output fit existing editor and CI integrations with minimal changes.

Features

Sonicop implements cops in the Layout, Lint, Metrics, Naming, Security, and Style departments. The implemented set grows with each release, so the binary itself is the authoritative list:

# Every recognized cop and its implementation status
sonicop --show-cops

The bundled upstream configuration recognizes all 609 RuboCop 1.89 cops. Implemented cops run normally; recognized but not-yet-implemented cops remain configuration-compatible and are reported by --debug. Truly unknown cop names still fail validation unless --ignore-unrecognized-cops is supplied.

Installation

gem install sonicop

Platform gems include native executables for Linux, macOS, and Windows. When a prebuilt platform gem is unavailable, the source gem builds the executable with Cargo during installation.

You can also install the latest source directly:

cargo install --git https://github.com/owayo/sonicop

Usage

# Inspect the current project
sonicop

# Select cops or departments
sonicop --only Layout,Style/StringLiterals app spec

# Safe correction / all correction
sonicop -a
sonicop -A

# RuboCop-shaped JSON
sonicop --format json

# Editor input
printf '%s\n' 'value=10000' | sonicop --stdin example.rb --format json

# List recognized cops and their implementation status
sonicop --show-cops

Key compatibility flags include -l, -x, --only, --except, -s/--stdin, -P/--parallel, -f/--format, -a/--autocorrect, -A/--autocorrect-all, -L/--list-target-files, -c/--config, -v/--version, and -V/--verbose-version.

Configuration

Sonicop resolves .rubocop.yml from each target file, so nested configurations apply within one run. Local and HTTPS inherit_from, inherit_gem, inherit_mode, AllCops/DisabledByDefault, Include, and Exclude, plus per-cop Enabled, Exclude, Severity, Safe, SafeAutoCorrect, and cop settings are supported. Cops supplied by declared plugins are accepted as recognized-but-unimplemented without executing Ruby plugin code.

inherit_from: .rubocop_todo.yml

AllCops:
  Exclude:
    - "vendor/**/*"

Layout/LineLength:
  Max: 100

Style/StringLiterals:
  EnforcedStyle: single_quotes

The CLI accepts RuboCop's server/LSP/MCP, plugin, and cache flags to keep existing command lines parse-compatible. These flags are reported as compatibility no-ops when they request unsupported functionality. Sonicop does not currently provide server transports, Ruby plugin execution, cache reuse, custom Ruby cops, or cops outside the implemented set.

Conformance

The implemented cops are verified against RuboCop 1.89.0 over five Ruby projects — RuboCop itself, Rails, Ruby, Homebrew and Mastodon — totalling 18,244 files, with the upstream default configuration on both sides. Every offense is compared by cop, path, line, column, last line, last column, length, message, severity and correctability.

Three of the five match exactly: RuboCop's own tree (4,142 offenses), Rails (117,541) and Mastodon (7,610), with no excess, no shortfall and no metadata differences. The target file lists match exactly on all five. What remains is concentrated in Lint/Syntax, where RuboCop's LALR parser recovers from an error and emits diagnostics a tree-sitter parse cannot reconstruct. Autocorrect is byte-identical on every corpus it was run against.

See CONFORMANCE.md for the commands, the per-corpus numbers, and the two ways a measurement of this kind can mislead you.

Performance

Measured over all five conformance corpora. Both tools were given their own bundled default configuration (--force-default-config), so neither reads the project's .rubocop.yml, and on every corpus the two resolve the same file list.

RuboCop is restricted to the 28 cops Sonicop implements, which is the only like-for-like comparison available. Times are the fastest of two warmed runs.

Corpus Files RuboCop parallel Sonicop parallel RuboCop single Sonicop single
rubocop/rubocop 1,765 5.30 s 1.41 s 20.88 s 6.22 s
mastodon/mastodon 3,289 3.77 s 1.54 s 11.18 s 5.67 s
Homebrew/brew 2,175 8.52 s 1.47 s 19.26 s 5.20 s
rails/rails 3,550 10.62 s 5.07 s 32.89 s 15.58 s
ruby/ruby 7,465 27.31 s 11.19 s 96.19 s 34.85 s

The gap is 2.1x to 5.8x in parallel and 2.0x to 3.7x single-process, so no single corpus summarizes it. The single-process column is the steadier of the two — it measures the engines rather than how well each one's parallelism happens to fit the tree it was pointed at.

Out of the box the two commands do different amounts of work — RuboCop enables 394 cops by default against Sonicop's 28 — so that comparison is not like-for-like. For the record, RuboCop's default parallel run takes 10.46 s on its own tree, 9.91 s on Mastodon, 10.83 s on Homebrew, 20.73 s on Rails and 75.05 s on Ruby.

Over the 28 shared cops the two agree on every offense on RuboCop's own tree, on Rails and on Mastodon, so the speed is not bought by skipping work.

Two details matter for reproducing this. RuboCop silently turns --parallel off when combined with --cache false, so its parallel runs here use a cache directory that is deleted before each run rather than disabled; timing it with --cache false --parallel measures a single process and overstates the difference. RuboCop's default is a single process, while Sonicop is parallel unless --no-parallel is passed.

# RuboCop, parallel, cold cache, restricted to the cops Sonicop implements
rubocop --force-default-config --cache true --cache-root "$(mktemp -d)" \
        --no-color --parallel --only "$COPS" -f quiet

# Sonicop
sonicop --force-default-config --format quiet

Machine: Apple M2 (8 cores), Ruby 4.0.6 with YJIT available, RubyGems-installed RuboCop 1.89.0. Measure on an otherwise idle machine: anything else competing for cores inflates both sides unevenly, and a contended run here reported RuboCop's Rails time as 21 s rather than 10.6 s.

Development

make is the single entry point; make help lists every target. The Rakefile holds the gem packaging tasks that make delegates to.

make build   # debug build
make check   # fmt, clippy, Rust tests, Ruby wrapper tests, version consistency
make gem     # source gem

Adding a cop

A cop is one file under src/rules/<department>/<cop>.rs exposing a single check(context, offenses), plus one line in that department's mod.rs:

department_rules! {
    "Layout";
    line_length => ("LineLength", Convention),
}

That line is the only place the cop's name and default severity are written. Inside the cop the name stays implicit: context.setting("Max") reads Layout/LineLength: Max, and context.offense(message, range) reports under the cop's own name at its configured severity. A cop that spelled its name a second time could disagree with the registry, and nothing in the type system would catch it.

Prefer context.nodes_of("kind") over walking every node: each cop runs on every file, so a full walk per cop is what makes inspection scale with the registry rather than with the file.

Cargo.toml is the single source of truth for the version. lib/sonicop/version.rb is generated from it by make version-sync and committed, because the gemspec reads it at package time. CI fails when the two disagree.

config/default.yml is vendored from upstream RuboCop; re-fetch it with scripts/sync_default_yml.sh <rubocop-version>, which records the source version in the file header.

Dependencies are updated with depup --install. The Ruby grammar dependency is pinned to an exact fork commit in Cargo.toml for reproducible builds.

License

MIT. The bundled RuboCop default configuration and parser dependency retain their upstream notices in NOTICE and licenses/.