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,228 @@
1
+ require "ncursesw"
2
+
3
+ module SugarCane
4
+
5
+ # Produces a ncurses menu that the user can navigate with:
6
+ # J/ K: Move up/down
7
+ # Q: Quit
8
+ # Enter: Open violation in text editor
9
+ #
10
+ # Constructor Parameters:
11
+ # checks: like ones produced from style_check.rb or doc_check.rb
12
+ # opts: command-line parsed options applied to each check
13
+ # height: the maximum number of items that can be in the menu
14
+ class Menu
15
+
16
+ TITLE = <<-'SUGARCANE'
17
+ ___ _ _ __ _ __ _ _ __ ___ __ _ _ __ ___
18
+ / __| | | |/ _` |/ _` | '__/ __/ _` | '_ \ / _ \
19
+ \__ \ |_| | (_| | (_| | | | (_| (_| | | | | __/
20
+ |___/\__,_|\__, |\__,_|_| \___\__,_|_| |_|\___|
21
+ |___/
22
+ SUGARCANE
23
+
24
+ # Don't trust ncursew keys as they don't always work
25
+ KEY_C = 99
26
+ KEY_Q = 113
27
+ KEY_X = 120
28
+ KEY_J = 106
29
+ KEY_K = 107
30
+ KEY_W = 119
31
+ KEY_S = 115
32
+ KEY_O = 111
33
+ KEY_UP = 259
34
+ KEY_DOWN = 258
35
+ KEY_ENTER = 13
36
+ KEY_SPACE = 32
37
+
38
+ def initialize(checks, options, height = 30)
39
+ @checks = checks
40
+ @options = options
41
+ @height = height
42
+ check_violations
43
+ end
44
+
45
+ def run
46
+ if @data.nil? or @data.empty?
47
+ return nil
48
+ end
49
+ begin
50
+ # can't go in separate function because redeclares constants
51
+ Ncurses.initscr
52
+ init_ncurses
53
+ draw_menu(@menu, @menu_position)
54
+ draw_fix_window(@fix_window)
55
+ draw_title_window(@title_window)
56
+ while ch = @menu.wgetch
57
+ case ch
58
+ when KEY_K, KEY_W, KEY_UP
59
+ # draw menu, 'move up'
60
+ @menu_position -= 1 unless @menu_position == @min_position
61
+ @data_position -= 1 unless @data_position == 0
62
+ when KEY_J, KEY_S, KEY_DOWN
63
+ # draw_info 'move down'
64
+ @menu_position += 1 unless @menu_position == @max_position
65
+ @data_position += 1 unless @data_position == @size - 1
66
+ when KEY_O, KEY_ENTER, KEY_SPACE
67
+ clean_up
68
+ selected = @data[@data_position]
69
+ edit_file(selected[:file], selected[:line])
70
+ init_ncurses
71
+ check_violations
72
+ when KEY_Q, KEY_X
73
+ clean_up
74
+ break
75
+ end
76
+ # For cycling through the options but is buggy
77
+ # @data_position = @size - 1 if @data_position < 0
78
+ # @data_position = 0 if @data_position > @size - 1
79
+ draw_menu(@menu, @menu_position)
80
+ draw_fix_window(@fix_window)
81
+ draw_title_window(@title_window)
82
+ end
83
+ return @data[@data_position]
84
+ ensure
85
+ clean_up
86
+ end
87
+ end
88
+
89
+ def draw_menu(menu, active_index=nil)
90
+ Ncurses.stdscr.border(*([0]*8))
91
+ Ncurses.stdscr.refresh
92
+ menu.clear
93
+ menu.border(*([0]*8))
94
+ @height.times do |i|
95
+ menu.move(i + 1, 1)
96
+ position = i + @data_position - @menu_position
97
+ file = @data[position][:file]
98
+ if @data[position][:line]
99
+ line = " #{@data[position][:line]}: "
100
+ else
101
+ line = " "
102
+ end
103
+ desc = @data[position][:menu_description] || ""
104
+ if desc.length > Ncurses.COLS - 10
105
+ desc << "..."
106
+ end
107
+ if i == active_index
108
+ style = Ncurses::A_STANDOUT
109
+ menu.attrset(style)
110
+ menu.addstr(file)
111
+ menu.addstr(line)
112
+ menu.addstr(desc)
113
+ menu.attrset(Ncurses::A_NORMAL)
114
+ else
115
+ menu.attrset(Ncurses.COLOR_PAIR(2))
116
+ menu.addstr(file)
117
+ menu.attrset(Ncurses.COLOR_PAIR(3))
118
+ menu.addstr(line)
119
+ menu.attrset(Ncurses.COLOR_PAIR(4))
120
+ menu.addstr(desc)
121
+ # menu.attrset(Ncurses.COLOR_PAIR(1))
122
+ menu.attrset(Ncurses::A_NORMAL)
123
+ end
124
+ end
125
+ menu.refresh
126
+ Ncurses.keypad(menu, true)
127
+ end
128
+
129
+ def draw_title_window(window)
130
+ window.clear
131
+ # window.border(*([0]*8))
132
+ window.attrset(Ncurses.COLOR_PAIR(5))
133
+ window.addstr(TITLE)
134
+ window.attrset(Ncurses.COLOR_PAIR(1))
135
+ window.refresh
136
+ end
137
+
138
+ def draw_fix_window(window)
139
+ window.clear
140
+ window.border(*([0]*8))
141
+ window.move(1, 1)
142
+ line = "Violations left: #{@data.size}"
143
+ window.addstr(line)
144
+ window.refresh
145
+ end
146
+
147
+ def init_ncurses
148
+ Ncurses.cbreak
149
+ Ncurses.start_color
150
+ Ncurses.noecho
151
+ Ncurses.nonl
152
+ Ncurses.curs_set(0)
153
+
154
+ if Ncurses.has_colors?
155
+ @background_color = Ncurses::COLOR_BLACK
156
+ Ncurses.init_pair(1, Ncurses::COLOR_WHITE, @background_color)
157
+ Ncurses.init_pair(2, Ncurses::COLOR_BLUE, @background_color)
158
+ Ncurses.init_pair(3, Ncurses::COLOR_CYAN, @background_color)
159
+ Ncurses.init_pair(4, Ncurses::COLOR_RED, @background_color)
160
+ Ncurses.init_pair(5, Ncurses::COLOR_GREEN, @background_color)
161
+ end
162
+
163
+ @title_window = Ncurses::WINDOW.new(5, Ncurses.COLS - 2,2,1)
164
+ @menu = Ncurses::WINDOW.new(@height + 2, Ncurses.COLS - 2,7,1)
165
+ @fix_window = Ncurses::WINDOW.new(3, Ncurses.COLS - 2,@height+9,1)
166
+ end
167
+
168
+ def clean_up
169
+ Ncurses.stdscr.clear
170
+ Ncurses.stdscr.refresh
171
+ Ncurses.echo
172
+ Ncurses.nocbreak
173
+ Ncurses.nl
174
+ Ncurses.endwin
175
+ end
176
+
177
+ def edit_file(file, line)
178
+ if @options[:editor]
179
+ system("#{@options[:editor]} +#{line} #{file}")
180
+ # If someone purchased sublime, they probably want to use it
181
+ elsif program_exist? "sublimetext"
182
+ system("sublimetext #{file}:#{line}")
183
+ elsif ENV['VISUAL']
184
+ system("#{ENV['VISUAL']} +#{line} #{file}")
185
+ elsif program_exist? "vim"
186
+ system("vim +#{line} #{file}")
187
+ elsif program_exist? "gedit"
188
+ system("gedit +#{line} #{file}")
189
+ elsif program_exist? "nano"
190
+ system("nano +#{line} #{file}")
191
+ elsif program_exist? "geany"
192
+ system("geany +#{line} #{file}")
193
+ else
194
+ # :(
195
+ system("notepad.exe #{file}")
196
+ end
197
+ end
198
+
199
+ # Allegedly cross-platform way to determine if an executable is in PATH
200
+ def program_exist?(command)
201
+ exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
202
+ ENV['PATH'].split(::File::PATH_SEPARATOR).each do |path|
203
+ exts.each { |ext|
204
+ exe = ::File.join(path, "#{command}#{ext}")
205
+ return exe if ::File.executable? exe
206
+ }
207
+ end
208
+ return nil
209
+ end
210
+
211
+ def check_violations
212
+ violations = @checks.
213
+ map {|check| check.new(@options).violations }.
214
+ flatten
215
+ @data = violations
216
+ @height = [@data.size,@height].min
217
+ @size = @data.size
218
+ @min_position = 0
219
+ @max_position = @height - 1
220
+ @data_position ||= 0
221
+ @menu_position ||= 0
222
+ if @data_position > @size - 1
223
+ @data_position = @size - 1
224
+ end
225
+ return violations
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,78 @@
1
+ require 'rake'
2
+ require 'rake/tasklib'
3
+
4
+ require 'sugarcane/cli/options'
5
+ require 'sugarcane/cli/parser'
6
+
7
+ module SugarCane
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
+ SugarCane::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 = SugarCane::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 = SugarCane::CLI.default_options
56
+ @options[:report] = true
57
+
58
+ if block_given?
59
+ yield self
60
+ else
61
+ if File.exists?('./sugarcane')
62
+ self.canefile = './.sugarcane'
63
+ else
64
+ self.canefile = './.cane'
65
+ end
66
+ end
67
+
68
+ unless ::Rake.application.last_comment
69
+ desc %(Check code quality metrics with cane)
70
+ end
71
+
72
+ task name do
73
+ require 'sugarcane/cli'
74
+ abort unless SugarCane.run(options)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,59 @@
1
+ require 'parallel'
2
+
3
+ require 'sugarcane/violation_formatter'
4
+ require 'sugarcane/json_formatter'
5
+ require 'sugarcane/menu'
6
+
7
+ # Accepts a parsed configuration and passes those options to a new Runner
8
+ module SugarCane
9
+ def run(*args)
10
+ Runner.new(*args).run
11
+ end
12
+ module_function :run
13
+
14
+ # Orchestrates the running of checks per the provided configuration, and
15
+ # hands the result to a formatter for display. This is the core of the
16
+ # application, but for the actual entry point see `SugarCane::CLI`.
17
+ class Runner
18
+ def initialize(spec)
19
+ @opts = spec
20
+ @checks = spec[:checks]
21
+ end
22
+
23
+ def run
24
+ check_options(violations, opts)
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 check_violations
39
+ @violations = checks.
40
+ map { |check| check.new(opts).violations }.
41
+ flatten
42
+ end
43
+
44
+ def check_options(violations, opts)
45
+ if opts[:report]
46
+ outputter.print ViolationFormatter.new(violations, opts)
47
+ elsif opts[:json]
48
+ outputter.print JsonFormatter.new(violations, opts)
49
+ else
50
+ menu = SugarCane::Menu.new(@checks, @opts)
51
+ menu.run
52
+ end
53
+ end
54
+
55
+ def outputter
56
+ opts.fetch(:out, $stdout)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,91 @@
1
+ require 'set'
2
+
3
+ require 'sugarcane/file'
4
+ require 'sugarcane/task_runner'
5
+
6
+ module SugarCane
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
+ value: line.length,
44
+ label: message,
45
+ description: "Lines violated style requirements",
46
+ menu_description: message
47
+ }}
48
+ end
49
+ end.flatten
50
+ end
51
+
52
+ protected
53
+
54
+ def violations_for_line(line)
55
+ result = []
56
+ if line.length > measure
57
+ result << "Line is > #{measure} characters (#{line.length})"
58
+ end
59
+ result << "Line contains trailing whitespace" if line =~ /\s$/
60
+ result << "Line contains hard tabs" if line =~ /\t/
61
+ result
62
+ end
63
+
64
+ def file_list
65
+ Dir[opts.fetch(:style_glob)].reject {|f| excluded?(f) }
66
+ end
67
+
68
+ def measure
69
+ opts.fetch(:style_measure)
70
+ end
71
+
72
+ def map_lines(file_path, &block)
73
+ SugarCane::File.iterator(file_path).map.with_index(&block)
74
+ end
75
+
76
+ def exclusions
77
+ @exclusions ||= opts.fetch(:style_exclude, []).flatten.map do |i|
78
+ Dir[i]
79
+ end.flatten.to_set
80
+ end
81
+
82
+ def excluded?(file)
83
+ exclusions.include?(file)
84
+ end
85
+
86
+ def worker
87
+ SugarCane.task_runner(opts)
88
+ end
89
+ end
90
+
91
+ end
@@ -0,0 +1,20 @@
1
+ # Provides a SimpleTaskRunner or Parallel task runner based on configuration
2
+ module SugarCane
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
@@ -0,0 +1,83 @@
1
+ require 'sugarcane/file'
2
+
3
+ module SugarCane
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 = SugarCane::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 >(_); false; end
76
+ def <=(_); false; end
77
+ def >=(_); false; end
78
+ def to_s; 'unavailable'; end
79
+ def real?; false; end
80
+ end
81
+ end
82
+
83
+ end
@@ -0,0 +1,3 @@
1
+ module SugarCane
2
+ VERSION = '0.0.1'
3
+ end
@@ -0,0 +1,75 @@
1
+ require 'stringio'
2
+ require 'ostruct'
3
+
4
+ module SugarCane
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