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