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
@@ -53,8 +53,10 @@ module Collie
53
53
  def collect_operators(ast)
54
54
  operators = Hash.new { |h, k| h[k] = [] }
55
55
 
56
- ast.rules.each do |rule|
56
+ each_rule_like(ast) do |rule|
57
57
  rule.alternatives.each do |alt|
58
+ next if alt.prec
59
+
58
60
  alt.symbols.each do |symbol|
59
61
  next unless symbol.terminal?
60
62
  next unless looks_like_operator?(symbol.name)
@@ -75,7 +77,8 @@ module Collie
75
77
  offense = Offense.new(
76
78
  rule: self.class,
77
79
  location: location,
78
- message: message
80
+ message: message,
81
+ severity: configured_severity
79
82
  )
80
83
  @offenses << offense
81
84
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module Collie
4
6
  module Linter
5
7
  module Rules
@@ -12,14 +14,16 @@ module Collie
12
14
 
13
15
  def check(ast, _context = {})
14
16
  @rules_map = build_rules_map(ast)
15
- @visited = Set.new
16
- @rec_stack = Set.new
17
+ @dependencies = build_dependency_graph
18
+ productive_rules = compute_productive_rules
17
19
 
18
- ast.rules.each do |rule|
19
- next if @visited.include?(rule.name)
20
+ strongly_connected_components.each do |component|
21
+ next unless cyclic_component?(component)
22
+ next if component.any? { |rule_name| productive_rules.include?(rule_name) }
20
23
 
21
- if has_cycle?(rule.name, [])
22
- add_offense(rule, message: "Rule '#{rule.name}' is part of a circular reference")
24
+ component.each do |rule_name|
25
+ rule = @rules_map[rule_name]
26
+ add_offense(rule, message: "Rule '#{rule_name}' is part of a non-productive circular reference") if rule
23
27
  end
24
28
  end
25
29
 
@@ -29,57 +33,111 @@ module Collie
29
33
  private
30
34
 
31
35
  def build_rules_map(ast)
32
- ast.rules.each_with_object({}) do |rule, map|
33
- map[rule.name] = rule
36
+ rules = ast.rules + ast.declarations.select do |decl|
37
+ decl.is_a?(AST::ParameterizedRule) || decl.is_a?(AST::InlineRule)
34
38
  end
35
- end
36
39
 
37
- def has_cycle?(rule_name, path)
38
- return false if @visited.include?(rule_name)
40
+ rules.each_with_object({}) do |rule, map|
41
+ map[rule_name(rule)] = rule
42
+ end
43
+ end
39
44
 
40
- if @rec_stack.include?(rule_name)
41
- # Found a cycle - check if it's truly circular (no terminals in alternatives)
42
- return true if pure_nonterminal_cycle?(rule_name)
45
+ def rule_name(rule)
46
+ rule.is_a?(AST::InlineRule) ? rule.rule : rule.name
47
+ end
43
48
 
44
- return false
49
+ def build_dependency_graph
50
+ @rules_map.transform_values do |rule|
51
+ rule.alternatives.each_with_object(Set.new) do |alternative, dependencies|
52
+ alternative.symbols.each { |symbol| collect_dependencies(symbol, dependencies) }
53
+ end
45
54
  end
55
+ end
56
+
57
+ def collect_dependencies(symbol, dependencies)
58
+ dependencies << symbol.name if symbol.nonterminal? && @rules_map.key?(symbol.name)
59
+ symbol.arguments&.each { |argument| collect_dependencies(argument, dependencies) }
60
+ end
61
+
62
+ def compute_productive_rules
63
+ productive = Set.new
64
+
65
+ loop do
66
+ changed = false
67
+
68
+ @rules_map.each do |name, rule|
69
+ next if productive.include?(name)
70
+ next unless rule.alternatives.any? { |alternative| productive_alternative?(alternative, productive) }
46
71
 
47
- @rec_stack.add(rule_name)
48
- current_path = path + [rule_name]
72
+ productive << name
73
+ changed = true
74
+ end
75
+
76
+ break unless changed
77
+ end
49
78
 
50
- rule = @rules_map[rule_name]
51
- # Check each alternative - only follow nonterminals
52
- rule&.alternatives&.each do |alt|
53
- # Skip alternatives with terminals or empty alternatives
54
- next if has_terminal_or_empty?(alt)
79
+ productive
80
+ end
55
81
 
56
- # Only check the first symbol for cycles
57
- first_symbol = alt.symbols.first
58
- next unless first_symbol&.nonterminal?
82
+ def productive_alternative?(alternative, productive_rules)
83
+ return true if alternative.explicit_empty || alternative.symbols.empty?
59
84
 
60
- return true if has_cycle?(first_symbol.name, current_path)
85
+ alternative.symbols.all? do |symbol|
86
+ productive_symbol?(symbol, productive_rules)
61
87
  end
88
+ end
62
89
 
63
- @rec_stack.delete(rule_name)
64
- @visited.add(rule_name)
90
+ def productive_symbol?(symbol, productive_rules)
91
+ return true if symbol.terminal?
65
92
 
66
- false
93
+ symbol.nonterminal? && productive_rules.include?(symbol.name)
67
94
  end
68
95
 
69
- def has_terminal_or_empty?(alternative)
70
- return true if alternative.symbols.empty?
96
+ def strongly_connected_components
97
+ @index = 0
98
+ @indices = {}
99
+ @lowlinks = {}
100
+ @stack = []
101
+ @on_stack = Set.new
102
+ components = []
103
+
104
+ @rules_map.each_key do |rule_name|
105
+ strong_connect(rule_name, components) unless @indices.key?(rule_name)
106
+ end
71
107
 
72
- alternative.symbols.any?(&:terminal?)
108
+ components
73
109
  end
74
110
 
75
- def pure_nonterminal_cycle?(rule_name)
76
- rule = @rules_map[rule_name]
77
- return false unless rule
111
+ def strong_connect(rule_name, components)
112
+ @indices[rule_name] = @index
113
+ @lowlinks[rule_name] = @index
114
+ @index += 1
115
+ @stack << rule_name
116
+ @on_stack << rule_name
117
+
118
+ @dependencies[rule_name].each do |dependency|
119
+ if !@indices.key?(dependency)
120
+ strong_connect(dependency, components)
121
+ @lowlinks[rule_name] = [@lowlinks[rule_name], @lowlinks[dependency]].min
122
+ elsif @on_stack.include?(dependency)
123
+ @lowlinks[rule_name] = [@lowlinks[rule_name], @indices[dependency]].min
124
+ end
125
+ end
126
+
127
+ return unless @lowlinks[rule_name] == @indices[rule_name]
78
128
 
79
- # Check if all alternatives contain only nonterminals
80
- rule.alternatives.all? do |alt|
81
- !has_terminal_or_empty?(alt)
129
+ component = []
130
+ loop do
131
+ dependency = @stack.pop
132
+ @on_stack.delete(dependency)
133
+ component << dependency
134
+ break if dependency == rule_name
82
135
  end
136
+ components << component
137
+ end
138
+
139
+ def cyclic_component?(component)
140
+ component.size > 1 || @dependencies[component.first].include?(component.first)
83
141
  end
84
142
  end
85
143
  end
@@ -17,7 +17,7 @@ module Collie
17
17
  styles = tags.group_by { |tag, _| detect_style(tag) }
18
18
 
19
19
  # If we have multiple styles, report inconsistency
20
- add_inconsistency_offense(ast, styles) if styles.size > 1
20
+ add_inconsistency_offenses(styles) if styles.size > 1
21
21
 
22
22
  @offenses
23
23
  end
@@ -45,21 +45,21 @@ module Collie
45
45
  :other
46
46
  end
47
47
 
48
- def add_inconsistency_offense(ast, styles)
48
+ Node = Struct.new(:location)
49
+
50
+ def add_inconsistency_offenses(styles)
49
51
  style_names = styles.keys.map(&:to_s).join(", ")
50
52
  most_common_style = styles.max_by { |_, tags| tags.size }[0]
53
+ expected_tags = styles.fetch(most_common_style)
54
+ outliers = styles.reject { |style, _| style == most_common_style }.values.flatten(1)
51
55
 
52
- # Add offense at the first declaration
53
- first_decl = ast.declarations.first
54
- location = first_decl&.location || AST::Location.new(file: "grammar", line: 1, column: 1)
55
-
56
- offense = Offense.new(
57
- rule: self.class,
58
- location: location,
59
- message: "Inconsistent type tag naming styles detected (#{style_names}). " \
60
- "Consider using #{most_common_style} throughout."
61
- )
62
- @offenses << offense
56
+ outliers.each do |tag, location|
57
+ add_offense(
58
+ Node.new(location || expected_tags.first[1] || AST::Location.new(file: "grammar", line: 1, column: 1)),
59
+ message: "Type tag '#{tag}' uses a different naming style (#{style_names}). " \
60
+ "Consider using #{most_common_style} throughout."
61
+ )
62
+ end
63
63
  end
64
64
  end
65
65
  end
@@ -10,9 +10,9 @@ module Collie
10
10
  self.severity = :convention
11
11
  self.autocorrectable = true
12
12
 
13
- def check(ast, _context = {})
14
- ast.rules.each do |rule|
15
- check_rule(rule)
13
+ def check(ast, context = {})
14
+ each_rule_like(ast) do |rule|
15
+ check_rule(rule, context)
16
16
  end
17
17
 
18
18
  @offenses
@@ -20,30 +20,61 @@ module Collie
20
20
 
21
21
  private
22
22
 
23
- def check_rule(rule)
23
+ def check_rule(rule, context)
24
24
  rule.alternatives.each do |alt|
25
25
  next unless alt.action
26
26
  next unless empty_action?(alt.action)
27
27
 
28
28
  add_offense(
29
- alt,
29
+ alt.action,
30
30
  message: "Empty action block can be removed",
31
- autocorrect: -> { remove_action(alt) }
31
+ autocorrect: -> { remove_action(alt, context) }
32
32
  )
33
33
  end
34
34
  end
35
35
 
36
36
  def empty_action?(action)
37
- # Check if action code is empty or contains only whitespace
38
- return true unless action.code
39
- return true if action.code.strip.empty?
37
+ code = action.code.to_s.strip
38
+ return true if code.empty?
40
39
 
41
- false
40
+ action_body = if code.start_with?("{") && code.end_with?("}")
41
+ code[1...-1].strip
42
+ else
43
+ code
44
+ end
45
+
46
+ action_body.empty?
42
47
  end
43
48
 
44
- def remove_action(alternative)
49
+ def remove_action(alternative, context)
50
+ action = alternative.action
51
+ if context[:source] && action&.location
52
+ context[:source] = remove_action_from_source(context[:source], action.location)
53
+ end
54
+
45
55
  alternative.action = nil
46
56
  end
57
+
58
+ def remove_action_from_source(source, location)
59
+ index = source_index(source, location)
60
+ return source unless index
61
+
62
+ prefix = source[0...index].sub(/[ \t]*\z/, "")
63
+ suffix = source[(index + location.length)..] || ""
64
+ "#{prefix}#{suffix}"
65
+ end
66
+
67
+ def source_index(source, location)
68
+ offset = 0
69
+
70
+ source.each_line.with_index(1) do |line, line_number|
71
+ return offset + location.column - 1 if line_number == location.line
72
+
73
+ offset += line.length
74
+ end
75
+
76
+ nil
77
+ end
47
78
  end
48
79
  end
49
80
  end
@@ -13,7 +13,7 @@ module Collie
13
13
  MIN_PREFIX_LENGTH = 2
14
14
 
15
15
  def check(ast, _context = {})
16
- ast.rules.each do |rule|
16
+ each_rule_like(ast) do |rule|
17
17
  check_rule(rule)
18
18
  end
19
19
 
@@ -37,7 +37,7 @@ module Collie
37
37
 
38
38
  add_offense(
39
39
  rule,
40
- message: "Rule '#{rule.name}' has #{alternatives.size} alternatives with common prefix " \
40
+ message: "Rule '#{rule_like_name(rule)}' has #{alternatives.size} alternatives with common prefix " \
41
41
  "(#{prefix_length} symbols). Consider factoring."
42
42
  )
43
43
  break # Only report once per rule
@@ -6,8 +6,8 @@ module Collie
6
6
  # Detects left recursion in grammar rules
7
7
  class LeftRecursion < Base
8
8
  self.rule_name = "LeftRecursion"
9
- self.description = "Detects left recursion (may cause issues with some parsers)"
10
- self.severity = :warning
9
+ self.description = "Notes left recursion for LL parser portability"
10
+ self.severity = :info
11
11
  self.autocorrectable = false
12
12
 
13
13
  def check(ast, _context = {})
@@ -15,12 +15,13 @@ module Collie
15
15
  result = analyzer.analyze
16
16
 
17
17
  result[:left_recursive].each do |rule_name|
18
- rule = ast.rules.find { |r| r.name == rule_name }
18
+ rule = find_rule_like(ast, rule_name)
19
19
  next unless rule
20
20
 
21
21
  add_offense(
22
22
  rule,
23
- message: "Rule '#{rule_name}' uses left recursion (consider using right recursion for LL parsers)"
23
+ message: "Rule '#{rule_name}' uses left recursion. This is normal for LR parsers; " \
24
+ "review only if targeting LL parser portability."
24
25
  )
25
26
  end
26
27
 
@@ -13,16 +13,16 @@ module Collie
13
13
  DEFAULT_MAX_ALTERNATIVES = 10
14
14
 
15
15
  def check(ast, _context = {})
16
- max_alternatives = @config.dig("rules", "LongRule", "max_alternatives") || DEFAULT_MAX_ALTERNATIVES
16
+ max_alternatives = config_value(:max_alternatives, DEFAULT_MAX_ALTERNATIVES)
17
17
 
18
- ast.rules.each do |rule|
18
+ each_rule_like(ast) do |rule|
19
19
  alternatives_count = rule.alternatives.size
20
20
 
21
21
  next unless alternatives_count > max_alternatives
22
22
 
23
23
  add_offense(
24
24
  rule,
25
- message: "Rule '#{rule.name}' has #{alternatives_count} alternatives " \
25
+ message: "Rule '#{rule_like_name(rule)}' has #{alternatives_count} alternatives " \
26
26
  "(max: #{max_alternatives}). Consider refactoring."
27
27
  )
28
28
  end
@@ -15,13 +15,15 @@ module Collie
15
15
  DEFAULT_PATTERN = /^[a-z][a-z0-9_]*$/
16
16
 
17
17
  def check(ast, _context = {})
18
- pattern = @config[:pattern] ? Regexp.new(@config[:pattern]) : DEFAULT_PATTERN
18
+ pattern_config = config_value(:pattern)
19
+ pattern = pattern_config ? Regexp.new(pattern_config) : DEFAULT_PATTERN
19
20
 
20
- ast.rules.each do |rule|
21
- next if rule.name.match?(pattern)
21
+ each_rule_like(ast) do |rule|
22
+ name = rule_like_name(rule)
23
+ next if name.match?(pattern)
22
24
 
23
25
  add_offense(rule,
24
- message: "Nonterminal '#{rule.name}' should match pattern #{pattern.inspect}")
26
+ message: "Nonterminal '#{name}' should match pattern #{pattern.inspect}")
25
27
  end
26
28
 
27
29
  @offenses
@@ -13,7 +13,7 @@ module Collie
13
13
  def check(ast, _context = {})
14
14
  precedence_tokens = collect_precedence_tokens(ast)
15
15
 
16
- ast.rules.each do |rule|
16
+ each_rule_like(ast) do |rule|
17
17
  check_rule(rule, precedence_tokens)
18
18
  end
19
19
 
@@ -6,12 +6,12 @@ module Collie
6
6
  # Detects potentially redundant epsilon productions
7
7
  class RedundantEpsilon < Base
8
8
  self.rule_name = "RedundantEpsilon"
9
- self.description = "Detects potentially redundant epsilon (empty) productions"
9
+ self.description = "Detects duplicate epsilon (empty) productions"
10
10
  self.severity = :info
11
11
  self.autocorrectable = false
12
12
 
13
13
  def check(ast, _context = {})
14
- ast.rules.each do |rule|
14
+ each_rule_like(ast) do |rule|
15
15
  check_rule(rule)
16
16
  end
17
17
 
@@ -21,21 +21,21 @@ module Collie
21
21
  private
22
22
 
23
23
  def check_rule(rule)
24
- epsilon_alternatives = rule.alternatives.select { |alt| alt.symbols.empty? }
25
- return if epsilon_alternatives.empty?
24
+ epsilon_alternatives = rule.alternatives.select { |alt| epsilon?(alt) }
25
+ return if epsilon_alternatives.size < 2
26
26
 
27
- # Only report if there are other non-epsilon alternatives
28
- non_epsilon_alternatives = rule.alternatives.reject { |alt| alt.symbols.empty? }
29
- return if non_epsilon_alternatives.empty?
30
-
31
- epsilon_alternatives.each do |alt|
27
+ epsilon_alternatives.drop(1).each do |alt|
32
28
  add_offense(
33
29
  alt,
34
- message: "Rule '#{rule.name}' has an epsilon production. " \
35
- "Verify if it's necessary or if the rule can be made optional elsewhere."
30
+ message: "Rule '#{rule_like_name(rule)}' has multiple epsilon productions. " \
31
+ "Keep one empty alternative and remove duplicates."
36
32
  )
37
33
  end
38
34
  end
35
+
36
+ def epsilon?(alternative)
37
+ alternative.symbols.empty? || alternative.explicit_empty
38
+ end
39
39
  end
40
40
  end
41
41
  end
@@ -13,9 +13,12 @@ module Collie
13
13
  def check(ast, _context = {})
14
14
  analyzer = Analyzer::Recursion.new(ast)
15
15
  result = analyzer.analyze
16
+ left_recursive = result[:left_recursive]
16
17
 
17
18
  result[:right_recursive].each do |rule_name|
18
- rule = ast.rules.find { |r| r.name == rule_name }
19
+ next if left_recursive.include?(rule_name)
20
+
21
+ rule = find_rule_like(ast, rule_name)
19
22
  next unless rule
20
23
 
21
24
  add_offense(
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+
5
+ module Collie
6
+ module Linter
7
+ module Rules
8
+ # Detects conflicting grammar symbol declarations.
9
+ class SymbolConflict < Base
10
+ self.rule_name = "SymbolConflict"
11
+ self.description = "Detects conflicting token and nonterminal declarations"
12
+ self.severity = :error
13
+ self.autocorrectable = false
14
+
15
+ def check(ast, _context = {})
16
+ tokens = collect_tokens(ast)
17
+ nonterminals = collect_nonterminals(ast)
18
+ precedence_tokens = collect_precedence_tokens(ast)
19
+
20
+ report_token_nonterminal_conflicts(tokens, nonterminals)
21
+ report_duplicate_nonterminals(nonterminals)
22
+ report_duplicate_precedence_tokens(precedence_tokens)
23
+
24
+ @offenses
25
+ end
26
+
27
+ private
28
+
29
+ Entry = Struct.new(:name, :location)
30
+
31
+ def collect_tokens(ast)
32
+ ast.declarations.each_with_object({}) do |decl, tokens|
33
+ token_names(decl).each do |name|
34
+ tokens[name] ||= Entry.new(name, decl.location)
35
+ end
36
+ end
37
+ end
38
+
39
+ def token_names(declaration)
40
+ case declaration
41
+ when AST::TokenDeclaration
42
+ declaration.names
43
+ when AST::PrecedenceDeclaration
44
+ declaration.tokens
45
+ else
46
+ []
47
+ end
48
+ end
49
+
50
+ def collect_nonterminals(ast)
51
+ entries = []
52
+
53
+ ast.declarations.each do |decl|
54
+ case decl
55
+ when AST::ParameterizedRule
56
+ entries << Entry.new(decl.name, decl.location)
57
+ when AST::InlineRule
58
+ entries << Entry.new(decl.rule, decl.location)
59
+ end
60
+ end
61
+
62
+ ast.rules.each do |rule|
63
+ entries << Entry.new(rule.name, rule.location)
64
+ end
65
+
66
+ entries
67
+ end
68
+
69
+ def collect_precedence_tokens(ast)
70
+ entries = []
71
+
72
+ ast.declarations.each do |decl|
73
+ next unless decl.is_a?(AST::PrecedenceDeclaration)
74
+
75
+ decl.tokens.each do |name|
76
+ entries << Entry.new(name, decl.location)
77
+ end
78
+ end
79
+
80
+ entries
81
+ end
82
+
83
+ def report_token_nonterminal_conflicts(tokens, nonterminals)
84
+ nonterminals.each do |entry|
85
+ next unless tokens.key?(entry.name)
86
+
87
+ add_offense(
88
+ Node.new(entry.location),
89
+ message: "Symbol '#{entry.name}' is declared as both token and nonterminal"
90
+ )
91
+ end
92
+ end
93
+
94
+ def report_duplicate_nonterminals(nonterminals)
95
+ seen = {}
96
+
97
+ nonterminals.each do |entry|
98
+ if seen.key?(entry.name)
99
+ add_offense(
100
+ Node.new(entry.location),
101
+ message: "Nonterminal '#{entry.name}' already defined at #{seen[entry.name]}"
102
+ )
103
+ else
104
+ seen[entry.name] = entry.location
105
+ end
106
+ end
107
+ end
108
+
109
+ def report_duplicate_precedence_tokens(precedence_tokens)
110
+ seen = {}
111
+
112
+ precedence_tokens.each do |entry|
113
+ if seen.key?(entry.name)
114
+ add_offense(
115
+ Node.new(entry.location),
116
+ message: "Precedence token '#{entry.name}' already declared at #{seen[entry.name]}"
117
+ )
118
+ else
119
+ seen[entry.name] = entry.location
120
+ end
121
+ end
122
+ end
123
+
124
+ Node = Struct.new(:location)
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ Collie::Linter::Registry.register(Collie::Linter::Rules::SymbolConflict)
@@ -15,7 +15,8 @@ module Collie
15
15
  DEFAULT_PATTERN = /^[A-Z][A-Z0-9_]*$/
16
16
 
17
17
  def check(ast, _context = {})
18
- pattern = @config[:pattern] ? Regexp.new(@config[:pattern]) : DEFAULT_PATTERN
18
+ pattern_config = config_value(:pattern)
19
+ pattern = pattern_config ? Regexp.new(pattern_config) : DEFAULT_PATTERN
19
20
 
20
21
  ast.declarations.each do |decl|
21
22
  next unless decl.is_a?(AST::TokenDeclaration)
@@ -31,13 +31,19 @@ module Collie
31
31
  Node.new(location),
32
32
  message: "Trailing whitespace detected",
33
33
  autocorrect: lambda {
34
- context[:source] = source.gsub(/[ \t]+\n/, "\n").gsub(/[ \t]+$/, "")
34
+ context[:source] = remove_trailing_whitespace(context[:source] || source)
35
35
  }
36
36
  )
37
37
  end
38
38
 
39
39
  @offenses
40
40
  end
41
+
42
+ private
43
+
44
+ def remove_trailing_whitespace(source)
45
+ source.gsub(/[ \t]+\n/, "\n").gsub(/[ \t]+$/, "")
46
+ end
41
47
  end
42
48
  end
43
49
  end