├── spec ├── fixtures │ ├── a │ │ ├── 2 │ │ │ └── i.haml │ │ ├── 4.txt │ │ ├── 1.rb │ │ └── 3.prawn │ ├── b │ │ ├── 3 │ │ │ └── i.haml │ │ ├── 1.rb │ │ └── 2.prawn │ └── c │ │ └── 1.md ├── json_formatter_spec.rb ├── file_spec.rb ├── runner_spec.rb ├── cli_spec.rb ├── encoding_aware_iterator_spec.rb ├── violation_formatter_spec.rb ├── spec_helper.rb ├── rake_task_spec.rb ├── style_check_spec.rb ├── cane_spec.rb ├── threshold_check_spec.rb ├── parser_spec.rb ├── doc_check_spec.rb └── abc_check_spec.rb ├── .gitignore ├── lib ├── cane.rb └── cane │ ├── version.rb │ ├── json_formatter.rb │ ├── default_checks.rb │ ├── cli.rb │ ├── task_runner.rb │ ├── encoding_aware_iterator.rb │ ├── file.rb │ ├── cli │ ├── options.rb │ └── parser.rb │ ├── runner.rb │ ├── rake_task.rb │ ├── violation_formatter.rb │ ├── threshold_check.rb │ ├── style_check.rb │ ├── doc_check.rb │ └── abc_check.rb ├── .travis.yml ├── Gemfile ├── bin └── cane ├── LICENSE ├── Rakefile ├── Gemfile.lock ├── script └── ci ├── cane.gemspec ├── HISTORY.md └── README.md /spec/fixtures/a/4.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | vendor 3 | .bundle 4 | -------------------------------------------------------------------------------- /spec/fixtures/a/1.rb: -------------------------------------------------------------------------------- 1 | puts "wooo I am a/1.rb" 2 | -------------------------------------------------------------------------------- /spec/fixtures/b/1.rb: -------------------------------------------------------------------------------- 1 | puts "wooo I am b/1.rb" 2 | -------------------------------------------------------------------------------- /spec/fixtures/c/1.md: -------------------------------------------------------------------------------- 1 | I'm a markdown file! 2 | ==== 3 | -------------------------------------------------------------------------------- /lib/cane.rb: -------------------------------------------------------------------------------- 1 | require 'cane/cli' 2 | require 'cane/version' 3 | -------------------------------------------------------------------------------- /lib/cane/version.rb: -------------------------------------------------------------------------------- 1 | module Cane 2 | VERSION = '3.0.0' 3 | end 4 | -------------------------------------------------------------------------------- /spec/fixtures/b/3/i.haml: -------------------------------------------------------------------------------- 1 | %ul 2 | %li banana 3 | %li kiwi 4 | %li mango 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - "2.0.0" 4 | - "2.1.0" 5 | script: "bundle exec rake" 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | # Specify your gem's dependencies in ..gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /spec/fixtures/a/3.prawn: -------------------------------------------------------------------------------- 1 | # .prawn files are actually ruby files, so 2 | # it makes sense to run ABC check on them. 3 | -------------------------------------------------------------------------------- /spec/fixtures/b/2.prawn: -------------------------------------------------------------------------------- 1 | # .prawn files are actually ruby files, so 2 | # it makes sense to run ABC check on them. 3 | -------------------------------------------------------------------------------- /bin/cane: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'cane/cli' 4 | 5 | result = Cane::CLI.run(ARGV) 6 | 7 | exit(1) unless result 8 | -------------------------------------------------------------------------------- /spec/fixtures/a/2/i.haml: -------------------------------------------------------------------------------- 1 | = select_tag :typical_rails_field, options_from_collection_for_select(@collection, :this_line_is, :deliberately_long) 2 | -------------------------------------------------------------------------------- /spec/json_formatter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'cane/json_formatter' 4 | 5 | describe Cane::JsonFormatter do 6 | it 'outputs violations as JSON' do 7 | violations = [{description: 'Fail', line: 3}] 8 | expect(JSON.parse(described_class.new(violations).to_s)).to eq( 9 | [{'description' => 'Fail', 'line' => 3}] 10 | ) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/cane/json_formatter.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module Cane 4 | 5 | # Computes a machine-readable JSON representation from an array of violations 6 | # computed by the checks. 7 | class JsonFormatter 8 | def initialize(violations, options = {}) 9 | @violations = violations 10 | end 11 | 12 | def to_s 13 | @violations.to_json 14 | end 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /lib/cane/default_checks.rb: -------------------------------------------------------------------------------- 1 | require 'cane/abc_check' 2 | require 'cane/style_check' 3 | require 'cane/doc_check' 4 | require 'cane/threshold_check' 5 | 6 | # Default checks performed when no checks are provided 7 | module Cane 8 | def default_checks 9 | [ 10 | AbcCheck, 11 | StyleCheck, 12 | DocCheck, 13 | ThresholdCheck 14 | ] 15 | end 16 | module_function :default_checks 17 | end 18 | -------------------------------------------------------------------------------- /lib/cane/cli.rb: -------------------------------------------------------------------------------- 1 | require 'cane/runner' 2 | require 'cane/version' 3 | 4 | require 'cane/cli/parser' 5 | 6 | module Cane 7 | # Command line interface. This passes off arguments to the parser and starts 8 | # the Cane runner 9 | module CLI 10 | def run(args) 11 | spec = Parser.parse(args) 12 | if spec.is_a?(Hash) 13 | Cane.run(spec) 14 | else 15 | spec 16 | end 17 | end 18 | module_function :run 19 | 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Copyright 2012 Square Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | -------------------------------------------------------------------------------- /lib/cane/task_runner.rb: -------------------------------------------------------------------------------- 1 | # Provides a SimpleTaskRunner or Parallel task runner based on configuration 2 | module Cane 3 | def task_runner(opts) 4 | if opts[:parallel] 5 | Parallel 6 | else 7 | SimpleTaskRunner 8 | end 9 | end 10 | module_function :task_runner 11 | 12 | # Mirrors the Parallel gem's interface but does not provide any parallelism. 13 | # This is faster for smaller tasks since it doesn't incur any overhead for 14 | # creating new processes and communicating between them. 15 | class SimpleTaskRunner 16 | def self.map(enumerable, &block) 17 | enumerable.map(&block) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/file_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'tmpdir' 3 | 4 | require 'cane/file' 5 | 6 | describe Cane::File do 7 | describe '.case_insensitive_glob' do 8 | it 'matches all kinds of readmes' do 9 | expected = %w( 10 | README 11 | readme.md 12 | ReaDME.TEXTILE 13 | ) 14 | 15 | Dir.mktmpdir do |dir| 16 | Dir.chdir(dir) do 17 | expected.each do |x| 18 | FileUtils.touch(x) 19 | end 20 | expect(Cane::File.case_insensitive_glob("README*")).to match_array( 21 | expected 22 | ) 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/cane/encoding_aware_iterator.rb: -------------------------------------------------------------------------------- 1 | module Cane 2 | 3 | # Provides iteration over lines (from a file), correctly handling encoding. 4 | class EncodingAwareIterator 5 | include Enumerable 6 | 7 | def initialize(lines) 8 | @lines = lines 9 | end 10 | 11 | def each(&block) 12 | return self.to_enum unless block 13 | 14 | lines.each do |line| 15 | begin 16 | line =~ /\s/ 17 | rescue ArgumentError 18 | line.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace) 19 | end 20 | 21 | block.call(line) 22 | end 23 | end 24 | 25 | protected 26 | 27 | attr_reader :lines 28 | end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /lib/cane/file.rb: -------------------------------------------------------------------------------- 1 | require 'cane/encoding_aware_iterator' 2 | 3 | module Cane 4 | 5 | # An interface for interacting with files that ensures encoding is handled in 6 | # a consistent manner. 7 | class File 8 | class << self 9 | def iterator(path) 10 | EncodingAwareIterator.new(open(path).each_line) 11 | end 12 | 13 | def contents(path) 14 | open(path).read 15 | end 16 | 17 | def open(path) 18 | ::File.open(path, 'r:utf-8') 19 | end 20 | 21 | def exists?(path) 22 | ::File.exists?(path) 23 | end 24 | 25 | def case_insensitive_glob(glob) 26 | Dir.glob(glob, ::File::FNM_CASEFOLD) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler/gem_tasks" 3 | $LOAD_PATH.unshift File.expand_path('../lib', __FILE__) 4 | 5 | begin 6 | require 'rspec/core/rake_task' 7 | 8 | RSpec::Core::RakeTask.new(:spec) do |c| 9 | c.rspec_opts = "--profile" 10 | end 11 | 12 | task :default => :spec 13 | rescue LoadError 14 | warn "rspec not available, spec task not provided" 15 | end 16 | 17 | begin 18 | require 'cane/rake_task' 19 | 20 | desc "Run cane to check quality metrics" 21 | Cane::RakeTask.new(:quality) do |cane| 22 | cane.abc_max = 12 23 | cane.add_threshold 'coverage/covered_percent', :>=, 100 24 | end 25 | 26 | task :default => :quality 27 | rescue LoadError 28 | warn "cane not available, quality task not provided." 29 | end 30 | -------------------------------------------------------------------------------- /spec/runner_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'cane/runner' 4 | 5 | describe Cane::Runner do 6 | describe '#run' do 7 | it 'returns true iff fewer violations than max allowed' do 8 | expect(described_class.new(checks: [], max_violations: 0).run).to be 9 | expect(described_class.new(checks: [], max_violations: -1).run).not_to be 10 | end 11 | 12 | it 'returns JSON output' do 13 | formatter = class_double("Cane::JsonFormatter").as_stubbed_const 14 | expect(formatter).to receive(:new).and_return("JSON") 15 | buffer = StringIO.new("") 16 | 17 | described_class.new( 18 | out: buffer, checks: [], max_violations: 0, json: true 19 | ).run 20 | 21 | expect(buffer.string).to eq("JSON") 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'cane/cli' 4 | 5 | describe Cane::CLI do 6 | describe '.run' do 7 | 8 | let!(:parser) { class_double("Cane::CLI::Parser").as_stubbed_const } 9 | let!(:cane) { class_double("Cane").as_stubbed_const } 10 | 11 | it 'runs Cane with the given arguments' do 12 | expect(parser).to receive(:parse).with("--args").and_return(args: true) 13 | expect(cane).to receive(:run).with(args: true).and_return("tracer") 14 | 15 | expect(described_class.run("--args")).to eq("tracer") 16 | end 17 | 18 | it 'does not run Cane if parser was able to handle input' do 19 | expect(parser).to receive(:parse).with("--args").and_return("tracer") 20 | 21 | expect(described_class.run("--args")).to eq("tracer") 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/cane/cli/options.rb: -------------------------------------------------------------------------------- 1 | require 'cane/default_checks' 2 | 3 | module Cane 4 | # Default options for command line interface 5 | module CLI 6 | def defaults(check) 7 | check.options.each_with_object({}) {|(k, v), h| 8 | option_opts = v[1] || {} 9 | if option_opts[:type] == Array 10 | h[k] = [] 11 | else 12 | h[k] = option_opts[:default] 13 | end 14 | } 15 | end 16 | module_function :defaults 17 | 18 | def default_options 19 | { 20 | max_violations: 0, 21 | parallel: false, 22 | exclusions_file: nil, 23 | checks: Cane.default_checks 24 | }.merge(Cane.default_checks.inject({}) {|a, check| 25 | a.merge(defaults(check)) 26 | }) 27 | end 28 | module_function :default_options 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | cane (3.0.0) 5 | parallel 6 | 7 | GEM 8 | remote: http://rubygems.org/ 9 | specs: 10 | diff-lcs (1.2.4) 11 | multi_json (1.8.2) 12 | parallel (1.6.2) 13 | rake (10.1.0) 14 | rspec (2.14.1) 15 | rspec-core (~> 2.14.0) 16 | rspec-expectations (~> 2.14.0) 17 | rspec-mocks (~> 2.14.0) 18 | rspec-core (2.14.7) 19 | rspec-expectations (2.14.3) 20 | diff-lcs (>= 1.1.3, < 2.0) 21 | rspec-fire (1.2.0) 22 | rspec (~> 2.11) 23 | rspec-mocks (2.14.4) 24 | simplecov (0.7.1) 25 | multi_json (~> 1.0) 26 | simplecov-html (~> 0.7.1) 27 | simplecov-html (0.7.1) 28 | 29 | PLATFORMS 30 | ruby 31 | 32 | DEPENDENCIES 33 | cane! 34 | rake 35 | rspec (~> 2.0) 36 | rspec-fire 37 | simplecov 38 | 39 | BUNDLED WITH 40 | 1.11.2 41 | -------------------------------------------------------------------------------- /script/ci: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | bundler_version="1.0.18" 4 | 5 | source "$HOME/.rvm/scripts/rvm" 6 | 7 | function install_ruby_if_needed() { 8 | echo "Checking for $1..." 9 | if ! rvm list rubies | grep $1 > /dev/null; then 10 | echo "Ruby not found $1..." 11 | rvm install $1 12 | fi 13 | } 14 | 15 | function switch_ruby() { 16 | install_ruby_if_needed $1 && rvm use $1 17 | } 18 | 19 | function install_bundler_if_needed() { 20 | echo "Checking for Bundler $bundler_version..." 21 | if ! gem list --installed bundler --version "$bundler_version" > /dev/null; then 22 | gem install bundler --version "$bundler_version" 23 | fi 24 | } 25 | 26 | function update_gems_if_needed() { 27 | echo "Installing gems..." 28 | bundle check || bundle install 29 | } 30 | 31 | function run_tests() { 32 | bundle exec rake 33 | } 34 | 35 | function prepare_and_run() { 36 | switch_ruby $1 && 37 | install_bundler_if_needed && 38 | update_gems_if_needed && 39 | run_tests 40 | } 41 | 42 | prepare_and_run "1.9.3-p0" 43 | -------------------------------------------------------------------------------- /spec/encoding_aware_iterator_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | require 'cane/encoding_aware_iterator' 5 | 6 | # Example bad input from: 7 | # http://stackoverflow.com/questions/1301402/example-invalid-utf8-string 8 | describe Cane::EncodingAwareIterator do 9 | it 'handles non-UTF8 input' do 10 | lines = ["\xc3\x28"] 11 | result = described_class.new(lines).map.with_index do |line, number| 12 | expect(line).to be_kind_of(String) 13 | [line =~ /\s/, number] 14 | end 15 | expect(result).to eq([[nil, 0]]) 16 | end 17 | 18 | it 'does not enter an infinite loop on persistently bad input' do 19 | expect{ 20 | described_class.new([""]).map.with_index do |line, number| 21 | "\xc3\x28" =~ /\s/ 22 | end 23 | }.to raise_error(ArgumentError) 24 | end 25 | 26 | it 'allows each with no block' do 27 | called_with_line = nil 28 | described_class.new([""]).each.with_index do |line, number| 29 | called_with_line = line 30 | end 31 | expect(called_with_line).to eq("") 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/cane/runner.rb: -------------------------------------------------------------------------------- 1 | require 'parallel' 2 | 3 | require 'cane/violation_formatter' 4 | require 'cane/json_formatter' 5 | 6 | # Accepts a parsed configuration and passes those options to a new Runner 7 | module Cane 8 | def run(*args) 9 | Runner.new(*args).run 10 | end 11 | module_function :run 12 | 13 | # Orchestrates the running of checks per the provided configuration, and 14 | # hands the result to a formatter for display. This is the core of the 15 | # application, but for the actual entry point see `Cane::CLI`. 16 | class Runner 17 | def initialize(spec) 18 | @opts = spec 19 | @checks = spec[:checks] 20 | end 21 | 22 | def run 23 | outputter.print formatter.new(violations, opts) 24 | 25 | violations.length <= opts.fetch(:max_violations) 26 | end 27 | 28 | protected 29 | 30 | attr_reader :opts, :checks 31 | 32 | def violations 33 | @violations ||= checks. 34 | map {|check| check.new(opts).violations }. 35 | flatten 36 | end 37 | 38 | def outputter 39 | opts.fetch(:out, $stdout) 40 | end 41 | 42 | def formatter 43 | if opts[:json] 44 | JsonFormatter 45 | else 46 | ViolationFormatter 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/violation_formatter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'cane/violation_formatter' 4 | 5 | describe Cane::ViolationFormatter do 6 | def violation(description) 7 | { 8 | description: description 9 | } 10 | end 11 | 12 | it 'includes number of violations in the group header' do 13 | expect(described_class.new([violation("FAIL")]).to_s).to include("(1)") 14 | end 15 | 16 | it 'includes total number of violations' do 17 | violations = [violation("FAIL1"), violation("FAIL2")] 18 | result = described_class.new(violations).to_s 19 | expect(result).to include("Total Violations: 2") 20 | end 21 | 22 | it 'does not colorize output by default' do 23 | result = described_class.new([violation("FAIL")]).to_s 24 | expect(result).not_to include("\e[31m") 25 | end 26 | 27 | it 'colorizes output when passed color: true' do 28 | result = described_class.new([violation("FAIL")], color: true).to_s 29 | expect(result).to include("\e[31m") 30 | expect(result).to include("\e[0m") 31 | end 32 | 33 | it 'does not colorize output if max_violations is not crossed' do 34 | options = { color: true, max_violations: 1 } 35 | result = described_class.new([violation("FAIL")], options).to_s 36 | 37 | expect(result).not_to include("\e[31m") 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /cane.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/cane/version', __FILE__) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.authors = ["Xavier Shay"] 6 | gem.email = ["xavier@squareup.com"] 7 | gem.description = 8 | %q{Fails your build if code quality thresholds are not met} 9 | gem.summary = %q{ 10 | Fails your build if code quality thresholds are not met. Provides 11 | complexity and style checkers built-in, and allows integration with with 12 | custom quality metrics. 13 | } 14 | gem.homepage = "http://github.com/square/cane" 15 | 16 | gem.executables = [] 17 | gem.required_ruby_version = '>= 1.9.0' 18 | gem.files = Dir.glob("{spec,lib}/**/*.rb") + %w( 19 | README.md 20 | HISTORY.md 21 | LICENSE 22 | cane.gemspec 23 | ) 24 | gem.test_files = Dir.glob("spec/**/*.rb") 25 | gem.name = "cane" 26 | gem.require_paths = ["lib"] 27 | gem.bindir = "bin" 28 | gem.executables << "cane" 29 | gem.license = "Apache 2.0" 30 | gem.version = Cane::VERSION 31 | gem.has_rdoc = false 32 | gem.add_dependency 'parallel' 33 | gem.add_development_dependency 'rspec', '~> 2.0' 34 | gem.add_development_dependency 'rake' 35 | gem.add_development_dependency 'simplecov' 36 | gem.add_development_dependency 'rspec-fire' 37 | end 38 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec/fire' 2 | require 'tempfile' 3 | require 'stringio' 4 | require 'rake' 5 | require 'rake/tasklib' 6 | 7 | RSpec.configure do |config| 8 | config.include(RSpec::Fire) 9 | 10 | def capture_stdout &block 11 | real_stdout, $stdout = $stdout, StringIO.new 12 | yield 13 | $stdout.string 14 | ensure 15 | $stdout = real_stdout 16 | end 17 | end 18 | 19 | # Keep a reference to all tempfiles so they are not garbage collected until the 20 | # process exits. 21 | $tempfiles = [] 22 | 23 | def make_file(content) 24 | tempfile = Tempfile.new('cane') 25 | $tempfiles << tempfile 26 | tempfile.print(content) 27 | tempfile.flush 28 | tempfile.path 29 | end 30 | 31 | def in_tmp_dir(&block) 32 | Dir.mktmpdir do |dir| 33 | Dir.chdir(dir, &block) 34 | end 35 | end 36 | 37 | RSpec::Matchers.define :have_violation do |label| 38 | match do |check| 39 | violations = check.violations 40 | expect(violations.length).to eq(1) 41 | expect(violations[0][:label]).to eq(label) 42 | end 43 | end 44 | 45 | RSpec::Matchers.define :have_no_violations do |label| 46 | match do |check| 47 | violations = check.violations 48 | expect(violations.length).to eq(0) 49 | end 50 | end 51 | 52 | require 'simplecov' 53 | 54 | class SimpleCov::Formatter::QualityFormatter 55 | def format(result) 56 | SimpleCov::Formatter::HTMLFormatter.new.format(result) 57 | File.open("coverage/covered_percent", "w") do |f| 58 | f.puts result.source_files.covered_percent.to_i 59 | end 60 | end 61 | end 62 | 63 | SimpleCov.formatter = SimpleCov::Formatter::QualityFormatter 64 | SimpleCov.start do 65 | add_filter "vendor/bundle/" 66 | end 67 | -------------------------------------------------------------------------------- /spec/rake_task_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'cane/rake_task' 4 | 5 | describe Cane::RakeTask do 6 | it 'enables cane to be configured an run via rake' do 7 | fn = make_file("90") 8 | my_check = Class.new(Struct.new(:opts)) do 9 | def violations 10 | [description: 'test', label: opts.fetch(:some_opt)] 11 | end 12 | end 13 | 14 | task = Cane::RakeTask.new(:quality) do |cane| 15 | cane.no_abc = true 16 | cane.no_doc = true 17 | cane.no_style = true 18 | cane.add_threshold fn, :>=, 99 19 | cane.use my_check, some_opt: "theopt" 20 | cane.max_violations = 0 21 | cane.parallel = false 22 | end 23 | 24 | expect(task.no_abc).to eq(true) 25 | 26 | expect(task).to receive(:abort) 27 | out = capture_stdout do 28 | Rake::Task['quality'].invoke 29 | end 30 | 31 | expect(out).to include("Quality threshold crossed") 32 | expect(out).to include("theopt") 33 | end 34 | 35 | it 'can be configured using a .cane file' do 36 | conf = "--gte 90,99" 37 | 38 | task = Cane::RakeTask.new(:canefile_quality) do |cane| 39 | cane.canefile = make_file(conf) 40 | end 41 | 42 | expect(task).to receive(:abort) 43 | out = capture_stdout do 44 | Rake::Task['canefile_quality'].invoke 45 | end 46 | 47 | expect(out).to include("Quality threshold crossed") 48 | end 49 | 50 | it 'defaults to using a canefile without a block' do 51 | in_tmp_dir do 52 | conf = "--gte 90,99" 53 | conf_file = File.open('.cane', 'w') {|f| f.write conf } 54 | 55 | task = Cane::RakeTask.new(:canefile_quality) 56 | 57 | expect(task).to receive(:abort) 58 | out = capture_stdout do 59 | Rake::Task['canefile_quality'].invoke 60 | end 61 | 62 | expect(out).to include("Quality threshold crossed") 63 | end 64 | end 65 | 66 | after do 67 | Rake::Task.clear 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/cane/rake_task.rb: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rake/tasklib' 3 | 4 | require 'cane/cli/options' 5 | require 'cane/cli/parser' 6 | 7 | module Cane 8 | # Creates a rake task to run cane with given configuration. 9 | # 10 | # Examples 11 | # 12 | # desc "Run code quality checks" 13 | # Cane::RakeTask.new(:quality) do |cane| 14 | # cane.abc_max = 10 15 | # cane.doc_glob = 'lib/**/*.rb' 16 | # cane.no_style = true 17 | # cane.add_threshold 'coverage/covered_percent', :>=, 99 18 | # end 19 | class RakeTask < ::Rake::TaskLib 20 | attr_accessor :name 21 | attr_reader :options 22 | 23 | Cane::CLI.default_options.keys.each do |name| 24 | define_method(name) do 25 | options.fetch(name) 26 | end 27 | 28 | define_method("#{name}=") do |v| 29 | options[name] = v 30 | end 31 | end 32 | 33 | # Add a threshold check. If the file exists and it contains a number, 34 | # compare that number with the given value using the operator. 35 | def add_threshold(file, operator, value) 36 | if operator == :>= 37 | @options[:gte] << [file, value] 38 | end 39 | end 40 | 41 | def use(check, options = {}) 42 | @options.merge!(options) 43 | @options[:checks] = @options[:checks] + [check] 44 | end 45 | 46 | def canefile=(file) 47 | canefile = Cane::CLI::Parser.new 48 | canefile.parser.parse!(canefile.read_options_from_file(file)) 49 | options.merge! canefile.options 50 | end 51 | 52 | def initialize(task_name = nil) 53 | self.name = task_name || :cane 54 | @gte = [] 55 | @options = Cane::CLI.default_options 56 | 57 | if block_given? 58 | yield self 59 | else 60 | self.canefile = './.cane' 61 | end 62 | 63 | unless ::Rake.application.last_description 64 | desc %(Check code quality metrics with cane) 65 | end 66 | 67 | task name do 68 | require 'cane/cli' 69 | abort unless Cane.run(options) 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/cane/violation_formatter.rb: -------------------------------------------------------------------------------- 1 | require 'stringio' 2 | require 'ostruct' 3 | 4 | module Cane 5 | 6 | # Computes a string to be displayed as output from an array of violations 7 | # computed by the checks. 8 | class ViolationFormatter 9 | attr_reader :violations, :options 10 | 11 | def initialize(violations, options = {}) 12 | @violations = violations.map do |v| 13 | v.merge(file_and_line: v[:line] ? 14 | "%s:%i" % v.values_at(:file, :line) : 15 | v[:file] 16 | ) 17 | end 18 | 19 | @options = options 20 | end 21 | 22 | def to_s 23 | return "" if violations.empty? 24 | 25 | string = violations.group_by {|x| x[:description] }.map do |d, vs| 26 | format_group_header(d, vs) + 27 | format_violations(vs) 28 | end.join("\n") + "\n\n" + totals + "\n\n" 29 | 30 | if violations.count > options.fetch(:max_violations, 0) 31 | string = colorize(string) 32 | end 33 | 34 | string 35 | end 36 | 37 | protected 38 | 39 | def format_group_header(description, violations) 40 | ["", "%s (%i):" % [description, violations.length], ""] 41 | end 42 | 43 | def format_violations(violations) 44 | columns = [:file_and_line, :label, :value] 45 | 46 | widths = column_widths(violations, columns) 47 | 48 | violations.map do |v| 49 | format_violation(v, widths) 50 | end 51 | end 52 | 53 | def column_widths(violations, columns) 54 | columns.each_with_object({}) do |column, h| 55 | h[column] = violations.map {|v| v[column].to_s.length }.max 56 | end 57 | end 58 | 59 | def format_violation(v, column_widths) 60 | ' ' + column_widths.keys.map {|column| 61 | v[column].to_s.ljust(column_widths[column]) 62 | }.join(' ').strip 63 | end 64 | 65 | def colorize(string) 66 | return string unless options[:color] 67 | 68 | "\e[31m#{string}\e[0m" 69 | end 70 | 71 | def totals 72 | "Total Violations: #{violations.length}" 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/cane/threshold_check.rb: -------------------------------------------------------------------------------- 1 | require 'cane/file' 2 | 3 | module Cane 4 | 5 | # Configurable check that allows the contents of a file to be compared against 6 | # a given value. 7 | class ThresholdCheck < Struct.new(:opts) 8 | THRESHOLDS = { 9 | lt: :<, 10 | lte: :<=, 11 | eq: :==, 12 | gte: :>=, 13 | gt: :> 14 | } 15 | 16 | def self.key; :threshold; end 17 | def self.options 18 | THRESHOLDS.each_with_object({}) do |(key, value), h| 19 | h[key] = ["Check the number in FILE is #{value} to THRESHOLD " + 20 | "(a number or another file name)", 21 | variable: "FILE,THRESHOLD", 22 | type: Array] 23 | end 24 | end 25 | 26 | def violations 27 | thresholds.map do |operator, file, threshold| 28 | value = normalized_limit(file) 29 | limit = normalized_limit(threshold) 30 | 31 | if !limit.real? 32 | { 33 | description: 'Quality threshold could not be read', 34 | label: "%s is not a number or a file" % [ 35 | threshold 36 | ] 37 | } 38 | elsif !value.send(operator, limit) 39 | { 40 | description: 'Quality threshold crossed', 41 | label: "%s is %s, should be %s %s" % [ 42 | file, value, operator, limit 43 | ] 44 | } 45 | end 46 | end.compact 47 | end 48 | 49 | def normalized_limit(limit) 50 | Float(limit) 51 | rescue ArgumentError 52 | value_from_file(limit) 53 | end 54 | 55 | def value_from_file(file) 56 | begin 57 | contents = Cane::File.contents(file).scan(/\d+\.?\d*/).first.to_f 58 | rescue Errno::ENOENT 59 | UnavailableValue.new 60 | end 61 | end 62 | 63 | def thresholds 64 | THRESHOLDS.map do |k, v| 65 | opts.fetch(k, []).map do |x| 66 | x.unshift(v) 67 | end 68 | end.reduce(:+) 69 | end 70 | 71 | # Null object for all cases when the value to be compared against cannot be 72 | # read. 73 | class UnavailableValue 74 | def >=(_); false end 75 | def to_s; 'unavailable' end 76 | def real?; false; end 77 | end 78 | end 79 | 80 | end 81 | -------------------------------------------------------------------------------- /spec/style_check_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'cane/style_check' 4 | 5 | describe Cane::StyleCheck do 6 | def check(file_name, opts = {}) 7 | described_class.new(opts.merge(style_glob: file_name)) 8 | end 9 | 10 | let(:ruby_with_style_issue) do 11 | [ 12 | "def test ", 13 | "\t1", 14 | "end" 15 | ].join("\n") 16 | end 17 | 18 | it 'creates a StyleViolation for each method above the threshold' do 19 | file_name = make_file(ruby_with_style_issue) 20 | 21 | violations = check(file_name, style_measure: 8).violations 22 | expect(violations.length).to eq(3) 23 | end 24 | 25 | it 'skips declared exclusions' do 26 | file_name = make_file(ruby_with_style_issue) 27 | 28 | violations = check(file_name, 29 | style_measure: 80, 30 | style_exclude: [file_name] 31 | ).violations 32 | 33 | expect(violations.length).to eq(0) 34 | end 35 | 36 | it 'skips declared glob-based exclusions' do 37 | file_name = make_file(ruby_with_style_issue) 38 | 39 | violations = check(file_name, 40 | style_measure: 80, 41 | style_exclude: ["#{File.dirname(file_name)}/*"] 42 | ).violations 43 | 44 | expect(violations.length).to eq(0) 45 | end 46 | 47 | it 'does not include trailing new lines in the character count' do 48 | file_name = make_file('#' * 80 + "\n" + '#' * 80) 49 | 50 | violations = check(file_name, 51 | style_measure: 80, 52 | style_exclude: [file_name] 53 | ).violations 54 | 55 | expect(violations.length).to eq(0) 56 | end 57 | 58 | describe "#file_list" do 59 | context "style_glob is an array" do 60 | it "returns an array of relative file paths" do 61 | glob = [ 62 | 'spec/fixtures/a/**/*.{rb,prawn}', 63 | 'spec/fixtures/b/**/*.haml' 64 | ] 65 | check = described_class.new(style_glob: glob) 66 | expect(check.send(:file_list)).to eq([ 67 | 'spec/fixtures/a/1.rb', 68 | 'spec/fixtures/a/3.prawn', 69 | 'spec/fixtures/b/3/i.haml' 70 | ]) 71 | end 72 | end 73 | 74 | context "style_exclude is an array" do 75 | it "returns an array of relative file paths" do 76 | glob = [ 77 | 'spec/fixtures/a/**/*.{rb,prawn}', 78 | 'spec/fixtures/b/**/*.haml' 79 | ] 80 | exclude = [ 81 | 'spec/fixtures/a/**/*.prawn', 82 | 'spec/fixtures/b/**/*.haml' 83 | ] 84 | check = described_class.new(style_exclude: exclude, style_glob: glob) 85 | expect(check.send(:file_list)).to eq(['spec/fixtures/a/1.rb']) 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /spec/cane_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require "stringio" 3 | require 'cane/cli' 4 | 5 | require 'cane/rake_task' 6 | require 'cane/task_runner' 7 | 8 | # Acceptance tests 9 | describe 'The cane application' do 10 | let(:class_name) { "C#{rand(10 ** 10)}" } 11 | 12 | it 'returns a non-zero exit code and a details of checks that failed' do 13 | fn = make_file(<<-RUBY + " ") 14 | class Harness 15 | def complex_method(a) 16 | if a < 2 17 | return "low" 18 | else 19 | return "high" 20 | end 21 | end 22 | end 23 | RUBY 24 | 25 | check_file = make_file <<-RUBY 26 | class #{class_name} < Struct.new(:opts) 27 | def self.options 28 | { 29 | unhappy_file: ["File to check", default: [nil]] 30 | } 31 | end 32 | 33 | def violations 34 | [ 35 | description: "Files are unhappy", 36 | file: opts.fetch(:unhappy_file), 37 | label: ":(" 38 | ] 39 | end 40 | end 41 | RUBY 42 | 43 | output, exitstatus = run %( 44 | --style-glob #{fn} 45 | --doc-glob #{fn} 46 | --abc-glob #{fn} 47 | --abc-max 1 48 | -r #{check_file} 49 | --check #{class_name} 50 | --unhappy-file #{fn} 51 | ) 52 | expect(output).to include("Lines violated style requirements") 53 | expect(output).to include("Methods exceeded maximum allowed ABC complexity") 54 | expect(output).to include( 55 | "Class and Module definitions require explanatory comments" 56 | ) 57 | expect(exitstatus).to eq(1) 58 | end 59 | 60 | it 'handles invalid unicode input' do 61 | fn = make_file("\xc3\x28") 62 | 63 | _, exitstatus = run("--style-glob #{fn} --abc-glob #{fn} --doc-glob #{fn}") 64 | 65 | expect(exitstatus).to eq(0) 66 | end 67 | 68 | it 'can run tasks in parallel' do 69 | # This spec isn't great, but there is no good way to actually observe that 70 | # tasks run in parallel and we want to verify the conditional is correct. 71 | expect(Cane.task_runner(parallel: true)).to eq(Parallel) 72 | end 73 | 74 | it 'colorizes output' do 75 | output, exitstatus = run("--color --abc-max 0") 76 | 77 | expect(output).to include("\e[31m") 78 | end 79 | 80 | after do 81 | if Object.const_defined?(class_name) 82 | Object.send(:remove_const, class_name) 83 | end 84 | end 85 | 86 | def run(cli_args) 87 | result = nil 88 | output = capture_stdout do 89 | result = Cane::CLI.run( 90 | %w(--no-abc --no-style --no-doc) + cli_args.split(/\s+/m) 91 | ) 92 | end 93 | 94 | [output, result ? 0 : 1] 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/cane/style_check.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | 3 | require 'cane/file' 4 | require 'cane/task_runner' 5 | 6 | module Cane 7 | 8 | # Creates violations for files that do not meet style conventions. Only 9 | # highly obvious, probable, and non-controversial checks are performed here. 10 | # It is not the goal of the tool to provide an extensive style report, but 11 | # only to prevent stupid mistakes. 12 | class StyleCheck < Struct.new(:opts) 13 | 14 | def self.key; :style; end 15 | def self.name; "style checking"; end 16 | def self.options 17 | { 18 | style_glob: ['Glob to run style checks over', 19 | default: '{app,lib,spec}/**/*.rb', 20 | variable: 'GLOB', 21 | clobber: :no_style], 22 | style_measure: ['Max line length', 23 | default: 80, 24 | cast: :to_i, 25 | clobber: :no_style], 26 | style_exclude: ['Exclude file or glob from style checking', 27 | variable: 'GLOB', 28 | type: Array, 29 | default: [], 30 | clobber: :no_style], 31 | no_style: ['Disable style checking', cast: ->(x) { !x }] 32 | } 33 | end 34 | 35 | def violations 36 | return [] if opts[:no_style] 37 | 38 | worker.map(file_list) do |file_path| 39 | map_lines(file_path) do |line, line_number| 40 | violations_for_line(line.chomp).map {|message| { 41 | file: file_path, 42 | line: line_number + 1, 43 | label: message, 44 | description: "Lines violated style requirements" 45 | }} 46 | end 47 | end.flatten 48 | end 49 | 50 | protected 51 | 52 | def violations_for_line(line) 53 | result = [] 54 | if line.length > measure 55 | result << "Line is >%i characters (%i)" % [measure, line.length] 56 | end 57 | result << "Line contains trailing whitespace" if line =~ /\s$/ 58 | result << "Line contains hard tabs" if line =~ /\t/ 59 | result 60 | end 61 | 62 | def file_list 63 | Dir.glob(opts.fetch(:style_glob)).reject {|f| excluded?(f) } 64 | end 65 | 66 | def measure 67 | opts.fetch(:style_measure) 68 | end 69 | 70 | def map_lines(file_path, &block) 71 | Cane::File.iterator(file_path).map.with_index(&block) 72 | end 73 | 74 | def exclusions 75 | @exclusions ||= opts.fetch(:style_exclude, []).flatten.map do |i| 76 | Dir.glob(i) 77 | end.flatten.to_set 78 | end 79 | 80 | def excluded?(file) 81 | exclusions.include?(file) 82 | end 83 | 84 | def worker 85 | Cane.task_runner(opts) 86 | end 87 | end 88 | 89 | end 90 | -------------------------------------------------------------------------------- /spec/threshold_check_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'cane/threshold_check' 4 | 5 | describe Cane::ThresholdCheck do 6 | 7 | let(:simplecov_last_run) do 8 | <<-ENDL 9 | { 10 | "result": { 11 | "covered_percent": 93.88 12 | } 13 | } 14 | ENDL 15 | end 16 | 17 | context "checking violations" do 18 | 19 | def run(threshold, value) 20 | described_class.new(threshold => [['x', value]]) 21 | end 22 | 23 | context "when the current coverage cannot be read" do 24 | it do 25 | expect(run(:gte, 20)).to \ 26 | have_violation('x is unavailable, should be >= 20.0') 27 | end 28 | end 29 | 30 | context "when the coverage threshold is incorrectly specified" do 31 | it do 32 | expect(described_class.new(gte: [['20', 'bogus_file']])).to \ 33 | have_violation('bogus_file is not a number or a file') 34 | end 35 | end 36 | 37 | context 'when coverage threshold is valid' do 38 | before do 39 | file = class_double("Cane::File").as_stubbed_const 40 | stub_const("Cane::File", file) 41 | expect(file).to receive(:contents).with('x').and_return("8\n") 42 | end 43 | 44 | context '>' do 45 | it { expect(run(:gt, 7)).to have_no_violations } 46 | it { 47 | expect(run(:gt, 8)).to have_violation('x is 8.0, should be > 8.0') 48 | } 49 | it { 50 | expect(run(:gt, 9)).to have_violation('x is 8.0, should be > 9.0') 51 | } 52 | end 53 | 54 | context '>=' do 55 | it { expect(run(:gte, 7)).to have_no_violations } 56 | it { expect(run(:gte, 8)).to have_no_violations } 57 | it { 58 | expect(run(:gte, 9)).to have_violation('x is 8.0, should be >= 9.0') 59 | } 60 | end 61 | 62 | context '==' do 63 | it { 64 | expect(run(:eq, 7)).to have_violation('x is 8.0, should be == 7.0') 65 | } 66 | it { expect(run(:eq, 8)).to have_no_violations } 67 | it { 68 | expect(run(:eq, 9)).to have_violation('x is 8.0, should be == 9.0') 69 | } 70 | end 71 | 72 | context '<=' do 73 | it { 74 | expect(run(:lte, 7)).to have_violation('x is 8.0, should be <= 7.0') 75 | } 76 | it { expect(run(:lte, 8)).to have_no_violations } 77 | it { expect(run(:lte, 9)).to have_no_violations } 78 | end 79 | 80 | context '<' do 81 | it { 82 | expect(run(:lt, 7)).to have_violation('x is 8.0, should be < 7.0') 83 | } 84 | it { 85 | expect(run(:lt, 8)).to have_violation('x is 8.0, should be < 8.0') 86 | } 87 | it { expect(run(:lt, 9)).to have_no_violations } 88 | end 89 | end 90 | 91 | end 92 | 93 | context "normalizing a user supplied value to a threshold" do 94 | it "normalizes an integer to itself" do 95 | expect(subject.normalized_limit(99)).to eq(99) 96 | end 97 | 98 | it "normalizes a float to itself" do 99 | expect(subject.normalized_limit(99.6)).to eq(99.6) 100 | end 101 | 102 | it "normalizes a valid file to its contents" do 103 | expect(subject.normalized_limit(make_file('99.5'))).to eq(99.5) 104 | end 105 | 106 | it "normalizes an invalid file to an unavailable value" do 107 | limit = subject.normalized_limit("/File.does.not.exist") 108 | expect(limit).to be_a Cane::ThresholdCheck::UnavailableValue 109 | end 110 | 111 | 112 | it 'normalizes a json file to a float' do 113 | expect(subject.normalized_limit(make_file(simplecov_last_run))).to eq( 114 | 93.88 115 | ) 116 | end 117 | 118 | end 119 | 120 | end 121 | -------------------------------------------------------------------------------- /spec/parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require "stringio" 3 | require 'cane/cli/parser' 4 | 5 | describe Cane::CLI::Parser do 6 | def run(cli_args) 7 | result = nil 8 | output = StringIO.new("") 9 | result = Cane::CLI::Parser.new(output).parse(cli_args.split(/\s+/m)) 10 | 11 | [output.string, result] 12 | end 13 | 14 | it 'allows style options to be configured' do 15 | output, result = run("--style-glob myfile --style-measure 3") 16 | expect(result[:style_glob]).to eq('myfile') 17 | expect(result[:style_measure]).to eq(3) 18 | end 19 | 20 | it 'allows checking gte of a value in a file' do 21 | output, result = run("--gte myfile,90") 22 | expect(result[:gte]).to eq([['myfile', '90']]) 23 | end 24 | 25 | it 'allows checking eq of a value in a file' do 26 | output, result = run("--eq myfile,90") 27 | expect(result[:eq]).to eq([['myfile', '90']]) 28 | end 29 | 30 | it 'allows checking lte of a value in a file' do 31 | output, result = run("--lte myfile,90") 32 | expect(result[:lte]).to eq([['myfile', '90']]) 33 | end 34 | 35 | it 'allows checking lt of a value in a file' do 36 | output, result = run("--lt myfile,90") 37 | expect(result[:lt]).to eq([['myfile', '90']]) 38 | end 39 | 40 | it 'allows checking gt of a value in a file' do 41 | output, resugt = run("--gt myfile,90") 42 | expect(resugt[:gt]).to eq([['myfile', '90']]) 43 | end 44 | 45 | it 'allows upper bound of failed checks' do 46 | output, result = run("--max-violations 1") 47 | expect(result[:max_violations]).to eq(1) 48 | end 49 | 50 | it 'uses positional arguments as shortcut for individual files' do 51 | output, result = run("--all mysinglefile") 52 | expect(result[:abc_glob]).to eq('mysinglefile') 53 | expect(result[:style_glob]).to eq('mysinglefile') 54 | expect(result[:doc_glob]).to eq('mysinglefile') 55 | 56 | output, result = run("--all mysinglefile --abc-glob myotherfile") 57 | expect(result[:abc_glob]).to eq('myotherfile') 58 | expect(result[:style_glob]).to eq('mysinglefile') 59 | expect(result[:doc_glob]).to eq('mysinglefile') 60 | end 61 | 62 | it 'displays a help message' do 63 | output, result = run("--help") 64 | 65 | expect(result).to be 66 | expect(output).to include("Usage:") 67 | end 68 | 69 | it 'handles invalid options by showing help' do 70 | output, result = run("--bogus") 71 | 72 | expect(output).to include("Usage:") 73 | expect(result).not_to be 74 | end 75 | 76 | it 'displays version' do 77 | output, result = run("--version") 78 | 79 | expect(result).to be 80 | expect(output).to include(Cane::VERSION) 81 | end 82 | 83 | it 'supports exclusions' do 84 | options = [ 85 | "--abc-exclude", "Harness#complex_method", 86 | "--doc-exclude", 'myfile', 87 | "--style-exclude", 'myfile' 88 | ].join(' ') 89 | 90 | _, result = run(options) 91 | expect(result[:abc_exclude]).to eq([['Harness#complex_method']]) 92 | expect(result[:doc_exclude]).to eq([['myfile']]) 93 | expect(result[:style_exclude]).to eq([['myfile']]) 94 | end 95 | 96 | describe 'argument ordering' do 97 | it 'gives precedence to the last argument #1' do 98 | _, result = run("--doc-glob myfile --no-doc") 99 | expect(result[:no_doc]).to be 100 | 101 | _, result = run("--no-doc --doc-glob myfile") 102 | expect(result[:no_doc]).not_to be 103 | end 104 | end 105 | 106 | it 'loads default options from .cane file' do 107 | defaults = <<-EOS 108 | --no-doc 109 | --abc-glob myfile 110 | --style-glob myfile 111 | EOS 112 | file = class_double("Cane::File").as_stubbed_const 113 | stub_const("Cane::File", file) 114 | expect(file).to receive(:exists?).with('./.cane').and_return(true) 115 | expect(file).to receive(:contents).with('./.cane').and_return(defaults) 116 | 117 | _, result = run("--style-glob myotherfile") 118 | 119 | expect(result[:no_doc]).to be 120 | expect(result[:abc_glob]).to eq('myfile') 121 | expect(result[:style_glob]).to eq('myotherfile') 122 | end 123 | 124 | it 'allows parallel option' do 125 | _, result = run("--parallel") 126 | expect(result[:parallel]).to be 127 | end 128 | 129 | it 'handles ambiguous options' do 130 | output, result = run("-abc-max") 131 | expect(output).to include("Usage:") 132 | expect(result).not_to be 133 | end 134 | 135 | it 'handles no_readme option' do 136 | _, result = run("--no-readme") 137 | expect(result[:no_readme]).to be 138 | end 139 | 140 | it 'handles json option' do 141 | _, result = run("--json") 142 | expect(result[:json]).to be 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /spec/doc_check_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'cane/doc_check' 4 | 5 | describe Cane::DocCheck do 6 | def check(file_name, opts = {}) 7 | described_class.new(opts.merge(doc_glob: file_name)) 8 | end 9 | 10 | it 'creates a DocViolation for each undocumented class with a method' do 11 | file_name = make_file <<-RUBY 12 | class Doc; end 13 | class Empty; end # No doc is fine 14 | class NoDoc; def with_method; end; end 15 | classIgnore = nil 16 | [:class] 17 | # class Ignore 18 | class Meta 19 | class << self; end 20 | end 21 | module DontNeedDoc; end 22 | # This module is documented 23 | module HasDoc 24 | def mixin; end 25 | end 26 | module AlsoNeedsDoc; def mixin; end; end 27 | module NoDocIsFine 28 | module ButThisNeedsDoc 29 | def self.global 30 | end 31 | end 32 | module AlsoNoDocIsFine; end 33 | # We've got docs 34 | module CauseWeNeedThem 35 | def mixin 36 | end 37 | end 38 | end 39 | module NoViolationCozComment 40 | # def method should ignore this comment 41 | # end 42 | end 43 | RUBY 44 | 45 | violations = check(file_name).violations 46 | expect(violations.length).to eq(3) 47 | 48 | expect(violations[0].values_at(:file, :line, :label)).to eq([ 49 | file_name, 3, "NoDoc" 50 | ]) 51 | 52 | expect(violations[1].values_at(:file, :line, :label)).to eq([ 53 | file_name, 15, "AlsoNeedsDoc" 54 | ]) 55 | 56 | expect(violations[2].values_at(:file, :line, :label)).to eq([ 57 | file_name, 17, "ButThisNeedsDoc" 58 | ]) 59 | end 60 | 61 | it 'does not create violations for single line classes without methods' do 62 | file_name = make_file <<-RUBY 63 | class NeedsDoc 64 | class AlsoNeedsDoc < StandardError; def foo; end; end 65 | class NoDocIsOk < StandardError; end 66 | class NoDocIsAlsoOk < StandardError; end # No doc is fine on this too 67 | 68 | def my_method 69 | end 70 | end 71 | RUBY 72 | 73 | violations = check(file_name).violations 74 | expect(violations.length).to eq(2) 75 | 76 | expect(violations[0].values_at(:file, :line, :label)).to eq([ 77 | file_name, 1, "NeedsDoc" 78 | ]) 79 | 80 | expect(violations[1].values_at(:file, :line, :label)).to eq([ 81 | file_name, 2, "AlsoNeedsDoc" 82 | ]) 83 | end 84 | 85 | it 'ignores magic encoding comments' do 86 | file_name = make_file <<-RUBY 87 | # coding = utf-8 88 | class NoDoc; def do_stuff; end; end 89 | # -*- encoding : utf-8 -*- 90 | class AlsoNoDoc; def do_more_stuff; end; end 91 | # Parse a Transfer-Encoding: Chunked response 92 | class Doc; end 93 | RUBY 94 | 95 | violations = check(file_name).violations 96 | expect(violations.length).to eq(2) 97 | 98 | expect(violations[0].values_at(:file, :line, :label)).to eq([ 99 | file_name, 2, "NoDoc" 100 | ]) 101 | expect(violations[1].values_at(:file, :line, :label)).to eq([ 102 | file_name, 4, "AlsoNoDoc" 103 | ]) 104 | end 105 | 106 | it 'creates a violation for missing README' do 107 | file = class_double("Cane::File").as_stubbed_const 108 | stub_const("Cane::File", file) 109 | expect(file).to receive(:case_insensitive_glob).with( 110 | "README*" 111 | ).and_return([]) 112 | 113 | violations = check("").violations 114 | expect(violations.length).to eq(1) 115 | 116 | expect(violations[0].values_at(:description, :label)).to eq([ 117 | "Missing documentation", "No README found" 118 | ]) 119 | end 120 | 121 | it 'does not create a violation when readme exists' do 122 | file = class_double("Cane::File").as_stubbed_const 123 | stub_const("Cane::File", file) 124 | expect(file) 125 | .to receive(:case_insensitive_glob) 126 | .with("README*") 127 | .and_return(%w(readme.md)) 128 | 129 | violations = check("").violations 130 | expect(violations.length).to eq(0) 131 | end 132 | 133 | it 'skips declared exclusions' do 134 | file_name = make_file <<-FILE.gsub /^\s{6}/, '' 135 | class NeedsDocumentation 136 | end 137 | FILE 138 | 139 | violations = check(file_name, 140 | doc_exclude: [file_name] 141 | ).violations 142 | 143 | expect(violations.length).to eq(0) 144 | end 145 | 146 | it 'skips declared glob-based exclusions' do 147 | file_name = make_file <<-FILE.gsub /^\s{6}/, '' 148 | class NeedsDocumentation 149 | end 150 | FILE 151 | 152 | violations = check(file_name, 153 | doc_exclude: ["#{File.dirname(file_name)}/*"] 154 | ).violations 155 | 156 | expect(violations.length).to eq(0) 157 | end 158 | 159 | it 'skips class inside an array' do 160 | file_name = make_file <<-RUBY 161 | %w( 162 | class 163 | method 164 | ) 165 | RUBY 166 | 167 | violations = check(file_name).violations 168 | expect(violations.length).to eq(0) 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # Cane History 2 | 3 | ## 3.0.0 - 10 March 2016 (f602b9) 4 | 5 | * Compat: Rake 11 supported 6 | * Compat: Ruby 1.9 no longer supported. 7 | * Feature: Glob options now take arrays. 8 | 9 | ## 2.6.2 - 20 April 2014 (8f54b4) 10 | 11 | * Bugfix: Commented methods no longer trigger a documentation violation for 12 | empty modules. 13 | * Feature: Ruby 2.1 supported. 14 | 15 | ## 2.6.1 - 30 October 2013 (2ea008) 16 | 17 | * Feature: Don't require doc for one-line class w/out method. 18 | * Bugfix: JsonFormatter initializer needs to take an options hash. 19 | * Doc: Add license definition to gemspec. 20 | 21 | ## 2.6.0 - 7 June 2013 (616bb8a5) 22 | 23 | * Feature: classes with no methods do not require documentation. 24 | * Feature: modules with methods require documentation. 25 | * Feature: support all README extensions. 26 | * Feature: --color option. 27 | * Bugfix: fix false positive on class matching for doc check. 28 | * Bugfix: better handling of invalid strings. 29 | * Compat: fix Ruby 2.0 deprecations. 30 | 31 | ## 2.5.2 - 26 January 2013 (a0cf38ba) 32 | 33 | * Feature: support operators beside `>=` in threshold check. 34 | 35 | ## 2.5.1 - 26 January 2013 (93819f19) 36 | 37 | * Feature: documentation check supports `.mdown` and `.rdoc` extensions. 38 | * Feature: expanded threshold regex to support `coverage/.last_run.json` from 39 | `SimpleCov`. 40 | * Compat: Ruby 2.0 compatibility. 41 | 42 | ## 2.5.0 - 17 November 2012 (628cc1e9) 43 | 44 | * Feature: `--doc-exclude` option to exclude globs from documentation checks. 45 | * Feature: `--style-exclude` supports globbing. 46 | 47 | ## 2.4.0 - 21 October 2012 (46949e77) 48 | 49 | * Feature: Rake task can load configuration from a `.cane` file. 50 | * Feature: Coverage threshold can be specifed in a file. 51 | * Feature: Provide `--all` option for working with single files. 52 | * Bugfix: Allow README file to be lowercase. 53 | 54 | ## 2.3.0 - 16 September 2012 (229252ff) 55 | 56 | * Feature: `--json` option for machine-readable output. 57 | * Feature: absence of a README will cause a failure. 58 | * Bugfix: `--no-style` option actually works now. 59 | 60 | ## 2.2.3 - 3 September 2012 (e4fe90ee) 61 | 62 | * Bugfix: Allow multiple spaces before class name. (#34) 63 | * Bugfix: Remove wacky broken conditional in AbcCheck. (#33) 64 | * Doc: Better guidance on class level comments. (#35) 65 | 66 | ## 2.2.2 - 29 August 2012 (3a9be454) 67 | 68 | * Bugfix: Stricter magic comment regex to avoid false positives (#31) 69 | 70 | ## 2.2.1 - 26 August 2012 (b5e5a362) 71 | 72 | * Bugfix: parallel option can be set in rake tasks 73 | 74 | ## 2.2.0 - 26 August 2012 (f4198619) 75 | 76 | * Gracefully handle ambiguous options like `-abc-max` (#27) 77 | * Provide the `--parallel` option to use all processors. This will be faster on 78 | larger projects, but slower on smaller ones (#28) 79 | 80 | ## 2.1.0 - 26 August 2012 (2962d8fb) 81 | 82 | * Support for user-defined checks (#30) 83 | 84 | ## 2.0.0 - 19 August 2012 (35cae086) 85 | 86 | * ABC check labels `MyClass = Struct.new {}` and `Class.new` correctly (#20) 87 | * Magic comments (`# encoding: utf-8`) are not recognized as appropriate class documentation (#21) 88 | * Invalid UTF-8 is handled correctly (#22) 89 | * Gracefully handle unknown options 90 | * ABC check output uses a standard format (`Foo::Bar#method` rather than `Foo > Bar > method`) 91 | * **BREAKING** Add `--abc-exclude`, `--style-exclude` CLI flags, remove YAML support 92 | * **BREAKING-INTERNAL** Use hashes rather than explicit violation classes 93 | * **BREAKING-INTERNAL** Remove translator class, pass CLI args direct to checks 94 | * **INTERNAL** Wiring in a new check only requires changing one file (#15) 95 | 96 | This snippet will convert your YAML exclusions file to the new CLI syntax: 97 | 98 | y = YAML.load(File.read('exclusions.yml')) 99 | puts ( 100 | y.fetch('abc', []).map {|x| %|--abc-exclude "#{x}"| } + 101 | y.fetch('style', []).map {|x| %|--style-exclude "#{x}"| } 102 | ).join("\n") 103 | 104 | ## 1.4.0 - 2 July 2012 (1afc999d) 105 | 106 | * Allow files and methods to be whitelisted (#16) 107 | * Show total number of violations in output (#14) 108 | 109 | ## 1.3.0 - 20 April 2012 (c166dfa0) 110 | 111 | * Remove dependency on tailor. Fewer styles checks are performed, but the three 112 | remaining are the only ones I've found useful. 113 | 114 | ## 1.2.0 - 31 March 2012 (adce51b9) 115 | 116 | * Gracefully handle files with invalid syntax (#1) 117 | * Included class methods in ABC check (#8) 118 | * Can disable style and doc checks from rake task (#9) 119 | 120 | ## 1.1.0 - 24 March 2012 (ba8a74fc) 121 | 122 | * `app` added to default globs 123 | * Added `cane/rake_task` 124 | * `class << obj` syntax ignore by documentation check 125 | * Line length checks no longer include trailing new lines 126 | * Add support for a `.cane` file for setting per-project default options. 127 | 128 | ## 1.0.0 - 14 January 2012 (4e400534) 129 | 130 | * Initial release. 131 | -------------------------------------------------------------------------------- /lib/cane/doc_check.rb: -------------------------------------------------------------------------------- 1 | require 'cane/file' 2 | require 'cane/task_runner' 3 | 4 | module Cane 5 | 6 | # Creates violations for class definitions that do not have an explanatory 7 | # comment immediately preceding. 8 | class DocCheck < Struct.new(:opts) 9 | 10 | DESCRIPTION = 11 | "Class and Module definitions require explanatory comments on previous line" 12 | 13 | ClassDefinition = Struct.new(:values) do 14 | def line; values.fetch(:line); end 15 | def label; values.fetch(:label); end 16 | def missing_doc?; !values.fetch(:has_doc); end 17 | def requires_doc?; values.fetch(:requires_doc, false); end 18 | def requires_doc=(value); values[:requires_doc] = value; end 19 | end 20 | 21 | def self.key; :doc; end 22 | def self.name; "documentation checking"; end 23 | def self.options 24 | { 25 | doc_glob: ['Glob to run doc checks over', 26 | default: '{app,lib}/**/*.rb', 27 | variable: 'GLOB', 28 | clobber: :no_doc], 29 | doc_exclude: ['Exclude file or glob from documentation checking', 30 | variable: 'GLOB', 31 | type: Array, 32 | default: [], 33 | clobber: :no_doc], 34 | no_readme: ['Disable readme checking', cast: ->(x) { !x }], 35 | no_doc: ['Disable documentation checking', cast: ->(x) { !x }] 36 | } 37 | end 38 | 39 | # Stolen from ERB source, amended to be slightly stricter to work around 40 | # some known false positives. 41 | MAGIC_COMMENT_REGEX = 42 | %r"#(\s+-\*-)?\s+(en)?coding\s*[=:]\s*([[:alnum:]\-_]+)" 43 | 44 | CLASS_REGEX = /^\s*(?:class|module)\s+([^\s;]+)/ 45 | 46 | # http://rubular.com/r/53BapkefdD 47 | SINGLE_LINE_CLASS_REGEX = 48 | /^\s*(?:class|module).*;\s*end\s*(#.*)?\s*$/ 49 | 50 | METHOD_REGEX = /(?:^|\s)def\s+/ 51 | 52 | def violations 53 | return [] if opts[:no_doc] 54 | 55 | missing_file_violations + worker.map(file_names) {|file_name| 56 | find_violations(file_name) 57 | }.flatten 58 | end 59 | 60 | def find_violations(file_name) 61 | class_definitions_in(file_name).map do |class_definition| 62 | if class_definition.requires_doc? && class_definition.missing_doc? 63 | { 64 | file: file_name, 65 | line: class_definition.line, 66 | label: class_definition.label, 67 | description: DESCRIPTION 68 | } 69 | end 70 | end.compact 71 | end 72 | 73 | def class_definitions_in(file_name) 74 | closed_classes = [] 75 | open_classes = [] 76 | last_line = "" 77 | 78 | Cane::File.iterator(file_name).each_with_index do |line, number| 79 | if class_definition? line 80 | if single_line_class_definition? line 81 | closed_classes 82 | else 83 | open_classes 84 | end.push class_definition(number, line, last_line) 85 | 86 | elsif method_definition?(line) && !open_classes.empty? 87 | open_classes.last.requires_doc = true 88 | end 89 | 90 | last_line = line 91 | end 92 | 93 | (closed_classes + open_classes).sort_by(&:line) 94 | end 95 | 96 | def class_definition(number, line, last_line) 97 | ClassDefinition.new({ 98 | line: (number + 1), 99 | label: extract_class_name(line), 100 | has_doc: comment?(last_line), 101 | requires_doc: method_definition?(line) 102 | }) 103 | end 104 | 105 | def missing_file_violations 106 | result = [] 107 | return result if opts[:no_readme] 108 | 109 | if Cane::File.case_insensitive_glob("README*").none? 110 | result << { description: 'Missing documentation', 111 | label: 'No README found' } 112 | end 113 | result 114 | end 115 | 116 | def file_names 117 | Dir.glob(opts.fetch(:doc_glob)).reject { |file| excluded?(file) } 118 | end 119 | 120 | def method_definition?(line) 121 | !comment?(line) && line =~ METHOD_REGEX 122 | end 123 | 124 | def class_definition?(line) 125 | line =~ CLASS_REGEX && $1.index('<<') != 0 126 | end 127 | 128 | def single_line_class_definition?(line) 129 | line =~ SINGLE_LINE_CLASS_REGEX 130 | end 131 | 132 | def comment?(line) 133 | line =~ /^\s*#/ && !(MAGIC_COMMENT_REGEX =~ line) 134 | end 135 | 136 | def extract_class_name(line) 137 | line.match(CLASS_REGEX)[1] 138 | end 139 | 140 | def exclusions 141 | @exclusions ||= opts.fetch(:doc_exclude, []).flatten.map do |i| 142 | Dir.glob(i) 143 | end.flatten.to_set 144 | end 145 | 146 | def excluded?(file) 147 | exclusions.include?(file) 148 | end 149 | 150 | def worker 151 | Cane.task_runner(opts) 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /spec/abc_check_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'cane/abc_check' 4 | 5 | describe Cane::AbcCheck do 6 | def check(file_name, opts = {}) 7 | described_class.new(opts.merge(abc_glob: file_name)) 8 | end 9 | 10 | it 'does not create violations when no_abc flag is set' do 11 | file_name = make_file(<<-RUBY) 12 | class Harness 13 | def complex_method(a) 14 | b = a 15 | return b if b > 3 16 | end 17 | end 18 | RUBY 19 | 20 | violations = check(file_name, abc_max: 1, no_abc: true).violations 21 | expect(violations).to be_empty 22 | end 23 | 24 | it 'creates an AbcMaxViolation for each method above the threshold' do 25 | file_name = make_file(<<-RUBY) 26 | class Harness 27 | def not_complex 28 | true 29 | end 30 | 31 | def complex_method(a) 32 | b = a 33 | return b if b > 3 34 | end 35 | end 36 | RUBY 37 | 38 | violations = check(file_name, abc_max: 1, no_abc: false).violations 39 | expect(violations.length).to eq(1) 40 | expect(violations[0].values_at(:file, :label, :value)).to eq( 41 | [file_name, "Harness#complex_method", 2] 42 | ) 43 | end 44 | 45 | it 'sorts violations by complexity' do 46 | file_name = make_file(<<-RUBY) 47 | class Harness 48 | def not_complex 49 | true 50 | end 51 | 52 | def complex_method(a) 53 | b = a 54 | return b if b > 3 55 | end 56 | end 57 | RUBY 58 | 59 | violations = check(file_name, abc_max: 0).violations 60 | expect(violations.length).to eq(2) 61 | complexities = violations.map {|x| x[:value] } 62 | expect(complexities).to eq(complexities.sort.reverse) 63 | end 64 | 65 | it 'creates a violation when code cannot be parsed' do 66 | file_name = make_file(<<-RUBY) 67 | class Harness 68 | RUBY 69 | 70 | violations = check(file_name).violations 71 | expect(violations.length).to eq(1) 72 | expect(violations[0][:file]).to eq(file_name) 73 | expect(violations[0][:description]).to be_instance_of(String) 74 | end 75 | 76 | it 'skips declared exclusions' do 77 | file_name = make_file(<<-RUBY) 78 | class Harness 79 | def instance_meth 80 | true 81 | end 82 | 83 | def self.class_meth 84 | true 85 | end 86 | 87 | module Nested 88 | def i_meth 89 | true 90 | end 91 | 92 | def self.c_meth 93 | true 94 | end 95 | 96 | def other_meth 97 | true 98 | end 99 | end 100 | end 101 | RUBY 102 | 103 | exclusions = %w[ Harness#instance_meth Harness.class_meth 104 | Harness::Nested#i_meth Harness::Nested.c_meth ] 105 | violations = check(file_name, 106 | abc_max: 0, 107 | abc_exclude: exclusions 108 | ).violations 109 | expect(violations.length).to eq(1) 110 | expect(violations[0].values_at(:file, :label, :value)).to eq( 111 | [file_name, "Harness::Nested#other_meth", 1] 112 | ) 113 | end 114 | 115 | it "creates an AbcMaxViolation for method in assigned anonymous class" do 116 | file_name = make_file(<<-RUBY) 117 | MyClass = Struct.new(:foo) do 118 | def test_method(a) 119 | b = a 120 | return b if b > 3 121 | end 122 | end 123 | RUBY 124 | 125 | violations = check(file_name, abc_max: 1).violations 126 | violations[0][:label] == "MyClass#test_method" 127 | end 128 | 129 | it "creates an AbcMaxViolation for method in anonymous class" do 130 | file_name = make_file(<<-RUBY) 131 | Class.new do 132 | def test_method(a) 133 | b = a 134 | return b if b > 3 135 | end 136 | end 137 | RUBY 138 | 139 | violations = check(file_name, abc_max: 1).violations 140 | expect(violations[0][:label]).to eq("(anon)#test_method") 141 | end 142 | 143 | def self.it_should_extract_method_name(name, label=name, sep='#') 144 | it "creates an AbcMaxViolation for #{name}" do 145 | file_name = make_file(<<-RUBY) 146 | class Harness 147 | def #{name}(a) 148 | b = a 149 | return b if b > 3 150 | end 151 | end 152 | RUBY 153 | 154 | violations = check(file_name, abc_max: 1).violations 155 | expect(violations[0][:label]).to eq("Harness#{sep}#{label}") 156 | end 157 | end 158 | 159 | # These method names all create different ASTs. Which is weird. 160 | it_should_extract_method_name 'a' 161 | it_should_extract_method_name 'self.a', 'a', '.' 162 | it_should_extract_method_name 'next' 163 | it_should_extract_method_name 'GET' 164 | it_should_extract_method_name '`' 165 | it_should_extract_method_name '>=' 166 | 167 | describe "#file_names" do 168 | context "abc_glob is an array" do 169 | it "returns an array of relative file paths" do 170 | glob = [ 171 | 'spec/fixtures/a/**/*.{rb,prawn}', 172 | 'spec/fixtures/b/**/*.rb' 173 | ] 174 | check = described_class.new(abc_glob: glob) 175 | expect(check.send(:file_names)).to eq([ 176 | 'spec/fixtures/a/1.rb', 177 | 'spec/fixtures/a/3.prawn', 178 | 'spec/fixtures/b/1.rb' 179 | ]) 180 | end 181 | end 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /lib/cane/cli/parser.rb: -------------------------------------------------------------------------------- 1 | require 'optparse' 2 | 3 | require 'cane/default_checks' 4 | require 'cane/cli/options' 5 | require 'cane/version' 6 | 7 | module Cane 8 | module CLI 9 | 10 | # Provides a specification for the command line interface that drives 11 | # documentation, parsing, and default values. 12 | class Parser 13 | 14 | # Exception to indicate that no further processing is required and the 15 | # program can exit. This is used to handle --help and --version flags. 16 | class OptionsHandled < RuntimeError; end 17 | 18 | def self.parse(*args) 19 | new.parse(*args) 20 | end 21 | 22 | def initialize(stdout = $stdout) 23 | @stdout = stdout 24 | 25 | add_banner 26 | add_user_defined_checks 27 | 28 | Cane.default_checks.each do |check| 29 | add_check_options(check) 30 | end 31 | add_checks_shortcut 32 | 33 | add_cane_options 34 | 35 | add_version 36 | add_help 37 | end 38 | 39 | def parse(args, ret = true) 40 | parser.parse!(get_default_options + args) 41 | 42 | Cane::CLI.default_options.merge(options) 43 | rescue OptionParser::InvalidOption, OptionParser::AmbiguousOption 44 | args = %w(--help) 45 | ret = false 46 | retry 47 | rescue OptionsHandled 48 | ret 49 | end 50 | 51 | def get_default_options 52 | read_options_from_file './.cane' 53 | end 54 | 55 | def read_options_from_file(file) 56 | if Cane::File.exists?(file) 57 | Cane::File.contents(file).split(/\s+/m) 58 | else 59 | [] 60 | end 61 | end 62 | 63 | def add_banner 64 | parser.banner = <<-BANNER 65 | Usage: cane [options] 66 | 67 | Default options are loaded from a .cane file in the current directory. 68 | 69 | BANNER 70 | end 71 | 72 | def add_user_defined_checks 73 | description = "Load a Ruby file containing user-defined checks" 74 | parser.on("-r", "--require FILE", description) do |f| 75 | load(f) 76 | end 77 | 78 | description = "Use the given user-defined check" 79 | parser.on("-c", "--check CLASS", description) do |c| 80 | check = Kernel.const_get(c) 81 | options[:checks] << check 82 | add_check_options(check) 83 | end 84 | parser.separator "" 85 | end 86 | 87 | def add_check_options(check) 88 | check.options.each do |key, data| 89 | cli_key = key.to_s.tr('_', '-') 90 | opts = data[1] || {} 91 | variable = opts[:variable] || "VALUE" 92 | defaults = opts[:default] || [] 93 | 94 | if opts[:type] == Array 95 | parser.on("--#{cli_key} #{variable}", Array, data[0]) do |opts| 96 | (options[key.to_sym] ||= []) << opts 97 | end 98 | else 99 | if [*defaults].length > 0 100 | add_option ["--#{cli_key}", variable], *data 101 | else 102 | add_option ["--#{cli_key}"], *data 103 | end 104 | end 105 | end 106 | 107 | parser.separator "" 108 | end 109 | 110 | def add_cane_options 111 | add_option %w(--max-violations VALUE), 112 | "Max allowed violations", default: 0, cast: :to_i 113 | 114 | add_option %w(--json), 115 | "Output as JSON", default: false 116 | 117 | add_option %w(--parallel), 118 | "Use all processors. Slower on small projects, faster on large.", 119 | cast: ->(x) { x } 120 | 121 | add_option %w(--color), 122 | "Colorize output", default: false 123 | 124 | parser.separator "" 125 | end 126 | 127 | def add_checks_shortcut 128 | description = "Apply all checks to given file" 129 | parser.on("-f", "--all FILE", description) do |f| 130 | # This is a bit of a hack, but provides a really useful UI for 131 | # dealing with single files. Let's see how it evolves. 132 | options[:abc_glob] = f 133 | options[:style_glob] = f 134 | options[:doc_glob] = f 135 | end 136 | end 137 | 138 | def add_version 139 | parser.on_tail("-v", "--version", "Show version") do 140 | stdout.puts Cane::VERSION 141 | raise OptionsHandled 142 | end 143 | end 144 | 145 | def add_help 146 | parser.on_tail("-h", "--help", "Show this message") do 147 | stdout.puts parser 148 | raise OptionsHandled 149 | end 150 | end 151 | 152 | def add_option(option, description, opts={}) 153 | option_key = option[0].gsub('--', '').tr('-', '_').to_sym 154 | default = opts[:default] 155 | cast = opts[:cast] || ->(x) { x } 156 | 157 | if default 158 | description += " (default: %s)" % default 159 | end 160 | 161 | parser.on(option.join(' '), description) do |v| 162 | options[option_key] = cast.to_proc.call(v) 163 | options.delete(opts[:clobber]) 164 | end 165 | end 166 | 167 | def options 168 | @options ||= { 169 | checks: Cane.default_checks 170 | } 171 | end 172 | 173 | def parser 174 | @parser ||= OptionParser.new 175 | end 176 | 177 | attr_reader :stdout 178 | end 179 | 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /lib/cane/abc_check.rb: -------------------------------------------------------------------------------- 1 | require 'ripper' 2 | require 'set' 3 | 4 | require 'cane/file' 5 | require 'cane/task_runner' 6 | 7 | module Cane 8 | 9 | # Creates violations for methods that are too complicated using a simple 10 | # algorithm run against the parse tree of a file to count assignments, 11 | # branches, and conditionals. Borrows heavily from metric_abc. 12 | class AbcCheck < Struct.new(:opts) 13 | 14 | def self.key; :abc; end 15 | def self.name; "ABC check"; end 16 | def self.options 17 | { 18 | abc_glob: ['Glob to run ABC metrics over', 19 | default: '{app,lib}/**/*.rb', 20 | variable: 'GLOB', 21 | clobber: :no_abc], 22 | abc_max: ['Ignore methods under this complexity', 23 | default: 15, 24 | cast: :to_i, 25 | clobber: :no_abc], 26 | abc_exclude: ['Exclude method from analysis (eg. Foo::Bar#method)', 27 | variable: 'METHOD', 28 | type: Array, 29 | default: [], 30 | clobber: :no_abc], 31 | no_abc: ['Disable ABC checking', 32 | cast: ->(x) { !x }] 33 | } 34 | end 35 | 36 | def violations 37 | return [] if opts[:no_abc] 38 | 39 | order worker.map(file_names) {|file_name| 40 | find_violations(file_name) 41 | }.flatten 42 | end 43 | 44 | protected 45 | 46 | def find_violations(file_name) 47 | ast = Ripper::SexpBuilder.new(Cane::File.contents(file_name)).parse 48 | case ast 49 | when nil 50 | InvalidAst.new(file_name) 51 | else 52 | RubyAst.new(file_name, max_allowed_complexity, ast, exclusions) 53 | end.violations 54 | end 55 | 56 | # Null object for when the file cannot be parsed. 57 | class InvalidAst < Struct.new(:file_name) 58 | def violations 59 | [{file: file_name, description: "Files contained invalid syntax"}] 60 | end 61 | end 62 | 63 | # Wrapper object around sexps returned from ripper. 64 | class RubyAst < Struct.new(:file_name, :max_allowed_complexity, 65 | :sexps, :exclusions) 66 | 67 | def initialize(*args) 68 | super 69 | self.anon_method_add = true 70 | end 71 | 72 | def violations 73 | process_ast(sexps). 74 | select {|nesting, complexity| complexity > max_allowed_complexity }. 75 | map {|x| { 76 | file: file_name, 77 | label: x.first, 78 | value: x.last, 79 | description: "Methods exceeded maximum allowed ABC complexity" 80 | }} 81 | end 82 | 83 | protected 84 | 85 | # Stateful flag used to determine whether we are currently parsing an 86 | # anonymous class. See #container_label. 87 | attr_accessor :anon_method_add 88 | 89 | # Recursive function to process an AST. The `complexity` variable mutates, 90 | # which is a bit confusing. `nesting` does not. 91 | def process_ast(node, complexity = {}, nesting = []) 92 | if method_nodes.include?(node[0]) 93 | nesting = nesting + [label_for(node)] 94 | desc = method_description(node, *nesting) 95 | unless excluded?(desc) 96 | complexity[desc] = calculate_abc(node) 97 | end 98 | elsif parent = container_label(node) 99 | nesting = nesting + [parent] 100 | end 101 | 102 | if node.is_a? Array 103 | node[1..-1].each {|n| process_ast(n, complexity, nesting) if n } 104 | end 105 | complexity 106 | end 107 | 108 | def calculate_abc(method_node) 109 | a = count_nodes(method_node, assignment_nodes) 110 | b = count_nodes(method_node, branch_nodes) + 1 111 | c = count_nodes(method_node, condition_nodes) 112 | abc = Math.sqrt(a**2 + b**2 + c**2).round 113 | abc 114 | end 115 | 116 | def container_label(node) 117 | if container_nodes.include?(node[0]) 118 | # def foo, def self.foo 119 | node[1][-1][1] 120 | elsif node[0] == :method_add_block 121 | if anon_method_add 122 | # Class.new do ... 123 | "(anon)" 124 | else 125 | # MyClass = Class.new do ... 126 | # parent already added when processing a parent node 127 | anon_method_add = true 128 | nil 129 | end 130 | elsif node[0] == :assign && node[2][0] == :method_add_block 131 | # MyClass = Class.new do ... 132 | self.anon_method_add = false 133 | node[1][-1][1] 134 | end 135 | end 136 | 137 | def label_for(node) 138 | # A default case is deliberately omitted since I know of no way this 139 | # could fail and want it to fail fast. 140 | node.detect {|x| 141 | [:@ident, :@op, :@kw, :@const, :@backtick].include?(x[0]) 142 | }[1] 143 | end 144 | 145 | def count_nodes(node, types) 146 | node.flatten.select {|n| types.include?(n) }.length 147 | end 148 | 149 | def assignment_nodes 150 | [:assign, :opassign] 151 | end 152 | 153 | def method_nodes 154 | [:def, :defs] 155 | end 156 | 157 | def container_nodes 158 | [:class, :module] 159 | end 160 | 161 | def branch_nodes 162 | [:call, :fcall, :brace_block, :do_block] 163 | end 164 | 165 | def condition_nodes 166 | [:==, :===, :"<>", :"<=", :">=", :"=~", :>, :<, :else, :"<=>"] 167 | end 168 | 169 | METH_CHARS = { def: '#', defs: '.' } 170 | 171 | def excluded?(method_description) 172 | exclusions.include?(method_description) 173 | end 174 | 175 | def method_description(node, *modules, meth_name) 176 | separator = METH_CHARS.fetch(node.first) 177 | description = [modules.join('::'), meth_name].join(separator) 178 | end 179 | end 180 | 181 | def file_names 182 | Dir.glob(opts.fetch(:abc_glob)) 183 | end 184 | 185 | def order(result) 186 | result.sort_by {|x| x[:value].to_i }.reverse 187 | end 188 | 189 | def max_allowed_complexity 190 | opts.fetch(:abc_max) 191 | end 192 | 193 | def exclusions 194 | opts.fetch(:abc_exclude, []).flatten.to_set 195 | end 196 | 197 | def worker 198 | Cane.task_runner(opts) 199 | end 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cane 2 | 3 | Fails your build if code quality thresholds are not met. 4 | 5 | > Discipline will set you free. 6 | 7 | **Cane still technically works, but for new projects you're probably better off 8 | using [Rubocop](http://rubocop.readthedocs.io/en/latest/) and customizing it to 9 | your liking. It is far more comprehensive and more widely used.** 10 | 11 | ## Usage 12 | 13 | gem install cane 14 | cane --abc-glob '{lib,spec}/**/*.rb' --abc-max 15 15 | 16 | Your main build task should run this, probably via `bundle exec`. It will have 17 | a non-zero exit code if any quality checks fail. Also, a report: 18 | 19 | > cane 20 | 21 | Methods exceeded maximum allowed ABC complexity (2): 22 | 23 | lib/cane.rb Cane#sample 23 24 | lib/cane.rb Cane#sample_2 17 25 | 26 | Lines violated style requirements (2): 27 | 28 | lib/cane.rb:20 Line length >80 29 | lib/cane.rb:42 Trailing whitespace 30 | 31 | Class definitions require explanatory comments on preceding line (1): 32 | lib/cane:3 SomeClass 33 | 34 | Customize behaviour with a wealth of options: 35 | 36 | > cane --help 37 | Usage: cane [options] 38 | 39 | Default options are loaded from a .cane file in the current directory. 40 | 41 | -r, --require FILE Load a Ruby file containing user-defined checks 42 | -c, --check CLASS Use the given user-defined check 43 | 44 | --abc-glob GLOB Glob to run ABC metrics over (default: {app,lib}/**/*.rb) 45 | --abc-max VALUE Ignore methods under this complexity (default: 15) 46 | --abc-exclude METHOD Exclude method from analysis (eg. Foo::Bar#method) 47 | --no-abc Disable ABC checking 48 | 49 | --style-glob GLOB Glob to run style checks over (default: {app,lib,spec}/**/*.rb) 50 | --style-measure VALUE Max line length (default: 80) 51 | --style-exclude GLOB Exclude file or glob from style checking 52 | --no-style Disable style checking 53 | 54 | --doc-glob GLOB Glob to run doc checks over (default: {app,lib}/**/*.rb) 55 | --doc-exclude GLOB Exclude file or glob from documentation checking 56 | --no-readme Disable readme checking 57 | --no-doc Disable documentation checking 58 | 59 | --lt FILE,THRESHOLD Check the number in FILE is < to THRESHOLD (a number or another file name) 60 | --lte FILE,THRESHOLD Check the number in FILE is <= to THRESHOLD (a number or another file name) 61 | --eq FILE,THRESHOLD Check the number in FILE is == to THRESHOLD (a number or another file name) 62 | --gte FILE,THRESHOLD Check the number in FILE is >= to THRESHOLD (a number or another file name) 63 | --gt FILE,THRESHOLD Check the number in FILE is > to THRESHOLD (a number or another file name) 64 | 65 | -f, --all FILE Apply all checks to given file 66 | --max-violations VALUE Max allowed violations (default: 0) 67 | --json Output as JSON 68 | --parallel Use all processors. Slower on small projects, faster on large. 69 | --color Colorize output 70 | 71 | -v, --version Show version 72 | -h, --help Show this message 73 | 74 | Set default options using a `.cane` file: 75 | 76 | > cat .cane 77 | --no-doc 78 | --abc-glob **/*.rb 79 | > cane 80 | 81 | It works exactly the same as specifying the options on the command-line. 82 | Command-line arguments will override arguments specified in the `.cane` file. 83 | 84 | ## Integrating with Rake 85 | 86 | ```ruby 87 | begin 88 | require 'cane/rake_task' 89 | 90 | desc "Run cane to check quality metrics" 91 | Cane::RakeTask.new(:quality) do |cane| 92 | cane.abc_max = 10 93 | cane.add_threshold 'coverage/covered_percent', :>=, 99 94 | cane.no_style = true 95 | cane.abc_exclude = %w(Foo::Bar#some_method) 96 | end 97 | 98 | task :default => :quality 99 | rescue LoadError 100 | warn "cane not available, quality task not provided." 101 | end 102 | ``` 103 | 104 | Loading options from a `.cane` file is supported by setting `canefile=` to the 105 | file name. 106 | 107 | Rescuing `LoadError` is a good idea, since `rake -T` failing is totally 108 | frustrating. 109 | 110 | ## Adding to a legacy project 111 | 112 | Cane can be configured to still pass in the presence of a set number of 113 | violations using the `--max-violations` option. This is ideal for retrofitting 114 | on to an existing application that may already have many violations. By setting 115 | the maximum to the current number, no immediate changes will be required to 116 | your existing code base, but you will be protected from things getting worse. 117 | 118 | You may also consider beginning with high thresholds and ratcheting them down 119 | over time, or defining exclusions for specific troublesome violations (not 120 | recommended). 121 | 122 | ## Integrating with SimpleCov 123 | 124 | Any value in a file can be used as a threshold: 125 | 126 | > echo "89" > coverage/.last_run.json 127 | > cane --gte 'coverage/.last_run.json,90' 128 | 129 | Quality threshold crossed 130 | 131 | coverage/covered_percent is 89, should be >= 90 132 | 133 | ## Implementing your own checks 134 | 135 | Checks must implement: 136 | 137 | * A class level `options` method that returns a hash of available options. This 138 | will be included in help output if the check is added before `--help`. If 139 | your check does not require any configuration, return an empty hash. 140 | * A one argument constructor, into which will be passed the options specified 141 | for your check. 142 | * A `violations` method that returns an array of violations. 143 | 144 | See existing checks for guidance. Create your check in a new file: 145 | 146 | ```ruby 147 | # unhappy.rb 148 | class UnhappyCheck < Struct.new(:opts) 149 | def self.options 150 | { 151 | unhappy_file: ["File to check", default: [nil]] 152 | } 153 | end 154 | 155 | def violations 156 | [ 157 | description: "Files are unhappy", 158 | file: opts.fetch(:unhappy_file), 159 | label: ":(" 160 | ] 161 | end 162 | end 163 | ``` 164 | 165 | Include your check either using command-line options: 166 | 167 | cane -r unhappy.rb --check UnhappyCheck --unhappy-file myfile 168 | 169 | Or in your rake task: 170 | 171 | ```ruby 172 | require 'unhappy' 173 | 174 | Cane::RakeTask.new(:quality) do |c| 175 | c.use UnhappyCheck, unhappy_file: 'myfile' 176 | end 177 | ``` 178 | 179 | ## Protips 180 | 181 | ### Writing class level documentation 182 | 183 | Classes are commonly the first entry point into a code base, often for an 184 | oncall engineer responding to an exception, so provide enough information to 185 | orient first-time readers. 186 | 187 | A good class level comment should answer the following: 188 | 189 | * Why does this class exist? 190 | * How does it fit in to the larger system? 191 | * Explanation of any domain-specific terms. 192 | 193 | If you have specific documentation elsewhere (say, in the README or a wiki), a 194 | link to that suffices. 195 | 196 | If the class is a known entry point, such as a regular background job that can 197 | potentially fail, then also provide enough context that it can be efficiently 198 | dealt with. In the background job case: 199 | 200 | * Should it be retried? 201 | * What if it failed 5 days ago and we're only looking at it now? 202 | * Who cares that this job failed? 203 | 204 | ### Writing a readme 205 | 206 | A good README should include at a minimum: 207 | 208 | * Why the project exists. 209 | * How to get started with development. 210 | * How to deploy the project (if applicable). 211 | * Status of the project (spike, active development, stable in production). 212 | * Compatibility notes (1.8, 1.9, JRuby). 213 | * Any interesting technical or architectural decisions made on the project 214 | (this could be as simple as a link to an external design document). 215 | 216 | ## Compatibility 217 | 218 | Requires CRuby 2.0, since it depends on the `ripper` library to calculate 219 | complexity metrics. This only applies to the Ruby used to run Cane, not the 220 | project it is being run against. In other words, you can run Cane against your 221 | 1.8 or JRuby project. 222 | 223 | ## Support 224 | 225 | Make a [new github issue](https://github.com/square/cane/issues/new). 226 | 227 | ## Contributing 228 | 229 | Fork and patch! Before any changes are merged to master, we need you to sign an 230 | [Individual Contributor 231 | Agreement](https://spreadsheets.google.com/a/squareup.com/spreadsheet/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1) 232 | (Google Form). 233 | --------------------------------------------------------------------------------