sugarcane 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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