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,17 @@
|
|
|
1
|
+
# flexr with Racc
|
|
2
|
+
|
|
3
|
+
## What this example demonstrates
|
|
4
|
+
|
|
5
|
+
`RaccLexer` exposes the two-element `racc_next_token` protocol expected by Racc.
|
|
6
|
+
The parser dependency is intentionally optional in this repository.
|
|
7
|
+
|
|
8
|
+
## Run and inspect
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
ruby -Ilib -e 'load "examples/with_racc/lexer.flexr.rb"; lexer = WithRacc::RaccLexer.new("12 + 3"); p lexer.racc_next_token; p lexer.racc_next_token; p lexer.racc_next_token; p lexer.racc_next_token'
|
|
12
|
+
flexr tokens examples/with_racc/lexer.flexr.rb
|
|
13
|
+
flexr check examples/with_racc/lexer.flexr.rb --format json
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Compare the names printed by `flexr tokens` with the grammar's `%token` list.
|
|
17
|
+
See the [Racc integration guide](../../docs/how-to/integrate-with-racc.md).
|
data/exe/flexr
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flexr
|
|
4
|
+
module Automaton
|
|
5
|
+
Region = Struct.new(:state, :bytes, :regexp, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
module Accel
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def extract(dfa)
|
|
11
|
+
dfa.transitions.each_index.filter_map do |state|
|
|
12
|
+
bytes = Analysis.self_loop_set(dfa, state)
|
|
13
|
+
next if bytes.empty?
|
|
14
|
+
|
|
15
|
+
Region.new(state: state, bytes: bytes.freeze, regexp: regexp_for(bytes))
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def regexp_for(bytes)
|
|
20
|
+
source = bytes_to_source(bytes)
|
|
21
|
+
::Regexp.new("(?:[#{source}])+", ::Regexp::NOENCODING)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def bytes_to_source(bytes)
|
|
25
|
+
ranges = []
|
|
26
|
+
bytes.sort.each do |byte|
|
|
27
|
+
if ranges.empty? || byte > ranges.last.last + 1
|
|
28
|
+
ranges << [byte, byte]
|
|
29
|
+
else
|
|
30
|
+
ranges.last[1] = byte
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
ranges.map do |lo, hi|
|
|
34
|
+
lo == hi ? format("\\x%<byte>02X", byte: lo) : format("\\x%<lo>02X-\\x%<hi>02X", lo: lo, hi: hi)
|
|
35
|
+
end.join
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flexr
|
|
4
|
+
module Automaton
|
|
5
|
+
module Analysis
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def unreachable_rules(compiled)
|
|
9
|
+
present = compiled.machines.values.flat_map { |machine| machine.dfa.rule_ids }.uniq
|
|
10
|
+
compiled.rules.reject { |rule| present.include?(rule.index) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def needs_backup?(dfa)
|
|
14
|
+
dfa.transitions.each_index.any? do |state|
|
|
15
|
+
next false if dfa.accepts[state].empty?
|
|
16
|
+
|
|
17
|
+
dfa.transitions[state].compact.any? { |destination| dfa.accepts[destination].empty? }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self_loop_set(dfa, state)
|
|
22
|
+
dfa.transitions[state].each_with_index.with_object([]) do |(destination, class_id), result|
|
|
23
|
+
next unless destination == state
|
|
24
|
+
|
|
25
|
+
dfa.ec.each_with_index do |value, byte|
|
|
26
|
+
result << byte if value == class_id
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def dead_states(dfa)
|
|
32
|
+
dfa.transitions.each_index.select do |state|
|
|
33
|
+
dfa.accepts[state].empty? && dfa.transitions[state].compact.all? { |destination| destination == state }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flexr
|
|
4
|
+
module Automaton
|
|
5
|
+
class ByteClassSet
|
|
6
|
+
def initialize
|
|
7
|
+
@boundaries = Array.new(257, false)
|
|
8
|
+
@boundaries[0] = true
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def add_range(lo, hi)
|
|
12
|
+
raise ArgumentError, "invalid byte range" unless lo.between?(0, 255) && hi.between?(lo, 255)
|
|
13
|
+
|
|
14
|
+
@boundaries[lo] = true
|
|
15
|
+
@boundaries[hi + 1] = true if hi < 255
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build
|
|
19
|
+
ec = Array.new(256)
|
|
20
|
+
class_id = -1
|
|
21
|
+
256.times do |byte|
|
|
22
|
+
class_id += 1 if @boundaries[byte]
|
|
23
|
+
ec[byte] = class_id
|
|
24
|
+
end
|
|
25
|
+
[ec.freeze, class_id + 1]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flexr
|
|
4
|
+
module Automaton
|
|
5
|
+
CompiledSpec = Struct.new(:machines, :rules, :states, :stats, :diagnostics, keyword_init: true)
|
|
6
|
+
Machine = Struct.new(:dfa, :state_name, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
class Compiler
|
|
9
|
+
def initialize(spec)
|
|
10
|
+
@spec = spec
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def compile
|
|
14
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
15
|
+
validate_rules
|
|
16
|
+
state_names = effective_states
|
|
17
|
+
machines = state_names.to_h do |state_name|
|
|
18
|
+
rules = rules_for(state_name)
|
|
19
|
+
[state_name, Machine.new(dfa: compile_machine(rules), state_name: state_name)]
|
|
20
|
+
end
|
|
21
|
+
stats = machines.transform_values { |machine| machine.dfa.stats }
|
|
22
|
+
compiled = CompiledSpec.new(machines: machines, rules: @spec.rules, states: state_names, stats: stats)
|
|
23
|
+
compiled.diagnostics = diagnostics_for(compiled, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
|
|
24
|
+
compiled
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def effective_states
|
|
30
|
+
names = [:initial]
|
|
31
|
+
@spec.states.each_key { |name| names << name unless names.include?(name) }
|
|
32
|
+
names
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def rules_for(state_name)
|
|
36
|
+
state = @spec.states.fetch(state_name)
|
|
37
|
+
@spec.rules.select do |rule|
|
|
38
|
+
next true if state_name == :initial && rule.states.include?(:initial)
|
|
39
|
+
next true if state.inclusive && rule.states.include?(:initial)
|
|
40
|
+
next false unless rule.states.include?(state_name)
|
|
41
|
+
|
|
42
|
+
!rule.states.empty?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def compile_machine(rules)
|
|
47
|
+
@active_rule_ids = rules.map(&:index)
|
|
48
|
+
normalized = []
|
|
49
|
+
rules.each do |rule|
|
|
50
|
+
rule.pattern_conditions = []
|
|
51
|
+
if reference_rule?(rule)
|
|
52
|
+
validate_reference_patterns(rule)
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
rule.patterns.each_with_index do |pattern, pattern_index|
|
|
57
|
+
regexp = pattern.is_a?(::Regexp) ? pattern : ::Regexp.new(::Regexp.escape(pattern.to_s))
|
|
58
|
+
encoding = regexp.encoding == Encoding::BINARY ? Encoding::BINARY : @spec.encoding
|
|
59
|
+
parser = Regexp::Parser.new(regexp.source, options: regexp.options, encoding: encoding,
|
|
60
|
+
unicode: @spec.options[:unicode] == true)
|
|
61
|
+
ast = parser.parse
|
|
62
|
+
ast, bol_only, end_anchor = strip_anchors(ast)
|
|
63
|
+
condition = Acceptance.new(rule_index: rule.index, pattern_index: pattern_index,
|
|
64
|
+
bol_only: bol_only, end_anchor: end_anchor)
|
|
65
|
+
rule.pattern_conditions[pattern_index] = condition
|
|
66
|
+
normalized_ast = Regexp::Normalizer.new(ast, encoding: encoding, options: regexp.options).normalize
|
|
67
|
+
normalized << [normalized_ast, condition]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
return empty_dfa if normalized.empty?
|
|
71
|
+
|
|
72
|
+
nfa = NFABuilder.new.build(normalized)
|
|
73
|
+
ec, class_count = nfa.byte_classes.build
|
|
74
|
+
subset_construction(nfa, ec, class_count)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def empty_dfa
|
|
78
|
+
DFA.new(transitions: [[nil]], accepts: [[]], ec: Array.new(256, 0), class_count: 1, start: 0, rule_ids: [])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def reference_rule?(rule)
|
|
82
|
+
rule.patterns.any? { |pattern| reference_pattern?(pattern) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def reference_pattern?(pattern)
|
|
86
|
+
return false unless pattern.is_a?(::Regexp)
|
|
87
|
+
return true if pattern.source.match?(/\\[pP]\{/) || pattern.source.match?(/\[:(?:\^)?[a-z]+:\]/)
|
|
88
|
+
|
|
89
|
+
@spec.options[:unicode] == true && @spec.encoding != Encoding::BINARY &&
|
|
90
|
+
pattern.source.match?(/\\[dDwWsS]/)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_reference_patterns(rule)
|
|
94
|
+
rule.patterns.each_with_index do |pattern, pattern_index|
|
|
95
|
+
regexp = pattern.is_a?(::Regexp) ? pattern : ::Regexp.new(::Regexp.escape(pattern.to_s))
|
|
96
|
+
encoding = regexp.encoding == Encoding::BINARY ? Encoding::BINARY : @spec.encoding
|
|
97
|
+
parser = Regexp::Parser.new(regexp.source, options: regexp.options, encoding: encoding,
|
|
98
|
+
unicode: @spec.options[:unicode] == true)
|
|
99
|
+
ast = parser.parse
|
|
100
|
+
_body, bol_only, end_anchor = strip_anchors(ast)
|
|
101
|
+
rule.pattern_conditions[pattern_index] = Acceptance.new(
|
|
102
|
+
rule_index: rule.index, pattern_index: pattern_index, bol_only: bol_only, end_anchor: end_anchor
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def strip_anchors(ast)
|
|
108
|
+
children = ast.is_a?(Regexp::AST::Seq) ? ast.children.dup : [ast]
|
|
109
|
+
children.shift while children.first.is_a?(Regexp::AST::Empty)
|
|
110
|
+
children.pop while children.last.is_a?(Regexp::AST::Empty)
|
|
111
|
+
bol_only = children.first.is_a?(Regexp::AST::Anchor) && children.first.kind == :bol
|
|
112
|
+
end_anchor = children.last.is_a?(Regexp::AST::Anchor) && children.last.kind == :eol
|
|
113
|
+
children.shift if bol_only
|
|
114
|
+
children.pop if end_anchor
|
|
115
|
+
body = if children.empty?
|
|
116
|
+
Regexp::AST::Empty.new(loc: nil)
|
|
117
|
+
elsif children.length == 1
|
|
118
|
+
children.first
|
|
119
|
+
else
|
|
120
|
+
Regexp::AST::Seq.new(children: children, loc: nil)
|
|
121
|
+
end
|
|
122
|
+
if contains_anchor?(body)
|
|
123
|
+
diagnostic = Diagnostics.error(
|
|
124
|
+
"FLEXR-E009", "anchors are only valid at the outermost pattern boundaries",
|
|
125
|
+
help: "split alternatives into separate rules or move ^/$ outside the alternation"
|
|
126
|
+
)
|
|
127
|
+
raise CompileError.new(diagnostic.message, diagnostic: diagnostic)
|
|
128
|
+
end
|
|
129
|
+
[body, bol_only, end_anchor]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def contains_anchor?(node)
|
|
133
|
+
return true if node.is_a?(Regexp::AST::Anchor)
|
|
134
|
+
return false unless node.respond_to?(:children)
|
|
135
|
+
|
|
136
|
+
node.children.any? { |child| contains_anchor?(child) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def subset_construction(nfa, ec, class_count)
|
|
140
|
+
representatives = Array.new(class_count)
|
|
141
|
+
ec.each_with_index { |class_id, byte| representatives[class_id] ||= byte }
|
|
142
|
+
start_set = epsilon_closure(nfa, 1 << nfa.start)
|
|
143
|
+
sets = [start_set]
|
|
144
|
+
ids = { start_set => 0 }
|
|
145
|
+
transitions = []
|
|
146
|
+
accepts = []
|
|
147
|
+
queue = [start_set]
|
|
148
|
+
|
|
149
|
+
until queue.empty?
|
|
150
|
+
set = queue.shift
|
|
151
|
+
state_id = ids.fetch(set)
|
|
152
|
+
transitions[state_id] ||= Array.new(class_count)
|
|
153
|
+
accepts[state_id] = accepting_rules(nfa, set)
|
|
154
|
+
class_count.times do |class_id|
|
|
155
|
+
moved = move(nfa, set, representatives[class_id])
|
|
156
|
+
next if moved.zero?
|
|
157
|
+
closure = epsilon_closure(nfa, moved)
|
|
158
|
+
destination = ids[closure]
|
|
159
|
+
unless destination
|
|
160
|
+
destination = sets.length
|
|
161
|
+
limit = @spec.options.fetch(:max_dfa_states, 100_000)
|
|
162
|
+
limit = 100_000 unless limit.is_a?(Integer) && limit.positive?
|
|
163
|
+
if destination >= limit
|
|
164
|
+
message = "DFA state limit exceeded while compiling rules #{@active_rule_ids.join(', ')}"
|
|
165
|
+
diagnostic = Diagnostics.error("FLEXR-E006", message,
|
|
166
|
+
help: "raise max_dfa_states or split the listed rules")
|
|
167
|
+
raise CompileError.new(diagnostic.message, diagnostic: diagnostic)
|
|
168
|
+
end
|
|
169
|
+
ids[closure] = destination
|
|
170
|
+
sets << closure
|
|
171
|
+
queue << closure
|
|
172
|
+
end
|
|
173
|
+
transitions[state_id][class_id] = destination
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
transitions.each { |row| row.map! { |value| value } }
|
|
177
|
+
rule_ids = accepts.flatten.map(&:rule_index).uniq.sort
|
|
178
|
+
dfa = DFA.new(transitions: transitions, accepts: accepts, ec: ec, class_count: class_count, start: 0,
|
|
179
|
+
rule_ids: rule_ids)
|
|
180
|
+
Minimizer.minimize(dfa)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def epsilon_closure(nfa, set)
|
|
184
|
+
closure = set
|
|
185
|
+
stack = []
|
|
186
|
+
nfa.states.each_index { |id| stack << id if set.anybits?(1 << id) }
|
|
187
|
+
until stack.empty?
|
|
188
|
+
state = stack.pop
|
|
189
|
+
nfa.states[state].epsilon.each do |target|
|
|
190
|
+
next if closure.anybits?(1 << target)
|
|
191
|
+
|
|
192
|
+
closure |= 1 << target
|
|
193
|
+
stack << target
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
closure
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def move(nfa, set, byte)
|
|
200
|
+
moved = 0
|
|
201
|
+
nfa.states.each_index do |state|
|
|
202
|
+
next if set.nobits?(1 << state)
|
|
203
|
+
|
|
204
|
+
nfa.states[state].transitions.each do |transition|
|
|
205
|
+
next unless byte.between?(transition.lo, transition.hi)
|
|
206
|
+
|
|
207
|
+
moved |= 1 << transition.to
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
moved
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def accepting_rules(nfa, set)
|
|
214
|
+
rules = []
|
|
215
|
+
nfa.states.each_index do |state|
|
|
216
|
+
next if set.nobits?(1 << state)
|
|
217
|
+
|
|
218
|
+
rules.concat(nfa.states[state].accepts)
|
|
219
|
+
end
|
|
220
|
+
rules.uniq.sort_by { |acceptance| [acceptance.rule_index, acceptance.pattern_index] }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def validate_rules
|
|
224
|
+
raise CompileError, "firstmatch requires option :experimental" if @spec.backend == :firstmatch && !@spec.options[:experimental]
|
|
225
|
+
|
|
226
|
+
@spec.rules.each do |rule|
|
|
227
|
+
raise CompileError, "rule #{rule.index} has no pattern" if rule.patterns.empty?
|
|
228
|
+
next if @spec.options[:allow_empty_match]
|
|
229
|
+
|
|
230
|
+
rule.patterns.each do |pattern|
|
|
231
|
+
regexp = pattern.is_a?(String) ? ::Regexp.new(::Regexp.escape(pattern)) : pattern
|
|
232
|
+
parse_regexp(regexp) if regexp.is_a?(::Regexp)
|
|
233
|
+
next unless regexp.is_a?(::Regexp) && regexp.match?("")
|
|
234
|
+
|
|
235
|
+
diagnostic = Diagnostics.error("FLEXR-E005", "rule #{rule.index} can match an empty string")
|
|
236
|
+
raise CompileError.new(diagnostic.message, diagnostic: diagnostic)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def parse_regexp(regexp)
|
|
242
|
+
encoding = regexp.encoding == Encoding::BINARY ? Encoding::BINARY : @spec.encoding
|
|
243
|
+
Regexp::Parser.new(regexp.source, options: regexp.options, encoding: encoding,
|
|
244
|
+
unicode: @spec.options[:unicode] == true).parse
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def diagnostics_for(compiled, elapsed)
|
|
248
|
+
diagnostics = []
|
|
249
|
+
present = compiled.machines.values.flat_map do |machine|
|
|
250
|
+
machine.dfa.accepts.filter_map { |acceptances| acceptances.min_by(&:rule_index)&.rule_index }
|
|
251
|
+
end
|
|
252
|
+
reference_rules = @spec.rules.select { |rule| reference_rule?(rule) && rule_active_anywhere?(rule) }
|
|
253
|
+
present.concat(reference_rules.map(&:index))
|
|
254
|
+
shadowers = shadowed_rules(compiled)
|
|
255
|
+
diagnostics.concat(@spec.rules.reject { |rule| present.include?(rule.index) }.map do |rule|
|
|
256
|
+
winners = shadowers.fetch(rule.index, []).uniq.sort
|
|
257
|
+
suffix = winners.empty? ? "" : " (shadowed by rule #{winners.join(', ')})"
|
|
258
|
+
Diagnostics.warning("FLEXR-W001", "rule #{rule.index} is unreachable#{suffix}", location: rule.location,
|
|
259
|
+
help: "remove it, reorder the rules, or make its language distinct")
|
|
260
|
+
end)
|
|
261
|
+
|
|
262
|
+
@spec.states.each_key do |state_name|
|
|
263
|
+
next if state_name == :initial
|
|
264
|
+
next unless rules_for(state_name).empty?
|
|
265
|
+
|
|
266
|
+
diagnostics << Diagnostics.warning("FLEXR-W002", "state #{state_name.inspect} has no rules",
|
|
267
|
+
help: "add a rule to the state or remove the unused state")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
firstmatch_conflicts(@spec.rules).each do |left, right|
|
|
271
|
+
diagnostics << Diagnostics.warning(
|
|
272
|
+
"FLEXR-W010", "firstmatch rules #{left.index} and #{right.index} may change longest-match semantics",
|
|
273
|
+
help: "use backend :table unless first-match compatibility is required"
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
max_cells = compiled.stats.values.map { |stat| stat[:states] * stat[:classes] }.max.to_i
|
|
278
|
+
if max_cells > 1_000_000
|
|
279
|
+
diagnostics << Diagnostics.warning(
|
|
280
|
+
"FLEXR-W011",
|
|
281
|
+
"generated transition table is large",
|
|
282
|
+
help: "use backend :direct, table compression, or split the specification"
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
capture_rules.each do |rule|
|
|
287
|
+
diagnostics << Diagnostics.warning(
|
|
288
|
+
"FLEXR-W013",
|
|
289
|
+
"rule #{rule.index} uses a capturing group; flexr treats it as non-capturing",
|
|
290
|
+
help: "rewrite capturing groups as (?:...) and extract text in the action"
|
|
291
|
+
)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
undeclared_tokens.each do |token|
|
|
295
|
+
diagnostics << Diagnostics.warning(
|
|
296
|
+
"FLEXR-W014",
|
|
297
|
+
"token #{token.inspect} is not declared by emits",
|
|
298
|
+
help: "add the token to emits or remove the declaration if it is intentionally private"
|
|
299
|
+
)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
diagnostics.concat(variable_trailing_rules.map do |rule|
|
|
303
|
+
Diagnostics.warning("FLEXR-W003", "rule #{rule.index} uses variable-length trailing context",
|
|
304
|
+
help: "make the body or followed_by expression fixed length when possible")
|
|
305
|
+
end)
|
|
306
|
+
|
|
307
|
+
if @spec.options.fetch(:accel, :auto) != :none
|
|
308
|
+
diagnostics.concat(@spec.rules.select(&:trailing).map do |rule|
|
|
309
|
+
Diagnostics.warning(
|
|
310
|
+
"FLEXR-W012",
|
|
311
|
+
"rule #{rule.index} cannot use region acceleration with trailing context",
|
|
312
|
+
help: "remove trailing context or set accel: :none when the trade-off is intentional"
|
|
313
|
+
)
|
|
314
|
+
end)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
if elapsed > 0.5
|
|
318
|
+
diagnostics << Diagnostics.warning("FLEXR-W016", format("DFA construction took %.3fs", elapsed),
|
|
319
|
+
help: "use generated mode for production startup")
|
|
320
|
+
end
|
|
321
|
+
diagnostics
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def shadowed_rules(compiled)
|
|
325
|
+
shadowers = Hash.new { |hash, key| hash[key] = [] }
|
|
326
|
+
compiled.machines.each_value do |machine|
|
|
327
|
+
machine.dfa.accepts.each do |acceptances|
|
|
328
|
+
winner = acceptances.min_by(&:rule_index)&.rule_index
|
|
329
|
+
next unless winner
|
|
330
|
+
|
|
331
|
+
acceptances.each do |acceptance|
|
|
332
|
+
next if acceptance.rule_index == winner
|
|
333
|
+
|
|
334
|
+
shadowers[acceptance.rule_index] << winner
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
shadowers
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def firstmatch_conflicts(rules)
|
|
342
|
+
return [] unless @spec.backend == :firstmatch
|
|
343
|
+
|
|
344
|
+
rules.combination(2).to_a
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def rule_active_anywhere?(rule)
|
|
348
|
+
@spec.states.keys.any? { |state_name| rules_for(state_name).include?(rule) }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def capture_rules
|
|
352
|
+
@spec.rules.select do |rule|
|
|
353
|
+
rule.patterns.any? do |pattern|
|
|
354
|
+
source = pattern.respond_to?(:source) ? pattern.source : pattern.to_s
|
|
355
|
+
capturing_group?(source)
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def capturing_group?(source)
|
|
361
|
+
escaped = false
|
|
362
|
+
in_class = false
|
|
363
|
+
source.each_char.with_index do |character, index|
|
|
364
|
+
if escaped
|
|
365
|
+
escaped = false
|
|
366
|
+
next
|
|
367
|
+
end
|
|
368
|
+
if character == "\\"
|
|
369
|
+
escaped = true
|
|
370
|
+
elsif character == "["
|
|
371
|
+
in_class = true
|
|
372
|
+
elsif character == "]"
|
|
373
|
+
in_class = false
|
|
374
|
+
elsif character == "(" && !in_class
|
|
375
|
+
return true unless source[index + 1] == "?"
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
false
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def undeclared_tokens
|
|
382
|
+
return [] if Array(@spec.declared_tokens).empty?
|
|
383
|
+
|
|
384
|
+
emitted = @spec.rules.flat_map do |rule|
|
|
385
|
+
action = rule.action
|
|
386
|
+
if action.is_a?(Array) && action.first == :emit
|
|
387
|
+
[action.last]
|
|
388
|
+
elsif action.is_a?(String)
|
|
389
|
+
action.scan(/\bemit\s*\(?\s*:([A-Za-z_]\w*)/).flatten.map(&:to_sym)
|
|
390
|
+
else
|
|
391
|
+
[]
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
declared = Array(@spec.declared_tokens)
|
|
395
|
+
emitted.uniq.reject { |token| declared.include?(token) }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def variable_trailing_rules
|
|
399
|
+
@spec.rules.select do |rule|
|
|
400
|
+
next false unless rule.trailing
|
|
401
|
+
|
|
402
|
+
rule.patterns.any? { |pattern| variable_pattern?(pattern) } && variable_pattern?(rule.trailing)
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def variable_pattern?(pattern)
|
|
407
|
+
source = pattern.respond_to?(:source) ? pattern.source : pattern.to_s
|
|
408
|
+
source = source.gsub(/\\./, "")
|
|
409
|
+
source.match?(/[+*]|\{\d+,\d*\}/)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flexr
|
|
4
|
+
module Automaton
|
|
5
|
+
class DFA
|
|
6
|
+
attr_reader :transitions, :accepts, :ec, :class_count, :start, :states, :rule_ids, :packed, :direct
|
|
7
|
+
|
|
8
|
+
def initialize(transitions:, accepts:, ec:, class_count:, start:, rule_ids:, packed: nil, direct: nil)
|
|
9
|
+
@transitions = transitions.freeze
|
|
10
|
+
@accepts = accepts.map do |rules|
|
|
11
|
+
rules.map do |acceptance|
|
|
12
|
+
next acceptance if acceptance.is_a?(Acceptance)
|
|
13
|
+
|
|
14
|
+
Acceptance.new(rule_index: acceptance[0], pattern_index: acceptance[1],
|
|
15
|
+
bol_only: acceptance[2], end_anchor: acceptance[3])
|
|
16
|
+
end.freeze
|
|
17
|
+
end.freeze
|
|
18
|
+
@ec = ec.freeze
|
|
19
|
+
@class_count = class_count
|
|
20
|
+
@start = start
|
|
21
|
+
@states = transitions.length
|
|
22
|
+
@rule_ids = rule_ids.map { |id| id.respond_to?(:rule_index) ? id.rule_index : id }.uniq.sort.freeze
|
|
23
|
+
@packed = packed
|
|
24
|
+
@direct = direct
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def transition(state, byte)
|
|
28
|
+
class_id = @ec[byte]
|
|
29
|
+
return @transitions[state][class_id] unless @packed
|
|
30
|
+
|
|
31
|
+
cursor = state
|
|
32
|
+
loop do
|
|
33
|
+
index = @packed.fetch(:base).fetch(cursor) + class_id
|
|
34
|
+
return @packed.fetch(:next).fetch(index) if @packed.fetch(:check)[index] == cursor
|
|
35
|
+
|
|
36
|
+
fallback = @packed[:fallback]&.fetch(cursor)
|
|
37
|
+
return @packed.fetch(:default).fetch(cursor) unless fallback
|
|
38
|
+
|
|
39
|
+
cursor = fallback
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Generated direct lexers use a flattened dispatch representation. The
|
|
44
|
+
# interpreter keeps this route separate from packed/table equivalence.
|
|
45
|
+
def transition_direct(state, byte)
|
|
46
|
+
if @direct
|
|
47
|
+
class_id = @ec[byte]
|
|
48
|
+
value = @direct.fetch(:nxt).fetch((state * @direct.fetch(:classes)) + class_id)
|
|
49
|
+
return value >= 0 ? value : nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@transitions[state][@ec[byte]]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def accept?(bytes)
|
|
56
|
+
data = bytes.dup.force_encoding(Encoding::BINARY)
|
|
57
|
+
state = @start
|
|
58
|
+
data.each_byte do |byte|
|
|
59
|
+
state = transition(state, byte)
|
|
60
|
+
return false unless state
|
|
61
|
+
end
|
|
62
|
+
!@accepts[state].empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def stats
|
|
66
|
+
{ states: states, classes: class_count, accepting_states: accepts.count { |rules| !rules.empty? } }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class ReferenceDFA
|
|
71
|
+
def initialize(regexp, unicode: false)
|
|
72
|
+
source, options = if reference_pattern?(regexp, unicode: unicode)
|
|
73
|
+
converted = Unicode::ReferenceRegexp.compiled(
|
|
74
|
+
regexp, encoding: regexp.encoding, options: regexp.options, unicode: unicode
|
|
75
|
+
)
|
|
76
|
+
[converted.source, converted.options]
|
|
77
|
+
else
|
|
78
|
+
[regexp.source, regexp.options]
|
|
79
|
+
end
|
|
80
|
+
@regexp = ::Regexp.new("\\A(?:#{source})\\z", options)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def accept?(bytes)
|
|
84
|
+
data = bytes.dup.force_encoding(@regexp.encoding)
|
|
85
|
+
@regexp.match?(data)
|
|
86
|
+
rescue ArgumentError, EncodingError
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def stats
|
|
91
|
+
{ states: 0, classes: 0, accepting_states: 0, reference: true }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def reference_pattern?(regexp, unicode: false)
|
|
97
|
+
return true if regexp.source.match?(/\\[pP]\{/) || regexp.source.match?(/\[:(?:\^)?[a-z]+:\]/)
|
|
98
|
+
|
|
99
|
+
unicode && regexp.encoding != Encoding::BINARY && regexp.source.match?(/\\[dDwWsS]/)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|