rusa 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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +175 -0
  4. data/Rakefile +26 -0
  5. data/Steepfile +9 -0
  6. data/examples/calc.rb +29 -0
  7. data/examples/json.rb +55 -0
  8. data/examples/mini_lang.rb +52 -0
  9. data/exe/rusa +6 -0
  10. data/lib/rusa/analysis/automaton.rb +60 -0
  11. data/lib/rusa/analysis/conflict_resolver.rb +211 -0
  12. data/lib/rusa/analysis/first_follow.rb +106 -0
  13. data/lib/rusa/analysis/item.rb +51 -0
  14. data/lib/rusa/analysis/item_set.rb +64 -0
  15. data/lib/rusa/analysis/lalr_table.rb +460 -0
  16. data/lib/rusa/analysis/parse_action.rb +81 -0
  17. data/lib/rusa/cli.rb +188 -0
  18. data/lib/rusa/errors.rb +12 -0
  19. data/lib/rusa/generator/code_generator.rb +334 -0
  20. data/lib/rusa/grammar/action_capture.rb +128 -0
  21. data/lib/rusa/grammar/dsl.rb +123 -0
  22. data/lib/rusa/grammar/grammar.rb +212 -0
  23. data/lib/rusa/grammar/precedence.rb +29 -0
  24. data/lib/rusa/grammar/rule.rb +55 -0
  25. data/lib/rusa/grammar/symbol.rb +71 -0
  26. data/lib/rusa/version.rb +5 -0
  27. data/lib/rusa.rb +31 -0
  28. data/sig/generated/rusa/analysis/automaton.rbs +25 -0
  29. data/sig/generated/rusa/analysis/conflict_resolver.rbs +57 -0
  30. data/sig/generated/rusa/analysis/first_follow.rbs +33 -0
  31. data/sig/generated/rusa/analysis/item.rbs +35 -0
  32. data/sig/generated/rusa/analysis/item_set.rbs +31 -0
  33. data/sig/generated/rusa/analysis/lalr_table.rbs +182 -0
  34. data/sig/generated/rusa/analysis/parse_action.rbs +58 -0
  35. data/sig/generated/rusa/cli.rbs +68 -0
  36. data/sig/generated/rusa/errors.rbs +24 -0
  37. data/sig/generated/rusa/generator/code_generator.rbs +82 -0
  38. data/sig/generated/rusa/grammar/action_capture.rbs +46 -0
  39. data/sig/generated/rusa/grammar/dsl.rbs +62 -0
  40. data/sig/generated/rusa/grammar/grammar.rbs +103 -0
  41. data/sig/generated/rusa/grammar/precedence.rbs +23 -0
  42. data/sig/generated/rusa/grammar/rule.rbs +35 -0
  43. data/sig/generated/rusa/grammar/symbol.rbs +51 -0
  44. data/sig/generated/rusa/version.rbs +5 -0
  45. data/sig/generated/rusa.rbs +6 -0
  46. data/test/test_automaton.rb +27 -0
  47. data/test/test_code_generator.rb +74 -0
  48. data/test/test_dsl.rb +77 -0
  49. data/test/test_e2e.rb +134 -0
  50. data/test/test_first_follow.rb +70 -0
  51. data/test/test_grammar_model.rb +60 -0
  52. data/test/test_helper.rb +6 -0
  53. data/test/test_lalr_table.rb +64 -0
  54. metadata +96 -0
@@ -0,0 +1,103 @@
1
+ # Generated from lib/rusa/grammar/grammar.rb with RBS::Inline
2
+
3
+ module Rusa
4
+ module Grammar
5
+ # Grammar keeps the normalized internal representation of the DSL.
6
+ class Grammar
7
+ AUGMENTED_START: ::Symbol
8
+
9
+ END_OF_INPUT: ::Symbol
10
+
11
+ attr_accessor start_symbol: Symbol?
12
+
13
+ attr_reader terminals: Hash[Symbol, TerminalSymbol]
14
+
15
+ attr_reader nonterminals: Hash[Symbol, NonterminalSymbol]
16
+
17
+ attr_reader productions: Array[Production]
18
+
19
+ attr_reader precedences: Hash[Symbol, Precedence]
20
+
21
+ attr_reader skip_patterns: Array[Regexp]
22
+
23
+ attr_reader warnings: Array[String]
24
+
25
+ # : () -> void
26
+ def initialize: () -> void
27
+
28
+ # : (Symbol | String, String | Regexp) -> TerminalSymbol
29
+ def add_terminal: (Symbol | String, String | Regexp) -> TerminalSymbol
30
+
31
+ # : (Symbol | String) -> NonterminalSymbol
32
+ def add_nonterminal: (Symbol | String) -> NonterminalSymbol
33
+
34
+ # : (Production) -> Production
35
+ def add_production: (Production) -> Production
36
+
37
+ # : (Regexp) -> Regexp
38
+ def add_skip_pattern: (Regexp) -> Regexp
39
+
40
+ # : (Symbol | String, Integer, Symbol) -> Precedence
41
+ def set_precedence: (Symbol | String, Integer, Symbol) -> Precedence
42
+
43
+ # : (Symbol | String) -> Precedence?
44
+ def precedence_for: (Symbol | String) -> Precedence?
45
+
46
+ # : (Symbol | String) -> Array[Production]
47
+ def productions_for: (Symbol | String) -> Array[Production]
48
+
49
+ # : () -> self
50
+ def augment!: () -> self
51
+
52
+ # : () -> self
53
+ def validate!: () -> self
54
+
55
+ # : () -> Symbol
56
+ def augmented_start: () -> Symbol
57
+
58
+ # : () -> Production
59
+ def augmented_production: () -> Production
60
+
61
+ # : () -> Hash[Symbol, TerminalSymbol | NonterminalSymbol]
62
+ def symbols: () -> Hash[Symbol, TerminalSymbol | NonterminalSymbol]
63
+
64
+ # : (Symbol | String) -> bool
65
+ def terminal?: (Symbol | String) -> bool
66
+
67
+ # : (Symbol | String) -> bool
68
+ def nonterminal?: (Symbol | String) -> bool
69
+
70
+ # : () -> String
71
+ def dump: () -> String
72
+
73
+ private
74
+
75
+ # : (Symbol) -> String
76
+ def duplicate_nonterminal_message: (Symbol) -> String
77
+
78
+ # : () -> Symbol
79
+ def required_start_symbol: () -> Symbol
80
+
81
+ # : () -> Array[Symbol]
82
+ def undefined_symbols: () -> Array[Symbol]
83
+
84
+ # : (Production) -> Array[Symbol]
85
+ def undefined_symbols_in: (Production) -> Array[Symbol]
86
+
87
+ # : (Symbol) -> bool
88
+ def defined_symbol?: (Symbol) -> bool
89
+
90
+ # : () -> Array[String]
91
+ def unreachable_warnings: () -> Array[String]
92
+
93
+ # : () -> Array[Production]
94
+ def reindex_productions!: () -> Array[Production]
95
+
96
+ # : () -> Array[Symbol]
97
+ def unreachable_nonterminals: () -> Array[Symbol]
98
+
99
+ # : (Symbol, Array[Symbol]) -> Array[Symbol]
100
+ def next_reachable_nonterminals: (Symbol, Array[Symbol]) -> Array[Symbol]
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,23 @@
1
+ # Generated from lib/rusa/grammar/precedence.rb with RBS::Inline
2
+
3
+ module Rusa
4
+ module Grammar
5
+ # Operator precedence metadata used to resolve parser conflicts.
6
+ class Precedence
7
+ attr_reader level: Integer
8
+
9
+ attr_reader associativity: Symbol
10
+
11
+ # : (Integer, Symbol) -> void
12
+ def initialize: (Integer, Symbol) -> void
13
+
14
+ # : (Object) -> bool
15
+ def ==: (Object) -> bool
16
+
17
+ alias eql? ==
18
+
19
+ # : () -> Integer
20
+ def hash: () -> Integer
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,35 @@
1
+ # Generated from lib/rusa/grammar/rule.rb with RBS::Inline
2
+
3
+ module Rusa
4
+ module Grammar
5
+ # Production rules connect one nonterminal with a sequence of symbols.
6
+ class Production
7
+ attr_accessor id: Integer?
8
+
9
+ attr_accessor lhs: Symbol
10
+
11
+ attr_accessor rhs: Array[Symbol]
12
+
13
+ attr_accessor action: Proc?
14
+
15
+ attr_accessor context_precedence: Symbol?
16
+
17
+ attr_accessor action_source: String?
18
+
19
+ # : (Symbol | String, Array[Symbol | String], ?Proc?, ?context_precedence: Symbol | String?, ?action_source: String?) -> void
20
+ def initialize: (Symbol | String, Array[Symbol | String], ?Proc?, ?context_precedence: Symbol | String?, ?action_source: String?) -> void
21
+
22
+ # : (Grammar) -> Symbol?
23
+ def precedence_token: (Grammar) -> Symbol?
24
+
25
+ # : (Grammar) -> Precedence?
26
+ def precedence: (Grammar) -> Precedence?
27
+
28
+ # : () -> bool
29
+ def empty?: () -> bool
30
+
31
+ # : () -> String
32
+ def to_s: () -> String
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,51 @@
1
+ # Generated from lib/rusa/grammar/symbol.rb with RBS::Inline
2
+
3
+ module Rusa
4
+ module Grammar
5
+ # Terminal symbols are matched by the generated tokenizer.
6
+ class TerminalSymbol
7
+ attr_reader name: Symbol
8
+
9
+ attr_reader pattern: Regexp
10
+
11
+ # : (Symbol | String, String | Regexp) -> void
12
+ def initialize: (Symbol | String, String | Regexp) -> void
13
+
14
+ # : () -> bool
15
+ def terminal?: () -> bool
16
+
17
+ # : () -> bool
18
+ def nonterminal?: () -> bool
19
+
20
+ # : (Object) -> bool
21
+ def ==: (Object) -> bool
22
+
23
+ alias eql? ==
24
+
25
+ # : () -> Integer
26
+ def hash: () -> Integer
27
+ end
28
+
29
+ # Nonterminal symbols are expanded by grammar productions.
30
+ class NonterminalSymbol
31
+ attr_reader name: Symbol
32
+
33
+ # : (Symbol | String) -> void
34
+ def initialize: (Symbol | String) -> void
35
+
36
+ # : () -> bool
37
+ def terminal?: () -> bool
38
+
39
+ # : () -> bool
40
+ def nonterminal?: () -> bool
41
+
42
+ # : (Object) -> bool
43
+ def ==: (Object) -> bool
44
+
45
+ alias eql? ==
46
+
47
+ # : () -> Integer
48
+ def hash: () -> Integer
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ # Generated from lib/rusa/version.rb with RBS::Inline
2
+
3
+ module Rusa
4
+ VERSION: ::String
5
+ end
@@ -0,0 +1,6 @@
1
+ # Generated from lib/rusa.rb with RBS::Inline
2
+
3
+ module Rusa
4
+ # : () { (Grammar::DSL) [self: Grammar::DSL] -> void } -> Grammar::Grammar
5
+ def self.grammar: () { (Grammar::DSL) [self: Grammar::DSL] -> void } -> Grammar::Grammar
6
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class AutomatonTest < Minitest::Test
6
+ def test_builds_lr0_states_for_balanced_parens_grammar
7
+ grammar = Rusa.grammar do
8
+ token :LPAREN, "("
9
+ token :RPAREN, ")"
10
+ token :ID, "id"
11
+
12
+ start :s
13
+
14
+ rule :s do
15
+ alt(:LPAREN, :s, :RPAREN)
16
+ alt(:ID)
17
+ end
18
+ end
19
+
20
+ automaton = Rusa::Analysis::Automaton.new(grammar)
21
+
22
+ assert_equal 6, automaton.states.length
23
+ assert_equal({ s: 1, LPAREN: 2, ID: 3 }, automaton.transitions[0])
24
+ assert automaton.states.any? { |state| state.items.any? { |item| item.to_s == "s -> LPAREN s · RPAREN" } }
25
+ assert automaton.states.any? { |state| state.items.any? { |item| item.to_s == "s -> ID ·" } }
26
+ end
27
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require_relative "test_helper"
5
+
6
+ class CodeGeneratorTest < Minitest::Test
7
+ def test_generates_parser_from_file_backed_block_actions
8
+ Dir.mktmpdir do |dir|
9
+ grammar_path = File.join(dir, "calc.rb")
10
+ File.write(grammar_path, <<~RUBY)
11
+ Rusa.grammar do
12
+ token :NUMBER,(/\\d+/)
13
+ token :PLUS, "+"
14
+ token :STAR, "*"
15
+ skip(/\\s+/)
16
+
17
+ left :PLUS
18
+ left :STAR
19
+
20
+ start :expr
21
+
22
+ rule :expr do
23
+ alt(:expr, :PLUS, :expr) { |left, _, right| [:add, left, right] }
24
+ alt(:expr, :STAR, :expr) { |left, _, right| [:mul, left, right] }
25
+ alt(:NUMBER) { |number| number.to_i }
26
+ end
27
+ end
28
+ RUBY
29
+
30
+ grammar = Rusa::CLI.new.send(:load_grammar, grammar_path)
31
+ table = Rusa::Analysis::LALRTable.new(grammar)
32
+ code = Rusa::Generator::CodeGenerator.new(grammar, table, class_name: "GeneratedCalcParser").generate
33
+ parser = build_parser("GeneratedCalcParser", code)
34
+
35
+ assert_equal [:add, 3, [:mul, 4, 5]], parser.parse("3 + 4 * 5")
36
+ end
37
+ end
38
+
39
+ def test_generates_compact_parser_from_action_strings
40
+ grammar = Rusa.grammar do
41
+ token :NUMBER,(/\d+/)
42
+ token :PLUS, "+"
43
+ skip(/\s+/)
44
+
45
+ left :PLUS
46
+ start :expr
47
+
48
+ rule :expr do
49
+ alt(:expr, :PLUS, :expr).action("[:add, _1, _3]")
50
+ alt(:NUMBER).action("_1.to_i")
51
+ end
52
+ end
53
+
54
+ table = Rusa::Analysis::LALRTable.new(grammar)
55
+ code = Rusa::Generator::CodeGenerator.new(
56
+ grammar,
57
+ table,
58
+ class_name: "CompactExprParser",
59
+ compact: true
60
+ ).generate
61
+ parser = build_parser("CompactExprParser", code)
62
+
63
+ assert_includes code, "Base64.decode64"
64
+ assert_equal [:add, [:add, 1, 2], 3], parser.parse("1 + 2 + 3")
65
+ end
66
+
67
+ private
68
+
69
+ def build_parser(class_name, code)
70
+ Object.send(:remove_const, class_name.to_sym) if Object.const_defined?(class_name, false)
71
+ Object.class_eval(code)
72
+ Object.const_get(class_name).new
73
+ end
74
+ end
data/test/test_dsl.rb ADDED
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class DslTest < Minitest::Test
6
+ def test_dsl_builds_grammar_and_augments_it
7
+ grammar = Rusa.grammar do
8
+ token :NUMBER,(/\d+/)
9
+ token :PLUS, "+"
10
+ skip(/\s+/)
11
+ left :PLUS
12
+
13
+ start :expr
14
+
15
+ rule :expr do
16
+ alt(:expr, :PLUS, :expr) { |l, _, r| [:add, l, r] }
17
+ alt(:NUMBER) { |n| n.to_i }
18
+ end
19
+ end
20
+
21
+ assert grammar.terminals.key?(:NUMBER)
22
+ assert grammar.terminals.key?(:PLUS)
23
+ assert grammar.nonterminals.key?(:expr)
24
+ assert grammar.nonterminals.key?(:"$start")
25
+ assert_equal 3, grammar.productions.length
26
+ assert_equal Rusa::Grammar::Precedence.new(1, :left), grammar.precedence_for(:PLUS)
27
+ assert_equal :expr, grammar.start_symbol
28
+ assert_equal 1, grammar.skip_patterns.length
29
+ assert_match(/\Alambda /, grammar.productions[1].action_source)
30
+ end
31
+
32
+ def test_keyword_literals_are_bounded_and_regexes_are_anchored
33
+ grammar = Rusa.grammar do
34
+ token :IF, "if"
35
+ token :IDENT,(/[a-z_]\w*/)
36
+ skip(/\s+/)
37
+ start :expr
38
+
39
+ rule :expr do
40
+ alt(:IDENT) { |value| value }
41
+ end
42
+ end
43
+
44
+ assert_equal "\\Aif\\b", grammar.terminals[:IF].pattern.source
45
+ assert_equal "\\A(?:[a-z_]\\w*)", grammar.terminals[:IDENT].pattern.source
46
+ assert_equal "\\A(?:\\s+)", grammar.skip_patterns.first.source
47
+ end
48
+
49
+ def test_action_capture_returns_nil_for_non_file_backed_proc
50
+ captured = Rusa::Grammar::ActionCapture.capture(eval("proc { |value| value }"))
51
+
52
+ assert_nil captured
53
+ end
54
+
55
+ def test_action_capture_does_not_swallow_unexpected_internal_errors
56
+ action = proc { |value| value }
57
+ action_capture = Rusa::Grammar::ActionCapture
58
+ singleton_class = action_capture.singleton_class
59
+ singleton_class.alias_method(:__extract_block_before_test__, :extract_block)
60
+ singleton_class.send(:remove_method, :extract_block)
61
+ singleton_class.define_method(:extract_block) { |_source| raise "boom" }
62
+
63
+ error = assert_raises(RuntimeError) do
64
+ action_capture.capture(action)
65
+ end
66
+
67
+ assert_equal "boom", error.message
68
+ ensure
69
+ if singleton_class.method_defined?(:extract_block)
70
+ singleton_class.send(:remove_method, :extract_block)
71
+ end
72
+ if singleton_class.method_defined?(:__extract_block_before_test__)
73
+ singleton_class.alias_method(:extract_block, :__extract_block_before_test__)
74
+ singleton_class.send(:remove_method, :__extract_block_before_test__)
75
+ end
76
+ end
77
+ end
data/test/test_e2e.rb ADDED
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require_relative "test_helper"
5
+
6
+ class E2ETest < Minitest::Test
7
+ def test_cli_generates_calc_parser_and_parses_examples
8
+ Dir.mktmpdir do |dir|
9
+ output_path = File.join(dir, "calc_parser.rb")
10
+ status, = run_cli(
11
+ "generate",
12
+ "examples/calc.rb",
13
+ "-o",
14
+ output_path,
15
+ "--class",
16
+ "ExampleCalcParser"
17
+ )
18
+
19
+ assert_equal 0, status
20
+ parser = load_generated_parser("ExampleCalcParser", output_path)
21
+
22
+ assert_equal [:add, 1, 2], parser.parse("1 + 2")
23
+ assert_equal [:add, 1, [:mul, 2, 3]], parser.parse("1 + 2 * 3")
24
+ assert_equal [:mul, [:add, 1, 2], 3], parser.parse("(1 + 2) * 3")
25
+ assert_equal [:add, [:add, 1, 2], 3], parser.parse("1 + 2 + 3")
26
+ end
27
+ end
28
+
29
+ def test_generated_parser_reports_parse_and_lex_errors
30
+ Dir.mktmpdir do |dir|
31
+ output_path = File.join(dir, "calc_parser.rb")
32
+ run_cli(
33
+ "generate",
34
+ "examples/calc.rb",
35
+ "-o",
36
+ output_path,
37
+ "--class",
38
+ "ErrorCalcParser"
39
+ )
40
+ parser = load_generated_parser("ErrorCalcParser", output_path)
41
+
42
+ parse_error = assert_raises(ErrorCalcParser::ParseError) { parser.parse("1 + + 2") }
43
+ lex_error = assert_raises(ErrorCalcParser::ParseError) { parser.parse("1 @ 2") }
44
+
45
+ assert_includes parse_error.message, "line 1, column 5"
46
+ assert_includes lex_error.message, "Unexpected character"
47
+ end
48
+ end
49
+
50
+ def test_epsilon_grammar_parses_optional_prefix
51
+ Dir.mktmpdir do |dir|
52
+ grammar_path = File.join(dir, "optional.rb")
53
+ parser_path = File.join(dir, "optional_parser.rb")
54
+
55
+ File.write(grammar_path, <<~RUBY)
56
+ Rusa.grammar do
57
+ token :A, "a"
58
+ token :B, "b"
59
+ skip(/\\s+/)
60
+
61
+ start :s
62
+
63
+ rule :s do
64
+ alt(:a, :B) { |prefix, b| [prefix, b] }
65
+ end
66
+
67
+ rule :a do
68
+ alt(:A) { |a| a }
69
+ alt
70
+ end
71
+ end
72
+ RUBY
73
+
74
+ run_cli(
75
+ "generate",
76
+ grammar_path,
77
+ "-o",
78
+ parser_path,
79
+ "--class",
80
+ "OptionalParser"
81
+ )
82
+ parser = load_generated_parser("OptionalParser", parser_path)
83
+
84
+ assert_equal ["a", "b"], parser.parse("a b")
85
+ assert_equal [nil, "b"], parser.parse("b")
86
+ end
87
+ end
88
+
89
+ def test_report_command_prints_conflict_information
90
+ Dir.mktmpdir do |dir|
91
+ grammar_path = File.join(dir, "dangling_else.rb")
92
+ File.write(grammar_path, <<~RUBY)
93
+ Rusa.grammar do
94
+ token :IF, "if"
95
+ token :THEN, "then"
96
+ token :ELSE, "else"
97
+ token :ID,(/[a-z]+/)
98
+ skip(/\\s+/)
99
+
100
+ start :stmt
101
+
102
+ rule :stmt do
103
+ alt(:IF, :ID, :THEN, :stmt, :ELSE, :stmt)
104
+ alt(:IF, :ID, :THEN, :stmt)
105
+ alt(:ID)
106
+ end
107
+ end
108
+ RUBY
109
+
110
+ status, stdout = run_cli("report", grammar_path)
111
+
112
+ assert_equal 0, status
113
+ assert_includes stdout, "Conflict report:"
114
+ assert_includes stdout, "default shift"
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def load_generated_parser(class_name, path)
121
+ Object.send(:remove_const, class_name.to_sym) if Object.const_defined?(class_name, false)
122
+ Object.class_eval(File.read(path), path)
123
+ Object.const_get(class_name).new
124
+ end
125
+
126
+ def run_cli(*argv)
127
+ status = nil
128
+ stdout, stderr = capture_io do
129
+ status = Rusa::CLI.new.run(argv)
130
+ end
131
+
132
+ [status, stdout, stderr]
133
+ end
134
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class FirstFollowTest < Minitest::Test
6
+ def test_expression_first_and_follow_sets
7
+ grammar = Rusa.grammar do
8
+ token :ID, "id"
9
+ token :PLUS, "+"
10
+ token :STAR, "*"
11
+ token :LPAREN, "("
12
+ token :RPAREN, ")"
13
+
14
+ start :e
15
+
16
+ rule :e do
17
+ alt(:e, :PLUS, :t)
18
+ alt(:t)
19
+ end
20
+
21
+ rule :t do
22
+ alt(:t, :STAR, :f)
23
+ alt(:f)
24
+ end
25
+
26
+ rule :f do
27
+ alt(:LPAREN, :e, :RPAREN)
28
+ alt(:ID)
29
+ end
30
+ end
31
+
32
+ first_follow = Rusa::Analysis::FirstFollow.new(grammar)
33
+
34
+ assert_equal Set[:LPAREN, :ID], first_follow.first_sets[:f]
35
+ assert_equal Set[:LPAREN, :ID], first_follow.first_sets[:t]
36
+ assert_equal Set[:LPAREN, :ID], first_follow.first_sets[:e]
37
+ assert_equal Set[:$end, :PLUS, :RPAREN], first_follow.follow_sets[:e]
38
+ assert_equal Set[:$end, :PLUS, :STAR, :RPAREN], first_follow.follow_sets[:t]
39
+ assert_equal Set[:$end, :PLUS, :STAR, :RPAREN], first_follow.follow_sets[:f]
40
+ end
41
+
42
+ def test_epsilon_productions_flow_through_sequence_first_sets
43
+ grammar = Rusa.grammar do
44
+ token :A, "a"
45
+ token :B, "b"
46
+
47
+ start :s
48
+
49
+ rule :s do
50
+ alt(:a, :b)
51
+ end
52
+
53
+ rule :a do
54
+ alt(:A)
55
+ alt
56
+ end
57
+
58
+ rule :b do
59
+ alt(:B)
60
+ alt
61
+ end
62
+ end
63
+
64
+ first_follow = Rusa::Analysis::FirstFollow.new(grammar)
65
+
66
+ assert_equal Set[:A, :B, Rusa::Analysis::FirstFollow::EMPTY], first_follow.first_sets[:s]
67
+ assert_equal Set[:B, :$end], first_follow.follow_sets[:a]
68
+ assert_equal Set[:$end], first_follow.follow_sets[:b]
69
+ end
70
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class GrammarModelTest < Minitest::Test
6
+ def test_symbol_types_compare_by_name_and_type
7
+ number_a = Rusa::Grammar::TerminalSymbol.new(:NUMBER, /\d+/)
8
+ number_b = Rusa::Grammar::TerminalSymbol.new(:NUMBER, /\d+/)
9
+ expr = Rusa::Grammar::NonterminalSymbol.new(:expr)
10
+
11
+ assert_equal number_a, number_b
12
+ refute_equal number_a, expr
13
+ assert number_a.terminal?
14
+ refute number_a.nonterminal?
15
+ assert expr.nonterminal?
16
+ refute expr.terminal?
17
+ end
18
+
19
+ def test_production_uses_context_precedence_or_rightmost_terminal
20
+ grammar = Rusa::Grammar::Grammar.new
21
+ grammar.add_terminal(:PLUS, /\+/)
22
+ grammar.add_terminal(:STAR, /\*/)
23
+
24
+ plain = Rusa::Grammar::Production.new(:expr, [:expr, :PLUS, :expr, :STAR])
25
+ explicit = Rusa::Grammar::Production.new(:expr, [:MINUS, :expr], context_precedence: :UMINUS)
26
+
27
+ assert_equal :STAR, plain.precedence_token(grammar)
28
+ assert_equal :UMINUS, explicit.precedence_token(grammar)
29
+ end
30
+
31
+ def test_grammar_tracks_rules_and_validation
32
+ grammar = Rusa::Grammar::Grammar.new
33
+ grammar.add_terminal(:NUMBER, /\d+/)
34
+ grammar.add_terminal(:PLUS, /\+/)
35
+ grammar.start_symbol = :expr
36
+ grammar.add_nonterminal(:expr)
37
+ grammar.add_nonterminal(:unused)
38
+ grammar.add_production(Rusa::Grammar::Production.new(:expr, [:expr, :PLUS, :expr]))
39
+ grammar.add_production(Rusa::Grammar::Production.new(:expr, [:NUMBER]))
40
+
41
+ grammar.augment!
42
+ grammar.validate!
43
+
44
+ assert_equal 3, grammar.productions.length
45
+ assert_equal :expr, grammar.start_symbol
46
+ assert_includes grammar.warnings, "nonterminal unused is unreachable from expr"
47
+ end
48
+
49
+ def test_validation_raises_for_undefined_symbols
50
+ grammar = Rusa::Grammar::Grammar.new
51
+ grammar.add_terminal(:NUMBER, /\d+/)
52
+ grammar.start_symbol = :expr
53
+ grammar.add_nonterminal(:expr)
54
+ grammar.add_production(Rusa::Grammar::Production.new(:expr, [:NUMBER, :MISSING]))
55
+ grammar.augment!
56
+
57
+ error = assert_raises(Rusa::UndefinedSymbolError) { grammar.validate! }
58
+ assert_includes error.message, "MISSING"
59
+ end
60
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+
5
+ require "minitest/autorun"
6
+ require "rusa"