sugarcane 0.0.1

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