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.
- checksums.yaml +7 -0
- data/.rubocop.yml +33 -0
- data/CONTRIBUTING.md +39 -0
- data/LICENSE.txt +21 -0
- data/README.md +116 -0
- data/Rakefile +468 -0
- data/benchmark/baselines/json.json +34 -0
- data/benchmark/baselines/json_handwritten.rb +43 -0
- data/benchmark/baselines/json_rexical.rex +25 -0
- data/benchmark/corpora/README.md +11 -0
- data/benchmark/corpora/generate_json.rb +26 -0
- data/benchmark/golden/calculator_lexer.sha256 +1 -0
- data/benchmark/golden/json_lexer.sha256 +1 -0
- data/benchmark/golden/regexp_tokenizer.sha256 +1 -0
- data/benchmark/golden/ruby_subset_lexer.sha256 +1 -0
- data/benchmark/golden/toy_lang_lexer.sha256 +1 -0
- data/benchmark/golden/with_lrama_lexer.sha256 +1 -0
- data/benchmark/golden/with_racc_lexer.sha256 +1 -0
- data/benchmark/run.rb +254 -0
- data/docs/README.md +64 -0
- data/docs/RELEASING.md +30 -0
- data/docs/adr/0001-byte-level-dfa.md +5 -0
- data/docs/adr/0003-leftmost-longest.md +4 -0
- data/docs/adr/0006-accel-not-scanner.md +4 -0
- data/docs/adr/0008-what-pure-ruby-means.md +5 -0
- data/docs/adr/0016-spec-is-plain-ruby.md +4 -0
- data/docs/adr/0017-static-analysis-by-default.md +5 -0
- data/docs/adr/0018-prism-for-generator-only.md +4 -0
- data/docs/adr/0019-measured-performance-floor.md +26 -0
- data/docs/adr/0020-vendored-unicode-contract.md +21 -0
- data/docs/explanation/backends.md +33 -0
- data/docs/explanation/matching-semantics.md +20 -0
- data/docs/explanation/runtime-vs-generated.md +22 -0
- data/docs/explanation/security-model.md +18 -0
- data/docs/explanation/unicode-and-encoding.md +20 -0
- data/docs/how-to/deploy-a-standalone-lexer.md +23 -0
- data/docs/how-to/generate-a-lexer.md +39 -0
- data/docs/how-to/handle-errors.md +32 -0
- data/docs/how-to/integrate-with-lrama.md +21 -0
- data/docs/how-to/integrate-with-racc.md +25 -0
- data/docs/how-to/migrate-from-flex.md +21 -0
- data/docs/how-to/migrate-from-rexical.md +23 -0
- data/docs/how-to/run-a-lexer-at-runtime.md +29 -0
- data/docs/how-to/track-token-locations.md +27 -0
- data/docs/how-to/tune-performance.md +23 -0
- data/docs/how-to/use-states.md +36 -0
- data/docs/how-to/use-trailing-context.md +22 -0
- data/docs/internals/README.md +14 -0
- data/docs/perf-log.md +56 -0
- data/docs/reference/README.md +23 -0
- data/docs/reference/actions.md +47 -0
- data/docs/reference/cli.md +80 -0
- data/docs/reference/compatibility.md +38 -0
- data/docs/reference/diagnostics.md +41 -0
- data/docs/reference/dsl.md +81 -0
- data/docs/reference/errors.md +27 -0
- data/docs/reference/generated-artifacts.md +50 -0
- data/docs/reference/public-api.md +42 -0
- data/docs/reference/regexp.md +39 -0
- data/docs/reference/runtime.md +49 -0
- data/docs/reference/tokens-and-locations.md +33 -0
- data/docs/tutorial/build-a-calculator-lexer.md +96 -0
- data/examples/calculator/README.md +27 -0
- data/examples/calculator/lexer.flexr.rb +17 -0
- data/examples/json/README.md +30 -0
- data/examples/json/lexer.flexr.rb +24 -0
- data/examples/ruby_subset/README.md +17 -0
- data/examples/ruby_subset/lexer.flexr.rb +22 -0
- data/examples/toy_lang/README.md +17 -0
- data/examples/toy_lang/lexer.flexr.rb +18 -0
- data/examples/with_lrama/README.md +17 -0
- data/examples/with_lrama/lexer.flexr.rb +13 -0
- data/examples/with_racc/README.md +17 -0
- data/examples/with_racc/lexer.flexr.rb +13 -0
- data/exe/flexr +7 -0
- data/lib/flexr/automaton/accel.rb +39 -0
- data/lib/flexr/automaton/analysis.rb +38 -0
- data/lib/flexr/automaton/byte_class_set.rb +29 -0
- data/lib/flexr/automaton/compiler.rb +413 -0
- data/lib/flexr/automaton/dfa.rb +103 -0
- data/lib/flexr/automaton/minimizer.rb +70 -0
- data/lib/flexr/automaton/nfa.rb +92 -0
- data/lib/flexr/cli.rb +342 -0
- data/lib/flexr/codegen/base.rb +17 -0
- data/lib/flexr/codegen/direct.rb +52 -0
- data/lib/flexr/codegen/firstmatch.rb +17 -0
- data/lib/flexr/codegen/table.rb +158 -0
- data/lib/flexr/codegen/table_packer.rb +61 -0
- data/lib/flexr/diagnostics.rb +94 -0
- data/lib/flexr/dsl.rb +182 -0
- data/lib/flexr/errors.rb +28 -0
- data/lib/flexr/generated.rb +125 -0
- data/lib/flexr/generator.rb +400 -0
- data/lib/flexr/importer.rb +560 -0
- data/lib/flexr/ir.rb +36 -0
- data/lib/flexr/lexer.rb +10 -0
- data/lib/flexr/options.rb +47 -0
- data/lib/flexr/rake_task.rb +27 -0
- data/lib/flexr/regexp/ast.rb +45 -0
- data/lib/flexr/regexp/char_class.rb +7 -0
- data/lib/flexr/regexp/normalizer.rb +117 -0
- data/lib/flexr/regexp/parser.rb +517 -0
- data/lib/flexr/regexp/tokenizer.flexr.rb +27 -0
- data/lib/flexr/regexp/tokenizer.rb +168 -0
- data/lib/flexr/regexp/unsupported.rb +7 -0
- data/lib/flexr/runtime/buffer.rb +112 -0
- data/lib/flexr/runtime/core.rb +388 -0
- data/lib/flexr/runtime/errors.rb +22 -0
- data/lib/flexr/runtime/interpreter.rb +505 -0
- data/lib/flexr/runtime/location.rb +26 -0
- data/lib/flexr/runtime/token.rb +7 -0
- data/lib/flexr/source/passthrough.rb +31 -0
- data/lib/flexr/source/prism_reader.rb +283 -0
- data/lib/flexr/source/static_eval.rb +145 -0
- data/lib/flexr/unicode/case_fold.rb +45 -0
- data/lib/flexr/unicode/data/LICENSE-UNICODE.txt +5 -0
- data/lib/flexr/unicode/data/UNICODE_VERSION +1 -0
- data/lib/flexr/unicode/data/case_folding.rb +9 -0
- data/lib/flexr/unicode/data/properties.rb +10 -0
- data/lib/flexr/unicode/property.rb +107 -0
- data/lib/flexr/unicode/reference_regexp.rb +102 -0
- data/lib/flexr/unicode/utf8_splitter.rb +109 -0
- data/lib/flexr/version.rb +5 -0
- data/lib/flexr.rb +81 -0
- data/site/README.md +22 -0
- data/site/astro.config.mjs +57 -0
- data/site/package.json +19 -0
- data/site/pnpm-lock.yaml +5029 -0
- data/site/pnpm-workspace.yaml +6 -0
- data/site/public/playground.js +189 -0
- data/site/scripts/verify-site.mjs +42 -0
- data/site/src/content/docs/benchmarks.md +8 -0
- data/site/src/content/docs/concepts/matching-semantics.md +15 -0
- data/site/src/content/docs/concepts/regexp-model.md +18 -0
- data/site/src/content/docs/concepts/runtime-vs-generated.md +15 -0
- data/site/src/content/docs/concepts/security-model.md +15 -0
- data/site/src/content/docs/examples.md +17 -0
- data/site/src/content/docs/learn/generation.md +29 -0
- data/site/src/content/docs/learn/getting-started.md +56 -0
- data/site/src/content/docs/learn/parser-integration.md +27 -0
- data/site/src/content/docs/learn/runtime-mode.md +32 -0
- data/site/src/content/docs/reference/action-context.md +20 -0
- data/site/src/content/docs/reference/cli.md +22 -0
- data/site/src/content/docs/reference/diagnostics.md +16 -0
- data/site/src/content/docs/reference/dsl.md +19 -0
- data/site/src/content/docs/reference/public-api.md +18 -0
- data/site/src/content/docs/reference/regexp.md +16 -0
- data/site/src/content/docs/reference/runtime.md +16 -0
- data/site/src/content/docs/reference/tokens-and-locations.md +16 -0
- data/site/src/content.config.ts +12 -0
- data/site/src/env.d.ts +1 -0
- data/site/src/layouts/SiteLayout.astro +39 -0
- data/site/src/pages/index.astro +174 -0
- data/site/src/pages/playground.astro +64 -0
- data/site/src/styles/custom.css +711 -0
- data/site/tsconfig.json +5 -0
- data/tools/coverage.rb +32 -0
- data/tools/docs_verify.rb +116 -0
- data/tools/gen_unicode_tables.rb +202 -0
- data/tools/regexp_tokenizer_reference.rb +60 -0
- 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,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.
|