sugarcane 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/HISTORY.md +126 -0
- data/LICENSE +14 -0
- data/README.md +235 -0
- data/bin/sugarcane +7 -0
- data/lib/cane.rb +4 -0
- data/lib/sugarcane/abc_check.rb +216 -0
- data/lib/sugarcane/cli/options.rb +30 -0
- data/lib/sugarcane/cli/parser.rb +191 -0
- data/lib/sugarcane/cli.rb +21 -0
- data/lib/sugarcane/default_checks.rb +17 -0
- data/lib/sugarcane/doc_check.rb +156 -0
- data/lib/sugarcane/encoding_aware_iterator.rb +30 -0
- data/lib/sugarcane/ext/array.rb +24 -0
- data/lib/sugarcane/file.rb +30 -0
- data/lib/sugarcane/json_formatter.rb +17 -0
- data/lib/sugarcane/menu.rb +228 -0
- data/lib/sugarcane/rake_task.rb +78 -0
- data/lib/sugarcane/runner.rb +59 -0
- data/lib/sugarcane/style_check.rb +91 -0
- data/lib/sugarcane/task_runner.rb +20 -0
- data/lib/sugarcane/threshold_check.rb +83 -0
- data/lib/sugarcane/version.rb +3 -0
- data/lib/sugarcane/violation_formatter.rb +75 -0
- data/spec/abc_check_spec.rb +164 -0
- data/spec/cane_spec.rb +136 -0
- data/spec/cli_spec.rb +23 -0
- data/spec/doc_check_spec.rb +163 -0
- data/spec/encoding_aware_iterator_spec.rb +32 -0
- data/spec/file_spec.rb +25 -0
- data/spec/json_formatter_spec.rb +10 -0
- data/spec/parser_spec.rb +161 -0
- data/spec/rake_task_spec.rb +68 -0
- data/spec/runner_spec.rb +23 -0
- data/spec/spec_helper.rb +71 -0
- data/spec/style_check_spec.rb +57 -0
- data/spec/threshold_check_spec.rb +101 -0
- data/spec/violation_formatter_spec.rb +38 -0
- data/sugarcane.gemspec +41 -0
- metadata +196 -0
@@ -0,0 +1,191 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require 'sugarcane/default_checks'
|
3
|
+
require 'sugarcane/cli/options'
|
4
|
+
require 'sugarcane/version'
|
5
|
+
|
6
|
+
module SugarCane
|
7
|
+
module CLI
|
8
|
+
|
9
|
+
# Provides a specification for the command line interface that drives
|
10
|
+
# documentation, parsing, and default values.
|
11
|
+
class Parser
|
12
|
+
|
13
|
+
# Exception to indicate that no further processing is required and the
|
14
|
+
# program can exit. This is used to handle --help and --version flags.
|
15
|
+
class OptionsHandled < RuntimeError; end
|
16
|
+
|
17
|
+
def self.parse(*args)
|
18
|
+
new.parse(*args)
|
19
|
+
end
|
20
|
+
|
21
|
+
def initialize(stdout = $stdout)
|
22
|
+
@stdout = stdout
|
23
|
+
|
24
|
+
add_banner
|
25
|
+
add_user_defined_checks
|
26
|
+
|
27
|
+
SugarCane.default_checks.each do |check|
|
28
|
+
add_check_options(check)
|
29
|
+
end
|
30
|
+
add_checks_shortcut
|
31
|
+
|
32
|
+
add_cane_options
|
33
|
+
|
34
|
+
add_version
|
35
|
+
add_help
|
36
|
+
end
|
37
|
+
|
38
|
+
def parse(args, ret = true)
|
39
|
+
parser.parse!(get_default_options + args)
|
40
|
+
SugarCane::CLI.default_options.merge(options)
|
41
|
+
rescue OptionParser::InvalidOption, OptionParser::AmbiguousOption
|
42
|
+
args = %w(--help)
|
43
|
+
ret = false
|
44
|
+
retry
|
45
|
+
rescue OptionsHandled
|
46
|
+
ret
|
47
|
+
end
|
48
|
+
|
49
|
+
def get_default_options
|
50
|
+
# Right now, read_options_from_file can't just be called on
|
51
|
+
# both because if the second one doesn't exist the defaults for
|
52
|
+
# non-existent values will override what's already read
|
53
|
+
if SugarCane::File.exists? './.sugarcane'
|
54
|
+
read_options_from_file './.sugarcane'
|
55
|
+
else
|
56
|
+
read_options_from_file './.cane'
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def read_options_from_file(file)
|
61
|
+
if SugarCane::File.exists?(file)
|
62
|
+
SugarCane::File.contents(file).split(/\s+/m)
|
63
|
+
else
|
64
|
+
[]
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def add_banner
|
69
|
+
parser.banner = <<-BANNER
|
70
|
+
Usage: sugarcane [options]
|
71
|
+
|
72
|
+
Default options are loaded from a .sugarcane file in the current directory.
|
73
|
+
|
74
|
+
BANNER
|
75
|
+
end
|
76
|
+
|
77
|
+
def add_user_defined_checks
|
78
|
+
description = "Load a Ruby file containing user-defined checks"
|
79
|
+
parser.on("-r", "--require FILE", description) do |f|
|
80
|
+
load(f)
|
81
|
+
end
|
82
|
+
|
83
|
+
description = "Use the given user-defined check"
|
84
|
+
parser.on("-c", "--check CLASS", description) do |c|
|
85
|
+
check = Kernel.const_get(c)
|
86
|
+
options[:checks] << check
|
87
|
+
add_check_options(check)
|
88
|
+
end
|
89
|
+
parser.separator ""
|
90
|
+
end
|
91
|
+
|
92
|
+
def add_check_options(check)
|
93
|
+
check.options.each do |key, data|
|
94
|
+
cli_key = key.to_s.tr('_', '-')
|
95
|
+
opts = data[1] || {}
|
96
|
+
variable = opts[:variable] || "VALUE"
|
97
|
+
defaults = opts[:default] || []
|
98
|
+
|
99
|
+
if opts[:type] == Array
|
100
|
+
parser.on("--#{cli_key} #{variable}", Array, data[0]) do |opts|
|
101
|
+
(options[key.to_sym] ||= []) << opts
|
102
|
+
end
|
103
|
+
else
|
104
|
+
if [*defaults].length > 0
|
105
|
+
add_option ["--#{cli_key}", variable], *data
|
106
|
+
else
|
107
|
+
add_option ["--#{cli_key}"], *data
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
parser.separator ""
|
113
|
+
end
|
114
|
+
|
115
|
+
def add_cane_options
|
116
|
+
add_option %w(--max-violations VALUE),
|
117
|
+
"Max allowed violations", default: 0, cast: :to_i
|
118
|
+
|
119
|
+
add_option %w(--editor PROGRAM), "Text editor to use", default: nil
|
120
|
+
|
121
|
+
add_option %w(--json),
|
122
|
+
"output as json", default: false
|
123
|
+
|
124
|
+
add_option %w(--report),
|
125
|
+
"output a report", default: false
|
126
|
+
|
127
|
+
add_option %w(--parallel),
|
128
|
+
"Use all processors. Slower on small projects, faster on large.",
|
129
|
+
cast: ->(x) { x }
|
130
|
+
|
131
|
+
add_option %w(--color),
|
132
|
+
"Colorize output", default: false
|
133
|
+
|
134
|
+
parser.separator ""
|
135
|
+
end
|
136
|
+
|
137
|
+
def add_checks_shortcut
|
138
|
+
description = "Apply all checks to given file"
|
139
|
+
parser.on("-f", "--all FILE", description) do |f|
|
140
|
+
# This is a bit of a hack, but provides a really useful UI for
|
141
|
+
# dealing with single files. Let's see how it evolves.
|
142
|
+
options[:abc_glob] = f
|
143
|
+
options[:style_glob] = f
|
144
|
+
options[:doc_glob] = f
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def add_version
|
149
|
+
parser.on_tail("-v", "--version", "Show version") do
|
150
|
+
stdout.puts SugarCane::VERSION
|
151
|
+
raise OptionsHandled
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def add_help
|
156
|
+
parser.on_tail("-h", "--help", "Show this message") do
|
157
|
+
stdout.puts parser
|
158
|
+
raise OptionsHandled
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def add_option(option, description, opts={})
|
163
|
+
option_key = option[0].gsub('--', '').tr('-', '_').to_sym
|
164
|
+
default = opts[:default]
|
165
|
+
cast = opts[:cast] || ->(x) { x }
|
166
|
+
|
167
|
+
if default
|
168
|
+
description += " (default: %s)" % default
|
169
|
+
end
|
170
|
+
|
171
|
+
parser.on(option.join(' '), description) do |v|
|
172
|
+
options[option_key] = cast.to_proc.call(v)
|
173
|
+
options.delete(opts[:clobber])
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
def options
|
178
|
+
@options ||= {
|
179
|
+
checks: SugarCane.default_checks
|
180
|
+
}
|
181
|
+
end
|
182
|
+
|
183
|
+
def parser
|
184
|
+
@parser ||= OptionParser.new
|
185
|
+
end
|
186
|
+
|
187
|
+
attr_reader :stdout
|
188
|
+
end
|
189
|
+
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'sugarcane/cli/parser'
|
2
|
+
require 'sugarcane/runner'
|
3
|
+
require 'sugarcane/version'
|
4
|
+
require 'sugarcane/file'
|
5
|
+
|
6
|
+
module SugarCane
|
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
|
+
SugarCane.run(spec)
|
14
|
+
else
|
15
|
+
spec
|
16
|
+
end
|
17
|
+
end
|
18
|
+
module_function :run
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'sugarcane/abc_check'
|
2
|
+
require 'sugarcane/style_check'
|
3
|
+
require 'sugarcane/doc_check'
|
4
|
+
require 'sugarcane/threshold_check'
|
5
|
+
|
6
|
+
# Default checks performed when no checks are provided
|
7
|
+
module SugarCane
|
8
|
+
def default_checks
|
9
|
+
[
|
10
|
+
AbcCheck,
|
11
|
+
StyleCheck,
|
12
|
+
DocCheck,
|
13
|
+
ThresholdCheck
|
14
|
+
]
|
15
|
+
end
|
16
|
+
module_function :default_checks
|
17
|
+
end
|
@@ -0,0 +1,156 @@
|
|
1
|
+
require 'sugarcane/file'
|
2
|
+
require 'sugarcane/task_runner'
|
3
|
+
|
4
|
+
module SugarCane
|
5
|
+
|
6
|
+
# Creates violations for class definitions that do not have an explantory
|
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
|
+
menu_description: "#{class_definition.label} requires explanatory "\
|
69
|
+
"comments on the previous line"
|
70
|
+
}
|
71
|
+
end
|
72
|
+
end.compact
|
73
|
+
end
|
74
|
+
|
75
|
+
def class_definitions_in(file_name)
|
76
|
+
closed_classes = []
|
77
|
+
open_classes = []
|
78
|
+
last_line = ""
|
79
|
+
|
80
|
+
SugarCane::File.iterator(file_name).each_with_index do |line, number|
|
81
|
+
if class_definition? line
|
82
|
+
if single_line_class_definition? line
|
83
|
+
closed_classes
|
84
|
+
else
|
85
|
+
open_classes
|
86
|
+
end.push class_definition(number, line, last_line)
|
87
|
+
|
88
|
+
elsif method_definition?(line) && !open_classes.empty?
|
89
|
+
open_classes.last.requires_doc = true
|
90
|
+
end
|
91
|
+
|
92
|
+
last_line = line
|
93
|
+
end
|
94
|
+
|
95
|
+
(closed_classes + open_classes).sort_by(&:line)
|
96
|
+
end
|
97
|
+
|
98
|
+
def class_definition(number, line, last_line)
|
99
|
+
ClassDefinition.new({
|
100
|
+
line: (number + 1),
|
101
|
+
label: extract_class_name(line),
|
102
|
+
has_doc: comment?(last_line),
|
103
|
+
requires_doc: method_definition?(line)
|
104
|
+
})
|
105
|
+
end
|
106
|
+
|
107
|
+
def missing_file_violations
|
108
|
+
result = []
|
109
|
+
return result if opts[:no_readme]
|
110
|
+
|
111
|
+
if SugarCane::File.case_insensitive_glob("README*").none?
|
112
|
+
result << { description: 'Missing documentation',
|
113
|
+
label: 'No README found' }
|
114
|
+
end
|
115
|
+
result
|
116
|
+
end
|
117
|
+
|
118
|
+
def file_names
|
119
|
+
Dir[opts.fetch(:doc_glob)].reject { |file| excluded?(file) }
|
120
|
+
end
|
121
|
+
|
122
|
+
def method_definition?(line)
|
123
|
+
line =~ METHOD_REGEX
|
124
|
+
end
|
125
|
+
|
126
|
+
def class_definition?(line)
|
127
|
+
line =~ CLASS_REGEX && $1.index('<<') != 0
|
128
|
+
end
|
129
|
+
|
130
|
+
def single_line_class_definition?(line)
|
131
|
+
line =~ SINGLE_LINE_CLASS_REGEX
|
132
|
+
end
|
133
|
+
|
134
|
+
def comment?(line)
|
135
|
+
line =~ /^\s*#/ && !(MAGIC_COMMENT_REGEX =~ line)
|
136
|
+
end
|
137
|
+
|
138
|
+
def extract_class_name(line)
|
139
|
+
line.match(CLASS_REGEX)[1]
|
140
|
+
end
|
141
|
+
|
142
|
+
def exclusions
|
143
|
+
@exclusions ||= opts.fetch(:doc_exclude, []).flatten.map do |i|
|
144
|
+
Dir[i]
|
145
|
+
end.flatten.to_set
|
146
|
+
end
|
147
|
+
|
148
|
+
def excluded?(file)
|
149
|
+
exclusions.include?(file)
|
150
|
+
end
|
151
|
+
|
152
|
+
def worker
|
153
|
+
SugarCane.task_runner(opts)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module SugarCane
|
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
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# Adapted from https://github.com/flyerhzm/code_analyzer
|
2
|
+
# License: MIT
|
3
|
+
class Array
|
4
|
+
def line_number
|
5
|
+
case self[0]
|
6
|
+
when :def, :defs, :command, :command_call, :call, :fcall, :method_add_arg,
|
7
|
+
:method_add_block, :var_ref, :vcall, :const_ref, :const_path_ref,
|
8
|
+
:class, :module, :if, :unless, :elsif, :ifop, :if_mod, :unless_mod,
|
9
|
+
:binary, :alias, :symbol_literal, :symbol, :aref, :hash, :assoc_new,
|
10
|
+
:string_literal, :massign
|
11
|
+
self[1].line_number
|
12
|
+
when :assoclist_from_args, :bare_assoc_hash
|
13
|
+
self[1][0].line_number
|
14
|
+
when :string_add, :opassign
|
15
|
+
self[2].line_number
|
16
|
+
when :array
|
17
|
+
array_values.first.line_number
|
18
|
+
when :mlhs_add
|
19
|
+
self.last.line_number
|
20
|
+
else
|
21
|
+
self.last.first if self.last.is_a? Array
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'sugarcane/encoding_aware_iterator'
|
2
|
+
|
3
|
+
module SugarCane
|
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
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module SugarCane
|
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
|