collie 0.1.0 → 1.0.0
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 +4 -4
- data/CHANGELOG.md +28 -1
- data/README.md +55 -258
- data/lib/collie/analyzer/reachability.rb +17 -20
- data/lib/collie/analyzer/recursion.rb +28 -9
- data/lib/collie/analyzer/symbol_resolver.rb +51 -0
- data/lib/collie/ast.rb +18 -4
- data/lib/collie/cli.rb +388 -50
- data/lib/collie/config/schema.rb +117 -0
- data/lib/collie/config.rb +106 -22
- data/lib/collie/formatter/formatter.rb +95 -50
- data/lib/collie/formatter/options.rb +17 -5
- data/lib/collie/formatter/signature.rb +72 -0
- data/lib/collie/linter/base.rb +49 -0
- data/lib/collie/linter/rules/ambiguous_precedence.rb +5 -2
- data/lib/collie/linter/rules/circular_reference.rb +96 -38
- data/lib/collie/linter/rules/consistent_tag_naming.rb +13 -13
- data/lib/collie/linter/rules/empty_action.rb +42 -11
- data/lib/collie/linter/rules/factorizable_rules.rb +2 -2
- data/lib/collie/linter/rules/left_recursion.rb +5 -4
- data/lib/collie/linter/rules/long_rule.rb +3 -3
- data/lib/collie/linter/rules/nonterminal_naming.rb +6 -4
- data/lib/collie/linter/rules/prec_improvement.rb +1 -1
- data/lib/collie/linter/rules/redundant_epsilon.rb +11 -11
- data/lib/collie/linter/rules/right_recursion.rb +4 -1
- data/lib/collie/linter/rules/symbol_conflict.rb +130 -0
- data/lib/collie/linter/rules/token_naming.rb +2 -1
- data/lib/collie/linter/rules/trailing_whitespace.rb +7 -1
- data/lib/collie/linter/rules/undefined_symbol.rb +50 -8
- data/lib/collie/linter/rules/unused_nonterminal.rb +36 -1
- data/lib/collie/linter/rules/unused_token.rb +34 -9
- data/lib/collie/parser/debug_serializer.rb +205 -0
- data/lib/collie/parser/lexer.rb +182 -11
- data/lib/collie/parser/parser.rb +73 -13
- data/lib/collie/reporter/github.rb +15 -2
- data/lib/collie/reporter/json.rb +4 -1
- data/lib/collie/reporter/sarif.rb +81 -0
- data/lib/collie/version.rb +1 -1
- data/lib/collie.rb +6 -1
- metadata +8 -2
data/lib/collie/cli.rb
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "config"
|
|
6
|
+
require_relative "formatter/signature"
|
|
7
|
+
require_relative "parser/debug_serializer"
|
|
4
8
|
|
|
5
9
|
module Collie
|
|
6
10
|
# Command-line interface
|
|
7
11
|
class CLI < Thor
|
|
12
|
+
PARSE_ERROR_RULE = Class.new do
|
|
13
|
+
class << self
|
|
14
|
+
def rule_name = "ParseError"
|
|
15
|
+
def description = "Reports grammar parse errors"
|
|
16
|
+
def severity = :error
|
|
17
|
+
def autocorrectable = false
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
8
21
|
def self.exit_on_failure?
|
|
9
22
|
true
|
|
10
23
|
end
|
|
@@ -13,24 +26,34 @@ module Collie
|
|
|
13
26
|
|
|
14
27
|
desc "lint FILES", "Lint grammar files"
|
|
15
28
|
option :config, type: :string, desc: "Config file path"
|
|
16
|
-
option :format, type: :string, default: "text", enum: %w[text json github], desc: "Output format"
|
|
29
|
+
option :format, type: :string, default: "text", enum: %w[text json github sarif], desc: "Output format"
|
|
17
30
|
option :autocorrect, type: :boolean, aliases: "-a", desc: "Auto-fix offenses"
|
|
18
|
-
option :only, type: :
|
|
19
|
-
option :except, type: :
|
|
31
|
+
option :only, type: :string, repeatable: true, desc: "Run only specified rules"
|
|
32
|
+
option :except, type: :string, repeatable: true, desc: "Exclude specified rules"
|
|
33
|
+
option :fail_level, type: :string, default: "error", enum: %w[error warning convention info],
|
|
34
|
+
desc: "Minimum severity that exits with failure"
|
|
35
|
+
option :stdin, type: :boolean, desc: "Read source from standard input"
|
|
36
|
+
option :stdin_filename, type: :string, default: "<stdin>", desc: "Filename to use for standard input"
|
|
20
37
|
def lint(*files)
|
|
38
|
+
config = load_config
|
|
39
|
+
Linter::Registry.load_rules
|
|
40
|
+
validate_rule_filters!
|
|
41
|
+
|
|
42
|
+
return lint_stdin(config) if options[:stdin]
|
|
43
|
+
|
|
44
|
+
files = resolve_files(files, config)
|
|
21
45
|
if files.empty?
|
|
22
|
-
say "No files
|
|
46
|
+
say "No files matched", :red
|
|
23
47
|
exit 1
|
|
24
48
|
end
|
|
25
49
|
|
|
26
|
-
config = Config.new(options[:config])
|
|
27
|
-
Linter::Registry.load_rules
|
|
28
|
-
|
|
29
50
|
all_offenses = []
|
|
51
|
+
failed = false
|
|
30
52
|
|
|
31
53
|
files.each do |file|
|
|
32
54
|
unless File.exist?(file)
|
|
33
55
|
say "File not found: #{file}", :red
|
|
56
|
+
failed = true
|
|
34
57
|
next
|
|
35
58
|
end
|
|
36
59
|
|
|
@@ -41,64 +64,146 @@ module Collie
|
|
|
41
64
|
reporter = create_reporter(options[:format])
|
|
42
65
|
puts reporter.report(all_offenses)
|
|
43
66
|
|
|
44
|
-
exit 1 if
|
|
67
|
+
exit 1 if failed || fail_level_reached?(all_offenses)
|
|
45
68
|
end
|
|
46
69
|
|
|
47
70
|
desc "fmt FILES", "Format grammar files"
|
|
48
71
|
option :check, type: :boolean, desc: "Check only, don't modify"
|
|
49
72
|
option :diff, type: :boolean, desc: "Show diff"
|
|
50
73
|
option :config, type: :string, desc: "Config file path"
|
|
74
|
+
option :stdin, type: :boolean, desc: "Read source from standard input"
|
|
75
|
+
option :stdin_filename, type: :string, default: "<stdin>", desc: "Filename to use for standard input"
|
|
51
76
|
def fmt(*files)
|
|
77
|
+
config = load_config
|
|
78
|
+
formatter = Formatter::Formatter.new(Formatter::Options.new(config.formatter_options))
|
|
79
|
+
|
|
80
|
+
return fmt_stdin(formatter) if options[:stdin]
|
|
81
|
+
|
|
82
|
+
files = resolve_files(files, config)
|
|
52
83
|
if files.empty?
|
|
53
|
-
say "No files
|
|
84
|
+
say "No files matched", :red
|
|
54
85
|
exit 1
|
|
55
86
|
end
|
|
56
87
|
|
|
57
|
-
|
|
58
|
-
|
|
88
|
+
failed = false
|
|
89
|
+
changed = false
|
|
59
90
|
|
|
60
91
|
files.each do |file|
|
|
61
92
|
unless File.exist?(file)
|
|
62
93
|
say "File not found: #{file}", :red
|
|
94
|
+
failed = true
|
|
63
95
|
next
|
|
64
96
|
end
|
|
65
97
|
|
|
66
|
-
format_file(file, formatter, check: options[:check], diff: options[:diff])
|
|
98
|
+
result = format_file(file, formatter, check: options[:check], diff: options[:diff])
|
|
99
|
+
failed = true if result == :failed
|
|
100
|
+
changed = true if result == :changed
|
|
67
101
|
end
|
|
102
|
+
|
|
103
|
+
exit 1 if failed || changed
|
|
68
104
|
end
|
|
69
105
|
|
|
70
106
|
desc "rules", "List all available rules"
|
|
107
|
+
option :config, type: :string, desc: "Config file path"
|
|
71
108
|
option :format, type: :string, default: "text", enum: %w[text json]
|
|
72
109
|
def rules
|
|
110
|
+
config = load_config
|
|
73
111
|
Linter::Registry.load_rules
|
|
74
112
|
|
|
75
113
|
if options[:format] == "json"
|
|
76
114
|
output = Linter::Registry.all.map do |rule|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
description: rule.description,
|
|
80
|
-
severity: rule.severity,
|
|
81
|
-
autocorrectable: rule.autocorrectable
|
|
82
|
-
}
|
|
115
|
+
rule_config = config.rule_config(rule.rule_name)
|
|
116
|
+
rule_metadata(rule, config, rule_config)
|
|
83
117
|
end
|
|
84
118
|
puts JSON.pretty_generate(output)
|
|
85
119
|
else
|
|
86
120
|
say "Available lint rules:", :bold
|
|
87
121
|
Linter::Registry.all.each do |rule|
|
|
88
|
-
|
|
122
|
+
rule_config = config.rule_config(rule.rule_name)
|
|
123
|
+
severity = configured_rule_severity(rule, rule_config)
|
|
124
|
+
severity_color = severity_color(severity)
|
|
89
125
|
autocorrect = rule.autocorrectable ? " [autocorrectable]" : ""
|
|
90
|
-
|
|
126
|
+
enabled = config.rule_enabled?(rule.rule_name) ? "" : " [disabled]"
|
|
127
|
+
say " #{rule.rule_name} (#{set_color(severity, severity_color)})#{autocorrect}#{enabled}"
|
|
91
128
|
say " #{rule.description}", :dim
|
|
92
129
|
end
|
|
93
130
|
end
|
|
94
131
|
end
|
|
95
132
|
|
|
96
|
-
desc "
|
|
133
|
+
desc "explain RULE", "Explain a lint rule"
|
|
134
|
+
option :config, type: :string, desc: "Config file path"
|
|
135
|
+
option :format, type: :string, default: "text", enum: %w[text json]
|
|
136
|
+
def explain(rule_name)
|
|
137
|
+
config = load_config
|
|
138
|
+
Linter::Registry.load_rules
|
|
139
|
+
|
|
140
|
+
rule = Linter::Registry.find(rule_name)
|
|
141
|
+
unless rule
|
|
142
|
+
say "Unknown rule: #{rule_name}", :red
|
|
143
|
+
exit 1
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
rule_config = config.rule_config(rule.rule_name)
|
|
147
|
+
metadata = rule_metadata(rule, config, rule_config)
|
|
148
|
+
|
|
149
|
+
if options[:format] == "json"
|
|
150
|
+
puts JSON.pretty_generate(metadata)
|
|
151
|
+
else
|
|
152
|
+
say metadata[:name], :bold
|
|
153
|
+
say " Description: #{metadata[:description]}"
|
|
154
|
+
say " Enabled: #{metadata[:enabled]}"
|
|
155
|
+
say " Severity: #{metadata[:severity]}"
|
|
156
|
+
say " Autocorrectable: #{metadata[:autocorrectable]}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
desc "tokens FILE", "Print lexer tokens for debugging"
|
|
161
|
+
option :stdin, type: :boolean, desc: "Read source from standard input"
|
|
162
|
+
option :stdin_filename, type: :string, default: "<stdin>", desc: "Filename to use for standard input"
|
|
163
|
+
def tokens(file = nil)
|
|
164
|
+
source, filename = debug_source(file)
|
|
165
|
+
lexer = Parser::Lexer.new(source, filename: filename)
|
|
166
|
+
output = lexer.tokenize.map { |token| Parser::DebugSerializer.token(token) }
|
|
167
|
+
|
|
168
|
+
puts JSON.pretty_generate(output)
|
|
169
|
+
rescue Error => e
|
|
170
|
+
say e.message, :red
|
|
171
|
+
exit 1
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
desc "ast FILE", "Print parsed AST for debugging"
|
|
175
|
+
option :stdin, type: :boolean, desc: "Read source from standard input"
|
|
176
|
+
option :stdin_filename, type: :string, default: "<stdin>", desc: "Filename to use for standard input"
|
|
177
|
+
def ast(file = nil)
|
|
178
|
+
source, filename = debug_source(file)
|
|
179
|
+
tree = parse_source(source, filename: filename)
|
|
180
|
+
|
|
181
|
+
puts JSON.pretty_generate(Parser::DebugSerializer.ast(tree))
|
|
182
|
+
rescue Error => e
|
|
183
|
+
say e.message, :red
|
|
184
|
+
exit 1
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
map "config-schema" => :config_schema
|
|
188
|
+
|
|
189
|
+
desc "init", "Generate .collie.yml"
|
|
190
|
+
option :profile, type: :string, default: "default", enum: Config::PROFILE_NAMES,
|
|
191
|
+
desc: "Configuration profile"
|
|
192
|
+
option :path, type: :string, default: ".collie.yml", desc: "Path to write"
|
|
97
193
|
def init
|
|
98
|
-
|
|
194
|
+
path = options[:path]
|
|
195
|
+
return if File.exist?(path) && !yes?("#{path} already exists. Overwrite? (y/n)")
|
|
196
|
+
|
|
197
|
+
Config.generate_default(path, profile: options[:profile])
|
|
198
|
+
say "Generated #{path}", :green
|
|
199
|
+
rescue Error => e
|
|
200
|
+
say e.message, :red
|
|
201
|
+
exit 1
|
|
202
|
+
end
|
|
99
203
|
|
|
100
|
-
|
|
101
|
-
|
|
204
|
+
desc "config-schema", "Print JSON Schema for .collie.yml"
|
|
205
|
+
def config_schema
|
|
206
|
+
puts JSON.pretty_generate(Config.schema)
|
|
102
207
|
end
|
|
103
208
|
|
|
104
209
|
desc "version", "Show version"
|
|
@@ -108,23 +213,41 @@ module Collie
|
|
|
108
213
|
|
|
109
214
|
private
|
|
110
215
|
|
|
216
|
+
def load_config
|
|
217
|
+
Config.new(options[:config])
|
|
218
|
+
rescue Error => e
|
|
219
|
+
say e.message, :red
|
|
220
|
+
exit 1
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def lint_stdin(config)
|
|
224
|
+
source = $stdin.read
|
|
225
|
+
offenses = lint_source(source, filename: options[:stdin_filename], config: config)
|
|
226
|
+
|
|
227
|
+
reporter = create_reporter(options[:format])
|
|
228
|
+
puts reporter.report(offenses)
|
|
229
|
+
|
|
230
|
+
exit 1 if fail_level_reached?(offenses)
|
|
231
|
+
end
|
|
232
|
+
|
|
111
233
|
def lint_file(file, config)
|
|
112
234
|
source = File.read(file)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
235
|
+
lint_source(source, filename: file, config: config, autocorrect_path: file)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def lint_source(source, filename:, config:, autocorrect_path: nil)
|
|
239
|
+
ast = parse_source(source, filename: filename)
|
|
117
240
|
|
|
118
241
|
symbol_table = build_symbol_table(ast)
|
|
119
|
-
|
|
242
|
+
Analyzer::SymbolResolver.resolve(ast, symbol_table)
|
|
243
|
+
context = { symbol_table: symbol_table, source: source, file: filename }
|
|
120
244
|
|
|
121
245
|
offenses = run_lint_rules(ast, context, config)
|
|
122
|
-
apply_autocorrect(
|
|
246
|
+
apply_autocorrect(autocorrect_path, source, context, offenses) if autocorrect_path && options[:autocorrect]
|
|
123
247
|
|
|
124
248
|
offenses
|
|
125
249
|
rescue Error => e
|
|
126
|
-
|
|
127
|
-
[]
|
|
250
|
+
[parse_error_offense(filename, e.message)]
|
|
128
251
|
end
|
|
129
252
|
|
|
130
253
|
def build_symbol_table(ast)
|
|
@@ -135,10 +258,18 @@ module Collie
|
|
|
135
258
|
decl.names.each do |name|
|
|
136
259
|
symbol_table.add_token(name, type_tag: decl.type_tag, location: decl.location)
|
|
137
260
|
rescue Error
|
|
138
|
-
# Ignore
|
|
261
|
+
# Ignore duplicates while building the resolver table.
|
|
262
|
+
end
|
|
263
|
+
when AST::PrecedenceDeclaration
|
|
264
|
+
decl.tokens.each do |name|
|
|
265
|
+
symbol_table.add_token(name, location: decl.location)
|
|
266
|
+
rescue Error
|
|
267
|
+
# Ignore duplicates while building the resolver table.
|
|
139
268
|
end
|
|
140
269
|
when AST::ParameterizedRule
|
|
141
270
|
symbol_table.add_nonterminal(decl.name, location: decl.location)
|
|
271
|
+
when AST::InlineRule
|
|
272
|
+
symbol_table.add_nonterminal(decl.rule, location: decl.location)
|
|
142
273
|
end
|
|
143
274
|
end
|
|
144
275
|
|
|
@@ -176,51 +307,219 @@ module Collie
|
|
|
176
307
|
say "Auto-corrected #{autocorrectable_offenses.size} offense(s) in #{file}", :green
|
|
177
308
|
end
|
|
178
309
|
|
|
310
|
+
def fmt_stdin(formatter)
|
|
311
|
+
source = $stdin.read
|
|
312
|
+
formatted = format_source(source, formatter, filename: options[:stdin_filename])
|
|
313
|
+
exit 1 unless formatted
|
|
314
|
+
|
|
315
|
+
if options[:check]
|
|
316
|
+
if source == formatted
|
|
317
|
+
say "#{options[:stdin_filename]}: OK", :green
|
|
318
|
+
else
|
|
319
|
+
say "#{options[:stdin_filename]}: needs formatting", :yellow
|
|
320
|
+
show_diff(source, formatted) if options[:diff]
|
|
321
|
+
exit 1
|
|
322
|
+
end
|
|
323
|
+
elsif options[:diff]
|
|
324
|
+
if source == formatted
|
|
325
|
+
say "#{options[:stdin_filename]}: OK", :green
|
|
326
|
+
else
|
|
327
|
+
say "#{options[:stdin_filename]}: needs formatting", :yellow
|
|
328
|
+
show_diff(source, formatted)
|
|
329
|
+
exit 1
|
|
330
|
+
end
|
|
331
|
+
else
|
|
332
|
+
puts formatted
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
179
336
|
def format_file(file, formatter, check: false, diff: false)
|
|
180
337
|
source = File.read(file)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
parser = Parser::Parser.new(tokens)
|
|
184
|
-
ast = parser.parse
|
|
185
|
-
|
|
186
|
-
formatted = formatter.format(ast)
|
|
338
|
+
formatted = format_source(source, formatter, filename: file)
|
|
339
|
+
return :failed unless formatted
|
|
187
340
|
|
|
188
341
|
if check
|
|
189
342
|
if source == formatted
|
|
190
343
|
say "#{file}: OK", :green
|
|
344
|
+
:ok
|
|
191
345
|
else
|
|
192
346
|
say "#{file}: needs formatting", :yellow
|
|
193
347
|
show_diff(source, formatted) if diff
|
|
348
|
+
:changed
|
|
349
|
+
end
|
|
350
|
+
elsif diff
|
|
351
|
+
if source == formatted
|
|
352
|
+
say "#{file}: OK", :green
|
|
353
|
+
:ok
|
|
354
|
+
else
|
|
355
|
+
say "#{file}: needs formatting", :yellow
|
|
356
|
+
show_diff(source, formatted)
|
|
357
|
+
:changed
|
|
194
358
|
end
|
|
195
359
|
else
|
|
196
360
|
File.write(file, formatted)
|
|
197
361
|
say "Formatted #{file}", :green
|
|
362
|
+
:ok
|
|
198
363
|
end
|
|
199
364
|
rescue Error => e
|
|
200
365
|
say "Error formatting #{file}: #{e.message}", :red
|
|
366
|
+
:failed
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def format_source(source, formatter, filename:)
|
|
370
|
+
ast = parse_source(source, filename: filename)
|
|
371
|
+
formatted = formatter.format(ast)
|
|
372
|
+
formatted_ast = parse_source(formatted, filename: filename)
|
|
373
|
+
unless Formatter::Signature.build(ast) == Formatter::Signature.build(formatted_ast)
|
|
374
|
+
raise Error, "Formatted output changed grammar structure"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
formatted
|
|
378
|
+
rescue Error => e
|
|
379
|
+
say "Error formatting #{filename}: #{e.message}", :red
|
|
380
|
+
nil
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def parse_source(source, filename:)
|
|
384
|
+
lexer = Parser::Lexer.new(source, filename: filename)
|
|
385
|
+
tokens = lexer.tokenize
|
|
386
|
+
parser = Parser::Parser.new(tokens)
|
|
387
|
+
parser.parse
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def debug_source(file)
|
|
391
|
+
return [$stdin.read, options[:stdin_filename]] if options[:stdin]
|
|
392
|
+
|
|
393
|
+
unless file
|
|
394
|
+
say "No file specified", :red
|
|
395
|
+
exit 1
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
unless File.exist?(file)
|
|
399
|
+
say "File not found: #{file}", :red
|
|
400
|
+
exit 1
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
[File.read(file), file]
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def parse_error_offense(file, message)
|
|
407
|
+
Linter::Offense.new(
|
|
408
|
+
rule: PARSE_ERROR_RULE,
|
|
409
|
+
location: parse_error_location(file, message),
|
|
410
|
+
message: message,
|
|
411
|
+
severity: :error
|
|
412
|
+
)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def parse_error_location(file, message)
|
|
416
|
+
match = message.match(/:(\d+):(\d+)\b/)
|
|
417
|
+
line = match ? match[1].to_i : 1
|
|
418
|
+
column = match ? match[2].to_i : 1
|
|
419
|
+
|
|
420
|
+
AST::Location.new(file: file, line: line, column: column)
|
|
201
421
|
end
|
|
202
422
|
|
|
203
423
|
def filter_rules(rules)
|
|
204
424
|
filtered = rules
|
|
205
425
|
|
|
206
|
-
|
|
426
|
+
only = rule_filter(:only)
|
|
427
|
+
except = rule_filter(:except)
|
|
428
|
+
|
|
429
|
+
filtered = filtered.select { |r| only.include?(r.rule_name) } if only.any?
|
|
207
430
|
|
|
208
|
-
filtered = filtered.reject { |r|
|
|
431
|
+
filtered = filtered.reject { |r| except.include?(r.rule_name) } if except.any?
|
|
209
432
|
|
|
210
433
|
filtered
|
|
211
434
|
end
|
|
212
435
|
|
|
436
|
+
def rule_filter(option_name)
|
|
437
|
+
Array(options[option_name]).flat_map { |value| value.split(",") }.map(&:strip).reject(&:empty?)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def validate_rule_filters!
|
|
441
|
+
unknown_rules = (rule_filter(:only) + rule_filter(:except)).uniq.reject do |rule_name|
|
|
442
|
+
Linter::Registry.find(rule_name)
|
|
443
|
+
end
|
|
444
|
+
return if unknown_rules.empty?
|
|
445
|
+
|
|
446
|
+
say "Unknown rule(s): #{unknown_rules.join(', ')}", :red
|
|
447
|
+
exit 1
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def resolve_files(files, config)
|
|
451
|
+
targets = files.empty? ? config.included_patterns : files
|
|
452
|
+
resolved = targets.flat_map { |target| expand_target(target) }.uniq
|
|
453
|
+
return resolved unless files.empty?
|
|
454
|
+
|
|
455
|
+
resolved.reject { |file| excluded?(file, config.excluded_patterns) }
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def expand_target(target)
|
|
459
|
+
if glob_pattern?(target)
|
|
460
|
+
return Dir.glob(target).select { |path| File.file?(path) }.sort
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
if File.directory?(target)
|
|
464
|
+
return Dir.glob(File.join(target, "**", "*.y")).select { |path| File.file?(path) }.sort
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
[target]
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def glob_pattern?(target)
|
|
471
|
+
target.match?(/[*?\[\]{}]/)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def excluded?(file, patterns)
|
|
475
|
+
patterns.any? { |pattern| File.fnmatch?(pattern, file, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def fail_level_reached?(offenses)
|
|
479
|
+
threshold = severity_rank(options[:fail_level].to_sym)
|
|
480
|
+
offenses.any? { |offense| severity_rank(offense.severity) >= threshold }
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def severity_rank(severity)
|
|
484
|
+
{
|
|
485
|
+
info: 0,
|
|
486
|
+
convention: 1,
|
|
487
|
+
warning: 2,
|
|
488
|
+
error: 3
|
|
489
|
+
}.fetch(severity, 3)
|
|
490
|
+
end
|
|
491
|
+
|
|
213
492
|
def create_reporter(format)
|
|
214
493
|
case format
|
|
215
494
|
when "json"
|
|
216
495
|
Reporter::Json.new
|
|
217
496
|
when "github"
|
|
218
497
|
Reporter::Github.new
|
|
498
|
+
when "sarif"
|
|
499
|
+
Reporter::Sarif.new
|
|
219
500
|
else
|
|
220
501
|
Reporter::Text.new
|
|
221
502
|
end
|
|
222
503
|
end
|
|
223
504
|
|
|
505
|
+
def configured_rule_severity(rule, rule_config)
|
|
506
|
+
configured = rule_config["severity"] || rule_config[:severity]
|
|
507
|
+
return rule.severity unless configured
|
|
508
|
+
|
|
509
|
+
normalized = configured.to_sym
|
|
510
|
+
Linter::Base::VALID_SEVERITIES.include?(normalized) ? normalized : rule.severity
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def rule_metadata(rule, config, rule_config)
|
|
514
|
+
{
|
|
515
|
+
name: rule.rule_name,
|
|
516
|
+
description: rule.description,
|
|
517
|
+
enabled: config.rule_enabled?(rule.rule_name),
|
|
518
|
+
severity: configured_rule_severity(rule, rule_config),
|
|
519
|
+
autocorrectable: rule.autocorrectable
|
|
520
|
+
}
|
|
521
|
+
end
|
|
522
|
+
|
|
224
523
|
def severity_color(severity)
|
|
225
524
|
case severity
|
|
226
525
|
when :error then :red
|
|
@@ -232,18 +531,57 @@ module Collie
|
|
|
232
531
|
end
|
|
233
532
|
|
|
234
533
|
def show_diff(original, formatted)
|
|
235
|
-
|
|
534
|
+
puts unified_diff(original, formatted)
|
|
535
|
+
end
|
|
236
536
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
537
|
+
def unified_diff(original, formatted)
|
|
538
|
+
original_lines = original.lines
|
|
539
|
+
formatted_lines = formatted.lines
|
|
540
|
+
prefix = common_prefix_length(original_lines, formatted_lines)
|
|
541
|
+
suffix = common_suffix_length(original_lines, formatted_lines, prefix)
|
|
542
|
+
|
|
543
|
+
original_start = [prefix - 3, 0].max
|
|
544
|
+
formatted_start = [prefix - 3, 0].max
|
|
545
|
+
original_end = [original_lines.length - suffix + 3, original_lines.length].min
|
|
546
|
+
formatted_end = [formatted_lines.length - suffix + 3, formatted_lines.length].min
|
|
547
|
+
|
|
548
|
+
output = [
|
|
549
|
+
"--- original",
|
|
550
|
+
"+++ formatted",
|
|
551
|
+
"@@ -#{hunk_range(original_start, original_end)} +#{hunk_range(formatted_start, formatted_end)} @@"
|
|
552
|
+
]
|
|
553
|
+
|
|
554
|
+
original_lines[original_start...prefix].each { |line| output << " #{line.chomp}" }
|
|
555
|
+
original_lines[prefix...(original_lines.length - suffix)].each { |line| output << "-#{line.chomp}" }
|
|
556
|
+
formatted_lines[prefix...(formatted_lines.length - suffix)].each { |line| output << "+#{line.chomp}" }
|
|
557
|
+
formatted_lines[(formatted_lines.length - suffix)...formatted_end].each { |line| output << " #{line.chomp}" }
|
|
558
|
+
|
|
559
|
+
output.join("\n")
|
|
560
|
+
end
|
|
243
561
|
|
|
244
|
-
|
|
245
|
-
|
|
562
|
+
def common_prefix_length(left, right)
|
|
563
|
+
index = 0
|
|
564
|
+
index += 1 while index < left.length && index < right.length && left[index] == right[index]
|
|
565
|
+
index
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def common_suffix_length(left, right, prefix_length)
|
|
569
|
+
left_index = left.length - 1
|
|
570
|
+
right_index = right.length - 1
|
|
571
|
+
count = 0
|
|
572
|
+
|
|
573
|
+
while left_index >= prefix_length && right_index >= prefix_length && left[left_index] == right[right_index]
|
|
574
|
+
count += 1
|
|
575
|
+
left_index -= 1
|
|
576
|
+
right_index -= 1
|
|
246
577
|
end
|
|
578
|
+
|
|
579
|
+
count
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def hunk_range(start_index, end_index)
|
|
583
|
+
length = end_index - start_index
|
|
584
|
+
length == 1 ? (start_index + 1).to_s : "#{start_index + 1},#{length}"
|
|
247
585
|
end
|
|
248
586
|
end
|
|
249
587
|
end
|