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.
@@ -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