├── .ruby-version ├── lib ├── ruumba.rb └── ruumba │ ├── version.rb │ ├── rake_task.rb │ ├── iterators.rb │ ├── rubocop_runner.rb │ ├── correctors.rb │ ├── analyzer.rb │ └── parser.rb ├── .gitignore ├── .travis.yml ├── scripts └── git_hooks │ ├── pre-commit │ └── install ├── Guardfile ├── Gemfile ├── .rubocop.yml ├── spec ├── ruumba │ ├── rake_task_spec.rb │ ├── rubocop_runner_spec.rb │ ├── iterators_spec.rb │ ├── parser_spec.rb │ ├── analyzer_spec.rb │ └── correctors_spec.rb └── spec_helper.rb ├── Rakefile ├── ruumba.gemspec ├── LICENSE ├── Gemfile.lock ├── README.md └── bin └── ruumba /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.4.9 2 | -------------------------------------------------------------------------------- /lib/ruumba.rb: -------------------------------------------------------------------------------- 1 | # @author Eric Weinstein 2 | 3 | require_relative './ruumba/analyzer' 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gem stuffs 2 | *.gem 3 | 4 | # Documentation 5 | .yardoc/ 6 | doc/ 7 | 8 | # Testing 9 | coverage/ 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.4.10 4 | - 2.5.8 5 | - 2.6.6 6 | - 2.7.1 7 | script: 8 | - bundle exec rake test:all 9 | -------------------------------------------------------------------------------- /scripts/git_hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Lint and test every commit. 3 | 4 | # The default Rake task in this repo is test:all. 5 | bundle exec rake && bundle exec rake yard 6 | -------------------------------------------------------------------------------- /lib/ruumba/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # @author Andrew Clemons 4 | 5 | module Ruumba 6 | # Provides the ruumba version 7 | module Version 8 | STRING = '0.1.17' 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # @author Eric Weinstein 2 | # More info at https://github.com/guard/guard#readme 3 | 4 | guard :rspec, cmd: 'rspec --color --format d' do 5 | watch %r{^spec/.+_spec\.rb$} 6 | watch(%r{^lib/ruumba/(.+)\.rb$}) { |m| "spec/ruumba/#{m[1]}_spec.rb" } 7 | watch('spec/spec_helper.rb') { 'spec' } 8 | end 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby '~> 2.4' 4 | 5 | gemspec 6 | 7 | group :development, :test do 8 | gem 'guard', '~> 2.16.2' 9 | gem 'guard-rspec', '~> 4.7.3' 10 | gem 'rake', '~> 13.0.1' 11 | gem 'rspec', '~> 3.9.0' 12 | gem 'rubocop' 13 | gem 'simplecov', require: false 14 | gem 'yard', '>= 0.9.20' 15 | end 16 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Metrics/LineLength: 2 | Max: 200 3 | 4 | Metrics/MethodLength: 5 | Max: 50 6 | 7 | Style/FrozenStringLiteralComment: 8 | Enabled: false 9 | 10 | Style/FormatStringToken: 11 | Enabled: false 12 | 13 | Metrics/AbcSize: 14 | Max: 30 15 | 16 | Metrics/PerceivedComplexity: 17 | Max: 15 18 | 19 | Metrics/CyclomaticComplexity: 20 | Max: 15 21 | 22 | Metrics/ClassLength: 23 | Max: 120 24 | 25 | Metrics/BlockLength: 26 | Max: 130 27 | 28 | Metrics/AbcSize: 29 | Max: 50 30 | -------------------------------------------------------------------------------- /spec/ruumba/rake_task_spec.rb: -------------------------------------------------------------------------------- 1 | # @author Eric Weinstein 2 | 3 | require 'spec_helper' 4 | 5 | describe Ruumba::RakeTask do 6 | describe '#initialize' do 7 | it 'sets the name of the task when provided' do 8 | ruumba = Ruumba::RakeTask.new(:foo) 9 | expect(ruumba.name).to eq :foo 10 | end 11 | 12 | it 'defaults the task name to :ruumba when not provided' do 13 | ruumba = Ruumba::RakeTask.new 14 | expect(ruumba.name).to eq :ruumba 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # @author Eric Weinstein 2 | 3 | # Test coverage report 4 | require 'simplecov' 5 | SimpleCov.start 6 | 7 | # Don't include spec files in the coverage report 8 | SimpleCov.add_filter '/spec/' 9 | 10 | # Require all lib/ files 11 | Dir["#{File.dirname(__FILE__)}/../lib/**/*.rb"].sort.each { |f| require f } 12 | 13 | RSpec.configure do |c| 14 | c.mock_framework = :rspec 15 | # Ensure specs run in a random order to surface order depenencies 16 | c.order = 'random' 17 | end 18 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | 3 | require 'rspec/core/rake_task' 4 | require 'rubocop/rake_task' 5 | require 'yard' 6 | 7 | task default: 'test:all' 8 | 9 | desc 'Run IRB with the gem environment loaded' 10 | task :console do 11 | puts '[+] Loading development console...' 12 | system('irb -r ./lib/ruumba.rb') 13 | end 14 | 15 | desc 'Run tests' 16 | task :spec do 17 | RSpec::Core::RakeTask.new(:spec) do |t| 18 | t.pattern = './spec/**/*_spec.rb' 19 | t.rspec_opts = ['--color --format d'] 20 | end 21 | end 22 | 23 | desc 'Lint' 24 | RuboCop::RakeTask.new(:rubocop) do |t| 25 | t.patterns = %w(lib/**/*.rb spec/**/*.rb) 26 | end 27 | 28 | desc 'Run all the tests, lint all the things' 29 | namespace :test do 30 | task all: %i(spec rubocop) 31 | end 32 | -------------------------------------------------------------------------------- /ruumba.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.push File.expand_path('../lib', __FILE__) 2 | 3 | require 'ruumba/version' 4 | 5 | Gem::Specification.new do |g| 6 | g.name = 'ruumba' 7 | g.version = ::Ruumba::Version::STRING 8 | g.authors = ['Eric Weinstein', 'Jan Biniok', 'Yvan Barthélemy', 'Andrew Clemons'] 9 | g.date = '2021-01-15' 10 | g.description = 'RuboCop linting for ERB templates.' 11 | g.email = 'eric.q.weinstein@gmail.com' 12 | g.files = Dir.glob('{lib}/**/*') + %w(README.md Rakefile) 13 | g.homepage = 'https://github.com/ericqweinstein/ruumba' 14 | g.require_paths = %w(lib) 15 | g.summary = 'Allows users to lint Ruby code in ERB templates the same way they lint source code (using RuboCop).' 16 | g.licenses = %w(MIT) 17 | g.executables << 'ruumba' 18 | g.add_dependency 'rubocop' 19 | end 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015 Eric Weinstein 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/ruumba/rake_task.rb: -------------------------------------------------------------------------------- 1 | # @author Eric Weinstein 2 | 3 | require 'rake' 4 | require 'rake/tasklib' 5 | 6 | module Ruumba 7 | # Provides a custom Rake task. 8 | class RakeTask < Rake::TaskLib 9 | attr_accessor :name, :dir, :options 10 | 11 | # Sets up the custom Rake task. 12 | # @param [Array] args Arguments to the Rake task. 13 | # @yield If called with a block, we'll call it to ensure 14 | # additional options are included (_e.g._ dir). 15 | def initialize(*args, &block) 16 | @name = args.shift || :ruumba 17 | @dir = [] 18 | @options = nil 19 | 20 | desc 'Run RuboCop on ERB files' 21 | task(name, *args) do |_, task_args| 22 | yield(*[self, task_args].slice(0, block.arity)) if block 23 | run 24 | end 25 | end 26 | 27 | private 28 | 29 | # Executes the custom Rake task. 30 | # @private 31 | def run 32 | # Like RuboCop itself, we'll lazy load so the task 33 | # doesn't substantially impact Rakefile load time. 34 | require 'ruumba' 35 | 36 | analyzer = Ruumba::Analyzer.new(@options) 37 | puts 'Running Ruumba...' 38 | 39 | exit(analyzer.run(@dir)) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/ruumba/iterators.rb: -------------------------------------------------------------------------------- 1 | # @author Eric Weinstein 2 | 3 | # Ruumba: RuboCop's sidekick. 4 | module Ruumba 5 | # Generates analyzer objects that, when run, delegate 6 | # to RuboCop for linting (style, correctness, &c). 7 | module Iterators 8 | # Iterator which returns the file passed in via stdin 9 | class StdinIterator 10 | include Enumerable 11 | 12 | def initialize(file) 13 | @file = file 14 | end 15 | 16 | def each(&block) 17 | [[file, STDIN.read]].each(&block) 18 | end 19 | 20 | private 21 | 22 | attr_reader :file 23 | end 24 | 25 | # Iterator which returns matching files from the given directory or file list 26 | class DirectoryIterator 27 | include Enumerable 28 | 29 | def initialize(files_or_dirs, temp_dir) 30 | @files_or_dirs = files_or_dirs 31 | @temp_dir = temp_dir 32 | end 33 | 34 | def each(&block) 35 | files.map do |file| 36 | [file, File.read(file)] 37 | end.each(&block) 38 | end 39 | 40 | private 41 | 42 | attr_reader :files_or_dirs, :temp_dir 43 | 44 | def files 45 | full_list.flat_map do |file_or_dir| 46 | if file_or_dir.file? 47 | file_or_dir if file_or_dir.to_s.end_with?('.erb') 48 | else 49 | Dir[File.join(file_or_dir, '**/*.erb')].map do |file| 50 | Pathname.new(file) unless file.start_with?(temp_dir) 51 | end 52 | end 53 | end.compact 54 | end 55 | 56 | def full_list 57 | if files_or_dirs.nil? || files_or_dirs.empty? 58 | [expand_path('.')] 59 | else 60 | files_or_dirs.map do |file_or_dir| 61 | expand_path(file_or_dir) 62 | end 63 | end 64 | end 65 | 66 | def expand_path(file_or_dir) 67 | Pathname.new(File.expand_path(file_or_dir)) 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/ruumba/rubocop_runner.rb: -------------------------------------------------------------------------------- 1 | # @author Eric Weinstein 2 | 3 | require 'open3' 4 | 5 | module Ruumba 6 | # Runs rubocop on the files in the given target_directory 7 | class RubocopRunner 8 | def initialize(arguments, current_directory, target_directory, stdin, rb_extension_enabled) 9 | @arguments = Array(arguments) 10 | @current_directory = current_directory 11 | @rb_extension_enabled = rb_extension_enabled 12 | @stdin = stdin 13 | @target_directory = target_directory 14 | @replacements = [] 15 | 16 | # if adding the .rb extension is enabled, remove the extension again from 17 | # any output so it matches the actual files names we are linting 18 | @replacements << [/\.erb\.rb/, '.erb'] if rb_extension_enabled 19 | end 20 | 21 | # Executes rubocop, updating filenames in the output if needed. 22 | # @return the exit code of the rubocop process 23 | def execute 24 | args = ['rubocop'] + arguments 25 | todo = target_directory.join('.rubocop_todo.yml') 26 | 27 | results = Dir.chdir(target_directory) do 28 | replacements.unshift([/^#{Regexp.quote(Dir.pwd)}/, current_directory.to_s]) 29 | 30 | stdout, stderr, status = Open3.capture3(*args, stdin_data: stdin) 31 | 32 | [munge_output(stdout), munge_output(stderr), status.exitstatus] 33 | end 34 | 35 | # copy the todo file back for the case where we've used --auto-gen-config 36 | FileUtils.cp(todo, current_directory) if todo.exist? 37 | 38 | results 39 | end 40 | 41 | private 42 | 43 | attr_reader :arguments, :current_directory, :rb_extension_enabled, :replacements, :stdin, :target_directory 44 | 45 | def munge_output(output) 46 | return output if output.nil? || output.empty? 47 | 48 | replacements.each do |pattern, replacement| 49 | output = output.gsub(pattern, replacement) 50 | end 51 | 52 | output 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /scripts/git_hooks/install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash -e 2 | # Installs all project Git hooks. 3 | # Author: Eric Weinstein 4 | 5 | # Output colors 6 | error() { 7 | echo -e "\033[0;31m[!] $1\033[0;m" 8 | } 9 | warn() { 10 | echo -e "\033[0;33m[-] $1\033[0;m" 11 | } 12 | warn_and_confirm() { 13 | echo -ne "\033[0;33m[-] $1\033[0;m" 14 | } 15 | ok() { 16 | echo -e "\033[0;32m[\xE2\x9C\x93] $1\033[0;m" 17 | } 18 | info() { 19 | echo -e "\033[0;35m[+] $1\033[0;m" 20 | } 21 | 22 | TARGET_DIR="../../.git/hooks" 23 | 24 | # Check for .git/hooks relative to this directory 25 | check_dir() { 26 | if [[ ! -d $TARGET_DIR ]]; then 27 | error 'Could not locate Git hooks directory.' 28 | info 'Ensure you have a .git/hooks directory in your project root and that you are running this script from within the /scripts/git_hooks directory.' 29 | exit 1 30 | fi 31 | } 32 | 33 | # Check to make sure we're not overwriting existing hooks 34 | check_overwrite() { 35 | if [[ -e "$TARGET_DIR/$1" ]]; then 36 | warn_and_confirm "You have an existing $1 hook; this script will overwrite it. Is this OK? [Y/N]: " 37 | read response 38 | case $response in 39 | [nN] | [nN][Oo] ) 40 | error "Skipping $1 hook installation." 41 | return 1 42 | ;; 43 | [yY] | [yY][Ee][Ss] ) 44 | return 0 45 | ;; 46 | * ) 47 | error "Default: skipping $1 hook installation due to invalid input." 48 | return 1 49 | ;; 50 | esac 51 | fi 52 | } 53 | 54 | # Copy hooks to .git/hooks 55 | install_hooks() { 56 | check_dir 57 | 58 | for file_path in $PWD/*; do 59 | filename=$( echo $file_path | tr '/' '\n' | tail -n '1' ) 60 | 61 | # Don't install the installation script itself 62 | if [[ $file_path != "$PWD/install" ]]; then 63 | if check_overwrite $filename; then 64 | info "Installing $filename hook..." 65 | chmod +x $filename 66 | cp $file_path $TARGET_DIR 67 | ok "Successfully installed $filename hook." 68 | fi 69 | fi 70 | done 71 | ok "Done." 72 | } 73 | 74 | install_hooks 75 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | ruumba (0.1.17) 5 | rubocop 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | ast (2.4.0) 11 | coderay (1.1.2) 12 | diff-lcs (1.3) 13 | docile (1.3.2) 14 | ffi (1.12.2) 15 | formatador (0.2.5) 16 | guard (2.16.2) 17 | formatador (>= 0.2.4) 18 | listen (>= 2.7, < 4.0) 19 | lumberjack (>= 1.0.12, < 2.0) 20 | nenv (~> 0.1) 21 | notiffany (~> 0.0) 22 | pry (>= 0.9.12) 23 | shellany (~> 0.0) 24 | thor (>= 0.18.1) 25 | guard-compat (1.2.1) 26 | guard-rspec (4.7.3) 27 | guard (~> 2.1) 28 | guard-compat (~> 1.1) 29 | rspec (>= 2.99.0, < 4.0) 30 | listen (3.2.1) 31 | rb-fsevent (~> 0.10, >= 0.10.3) 32 | rb-inotify (~> 0.9, >= 0.9.10) 33 | lumberjack (1.2.4) 34 | method_source (1.0.0) 35 | nenv (0.3.0) 36 | notiffany (0.1.3) 37 | nenv (~> 0.1) 38 | shellany (~> 0.0) 39 | parallel (1.19.1) 40 | parser (2.7.1.2) 41 | ast (~> 2.4.0) 42 | pry (0.13.1) 43 | coderay (~> 1.1) 44 | method_source (~> 1.0) 45 | rainbow (3.0.0) 46 | rake (13.0.1) 47 | rb-fsevent (0.10.4) 48 | rb-inotify (0.10.1) 49 | ffi (~> 1.0) 50 | rexml (3.2.4) 51 | rspec (3.9.0) 52 | rspec-core (~> 3.9.0) 53 | rspec-expectations (~> 3.9.0) 54 | rspec-mocks (~> 3.9.0) 55 | rspec-core (3.9.2) 56 | rspec-support (~> 3.9.3) 57 | rspec-expectations (3.9.2) 58 | diff-lcs (>= 1.2.0, < 2.0) 59 | rspec-support (~> 3.9.0) 60 | rspec-mocks (3.9.1) 61 | diff-lcs (>= 1.2.0, < 2.0) 62 | rspec-support (~> 3.9.0) 63 | rspec-support (3.9.3) 64 | rubocop (0.83.0) 65 | parallel (~> 1.10) 66 | parser (>= 2.7.0.1) 67 | rainbow (>= 2.2.2, < 4.0) 68 | rexml 69 | ruby-progressbar (~> 1.7) 70 | unicode-display_width (>= 1.4.0, < 2.0) 71 | ruby-progressbar (1.10.1) 72 | shellany (0.0.1) 73 | simplecov (0.18.5) 74 | docile (~> 1.1) 75 | simplecov-html (~> 0.11) 76 | simplecov-html (0.12.2) 77 | thor (1.0.1) 78 | unicode-display_width (1.7.0) 79 | yard (0.9.25) 80 | 81 | PLATFORMS 82 | ruby 83 | 84 | DEPENDENCIES 85 | guard (~> 2.16.2) 86 | guard-rspec (~> 4.7.3) 87 | rake (~> 13.0.1) 88 | rspec (~> 3.9.0) 89 | rubocop 90 | ruumba! 91 | simplecov 92 | yard (>= 0.9.20) 93 | 94 | RUBY VERSION 95 | ruby 2.4.9p362 96 | 97 | BUNDLED WITH 98 | 2.0.2 99 | -------------------------------------------------------------------------------- /lib/ruumba/correctors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ruumba 4 | # Responsible for extracted auto corrected code and updating the original ERBs files 5 | module Correctors 6 | # Module to help replace code 7 | module Replacer 8 | def handle_corrected_output(old_digest, new_contents, original_contents) 9 | new_digest = digestor.call(new_contents) 10 | 11 | return if old_digest == new_digest 12 | 13 | original_contents = original_contents.call if original_contents.respond_to?(:call) 14 | 15 | replaced_output = parser.replace(original_contents, new_contents) 16 | 17 | yield(replaced_output) if replaced_output 18 | end 19 | end 20 | 21 | # Corrector for when the checked file was passed through stdin. 22 | class StdinCorrector 23 | include Replacer 24 | 25 | def initialize(digestor, parser) 26 | @digestor = digestor 27 | @parser = parser 28 | end 29 | 30 | def correct(stdout, stderr, file_mappings) 31 | _, old_ruumba_digest, original_contents = *file_mappings.values.first 32 | 33 | [stdout, stderr].each do |output| 34 | next if output.nil? || output.empty? 35 | 36 | matches = output.scan(/\A(.*====================)?$(.*)\z/m) 37 | 38 | next if matches.empty? 39 | 40 | prefix, new_contents = *matches.first 41 | 42 | handle_corrected_output(old_ruumba_digest, new_contents, original_contents) do |corrected_output| 43 | output.clear 44 | output.concat("#{prefix}\n#{corrected_output}") 45 | end 46 | end 47 | end 48 | 49 | private 50 | 51 | attr_reader :digestor, :parser 52 | end 53 | 54 | # Corrector for when normal file checking 55 | class FileCorrector 56 | include Replacer 57 | 58 | def initialize(digestor, parser) 59 | @digestor = digestor 60 | @parser = parser 61 | end 62 | 63 | def correct(_stdout, _stderr, file_mappings) 64 | file_mappings.each do |original_file, (ruumba_file, old_ruumba_digest, original_contents)| 65 | new_contents = File.read(ruumba_file) 66 | 67 | handle_corrected_output(old_ruumba_digest, new_contents, original_contents) do |corrected_output| 68 | File.open(original_file, 'w+') do |file_handle| 69 | file_handle.write(corrected_output) 70 | end 71 | end 72 | end 73 | end 74 | 75 | private 76 | 77 | attr_reader :digestor, :parser 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/ruumba/rubocop_runner_spec.rb: -------------------------------------------------------------------------------- 1 | # @author Eric Weinstein 2 | 3 | require 'spec_helper' 4 | 5 | describe Ruumba::RubocopRunner do 6 | let(:runner) { described_class.new(arguments, current_directory, target, stdin, rb_extension_enabled) } 7 | let(:rb_extension_enabled) { false } 8 | let(:target) { Pathname.new(Dir.mktmpdir) } 9 | let(:arguments) { %w[--option val] } 10 | let(:status) { instance_double(Process::Status, exitstatus: exitstatus) } 11 | let(:stdout) { '' } 12 | let(:stderr) { '' } 13 | let(:munged_stdout) { '' } 14 | let(:munged_stderr) { '' } 15 | let(:stdin) { nil } 16 | let(:open3_results) { [stdout, stderr, status] } 17 | let(:results) { [stdout, stderr, exitstatus] } 18 | let!(:current_directory) { Pathname.new(ENV['PWD']) } 19 | let(:exitstatus) { 0 } 20 | 21 | describe '#execute' do 22 | before do 23 | expect(Open3).to receive(:capture3).with(*(['rubocop'] + arguments), stdin_data: stdin).and_return(open3_results) 24 | end 25 | 26 | after do 27 | FileUtils.remove_dir(target) 28 | end 29 | 30 | it 'returns the exitstatus from rubocop' do 31 | expect(runner.execute).to eq(results) 32 | end 33 | 34 | context 'when adding the .rb extension is enabled' do 35 | let(:results) { ['blah.js.erb', 'blubb.html.erb', exitstatus] } 36 | let(:rb_extension_enabled) { true } 37 | let(:stdout) { 'blah.js.erb.rb' } 38 | let(:stderr) { 'blubb.html.erb.rb' } 39 | 40 | it 'removes the rb extension from stdout and stderr' do 41 | expect(runner.execute).to eq(results) 42 | end 43 | end 44 | 45 | context 'when the output contains the temporary directory name' do 46 | let(:rb_extension_enabled) { true } 47 | let(:stdout) { target.join('blah.js.erb.rb').to_s } 48 | let(:stderr) { target.join('blubb.html.erb.rb').to_s } 49 | let(:results) { [current_directory.join('blah.js.erb').to_s, current_directory.join('blubb.html.erb').to_s, exitstatus] } 50 | 51 | it 'it replaces the target directory name with the current directory name' do 52 | expect(runner.execute).to eq(results) 53 | end 54 | end 55 | 56 | context 'when .rubocop_todo.yml exists in the target directory after executing' do 57 | before do 58 | FileUtils.touch(target.join('.rubocop_todo.yml')) 59 | end 60 | 61 | it 'copies .rubocop_todo.yml from the target directory to the current directory' do 62 | expect(FileUtils).to receive(:cp).with(target.join('.rubocop_todo.yml'), current_directory) 63 | expect(runner.execute).to eq(results) 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/ruumba/iterators_spec.rb: -------------------------------------------------------------------------------- 1 | # @author Eric Weinstein 2 | # @author Andrew Clemons 3 | 4 | require 'spec_helper' 5 | 6 | describe Ruumba::Iterators::StdinIterator do 7 | let(:iterator) { described_class.new(filename) } 8 | let(:files) { iterator.to_a } 9 | let(:filename) { 'blah.erb' } 10 | let(:contents) { 'file contents' } 11 | 12 | describe '#each' do 13 | before do 14 | expect(STDIN).to receive(:read).and_return(contents) 15 | end 16 | 17 | it 'returns an iterator for a single file with the contents of stdin' do 18 | expect(files).to eq([[filename, contents]]) 19 | end 20 | end 21 | end 22 | 23 | describe Ruumba::Iterators::DirectoryIterator do 24 | let(:iterator) { described_class.new(input_list, tmp_dir_path) } 25 | let(:files) { iterator.to_a } 26 | 27 | describe '#each' do 28 | let(:target_dir) { Pathname.new(Dir.mktmpdir) } 29 | let(:file1) { target_dir.join('dir1', 'file1.erb') } 30 | let(:file2) { target_dir.join('dir2', 'file2.erb') } 31 | let(:file3) { target_dir.join('file3.erb') } 32 | let(:file4) { target_dir.join('file4.rb') } 33 | let(:tmp_dir_path) { '/fake/tmp_dir' } 34 | 35 | before do 36 | [file1, file2, file3, file4].each do |file| 37 | FileUtils.mkdir_p(File.dirname(file)) 38 | 39 | File.open(file, 'w+') do |tmp_file| 40 | tmp_file.write("Contents of #{file}") 41 | end 42 | end 43 | end 44 | 45 | after do 46 | FileUtils.remove_dir(target_dir) 47 | end 48 | 49 | context 'when nil is passed as the directory' do 50 | let(:input_list) { nil } 51 | 52 | before do 53 | expect(File).to receive(:expand_path).with('.').and_return(target_dir.to_s) 54 | end 55 | 56 | it 'returns the erb files found' do 57 | expect(files.map(&:first).map(&:to_s)).to match_array([file1, file2, file3].map(&:to_s)) 58 | end 59 | end 60 | 61 | context 'when a list of files and directories is passed' do 62 | let(:input_list) { [File.dirname(file1), File.dirname(file2), file3.to_s, file4.to_s] } 63 | 64 | it 'expands the directories and returns the erb files found' do 65 | expect(files.map(&:first).map(&:to_s)).to match_array([file1, file2, file3].map(&:to_s)) 66 | end 67 | end 68 | 69 | context 'when tmp dir is inside project' do 70 | let(:input_list) { nil } 71 | let(:tmp_dir_path) { target_dir.to_s } 72 | 73 | before do 74 | allow(File).to receive(:expand_path).with('.').and_return(target_dir.to_s) 75 | end 76 | 77 | it 'returns the erb files found' do 78 | expect(files).to be_empty 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Ruumba 2 | ====== 3 | 4 | [![Build Status](https://travis-ci.org/ericqweinstein/ruumba.svg?branch=master)](https://travis-ci.org/ericqweinstein/ruumba) 5 | 6 | > .erb or .rb, you're coming with me. 7 | 8 | > — RuboCop 9 | 10 | ## About 11 | Ruumba is [RuboCop's](https://github.com/bbatsov/rubocop) sidekick, allowing you to lint your .erb Rubies as well as your regular-type ones. 12 | 13 | ## Dependencies 14 | * Ruby 2.4+ 15 | 16 | ## Installation 17 | ```bash 18 | λ gem install ruumba 19 | ``` 20 | 21 | ## Usage 22 | Command line: 23 | 24 | ```bash 25 | λ ruumba directory_of_erb_files/ 26 | ``` 27 | 28 | Rake task: 29 | 30 | ```ruby 31 | require 'ruumba/rake_task' 32 | 33 | Ruumba::RakeTask.new(:ruumba) do |t| 34 | t.dir = %w(lib/views) 35 | 36 | # You can specify CLI options too: 37 | t.options = { arguments: %w[-c .ruumba.yml] } 38 | end 39 | ``` 40 | 41 | Then: 42 | 43 | ```bash 44 | λ bundle exec rake ruumba 45 | ``` 46 | 47 | ## Fix Paths and Non-Applicable Cops 48 | 49 | By default, RuboCop only scans `.rb` files and so does Ruumba. If you want shown 50 | paths to reflect original paths, you can add create a `.ruumba.yml` config file 51 | with the following contents: 52 | 53 | ```yaml 54 | AllCops: 55 | Include: 56 | - '**/*.erb' 57 | ``` 58 | 59 | You can then disable the `.rb` extension auto-append and use your config file: 60 | 61 | ```bash 62 | λ ruumba -D -e app/views -c .ruumba.yml 63 | ``` 64 | 65 | Since Ruumba rewrites new files from `.erb` files contents, some formatting cops 66 | cannot apply. You can disable them in your Ruumba config file: 67 | 68 | ```yaml 69 | Style/FrozenStringLiteralComment: 70 | Enabled: false 71 | Layout/HashAlignment: 72 | Enabled: false 73 | Layout/ParameterAlignment: 74 | Enabled: false 75 | Layout/IndentationWidth: 76 | Enabled: false 77 | Layout/TrailingEmptyLines: 78 | Enabled: false 79 | ``` 80 | 81 | You can use `ruumba -a` or `ruumba -D` to look for other cops if this list is 82 | missing some. 83 | 84 | You might want to include your existing RuboCop config file by appending this in 85 | front of your Ruumba config: 86 | 87 | ```yaml 88 | inherit_from: .rubocop.yml 89 | ``` 90 | 91 | ### Editor Integrations 92 | 93 | * [Atom plugin](https://atom.io/packages/linter-ruumba) 94 | * [Vim plugin](https://github.com/dense-analysis/ale) 95 | * [Emacs package](https://github.com/flycheck/flycheck) 96 | 97 | ## Contributing 98 | 1. Branch (`git checkout -b fancy-new-feature`) 99 | 2. Commit (`git commit -m "Fanciness!"`) 100 | 3. Test (`bundle exec rake spec`) 101 | 4. Lint (`bundle exec rake rubocop`) 102 | 5. Push (`git push origin fancy-new-feature`) 103 | 6. Ye Olde Pulle Requeste 104 | -------------------------------------------------------------------------------- /spec/ruumba/parser_spec.rb: -------------------------------------------------------------------------------- 1 | # @author Eric Weinstein 2 | 3 | require 'spec_helper' 4 | 5 | describe Ruumba::Parser do 6 | let(:parser) { described_class.new } 7 | 8 | describe '#extract' do 9 | it 'extracts one line of Ruby code from an ERB template' do 10 | erb = "<%= puts 'Hello, world!' %>" 11 | 12 | expect(parser.extract(erb)).to eq(" puts 'Hello, world!' ") 13 | end 14 | 15 | it 'extracts many lines of Ruby code from an ERB template' do 16 | erb = <<~RHTML 17 | <%= puts 'foo' %> 18 | <%= puts 'bar' %> 19 | <% baz %> 20 | RHTML 21 | 22 | expect(parser.extract(erb)).to eq(" puts 'foo' \n puts 'bar' \n baz \n") 23 | end 24 | 25 | it 'extracts multiple interpolations per line' do 26 | erb = "<%= puts 'foo' %> then <% bar %>" 27 | 28 | expect(parser.extract(erb)).to eq(" puts 'foo' ; bar ") 29 | end 30 | 31 | it 'does extract single line ruby comments from an ERB template' do 32 | erb = 33 | <<~RHTML 34 | <% puts 'foo' 35 | # that puts is ruby code 36 | bar %> 37 | RHTML 38 | 39 | parsed = 40 | <<~RUBY 41 | puts 'foo' 42 | # that puts is ruby code 43 | bar 44 | RUBY 45 | 46 | expect(parser.extract(erb)).to eq(parsed) 47 | end 48 | 49 | it 'does not extract ruby comments from interpolated code' do 50 | erb = 51 | <<~RHTML 52 | <%# this is a multiline comment 53 | interpolated in the ERB template 54 | it should be inside a comment %> 55 | <% puts 'foo' %> 56 | RHTML 57 | 58 | parsed = 59 | <<~RUBY 60 | # this is a multiline comment 61 | # interpolated in the ERB template 62 | # it should be inside a comment 63 | puts 'foo' 64 | RUBY 65 | 66 | expect(parser.extract(erb)).to eq(parsed) 67 | end 68 | 69 | it 'extracts and converts lines using <%== for the raw helper' do 70 | erb = <<~RHTML 71 | > 72 | RHTML 73 | 74 | expect(parser.extract(erb)) 75 | .to eq(" raw 'style=\"display: none;\"' if num.even? \n") 76 | end 77 | 78 | it 'does not extract code from lines without ERB interpolation' do 79 | erb = "

Dead or alive, you're coming with me.

" 80 | 81 | expect(parser.extract(erb)).to eq(' ' * 46) 82 | end 83 | 84 | it 'extracts comments on the same line' do 85 | erb = '<% if (foo = bar) %><%# should always be truthy %>' 86 | 87 | expect(parser.extract(erb)) 88 | .to eq(' if (foo = bar) ; # should always be truthy ') 89 | end 90 | 91 | context 'when configured with a region marker' do 92 | let(:parser) { described_class.new('mark') } 93 | 94 | it 'extracts comments on the same line' do 95 | erb = '<% if (foo = bar) %><%# should always be truthy %>' 96 | 97 | ruby = 98 | <<~RUBY 99 | 100 | mark_0000000001 101 | if (foo = bar) 102 | mark_0000000001 103 | ; 104 | mark_0000000002 105 | # should always be truthy 106 | mark_0000000002 107 | 108 | RUBY 109 | .chomp 110 | 111 | expect(parser.extract(erb)).to eq(ruby) 112 | end 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/ruumba/analyzer.rb: -------------------------------------------------------------------------------- 1 | # @author Eric Weinstein 2 | 3 | require 'securerandom' 4 | require 'digest' 5 | require 'pathname' 6 | require 'tmpdir' 7 | require 'open3' 8 | require 'English' 9 | 10 | require 'ruumba/iterators' 11 | require 'ruumba/correctors' 12 | require 'ruumba/parser' 13 | require 'ruumba/rubocop_runner' 14 | 15 | # Ruumba: RuboCop's sidekick. 16 | module Ruumba 17 | # Generates analyzer objects that, when run, delegate 18 | # to RuboCop for linting (style, correctness, &c). 19 | class Analyzer 20 | def initialize(opts = nil) 21 | @options = opts || {} 22 | end 23 | 24 | # Performs static analysis on the provided directory. 25 | # @param [Array] dir The directories / files to analyze. 26 | def run(files_or_dirs = ARGV) 27 | if options[:tmp_folder] 28 | analyze(File.expand_path(options[:tmp_folder]), files_or_dirs) 29 | else 30 | Dir.mktmpdir do |dir| 31 | analyze(dir, files_or_dirs) 32 | end 33 | end 34 | end 35 | 36 | private 37 | 38 | attr_reader :options 39 | 40 | def analyze(temp_dir, files_or_dirs) 41 | temp_dir_path = Pathname.new(temp_dir) 42 | 43 | iterator, corrector = 44 | if stdin? 45 | [Iterators::StdinIterator.new(File.expand_path(stdin_filename)), Correctors::StdinCorrector.new(digestor, parser)] 46 | else 47 | [Iterators::DirectoryIterator.new(files_or_dirs, temp_dir.to_s), Correctors::FileCorrector.new(digestor, parser)] 48 | end 49 | 50 | iterator.each do |file, contents| 51 | code, new_file_name = copy_erb_file(file, contents, temp_dir_path) 52 | 53 | if stdin? 54 | @stdin_contents = code 55 | @new_stdin_filename = new_file_name 56 | end 57 | end 58 | 59 | stdout, stderr, exit_code = RubocopRunner.new(arguments, pwd, temp_dir_path, @stdin_contents, !disable_rb_extension?).execute 60 | 61 | corrector.correct(stdout, stderr, file_mappings) if auto_correct? 62 | 63 | [[STDOUT, stdout], [STDERR, stderr]].each do |output_stream, output| 64 | next if output.nil? || output.empty? 65 | 66 | output_stream.puts(output) 67 | end 68 | 69 | exit_code 70 | end 71 | 72 | def extension 73 | '.rb' unless disable_rb_extension? 74 | end 75 | 76 | def auto_correct? 77 | options[:auto_correct] 78 | end 79 | 80 | def stdin? 81 | stdin_filename 82 | end 83 | 84 | def stdin_filename 85 | options[:stdin] 86 | end 87 | 88 | def arguments 89 | if stdin? 90 | options[:arguments] + ['--stdin', @new_stdin_filename] 91 | else 92 | options[:arguments] 93 | end 94 | end 95 | 96 | def disable_rb_extension? 97 | options[:disable_rb_extension] 98 | end 99 | 100 | def pwd 101 | @pwd ||= Pathname.new(ENV['PWD']) 102 | end 103 | 104 | def auto_correct_marker 105 | return @auto_correct_marker if defined?(@auto_correct_marker) 106 | 107 | @auto_correct_marker = auto_correct? ? 'marker_' + SecureRandom.uuid.tr('-', '_') : nil 108 | end 109 | 110 | def parser 111 | @parser ||= Parser.new(auto_correct_marker) 112 | end 113 | 114 | def digestor 115 | @digestor ||= ->(contents) { Digest::SHA256.base64digest(contents) } 116 | end 117 | 118 | def file_mappings 119 | @file_mappings ||= {} 120 | end 121 | 122 | def copy_erb_file(file, contents, temp_dir) 123 | code = parser.extract(contents) 124 | new_file = temp_filename_for(file, temp_dir) 125 | 126 | if auto_correct? 127 | properties = [] 128 | properties << new_file 129 | properties << digestor.call(code) 130 | 131 | properties << 132 | if stdin? 133 | contents 134 | else 135 | -> { File.read(file) } 136 | end 137 | 138 | file_mappings[file] = properties 139 | end 140 | 141 | unless stdin? 142 | FileUtils.mkdir_p(File.dirname(new_file)) 143 | 144 | File.open(new_file, 'w+') do |tmp_file| 145 | tmp_file.write(code) 146 | end 147 | end 148 | 149 | [code, new_file] 150 | end 151 | 152 | def temp_filename_for(file, temp_dir) 153 | name = temp_dir.join(Pathname.new(file).relative_path_from(pwd)) 154 | 155 | "#{name}#{extension}" 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/ruumba/parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # @author Eric Weinstein 4 | 5 | module Ruumba 6 | # Responsible for extracting interpolated Ruby. 7 | class Parser 8 | # The regular expression to capture interpolated Ruby. 9 | ERB_REGEX = /<%[-=]?(.*?)-?%>/m.freeze 10 | 11 | def initialize(region_start_marker = nil) 12 | @region_start_marker = region_start_marker 13 | end 14 | 15 | # Extracts Ruby code from an ERB template. 16 | # @return [String] The extracted ruby code 17 | def extract(contents) 18 | file_text, matches = parse(contents) 19 | 20 | extracted_ruby = +'' 21 | 22 | last_match = [0, 0] 23 | matches.each_with_index do |(start_index, end_index), index| 24 | handle_region_before(start_index, last_match.last, file_text, extracted_ruby) 25 | 26 | match_marker = "#{region_start_marker}_#{format('%010d', index + 1)}" if region_start_marker 27 | extracted_ruby << extract_match(file_text, start_index, end_index, match_marker) 28 | 29 | last_match = [start_index, end_index] 30 | end 31 | 32 | extracted_ruby << file_text[last_match.last..-1].gsub(/./, ' ') 33 | 34 | # if we replaced <%== with <%= raw, try to shift the columns back to the 35 | # left so they match the original again 36 | extracted_ruby.gsub!(/ raw/, 'raw') 37 | 38 | extracted_ruby 39 | end 40 | 41 | def replace(old_contents, new_contents) 42 | file_text, matches = parse(old_contents) 43 | 44 | auto_corrected_erb = +'' 45 | 46 | last_match = [0, 0] 47 | matches.each_with_index do |(start_index, end_index), index| 48 | match_start = start_index 49 | prev_end_index = last_match.last 50 | 51 | if start_index > prev_end_index 52 | region_before = file_text[prev_end_index..match_start - 1] 53 | 54 | auto_corrected_erb << region_before 55 | end 56 | 57 | suffix = format('%010d', index + 1) 58 | match_marker = "#{region_start_marker}_#{suffix}" 59 | 60 | match_without_markers = new_contents[/\n#{match_marker}$\n(.*)\n#{match_marker}\n/m, 1] 61 | 62 | # auto-correct is still experimental and can cause invalid ruby to be generated when extracting ruby from ERBs 63 | return nil unless match_without_markers 64 | 65 | auto_corrected_erb << match_without_markers 66 | 67 | last_match = [start_index, end_index] 68 | end 69 | 70 | auto_corrected_erb << file_text[last_match.last..-1] 71 | 72 | auto_corrected_erb 73 | end 74 | 75 | private 76 | 77 | attr_reader :region_start_marker 78 | 79 | def parse(contents) 80 | # http://edgeguides.rubyonrails.org/active_support_core_extensions.html#output-safety 81 | # replace '<%==' with '<%= raw' to avoid generating invalid ruby code 82 | file_text = contents.gsub(/<%==/, '<%= raw') 83 | 84 | matching_regions = file_text.enum_for(:scan, ERB_REGEX) 85 | .map { Regexp.last_match.offset(1) } 86 | 87 | [file_text, matching_regions] 88 | end 89 | 90 | def handle_region_before(match_start, prev_end_index, file_text, extracted_ruby) 91 | return unless match_start > prev_end_index 92 | 93 | last_position = extracted_ruby.length 94 | 95 | region_before = file_text[prev_end_index..match_start - 1] 96 | 97 | region_before.gsub!(/./, ' ') 98 | 99 | # if the last match was on the same line, we need to use a semicolon to 100 | # separate statements 101 | extracted_ruby[last_position] = ';' if needs_stmt_delimiter?(prev_end_index, region_before) 102 | 103 | extracted_ruby << region_before 104 | end 105 | 106 | def needs_stmt_delimiter?(last_match, region_before) 107 | last_match.positive? && region_before.index("\n").nil? 108 | end 109 | 110 | def extract_match(file_text, start_index, end_index, match_marker) 111 | file_text[start_index...end_index].tap do |region| 112 | # if there is a ruby comment inside, replace the beginning of each line 113 | # with the '#' so we end up with valid ruby 114 | 115 | if region[0] == '#' 116 | region.gsub!(/^ /, '#') 117 | region.gsub!(/^(?!#)/, '#') 118 | end 119 | 120 | if match_marker 121 | region.prepend("\n", match_marker, "\n") 122 | region.concat("\n", match_marker, "\n") 123 | end 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /bin/ruumba: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby -w 2 | # @author Eric Weinstein 3 | 4 | require 'optparse' 5 | require_relative '../lib/ruumba' 6 | require_relative '../lib/ruumba/version' 7 | 8 | options = { arguments: [] } 9 | opts_parser = OptionParser.new do |opts| 10 | opts.version = Ruumba::Version::STRING 11 | 12 | opts.banner = 'Usage: ruumba path/to/ERBs/' 13 | 14 | opts.on('-h', '--help', 'Display this screen') do 15 | puts opts_parser 16 | exit 17 | end 18 | 19 | opts.on('-a', '--auto-gen-config', 'Generate a configuration file acting as a TODO list.') do 20 | options[:arguments] << '--auto-gen-config' 21 | end 22 | 23 | opts.on('--exclude-limit COUNT', 'Used together with --auto-gen-config to set the limit for how many Exclude properties to generate. Default is 15.') do |count| 24 | options[:arguments] << '--exclude-limit' 25 | options[:arguments] << count 26 | end 27 | 28 | opts.on('--no-offense-counts', 'Do not include offense counts in configuration file generated by --auto-gen-config.') do 29 | options[:arguments] << '--no-offense-counts' 30 | end 31 | 32 | opts.on('-t', '--tmp-folder [TEMP_FOLDER]', 'Use this suffix for the temporary folder') do |f| 33 | options[:tmp_folder] = f 34 | end 35 | 36 | opts.on('-c', '--config [CONFIG]', 'Use this config for rubocop') do |c| 37 | options[:arguments] << '--config' 38 | options[:arguments] << File.expand_path(c) 39 | end 40 | 41 | opts.on('-C', '--cache FLAG', "Use result caching (FLAG=true) or don't", '(FLAG=false), default determined by', 'configuration parameter AllCops: UseCache.') do |flag| 42 | options[:arguments] << '--cache' 43 | options[:arguments] << flag 44 | end 45 | 46 | opts.on('-d', '--debug', 'Display debug info.') do 47 | options[:arguments] << '--debug' 48 | end 49 | 50 | opts.on('-D', '--display-cop-names', 'Display cop names') do 51 | options[:arguments] << '--display-cop-names' 52 | end 53 | 54 | opts.on('-F', '--fail-level [LEVEL]', 'Rubocop fail level') do |l| 55 | options[:arguments] << '--fail-level' 56 | options[:arguments] << l 57 | end 58 | 59 | opts.on('-e', '--disable-ruby-ext', 'Disable auto-append of .rb extension') do 60 | options[:disable_rb_extension] = true 61 | end 62 | 63 | opts.on('--only [COP1,COP2,...]', 'Run only the given cop(s).') do |cops| 64 | options[:arguments] << '--only' 65 | options[:arguments] << cops 66 | end 67 | 68 | opts.on('-l', '--lint', 'Run only lint cops.') do 69 | options[:arguments] << '--lint' 70 | end 71 | 72 | opts.on('-r', '--require [FILE]', 'Require Ruby file.') do |file| 73 | options[:arguments] << '--require' 74 | options[:arguments] << file 75 | end 76 | 77 | opts.on('-o', '--out [FILE]', 'Write output to a file instead of STDOUT.') do |file| 78 | options[:arguments] << '--out' 79 | options[:arguments] << file 80 | end 81 | 82 | opts.on('-f', '--format [FORMAT]', 'Choose an output formatter.') do |format| 83 | options[:arguments] << '--format' 84 | options[:arguments] << format 85 | end 86 | 87 | opts.on('-s', '--stdin [FILE]', 'Pipe source from STDIN, using FILE in offense reports. This is useful for editor integration') do |file| 88 | options[:stdin] = file 89 | end 90 | 91 | opts.on('--force-exclusion', 'Force excluding files specified in the configuration `Exclude` even if they are explicitly passed as arguments.') do 92 | options[:arguments] << '--force-exclusion' 93 | end 94 | 95 | opts.on('-P', '--parallel', 'Use available CPUs to execute inspection in parallel.') do 96 | options[:arguments] << '--parallel' 97 | end 98 | 99 | opts.on('-L', '--list-target-files', 'List all files Ruumba will inspect.') do 100 | options[:arguments] << '--list-target-files' 101 | end 102 | 103 | opts.on('-a', '--auto-correct', 'Auto-correct offenses.') do 104 | options[:arguments] << '--auto-correct' 105 | options[:auto_correct] = true 106 | end 107 | 108 | opts.on(nil, '--safe-auto-correct', "Run auto-correct only when it's safe.") do 109 | options[:arguments] << '--safe-auto-correct' 110 | options[:auto_correct] = true 111 | end 112 | end 113 | 114 | begin 115 | opts_parser.parse! 116 | rescue OptionParser::InvalidOption => e 117 | abort "An error occurred: #{e}. Run ruumba -h for help." 118 | end 119 | 120 | if !options[:arguments].include?('--config') && File.exist?('.ruumba.yml') 121 | options[:arguments] << '--config' 122 | options[:arguments] << File.expand_path('.ruumba.yml') 123 | end 124 | 125 | analyzer = Ruumba::Analyzer.new(options) 126 | exit(analyzer.run) 127 | -------------------------------------------------------------------------------- /spec/ruumba/analyzer_spec.rb: -------------------------------------------------------------------------------- 1 | # @author Eric Weinstein 2 | 3 | require 'spec_helper' 4 | 5 | describe Ruumba::Analyzer do 6 | subject(:analysis) { analyzer.run(file_list) } 7 | let(:analyzer) { described_class.new(options) } 8 | let(:current_directory) { Pathname.new(ENV['PWD']) } 9 | let(:rubocop_runner) { instance_double(Ruumba::RubocopRunner) } 10 | let(:parser) { instance_double(Ruumba::Parser) } 11 | let(:result) { [nil, nil, 1] } 12 | let(:analyze_result) { result.last } 13 | let(:rubocop_stdin_contents) { nil } 14 | let(:disable_rb_extension) { false } 15 | let(:file_list) { ['app', 'lib', 'spec/thing_spec.rb'] } 16 | let(:arguments) { %w[--lint-only] } 17 | let(:rubocop_arguments) { %w[--lint-only] } 18 | let(:options) do 19 | { 20 | arguments: arguments, 21 | disable_rb_extension: disable_rb_extension, 22 | tmp_folder: temp_folder_option 23 | } 24 | end 25 | 26 | describe '#run' do 27 | before do 28 | expect(Ruumba::RubocopRunner).to receive(:new).with( 29 | rubocop_arguments, current_directory, temp_dir, rubocop_stdin_contents, !disable_rb_extension 30 | ).and_return(rubocop_runner) 31 | expect(Ruumba::Parser).to receive(:new).and_return(parser) 32 | end 33 | 34 | context 'when passing in the filename via stdin' do 35 | let(:rubocop_stdin_filename) { File.expand_path(temp_dir.join(Pathname.new(stdin_filename)).to_s) + '.rb' } 36 | let(:rubocop_arguments) { ['--lint-only', '--stdin', rubocop_stdin_filename] } 37 | let(:rubocop_stdin_contents) { 'code1' } 38 | let(:stdin_contents) { 'contents1 of erb' } 39 | let(:stdin_filename) { 'file1.erb' } 40 | let(:file_and_content) do 41 | [ 42 | [File.expand_path(stdin_filename), stdin_contents] 43 | ] 44 | end 45 | 46 | before do 47 | options[:stdin] = stdin_filename 48 | expect(Ruumba::Iterators::StdinIterator).to receive(:new).with(File.expand_path(stdin_filename)).and_return(file_and_content) 49 | expect(parser).to receive(:extract).with(stdin_contents).and_return(rubocop_stdin_contents) 50 | end 51 | 52 | shared_examples_for 'linting a single file' do 53 | it 'passes the extracted files contents on stdin, appends the rb extension to the stdin filename argument and runs rubocop' do 54 | expect(rubocop_runner).to receive(:execute).and_return(result) 55 | 56 | expect(analysis).to eq(analyze_result) 57 | end 58 | 59 | context 'when the rb extension is disabled' do 60 | let(:disable_rb_extension) { true } 61 | let(:rubocop_stdin_filename) { File.expand_path(temp_dir.join(Pathname.new(stdin_filename)).to_s) } 62 | 63 | it 'passes the extracted files contents on stdin and runs rubocop' do 64 | expect(rubocop_runner).to receive(:execute).and_return(result) 65 | 66 | expect(analysis).to eq(analyze_result) 67 | end 68 | end 69 | end 70 | 71 | context 'when no temporary directory is configured' do 72 | let(:temp_folder_option) { nil } 73 | let(:temp_dir) { Pathname.new(Dir.mktmpdir) } 74 | 75 | before do 76 | expect(Dir).to receive(:mktmpdir) do |*_args, &block| 77 | block.call(temp_dir) 78 | end 79 | end 80 | 81 | after do 82 | FileUtils.remove_dir(temp_dir) 83 | end 84 | 85 | it_behaves_like 'linting a single file' 86 | end 87 | 88 | context 'when a temporary directory is configured' do 89 | let(:temp_folder_option) { temp_dir.to_s } 90 | let(:temp_dir) { Pathname.new(Dir.mktmpdir) } 91 | 92 | after do 93 | FileUtils.remove_dir(temp_dir) 94 | end 95 | 96 | it_behaves_like 'linting a single file' 97 | end 98 | end 99 | 100 | context 'when file names are passed as arguments' do 101 | let(:files_and_contents) do 102 | [ 103 | [current_directory.join('file1.erb'), 'contents1'], 104 | [current_directory.join('file2.erb'), 'contents2'] 105 | ] 106 | end 107 | 108 | before do 109 | expect(Ruumba::Iterators::DirectoryIterator).to receive(:new).with(file_list, temp_dir.to_s).and_return(files_and_contents) 110 | expect(parser).to receive(:extract).with('contents1').and_return('code1') 111 | expect(parser).to receive(:extract).with('contents2').and_return('code2') 112 | end 113 | 114 | shared_examples_for 'linting a list of files' do 115 | it 'copies the files, adding the .rb extension and runs rubocop' do 116 | expect(File).to receive(:open).with(temp_dir.join('file1.erb.rb').to_s, 'w+') 117 | expect(File).to receive(:open).with(temp_dir.join('file2.erb.rb').to_s, 'w+') 118 | 119 | expect(rubocop_runner).to receive(:execute).and_return(result) 120 | 121 | expect(analysis).to eq(analyze_result) 122 | end 123 | 124 | context 'when the rb extension is disabled' do 125 | let(:disable_rb_extension) { true } 126 | 127 | it 'copies the files and runs rubocop' do 128 | expect(File).to receive(:open).with(temp_dir.join('file1.erb').to_s, 'w+') 129 | expect(File).to receive(:open).with(temp_dir.join('file2.erb').to_s, 'w+') 130 | 131 | expect(rubocop_runner).to receive(:execute).and_return(result) 132 | 133 | expect(analysis).to eq(analyze_result) 134 | end 135 | end 136 | end 137 | 138 | context 'when no temporary directory is configured' do 139 | let(:temp_folder_option) { nil } 140 | let(:temp_dir) { Pathname.new(Dir.mktmpdir) } 141 | 142 | before do 143 | expect(Dir).to receive(:mktmpdir) do |*_args, &block| 144 | block.call(temp_dir) 145 | end 146 | end 147 | 148 | after do 149 | FileUtils.remove_dir(temp_dir) 150 | end 151 | 152 | it_behaves_like 'linting a list of files' 153 | end 154 | 155 | context 'when a temporary directory is configured' do 156 | let(:temp_folder_option) { temp_dir.to_s } 157 | let(:temp_dir) { Pathname.new(Dir.mktmpdir) } 158 | 159 | it_behaves_like 'linting a list of files' 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/ruumba/correctors_spec.rb: -------------------------------------------------------------------------------- 1 | # @author Andrew Clemons 2 | 3 | require 'spec_helper' 4 | require 'tempfile' 5 | 6 | describe Ruumba::Correctors::Replacer do 7 | class TestReplacer 8 | include Ruumba::Correctors::Replacer 9 | 10 | attr_accessor :digestor, :parser 11 | 12 | def initialize(digestor, parser) 13 | @digestor = digestor 14 | @parser = parser 15 | end 16 | end 17 | 18 | describe '#handle_corrected_input' do 19 | subject(:replacer) { TestReplacer.new(digestor, parser) } 20 | let(:digestor) { double } 21 | let(:parser) { double } 22 | let(:old_digest) { 1 } 23 | let(:original_contents) { 'some code' } 24 | 25 | before do 26 | expect(digestor).to receive(:call).with(new_contents).and_return(new_digest) 27 | end 28 | 29 | context 'when the contents have not changed' do 30 | let(:new_digest) { old_digest } 31 | let(:new_contents) { original_contents } 32 | 33 | it 'returns nil' do 34 | expect(replacer.handle_corrected_output(old_digest, new_contents, original_contents)).to be nil 35 | end 36 | end 37 | 38 | context 'when the contents have changed' do 39 | let(:new_digest) { 2 } 40 | let(:new_contents) { 'some new code' } 41 | 42 | shared_examples_for 'corrected output' do 43 | before do 44 | expect(parser).to receive(:replace).with(original_contents_value, new_contents).and_return(replaced_output) 45 | end 46 | 47 | context 'when the output is successfully replaced' do 48 | let(:replaced_output) { 'replaced code' } 49 | let(:block) do 50 | lambda do |yielded_output| 51 | expect(yielded_output).to eq(replaced_output) 52 | 53 | yielded_output 54 | end 55 | end 56 | 57 | it 'yields the result' do 58 | expect(replacer.handle_corrected_output(old_digest, new_contents, original_contents, &block)).to eq(replaced_output) 59 | end 60 | end 61 | 62 | context 'when the output was not successfully replaced' do 63 | let(:replaced_output) { nil } 64 | 65 | it 'returns nil' do 66 | expect(replacer.handle_corrected_output(old_digest, new_contents, original_contents)).to be nil 67 | end 68 | end 69 | end 70 | 71 | context 'when the original contents are directly passed' do 72 | let(:original_contents_value) { original_contents } 73 | 74 | it_behaves_like 'corrected output' 75 | end 76 | 77 | context 'when the original contents are passed as a proc' do 78 | let(:original_contents) { -> { 'some code' } } 79 | let(:original_contents_value) { 'some code' } 80 | 81 | it_behaves_like 'corrected output' 82 | end 83 | end 84 | end 85 | end 86 | 87 | describe Ruumba::Correctors::StdinCorrector do 88 | let(:digestor) { double } 89 | let(:parser) { double } 90 | let(:old_digest) { 1 } 91 | let(:original_contents) { 'some code' } 92 | let(:new_contents) { 'new code' } 93 | let(:file_mappings) { { 'ignored' => ['ignored2', old_digest, original_contents] } } 94 | let(:stderr) { '' } 95 | let(:stdout_base) do 96 | <<~STDOUT 97 | Inspecting 1 file 98 | W 99 | 100 | Offenses: 101 | 102 | app/views/file.rb:17:31: C: [Corrected] Layout/SpaceAroundEqualsInParameterDefault: Surrounding space missing in default value assignment. 103 | def blah(thing='other') 104 | ^ 105 | 106 | 1 file inspected, 1 offense detected, 1 offense corrected 107 | ==================== 108 | #{new_contents} 109 | STDOUT 110 | end 111 | let(:stdout) { stdout_base.dup } 112 | let(:stdout_replaced) { "#{stdout_base.chomp} - fixed" } 113 | subject(:corrector) { described_class.new(digestor, parser) } 114 | 115 | describe '#correct' do 116 | it 'replaces the output contents with the corrected output' do 117 | expect(corrector).to receive(:handle_corrected_output).with(old_digest, "\n#{new_contents}\n", original_contents) { |&block| block.call("#{new_contents} - fixed") } 118 | 119 | corrector.correct(stdout, stderr, file_mappings) 120 | 121 | expect(stdout).to eq(stdout_replaced) 122 | end 123 | 124 | context 'when outputting in JSON format' do 125 | let(:stdout_base) do 126 | <<~STDOUT 127 | {"metadata":{"rubocop_version":"0.74.0","ruby_engine":"ruby","ruby_version":"2.6.3","ruby_patchlevel":"62","ruby_platform":"x86_64-linux"},"files":[{"path":"app/views/file.rb","offenses":[{"severity":"convention","message":"Surrounding space missing in default value assignment.","cop_name":"Layout/SpaceAroundEqualsInParameterDefault","corrected":true,"location":{"start_line":17,"start_column":31,"last_line":17,"last_column":31,"length":1,"line":17,"column":31}}]}],"summary":{"offense_count":1,"target_file_count":1,"inspected_file_count":1}}==================== 128 | #{new_contents} 129 | STDOUT 130 | end 131 | it 'replaces the output contents with the corrected output' do 132 | expect(corrector).to receive(:handle_corrected_output).with(old_digest, "\n#{new_contents}\n", original_contents) { |&block| block.call("#{new_contents} - fixed") } 133 | 134 | corrector.correct(stdout, stderr, file_mappings) 135 | 136 | expect(stdout).to eq(stdout_replaced) 137 | end 138 | end 139 | end 140 | end 141 | 142 | describe Ruumba::Correctors::FileCorrector do 143 | let(:digestor) { double } 144 | let(:parser) { double } 145 | let(:old_digest) { 1 } 146 | let(:original_contents) { 'some code' } 147 | let(:new_contents) { 'new code' } 148 | let(:file_mappings) { { original_file => [ruumba_file, old_digest, original_contents] } } 149 | let(:original_file) { Tempfile.new } 150 | let(:ruumba_file) { Tempfile.new } 151 | subject(:corrector) { described_class.new(digestor, parser) } 152 | 153 | describe '#correct' do 154 | before do 155 | File.open(ruumba_file, 'w') { |file| file.puts new_contents } 156 | File.open(original_file, 'w') { |file| file.puts original_contents } 157 | end 158 | 159 | it 'replaces the file contents with the corrected output' do 160 | expect(corrector).to receive(:handle_corrected_output).with(old_digest, "#{new_contents}\n", original_contents) { |&block| block.call(new_contents) } 161 | 162 | corrector.correct(nil, nil, file_mappings) 163 | 164 | expect(File.read(original_file)).to eq(new_contents) 165 | end 166 | end 167 | end 168 | --------------------------------------------------------------------------------