collie 0.1.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 +7 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +333 -0
- data/Rakefile +9 -0
- data/collie.gemspec +37 -0
- data/docs/TUTORIAL.md +588 -0
- data/docs/index.html +56 -0
- data/docs/playground/README.md +134 -0
- data/docs/playground/build-collie-bundle.rb +85 -0
- data/docs/playground/css/styles.css +402 -0
- data/docs/playground/index.html +146 -0
- data/docs/playground/js/app.js +231 -0
- data/docs/playground/js/collie-bridge.js +186 -0
- data/docs/playground/js/editor.js +129 -0
- data/docs/playground/js/examples.js +80 -0
- data/docs/playground/js/ruby-runner.js +75 -0
- data/docs/playground/test-server.sh +18 -0
- data/exe/collie +15 -0
- data/lib/collie/analyzer/conflict.rb +114 -0
- data/lib/collie/analyzer/reachability.rb +83 -0
- data/lib/collie/analyzer/recursion.rb +96 -0
- data/lib/collie/analyzer/symbol_table.rb +67 -0
- data/lib/collie/ast.rb +183 -0
- data/lib/collie/cli.rb +249 -0
- data/lib/collie/config.rb +91 -0
- data/lib/collie/formatter/formatter.rb +196 -0
- data/lib/collie/formatter/options.rb +23 -0
- data/lib/collie/linter/base.rb +62 -0
- data/lib/collie/linter/registry.rb +34 -0
- data/lib/collie/linter/rules/ambiguous_precedence.rb +87 -0
- data/lib/collie/linter/rules/circular_reference.rb +89 -0
- data/lib/collie/linter/rules/consistent_tag_naming.rb +69 -0
- data/lib/collie/linter/rules/duplicate_token.rb +38 -0
- data/lib/collie/linter/rules/empty_action.rb +52 -0
- data/lib/collie/linter/rules/factorizable_rules.rb +67 -0
- data/lib/collie/linter/rules/left_recursion.rb +34 -0
- data/lib/collie/linter/rules/long_rule.rb +37 -0
- data/lib/collie/linter/rules/missing_start_symbol.rb +38 -0
- data/lib/collie/linter/rules/nonterminal_naming.rb +34 -0
- data/lib/collie/linter/rules/prec_improvement.rb +54 -0
- data/lib/collie/linter/rules/redundant_epsilon.rb +44 -0
- data/lib/collie/linter/rules/right_recursion.rb +35 -0
- data/lib/collie/linter/rules/token_naming.rb +39 -0
- data/lib/collie/linter/rules/trailing_whitespace.rb +46 -0
- data/lib/collie/linter/rules/undefined_symbol.rb +55 -0
- data/lib/collie/linter/rules/unreachable_rule.rb +49 -0
- data/lib/collie/linter/rules/unused_nonterminal.rb +93 -0
- data/lib/collie/linter/rules/unused_token.rb +82 -0
- data/lib/collie/parser/lexer.rb +349 -0
- data/lib/collie/parser/parser.rb +416 -0
- data/lib/collie/reporter/github.rb +35 -0
- data/lib/collie/reporter/json.rb +52 -0
- data/lib/collie/reporter/text.rb +97 -0
- data/lib/collie/version.rb +5 -0
- data/lib/collie.rb +52 -0
- metadata +145 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ast"
|
|
4
|
+
|
|
5
|
+
module Collie
|
|
6
|
+
module Parser
|
|
7
|
+
# Parser for .y grammar files
|
|
8
|
+
class Parser
|
|
9
|
+
def initialize(tokens)
|
|
10
|
+
@tokens = tokens
|
|
11
|
+
@pos = 0
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parse
|
|
15
|
+
prologue = parse_prologue
|
|
16
|
+
declarations = parse_declarations
|
|
17
|
+
expect(:SECTION_SEPARATOR)
|
|
18
|
+
rules = parse_rules
|
|
19
|
+
epilogue = parse_epilogue
|
|
20
|
+
|
|
21
|
+
AST::GrammarFile.new(
|
|
22
|
+
prologue: prologue,
|
|
23
|
+
declarations: declarations,
|
|
24
|
+
rules: rules,
|
|
25
|
+
epilogue: epilogue,
|
|
26
|
+
location: current_token.location
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def current_token
|
|
33
|
+
@tokens[@pos] || @tokens.last
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def peek_token(offset = 1)
|
|
37
|
+
@tokens[@pos + offset] || @tokens.last
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def advance
|
|
41
|
+
@pos += 1 unless @pos >= @tokens.length
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def expect(type)
|
|
45
|
+
unless current_token.type == type
|
|
46
|
+
raise Error, "Expected #{type} but got #{current_token.type} at #{current_token.location}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
token = current_token
|
|
50
|
+
advance
|
|
51
|
+
token
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def match?(type)
|
|
55
|
+
current_token.type == type
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse_prologue
|
|
59
|
+
return nil unless match?(:PROLOGUE_START)
|
|
60
|
+
|
|
61
|
+
token = current_token
|
|
62
|
+
advance
|
|
63
|
+
AST::Prologue.new(code: token.value, location: token.location)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_declarations
|
|
67
|
+
declarations = []
|
|
68
|
+
|
|
69
|
+
while !match?(:SECTION_SEPARATOR) && !match?(:EOF)
|
|
70
|
+
case current_token.type
|
|
71
|
+
when :TOKEN
|
|
72
|
+
declarations << parse_token_declaration
|
|
73
|
+
when :TYPE
|
|
74
|
+
declarations << parse_type_declaration
|
|
75
|
+
when :LEFT, :RIGHT, :NONASSOC
|
|
76
|
+
declarations << parse_precedence_declaration
|
|
77
|
+
when :START
|
|
78
|
+
declarations << parse_start_declaration
|
|
79
|
+
when :UNION
|
|
80
|
+
declarations << parse_union_declaration
|
|
81
|
+
when :RULE
|
|
82
|
+
# %rule for Lrama extensions (handled inline)
|
|
83
|
+
advance
|
|
84
|
+
declarations << parse_lrama_rule_declaration
|
|
85
|
+
when :INLINE
|
|
86
|
+
# %inline for Lrama extensions
|
|
87
|
+
advance
|
|
88
|
+
declarations << parse_inline_declaration
|
|
89
|
+
else
|
|
90
|
+
advance # Skip unknown declarations for now
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
declarations
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def parse_lrama_rule_declaration
|
|
98
|
+
# %rule followed by rule definition
|
|
99
|
+
# This is similar to parse_rule but in declaration section
|
|
100
|
+
name_token = expect(:IDENTIFIER)
|
|
101
|
+
|
|
102
|
+
parameters = []
|
|
103
|
+
if match?(:LPAREN)
|
|
104
|
+
advance
|
|
105
|
+
parameters = parse_parameter_list
|
|
106
|
+
expect(:RPAREN)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
expect(:COLON)
|
|
110
|
+
|
|
111
|
+
alternatives = []
|
|
112
|
+
alternatives << parse_alternative
|
|
113
|
+
|
|
114
|
+
while match?(:PIPE)
|
|
115
|
+
advance
|
|
116
|
+
alternatives << parse_alternative
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
expect(:SEMICOLON) if match?(:SEMICOLON)
|
|
120
|
+
|
|
121
|
+
AST::ParameterizedRule.new(
|
|
122
|
+
name: name_token.value,
|
|
123
|
+
parameters: parameters,
|
|
124
|
+
alternatives: alternatives,
|
|
125
|
+
location: name_token.location
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse_inline_declaration
|
|
130
|
+
# %inline followed by rule name
|
|
131
|
+
rule_name = expect(:IDENTIFIER).value
|
|
132
|
+
|
|
133
|
+
AST::InlineRule.new(
|
|
134
|
+
rule: rule_name,
|
|
135
|
+
location: current_token.location
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parse_token_declaration
|
|
140
|
+
token = expect(:TOKEN)
|
|
141
|
+
type_tag = nil
|
|
142
|
+
names = []
|
|
143
|
+
|
|
144
|
+
if match?(:TYPE_TAG)
|
|
145
|
+
type_tag = current_token.value
|
|
146
|
+
advance
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
while match?(:IDENTIFIER) || match?(:STRING) || match?(:CHAR)
|
|
150
|
+
names << current_token.value
|
|
151
|
+
advance
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
AST::TokenDeclaration.new(
|
|
155
|
+
names: names,
|
|
156
|
+
type_tag: type_tag,
|
|
157
|
+
location: token.location
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse_type_declaration
|
|
162
|
+
token = expect(:TYPE)
|
|
163
|
+
type_tag = nil
|
|
164
|
+
|
|
165
|
+
if match?(:TYPE_TAG)
|
|
166
|
+
type_tag = current_token.value
|
|
167
|
+
advance
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
names = []
|
|
171
|
+
while match?(:IDENTIFIER)
|
|
172
|
+
names << current_token.value
|
|
173
|
+
advance
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
AST::TypeDeclaration.new(
|
|
177
|
+
type_tag: type_tag,
|
|
178
|
+
names: names,
|
|
179
|
+
location: token.location
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def parse_precedence_declaration
|
|
184
|
+
token = current_token
|
|
185
|
+
associativity = case token.type
|
|
186
|
+
when :LEFT then :left
|
|
187
|
+
when :RIGHT then :right
|
|
188
|
+
when :NONASSOC then :nonassoc
|
|
189
|
+
end
|
|
190
|
+
advance
|
|
191
|
+
|
|
192
|
+
tokens = []
|
|
193
|
+
while match?(:IDENTIFIER) || match?(:STRING) || match?(:CHAR)
|
|
194
|
+
tokens << current_token.value
|
|
195
|
+
advance
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
AST::PrecedenceDeclaration.new(
|
|
199
|
+
associativity: associativity,
|
|
200
|
+
tokens: tokens,
|
|
201
|
+
location: token.location
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def parse_start_declaration
|
|
206
|
+
token = expect(:START)
|
|
207
|
+
symbol = expect(:IDENTIFIER).value
|
|
208
|
+
|
|
209
|
+
AST::StartDeclaration.new(
|
|
210
|
+
symbol: symbol,
|
|
211
|
+
location: token.location
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def parse_union_declaration
|
|
216
|
+
token = expect(:UNION)
|
|
217
|
+
body = +""
|
|
218
|
+
|
|
219
|
+
if match?(:ACTION)
|
|
220
|
+
body = current_token.value
|
|
221
|
+
advance
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
AST::UnionDeclaration.new(
|
|
225
|
+
body: body,
|
|
226
|
+
location: token.location
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def parse_rules
|
|
231
|
+
rules = []
|
|
232
|
+
|
|
233
|
+
until match?(:SECTION_SEPARATOR) || match?(:EOF)
|
|
234
|
+
if match?(:IDENTIFIER)
|
|
235
|
+
rules << parse_rule
|
|
236
|
+
else
|
|
237
|
+
advance
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
rules
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def parse_rule
|
|
245
|
+
name_token = expect(:IDENTIFIER)
|
|
246
|
+
|
|
247
|
+
# Check for parameterized rule: rule_name(param1, param2)
|
|
248
|
+
parameters = []
|
|
249
|
+
if match?(:LPAREN)
|
|
250
|
+
advance
|
|
251
|
+
parameters = parse_parameter_list
|
|
252
|
+
expect(:RPAREN)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
expect(:COLON)
|
|
256
|
+
|
|
257
|
+
alternatives = []
|
|
258
|
+
alternatives << parse_alternative
|
|
259
|
+
|
|
260
|
+
while match?(:PIPE)
|
|
261
|
+
advance
|
|
262
|
+
alternatives << parse_alternative
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
expect(:SEMICOLON) if match?(:SEMICOLON)
|
|
266
|
+
|
|
267
|
+
# Return ParameterizedRule if parameters exist
|
|
268
|
+
if parameters.empty?
|
|
269
|
+
AST::Rule.new(
|
|
270
|
+
name: name_token.value,
|
|
271
|
+
alternatives: alternatives,
|
|
272
|
+
location: name_token.location
|
|
273
|
+
)
|
|
274
|
+
else
|
|
275
|
+
AST::ParameterizedRule.new(
|
|
276
|
+
name: name_token.value,
|
|
277
|
+
parameters: parameters,
|
|
278
|
+
alternatives: alternatives,
|
|
279
|
+
location: name_token.location
|
|
280
|
+
)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def parse_parameter_list
|
|
285
|
+
params = []
|
|
286
|
+
params << expect(:IDENTIFIER).value
|
|
287
|
+
|
|
288
|
+
while match?(:COMMA)
|
|
289
|
+
advance
|
|
290
|
+
params << expect(:IDENTIFIER).value
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
params
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def parse_argument_list
|
|
297
|
+
# Parse arguments for parameterized rule calls
|
|
298
|
+
# Arguments are symbols (terminals or nonterminals)
|
|
299
|
+
args = []
|
|
300
|
+
|
|
301
|
+
if match?(:IDENTIFIER) || match?(:STRING) || match?(:CHAR)
|
|
302
|
+
symbol_token = current_token
|
|
303
|
+
kind = if symbol_token.value.match?(/^[A-Z]/) || match?(:STRING) || match?(:CHAR)
|
|
304
|
+
:terminal
|
|
305
|
+
else
|
|
306
|
+
:nonterminal
|
|
307
|
+
end
|
|
308
|
+
advance
|
|
309
|
+
|
|
310
|
+
args << AST::Symbol.new(
|
|
311
|
+
name: symbol_token.value,
|
|
312
|
+
kind: kind,
|
|
313
|
+
location: symbol_token.location
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
while match?(:COMMA)
|
|
317
|
+
advance
|
|
318
|
+
symbol_token = current_token
|
|
319
|
+
kind = if symbol_token.value.match?(/^[A-Z]/) || match?(:STRING) || match?(:CHAR)
|
|
320
|
+
:terminal
|
|
321
|
+
else
|
|
322
|
+
:nonterminal
|
|
323
|
+
end
|
|
324
|
+
advance
|
|
325
|
+
|
|
326
|
+
args << AST::Symbol.new(
|
|
327
|
+
name: symbol_token.value,
|
|
328
|
+
kind: kind,
|
|
329
|
+
location: symbol_token.location
|
|
330
|
+
)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
args
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def parse_alternative
|
|
338
|
+
symbols = []
|
|
339
|
+
action = nil
|
|
340
|
+
prec = nil
|
|
341
|
+
start_location = current_token.location
|
|
342
|
+
|
|
343
|
+
until match?(:PIPE) || match?(:SEMICOLON) || match?(:ACTION) ||
|
|
344
|
+
match?(:SECTION_SEPARATOR) || match?(:EOF)
|
|
345
|
+
if match?(:PREC)
|
|
346
|
+
advance
|
|
347
|
+
prec = current_token.value
|
|
348
|
+
advance
|
|
349
|
+
elsif match?(:IDENTIFIER) || match?(:STRING) || match?(:CHAR)
|
|
350
|
+
symbol_token = current_token
|
|
351
|
+
kind = if symbol_token.value.match?(/^[A-Z]/) || match?(:STRING) || match?(:CHAR)
|
|
352
|
+
:terminal
|
|
353
|
+
else
|
|
354
|
+
:nonterminal
|
|
355
|
+
end
|
|
356
|
+
advance
|
|
357
|
+
|
|
358
|
+
# Check for named reference: symbol[name] or parameterized call: symbol(args)
|
|
359
|
+
alias_name = nil
|
|
360
|
+
arguments = nil
|
|
361
|
+
|
|
362
|
+
if match?(:LBRACKET)
|
|
363
|
+
advance
|
|
364
|
+
alias_name = expect(:IDENTIFIER).value
|
|
365
|
+
expect(:RBRACKET)
|
|
366
|
+
elsif match?(:LPAREN)
|
|
367
|
+
# Parameterized rule call: list(expr)
|
|
368
|
+
advance
|
|
369
|
+
arguments = parse_argument_list
|
|
370
|
+
expect(:RPAREN)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
symbols << AST::Symbol.new(
|
|
374
|
+
name: symbol_token.value,
|
|
375
|
+
kind: kind,
|
|
376
|
+
alias_name: alias_name,
|
|
377
|
+
arguments: arguments,
|
|
378
|
+
location: symbol_token.location
|
|
379
|
+
)
|
|
380
|
+
else
|
|
381
|
+
break
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
if match?(:ACTION)
|
|
386
|
+
action = AST::Action.new(
|
|
387
|
+
code: current_token.value,
|
|
388
|
+
location: current_token.location
|
|
389
|
+
)
|
|
390
|
+
advance
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
AST::Alternative.new(
|
|
394
|
+
symbols: symbols,
|
|
395
|
+
action: action,
|
|
396
|
+
prec: prec,
|
|
397
|
+
location: symbols.first&.location || start_location
|
|
398
|
+
)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def parse_epilogue
|
|
402
|
+
return nil unless match?(:SECTION_SEPARATOR)
|
|
403
|
+
|
|
404
|
+
advance
|
|
405
|
+
code = +""
|
|
406
|
+
|
|
407
|
+
until match?(:EOF)
|
|
408
|
+
code << current_token.value
|
|
409
|
+
advance
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
AST::Epilogue.new(code: code, location: current_token.location) unless code.empty?
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collie
|
|
4
|
+
module Reporter
|
|
5
|
+
# GitHub Actions format reporter
|
|
6
|
+
class Github
|
|
7
|
+
def report(offenses)
|
|
8
|
+
offenses.map { |o| format_offense(o) }.join("\n")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def format_offense(offense)
|
|
14
|
+
level = github_level(offense.severity)
|
|
15
|
+
file = offense.location.file
|
|
16
|
+
line = offense.location.line
|
|
17
|
+
col = offense.location.column
|
|
18
|
+
message = offense.message.gsub(",", "%2C") # Escape commas
|
|
19
|
+
|
|
20
|
+
"::#{level} file=#{file},line=#{line},col=#{col}::#{message}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def github_level(severity)
|
|
24
|
+
case severity
|
|
25
|
+
when :error
|
|
26
|
+
"error"
|
|
27
|
+
when :warning
|
|
28
|
+
"warning"
|
|
29
|
+
else
|
|
30
|
+
"notice"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Collie
|
|
6
|
+
module Reporter
|
|
7
|
+
# JSON reporter
|
|
8
|
+
class Json
|
|
9
|
+
def report(offenses)
|
|
10
|
+
output = {
|
|
11
|
+
summary: {
|
|
12
|
+
total: offenses.length,
|
|
13
|
+
by_severity: count_by_severity(offenses)
|
|
14
|
+
},
|
|
15
|
+
files: group_by_file(offenses)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
JSON.pretty_generate(output)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def count_by_severity(offenses)
|
|
24
|
+
offenses.group_by(&:severity).transform_values(&:count)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def group_by_file(offenses)
|
|
28
|
+
grouped = offenses.group_by { |o| o.location.file }
|
|
29
|
+
|
|
30
|
+
grouped.map do |file, file_offenses|
|
|
31
|
+
{
|
|
32
|
+
path: file,
|
|
33
|
+
offenses: file_offenses.map { |o| format_offense(o) }
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def format_offense(offense)
|
|
39
|
+
{
|
|
40
|
+
rule: offense.rule.rule_name,
|
|
41
|
+
severity: offense.severity,
|
|
42
|
+
message: offense.message,
|
|
43
|
+
location: {
|
|
44
|
+
line: offense.location.line,
|
|
45
|
+
column: offense.location.column,
|
|
46
|
+
length: offense.location.length
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "pastel"
|
|
5
|
+
PASTEL_AVAILABLE = true
|
|
6
|
+
rescue LoadError
|
|
7
|
+
PASTEL_AVAILABLE = false
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module Collie
|
|
11
|
+
module Reporter
|
|
12
|
+
# Text reporter for terminal output
|
|
13
|
+
class Text
|
|
14
|
+
def initialize(colorize: true)
|
|
15
|
+
@colorize = colorize && PASTEL_AVAILABLE
|
|
16
|
+
@pastel = PASTEL_AVAILABLE ? Pastel.new(enabled: @colorize) : nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def report(offenses)
|
|
20
|
+
return success_message if offenses.empty?
|
|
21
|
+
|
|
22
|
+
grouped = offenses.group_by { |o| o.location.file }
|
|
23
|
+
output = []
|
|
24
|
+
|
|
25
|
+
grouped.each do |file, file_offenses|
|
|
26
|
+
output << ""
|
|
27
|
+
output << (@pastel ? @pastel.bold(file) : file)
|
|
28
|
+
|
|
29
|
+
file_offenses.sort_by { |o| [o.location.line, o.location.column] }.each do |offense|
|
|
30
|
+
output << format_offense(offense)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
output << ""
|
|
35
|
+
output << summary(offenses)
|
|
36
|
+
output.join("\n")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def format_offense(offense)
|
|
42
|
+
location = "#{offense.location.line}:#{offense.location.column}"
|
|
43
|
+
severity = colorize_severity(offense.severity)
|
|
44
|
+
rule = offense.rule.rule_name
|
|
45
|
+
|
|
46
|
+
" #{location}: #{severity}: [#{rule}] #{offense.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def colorize_severity(severity)
|
|
50
|
+
text = severity.to_s
|
|
51
|
+
return text unless @pastel
|
|
52
|
+
|
|
53
|
+
case severity
|
|
54
|
+
when :error
|
|
55
|
+
@pastel.red.bold(text)
|
|
56
|
+
when :warning
|
|
57
|
+
@pastel.yellow.bold(text)
|
|
58
|
+
when :convention
|
|
59
|
+
@pastel.blue(text)
|
|
60
|
+
when :info
|
|
61
|
+
@pastel.cyan(text)
|
|
62
|
+
else
|
|
63
|
+
text
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def success_message
|
|
68
|
+
msg = "✓ No offenses detected"
|
|
69
|
+
@pastel ? @pastel.green(msg) : msg
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def summary(offenses)
|
|
73
|
+
counts = offenses.group_by(&:severity).transform_values(&:count)
|
|
74
|
+
parts = []
|
|
75
|
+
|
|
76
|
+
if counts[:error]
|
|
77
|
+
msg = "#{counts[:error]} error(s)"
|
|
78
|
+
parts << (@pastel ? @pastel.red(msg) : msg)
|
|
79
|
+
end
|
|
80
|
+
if counts[:warning]
|
|
81
|
+
msg = "#{counts[:warning]} warning(s)"
|
|
82
|
+
parts << (@pastel ? @pastel.yellow(msg) : msg)
|
|
83
|
+
end
|
|
84
|
+
if counts[:convention]
|
|
85
|
+
msg = "#{counts[:convention]} convention(s)"
|
|
86
|
+
parts << (@pastel ? @pastel.blue(msg) : msg)
|
|
87
|
+
end
|
|
88
|
+
if counts[:info]
|
|
89
|
+
msg = "#{counts[:info]} info"
|
|
90
|
+
parts << (@pastel ? @pastel.cyan(msg) : msg)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
"#{parts.join(', ')} found"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/collie.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "collie/version"
|
|
4
|
+
require_relative "collie/cli"
|
|
5
|
+
require_relative "collie/config"
|
|
6
|
+
require_relative "collie/ast"
|
|
7
|
+
require_relative "collie/parser/lexer"
|
|
8
|
+
require_relative "collie/parser/parser"
|
|
9
|
+
require_relative "collie/analyzer/symbol_table"
|
|
10
|
+
require_relative "collie/analyzer/reachability"
|
|
11
|
+
require_relative "collie/analyzer/recursion"
|
|
12
|
+
require_relative "collie/analyzer/conflict"
|
|
13
|
+
require_relative "collie/linter/base"
|
|
14
|
+
require_relative "collie/linter/registry"
|
|
15
|
+
require_relative "collie/formatter/formatter"
|
|
16
|
+
require_relative "collie/formatter/options"
|
|
17
|
+
require_relative "collie/reporter/text"
|
|
18
|
+
require_relative "collie/reporter/json"
|
|
19
|
+
require_relative "collie/reporter/github"
|
|
20
|
+
|
|
21
|
+
# Collie is a linter and formatter for Lrama Style BNF grammar files (.y files).
|
|
22
|
+
#
|
|
23
|
+
# @example Basic usage
|
|
24
|
+
# require 'collie'
|
|
25
|
+
#
|
|
26
|
+
# # Parse a grammar file
|
|
27
|
+
# parser = Collie::Parser::Parser.new(File.read('grammar.y'))
|
|
28
|
+
# ast = parser.parse
|
|
29
|
+
#
|
|
30
|
+
# # Run linter
|
|
31
|
+
# config = Collie::Config.new
|
|
32
|
+
# linter = Collie::Linter.new(config)
|
|
33
|
+
# offenses = linter.lint(ast)
|
|
34
|
+
#
|
|
35
|
+
# # Format the grammar
|
|
36
|
+
# formatter = Collie::Formatter::Formatter.new(config.formatter_options)
|
|
37
|
+
# puts formatter.format(ast)
|
|
38
|
+
#
|
|
39
|
+
# @see https://github.com/ruby/lrama Lrama parser generator
|
|
40
|
+
module Collie
|
|
41
|
+
# Base error class for all Collie errors
|
|
42
|
+
class Error < StandardError; end
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# Returns the root directory of the Collie gem
|
|
46
|
+
#
|
|
47
|
+
# @return [String] absolute path to the gem root directory
|
|
48
|
+
def root
|
|
49
|
+
File.expand_path("..", __dir__)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|