ibex 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 (118) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +43 -0
  3. data/CHANGELOG.md +30 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +194 -0
  6. data/Rakefile +25 -0
  7. data/Steepfile +10 -0
  8. data/docs/architecture.md +99 -0
  9. data/docs/compat-notes.md +37 -0
  10. data/docs/grammar-reference.md +127 -0
  11. data/docs/lexer-coverage.md +14 -0
  12. data/docs/phase10-extensions.md +27 -0
  13. data/docs/racc-migration.md +47 -0
  14. data/exe/ibex +6 -0
  15. data/gemfiles/Gemfile +7 -0
  16. data/gemfiles/Gemfile.lock +98 -0
  17. data/lib/ibex/analysis/sets.rb +194 -0
  18. data/lib/ibex/analysis.rb +8 -0
  19. data/lib/ibex/cli/counterexample_options.rb +38 -0
  20. data/lib/ibex/cli/outputs.rb +88 -0
  21. data/lib/ibex/cli.rb +333 -0
  22. data/lib/ibex/codegen/dot.rb +52 -0
  23. data/lib/ibex/codegen/html.rb +110 -0
  24. data/lib/ibex/codegen/rbs.rb +54 -0
  25. data/lib/ibex/codegen/report.rb +149 -0
  26. data/lib/ibex/codegen/ruby.rb +235 -0
  27. data/lib/ibex/codegen/symbol_labels.rb +18 -0
  28. data/lib/ibex/error.rb +7 -0
  29. data/lib/ibex/frontend/action_scanner.rb +266 -0
  30. data/lib/ibex/frontend/ast.rb +146 -0
  31. data/lib/ibex/frontend/bootstrap_parser.rb +163 -0
  32. data/lib/ibex/frontend/dsl.rb +196 -0
  33. data/lib/ibex/frontend/generated_parser.rb +434 -0
  34. data/lib/ibex/frontend/generated_parser_base.rb +273 -0
  35. data/lib/ibex/frontend/grammar.y +156 -0
  36. data/lib/ibex/frontend/lexer.rb +165 -0
  37. data/lib/ibex/frontend/parser/declarations.rb +163 -0
  38. data/lib/ibex/frontend/parser/rules.rb +154 -0
  39. data/lib/ibex/frontend/parser.rb +23 -0
  40. data/lib/ibex/frontend/regenerator.rb +41 -0
  41. data/lib/ibex/frontend/source_cursor.rb +94 -0
  42. data/lib/ibex/frontend/token_adapter/declaration_state.rb +248 -0
  43. data/lib/ibex/frontend/token_adapter/delimiter_tracker.rb +37 -0
  44. data/lib/ibex/frontend/token_adapter/rule_state.rb +162 -0
  45. data/lib/ibex/frontend/token_adapter.rb +108 -0
  46. data/lib/ibex/frontend.rb +28 -0
  47. data/lib/ibex/ir/automaton_ir.rb +109 -0
  48. data/lib/ibex/ir/grammar_ir.rb +192 -0
  49. data/lib/ibex/ir/serialize.rb +154 -0
  50. data/lib/ibex/ir.rb +53 -0
  51. data/lib/ibex/lalr/builder.rb +270 -0
  52. data/lib/ibex/lalr/conflict.rb +120 -0
  53. data/lib/ibex/lalr/conflict_search.rb +332 -0
  54. data/lib/ibex/lalr/conflict_search_limits.rb +39 -0
  55. data/lib/ibex/lalr/counterexample.rb +138 -0
  56. data/lib/ibex/lalr/default_reductions.rb +67 -0
  57. data/lib/ibex/lalr.rb +27 -0
  58. data/lib/ibex/normalize/declarations.rb +60 -0
  59. data/lib/ibex/normalize/diagnostics.rb +99 -0
  60. data/lib/ibex/normalize/expander.rb +218 -0
  61. data/lib/ibex/normalize/expression.rb +54 -0
  62. data/lib/ibex/normalize.rb +166 -0
  63. data/lib/ibex/runtime/parser.rb +360 -0
  64. data/lib/ibex/runtime.rb +8 -0
  65. data/lib/ibex/tables.rb +125 -0
  66. data/lib/ibex/version.rb +6 -0
  67. data/lib/ibex.rb +23 -0
  68. data/sig/ibex/analysis/sets.rbs +72 -0
  69. data/sig/ibex/analysis.rbs +6 -0
  70. data/sig/ibex/cli/counterexample_options.rbs +16 -0
  71. data/sig/ibex/cli/outputs.rbs +31 -0
  72. data/sig/ibex/cli.rbs +108 -0
  73. data/sig/ibex/codegen/dot.rbs +19 -0
  74. data/sig/ibex/codegen/html.rbs +35 -0
  75. data/sig/ibex/codegen/rbs.rbs +31 -0
  76. data/sig/ibex/codegen/report.rbs +39 -0
  77. data/sig/ibex/codegen/ruby.rbs +92 -0
  78. data/sig/ibex/codegen/symbol_labels.rbs +11 -0
  79. data/sig/ibex/error.rbs +7 -0
  80. data/sig/ibex/frontend/action_scanner.rbs +74 -0
  81. data/sig/ibex/frontend/ast.rbs +223 -0
  82. data/sig/ibex/frontend/bootstrap_parser.rbs +80 -0
  83. data/sig/ibex/frontend/dsl.rbs +124 -0
  84. data/sig/ibex/frontend/generated_parser.rbs +163 -0
  85. data/sig/ibex/frontend/generated_parser_base.rbs +114 -0
  86. data/sig/ibex/frontend/lexer.rbs +61 -0
  87. data/sig/ibex/frontend/parser/declarations.rbs +56 -0
  88. data/sig/ibex/frontend/parser/rules.rbs +48 -0
  89. data/sig/ibex/frontend/parser.rbs +16 -0
  90. data/sig/ibex/frontend/regenerator.rbs +16 -0
  91. data/sig/ibex/frontend/source_cursor.rbs +73 -0
  92. data/sig/ibex/frontend/token_adapter/declaration_state.rbs +97 -0
  93. data/sig/ibex/frontend/token_adapter/delimiter_tracker.rbs +25 -0
  94. data/sig/ibex/frontend/token_adapter/rule_state.rbs +77 -0
  95. data/sig/ibex/frontend/token_adapter.rbs +67 -0
  96. data/sig/ibex/frontend.rbs +15 -0
  97. data/sig/ibex/ir/automaton_ir.rbs +77 -0
  98. data/sig/ibex/ir/grammar_ir.rbs +143 -0
  99. data/sig/ibex/ir/serialize.rbs +50 -0
  100. data/sig/ibex/ir.rbs +43 -0
  101. data/sig/ibex/lalr/builder.rbs +85 -0
  102. data/sig/ibex/lalr/conflict.rbs +47 -0
  103. data/sig/ibex/lalr/conflict_search.rbs +131 -0
  104. data/sig/ibex/lalr/conflict_search_limits.rbs +29 -0
  105. data/sig/ibex/lalr/counterexample.rbs +53 -0
  106. data/sig/ibex/lalr/default_reductions.rbs +20 -0
  107. data/sig/ibex/lalr.rbs +23 -0
  108. data/sig/ibex/normalize/declarations.rbs +20 -0
  109. data/sig/ibex/normalize/diagnostics.rbs +32 -0
  110. data/sig/ibex/normalize/expander.rbs +66 -0
  111. data/sig/ibex/normalize/expression.rbs +21 -0
  112. data/sig/ibex/normalize.rbs +96 -0
  113. data/sig/ibex/runtime/parser.rbs +167 -0
  114. data/sig/ibex/runtime.rbs +6 -0
  115. data/sig/ibex/tables.rbs +50 -0
  116. data/sig/ibex/version.rbs +6 -0
  117. data/sig/ibex.rbs +6 -0
  118. metadata +161 -0
data/lib/ibex/cli.rb ADDED
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../ibex"
5
+ require_relative "cli/counterexample_options"
6
+ require_relative "cli/outputs"
7
+
8
+ module Ibex
9
+ # @rbs!
10
+ # interface _CLIOutput
11
+ # def puts: (*untyped) -> untyped
12
+ # def write: (String) -> untyped
13
+ # end
14
+ # type cli_options = {
15
+ # emit: String,
16
+ # mode: Symbol,
17
+ # table: Symbol,
18
+ # line_convert: bool,
19
+ # ?line_convert_all: bool,
20
+ # counterexample_max_tokens: Integer,
21
+ # counterexample_max_configurations: Integer,
22
+ # ?from: String,
23
+ # ?algorithm: Symbol,
24
+ # ?warnings: Array[Symbol],
25
+ # ?output: String,
26
+ # ?embedded: bool,
27
+ # ?debug: bool,
28
+ # ?verbose: bool,
29
+ # ?rbs: String | true,
30
+ # ?dot: String,
31
+ # ?html: String,
32
+ # ?log_file: String,
33
+ # ?executable: String,
34
+ # ?frozen: bool,
35
+ # ?omit_actions: bool,
36
+ # ?superclass: String,
37
+ # ?check_only: bool,
38
+ # ?status: bool,
39
+ # ?profile: bool,
40
+ # ?debug_flags: String,
41
+ # ?version: bool,
42
+ # ?runtime_version: bool,
43
+ # ?copyright: bool,
44
+ # ?help: bool
45
+ # }
46
+
47
+ # Command-line pipeline coordinator.
48
+ # rubocop:disable Metrics/ClassLength -- inline type contracts add lines without adding runtime responsibilities.
49
+ class CLI
50
+ include CLICounterexampleOptions
51
+ include CLIOutputs
52
+
53
+ # @rbs @stdout: _CLIOutput
54
+ # @rbs @stderr: _CLIOutput
55
+ # @rbs @options: cli_options
56
+
57
+ # @rbs (Array[String] arguments, ?stdout: _CLIOutput, ?stderr: _CLIOutput) -> Integer
58
+ def self.start(arguments, stdout: $stdout, stderr: $stderr)
59
+ new(stdout: stdout, stderr: stderr).run(arguments)
60
+ end
61
+
62
+ # @rbs (stdout: _CLIOutput, stderr: _CLIOutput) -> void
63
+ def initialize(stdout:, stderr:)
64
+ @stdout = stdout
65
+ @stderr = stderr
66
+ @options = { emit: "ruby", mode: :racc, table: :compact, line_convert: true }
67
+ .merge(CLICounterexampleOptions::DEFAULTS)
68
+ end
69
+
70
+ # @rbs (Array[String] arguments) -> Integer
71
+ def run(arguments)
72
+ parser = option_parser
73
+ remaining = parser.parse(arguments)
74
+ information = informational_result(parser)
75
+ return information unless information.nil?
76
+
77
+ process_grammar(input_path(remaining))
78
+ rescue OptionParser::ParseError, Ibex::Error, Errno::ENOENT => e
79
+ @stderr.puts(e.message)
80
+ 1
81
+ end
82
+
83
+ private
84
+
85
+ # @rbs () -> OptionParser
86
+ def option_parser
87
+ OptionParser.new do |options|
88
+ options.banner = "Usage: ibex [options] grammarfile"
89
+ add_pipeline_options(options)
90
+ add_output_options(options)
91
+ add_compatibility_options(options)
92
+ add_information_options(options)
93
+ end
94
+ end
95
+
96
+ # @rbs (OptionParser options) -> void
97
+ def add_pipeline_options(options)
98
+ options.on("--emit=FORMAT", "ast, grammar-ir, automaton-ir, or ruby") { |value| @options[:emit] = value }
99
+ options.on("--from=FORMAT", %w[grammar-ir automaton-ir], "resume from IR JSON") do |value|
100
+ @options[:from] = value
101
+ end
102
+ options.on("--mode=MODE", %w[racc extended], "grammar mode") { |value| @options[:mode] = value.to_sym }
103
+ options.on("--table=FORMAT", %w[plain compact], "parser table format") do |value|
104
+ @options[:table] = value.to_sym
105
+ end
106
+ options.on("--algorithm=NAME", %w[slr lalr lr1], "parser construction algorithm") do |value|
107
+ @options[:algorithm] = value.to_sym
108
+ end
109
+ options.on("--warnings=CATEGORIES", "all, error, all,error, or none") do |value|
110
+ @options[:warnings] = warning_categories(value)
111
+ end
112
+ end
113
+
114
+ # @rbs (OptionParser options) -> void
115
+ def add_output_options(options)
116
+ options.on("-o", "--output-file=FILE", "generated parser path") { |value| @options[:output] = value }
117
+ options.on("-E", "--embedded", "embed the Pure Ruby runtime") { @options[:embedded] = true }
118
+ options.on("-t", "--debug", "generate a debug-capable parser") { @options[:debug] = true }
119
+ options.on("-g", "obsolete alias for --debug") { @options[:debug] = true }
120
+ options.on("-v", "--verbose", "write an automaton report") { @options[:verbose] = true }
121
+ add_counterexample_options(options)
122
+ options.on("--rbs[=FILE]", "write an RBS signature (defaults beside parser)") do |value|
123
+ @options[:rbs] = value || true
124
+ end
125
+ options.on("--dot=FILE", "write Graphviz DOT") { |value| @options[:dot] = value }
126
+ options.on("--html=FILE", "write a self-contained HTML report") { |value| @options[:html] = value }
127
+ options.on("-O", "--log-file=FILE", "automaton report path") do |value|
128
+ @options[:verbose] = true
129
+ @options[:log_file] = value
130
+ end
131
+ options.on("-e", "--executable [RUBY]", "add a shebang") do |value|
132
+ @options[:executable] = value || "/usr/bin/env ruby"
133
+ end
134
+ end
135
+
136
+ # @rbs (OptionParser options) -> void
137
+ def add_compatibility_options(options)
138
+ options.on("-F", "--frozen", "emit frozen string literals") { @options[:frozen] = true }
139
+ options.on("--line-convert-all", "convert all source lines") do
140
+ @options[:line_convert] = true
141
+ @options[:line_convert_all] = true
142
+ end
143
+ options.on("-l", "--no-line-convert", "use generated-file action lines") do
144
+ @options[:line_convert] = false
145
+ @options[:line_convert_all] = false
146
+ end
147
+ options.on("-a", "--no-omit-actions", "generate implicit action methods") { @options[:omit_actions] = false }
148
+ options.on("--superclass=CLASS", "override parser superclass") { |value| @options[:superclass] = value }
149
+ options.on("-C", "--check-only", "check grammar and exit") { @options[:check_only] = true }
150
+ options.on("-S", "--output-status", "show pipeline status") { @options[:status] = true }
151
+ options.on("-P", "accept the compatibility profiling flag") { @options[:profile] = true }
152
+ options.on("-D FLAGS", "accept internal compatibility flags") { |value| @options[:debug_flags] = value }
153
+ end
154
+
155
+ # @rbs (OptionParser options) -> void
156
+ def add_information_options(options)
157
+ options.on("--version", "show version") { @options[:version] = true }
158
+ options.on("--runtime-version", "show runtime version") { @options[:runtime_version] = true }
159
+ options.on("--copyright", "show copyright") { @options[:copyright] = true }
160
+ options.on("--help", "show help") { @options[:help] = true }
161
+ end
162
+
163
+ # @rbs (OptionParser parser) -> Integer?
164
+ def informational_result(parser)
165
+ return print_version if @options[:version] || @options[:runtime_version]
166
+ return print_copyright if @options[:copyright]
167
+ return print_help(parser) if @options[:help]
168
+
169
+ nil
170
+ end
171
+
172
+ # @rbs (Array[String] remaining) -> String
173
+ def input_path(remaining)
174
+ path = remaining.first || raise(Ibex::Error, "(cli):1:1: grammar file is required")
175
+ raise Ibex::Error, "(cli):1:1: only one grammar file may be specified" if remaining.length > 1
176
+
177
+ path
178
+ end
179
+
180
+ # @rbs (String path) -> Integer
181
+ def process_grammar(path)
182
+ return process_ir(path) if @options[:from]
183
+
184
+ report_status("reading #{path}")
185
+ ast = Frontend::Parser.new(File.read(path), file: path, mode: @options[:mode]).parse
186
+ return emit_ast(ast) if @options[:emit] == "ast"
187
+
188
+ grammar = Normalizer.new(ast, mode: @options[:mode]).normalize
189
+ dispatch_grammar(grammar, path)
190
+ end
191
+
192
+ # @rbs (String path) -> Integer
193
+ def process_ir(path)
194
+ report_status("reading #{path}")
195
+ value = IR::Serialize.load(File.read(path))
196
+ expected = @options[:from] == "grammar-ir" ? IR::Grammar : IR::Automaton
197
+ raise Ibex::Error, "#{path}:1:1: expected #{@options[:from]} input" unless value.is_a?(expected)
198
+
199
+ return dispatch_grammar(value, path) if value.is_a?(IR::Grammar)
200
+
201
+ dispatch_automaton(value, path)
202
+ end
203
+
204
+ # @rbs (IR::Grammar grammar, String path) -> Integer
205
+ def dispatch_grammar(grammar, path)
206
+ handle_grammar_warnings(grammar, path)
207
+ return 0 if @options[:check_only]
208
+ return emit_grammar(grammar) if @options[:emit] == "grammar-ir"
209
+ return emit_automaton(grammar, path) if @options[:emit] == "automaton-ir"
210
+ return emit_ruby(grammar, path) if @options[:emit] == "ruby"
211
+
212
+ raise Ibex::Error, "(cli):1:1: emit format #{@options[:emit].inspect} is not available yet"
213
+ end
214
+
215
+ # @rbs (IR::Automaton automaton, String path) -> Integer
216
+ def dispatch_automaton(automaton, path)
217
+ handle_grammar_warnings(automaton.grammar, path)
218
+ return 0 if @options[:check_only]
219
+ return emit_grammar(automaton.grammar) if @options[:emit] == "grammar-ir"
220
+ return emit_loaded_automaton(automaton, path) if @options[:emit] == "automaton-ir"
221
+
222
+ if @options[:emit] == "ruby"
223
+ prepare_loaded_automaton(automaton, path)
224
+ return generate_ruby(automaton, path)
225
+ end
226
+
227
+ raise Ibex::Error, "(cli):1:1: AST cannot be reconstructed from Automaton IR"
228
+ end
229
+
230
+ # @rbs () -> Integer
231
+ def print_version
232
+ @stdout.puts("ibex #{VERSION}")
233
+ 0
234
+ end
235
+
236
+ # @rbs (OptionParser parser) -> Integer
237
+ def print_help(parser)
238
+ @stdout.puts(parser)
239
+ 0
240
+ end
241
+
242
+ # @rbs () -> Integer
243
+ def print_copyright
244
+ @stdout.puts("Ibex #{VERSION} Copyright (c) 2026 Yudai Takada")
245
+ 0
246
+ end
247
+
248
+ # @rbs (Frontend::AST::Root ast) -> Integer
249
+ def emit_ast(ast)
250
+ @stdout.puts(JSON.pretty_generate(ast.to_h))
251
+ 0
252
+ end
253
+
254
+ # @rbs (IR::Grammar grammar) -> Integer
255
+ def emit_grammar(grammar)
256
+ @stdout.write(IR::Serialize.dump(grammar))
257
+ 0
258
+ end
259
+
260
+ # @rbs (IR::Grammar grammar, String input_path) -> Integer
261
+ def emit_automaton(grammar, input_path)
262
+ @stdout.write(IR::Serialize.dump(build_automaton(grammar, input_path)))
263
+ 0
264
+ end
265
+
266
+ # @rbs (IR::Grammar grammar, String input_path) -> Integer
267
+ def emit_ruby(grammar, input_path)
268
+ automaton = build_automaton(grammar, input_path)
269
+ generate_ruby(automaton, input_path)
270
+ end
271
+
272
+ # @rbs (IR::Automaton automaton, String input_path) -> Integer
273
+ def generate_ruby(automaton, input_path)
274
+ source = Codegen::Ruby.new(
275
+ automaton, table: @options[:table], embedded: @options.fetch(:embedded, false),
276
+ line_convert: @options.fetch(:line_convert), debug: @options.fetch(:debug, false),
277
+ line_convert_all: @options.fetch(:line_convert_all, false),
278
+ omit_action_call: @options[:omit_actions], superclass: @options[:superclass],
279
+ executable: @options[:executable]
280
+ ).generate
281
+ output_path = @options[:output] || default_output_path(input_path, ".rb")
282
+ File.write(output_path, source)
283
+ File.chmod(0o755, output_path) if @options[:executable]
284
+ report_status("wrote #{output_path}")
285
+ write_rbs(automaton, output_path) if @options[:rbs]
286
+ 0
287
+ end
288
+
289
+ # @rbs (IR::Automaton automaton, String output_path) -> void
290
+ def write_rbs(automaton, output_path)
291
+ configured_path = @options[:rbs]
292
+ path = configured_path == true ? default_output_path(output_path, ".rbs") : configured_path
293
+ raise ArgumentError, "RBS output path is required" unless path.is_a?(String)
294
+
295
+ source = Codegen::RBS.new(automaton, superclass: @options[:superclass]).generate
296
+ File.write(path, source)
297
+ report_status("wrote #{path}")
298
+ end
299
+
300
+ # @rbs (IR::Automaton automaton, String input_path) -> Integer
301
+ def emit_loaded_automaton(automaton, input_path)
302
+ prepare_loaded_automaton(automaton, input_path)
303
+ @stdout.write(IR::Serialize.dump(automaton))
304
+ 0
305
+ end
306
+
307
+ # @rbs (IR::Grammar grammar, String input_path) -> IR::Automaton
308
+ def build_automaton(grammar, input_path)
309
+ report_status("building LALR automaton")
310
+ automaton = LALR::Builder.new(grammar, algorithm: @options[:algorithm] || :lalr).build
311
+ report_conflicts(automaton, input_path)
312
+ write_report(automaton, input_path) if @options[:verbose]
313
+ write_visualizations(automaton)
314
+ automaton
315
+ end
316
+
317
+ # @rbs (IR::Automaton automaton, String input_path) -> void
318
+ def prepare_loaded_automaton(automaton, input_path)
319
+ report_conflicts(automaton, input_path)
320
+ write_report(automaton, input_path) if @options[:verbose]
321
+ write_visualizations(automaton)
322
+ end
323
+
324
+ # @rbs (IR::Automaton automaton) -> void
325
+ def write_visualizations(automaton)
326
+ dot_path = @options[:dot]
327
+ html_path = @options[:html]
328
+ File.write(dot_path, Codegen::Dot.render(automaton)) if dot_path
329
+ File.write(html_path, Codegen::HTML.render(automaton)) if html_path
330
+ end
331
+ end
332
+ # rubocop:enable Metrics/ClassLength
333
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "symbol_labels"
4
+
5
+ module Ibex
6
+ module Codegen
7
+ # Renders Graphviz DOT from Automaton IR.
8
+ module Dot
9
+ # @rbs!
10
+ # private def escape: (String value) -> String
11
+ # private def self.escape: (String value) -> String
12
+ # private def symbol_name: (Hash[Integer, String] labels, Integer id) -> String
13
+ # private def self.symbol_name: (Hash[Integer, String] labels, Integer id) -> String
14
+
15
+ # @rbs (IR::Automaton automaton) -> String
16
+ def render(automaton)
17
+ labels = SymbolLabels.build(automaton.grammar)
18
+ lines = ["digraph ibex_automaton {", " rankdir=LR;", " node [shape=box];"]
19
+ automaton.states.each do |state|
20
+ attributes = ["label=\"State #{state.id}\""]
21
+ attributes << "color=red" unless state.conflicts.empty?
22
+ lines << " state_#{state.id} [#{attributes.join(', ')}];"
23
+ state.transitions.each do |symbol_id, target|
24
+ label = escape(symbol_name(labels, symbol_id))
25
+ lines << " state_#{state.id} -> state_#{target} [label=\"#{label}\"];"
26
+ end
27
+ end
28
+ lines << "}"
29
+ "#{lines.join("\n")}\n"
30
+ end
31
+ module_function :render
32
+
33
+ # @rbs skip
34
+ private
35
+
36
+ # @rbs skip
37
+ def escape(value)
38
+ value.gsub("\\", "\\\\").gsub('"', '\\"')
39
+ end
40
+
41
+ # @rbs skip
42
+ def symbol_name(labels, id)
43
+ labels.fetch(id) { raise Ibex::Error, "missing grammar symbol id #{id}" }
44
+ end
45
+ module_function :escape, :symbol_name
46
+
47
+ class << self
48
+ private :escape, :symbol_name
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "symbol_labels"
4
+
5
+ module Ibex
6
+ module Codegen
7
+ # Renders a self-contained navigable automaton report.
8
+ module HTML
9
+ # @rbs!
10
+ # private def state_sections: (IR::Automaton automaton, IR::Grammar grammar,
11
+ # Hash[Integer, String] labels) -> String
12
+ # private def self.state_sections: (IR::Automaton automaton, IR::Grammar grammar,
13
+ # Hash[Integer, String] labels) -> String
14
+ # private def item_html: (IR::AutomatonItem item, IR::Grammar grammar, Hash[Integer, String] labels) -> String
15
+ # private def self.item_html: (IR::AutomatonItem item, IR::Grammar grammar,
16
+ # Hash[Integer, String] labels) -> String
17
+ # private def rule_sections: (IR::Grammar grammar, Hash[Integer, String] labels) -> String
18
+ # private def self.rule_sections: (IR::Grammar grammar, Hash[Integer, String] labels) -> String
19
+ # private def conflict_sections: (IR::Automaton automaton) -> String
20
+ # private def self.conflict_sections: (IR::Automaton automaton) -> String
21
+ # private def escape: (String value) -> String
22
+ # private def self.escape: (String value) -> String
23
+ # private def symbol_name: (Hash[Integer, String] labels, Integer id) -> String
24
+ # private def self.symbol_name: (Hash[Integer, String] labels, Integer id) -> String
25
+
26
+ # @rbs (IR::Automaton automaton) -> String
27
+ def render(automaton)
28
+ grammar = automaton.grammar
29
+ labels = SymbolLabels.build(grammar)
30
+ <<~HTML
31
+ <!doctype html>
32
+ <html lang="en"><head><meta charset="utf-8"><title>Ibex automaton</title>
33
+ <style>body{font:14px system-ui;margin:2rem;max-width:90rem}code{white-space:pre-wrap}.state{border:1px solid #bbb;padding:1rem;margin:1rem 0}.conflict{color:#a00}a{color:#075985}</style>
34
+ </head><body><h1>Ibex #{escape(automaton.algorithm)} automaton</h1>
35
+ <nav><a href="#rules">Rules</a> · <a href="#conflicts">Conflicts</a></nav>
36
+ #{state_sections(automaton, grammar, labels)}
37
+ <h2 id="rules">Rules</h2>#{rule_sections(grammar, labels)}
38
+ <h2 id="conflicts">Conflicts</h2>#{conflict_sections(automaton)}
39
+ </body></html>
40
+ HTML
41
+ end
42
+ module_function :render
43
+
44
+ # @rbs skip
45
+ private
46
+
47
+ # @rbs skip
48
+ def state_sections(automaton, grammar, labels)
49
+ automaton.states.map do |state|
50
+ transitions = state.transitions.map do |symbol_id, target|
51
+ symbol = escape(symbol_name(labels, symbol_id))
52
+ "<li>#{symbol} → <a href=\"#state-#{target}\">state #{target}</a></li>"
53
+ end.join
54
+ items = state.items.map { |item| item_html(item, grammar, labels) }.join
55
+ <<~HTML
56
+ <section class="state" id="state-#{state.id}"><h2>State #{state.id}</h2>
57
+ <ul>#{transitions}</ul><ul>#{items}</ul></section>
58
+ HTML
59
+ end.join
60
+ end
61
+
62
+ # @rbs skip
63
+ def item_html(item, grammar, labels)
64
+ return "<li><code>$accept</code></li>" if item.production.negative?
65
+
66
+ production = grammar.productions.fetch(item.production)
67
+ lhs = symbol_name(labels, production.lhs)
68
+ rhs = production.rhs.map { |id| symbol_name(labels, id) }.insert(item.dot, "•").join(" ")
69
+ link = "<a href=\"#rule-#{production.id}\">rule #{production.id}</a>"
70
+ "<li>#{link} <code>#{escape(lhs)} → #{escape(rhs)}</code></li>"
71
+ end
72
+
73
+ # @rbs skip
74
+ def rule_sections(grammar, labels)
75
+ grammar.productions.map do |production|
76
+ lhs = symbol_name(labels, production.lhs)
77
+ rhs = production.rhs.map { |id| symbol_name(labels, id) }.join(" ")
78
+ number = "<strong>#{production.id}</strong>"
79
+ "<p id=\"rule-#{production.id}\">#{number} <code>#{escape(lhs)} → #{escape(rhs)}</code></p>"
80
+ end.join
81
+ end
82
+
83
+ # @rbs skip
84
+ def conflict_sections(automaton)
85
+ conflicts = automaton.states.flat_map do |state|
86
+ state.conflicts.map do |conflict|
87
+ link = "<a href=\"#state-#{state.id}\">state #{state.id}</a>"
88
+ "<li class=\"conflict\">#{link}: #{escape(conflict.inspect)}</li>"
89
+ end
90
+ end
91
+ conflicts.empty? ? "<p>None</p>" : "<ul>#{conflicts.join}</ul>"
92
+ end
93
+
94
+ # @rbs skip
95
+ def escape(value)
96
+ value.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
97
+ end
98
+
99
+ # @rbs skip
100
+ def symbol_name(labels, id)
101
+ labels.fetch(id) { raise Ibex::Error, "missing grammar symbol id #{id}" }
102
+ end
103
+ module_function :state_sections, :item_html, :rule_sections, :conflict_sections, :escape, :symbol_name
104
+
105
+ class << self
106
+ private :state_sections, :item_html, :rule_sections, :conflict_sections, :escape, :symbol_name
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ibex
4
+ module Codegen
5
+ # Generates an RBS declaration for the public surface of a generated parser.
6
+ class RBS
7
+ # @rbs @automaton: IR::Automaton
8
+ # @rbs @grammar: IR::Grammar
9
+ # @rbs @superclass: String
10
+
11
+ # @rbs (IR::Automaton automaton, ?superclass: String?) -> void
12
+ def initialize(automaton, superclass: nil)
13
+ @automaton = automaton
14
+ @grammar = automaton.grammar
15
+ @superclass = superclass || @grammar.superclass || "Ibex::Runtime::Parser"
16
+ end
17
+
18
+ # @rbs () -> String
19
+ def generate
20
+ lines = ["# Generated by Ibex #{grammar_digest_comment}", ""]
21
+ modules, class_name = class_parts
22
+ modules.each { |name| lines << "module #{name}" }
23
+ lines << "class #{class_name} < #{@superclass}"
24
+ append_contract(lines)
25
+ lines << "end"
26
+ modules.reverse_each { lines << "end" }
27
+ "#{lines.join("\n")}\n"
28
+ end
29
+
30
+ private
31
+
32
+ # @rbs (Array[String] lines) -> void
33
+ def append_contract(lines)
34
+ lines.push(" PARSER_TABLE_FORMAT_VERSION: Integer",
35
+ " TOKEN_IDS: Hash[untyped, Integer]", " TOKEN_NAMES: Hash[Integer, String]",
36
+ " ACTIONS: untyped", " GOTOS: untyped", " DEFAULT_ACTIONS: Array[untyped]",
37
+ " PRODUCTIONS: Array[Hash[Symbol, untyped]]",
38
+ " PARSER_TABLES: Hash[Symbol, untyped]", " DEBUG_ENABLED: bool", "",
39
+ " def self.parser_tables: () -> Hash[Symbol, untyped]")
40
+ end
41
+
42
+ # @rbs () -> [Array[String], String]
43
+ def class_parts
44
+ parts = @grammar.class_name.split("::")
45
+ [parts.take(parts.length - 1), parts.fetch(-1)]
46
+ end
47
+
48
+ # @rbs () -> String
49
+ def grammar_digest_comment
50
+ @automaton.grammar_digest.delete_prefix("sha256:").chars.first(12).join
51
+ end
52
+ end
53
+ end
54
+ end