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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/Gemfile +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +333 -0
  6. data/Rakefile +9 -0
  7. data/collie.gemspec +37 -0
  8. data/docs/TUTORIAL.md +588 -0
  9. data/docs/index.html +56 -0
  10. data/docs/playground/README.md +134 -0
  11. data/docs/playground/build-collie-bundle.rb +85 -0
  12. data/docs/playground/css/styles.css +402 -0
  13. data/docs/playground/index.html +146 -0
  14. data/docs/playground/js/app.js +231 -0
  15. data/docs/playground/js/collie-bridge.js +186 -0
  16. data/docs/playground/js/editor.js +129 -0
  17. data/docs/playground/js/examples.js +80 -0
  18. data/docs/playground/js/ruby-runner.js +75 -0
  19. data/docs/playground/test-server.sh +18 -0
  20. data/exe/collie +15 -0
  21. data/lib/collie/analyzer/conflict.rb +114 -0
  22. data/lib/collie/analyzer/reachability.rb +83 -0
  23. data/lib/collie/analyzer/recursion.rb +96 -0
  24. data/lib/collie/analyzer/symbol_table.rb +67 -0
  25. data/lib/collie/ast.rb +183 -0
  26. data/lib/collie/cli.rb +249 -0
  27. data/lib/collie/config.rb +91 -0
  28. data/lib/collie/formatter/formatter.rb +196 -0
  29. data/lib/collie/formatter/options.rb +23 -0
  30. data/lib/collie/linter/base.rb +62 -0
  31. data/lib/collie/linter/registry.rb +34 -0
  32. data/lib/collie/linter/rules/ambiguous_precedence.rb +87 -0
  33. data/lib/collie/linter/rules/circular_reference.rb +89 -0
  34. data/lib/collie/linter/rules/consistent_tag_naming.rb +69 -0
  35. data/lib/collie/linter/rules/duplicate_token.rb +38 -0
  36. data/lib/collie/linter/rules/empty_action.rb +52 -0
  37. data/lib/collie/linter/rules/factorizable_rules.rb +67 -0
  38. data/lib/collie/linter/rules/left_recursion.rb +34 -0
  39. data/lib/collie/linter/rules/long_rule.rb +37 -0
  40. data/lib/collie/linter/rules/missing_start_symbol.rb +38 -0
  41. data/lib/collie/linter/rules/nonterminal_naming.rb +34 -0
  42. data/lib/collie/linter/rules/prec_improvement.rb +54 -0
  43. data/lib/collie/linter/rules/redundant_epsilon.rb +44 -0
  44. data/lib/collie/linter/rules/right_recursion.rb +35 -0
  45. data/lib/collie/linter/rules/token_naming.rb +39 -0
  46. data/lib/collie/linter/rules/trailing_whitespace.rb +46 -0
  47. data/lib/collie/linter/rules/undefined_symbol.rb +55 -0
  48. data/lib/collie/linter/rules/unreachable_rule.rb +49 -0
  49. data/lib/collie/linter/rules/unused_nonterminal.rb +93 -0
  50. data/lib/collie/linter/rules/unused_token.rb +82 -0
  51. data/lib/collie/parser/lexer.rb +349 -0
  52. data/lib/collie/parser/parser.rb +416 -0
  53. data/lib/collie/reporter/github.rb +35 -0
  54. data/lib/collie/reporter/json.rb +52 -0
  55. data/lib/collie/reporter/text.rb +97 -0
  56. data/lib/collie/version.rb +5 -0
  57. data/lib/collie.rb +52 -0
  58. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ VERSION = "0.1.0"
5
+ 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