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
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
|