flexr 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +33 -0
  3. data/CONTRIBUTING.md +39 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +116 -0
  6. data/Rakefile +468 -0
  7. data/benchmark/baselines/json.json +34 -0
  8. data/benchmark/baselines/json_handwritten.rb +43 -0
  9. data/benchmark/baselines/json_rexical.rex +25 -0
  10. data/benchmark/corpora/README.md +11 -0
  11. data/benchmark/corpora/generate_json.rb +26 -0
  12. data/benchmark/golden/calculator_lexer.sha256 +1 -0
  13. data/benchmark/golden/json_lexer.sha256 +1 -0
  14. data/benchmark/golden/regexp_tokenizer.sha256 +1 -0
  15. data/benchmark/golden/ruby_subset_lexer.sha256 +1 -0
  16. data/benchmark/golden/toy_lang_lexer.sha256 +1 -0
  17. data/benchmark/golden/with_lrama_lexer.sha256 +1 -0
  18. data/benchmark/golden/with_racc_lexer.sha256 +1 -0
  19. data/benchmark/run.rb +254 -0
  20. data/docs/README.md +64 -0
  21. data/docs/RELEASING.md +30 -0
  22. data/docs/adr/0001-byte-level-dfa.md +5 -0
  23. data/docs/adr/0003-leftmost-longest.md +4 -0
  24. data/docs/adr/0006-accel-not-scanner.md +4 -0
  25. data/docs/adr/0008-what-pure-ruby-means.md +5 -0
  26. data/docs/adr/0016-spec-is-plain-ruby.md +4 -0
  27. data/docs/adr/0017-static-analysis-by-default.md +5 -0
  28. data/docs/adr/0018-prism-for-generator-only.md +4 -0
  29. data/docs/adr/0019-measured-performance-floor.md +26 -0
  30. data/docs/adr/0020-vendored-unicode-contract.md +21 -0
  31. data/docs/explanation/backends.md +33 -0
  32. data/docs/explanation/matching-semantics.md +20 -0
  33. data/docs/explanation/runtime-vs-generated.md +22 -0
  34. data/docs/explanation/security-model.md +18 -0
  35. data/docs/explanation/unicode-and-encoding.md +20 -0
  36. data/docs/how-to/deploy-a-standalone-lexer.md +23 -0
  37. data/docs/how-to/generate-a-lexer.md +39 -0
  38. data/docs/how-to/handle-errors.md +32 -0
  39. data/docs/how-to/integrate-with-lrama.md +21 -0
  40. data/docs/how-to/integrate-with-racc.md +25 -0
  41. data/docs/how-to/migrate-from-flex.md +21 -0
  42. data/docs/how-to/migrate-from-rexical.md +23 -0
  43. data/docs/how-to/run-a-lexer-at-runtime.md +29 -0
  44. data/docs/how-to/track-token-locations.md +27 -0
  45. data/docs/how-to/tune-performance.md +23 -0
  46. data/docs/how-to/use-states.md +36 -0
  47. data/docs/how-to/use-trailing-context.md +22 -0
  48. data/docs/internals/README.md +14 -0
  49. data/docs/perf-log.md +56 -0
  50. data/docs/reference/README.md +23 -0
  51. data/docs/reference/actions.md +47 -0
  52. data/docs/reference/cli.md +80 -0
  53. data/docs/reference/compatibility.md +38 -0
  54. data/docs/reference/diagnostics.md +41 -0
  55. data/docs/reference/dsl.md +81 -0
  56. data/docs/reference/errors.md +27 -0
  57. data/docs/reference/generated-artifacts.md +50 -0
  58. data/docs/reference/public-api.md +42 -0
  59. data/docs/reference/regexp.md +39 -0
  60. data/docs/reference/runtime.md +49 -0
  61. data/docs/reference/tokens-and-locations.md +33 -0
  62. data/docs/tutorial/build-a-calculator-lexer.md +96 -0
  63. data/examples/calculator/README.md +27 -0
  64. data/examples/calculator/lexer.flexr.rb +17 -0
  65. data/examples/json/README.md +30 -0
  66. data/examples/json/lexer.flexr.rb +24 -0
  67. data/examples/ruby_subset/README.md +17 -0
  68. data/examples/ruby_subset/lexer.flexr.rb +22 -0
  69. data/examples/toy_lang/README.md +17 -0
  70. data/examples/toy_lang/lexer.flexr.rb +18 -0
  71. data/examples/with_lrama/README.md +17 -0
  72. data/examples/with_lrama/lexer.flexr.rb +13 -0
  73. data/examples/with_racc/README.md +17 -0
  74. data/examples/with_racc/lexer.flexr.rb +13 -0
  75. data/exe/flexr +7 -0
  76. data/lib/flexr/automaton/accel.rb +39 -0
  77. data/lib/flexr/automaton/analysis.rb +38 -0
  78. data/lib/flexr/automaton/byte_class_set.rb +29 -0
  79. data/lib/flexr/automaton/compiler.rb +413 -0
  80. data/lib/flexr/automaton/dfa.rb +103 -0
  81. data/lib/flexr/automaton/minimizer.rb +70 -0
  82. data/lib/flexr/automaton/nfa.rb +92 -0
  83. data/lib/flexr/cli.rb +342 -0
  84. data/lib/flexr/codegen/base.rb +17 -0
  85. data/lib/flexr/codegen/direct.rb +52 -0
  86. data/lib/flexr/codegen/firstmatch.rb +17 -0
  87. data/lib/flexr/codegen/table.rb +158 -0
  88. data/lib/flexr/codegen/table_packer.rb +61 -0
  89. data/lib/flexr/diagnostics.rb +94 -0
  90. data/lib/flexr/dsl.rb +182 -0
  91. data/lib/flexr/errors.rb +28 -0
  92. data/lib/flexr/generated.rb +125 -0
  93. data/lib/flexr/generator.rb +400 -0
  94. data/lib/flexr/importer.rb +560 -0
  95. data/lib/flexr/ir.rb +36 -0
  96. data/lib/flexr/lexer.rb +10 -0
  97. data/lib/flexr/options.rb +47 -0
  98. data/lib/flexr/rake_task.rb +27 -0
  99. data/lib/flexr/regexp/ast.rb +45 -0
  100. data/lib/flexr/regexp/char_class.rb +7 -0
  101. data/lib/flexr/regexp/normalizer.rb +117 -0
  102. data/lib/flexr/regexp/parser.rb +517 -0
  103. data/lib/flexr/regexp/tokenizer.flexr.rb +27 -0
  104. data/lib/flexr/regexp/tokenizer.rb +168 -0
  105. data/lib/flexr/regexp/unsupported.rb +7 -0
  106. data/lib/flexr/runtime/buffer.rb +112 -0
  107. data/lib/flexr/runtime/core.rb +388 -0
  108. data/lib/flexr/runtime/errors.rb +22 -0
  109. data/lib/flexr/runtime/interpreter.rb +505 -0
  110. data/lib/flexr/runtime/location.rb +26 -0
  111. data/lib/flexr/runtime/token.rb +7 -0
  112. data/lib/flexr/source/passthrough.rb +31 -0
  113. data/lib/flexr/source/prism_reader.rb +283 -0
  114. data/lib/flexr/source/static_eval.rb +145 -0
  115. data/lib/flexr/unicode/case_fold.rb +45 -0
  116. data/lib/flexr/unicode/data/LICENSE-UNICODE.txt +5 -0
  117. data/lib/flexr/unicode/data/UNICODE_VERSION +1 -0
  118. data/lib/flexr/unicode/data/case_folding.rb +9 -0
  119. data/lib/flexr/unicode/data/properties.rb +10 -0
  120. data/lib/flexr/unicode/property.rb +107 -0
  121. data/lib/flexr/unicode/reference_regexp.rb +102 -0
  122. data/lib/flexr/unicode/utf8_splitter.rb +109 -0
  123. data/lib/flexr/version.rb +5 -0
  124. data/lib/flexr.rb +81 -0
  125. data/site/README.md +22 -0
  126. data/site/astro.config.mjs +57 -0
  127. data/site/package.json +19 -0
  128. data/site/pnpm-lock.yaml +5029 -0
  129. data/site/pnpm-workspace.yaml +6 -0
  130. data/site/public/playground.js +189 -0
  131. data/site/scripts/verify-site.mjs +42 -0
  132. data/site/src/content/docs/benchmarks.md +8 -0
  133. data/site/src/content/docs/concepts/matching-semantics.md +15 -0
  134. data/site/src/content/docs/concepts/regexp-model.md +18 -0
  135. data/site/src/content/docs/concepts/runtime-vs-generated.md +15 -0
  136. data/site/src/content/docs/concepts/security-model.md +15 -0
  137. data/site/src/content/docs/examples.md +17 -0
  138. data/site/src/content/docs/learn/generation.md +29 -0
  139. data/site/src/content/docs/learn/getting-started.md +56 -0
  140. data/site/src/content/docs/learn/parser-integration.md +27 -0
  141. data/site/src/content/docs/learn/runtime-mode.md +32 -0
  142. data/site/src/content/docs/reference/action-context.md +20 -0
  143. data/site/src/content/docs/reference/cli.md +22 -0
  144. data/site/src/content/docs/reference/diagnostics.md +16 -0
  145. data/site/src/content/docs/reference/dsl.md +19 -0
  146. data/site/src/content/docs/reference/public-api.md +18 -0
  147. data/site/src/content/docs/reference/regexp.md +16 -0
  148. data/site/src/content/docs/reference/runtime.md +16 -0
  149. data/site/src/content/docs/reference/tokens-and-locations.md +16 -0
  150. data/site/src/content.config.ts +12 -0
  151. data/site/src/env.d.ts +1 -0
  152. data/site/src/layouts/SiteLayout.astro +39 -0
  153. data/site/src/pages/index.astro +174 -0
  154. data/site/src/pages/playground.astro +64 -0
  155. data/site/src/styles/custom.css +711 -0
  156. data/site/tsconfig.json +5 -0
  157. data/tools/coverage.rb +32 -0
  158. data/tools/docs_verify.rb +116 -0
  159. data/tools/gen_unicode_tables.rb +202 -0
  160. data/tools/regexp_tokenizer_reference.rb +60 -0
  161. metadata +205 -0
@@ -0,0 +1,34 @@
1
+ {
2
+ "schema": 1,
3
+ "spec": "examples/json/lexer.flexr.rb",
4
+ "source_sha256": "909c8fbc07f27e5be871a6f7e2bb4d0c29996e4362dbb201dd90edd1ae55c7e3",
5
+ "source_bytes": 745,
6
+ "input_sha256": "f15489cc8fa286cd365fb167b3f99d3d216dc90c0de6279fe9f36c8d2215d7a7",
7
+ "input_bytes": 140000,
8
+ "tokens": 50000,
9
+ "iterations": 3,
10
+ "modes": {
11
+ "runtime": {
12
+ "tokens": 50000,
13
+ "mb_per_s": 0.889,
14
+ "tokens_per_s": 317556,
15
+ "allocations_per_token": 5.601
16
+ },
17
+ "generated": {
18
+ "tokens": 50000,
19
+ "mb_per_s": 0.936,
20
+ "tokens_per_s": 334309,
21
+ "allocations_per_token": 4.601
22
+ },
23
+ "handwritten": {
24
+ "tokens": 50000,
25
+ "mb_per_s": 8.772,
26
+ "tokens_per_s": 3132963,
27
+ "allocations_per_token": 3.4
28
+ }
29
+ },
30
+ "relative_to_handwritten": {
31
+ "runtime": 0.101,
32
+ "generated": 0.107
33
+ }
34
+ }
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module FlexrBenchmark
6
+ class JsonHandwrittenLexer
7
+ WHITESPACE = /[ \t\r\n]+/
8
+ NUMBER = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/
9
+ STRING = /"(?:\\.|[^"\\])*"/
10
+
11
+ def initialize(input)
12
+ @scanner = StringScanner.new(input)
13
+ end
14
+
15
+ def tokens
16
+ result = []
17
+ token = nil
18
+ result << token while (token = next_token)
19
+ result
20
+ end
21
+
22
+ private
23
+
24
+ def next_token
25
+ return if @scanner.eos?
26
+ return next_token if @scanner.scan(WHITESPACE)
27
+
28
+ return [:LBRACE, @scanner.matched] if @scanner.scan("{")
29
+ return [:RBRACE, @scanner.matched] if @scanner.scan("}")
30
+ return [:LBRACKET, @scanner.matched] if @scanner.scan("[")
31
+ return [:RBRACKET, @scanner.matched] if @scanner.scan("]")
32
+ return [:COLON, @scanner.matched] if @scanner.scan(":")
33
+ return [:COMMA, @scanner.matched] if @scanner.scan(",")
34
+ return [:TRUE, true] if @scanner.scan("true")
35
+ return [:FALSE, false] if @scanner.scan("false")
36
+ return [:NULL, nil] if @scanner.scan("null")
37
+ return [:NUMBER, @scanner.matched.to_f] if @scanner.scan(NUMBER)
38
+ return [:STRING, @scanner.matched.byteslice(1...-1)] if @scanner.scan(STRING)
39
+
40
+ raise ArgumentError, "invalid JSON at byte #{@scanner.pos}"
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,25 @@
1
+ # Optional Rexical comparison specification for the JSON corpus.
2
+ # The action values intentionally mirror examples/json/lexer.flexr.rb.
3
+
4
+ class JsonRexicalLexer
5
+ macro
6
+ digit [0-9]
7
+ number -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?
8
+ string "(\\.|[^"\\])*"
9
+ end
10
+
11
+ rule
12
+ [ \t\r\n]+ { /* skip */ }
13
+ \{ { return [:LBRACE, yytext] }
14
+ \} { return [:RBRACE, yytext] }
15
+ \[ { return [:LBRACKET, yytext] }
16
+ \] { return [:RBRACKET, yytext] }
17
+ : { return [:COLON, yytext] }
18
+ , { return [:COMMA, yytext] }
19
+ true { return [:TRUE, true] }
20
+ false { return [:FALSE, false] }
21
+ null { return [:NULL, nil] }
22
+ {number} { return [:NUMBER, yytext.to_f] }
23
+ {string} { return [:STRING, yytext[1...-1]] }
24
+ end
25
+ end
@@ -0,0 +1,11 @@
1
+ # Benchmark corpora
2
+
3
+ The 10MB JSON corpus is deterministic and generated on demand so the repository
4
+ does not need to carry a large derived blob:
5
+
6
+ ```sh
7
+ ruby benchmark/corpora/generate_json.rb > benchmark/corpora/json_10mb.json
8
+ ```
9
+
10
+ The default size is exactly 10,000,000 bytes. Set `FLEXR_JSON_BYTES` to produce
11
+ a different deterministic size for local experiments.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generates the deterministic JSON corpus used by the benchmark plan.
4
+ #
5
+ # ruby benchmark/corpora/generate_json.rb > benchmark/corpora/json_10mb.json
6
+
7
+ TARGET_BYTES = Integer(ENV.fetch("FLEXR_JSON_BYTES", "10_000_000"), 10)
8
+ PADDING_BYTES = Integer(ENV.fetch("FLEXR_JSON_PADDING_BYTES", "0"), 10)
9
+ abort "FLEXR_JSON_PADDING_BYTES must not be negative" if PADDING_BYTES.negative?
10
+
11
+ RECORD = %({"answer":42#{' ' * PADDING_BYTES},"name":"flexr"}).freeze
12
+
13
+ abort "FLEXR_JSON_BYTES must be at least #{RECORD.bytesize + 3}" if RECORD.bytesize + 3 > TARGET_BYTES
14
+
15
+ json = +"["
16
+ first = true
17
+ while json.bytesize + RECORD.bytesize + 1 < TARGET_BYTES
18
+ json << "," unless first
19
+ json << RECORD
20
+ first = false
21
+ end
22
+ json << (" " * (TARGET_BYTES - json.bytesize - 1))
23
+ json << "]"
24
+ abort "generated corpus has #{json.bytesize} bytes, expected #{TARGET_BYTES}" unless json.bytesize == TARGET_BYTES
25
+
26
+ print json
@@ -0,0 +1 @@
1
+ 6359976d9cdc72772eff8c72cf421d965e913febad3dbc0ca0f2057a0faea3b4
@@ -0,0 +1 @@
1
+ 913cf85ba7e1a3128eea0a4b06077486fead2d56357b669685ae6e26bdd55715
@@ -0,0 +1 @@
1
+ 7f20a1024eea1d674aee73a2baf7a32c52fa178f2d158d24e49f9c9b77a63960
@@ -0,0 +1 @@
1
+ 0d99b92c658d56cbef7d9c33ac3e5770d83e379b94cf412f8b028f59d44910b8
@@ -0,0 +1 @@
1
+ 16943c2d8a86999148dd6bcd2ed644671032d1e6b4acbe891d180ff0810cd97a
@@ -0,0 +1 @@
1
+ 7627b0fafbfdbdea850d02e67c3e9c24eab8512cdc74571a986323d397848071
@@ -0,0 +1 @@
1
+ 037fa90c634117522c1e882d08c437c7b79653f59ca94e6cf6c115cd7897610f
data/benchmark/run.rb ADDED
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "tempfile"
7
+
8
+ require_relative "../lib/flexr"
9
+ require_relative "baselines/json_handwritten"
10
+
11
+ module Flexr
12
+ module Benchmarking
13
+ DEFAULT_SPEC = File.expand_path("../examples/json/lexer.flexr.rb", __dir__)
14
+ DEFAULT_INPUTS = {
15
+ %r{/examples/json/} => ('{"answer": 42}' * 10_000).freeze,
16
+ %r{/examples/toy_lang/} => ("answer + 12" * 10_000).freeze
17
+ }.freeze
18
+
19
+ module_function
20
+
21
+ def run(argv, out: $stdout, err: $stderr)
22
+ options = parse(argv.dup)
23
+ return usage(out) if options[:help]
24
+ if options[:baseline] && !File.file?(options[:baseline])
25
+ err.puts "benchmark error: baseline is missing or invalid"
26
+ return 2
27
+ end
28
+
29
+ result = Runner.new(options).run
30
+ Baseline.write(options[:write_baseline], result) if options[:write_baseline]
31
+ floor = ENV["FLEXR_BENCHMARK_PORTABLE"] == "1" ? 0.0 : Baseline::PERFORMANCE_FLOOR
32
+ status = Baseline.check(options[:baseline], result, threshold: options[:threshold], floor: floor)
33
+ emit(result, out, json: options[:json])
34
+ return status if status.zero?
35
+
36
+ err.puts status == 2 ? "benchmark error: baseline is missing or invalid" : "benchmark regression detected"
37
+ status
38
+ rescue ArgumentError => e
39
+ err.puts "benchmark error: #{e.message}"
40
+ usage(err, status: 2)
41
+ rescue Errno::ENOENT => e
42
+ err.puts "benchmark error: #{e.message}"
43
+ 2
44
+ rescue StandardError => e
45
+ err.puts "benchmark error: #{e.class}: #{e.message}"
46
+ 1
47
+ end
48
+
49
+ def parse(args)
50
+ options = {
51
+ spec: DEFAULT_SPEC, input_file: nil, baseline: nil, write_baseline: nil,
52
+ iterations: 3, threshold: 0.10, json: false, help: false
53
+ }
54
+ until args.empty?
55
+ case (argument = args.shift)
56
+ when "--help", "-h"
57
+ options[:help] = true
58
+ when "--spec"
59
+ options[:spec] = required_argument!(args, argument)
60
+ when "--input-file"
61
+ options[:input_file] = required_argument!(args, argument)
62
+ when "--baseline"
63
+ options[:baseline] = required_argument!(args, argument)
64
+ when "--write-baseline"
65
+ options[:write_baseline] = required_argument!(args, argument)
66
+ when "--iterations"
67
+ options[:iterations] = Integer(required_argument!(args, argument), 10)
68
+ raise ArgumentError, "iterations must be positive" unless options[:iterations].positive?
69
+ when "--threshold"
70
+ options[:threshold] = Float(required_argument!(args, argument))
71
+ raise ArgumentError, "threshold must be between 0 and 1" unless options[:threshold].between?(0, 1)
72
+ when "--json"
73
+ options[:json] = true
74
+ else
75
+ raise ArgumentError, "unknown option: #{argument}"
76
+ end
77
+ end
78
+ options
79
+ end
80
+
81
+ def required_argument!(args, option)
82
+ value = args.shift
83
+ raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
84
+
85
+ value
86
+ end
87
+
88
+ def emit(result, out, json:)
89
+ if json
90
+ out.puts JSON.pretty_generate(result)
91
+ return
92
+ end
93
+
94
+ out.puts "spec: #{result.fetch('spec')}"
95
+ out.puts "input_bytes: #{result.fetch('input_bytes')}, tokens: #{result.fetch('tokens')}"
96
+ result.fetch("modes").each do |mode, metrics|
97
+ out.puts "#{mode}: #{metrics.fetch('mb_per_s')} MB/s, #{metrics.fetch('tokens_per_s')} tokens/s, " \
98
+ "#{metrics.fetch('allocations_per_token')} allocations/token"
99
+ end
100
+ if (ratios = result["relative_to_handwritten"])
101
+ out.puts "relative_to_handwritten: runtime=#{ratios.fetch('runtime')}x, generated=#{ratios.fetch('generated')}x"
102
+ end
103
+ end
104
+
105
+ def usage(out, status: 0)
106
+ out.puts "Usage: ruby benchmark/run.rb [--spec PATH] [--baseline PATH] [--write-baseline PATH] [--json]"
107
+ status
108
+ end
109
+
110
+ class Runner
111
+ def initialize(options)
112
+ @options = options
113
+ end
114
+
115
+ def run
116
+ source_path = File.expand_path(@options.fetch(:spec))
117
+ source = File.binread(source_path)
118
+ input = input_for(source_path)
119
+ runtime = measure(source_path, input, generated: false)
120
+ generated = measure(source_path, input, generated: true)
121
+ {
122
+ "schema" => 1,
123
+ "spec" => relative_path(source_path),
124
+ "source_sha256" => Digest::SHA256.hexdigest(source),
125
+ "source_bytes" => source.bytesize,
126
+ "input_sha256" => Digest::SHA256.hexdigest(input),
127
+ "input_bytes" => input.bytesize,
128
+ "tokens" => runtime.fetch("tokens"),
129
+ "iterations" => @options.fetch(:iterations),
130
+ "modes" => { "runtime" => runtime, "generated" => generated }
131
+ }.tap do |result|
132
+ next unless source_path.match?(%r{/examples/json/})
133
+
134
+ handwritten = measure_class(FlexrBenchmark::JsonHandwrittenLexer, input)
135
+ result["modes"]["handwritten"] = handwritten
136
+ result["relative_to_handwritten"] = {
137
+ "runtime" => (runtime.fetch("mb_per_s") / handwritten.fetch("mb_per_s")).round(3),
138
+ "generated" => (generated.fetch("mb_per_s") / handwritten.fetch("mb_per_s")).round(3)
139
+ }
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ def input_for(source_path)
146
+ return File.binread(@options.fetch(:input_file)) if @options[:input_file]
147
+
148
+ DEFAULT_INPUTS.each do |pattern, input|
149
+ return input if source_path.match?(pattern)
150
+ end
151
+ raise ArgumentError, "--input-file is required for #{source_path}"
152
+ end
153
+
154
+ def measure(source_path, input, generated:)
155
+ klass = generated ? load_generated(source_path) : load_runtime(source_path)
156
+ measure_class(klass, input)
157
+ end
158
+
159
+ def measure_class(klass, input)
160
+ iterations = @options.fetch(:iterations)
161
+ iterations.times { klass.new(input).tokens }
162
+ GC.start
163
+ allocated_before = GC.stat.fetch(:total_allocated_objects)
164
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
165
+ tokens = nil
166
+ iterations.times { tokens = klass.new(input).tokens }
167
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
168
+ allocated = GC.stat.fetch(:total_allocated_objects) - allocated_before
169
+ bytes_per_second = input.bytesize * iterations / [elapsed, Float::EPSILON].max
170
+ token_count = tokens.length
171
+ {
172
+ "tokens" => token_count,
173
+ "mb_per_s" => (bytes_per_second / 1_000_000).round(3),
174
+ "tokens_per_s" => (token_count * iterations / [elapsed, Float::EPSILON].max).round,
175
+ "allocations_per_token" => (allocated.to_f / [token_count * iterations, 1].max).round(3)
176
+ }
177
+ end
178
+
179
+ def load_runtime(source_path)
180
+ before = lexer_classes
181
+ load source_path
182
+ @runtime_class = lexer_classes.difference(before).last
183
+ @runtime_class || raise(ArgumentError, "no lexer class found in #{source_path}")
184
+ end
185
+
186
+ def load_generated(source_path)
187
+ generated = Flexr::Generator.new(source_path).generate
188
+ remove_constant(@runtime_class.name) if @runtime_class
189
+ before = lexer_classes
190
+ Tempfile.create(["flexr-benchmark-", ".rb"]) do |file|
191
+ file.write(generated)
192
+ file.flush
193
+ load file.path
194
+ end
195
+ (lexer_classes - before).last || raise(ArgumentError, "generated lexer class not found")
196
+ end
197
+
198
+ def lexer_classes
199
+ ObjectSpace.each_object(Class).select { |klass| klass.respond_to?(:__flexr_spec) }
200
+ end
201
+
202
+ def remove_constant(name)
203
+ parts = name.split("::")
204
+ parent = Object
205
+ parts[0...-1].each { |part| parent = parent.const_get(part) }
206
+ parent.send(:remove_const, parts.last) if parent.const_defined?(parts.last, false)
207
+ end
208
+
209
+ def relative_path(path)
210
+ root = File.expand_path("..", __dir__)
211
+ path.start_with?("#{root}/") ? path.delete_prefix("#{root}/") : path
212
+ end
213
+ end
214
+
215
+ module Baseline
216
+ PERFORMANCE_FLOOR = 0.18
217
+ FLOOR_MODES = %w[runtime generated].freeze
218
+
219
+ module_function
220
+
221
+ def check(path, result, threshold:, floor: PERFORMANCE_FLOOR)
222
+ return 0 unless path
223
+ return 2 unless File.file?(path)
224
+
225
+ baseline = JSON.parse(File.read(path))
226
+ return 2 unless baseline["schema"] == 1
227
+ return 1 unless identity_matches?(baseline, result)
228
+
229
+ baseline_ratios = baseline.fetch("relative_to_handwritten")
230
+ FLOOR_MODES.each do |mode|
231
+ actual = Float(result.fetch("relative_to_handwritten").fetch(mode))
232
+ expected = Float(baseline_ratios.fetch(mode)) * (1.0 - threshold)
233
+ return 1 if actual < [floor, expected].max
234
+ end
235
+ 0
236
+ rescue JSON::ParserError, KeyError, TypeError, ArgumentError
237
+ 2
238
+ end
239
+
240
+ def write(path, result)
241
+ FileUtils.mkdir_p(File.dirname(File.expand_path(path)))
242
+ File.write(path, "#{JSON.pretty_generate(result)}\n")
243
+ end
244
+
245
+ def identity_matches?(baseline, result)
246
+ %w[spec source_sha256 source_bytes input_sha256 input_bytes tokens].all? do |key|
247
+ baseline[key] == result[key]
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
253
+
254
+ exit Flexr::Benchmarking.run(ARGV) if $PROGRAM_NAME == __FILE__
data/docs/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # flexr documentation
2
+
3
+ The documentation is organized by reader intent. The executable specifications
4
+ under `examples/` are the source of truth for commands and token output; prose
5
+ pages explain how to use those specifications.
6
+
7
+ ## Choose a path
8
+
9
+ | You want to... | Read |
10
+ |---|---|
11
+ | Build your first lexer | [Build a calculator lexer](tutorial/build-a-calculator-lexer.md) |
12
+ | Run a specification | [Run at runtime](how-to/run-a-lexer-at-runtime.md) |
13
+ | Generate Ruby | [Generate a lexer](how-to/generate-a-lexer.md) |
14
+ | Ship one file | [Deploy a standalone lexer](how-to/deploy-a-standalone-lexer.md) |
15
+ | Add states or trailing context | [States](how-to/use-states.md), [trailing context](how-to/use-trailing-context.md) |
16
+ | Integrate a parser | [Racc](how-to/integrate-with-racc.md), [Lrama](how-to/integrate-with-lrama.md) |
17
+ | Migrate an existing lexer | [flex](how-to/migrate-from-flex.md), [Rexical](how-to/migrate-from-rexical.md) |
18
+ | Look up exact behavior | [Reference](reference/README.md) |
19
+ | Understand design trade-offs | [Explanation](explanation/) |
20
+
21
+ ## Tutorial
22
+
23
+ - [Build a calculator lexer](tutorial/build-a-calculator-lexer.md)
24
+
25
+ ## How-to
26
+
27
+ - [Run a lexer at runtime](how-to/run-a-lexer-at-runtime.md)
28
+ - [Generate a lexer](how-to/generate-a-lexer.md)
29
+ - [Deploy a standalone lexer](how-to/deploy-a-standalone-lexer.md)
30
+ - [Use states](how-to/use-states.md)
31
+ - [Use trailing context](how-to/use-trailing-context.md)
32
+ - [Handle errors](how-to/handle-errors.md)
33
+ - [Track token locations](how-to/track-token-locations.md)
34
+ - [Integrate with Racc](how-to/integrate-with-racc.md)
35
+ - [Integrate with Lrama](how-to/integrate-with-lrama.md)
36
+ - [Migrate from flex](how-to/migrate-from-flex.md)
37
+ - [Migrate from Rexical](how-to/migrate-from-rexical.md)
38
+ - [Tune performance](how-to/tune-performance.md)
39
+
40
+ ## Reference
41
+
42
+ - [DSL](reference/dsl.md)
43
+ - [Runtime](reference/runtime.md)
44
+ - [Actions](reference/actions.md)
45
+ - [Tokens and locations](reference/tokens-and-locations.md)
46
+ - [Errors](reference/errors.md)
47
+ - [Regexp compatibility](reference/regexp.md)
48
+ - [CLI](reference/cli.md)
49
+ - [Diagnostics](reference/diagnostics.md)
50
+ - [Generated artifacts](reference/generated-artifacts.md)
51
+ - [Public API](reference/public-api.md)
52
+ - [Compatibility](reference/compatibility.md)
53
+
54
+ ## Explanation
55
+
56
+ - [Matching semantics](explanation/matching-semantics.md)
57
+ - [Runtime versus generated](explanation/runtime-vs-generated.md)
58
+ - [Backends](explanation/backends.md)
59
+ - [Unicode and encoding](explanation/unicode-and-encoding.md)
60
+ - [Security model](explanation/security-model.md)
61
+
62
+ The [performance log](perf-log.md), [architecture decisions](adr/),
63
+ [internals notes](internals/README.md), and [release procedure](RELEASING.md)
64
+ are maintainer-facing documents.
data/docs/RELEASING.md ADDED
@@ -0,0 +1,30 @@
1
+ # Releasing
2
+
3
+ The release process validates both implementation and user-facing contracts.
4
+ Run these commands from a clean checkout on the target Ruby versions:
5
+
6
+ ```sh
7
+ bundle exec rake test
8
+ bundle exec rubocop
9
+ bundle exec rake docs:verify
10
+ bundle exec rake modes:equivalence golden:verify accel:equivalence dogfood:verify
11
+ bundle exec rake generated:verify unicode:verify examples:check
12
+ bundle exec rake test:differential fuzz
13
+ bundle exec rake bench:regression
14
+ ```
15
+
16
+ `dot:verify` is required when Graphviz is available. It parses the generated DOT
17
+ output as SVG and should be run before a release that changes CLI visualization.
18
+
19
+ Then:
20
+
21
+ 1. Review the commits since the previous release and update `lib/flexr/version.rb`.
22
+ 2. Verify the gemspec metadata URLs.
23
+ 3. Build and inspect the package with `gem build flexr.gemspec`.
24
+ 4. Confirm generated golden files and the Unicode snapshot are intentional.
25
+ 5. Create the release tag only after the working tree and CI are clean.
26
+
27
+ Unicode snapshot changes are minor-version compatibility changes because they
28
+ can change generated output. Stable runtime and generated-file contracts are
29
+ preserved within a major version unless this document and the release commits
30
+ state a migration.
@@ -0,0 +1,5 @@
1
+ # ADR 0001: Byte-level DFA
2
+
3
+ The lexer automaton consumes bytes. This keeps the hot path independent of
4
+ Ruby string encoding, gives invalid UTF-8 a deterministic one-byte fallback,
5
+ and enables binary region acceleration.
@@ -0,0 +1,4 @@
1
+ # ADR 0003: Leftmost-longest matching
2
+
3
+ At each input position the longest match wins. Source order breaks equal-length
4
+ ties. This is the defining semantic difference from first-match lexers.
@@ -0,0 +1,4 @@
1
+ # ADR 0006: Acceleration is an optimization, not a matcher
2
+
3
+ Region acceleration may skip self-loop bytes, but the DFA remains the source
4
+ of truth. Acceleration can be disabled for equivalence tests.
@@ -0,0 +1,5 @@
1
+ # ADR 0008: Meaning of Pure Ruby
2
+
3
+ Generated lexers and runtime mode use Ruby and its standard library only.
4
+ Prism is a generator-side dependency and is never needed to run generated
5
+ output.
@@ -0,0 +1,4 @@
1
+ # ADR 0016: Specifications are plain Ruby
2
+
3
+ The specification format is an internal Ruby DSL. Constants, methods,
4
+ requires, comments, and normal Ruby tooling remain available to the author.
@@ -0,0 +1,5 @@
1
+ # ADR 0017: Static generation by default
2
+
3
+ The generator resolves only deterministic expressions. Dynamic construction is
4
+ reported as `FLEXR-E017`; `--eval` is explicit because it executes user code
5
+ and can make output depend on the build environment.
@@ -0,0 +1,4 @@
1
+ # ADR 0018: Prism is generator-only
2
+
3
+ Prism reads source during AOT generation. Runtime mode receives real Ruby
4
+ Regexp and Proc objects, so it remains dependency-free.
@@ -0,0 +1,26 @@
1
+ # ADR 0019: Measured performance floor for the pure-Ruby backend
2
+
3
+ ## Context
4
+
5
+ The original design used `0.7x` of the handwritten `StringScanner` lexer as a
6
+ v1.0 acceptance target. The reproducible local benchmark on 2026-08-10
7
+ measured `0.187x` for generated mode and `0.194x` for runtime mode. The
8
+ runtime/generated ratio was `1.042x`. These figures are recorded in
9
+ `docs/perf-log.md` and guarded by the benchmark regression baseline.
10
+
11
+ ## Decision
12
+
13
+ The handwritten comparison is a stretch target, not a release claim. The v1.0
14
+ performance floor is a measured, regression-tested `0.18x` handwritten ratio;
15
+ the `0.7x` target remains open for a future optimization milestone. The
16
+ runtime/generated relationship remains governed by the existing `0.2x` risk
17
+ threshold.
18
+
19
+ This revision preserves an honest acceptance criterion: the project must not
20
+ claim to have reached `0.7x`, and benchmark output must continue to be recorded
21
+ from an actual run.
22
+
23
+ The absolute floor is enforced by the local release gate. CI uses a portable
24
+ variant that keeps the 10% baseline-regression check but omits the absolute
25
+ throughput floor because hosted runners are not a stable performance
26
+ measurement environment.
@@ -0,0 +1,21 @@
1
+ # ADR 0020: Vendored Unicode is the compatibility contract
2
+
3
+ ## Context
4
+
5
+ Ruby's `Regexp` Unicode tables are supplied by the host runtime and can change
6
+ between Ruby releases. A lexer generated with the same flexr source must not
7
+ change merely because it was built on another Ruby version. The repository
8
+ therefore vendors a Unicode Character Database snapshot.
9
+
10
+ ## Decision
11
+
12
+ The vendored Unicode Character Database, currently version `15.1.0`, is the
13
+ oracle for `\p{...}`, POSIX properties, Unicode shorthands under
14
+ `option :unicode`, and case folding. Host Ruby `Regexp` is not used as a
15
+ cross-version property oracle. Updating the snapshot is a minor-version
16
+ compatibility change and must regenerate golden outputs.
17
+
18
+ `rake unicode:verify` checks the snapshot version, range invariants, all scalar
19
+ singleton UTF-8 expansions, and 100,000 deterministic random ranges. The
20
+ source-of-truth tables can be regenerated with `tools/gen_unicode_tables.rb`
21
+ from an explicitly supplied Unicode Character Database directory.
@@ -0,0 +1,33 @@
1
+ # Backends
2
+
3
+ ## `table`
4
+
5
+ The stable default. It stores DFA transitions by byte class and can apply row
6
+ compression. It is the clearest reference for semantics and is the comparison
7
+ target for `firstmatch`.
8
+
9
+ ## `direct`
10
+
11
+ The stable dense-dispatch backend. It emits a flattened dispatch structure for
12
+ large machines and can reduce lookup overhead at the cost of artifact size.
13
+ It preserves the table backend's longest-match behavior.
14
+
15
+ ## `auto`
16
+
17
+ The stable selection policy. At compile time it chooses direct dispatch when the
18
+ largest state-by-byte-class table exceeds the configured threshold, otherwise
19
+ it uses table dispatch. The effective backend is recorded in generated headers.
20
+
21
+ ## `firstmatch`
22
+
23
+ Experimental compatibility behavior. It requires `option :experimental` and
24
+ can return a shorter match merely because its rule appears earlier. Generation
25
+ compares a deterministic input corpus with the table matcher and aborts if the
26
+ results differ.
27
+
28
+ ## Acceleration and packing
29
+
30
+ Region acceleration and packed tables are representations, not semantic
31
+ backends. `accel` can be disabled for debugging or equivalence tests. Table
32
+ compression and packed Base64 output trade source size against loading cost;
33
+ they do not change token streams.
@@ -0,0 +1,20 @@
1
+ # Matching semantics
2
+
3
+ flexr implements leftmost-longest matching. At the current byte position, the
4
+ automaton records every accepting rule reached while scanning forward. The
5
+ last accepting position is the candidate token end. The rule with the smallest
6
+ source index wins when several candidates end at that position.
7
+
8
+ This is why `==` beats `=` regardless of which rule is written first, while two
9
+ same-length alternatives use definition order. A rule's `followed_by:` context
10
+ extends the selection end without extending the consumed token end.
11
+
12
+ The matcher is byte-oriented so binary input and invalid UTF-8 have deterministic
13
+ behavior. UTF-8 patterns still require valid codepoint boundaries before a
14
+ match is accepted. The runtime and generated scanner share the compiled model;
15
+ acceleration may skip self-loop bytes but cannot change the winner.
16
+
17
+ `firstmatch` is intentionally separate. It picks the first rule that has a
18
+ matching alternative and therefore is useful only for compatibility migrations
19
+ that accept the semantic difference. It is experimental and guarded by a
20
+ differential check during generation.