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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/README.md +55 -258
  4. data/lib/collie/analyzer/reachability.rb +17 -20
  5. data/lib/collie/analyzer/recursion.rb +28 -9
  6. data/lib/collie/analyzer/symbol_resolver.rb +51 -0
  7. data/lib/collie/ast.rb +18 -4
  8. data/lib/collie/cli.rb +388 -50
  9. data/lib/collie/config/schema.rb +117 -0
  10. data/lib/collie/config.rb +106 -22
  11. data/lib/collie/formatter/formatter.rb +95 -50
  12. data/lib/collie/formatter/options.rb +17 -5
  13. data/lib/collie/formatter/signature.rb +72 -0
  14. data/lib/collie/linter/base.rb +49 -0
  15. data/lib/collie/linter/rules/ambiguous_precedence.rb +5 -2
  16. data/lib/collie/linter/rules/circular_reference.rb +96 -38
  17. data/lib/collie/linter/rules/consistent_tag_naming.rb +13 -13
  18. data/lib/collie/linter/rules/empty_action.rb +42 -11
  19. data/lib/collie/linter/rules/factorizable_rules.rb +2 -2
  20. data/lib/collie/linter/rules/left_recursion.rb +5 -4
  21. data/lib/collie/linter/rules/long_rule.rb +3 -3
  22. data/lib/collie/linter/rules/nonterminal_naming.rb +6 -4
  23. data/lib/collie/linter/rules/prec_improvement.rb +1 -1
  24. data/lib/collie/linter/rules/redundant_epsilon.rb +11 -11
  25. data/lib/collie/linter/rules/right_recursion.rb +4 -1
  26. data/lib/collie/linter/rules/symbol_conflict.rb +130 -0
  27. data/lib/collie/linter/rules/token_naming.rb +2 -1
  28. data/lib/collie/linter/rules/trailing_whitespace.rb +7 -1
  29. data/lib/collie/linter/rules/undefined_symbol.rb +50 -8
  30. data/lib/collie/linter/rules/unused_nonterminal.rb +36 -1
  31. data/lib/collie/linter/rules/unused_token.rb +34 -9
  32. data/lib/collie/parser/debug_serializer.rb +205 -0
  33. data/lib/collie/parser/lexer.rb +182 -11
  34. data/lib/collie/parser/parser.rb +73 -13
  35. data/lib/collie/reporter/github.rb +15 -2
  36. data/lib/collie/reporter/json.rb +4 -1
  37. data/lib/collie/reporter/sarif.rb +81 -0
  38. data/lib/collie/version.rb +1 -1
  39. data/lib/collie.rb +6 -1
  40. metadata +8 -2
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ class Config
5
+ # JSON Schema for .collie.yml files.
6
+ module Schema
7
+ SEVERITIES = %w[error warning convention info].freeze
8
+
9
+ SEVERITY_SCHEMA = {
10
+ "type" => "string",
11
+ "enum" => SEVERITIES
12
+ }.freeze
13
+
14
+ FORMATTER_PROPERTIES = {
15
+ "indent_size" => {
16
+ "type" => "integer",
17
+ "minimum" => 1
18
+ },
19
+ "align_tokens" => {
20
+ "type" => "boolean"
21
+ },
22
+ "align_alternatives" => {
23
+ "type" => "boolean"
24
+ },
25
+ "blank_lines_around_sections" => {
26
+ "type" => "integer",
27
+ "minimum" => 0
28
+ },
29
+ "max_line_length" => {
30
+ "type" => "integer",
31
+ "minimum" => 1
32
+ }
33
+ }.freeze
34
+
35
+ class << self
36
+ def to_h
37
+ {
38
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
39
+ "title" => "Collie configuration",
40
+ "type" => "object",
41
+ "additionalProperties" => false,
42
+ "properties" => properties,
43
+ "$defs" => definitions
44
+ }
45
+ end
46
+
47
+ private
48
+
49
+ def properties
50
+ {
51
+ "inherit_from" => {
52
+ "type" => "string"
53
+ },
54
+ "include" => string_array_schema,
55
+ "exclude" => string_array_schema,
56
+ "formatter" => formatter_schema,
57
+ "rules" => rules_schema
58
+ }
59
+ end
60
+
61
+ def formatter_schema
62
+ {
63
+ "type" => "object",
64
+ "additionalProperties" => false,
65
+ "properties" => FORMATTER_PROPERTIES
66
+ }
67
+ end
68
+
69
+ def rules_schema
70
+ {
71
+ "type" => "object",
72
+ "additionalProperties" => { "$ref" => "#/$defs/ruleConfig" },
73
+ "propertyNames" => {
74
+ "pattern" => "^[A-Za-z][A-Za-z0-9_]*$"
75
+ }
76
+ }
77
+ end
78
+
79
+ def definitions
80
+ {
81
+ "severity" => SEVERITY_SCHEMA,
82
+ "ruleConfig" => {
83
+ "oneOf" => [
84
+ { "type" => "boolean" },
85
+ rule_object_schema
86
+ ]
87
+ }
88
+ }
89
+ end
90
+
91
+ def rule_object_schema
92
+ {
93
+ "type" => "object",
94
+ "additionalProperties" => true,
95
+ "properties" => {
96
+ "enabled" => { "type" => "boolean" },
97
+ "severity" => { "$ref" => "#/$defs/severity" },
98
+ "pattern" => { "type" => "string" },
99
+ "max_alternatives" => {
100
+ "type" => "integer",
101
+ "minimum" => 1
102
+ }
103
+ }
104
+ }
105
+ end
106
+
107
+ def string_array_schema
108
+ {
109
+ "type" => "array",
110
+ "items" => { "type" => "string" },
111
+ "uniqueItems" => true
112
+ }
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
data/lib/collie/config.rb CHANGED
@@ -18,6 +18,62 @@ module Collie
18
18
  "exclude" => ["vendor/**/*", "tmp/**/*"]
19
19
  }.freeze
20
20
 
21
+ PROFILE_OVERRIDES = {
22
+ "default" => {},
23
+ "lrama" => {
24
+ "rules" => {
25
+ "LeftRecursion" => { "enabled" => false },
26
+ "FactorizableRules" => { "enabled" => false },
27
+ "RightRecursion" => { "severity" => "warning" }
28
+ }
29
+ },
30
+ "bison" => {
31
+ "rules" => {
32
+ "LeftRecursion" => { "enabled" => false },
33
+ "FactorizableRules" => { "enabled" => false },
34
+ "RightRecursion" => { "severity" => "warning" }
35
+ }
36
+ },
37
+ "strict" => {
38
+ "formatter" => {
39
+ "max_line_length" => 100
40
+ },
41
+ "rules" => {
42
+ "AmbiguousPrecedence" => { "severity" => "warning" },
43
+ "ConsistentTagNaming" => { "severity" => "warning" },
44
+ "EmptyAction" => { "severity" => "warning" },
45
+ "FactorizableRules" => { "severity" => "warning" },
46
+ "LongRule" => { "severity" => "warning" },
47
+ "NonterminalNaming" => { "severity" => "warning" },
48
+ "PrecImprovement" => { "severity" => "warning" },
49
+ "RedundantEpsilon" => { "severity" => "warning" },
50
+ "TokenNaming" => { "severity" => "warning" },
51
+ "TrailingWhitespace" => { "severity" => "warning" }
52
+ }
53
+ },
54
+ "minimal" => {
55
+ "rules" => {
56
+ "AmbiguousPrecedence" => { "enabled" => false },
57
+ "ConsistentTagNaming" => { "enabled" => false },
58
+ "EmptyAction" => { "enabled" => false },
59
+ "FactorizableRules" => { "enabled" => false },
60
+ "LeftRecursion" => { "enabled" => false },
61
+ "LongRule" => { "enabled" => false },
62
+ "NonterminalNaming" => { "enabled" => false },
63
+ "PrecImprovement" => { "enabled" => false },
64
+ "RedundantEpsilon" => { "enabled" => false },
65
+ "RightRecursion" => { "enabled" => false },
66
+ "TokenNaming" => { "enabled" => false },
67
+ "TrailingWhitespace" => { "enabled" => false },
68
+ "UnreachableRule" => { "enabled" => false },
69
+ "UnusedNonterminal" => { "enabled" => false },
70
+ "UnusedToken" => { "enabled" => false }
71
+ }
72
+ }
73
+ }.freeze
74
+
75
+ PROFILE_NAMES = PROFILE_OVERRIDES.keys.freeze
76
+
21
77
  attr_reader :config
22
78
 
23
79
  def initialize(config_path = nil)
@@ -51,34 +107,22 @@ module Collie
51
107
  new
52
108
  end
53
109
 
54
- def self.generate_default(path = ".collie.yml")
55
- File.write(path, DEFAULT_CONFIG.to_yaml)
110
+ def self.generate_default(path = ".collie.yml", profile: "default")
111
+ File.write(path, profile_config(profile).to_yaml)
56
112
  end
57
113
 
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)
114
+ def self.profile_config(profile)
115
+ profile_name = profile.to_s
116
+ raise Error, "Unknown config profile: #{profile}" unless PROFILE_OVERRIDES.key?(profile_name)
66
117
 
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
118
+ deep_merge(DEFAULT_CONFIG, PROFILE_OVERRIDES.fetch(profile_name))
119
+ end
77
120
 
78
- config
121
+ def self.schema
122
+ Schema.to_h
79
123
  end
80
124
 
81
- def deep_merge(hash1, hash2)
125
+ def self.deep_merge(hash1, hash2)
82
126
  hash1.merge(hash2) do |_key, old_val, new_val|
83
127
  if old_val.is_a?(Hash) && new_val.is_a?(Hash)
84
128
  deep_merge(old_val, new_val)
@@ -87,5 +131,45 @@ module Collie
87
131
  end
88
132
  end
89
133
  end
134
+
135
+ private
136
+
137
+ def load_config(config_path)
138
+ config = DEFAULT_CONFIG.dup
139
+ if config_path
140
+ raise Error, "Config file not found: #{config_path}" unless File.exist?(config_path)
141
+
142
+ path = config_path
143
+ else
144
+ path = ".collie.yml" if File.exist?(".collie.yml")
145
+ end
146
+ return config unless path
147
+
148
+ user_config = load_yaml_config(path)
149
+
150
+ if user_config["inherit_from"]
151
+ parent_path = File.expand_path(user_config["inherit_from"], File.dirname(path))
152
+ raise Error, "Inherited config file not found: #{parent_path}" unless File.exist?(parent_path)
153
+
154
+ config = deep_merge(config, load_yaml_config(parent_path))
155
+ end
156
+
157
+ deep_merge(config, user_config)
158
+ end
159
+
160
+ def load_yaml_config(path)
161
+ loaded = YAML.safe_load(File.read(path), aliases: true) || {}
162
+ raise Error, "Config file must contain a YAML mapping: #{path}" unless loaded.is_a?(Hash)
163
+
164
+ loaded
165
+ rescue Psych::SyntaxError => e
166
+ raise Error, "Invalid config file #{path}: #{e.message}"
167
+ end
168
+
169
+ def deep_merge(hash1, hash2)
170
+ self.class.deep_merge(hash1, hash2)
171
+ end
90
172
  end
91
173
  end
174
+
175
+ require_relative "config/schema"
@@ -13,26 +13,28 @@ module Collie
13
13
 
14
14
  # Prologue
15
15
  output << format_prologue(ast.prologue) if ast.prologue
16
+ append_blank_lines(output) if ast.prologue
16
17
 
17
18
  # Declarations
18
19
  output << format_declarations(ast.declarations) unless ast.declarations.empty?
20
+ append_blank_lines(output) unless ast.declarations.empty?
19
21
 
20
22
  # Section separator
21
- output << ""
22
23
  output << "%%"
23
- output << ""
24
+ append_blank_lines(output)
24
25
 
25
26
  # Rules
26
- output << format_rules(ast.rules)
27
+ output << format_rules(ast.rules) unless ast.rules.empty?
27
28
 
28
29
  # Epilogue
29
30
  if ast.epilogue
30
- output << ""
31
+ append_blank_lines(output)
31
32
  output << "%%"
32
- output << ""
33
+ append_blank_lines(output)
33
34
  output << ast.epilogue.code
34
35
  end
35
36
 
37
+ output.pop while output.last == ""
36
38
  output.join("\n")
37
39
  end
38
40
 
@@ -43,47 +45,44 @@ module Collie
43
45
  end
44
46
 
45
47
  def format_declarations(declarations)
46
- grouped = declarations.group_by(&:class)
47
48
  output = []
49
+ index = 0
48
50
 
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])
51
+ while index < declarations.length
52
+ declaration = declarations[index]
53
+ declarations_group = consecutive_declarations(declarations, index, declaration.class)
54
+ output << format_declaration_group(declarations_group)
58
55
  output << ""
56
+ index += declarations_group.length
59
57
  end
60
58
 
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
59
+ output.pop while output.last == ""
60
+ output.join("\n")
61
+ end
73
62
 
74
- # Format %rule declarations (Lrama extension)
75
- if grouped[AST::ParameterizedRule]
76
- output << format_parameterized_rule_declarations(grouped[AST::ParameterizedRule])
77
- output << ""
78
- end
63
+ def consecutive_declarations(declarations, start_index, declaration_class)
64
+ declarations[start_index..].take_while { |declaration| declaration.is_a?(declaration_class) }
65
+ end
79
66
 
80
- # Format %inline declarations (Lrama extension)
81
- if grouped[AST::InlineRule]
82
- output << format_inline_rule_declarations(grouped[AST::InlineRule])
83
- output << ""
67
+ def format_declaration_group(declarations)
68
+ case declarations.first
69
+ when AST::TokenDeclaration
70
+ format_token_declarations(declarations)
71
+ when AST::TypeDeclaration
72
+ format_type_declarations(declarations)
73
+ when AST::PrecedenceDeclaration
74
+ format_precedence_declarations(declarations)
75
+ when AST::UnionDeclaration
76
+ format_union_declarations(declarations)
77
+ when AST::UnknownDeclaration
78
+ format_unknown_declarations(declarations)
79
+ when AST::StartDeclaration
80
+ format_start_declarations(declarations)
81
+ when AST::ParameterizedRule
82
+ format_parameterized_rule_declarations(declarations)
83
+ when AST::InlineRule
84
+ format_inline_rule_declarations(declarations)
84
85
  end
85
-
86
- output.join("\n")
87
86
  end
88
87
 
89
88
  def format_token_declarations(declarations)
@@ -98,19 +97,20 @@ module Collie
98
97
  max_tag_length = declarations.map { |d| d.type_tag ? d.type_tag.length + 2 : 0 }.max || 0
99
98
  declarations.map do |decl|
100
99
  tag = decl.type_tag ? "<#{decl.type_tag}>" : ""
101
- "%token #{tag.ljust(max_tag_length)} #{decl.names.join(' ')}"
100
+ prefix = tag.empty? ? "%token" : "%token #{tag.ljust(max_tag_length)}"
101
+ wrap_declaration(prefix, decl.names)
102
102
  end.join("\n")
103
103
  end
104
104
 
105
105
  def format_token_declaration(decl)
106
106
  tag = decl.type_tag ? " <#{decl.type_tag}>" : ""
107
- "%token#{tag} #{decl.names.join(' ')}"
107
+ wrap_declaration("%token#{tag}", decl.names)
108
108
  end
109
109
 
110
110
  def format_type_declarations(declarations)
111
111
  declarations.map do |decl|
112
112
  tag = decl.type_tag ? " <#{decl.type_tag}>" : ""
113
- "%type#{tag} #{decl.names.join(' ')}"
113
+ wrap_declaration("%type#{tag}", decl.names)
114
114
  end.join("\n")
115
115
  end
116
116
 
@@ -125,25 +125,65 @@ module Collie
125
125
 
126
126
  declarations.map do |decl|
127
127
  directive = directive_names[decl.associativity]
128
- if @options.align_tokens
129
- "#{directive.ljust(max_directive_length)} #{decl.tokens.join(' ')}"
128
+ prefix = @options.align_tokens ? directive.ljust(max_directive_length) : directive
129
+ wrap_declaration(prefix, decl.tokens)
130
+ end.join("\n")
131
+ end
132
+
133
+ def wrap_declaration(prefix, names)
134
+ return prefix if names.empty?
135
+
136
+ lines = []
137
+ current = prefix
138
+
139
+ names.each do |name|
140
+ candidate = "#{current} #{name}"
141
+ if current != prefix && candidate.length > @options.max_line_length
142
+ lines << current
143
+ current = "#{@options.indent}#{name}"
130
144
  else
131
- "#{directive} #{decl.tokens.join(' ')}"
145
+ current = candidate
132
146
  end
147
+ end
148
+
149
+ lines << current
150
+ lines.join("\n")
151
+ end
152
+
153
+ def format_union_declarations(declarations)
154
+ declarations.map do |decl|
155
+ body = decl.body.to_s
156
+ body.start_with?("{") ? "%union #{body}" : "%union {#{body}}"
133
157
  end.join("\n")
134
158
  end
135
159
 
160
+ def format_unknown_declarations(declarations)
161
+ declarations.map(&:source).join("\n")
162
+ end
163
+
164
+ def format_start_declarations(declarations)
165
+ declarations.map { |decl| "%start #{decl.symbol}" }.join("\n")
166
+ end
167
+
136
168
  def format_parameterized_rule_declarations(declarations)
137
169
  declarations.map do |decl|
138
- params = "(#{decl.parameters.join(', ')})"
170
+ params = decl.parameters.empty? ? "" : "(#{decl.parameters.join(', ')})"
139
171
  alternatives = decl.alternatives.map { |alt| format_alternative(alt) }.join(" | ")
140
- "%rule #{decl.name}#{params}: #{alternatives} ;"
172
+ separator = alternatives.start_with?(" |") ? "" : " "
173
+ "%rule #{decl.name}#{params}:#{separator}#{alternatives} ;"
141
174
  end.join("\n")
142
175
  end
143
176
 
144
177
  def format_inline_rule_declarations(declarations)
145
178
  declarations.map do |decl|
146
- "%inline #{decl.rule}"
179
+ output = "%inline #{decl.rule}"
180
+ output += "(#{decl.parameters.join(', ')})" unless decl.parameters.empty?
181
+ unless decl.alternatives.empty?
182
+ alternatives = decl.alternatives.map { |alt| format_alternative(alt) }.join(" | ")
183
+ separator = alternatives.start_with?(" |") ? "" : " "
184
+ output += ":#{separator}#{alternatives} ;"
185
+ end
186
+ output
147
187
  end.join("\n")
148
188
  end
149
189
 
@@ -159,18 +199,19 @@ module Collie
159
199
  end
160
200
 
161
201
  output = [rule_header]
202
+ indent = @options.align_alternatives ? @options.indent : ""
162
203
 
163
204
  rule.alternatives.each_with_index do |alt, index|
164
- prefix = index.zero? ? " :" : " |"
205
+ prefix = index.zero? ? "#{indent}:" : "#{indent}|"
165
206
  output << "#{prefix} #{format_alternative(alt)}"
166
207
  end
167
208
 
168
- output << " ;"
209
+ output << "#{indent};"
169
210
  output.join("\n")
170
211
  end
171
212
 
172
213
  def format_alternative(alt)
173
- symbols_str = alt.symbols.map { |sym| format_symbol(sym) }.join(" ")
214
+ symbols_str = alt.explicit_empty ? (alt.empty_marker || "%empty") : alt.symbols.map { |sym| format_symbol(sym) }.join(" ")
174
215
  action_str = alt.action ? " #{alt.action.code}" : ""
175
216
  prec_str = alt.prec ? " %prec #{alt.prec}" : ""
176
217
 
@@ -191,6 +232,10 @@ module Collie
191
232
 
192
233
  result
193
234
  end
235
+
236
+ def append_blank_lines(output)
237
+ @options.blank_lines_around_sections.times { output << "" }
238
+ end
194
239
  end
195
240
  end
196
241
  end
@@ -8,16 +8,28 @@ module Collie
8
8
  :blank_lines_around_sections, :max_line_length
9
9
 
10
10
  def initialize(config = {})
11
- @indent_size = config[:indent_size] || 2
12
- @align_tokens = config[:align_tokens] != false
13
- @align_alternatives = config[:align_alternatives] != false
14
- @blank_lines_around_sections = config[:blank_lines_around_sections] || 1
15
- @max_line_length = config[:max_line_length] || 120
11
+ @indent_size = fetch_option(config, :indent_size, 2)
12
+ @align_tokens = fetch_option(config, :align_tokens, true)
13
+ @align_alternatives = fetch_option(config, :align_alternatives, true)
14
+ @blank_lines_around_sections = fetch_option(config, :blank_lines_around_sections, 1)
15
+ @max_line_length = fetch_option(config, :max_line_length, 120)
16
16
  end
17
17
 
18
18
  def indent(level = 1)
19
19
  " " * (indent_size * level)
20
20
  end
21
+
22
+ private
23
+
24
+ def fetch_option(config, key, default)
25
+ return default unless config
26
+
27
+ string_key = key.to_s
28
+ return config[string_key] if config.key?(string_key)
29
+ return config[key] if config.key?(key)
30
+
31
+ default
32
+ end
21
33
  end
22
34
  end
23
35
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ module Formatter
5
+ # Semantic signature for verifying that formatting preserved grammar structure.
6
+ class Signature
7
+ def self.build(ast)
8
+ new.build(ast)
9
+ end
10
+
11
+ def build(ast)
12
+ [
13
+ :grammar,
14
+ ast.prologue&.code,
15
+ ast.declarations.map { |declaration| declaration_signature(declaration) },
16
+ ast.rules.map { |rule| rule_signature(rule) },
17
+ ast.epilogue&.code
18
+ ]
19
+ end
20
+
21
+ private
22
+
23
+ def declaration_signature(declaration)
24
+ case declaration
25
+ when AST::TokenDeclaration
26
+ [:token, declaration.type_tag, declaration.names]
27
+ when AST::TypeDeclaration
28
+ [:type, declaration.type_tag, declaration.names]
29
+ when AST::PrecedenceDeclaration
30
+ [:precedence, declaration.associativity, declaration.tokens]
31
+ when AST::StartDeclaration
32
+ [:start, declaration.symbol]
33
+ when AST::UnionDeclaration
34
+ [:union, declaration.body]
35
+ when AST::UnknownDeclaration
36
+ [:unknown, declaration.source]
37
+ when AST::ParameterizedRule, AST::InlineRule
38
+ rule_signature(declaration)
39
+ else
40
+ [declaration.class.name]
41
+ end
42
+ end
43
+
44
+ def rule_signature(rule)
45
+ [
46
+ rule.is_a?(AST::InlineRule) ? :inline_rule : :rule,
47
+ rule.is_a?(AST::InlineRule) ? rule.rule : rule.name,
48
+ rule.respond_to?(:parameters) ? rule.parameters : [],
49
+ rule.alternatives.map { |alternative| alternative_signature(alternative) }
50
+ ]
51
+ end
52
+
53
+ def alternative_signature(alternative)
54
+ [
55
+ alternative.symbols.map { |symbol| symbol_signature(symbol) },
56
+ alternative.action&.code,
57
+ alternative.prec,
58
+ alternative.explicit_empty
59
+ ]
60
+ end
61
+
62
+ def symbol_signature(symbol)
63
+ [
64
+ symbol.name,
65
+ symbol.kind,
66
+ symbol.alias_name,
67
+ Array(symbol.arguments).map { |argument| symbol_signature(argument) }
68
+ ]
69
+ end
70
+ end
71
+ end
72
+ end
@@ -28,6 +28,8 @@ module Collie
28
28
 
29
29
  # Base class for all lint rules
30
30
  class Base
31
+ VALID_SEVERITIES = %i[error warning convention info].freeze
32
+
31
33
  class << self
32
34
  attr_accessor :rule_name, :description, :severity, :autocorrectable
33
35
  end
@@ -52,11 +54,58 @@ module Collie
52
54
  rule: self.class,
53
55
  location: node.location,
54
56
  message: message,
57
+ severity: configured_severity,
55
58
  autocorrect: autocorrect
56
59
  )
57
60
  end
58
61
 
59
62
  attr_reader :offenses
63
+
64
+ def configured_severity
65
+ severity = config_value(:severity)
66
+ return self.class.severity unless severity
67
+
68
+ normalized = severity.to_sym
69
+ VALID_SEVERITIES.include?(normalized) ? normalized : self.class.severity
70
+ end
71
+
72
+ def config_value(key, default = nil)
73
+ string_key = key.to_s
74
+ return @config[string_key] if @config.key?(string_key)
75
+ return @config[key] if @config.key?(key)
76
+
77
+ default
78
+ end
79
+
80
+ def each_rule_like(ast)
81
+ ast.rules.each { |rule| yield rule }
82
+
83
+ ast.declarations.each do |declaration|
84
+ next unless rule_like_declaration?(declaration)
85
+
86
+ yield declaration
87
+ end
88
+ end
89
+
90
+ def rule_like_declaration?(declaration)
91
+ declaration.is_a?(AST::ParameterizedRule) || declaration.is_a?(AST::InlineRule)
92
+ end
93
+
94
+ def rule_like_name(rule)
95
+ rule.is_a?(AST::InlineRule) ? rule.rule : rule.name
96
+ end
97
+
98
+ def rule_like_parameters(rule)
99
+ rule.respond_to?(:parameters) ? rule.parameters : []
100
+ end
101
+
102
+ def find_rule_like(ast, name)
103
+ each_rule_like(ast) do |rule|
104
+ return rule if rule_like_name(rule) == name
105
+ end
106
+
107
+ nil
108
+ end
60
109
  end
61
110
  end
62
111
  end