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
data/lib/collie/ast.rb ADDED
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ module AST
5
+ # Location information for source code elements
6
+ class Location
7
+ attr_accessor :file, :line, :column, :length
8
+
9
+ def initialize(file:, line:, column:, length: 0)
10
+ @file = file
11
+ @line = line
12
+ @column = column
13
+ @length = length
14
+ end
15
+
16
+ def to_s
17
+ "#{file}:#{line}:#{column}"
18
+ end
19
+ end
20
+
21
+ # Root node representing the entire grammar file
22
+ class GrammarFile
23
+ attr_accessor :prologue, :declarations, :rules, :epilogue, :location
24
+
25
+ def initialize(prologue: nil, declarations: [], rules: [], epilogue: nil, location: nil)
26
+ @prologue = prologue
27
+ @declarations = declarations
28
+ @rules = rules
29
+ @epilogue = epilogue
30
+ @location = location
31
+ end
32
+ end
33
+
34
+ # Token declaration node (%token)
35
+ class TokenDeclaration
36
+ attr_accessor :names, :type_tag, :location
37
+
38
+ def initialize(names:, type_tag: nil, location: nil)
39
+ @names = names
40
+ @type_tag = type_tag
41
+ @location = location
42
+ end
43
+ end
44
+
45
+ # Type declaration node (%type)
46
+ class TypeDeclaration
47
+ attr_accessor :type_tag, :names, :location
48
+
49
+ def initialize(type_tag:, names:, location: nil)
50
+ @type_tag = type_tag
51
+ @names = names
52
+ @location = location
53
+ end
54
+ end
55
+
56
+ # Precedence declaration node (%left, %right, %nonassoc)
57
+ class PrecedenceDeclaration
58
+ attr_accessor :associativity, :tokens, :location
59
+
60
+ def initialize(associativity:, tokens:, location: nil)
61
+ @associativity = associativity # :left, :right, :nonassoc
62
+ @tokens = tokens
63
+ @location = location
64
+ end
65
+ end
66
+
67
+ # Start symbol declaration node (%start)
68
+ class StartDeclaration
69
+ attr_accessor :symbol, :location
70
+
71
+ def initialize(symbol:, location: nil)
72
+ @symbol = symbol
73
+ @location = location
74
+ end
75
+ end
76
+
77
+ # Union declaration node (%union)
78
+ class UnionDeclaration
79
+ attr_accessor :body, :location
80
+
81
+ def initialize(body:, location: nil)
82
+ @body = body
83
+ @location = location
84
+ end
85
+ end
86
+
87
+ # Grammar rule node
88
+ class Rule
89
+ attr_accessor :name, :alternatives, :location
90
+
91
+ def initialize(name:, alternatives: [], location: nil)
92
+ @name = name
93
+ @alternatives = alternatives
94
+ @location = location
95
+ end
96
+ end
97
+
98
+ # Alternative production for a rule
99
+ class Alternative
100
+ attr_accessor :symbols, :action, :prec, :location
101
+
102
+ def initialize(symbols: [], action: nil, prec: nil, location: nil)
103
+ @symbols = symbols
104
+ @action = action
105
+ @prec = prec
106
+ @location = location
107
+ end
108
+ end
109
+
110
+ # Symbol reference (terminal or nonterminal)
111
+ class Symbol
112
+ attr_accessor :name, :kind, :alias_name, :arguments, :location
113
+
114
+ def initialize(name:, kind:, alias_name: nil, arguments: nil, location: nil)
115
+ @name = name
116
+ @kind = kind # :terminal, :nonterminal
117
+ @alias_name = alias_name
118
+ @arguments = arguments # For parameterized rule calls like list(expr)
119
+ @location = location
120
+ end
121
+
122
+ def terminal?
123
+ kind == :terminal
124
+ end
125
+
126
+ def nonterminal?
127
+ kind == :nonterminal
128
+ end
129
+ end
130
+
131
+ # Action code block
132
+ class Action
133
+ attr_accessor :code, :location
134
+
135
+ def initialize(code:, location: nil)
136
+ @code = code
137
+ @location = location
138
+ end
139
+ end
140
+
141
+ # Lrama extension: Parameterized rule
142
+ class ParameterizedRule
143
+ attr_accessor :name, :parameters, :alternatives, :location
144
+
145
+ def initialize(name:, parameters:, alternatives:, location: nil)
146
+ @name = name
147
+ @parameters = parameters
148
+ @alternatives = alternatives
149
+ @location = location
150
+ end
151
+ end
152
+
153
+ # Lrama extension: Inline rule
154
+ class InlineRule
155
+ attr_accessor :rule, :location
156
+
157
+ def initialize(rule:, location: nil)
158
+ @rule = rule
159
+ @location = location
160
+ end
161
+ end
162
+
163
+ # Prologue section (code before first %%)
164
+ class Prologue
165
+ attr_accessor :code, :location
166
+
167
+ def initialize(code:, location: nil)
168
+ @code = code
169
+ @location = location
170
+ end
171
+ end
172
+
173
+ # Epilogue section (code after second %%)
174
+ class Epilogue
175
+ attr_accessor :code, :location
176
+
177
+ def initialize(code:, location: nil)
178
+ @code = code
179
+ @location = location
180
+ end
181
+ end
182
+ end
183
+ end
data/lib/collie/cli.rb ADDED
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Collie
6
+ # Command-line interface
7
+ class CLI < Thor
8
+ def self.exit_on_failure?
9
+ true
10
+ end
11
+
12
+ map %w[--version -v] => :version
13
+
14
+ desc "lint FILES", "Lint grammar files"
15
+ option :config, type: :string, desc: "Config file path"
16
+ option :format, type: :string, default: "text", enum: %w[text json github], desc: "Output format"
17
+ option :autocorrect, type: :boolean, aliases: "-a", desc: "Auto-fix offenses"
18
+ option :only, type: :array, desc: "Run only specified rules"
19
+ option :except, type: :array, desc: "Exclude specified rules"
20
+ def lint(*files)
21
+ if files.empty?
22
+ say "No files specified", :red
23
+ exit 1
24
+ end
25
+
26
+ config = Config.new(options[:config])
27
+ Linter::Registry.load_rules
28
+
29
+ all_offenses = []
30
+
31
+ files.each do |file|
32
+ unless File.exist?(file)
33
+ say "File not found: #{file}", :red
34
+ next
35
+ end
36
+
37
+ offenses = lint_file(file, config)
38
+ all_offenses.concat(offenses)
39
+ end
40
+
41
+ reporter = create_reporter(options[:format])
42
+ puts reporter.report(all_offenses)
43
+
44
+ exit 1 if all_offenses.any? { |o| o.severity == :error }
45
+ end
46
+
47
+ desc "fmt FILES", "Format grammar files"
48
+ option :check, type: :boolean, desc: "Check only, don't modify"
49
+ option :diff, type: :boolean, desc: "Show diff"
50
+ option :config, type: :string, desc: "Config file path"
51
+ def fmt(*files)
52
+ if files.empty?
53
+ say "No files specified", :red
54
+ exit 1
55
+ end
56
+
57
+ config = Config.new(options[:config])
58
+ formatter = Formatter::Formatter.new(Formatter::Options.new(config.formatter_options))
59
+
60
+ files.each do |file|
61
+ unless File.exist?(file)
62
+ say "File not found: #{file}", :red
63
+ next
64
+ end
65
+
66
+ format_file(file, formatter, check: options[:check], diff: options[:diff])
67
+ end
68
+ end
69
+
70
+ desc "rules", "List all available rules"
71
+ option :format, type: :string, default: "text", enum: %w[text json]
72
+ def rules
73
+ Linter::Registry.load_rules
74
+
75
+ if options[:format] == "json"
76
+ output = Linter::Registry.all.map do |rule|
77
+ {
78
+ name: rule.rule_name,
79
+ description: rule.description,
80
+ severity: rule.severity,
81
+ autocorrectable: rule.autocorrectable
82
+ }
83
+ end
84
+ puts JSON.pretty_generate(output)
85
+ else
86
+ say "Available lint rules:", :bold
87
+ Linter::Registry.all.each do |rule|
88
+ severity_color = severity_color(rule.severity)
89
+ autocorrect = rule.autocorrectable ? " [autocorrectable]" : ""
90
+ say " #{rule.rule_name} (#{set_color(rule.severity, severity_color)})#{autocorrect}"
91
+ say " #{rule.description}", :dim
92
+ end
93
+ end
94
+ end
95
+
96
+ desc "init", "Generate default .collie.yml"
97
+ def init
98
+ return if File.exist?(".collie.yml") && !yes?(".collie.yml already exists. Overwrite? (y/n)")
99
+
100
+ Config.generate_default
101
+ say "Generated .collie.yml", :green
102
+ end
103
+
104
+ desc "version", "Show version"
105
+ def version
106
+ puts "Collie version #{Collie::VERSION}"
107
+ end
108
+
109
+ private
110
+
111
+ def lint_file(file, config)
112
+ source = File.read(file)
113
+ lexer = Parser::Lexer.new(source, filename: file)
114
+ tokens = lexer.tokenize
115
+ parser = Parser::Parser.new(tokens)
116
+ ast = parser.parse
117
+
118
+ symbol_table = build_symbol_table(ast)
119
+ context = { symbol_table: symbol_table, source: source, file: file }
120
+
121
+ offenses = run_lint_rules(ast, context, config)
122
+ apply_autocorrect(file, source, context, offenses) if options[:autocorrect]
123
+
124
+ offenses
125
+ rescue Error => e
126
+ say "Error parsing #{file}: #{e.message}", :red
127
+ []
128
+ end
129
+
130
+ def build_symbol_table(ast)
131
+ symbol_table = Analyzer::SymbolTable.new
132
+ ast.declarations.each do |decl|
133
+ case decl
134
+ when AST::TokenDeclaration
135
+ decl.names.each do |name|
136
+ symbol_table.add_token(name, type_tag: decl.type_tag, location: decl.location)
137
+ rescue Error
138
+ # Ignore duplicate declarations here, they'll be caught by lint rules
139
+ end
140
+ when AST::ParameterizedRule
141
+ symbol_table.add_nonterminal(decl.name, location: decl.location)
142
+ end
143
+ end
144
+
145
+ ast.rules.each do |rule|
146
+ symbol_table.add_nonterminal(rule.name, location: rule.location)
147
+ end
148
+
149
+ symbol_table
150
+ end
151
+
152
+ def run_lint_rules(ast, context, config)
153
+ enabled_rules = Linter::Registry.enabled_rules(config)
154
+ enabled_rules = filter_rules(enabled_rules) if options[:only] || options[:except]
155
+
156
+ offenses = []
157
+ enabled_rules.each do |rule_class|
158
+ rule = rule_class.new(config.rule_config(rule_class.rule_name))
159
+ offenses.concat(rule.check(ast, context))
160
+ end
161
+
162
+ offenses
163
+ end
164
+
165
+ def apply_autocorrect(file, source, context, offenses)
166
+ autocorrectable_offenses = offenses.select(&:autocorrectable?)
167
+ return if autocorrectable_offenses.empty?
168
+
169
+ autocorrectable_offenses.each do |offense|
170
+ offense.autocorrect&.call
171
+ end
172
+
173
+ return unless context[:source] != source
174
+
175
+ File.write(file, context[:source])
176
+ say "Auto-corrected #{autocorrectable_offenses.size} offense(s) in #{file}", :green
177
+ end
178
+
179
+ def format_file(file, formatter, check: false, diff: false)
180
+ source = File.read(file)
181
+ lexer = Parser::Lexer.new(source, filename: file)
182
+ tokens = lexer.tokenize
183
+ parser = Parser::Parser.new(tokens)
184
+ ast = parser.parse
185
+
186
+ formatted = formatter.format(ast)
187
+
188
+ if check
189
+ if source == formatted
190
+ say "#{file}: OK", :green
191
+ else
192
+ say "#{file}: needs formatting", :yellow
193
+ show_diff(source, formatted) if diff
194
+ end
195
+ else
196
+ File.write(file, formatted)
197
+ say "Formatted #{file}", :green
198
+ end
199
+ rescue Error => e
200
+ say "Error formatting #{file}: #{e.message}", :red
201
+ end
202
+
203
+ def filter_rules(rules)
204
+ filtered = rules
205
+
206
+ filtered = filtered.select { |r| options[:only].include?(r.rule_name) } if options[:only]
207
+
208
+ filtered = filtered.reject { |r| options[:except].include?(r.rule_name) } if options[:except]
209
+
210
+ filtered
211
+ end
212
+
213
+ def create_reporter(format)
214
+ case format
215
+ when "json"
216
+ Reporter::Json.new
217
+ when "github"
218
+ Reporter::Github.new
219
+ else
220
+ Reporter::Text.new
221
+ end
222
+ end
223
+
224
+ def severity_color(severity)
225
+ case severity
226
+ when :error then :red
227
+ when :warning then :yellow
228
+ when :convention then :blue
229
+ when :info then :cyan
230
+ else :white
231
+ end
232
+ end
233
+
234
+ def show_diff(original, formatted)
235
+ require "tempfile"
236
+
237
+ Tempfile.create(["original", ".y"]) do |orig|
238
+ Tempfile.create(["formatted", ".y"]) do |fmt|
239
+ orig.write(original)
240
+ orig.flush
241
+ fmt.write(formatted)
242
+ fmt.flush
243
+
244
+ system("diff", "-u", orig.path, fmt.path)
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Collie
6
+ # Configuration management
7
+ class Config
8
+ DEFAULT_CONFIG = {
9
+ "rules" => {},
10
+ "formatter" => {
11
+ "indent_size" => 2,
12
+ "align_tokens" => true,
13
+ "align_alternatives" => true,
14
+ "blank_lines_around_sections" => 1,
15
+ "max_line_length" => 120
16
+ },
17
+ "include" => ["**/*.y"],
18
+ "exclude" => ["vendor/**/*", "tmp/**/*"]
19
+ }.freeze
20
+
21
+ attr_reader :config
22
+
23
+ def initialize(config_path = nil)
24
+ @config = load_config(config_path)
25
+ end
26
+
27
+ def rule_enabled?(rule_name)
28
+ rule_config = @config.dig("rules", rule_name)
29
+ return true if rule_config.nil? # Enabled by default
30
+
31
+ rule_config.is_a?(Hash) ? rule_config.fetch("enabled", true) : rule_config
32
+ end
33
+
34
+ def rule_config(rule_name)
35
+ @config.dig("rules", rule_name) || {}
36
+ end
37
+
38
+ def formatter_options
39
+ @config["formatter"] || DEFAULT_CONFIG["formatter"]
40
+ end
41
+
42
+ def included_patterns
43
+ @config["include"] || DEFAULT_CONFIG["include"]
44
+ end
45
+
46
+ def excluded_patterns
47
+ @config["exclude"] || DEFAULT_CONFIG["exclude"]
48
+ end
49
+
50
+ def self.default
51
+ new
52
+ end
53
+
54
+ def self.generate_default(path = ".collie.yml")
55
+ File.write(path, DEFAULT_CONFIG.to_yaml)
56
+ end
57
+
58
+ private
59
+
60
+ def load_config(config_path)
61
+ config = DEFAULT_CONFIG.dup
62
+
63
+ if config_path && File.exist?(config_path)
64
+ user_config = YAML.load_file(config_path)
65
+ config = deep_merge(config, user_config)
66
+
67
+ # Handle inheritance
68
+ if user_config["inherit_from"]
69
+ parent_path = File.expand_path(user_config["inherit_from"], File.dirname(config_path))
70
+ parent_config = YAML.load_file(parent_path) if File.exist?(parent_path)
71
+ config = deep_merge(parent_config, config) if parent_config
72
+ end
73
+ elsif File.exist?(".collie.yml")
74
+ user_config = YAML.load_file(".collie.yml")
75
+ config = deep_merge(config, user_config)
76
+ end
77
+
78
+ config
79
+ end
80
+
81
+ def deep_merge(hash1, hash2)
82
+ hash1.merge(hash2) do |_key, old_val, new_val|
83
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
84
+ deep_merge(old_val, new_val)
85
+ else
86
+ new_val
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ module Formatter
5
+ # Formatter for .y grammar files
6
+ class Formatter
7
+ def initialize(options = Options.new)
8
+ @options = options
9
+ end
10
+
11
+ def format(ast)
12
+ output = []
13
+
14
+ # Prologue
15
+ output << format_prologue(ast.prologue) if ast.prologue
16
+
17
+ # Declarations
18
+ output << format_declarations(ast.declarations) unless ast.declarations.empty?
19
+
20
+ # Section separator
21
+ output << ""
22
+ output << "%%"
23
+ output << ""
24
+
25
+ # Rules
26
+ output << format_rules(ast.rules)
27
+
28
+ # Epilogue
29
+ if ast.epilogue
30
+ output << ""
31
+ output << "%%"
32
+ output << ""
33
+ output << ast.epilogue.code
34
+ end
35
+
36
+ output.join("\n")
37
+ end
38
+
39
+ private
40
+
41
+ def format_prologue(prologue)
42
+ ["%{", prologue.code, "%}"].join("\n")
43
+ end
44
+
45
+ def format_declarations(declarations)
46
+ grouped = declarations.group_by(&:class)
47
+ output = []
48
+
49
+ # Format token declarations
50
+ if grouped[AST::TokenDeclaration]
51
+ output << format_token_declarations(grouped[AST::TokenDeclaration])
52
+ output << ""
53
+ end
54
+
55
+ # Format type declarations
56
+ if grouped[AST::TypeDeclaration]
57
+ output << format_type_declarations(grouped[AST::TypeDeclaration])
58
+ output << ""
59
+ end
60
+
61
+ # Format precedence declarations
62
+ if grouped[AST::PrecedenceDeclaration]
63
+ output << format_precedence_declarations(grouped[AST::PrecedenceDeclaration])
64
+ output << ""
65
+ end
66
+
67
+ # Format start declaration
68
+ if grouped[AST::StartDeclaration]
69
+ start_decl = grouped[AST::StartDeclaration].first
70
+ output << "%start #{start_decl.symbol}"
71
+ output << ""
72
+ end
73
+
74
+ # Format %rule declarations (Lrama extension)
75
+ if grouped[AST::ParameterizedRule]
76
+ output << format_parameterized_rule_declarations(grouped[AST::ParameterizedRule])
77
+ output << ""
78
+ end
79
+
80
+ # Format %inline declarations (Lrama extension)
81
+ if grouped[AST::InlineRule]
82
+ output << format_inline_rule_declarations(grouped[AST::InlineRule])
83
+ output << ""
84
+ end
85
+
86
+ output.join("\n")
87
+ end
88
+
89
+ def format_token_declarations(declarations)
90
+ if @options.align_tokens
91
+ format_aligned_tokens(declarations)
92
+ else
93
+ declarations.map { |decl| format_token_declaration(decl) }.join("\n")
94
+ end
95
+ end
96
+
97
+ def format_aligned_tokens(declarations)
98
+ max_tag_length = declarations.map { |d| d.type_tag ? d.type_tag.length + 2 : 0 }.max || 0
99
+ declarations.map do |decl|
100
+ tag = decl.type_tag ? "<#{decl.type_tag}>" : ""
101
+ "%token #{tag.ljust(max_tag_length)} #{decl.names.join(' ')}"
102
+ end.join("\n")
103
+ end
104
+
105
+ def format_token_declaration(decl)
106
+ tag = decl.type_tag ? " <#{decl.type_tag}>" : ""
107
+ "%token#{tag} #{decl.names.join(' ')}"
108
+ end
109
+
110
+ def format_type_declarations(declarations)
111
+ declarations.map do |decl|
112
+ tag = decl.type_tag ? " <#{decl.type_tag}>" : ""
113
+ "%type#{tag} #{decl.names.join(' ')}"
114
+ end.join("\n")
115
+ end
116
+
117
+ def format_precedence_declarations(declarations)
118
+ directive_names = {
119
+ left: "%left",
120
+ right: "%right",
121
+ nonassoc: "%nonassoc"
122
+ }
123
+
124
+ max_directive_length = directive_names.values.map(&:length).max
125
+
126
+ declarations.map do |decl|
127
+ directive = directive_names[decl.associativity]
128
+ if @options.align_tokens
129
+ "#{directive.ljust(max_directive_length)} #{decl.tokens.join(' ')}"
130
+ else
131
+ "#{directive} #{decl.tokens.join(' ')}"
132
+ end
133
+ end.join("\n")
134
+ end
135
+
136
+ def format_parameterized_rule_declarations(declarations)
137
+ declarations.map do |decl|
138
+ params = "(#{decl.parameters.join(', ')})"
139
+ alternatives = decl.alternatives.map { |alt| format_alternative(alt) }.join(" | ")
140
+ "%rule #{decl.name}#{params}: #{alternatives} ;"
141
+ end.join("\n")
142
+ end
143
+
144
+ def format_inline_rule_declarations(declarations)
145
+ declarations.map do |decl|
146
+ "%inline #{decl.rule}"
147
+ end.join("\n")
148
+ end
149
+
150
+ def format_rules(rules)
151
+ rules.map { |rule| format_rule(rule) }.join("\n\n")
152
+ end
153
+
154
+ def format_rule(rule)
155
+ # Handle parameterized rules: rule_name(X, Y)
156
+ rule_header = rule.name.to_s
157
+ if rule.is_a?(AST::ParameterizedRule) && rule.parameters && !rule.parameters.empty?
158
+ rule_header += "(#{rule.parameters.join(', ')})"
159
+ end
160
+
161
+ output = [rule_header]
162
+
163
+ rule.alternatives.each_with_index do |alt, index|
164
+ prefix = index.zero? ? " :" : " |"
165
+ output << "#{prefix} #{format_alternative(alt)}"
166
+ end
167
+
168
+ output << " ;"
169
+ output.join("\n")
170
+ end
171
+
172
+ def format_alternative(alt)
173
+ symbols_str = alt.symbols.map { |sym| format_symbol(sym) }.join(" ")
174
+ action_str = alt.action ? " #{alt.action.code}" : ""
175
+ prec_str = alt.prec ? " %prec #{alt.prec}" : ""
176
+
177
+ "#{symbols_str}#{prec_str}#{action_str}"
178
+ end
179
+
180
+ def format_symbol(symbol)
181
+ result = symbol.name
182
+
183
+ # Add named reference: symbol[name]
184
+ result += "[#{symbol.alias_name}]" if symbol.alias_name
185
+
186
+ # Add parameterized call arguments: symbol(arg1, arg2)
187
+ if symbol.arguments && !symbol.arguments.empty?
188
+ args_str = symbol.arguments.map { |arg| format_symbol(arg) }.join(", ")
189
+ result += "(#{args_str})"
190
+ end
191
+
192
+ result
193
+ end
194
+ end
195
+ end
196
+ end