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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 54c55f186a1f4aa048a7a46ca86cae52eb5f9566888fc9cdc09f527d4b6b341f
4
+ data.tar.gz: 7a5e9329e688889cfe665f3a4499deea7ca2e9de684f4fd5ad10847bd631fb4e
5
+ SHA512:
6
+ metadata.gz: 7399ef9f672ff36623a8d54a6eed882ee43fed2c7e9d59b417038bd0d8a9a32b892a8556774531aceb41b0e1c3729a169a003b190b8f2a330c4b825960da32b2
7
+ data.tar.gz: 4ecdca40a21a0ff5af1146f20912297c96542bd2ec66412f3726461472c45e10ebc7e6d81a17ef942f3d0bfa036c8701f123fc15be72408dd62c5673c5636154
data/.rubocop.yml ADDED
@@ -0,0 +1,33 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.1
3
+ NewCops: enable
4
+ Exclude:
5
+ - "lib/flexr/codegen/**/*"
6
+ - "lib/flexr/unicode/data/**/*"
7
+ - "lib/flexr/regexp/tokenizer.rb"
8
+ - "spec/fixtures/generated.rb"
9
+ - "vendor/**/*"
10
+
11
+ Metrics/MethodLength:
12
+ Max: 80
13
+
14
+ Metrics/ClassLength:
15
+ Max: 250
16
+
17
+ Style/Documentation:
18
+ Enabled: false
19
+
20
+ Style/StringLiterals:
21
+ Enabled: false
22
+
23
+ Metrics:
24
+ Enabled: false
25
+
26
+ Layout:
27
+ Enabled: false
28
+
29
+ Naming/MethodParameterName:
30
+ Enabled: false
31
+
32
+ Lint/ScriptPermission:
33
+ Enabled: false
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,39 @@
1
+ # Contributing
2
+
3
+ ## Development setup
4
+
5
+ ```sh
6
+ bundle install
7
+ bundle exec rake test
8
+ bundle exec rubocop
9
+ ```
10
+
11
+ The runtime supports Ruby 3.1 and newer. Generator changes need Ruby 3.3 or
12
+ newer because Prism is used for source analysis.
13
+
14
+ ## Validation layers
15
+
16
+ Run the focused test first, then the relevant contract checks:
17
+
18
+ ```sh
19
+ bundle exec rspec spec/cli_spec.rb
20
+ bundle exec rake docs:verify
21
+ bundle exec rake modes:equivalence generated:verify golden:verify
22
+ bundle exec rake test:differential fuzz
23
+ ```
24
+
25
+ The full CI workflow also checks Unicode invariants, acceleration equivalence,
26
+ generated-only loading, Graphviz output, coverage, and benchmark regressions.
27
+ Do not update a generated golden file to hide a semantic change; inspect the
28
+ generated diff and update the compatibility documentation when the change is
29
+ intentional.
30
+
31
+ ## Documentation changes
32
+
33
+ README is an entry point. Put task-oriented instructions in `docs/how-to/`,
34
+ exact contracts in `docs/reference/`, and design rationale in
35
+ `docs/explanation/`. Keep executable specifications in `examples/` and link to
36
+ them instead of duplicating large source blocks. `rake docs:verify` checks local
37
+ links, the tutorial example, CLI help, and stable DSL coverage.
38
+
39
+ All documentation in this repository is written in English.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # flexr
2
+
3
+ [![CI](https://github.com/ydah/flexr/actions/workflows/main.yml/badge.svg)](https://github.com/ydah/flexr/actions/workflows/main.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
5
+
6
+ [Product site](https://ydah.github.io/flexr/) · [Playground](https://ydah.github.io/flexr/playground/) · [Documentation](docs/README.md)
7
+
8
+ Ruby-native lexer generator for parser authors who want ordinary Ruby
9
+ specifications and deterministic generated output.
10
+
11
+ Write one specification, run it directly while developing, or generate Ruby
12
+ for deployment when startup cost and build reproducibility matter. Both modes
13
+ use the same rules and actions.
14
+
15
+ ## Why flexr?
16
+
17
+ - Ordinary Ruby DSL; no separate lexer language is required.
18
+ - Leftmost-longest matching: the longest rule wins, and source order breaks
19
+ equal-length ties.
20
+ - Runtime/generated parity with diagnostics for unsupported or risky designs.
21
+
22
+ ## Quick start
23
+
24
+ ```sh
25
+ gem install flexr
26
+ ```
27
+
28
+ ```ruby
29
+ # lexer.flexr.rb
30
+ require "flexr"
31
+
32
+ class Lexer < Flexr::Lexer
33
+ emits :INTEGER, :PLUS
34
+
35
+ rule(/[ \t\r\n]+/, skip: true)
36
+ rule(/[0-9]+/) { emit :INTEGER, text.to_i }
37
+ rule(/\+/) { emit :PLUS }
38
+ end
39
+
40
+ Lexer.new("12 + 3").tokens
41
+ # => [[:INTEGER, 12], [:PLUS, "+"], [:INTEGER, 3]]
42
+ ```
43
+
44
+ Follow the [calculator tutorial](docs/tutorial/build-a-calculator-lexer.md)
45
+ for validation, generation, and runtime/generated comparison.
46
+
47
+ ## How matching works
48
+
49
+ At each input position flexr considers every active rule and chooses the
50
+ longest match. If multiple rules consume the same number of bytes, the rule
51
+ defined first wins. A rule can use `followed_by:` to inspect trailing context
52
+ without consuming it.
53
+
54
+ The regexp engine is a DFA-oriented subset of Ruby regexp syntax. See the
55
+ [regexp reference](docs/reference/regexp.md) before relying on look-around,
56
+ backreferences, or other non-regular constructs.
57
+
58
+ ## Runtime or generated?
59
+
60
+ | Mode | Build requirement | Runtime requirement | Best for |
61
+ |---|---|---|---|
62
+ | Runtime | `flexr` gem | `flexr` gem | Development, tests, and dynamic Ruby specs |
63
+ | Generated | `flexr` plus Prism on Ruby 3.3+ | `flexr` gem | Reproducible deployment artifacts |
64
+ | Standalone generated | `flexr` plus Prism on Ruby 3.3+ | Generated file and Ruby standard library | Distribution without the gem |
65
+
66
+ Static generation is the default. Use `--eval` only for trusted specifications;
67
+ it executes the specification during the build. See the
68
+ [generation guide](docs/how-to/generate-a-lexer.md) and
69
+ [standalone deployment guide](docs/how-to/deploy-a-standalone-lexer.md).
70
+
71
+ ## Is flexr right for you?
72
+
73
+ flexr fits projects that want a Ruby-native lexer, deterministic longest-match
74
+ semantics, Unicode-aware byte-level matching, and parser integration. It is not
75
+ a drop-in replacement for a first-match lexer, and it does not accept regexp
76
+ features that require backtracking or capture-dependent matching.
77
+
78
+ ## Documentation
79
+
80
+ Start with the [documentation map](docs/README.md), then choose the path that
81
+ matches your task:
82
+
83
+ - [Tutorial](docs/tutorial/build-a-calculator-lexer.md) — build one lexer from
84
+ source to generated artifact.
85
+ - [How-to guides](docs/how-to/) — solve one focused integration or runtime
86
+ problem.
87
+ - [Reference](docs/reference/README.md) — look up APIs, CLI options, regexp
88
+ support, diagnostics, and compatibility.
89
+ - [Explanation](docs/explanation/) — understand matching, backends, Unicode,
90
+ generation, and security decisions.
91
+ - [Examples](examples/) — executable specifications and parser integrations.
92
+
93
+ ## Compatibility and stability
94
+
95
+ Runtime Ruby support starts at 3.1. The generator requires Ruby 3.3 or newer
96
+ because it uses Prism. Stable and experimental APIs are listed in the
97
+ [public API contract](docs/reference/public-api.md). The vendored Unicode
98
+ snapshot and generated-artifact policy are described in the
99
+ [compatibility reference](docs/reference/compatibility.md).
100
+
101
+ ## Security
102
+
103
+ Lexer actions are Ruby code and remain Ruby code in generated output. Static
104
+ generation parses the specification, while `--eval` executes it. Treat source
105
+ specifications and generated files as trusted build inputs; never process an
106
+ untrusted specification with `--eval`.
107
+
108
+ ## Contributing
109
+
110
+ Run `bundle exec rake docs:verify` together with the normal test and generated
111
+ artifact checks before submitting changes. See
112
+ [CONTRIBUTING.md](CONTRIBUTING.md) and [RELEASING.md](docs/RELEASING.md).
113
+
114
+ ## License
115
+
116
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "json"
7
+ require "open3"
8
+ require "rbconfig"
9
+ require "rspec/core/rake_task"
10
+ require "tmpdir"
11
+ require "flexr"
12
+ require_relative "tools/regexp_tokenizer_reference"
13
+
14
+ RSpec::Core::RakeTask.new(:spec)
15
+ task test: :spec
16
+
17
+ module FlexrVerification
18
+ ROOT = File.expand_path(__dir__)
19
+ EXAMPLES = Dir[File.join(ROOT, "examples/**/*.flexr.rb")].freeze
20
+ TOKENIZER_SPEC = File.join(ROOT, "lib/flexr/regexp/tokenizer.flexr.rb").freeze
21
+ TOKENIZER_GENERATED = File.join(ROOT, "lib/flexr/regexp/tokenizer.rb").freeze
22
+ VERIFICATION_SPECS = (EXAMPLES + [TOKENIZER_SPEC]).freeze
23
+ EXPECTED_INPUTS = {
24
+ %r{/examples/calculator/} => "if ifx == = 12 + 3",
25
+ %r{/examples/json/} => '{"answer": 42}',
26
+ %r{/examples/toy_lang/} => "answer + 12",
27
+ %r{/examples/ruby_subset/} => 'class Foo "ok" end',
28
+ %r{/examples/with_racc/} => "12 + 3",
29
+ %r{/examples/with_lrama/} => "12 - 3",
30
+ %r{/lib/flexr/regexp/tokenizer\.flexr\.rb\z} => "a|[a-z]+\\p{L}?"
31
+ }.freeze
32
+ TOKENIZER_REFERENCE_INPUTS = [
33
+ "a|[a-z]+\\p{L}?",
34
+ "あa",
35
+ "aあ",
36
+ "é|あ",
37
+ "\\xffa".b
38
+ ].freeze
39
+ RUNTIME_CLASS_NAMES = {
40
+ %r{/examples/calculator/} => "CalculatorExample::Lexer",
41
+ %r{/examples/json/} => "JsonExample::Lexer",
42
+ %r{/examples/toy_lang/} => "ToyLang::Lexer",
43
+ %r{/examples/ruby_subset/} => "RubySubset::Lexer",
44
+ %r{/examples/with_racc/} => "WithRacc::RaccLexer",
45
+ %r{/examples/with_lrama/} => "WithLrama::LramaLexer",
46
+ %r{/lib/flexr/regexp/tokenizer\.flexr\.rb\z} => "Flexr::Regexp::SourceLexer"
47
+ }.freeze
48
+ module_function
49
+
50
+ def input_for(spec)
51
+ normalized = normalized_path(spec)
52
+ EXPECTED_INPUTS.find { |pattern, _| normalized.match?(pattern) }&.last || raise("no verification input for #{spec}")
53
+ end
54
+
55
+ def golden_path(spec)
56
+ name = "#{File.basename(File.dirname(spec))}_#{File.basename(spec, '.flexr.rb')}.sha256"
57
+ File.join(ROOT, "benchmark/golden", name)
58
+ end
59
+
60
+ def generated_source(spec)
61
+ Flexr::Generator.new(relative_spec(spec)).generate
62
+ end
63
+
64
+ def relative_spec(spec)
65
+ normalized_spec = normalized_path(spec)
66
+ normalized_root = normalized_path(ROOT)
67
+ relative = normalized_spec.delete_prefix("#{normalized_root}/")
68
+ relative == normalized_spec ? normalized_spec.sub(%r{\A.*?(?=(?:examples|lib/flexr)/)}, "") : relative
69
+ end
70
+
71
+ def normalized_path(path)
72
+ path.to_s.tr("\\", "/")
73
+ end
74
+
75
+ def normalized_source(source)
76
+ source.gsub(/\r\n?/, "\n")
77
+ end
78
+
79
+ def random_unicode_string(random, max_codepoints: 8)
80
+ codepoints = Array.new(random.rand(max_codepoints + 1)) do
81
+ loop do
82
+ codepoint = random.rand(0x11_0000)
83
+ break codepoint unless codepoint.between?(0xd800, 0xdfff)
84
+ end
85
+ end
86
+ codepoints.pack("U*")
87
+ end
88
+
89
+ def load_runtime(spec)
90
+ class_name = runtime_class_name(spec)
91
+ existing = constantize(class_name)
92
+ return existing if runtime_lexer?(existing)
93
+ remove_constant(class_name) if existing
94
+
95
+ before = ObjectSpace.each_object(Class).to_a
96
+ load spec
97
+ loaded = (ObjectSpace.each_object(Class).to_a - before).find { |klass| runtime_lexer?(klass) }
98
+ return loaded if loaded
99
+
100
+ resolved = constantize(class_name)
101
+ return resolved if runtime_lexer?(resolved)
102
+
103
+ raise "no lexer class loaded from #{spec}"
104
+ end
105
+
106
+ def runtime_class_name(spec)
107
+ normalized = normalized_path(spec)
108
+ RUNTIME_CLASS_NAMES.find { |pattern, _class_name| normalized.match?(pattern) }&.last
109
+ end
110
+
111
+ def constantize(class_name)
112
+ return unless class_name
113
+
114
+ class_name.split("::").reject(&:empty?).reduce(Object) { |parent, name| parent.const_get(name) }
115
+ rescue NameError
116
+ nil
117
+ end
118
+
119
+ def remove_constant(class_name)
120
+ return unless class_name
121
+
122
+ parts = class_name.split("::").reject(&:empty?)
123
+ parent = constantize(parts[0...-1].join("::"))
124
+ parent.send(:remove_const, parts.last) if parent&.const_defined?(parts.last, false)
125
+ end
126
+
127
+ def runtime_lexer?(klass)
128
+ klass.is_a?(Class) && klass != Flexr::Lexer && klass.respond_to?(:__flexr_spec) &&
129
+ (!klass.respond_to?(:__flexr_generated?) || !klass.__flexr_generated?)
130
+ end
131
+
132
+ def verify_acceleration(spec)
133
+ lexer = load_runtime(spec)
134
+ random = Random.new(17)
135
+ inputs = [input_for(spec)] + Array.new(32) do
136
+ Array.new(random.rand(48..96)) { random.rand(32..126) }.pack("C*")
137
+ end
138
+ options = lexer.__flexr_config.options
139
+ original_accel = options.fetch(:accel, :auto)
140
+ accelerated = inputs.map { |input| lexer.new(input, error_mode: :panic).tokens }
141
+ options[:accel] = :none
142
+ reference = inputs.map { |input| lexer.new(input, error_mode: :panic).tokens }
143
+ raise "acceleration token mismatch in #{spec}" unless accelerated == reference
144
+
145
+ lexer.compile!.machines.each_value do |machine|
146
+ Flexr::Automaton::Accel.extract(machine.dfa).each do |region|
147
+ 256.times do |byte|
148
+ expected = region.bytes.include?(byte)
149
+ actual = region.regexp.match?(byte.chr(Encoding::BINARY))
150
+ next if expected == actual
151
+
152
+ raise "acceleration mismatch in #{spec}: state=#{region.state}, byte=#{byte}"
153
+ end
154
+ end
155
+ end
156
+ ensure
157
+ options[:accel] = original_accel if options && original_accel
158
+ end
159
+
160
+ def verify_dogfood(spec)
161
+ generated_path = File.join(Dir.tmpdir, "flexr-dogfood-#{Process.pid}-#{File.basename(spec)}")
162
+ source = generated_source(spec)
163
+ File.binwrite(generated_path, source)
164
+ _, stderr, status = Open3.capture3(RbConfig.ruby, "-c", generated_path)
165
+ raise "dogfood syntax check failed for #{spec}: #{stderr}" unless status.success?
166
+ raise "generated lexer missing compiled payload: #{spec}" unless source.include?("install_compiled!")
167
+ raise "generated lexer contains an unfinished TODO: #{spec}" if source.include?("FLEXR-TODO")
168
+
169
+ script = <<~RUBY
170
+ require "json"
171
+ load ARGV.fetch(0)
172
+ lexer = ObjectSpace.each_object(Class).find { |klass| klass.respond_to?(:__flexr_spec) && klass != Flexr::Lexer }
173
+ abort "no generated lexer" unless lexer
174
+ puts JSON.generate(lexer.new(ARGV.fetch(1)).tokens)
175
+ RUBY
176
+ runtime_output, runtime_error, runtime_status = Open3.capture3(RbConfig.ruby, "-Ilib", "-e", script,
177
+ spec, input_for(spec))
178
+ generated_output, generated_error, generated_status = Open3.capture3(RbConfig.ruby, "-Ilib", "-e", script,
179
+ generated_path, input_for(spec))
180
+ raise "dogfood token mismatch for #{spec}: #{runtime_error}#{generated_error}" unless
181
+ runtime_status.success? && generated_status.success? && runtime_output == generated_output
182
+
183
+ return unless spec == TOKENIZER_SPEC
184
+
185
+ reference_script = <<~RUBY
186
+ require "json"
187
+ require "regexp_tokenizer_reference"
188
+ puts JSON.generate(FlexrVerification::RegexpTokenizerReference.tokens(ARGV.fetch(0)))
189
+ RUBY
190
+ reference_output, reference_error, reference_status = Open3.capture3(
191
+ RbConfig.ruby, "-Itools", "-e", reference_script, input_for(spec)
192
+ )
193
+ raise "tokenizer reference failed: #{reference_error}" unless reference_status.success?
194
+ raise "tokenizer reference mismatch for #{spec}" unless runtime_output == reference_output
195
+ verify_tokenizer_reference
196
+ ensure
197
+ FileUtils.rm_f(generated_path) if generated_path
198
+ end
199
+
200
+ def verify_tokenizer_reference
201
+ runtime_lexer = load_runtime(TOKENIZER_SPEC)
202
+ generated_path = File.join(Dir.tmpdir, "flexr-tokenizer-reference-#{Process.pid}.rb")
203
+ remove_constant("Flexr::Regexp::SourceLexer")
204
+ File.binwrite(generated_path, generated_source(TOKENIZER_SPEC))
205
+ load generated_path
206
+ generated_lexer = Flexr::Regexp.const_get(:SourceLexer, false)
207
+
208
+ TOKENIZER_REFERENCE_INPUTS.each do |input|
209
+ expected = RegexpTokenizerReference.tokens(input)
210
+ runtime = runtime_lexer.new(input).tokens
211
+ generated = generated_lexer.new(input).tokens
212
+ next if runtime == expected && generated == expected
213
+
214
+ raise "tokenizer reference mismatch for #{input.inspect}: " \
215
+ "runtime=#{runtime.inspect}, generated=#{generated.inspect}, reference=#{expected.inspect}"
216
+ end
217
+ ensure
218
+ FileUtils.rm_f(generated_path) if generated_path
219
+ remove_constant("Flexr::Regexp::SourceLexer")
220
+ Flexr::Regexp.const_set(:SourceLexer, runtime_lexer) if runtime_lexer
221
+ end
222
+
223
+ end
224
+
225
+ namespace :modes do
226
+ task :equivalence do
227
+ FlexrVerification::VERIFICATION_SPECS.each do |spec|
228
+ generated_path = File.join(Dir.tmpdir, "flexr-mode-#{Process.pid}-#{File.basename(spec)}")
229
+ File.binwrite(generated_path, FlexrVerification.generated_source(spec))
230
+ script = <<~RUBY
231
+ load ARGV.fetch(0)
232
+ lexer = ObjectSpace.each_object(Class).find { |klass| klass.respond_to?(:__flexr_spec) && klass != Flexr::Lexer }
233
+ abort "no lexer found" unless lexer
234
+ p lexer.new(ARGV.fetch(1)).tokens
235
+ RUBY
236
+ runtime_output, runtime_error, runtime_status = Open3.capture3(RbConfig.ruby, "-Ilib", "-e", script,
237
+ spec, FlexrVerification.input_for(spec))
238
+ generated_output, generated_error, generated_status = Open3.capture3(RbConfig.ruby, "-Ilib", "-e", script,
239
+ generated_path, FlexrVerification.input_for(spec))
240
+ abort "mode mismatch: #{spec}\n#{runtime_error}#{generated_error}" unless runtime_status.success? && generated_status.success? && runtime_output == generated_output
241
+ ensure
242
+ FileUtils.rm_f(generated_path) if generated_path
243
+ end
244
+ end
245
+ end
246
+
247
+ task "golden:verify" do
248
+ FlexrVerification::VERIFICATION_SPECS.each do |spec|
249
+ golden = FlexrVerification.golden_path(spec)
250
+ abort "missing golden file: #{golden}" unless File.file?(golden)
251
+
252
+ expected = File.read(golden).strip
253
+ actual = Digest::SHA256.hexdigest(FlexrVerification.generated_source(spec))
254
+ abort "golden mismatch: #{spec} (expected #{expected}, got #{actual})" unless expected == actual
255
+ end
256
+ end
257
+
258
+ task "accel:equivalence" do
259
+ FlexrVerification::VERIFICATION_SPECS.each { |spec| FlexrVerification.verify_acceleration(spec) }
260
+ end
261
+
262
+ task "dogfood:verify" do
263
+ FlexrVerification::VERIFICATION_SPECS.each { |spec| FlexrVerification.verify_dogfood(spec) }
264
+ end
265
+
266
+ task "generated:verify" do
267
+ abort "missing committed tokenizer generated file: #{FlexrVerification::TOKENIZER_GENERATED}" unless
268
+ File.file?(FlexrVerification::TOKENIZER_GENERATED)
269
+
270
+ expected = FlexrVerification.normalized_source(File.binread(FlexrVerification::TOKENIZER_GENERATED))
271
+ actual = FlexrVerification.normalized_source(FlexrVerification.generated_source(FlexrVerification::TOKENIZER_SPEC))
272
+ abort "committed tokenizer generated file is stale" unless expected == actual
273
+
274
+ puts "generated: committed tokenizer is reproducible"
275
+ end
276
+
277
+ task "dot:verify" do
278
+ spec = File.join(FlexrVerification::ROOT, "examples/json/lexer.flexr.rb")
279
+ dot_source, dot_error, dot_status = Open3.capture3(
280
+ RbConfig.ruby, "-Ilib", "exe/flexr", "dot", spec, chdir: FlexrVerification::ROOT
281
+ )
282
+ abort "flexr dot failed: #{dot_error}" unless dot_status.success?
283
+
284
+ svg, svg_error, svg_status = Open3.capture3("dot", "-Tsvg", stdin_data: dot_source)
285
+ abort "dot -Tsvg failed: #{svg_error}" unless svg_status.success? && svg.include?("<svg")
286
+
287
+ puts "dot: parsed #{spec} as SVG"
288
+ rescue Errno::ENOENT => e
289
+ abort "dot executable is required for dot:verify: #{e.message}"
290
+ end
291
+
292
+ task "direct:verify" do
293
+ unless defined?(RubyVM::InstructionSequence)
294
+ puts "direct: disassembly unavailable on #{RUBY_ENGINE}; skipped"
295
+ next
296
+ end
297
+
298
+ spec = File.join(FlexrVerification::ROOT, "examples/json/lexer.flexr.rb")
299
+ disassembly = RubyVM::InstructionSequence.compile(FlexrVerification.generated_source(spec)).disasm
300
+ abort "direct dispatch did not compile to opt_case_dispatch" unless disassembly.include?("opt_case_dispatch")
301
+
302
+ puts "direct: opt_case_dispatch present"
303
+ end
304
+
305
+ task "unicode:verify" do
306
+ splitter = Flexr::Unicode::Utf8Splitter
307
+ properties = Flexr::Unicode::Data::PROPERTIES
308
+ abort "unexpected vendored Unicode version" unless Flexr::Unicode::VERSION == "15.1.0"
309
+ properties.each do |name, ranges|
310
+ previous = -1
311
+ ranges.each do |lo, hi|
312
+ abort "invalid #{name} Unicode range #{lo.inspect}..#{hi.inspect}" unless
313
+ lo.is_a?(Integer) && hi.is_a?(Integer) && lo <= hi && lo > previous && hi <= 0x10_ffff
314
+
315
+ previous = hi
316
+ end
317
+ end
318
+ scalar_count = 0
319
+ (0..0x10_ffff).each do |codepoint|
320
+ next if codepoint.between?(0xd800, 0xdfff)
321
+
322
+ expected = [codepoint].pack("U").bytes.map { |byte| [byte, byte] }
323
+ actual = splitter.split(codepoint, codepoint)
324
+ abort "Unicode singleton mismatch at U+#{codepoint.to_s(16)}" unless actual == [expected]
325
+ scalar_count += 1
326
+ end
327
+
328
+ random = Random.new(0xF1E2)
329
+ 100_000.times do
330
+ loop do
331
+ lo = random.rand(0x11_0000)
332
+ hi = [lo + random.rand(17), 0x10_ffff].min
333
+ next if lo <= 0xdfff && hi >= 0xd800
334
+
335
+ sequences = splitter.split(lo, hi)
336
+ abort "Unicode range split was empty for U+#{lo.to_s(16)}..U+#{hi.to_s(16)}" if sequences.empty?
337
+ (lo..hi).each do |codepoint|
338
+ bytes = [codepoint].pack("U").bytes
339
+ included = sequences.any? do |sequence|
340
+ sequence.length == bytes.length && sequence.zip(bytes).all? { |range, byte| byte.between?(*range) }
341
+ end
342
+ abort "Unicode range omitted U+#{codepoint.to_s(16)}" unless included
343
+ end
344
+ break
345
+ end
346
+ end
347
+ puts "unicode: UCD #{Flexr::Unicode::VERSION}, #{properties.length} properties, " \
348
+ "#{scalar_count} singleton and 100000 random range cases passed"
349
+ end
350
+
351
+ task "bench:regression" do
352
+ baseline = ENV.fetch("FLEXR_BENCHMARK_BASELINE", File.join(FlexrVerification::ROOT, "benchmark/baselines/json.json"))
353
+ command = [RbConfig.ruby, "-Ilib", "benchmark/run.rb", "--baseline", baseline, "--json"]
354
+ stdout, stderr, status = Open3.capture3(*command, chdir: FlexrVerification::ROOT)
355
+ abort "benchmark regression failed (#{status.exitstatus}): #{stderr}#{stdout}" unless status.success?
356
+
357
+ puts stdout
358
+ end
359
+
360
+ namespace :test do
361
+ task :differential do
362
+ patterns = [/[a-z]+/, /a(?:b|c)?/, /[0-9]{1,3}/, /[^\n]+/, /foo/,
363
+ /[[:alpha:]]+/, /[[:alnum:]]+/, /\p{L}+/, /\p{Nd}+/]
364
+ cases = Integer(ENV.fetch("FLEXR_DIFFERENTIAL_CASES", "1000000"), 10)
365
+ random = Random.new(Integer(ENV.fetch("FLEXR_SEED", "17"), 10))
366
+ unicode_inputs = ["", "a", "あ", "é", "ß", "Ω", "١", " ", "aあ", "éΩ", [0x18db8].pack("U")].freeze
367
+ compiled = {}
368
+ cases.times do
369
+ pattern = patterns[random.rand(patterns.length)]
370
+ input = if random.rand(3).zero?
371
+ random.rand(2).zero? ? unicode_inputs.sample(random: random) : FlexrVerification.random_unicode_string(random)
372
+ else
373
+ Array.new(random.rand(10)) { random.rand(32..126) }.pack("C*")
374
+ end
375
+ expected = if Flexr.reference_pattern?(pattern)
376
+ reference = Flexr::Unicode::ReferenceRegexp.compiled(
377
+ pattern, encoding: pattern.encoding, options: pattern.options, unicode: false
378
+ )
379
+ match = reference.match(input, 0)
380
+ if match
381
+ match.begin(0).zero? && match[0].bytesize == input.bytesize
382
+ else
383
+ false
384
+ end
385
+ else
386
+ Regexp.new("\\A(?:#{pattern.source})\\z", pattern.options).match?(input)
387
+ end
388
+ key = [pattern.source, pattern.options]
389
+ actual = (compiled[key] ||= Flexr.compile_pattern(pattern)).accept?(input)
390
+ next if expected == actual
391
+
392
+ abort "differential mismatch: #{pattern.inspect} #{input.inspect} expected=#{expected} actual=#{actual}"
393
+ end
394
+ puts "differential: #{cases} cases passed"
395
+ end
396
+ end
397
+
398
+ task :fuzz do
399
+ cases = Integer(ENV.fetch("FLEXR_FUZZ_CASES", "10000"), 10)
400
+ random = Random.new(Integer(ENV.fetch("FLEXR_SEED", "17"), 10))
401
+ FlexrVerification::VERIFICATION_SPECS.each do |spec|
402
+ runtime_lexer = FlexrVerification.load_runtime(spec)
403
+ parts = runtime_lexer.name.split("::")
404
+ parent = Object
405
+ parts[0...-1].each { |part| parent = parent.const_get(part) }
406
+ parent.send(:remove_const, parts.last) if parent.const_defined?(parts.last, false)
407
+ generated_path = File.join(Dir.tmpdir, "flexr-fuzz-#{Process.pid}-#{File.basename(spec)}")
408
+ before = ObjectSpace.each_object(Class).to_a
409
+ File.binwrite(generated_path, FlexrVerification.generated_source(spec))
410
+ load generated_path
411
+ generated_lexer = (ObjectSpace.each_object(Class).to_a - before).find do |klass|
412
+ klass.respond_to?(:__flexr_spec)
413
+ end
414
+ raise "no generated lexer found for #{spec}" unless generated_lexer
415
+
416
+ cases.times do
417
+ input = case random.rand(4)
418
+ when 0
419
+ Array.new(random.rand(128)) { random.rand(0..127) }.pack("C*").force_encoding(Encoding::UTF_8)
420
+ when 1
421
+ FlexrVerification.random_unicode_string(random, max_codepoints: 32)
422
+ when 2
423
+ Array.new(random.rand(128)) { random.rand(0..255) }.pack("C*").force_encoding(Encoding::UTF_8)
424
+ else
425
+ (FlexrVerification.random_unicode_string(random, max_codepoints: 16) +
426
+ Array.new(random.rand(64)) { random.rand(32..126) }.pack("C*")).force_encoding(Encoding::UTF_8)
427
+ end
428
+ runtime_tokens = runtime_lexer.new(input, error_mode: :panic).tokens
429
+ generated_tokens = generated_lexer.new(input, error_mode: :panic).tokens
430
+ next if runtime_tokens == generated_tokens
431
+
432
+ abort "fuzz mode mismatch: #{spec} input=#{input.inspect} runtime=#{runtime_tokens.inspect} generated=#{generated_tokens.inspect}"
433
+ rescue Flexr::LexError, ArgumentError, EncodingError
434
+ # Invalid input and user-defined error actions are expected fuzz outcomes.
435
+ end
436
+ ensure
437
+ FileUtils.rm_f(generated_path) if generated_path
438
+ end
439
+ puts "fuzz: #{cases} inputs per example passed"
440
+ end
441
+
442
+ namespace :examples do
443
+ task :check do
444
+ FlexrVerification::EXAMPLES.each do |spec|
445
+ stdout, stderr, status = Open3.capture3(
446
+ RbConfig.ruby, "-Ilib", "exe/flexr", "check", spec, "--format", "json", chdir: FlexrVerification::ROOT
447
+ )
448
+ abort "example diagnostics failed for #{spec}: #{stderr}#{stdout}" unless status.success?
449
+ diagnostics = JSON.parse(stdout)
450
+ abort "example diagnostics are not empty for #{spec}: #{diagnostics.inspect}" unless diagnostics.empty?
451
+ rescue JSON::ParserError => e
452
+ abort "example diagnostics were not JSON for #{spec}: #{e.message}\n#{stdout}#{stderr}"
453
+ end
454
+ puts "examples: all checks passed"
455
+ end
456
+ end
457
+
458
+ task :coverage do
459
+ sh RbConfig.ruby, "-Ilib", "tools/coverage.rb"
460
+ end
461
+
462
+ namespace :docs do
463
+ task :verify do
464
+ ruby "tools/docs_verify.rb"
465
+ end
466
+ end
467
+
468
+ task default: :spec