sugarcane 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|