collie 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/Gemfile +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +333 -0
  6. data/Rakefile +9 -0
  7. data/collie.gemspec +37 -0
  8. data/docs/TUTORIAL.md +588 -0
  9. data/docs/index.html +56 -0
  10. data/docs/playground/README.md +134 -0
  11. data/docs/playground/build-collie-bundle.rb +85 -0
  12. data/docs/playground/css/styles.css +402 -0
  13. data/docs/playground/index.html +146 -0
  14. data/docs/playground/js/app.js +231 -0
  15. data/docs/playground/js/collie-bridge.js +186 -0
  16. data/docs/playground/js/editor.js +129 -0
  17. data/docs/playground/js/examples.js +80 -0
  18. data/docs/playground/js/ruby-runner.js +75 -0
  19. data/docs/playground/test-server.sh +18 -0
  20. data/exe/collie +15 -0
  21. data/lib/collie/analyzer/conflict.rb +114 -0
  22. data/lib/collie/analyzer/reachability.rb +83 -0
  23. data/lib/collie/analyzer/recursion.rb +96 -0
  24. data/lib/collie/analyzer/symbol_table.rb +67 -0
  25. data/lib/collie/ast.rb +183 -0
  26. data/lib/collie/cli.rb +249 -0
  27. data/lib/collie/config.rb +91 -0
  28. data/lib/collie/formatter/formatter.rb +196 -0
  29. data/lib/collie/formatter/options.rb +23 -0
  30. data/lib/collie/linter/base.rb +62 -0
  31. data/lib/collie/linter/registry.rb +34 -0
  32. data/lib/collie/linter/rules/ambiguous_precedence.rb +87 -0
  33. data/lib/collie/linter/rules/circular_reference.rb +89 -0
  34. data/lib/collie/linter/rules/consistent_tag_naming.rb +69 -0
  35. data/lib/collie/linter/rules/duplicate_token.rb +38 -0
  36. data/lib/collie/linter/rules/empty_action.rb +52 -0
  37. data/lib/collie/linter/rules/factorizable_rules.rb +67 -0
  38. data/lib/collie/linter/rules/left_recursion.rb +34 -0
  39. data/lib/collie/linter/rules/long_rule.rb +37 -0
  40. data/lib/collie/linter/rules/missing_start_symbol.rb +38 -0
  41. data/lib/collie/linter/rules/nonterminal_naming.rb +34 -0
  42. data/lib/collie/linter/rules/prec_improvement.rb +54 -0
  43. data/lib/collie/linter/rules/redundant_epsilon.rb +44 -0
  44. data/lib/collie/linter/rules/right_recursion.rb +35 -0
  45. data/lib/collie/linter/rules/token_naming.rb +39 -0
  46. data/lib/collie/linter/rules/trailing_whitespace.rb +46 -0
  47. data/lib/collie/linter/rules/undefined_symbol.rb +55 -0
  48. data/lib/collie/linter/rules/unreachable_rule.rb +49 -0
  49. data/lib/collie/linter/rules/unused_nonterminal.rb +93 -0
  50. data/lib/collie/linter/rules/unused_token.rb +82 -0
  51. data/lib/collie/parser/lexer.rb +349 -0
  52. data/lib/collie/parser/parser.rb +416 -0
  53. data/lib/collie/reporter/github.rb +35 -0
  54. data/lib/collie/reporter/json.rb +52 -0
  55. data/lib/collie/reporter/text.rb +97 -0
  56. data/lib/collie/version.rb +5 -0
  57. data/lib/collie.rb +52 -0
  58. metadata +145 -0
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ module Formatter
5
+ # Formatting options
6
+ class Options
7
+ attr_accessor :indent_size, :align_tokens, :align_alternatives,
8
+ :blank_lines_around_sections, :max_line_length
9
+
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
16
+ end
17
+
18
+ def indent(level = 1)
19
+ " " * (indent_size * level)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ast"
4
+ require_relative "registry"
5
+
6
+ module Collie
7
+ module Linter
8
+ # Offense representation
9
+ class Offense
10
+ attr_reader :rule, :location, :message, :severity, :autocorrect
11
+
12
+ def initialize(rule:, location:, message:, severity: nil, autocorrect: nil)
13
+ @rule = rule
14
+ @location = location
15
+ @message = message
16
+ @severity = severity || rule.severity
17
+ @autocorrect = autocorrect
18
+ end
19
+
20
+ def autocorrectable?
21
+ !@autocorrect.nil?
22
+ end
23
+
24
+ def to_s
25
+ "#{location}: #{severity}: [#{rule.rule_name}] #{message}"
26
+ end
27
+ end
28
+
29
+ # Base class for all lint rules
30
+ class Base
31
+ class << self
32
+ attr_accessor :rule_name, :description, :severity, :autocorrectable
33
+ end
34
+
35
+ def initialize(config = {})
36
+ @config = config
37
+ @offenses = []
38
+ end
39
+
40
+ def check(_ast, _context = {})
41
+ raise NotImplementedError, "#{self.class} must implement #check"
42
+ end
43
+
44
+ def autocorrectable?
45
+ self.class.autocorrectable
46
+ end
47
+
48
+ protected
49
+
50
+ def add_offense(node, message:, autocorrect: nil)
51
+ @offenses << Offense.new(
52
+ rule: self.class,
53
+ location: node.location,
54
+ message: message,
55
+ autocorrect: autocorrect
56
+ )
57
+ end
58
+
59
+ attr_reader :offenses
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ module Linter
5
+ # Registry for lint rules
6
+ class Registry
7
+ @rules = {}
8
+
9
+ class << self
10
+ def register(rule_class)
11
+ @rules[rule_class.rule_name] = rule_class if rule_class.rule_name
12
+ end
13
+
14
+ def all
15
+ @rules.values
16
+ end
17
+
18
+ def find(name)
19
+ @rules[name]
20
+ end
21
+
22
+ def enabled_rules(config)
23
+ all.select { |rule| config.rule_enabled?(rule.rule_name) }
24
+ end
25
+
26
+ # Auto-load all rules from rules/ directory
27
+ def load_rules
28
+ rules_path = File.join(__dir__, "rules", "*.rb")
29
+ Dir[rules_path].each { |f| require f }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ module Linter
5
+ module Rules
6
+ # Detects operators without explicit precedence declarations
7
+ class AmbiguousPrecedence < Base
8
+ self.rule_name = "AmbiguousPrecedence"
9
+ self.description = "Detects operators without explicit precedence declarations"
10
+ self.severity = :warning
11
+ self.autocorrectable = false
12
+
13
+ # Common operator patterns (may be quoted or unquoted)
14
+ OPERATOR_PATTERNS = [
15
+ %r{^'[+\-*/%^&|<>=!]+'$}, # Quoted single-character operators
16
+ %r{^"[+\-*/%^&|<>=!]+"$}, # Double-quoted single-character operators
17
+ %r{^[+\-*/%^&|<>=!]+$}, # Unquoted symbolic operators
18
+ /^'(==|!=|<=|>=|<<|>>|\|\||&&)'$/, # Quoted multi-character operators
19
+ /^"(==|!=|<=|>=|<<|>>|\|\||&&)"$/, # Double-quoted multi-character operators
20
+ /^(==|!=|<=|>=|<<|>>|\|\||&&)$/ # Unquoted multi-character operators
21
+ ].freeze
22
+
23
+ def check(ast, _context = {})
24
+ precedence_tokens = collect_precedence_tokens(ast)
25
+ operators = collect_operators(ast)
26
+
27
+ operators.each do |operator, locations|
28
+ next if precedence_tokens.include?(operator)
29
+
30
+ locations.each do |location|
31
+ add_offense_at(
32
+ location,
33
+ message: "Operator '#{operator}' does not have an explicit precedence declaration"
34
+ )
35
+ end
36
+ end
37
+
38
+ @offenses
39
+ end
40
+
41
+ private
42
+
43
+ def collect_precedence_tokens(ast)
44
+ tokens = []
45
+ ast.declarations.each do |decl|
46
+ next unless decl.is_a?(AST::PrecedenceDeclaration)
47
+
48
+ tokens.concat(decl.tokens)
49
+ end
50
+ tokens
51
+ end
52
+
53
+ def collect_operators(ast)
54
+ operators = Hash.new { |h, k| h[k] = [] }
55
+
56
+ ast.rules.each do |rule|
57
+ rule.alternatives.each do |alt|
58
+ alt.symbols.each do |symbol|
59
+ next unless symbol.terminal?
60
+ next unless looks_like_operator?(symbol.name)
61
+
62
+ operators[symbol.name] << (symbol.location || rule.location)
63
+ end
64
+ end
65
+ end
66
+
67
+ operators
68
+ end
69
+
70
+ def looks_like_operator?(name)
71
+ OPERATOR_PATTERNS.any? { |pattern| name.match?(pattern) }
72
+ end
73
+
74
+ def add_offense_at(location, message:)
75
+ offense = Offense.new(
76
+ rule: self.class,
77
+ location: location,
78
+ message: message
79
+ )
80
+ @offenses << offense
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ Collie::Linter::Registry.register(Collie::Linter::Rules::AmbiguousPrecedence)
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ module Linter
5
+ module Rules
6
+ # Detects circular references that lead to infinite recursion
7
+ class CircularReference < Base
8
+ self.rule_name = "CircularReference"
9
+ self.description = "Detects infinite recursion in grammar rules"
10
+ self.severity = :error
11
+ self.autocorrectable = false
12
+
13
+ def check(ast, _context = {})
14
+ @rules_map = build_rules_map(ast)
15
+ @visited = Set.new
16
+ @rec_stack = Set.new
17
+
18
+ ast.rules.each do |rule|
19
+ next if @visited.include?(rule.name)
20
+
21
+ if has_cycle?(rule.name, [])
22
+ add_offense(rule, message: "Rule '#{rule.name}' is part of a circular reference")
23
+ end
24
+ end
25
+
26
+ @offenses
27
+ end
28
+
29
+ private
30
+
31
+ def build_rules_map(ast)
32
+ ast.rules.each_with_object({}) do |rule, map|
33
+ map[rule.name] = rule
34
+ end
35
+ end
36
+
37
+ def has_cycle?(rule_name, path)
38
+ return false if @visited.include?(rule_name)
39
+
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)
43
+
44
+ return false
45
+ end
46
+
47
+ @rec_stack.add(rule_name)
48
+ current_path = path + [rule_name]
49
+
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)
55
+
56
+ # Only check the first symbol for cycles
57
+ first_symbol = alt.symbols.first
58
+ next unless first_symbol&.nonterminal?
59
+
60
+ return true if has_cycle?(first_symbol.name, current_path)
61
+ end
62
+
63
+ @rec_stack.delete(rule_name)
64
+ @visited.add(rule_name)
65
+
66
+ false
67
+ end
68
+
69
+ def has_terminal_or_empty?(alternative)
70
+ return true if alternative.symbols.empty?
71
+
72
+ alternative.symbols.any?(&:terminal?)
73
+ end
74
+
75
+ def pure_nonterminal_cycle?(rule_name)
76
+ rule = @rules_map[rule_name]
77
+ return false unless rule
78
+
79
+ # Check if all alternatives contain only nonterminals
80
+ rule.alternatives.all? do |alt|
81
+ !has_terminal_or_empty?(alt)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ Collie::Linter::Registry.register(Collie::Linter::Rules::CircularReference)
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ module Linter
5
+ module Rules
6
+ # Detects inconsistent type tag naming
7
+ class ConsistentTagNaming < Base
8
+ self.rule_name = "ConsistentTagNaming"
9
+ self.description = "Ensures consistent naming style for type tags"
10
+ self.severity = :convention
11
+ self.autocorrectable = false
12
+
13
+ def check(ast, _context = {})
14
+ tags = collect_type_tags(ast)
15
+ return @offenses if tags.size < 2
16
+
17
+ styles = tags.group_by { |tag, _| detect_style(tag) }
18
+
19
+ # If we have multiple styles, report inconsistency
20
+ add_inconsistency_offense(ast, styles) if styles.size > 1
21
+
22
+ @offenses
23
+ end
24
+
25
+ private
26
+
27
+ def collect_type_tags(ast)
28
+ tags = []
29
+
30
+ ast.declarations.each do |decl|
31
+ if (decl.is_a?(AST::TokenDeclaration) || decl.is_a?(AST::TypeDeclaration)) && decl.type_tag
32
+ tags << [decl.type_tag, decl.location]
33
+ end
34
+ end
35
+
36
+ tags
37
+ end
38
+
39
+ def detect_style(tag)
40
+ return :snake_case if tag.match?(/^[a-z][a-z0-9_]*$/)
41
+ return :camel_case if tag.match?(/^[a-z][a-zA-Z0-9]*$/)
42
+ return :pascal_case if tag.match?(/^[A-Z][a-zA-Z0-9]*$/)
43
+ return :upper_snake_case if tag.match?(/^[A-Z][A-Z0-9_]*$/)
44
+
45
+ :other
46
+ end
47
+
48
+ def add_inconsistency_offense(ast, styles)
49
+ style_names = styles.keys.map(&:to_s).join(", ")
50
+ most_common_style = styles.max_by { |_, tags| tags.size }[0]
51
+
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
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ Collie::Linter::Registry.register(Collie::Linter::Rules::ConsistentTagNaming)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+
5
+ module Collie
6
+ module Linter
7
+ module Rules
8
+ # Detects tokens defined multiple times
9
+ class DuplicateToken < Base
10
+ self.rule_name = "DuplicateToken"
11
+ self.description = "Detects tokens defined multiple times"
12
+ self.severity = :error
13
+ self.autocorrectable = false
14
+
15
+ def check(ast, _context = {})
16
+ seen = {}
17
+
18
+ ast.declarations.each do |decl|
19
+ next unless decl.is_a?(AST::TokenDeclaration)
20
+
21
+ decl.names.each do |name|
22
+ if seen[name]
23
+ add_offense(decl,
24
+ message: "Token '#{name}' already defined at #{seen[name]}")
25
+ else
26
+ seen[name] = decl.location
27
+ end
28
+ end
29
+ end
30
+
31
+ @offenses
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ Collie::Linter::Registry.register(Collie::Linter::Rules::DuplicateToken)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ module Linter
5
+ module Rules
6
+ # Detects empty action blocks
7
+ class EmptyAction < Base
8
+ self.rule_name = "EmptyAction"
9
+ self.description = "Detects empty action blocks { }"
10
+ self.severity = :convention
11
+ self.autocorrectable = true
12
+
13
+ def check(ast, _context = {})
14
+ ast.rules.each do |rule|
15
+ check_rule(rule)
16
+ end
17
+
18
+ @offenses
19
+ end
20
+
21
+ private
22
+
23
+ def check_rule(rule)
24
+ rule.alternatives.each do |alt|
25
+ next unless alt.action
26
+ next unless empty_action?(alt.action)
27
+
28
+ add_offense(
29
+ alt,
30
+ message: "Empty action block can be removed",
31
+ autocorrect: -> { remove_action(alt) }
32
+ )
33
+ end
34
+ end
35
+
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?
40
+
41
+ false
42
+ end
43
+
44
+ def remove_action(alternative)
45
+ alternative.action = nil
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ Collie::Linter::Registry.register(Collie::Linter::Rules::EmptyAction)
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ module Linter
5
+ module Rules
6
+ # Detects rules with common prefixes that could be factored
7
+ class FactorizableRules < Base
8
+ self.rule_name = "FactorizableRules"
9
+ self.description = "Suggests factoring rules with common prefixes"
10
+ self.severity = :info
11
+ self.autocorrectable = false
12
+
13
+ MIN_PREFIX_LENGTH = 2
14
+
15
+ def check(ast, _context = {})
16
+ ast.rules.each do |rule|
17
+ check_rule(rule)
18
+ end
19
+
20
+ @offenses
21
+ end
22
+
23
+ private
24
+
25
+ def check_rule(rule)
26
+ return if rule.alternatives.size < 2
27
+
28
+ # Group alternatives by first symbol
29
+ groups = rule.alternatives.group_by { |alt| alt.symbols.first&.name }
30
+
31
+ groups.each do |first_symbol, alternatives|
32
+ next if alternatives.size < 2
33
+ next unless first_symbol # Skip epsilon alternatives
34
+
35
+ prefix_length = find_common_prefix_length(alternatives)
36
+ next if prefix_length < MIN_PREFIX_LENGTH
37
+
38
+ add_offense(
39
+ rule,
40
+ message: "Rule '#{rule.name}' has #{alternatives.size} alternatives with common prefix " \
41
+ "(#{prefix_length} symbols). Consider factoring."
42
+ )
43
+ break # Only report once per rule
44
+ end
45
+ end
46
+
47
+ def find_common_prefix_length(alternatives)
48
+ return 0 if alternatives.empty?
49
+
50
+ min_length = alternatives.map { |alt| alt.symbols.size }.min
51
+ prefix_length = 0
52
+
53
+ (0...min_length).each do |i|
54
+ symbol_names = alternatives.map { |alt| alt.symbols[i].name }
55
+ break unless symbol_names.uniq.size == 1
56
+
57
+ prefix_length += 1
58
+ end
59
+
60
+ prefix_length
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ Collie::Linter::Registry.register(Collie::Linter::Rules::FactorizableRules)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ module Linter
5
+ module Rules
6
+ # Detects left recursion in grammar rules
7
+ class LeftRecursion < Base
8
+ self.rule_name = "LeftRecursion"
9
+ self.description = "Detects left recursion (may cause issues with some parsers)"
10
+ self.severity = :warning
11
+ self.autocorrectable = false
12
+
13
+ def check(ast, _context = {})
14
+ analyzer = Analyzer::Recursion.new(ast)
15
+ result = analyzer.analyze
16
+
17
+ result[:left_recursive].each do |rule_name|
18
+ rule = ast.rules.find { |r| r.name == rule_name }
19
+ next unless rule
20
+
21
+ add_offense(
22
+ rule,
23
+ message: "Rule '#{rule_name}' uses left recursion (consider using right recursion for LL parsers)"
24
+ )
25
+ end
26
+
27
+ @offenses
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ Collie::Linter::Registry.register(Collie::Linter::Rules::LeftRecursion)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ module Linter
5
+ module Rules
6
+ # Detects rules with too many alternatives
7
+ class LongRule < Base
8
+ self.rule_name = "LongRule"
9
+ self.description = "Detects rules with too many alternatives"
10
+ self.severity = :convention
11
+ self.autocorrectable = false
12
+
13
+ DEFAULT_MAX_ALTERNATIVES = 10
14
+
15
+ def check(ast, _context = {})
16
+ max_alternatives = @config.dig("rules", "LongRule", "max_alternatives") || DEFAULT_MAX_ALTERNATIVES
17
+
18
+ ast.rules.each do |rule|
19
+ alternatives_count = rule.alternatives.size
20
+
21
+ next unless alternatives_count > max_alternatives
22
+
23
+ add_offense(
24
+ rule,
25
+ message: "Rule '#{rule.name}' has #{alternatives_count} alternatives " \
26
+ "(max: #{max_alternatives}). Consider refactoring."
27
+ )
28
+ end
29
+
30
+ @offenses
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ Collie::Linter::Registry.register(Collie::Linter::Rules::LongRule)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collie
4
+ module Linter
5
+ module Rules
6
+ # Detects missing %start declaration when it's ambiguous
7
+ class MissingStartSymbol < Base
8
+ self.rule_name = "MissingStartSymbol"
9
+ self.description = "Detects missing %start declaration with ambiguous default"
10
+ self.severity = :error
11
+ self.autocorrectable = false
12
+
13
+ def check(ast, _context = {})
14
+ has_start = ast.declarations.any? { |decl| decl.is_a?(AST::StartDeclaration) }
15
+
16
+ # If %start is declared, no problem
17
+ return @offenses if has_start
18
+
19
+ # If no rules defined, it's ambiguous
20
+ if ast.rules.empty?
21
+ # Create a pseudo-location since we don't have a specific node
22
+ location = AST::Location.new(file: "grammar", line: 1, column: 1)
23
+ offense = Offense.new(
24
+ rule: self.class,
25
+ location: location,
26
+ message: "No %start declaration and no rules defined"
27
+ )
28
+ @offenses << offense
29
+ end
30
+
31
+ @offenses
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ Collie::Linter::Registry.register(Collie::Linter::Rules::MissingStartSymbol)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+
5
+ module Collie
6
+ module Linter
7
+ module Rules
8
+ # Checks nonterminal naming conventions
9
+ class NonterminalNaming < Base
10
+ self.rule_name = "NonterminalNaming"
11
+ self.description = "Nonterminals should follow snake_case naming convention"
12
+ self.severity = :convention
13
+ self.autocorrectable = false
14
+
15
+ DEFAULT_PATTERN = /^[a-z][a-z0-9_]*$/
16
+
17
+ def check(ast, _context = {})
18
+ pattern = @config[:pattern] ? Regexp.new(@config[:pattern]) : DEFAULT_PATTERN
19
+
20
+ ast.rules.each do |rule|
21
+ next if rule.name.match?(pattern)
22
+
23
+ add_offense(rule,
24
+ message: "Nonterminal '#{rule.name}' should match pattern #{pattern.inspect}")
25
+ end
26
+
27
+ @offenses
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ Collie::Linter::Registry.register(Collie::Linter::Rules::NonterminalNaming)