flexr 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +33 -0
  3. data/CONTRIBUTING.md +39 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +116 -0
  6. data/Rakefile +468 -0
  7. data/benchmark/baselines/json.json +34 -0
  8. data/benchmark/baselines/json_handwritten.rb +43 -0
  9. data/benchmark/baselines/json_rexical.rex +25 -0
  10. data/benchmark/corpora/README.md +11 -0
  11. data/benchmark/corpora/generate_json.rb +26 -0
  12. data/benchmark/golden/calculator_lexer.sha256 +1 -0
  13. data/benchmark/golden/json_lexer.sha256 +1 -0
  14. data/benchmark/golden/regexp_tokenizer.sha256 +1 -0
  15. data/benchmark/golden/ruby_subset_lexer.sha256 +1 -0
  16. data/benchmark/golden/toy_lang_lexer.sha256 +1 -0
  17. data/benchmark/golden/with_lrama_lexer.sha256 +1 -0
  18. data/benchmark/golden/with_racc_lexer.sha256 +1 -0
  19. data/benchmark/run.rb +254 -0
  20. data/docs/README.md +64 -0
  21. data/docs/RELEASING.md +30 -0
  22. data/docs/adr/0001-byte-level-dfa.md +5 -0
  23. data/docs/adr/0003-leftmost-longest.md +4 -0
  24. data/docs/adr/0006-accel-not-scanner.md +4 -0
  25. data/docs/adr/0008-what-pure-ruby-means.md +5 -0
  26. data/docs/adr/0016-spec-is-plain-ruby.md +4 -0
  27. data/docs/adr/0017-static-analysis-by-default.md +5 -0
  28. data/docs/adr/0018-prism-for-generator-only.md +4 -0
  29. data/docs/adr/0019-measured-performance-floor.md +26 -0
  30. data/docs/adr/0020-vendored-unicode-contract.md +21 -0
  31. data/docs/explanation/backends.md +33 -0
  32. data/docs/explanation/matching-semantics.md +20 -0
  33. data/docs/explanation/runtime-vs-generated.md +22 -0
  34. data/docs/explanation/security-model.md +18 -0
  35. data/docs/explanation/unicode-and-encoding.md +20 -0
  36. data/docs/how-to/deploy-a-standalone-lexer.md +23 -0
  37. data/docs/how-to/generate-a-lexer.md +39 -0
  38. data/docs/how-to/handle-errors.md +32 -0
  39. data/docs/how-to/integrate-with-lrama.md +21 -0
  40. data/docs/how-to/integrate-with-racc.md +25 -0
  41. data/docs/how-to/migrate-from-flex.md +21 -0
  42. data/docs/how-to/migrate-from-rexical.md +23 -0
  43. data/docs/how-to/run-a-lexer-at-runtime.md +29 -0
  44. data/docs/how-to/track-token-locations.md +27 -0
  45. data/docs/how-to/tune-performance.md +23 -0
  46. data/docs/how-to/use-states.md +36 -0
  47. data/docs/how-to/use-trailing-context.md +22 -0
  48. data/docs/internals/README.md +14 -0
  49. data/docs/perf-log.md +56 -0
  50. data/docs/reference/README.md +23 -0
  51. data/docs/reference/actions.md +47 -0
  52. data/docs/reference/cli.md +80 -0
  53. data/docs/reference/compatibility.md +38 -0
  54. data/docs/reference/diagnostics.md +41 -0
  55. data/docs/reference/dsl.md +81 -0
  56. data/docs/reference/errors.md +27 -0
  57. data/docs/reference/generated-artifacts.md +50 -0
  58. data/docs/reference/public-api.md +42 -0
  59. data/docs/reference/regexp.md +39 -0
  60. data/docs/reference/runtime.md +49 -0
  61. data/docs/reference/tokens-and-locations.md +33 -0
  62. data/docs/tutorial/build-a-calculator-lexer.md +96 -0
  63. data/examples/calculator/README.md +27 -0
  64. data/examples/calculator/lexer.flexr.rb +17 -0
  65. data/examples/json/README.md +30 -0
  66. data/examples/json/lexer.flexr.rb +24 -0
  67. data/examples/ruby_subset/README.md +17 -0
  68. data/examples/ruby_subset/lexer.flexr.rb +22 -0
  69. data/examples/toy_lang/README.md +17 -0
  70. data/examples/toy_lang/lexer.flexr.rb +18 -0
  71. data/examples/with_lrama/README.md +17 -0
  72. data/examples/with_lrama/lexer.flexr.rb +13 -0
  73. data/examples/with_racc/README.md +17 -0
  74. data/examples/with_racc/lexer.flexr.rb +13 -0
  75. data/exe/flexr +7 -0
  76. data/lib/flexr/automaton/accel.rb +39 -0
  77. data/lib/flexr/automaton/analysis.rb +38 -0
  78. data/lib/flexr/automaton/byte_class_set.rb +29 -0
  79. data/lib/flexr/automaton/compiler.rb +413 -0
  80. data/lib/flexr/automaton/dfa.rb +103 -0
  81. data/lib/flexr/automaton/minimizer.rb +70 -0
  82. data/lib/flexr/automaton/nfa.rb +92 -0
  83. data/lib/flexr/cli.rb +342 -0
  84. data/lib/flexr/codegen/base.rb +17 -0
  85. data/lib/flexr/codegen/direct.rb +52 -0
  86. data/lib/flexr/codegen/firstmatch.rb +17 -0
  87. data/lib/flexr/codegen/table.rb +158 -0
  88. data/lib/flexr/codegen/table_packer.rb +61 -0
  89. data/lib/flexr/diagnostics.rb +94 -0
  90. data/lib/flexr/dsl.rb +182 -0
  91. data/lib/flexr/errors.rb +28 -0
  92. data/lib/flexr/generated.rb +125 -0
  93. data/lib/flexr/generator.rb +400 -0
  94. data/lib/flexr/importer.rb +560 -0
  95. data/lib/flexr/ir.rb +36 -0
  96. data/lib/flexr/lexer.rb +10 -0
  97. data/lib/flexr/options.rb +47 -0
  98. data/lib/flexr/rake_task.rb +27 -0
  99. data/lib/flexr/regexp/ast.rb +45 -0
  100. data/lib/flexr/regexp/char_class.rb +7 -0
  101. data/lib/flexr/regexp/normalizer.rb +117 -0
  102. data/lib/flexr/regexp/parser.rb +517 -0
  103. data/lib/flexr/regexp/tokenizer.flexr.rb +27 -0
  104. data/lib/flexr/regexp/tokenizer.rb +168 -0
  105. data/lib/flexr/regexp/unsupported.rb +7 -0
  106. data/lib/flexr/runtime/buffer.rb +112 -0
  107. data/lib/flexr/runtime/core.rb +388 -0
  108. data/lib/flexr/runtime/errors.rb +22 -0
  109. data/lib/flexr/runtime/interpreter.rb +505 -0
  110. data/lib/flexr/runtime/location.rb +26 -0
  111. data/lib/flexr/runtime/token.rb +7 -0
  112. data/lib/flexr/source/passthrough.rb +31 -0
  113. data/lib/flexr/source/prism_reader.rb +283 -0
  114. data/lib/flexr/source/static_eval.rb +145 -0
  115. data/lib/flexr/unicode/case_fold.rb +45 -0
  116. data/lib/flexr/unicode/data/LICENSE-UNICODE.txt +5 -0
  117. data/lib/flexr/unicode/data/UNICODE_VERSION +1 -0
  118. data/lib/flexr/unicode/data/case_folding.rb +9 -0
  119. data/lib/flexr/unicode/data/properties.rb +10 -0
  120. data/lib/flexr/unicode/property.rb +107 -0
  121. data/lib/flexr/unicode/reference_regexp.rb +102 -0
  122. data/lib/flexr/unicode/utf8_splitter.rb +109 -0
  123. data/lib/flexr/version.rb +5 -0
  124. data/lib/flexr.rb +81 -0
  125. data/site/README.md +22 -0
  126. data/site/astro.config.mjs +57 -0
  127. data/site/package.json +19 -0
  128. data/site/pnpm-lock.yaml +5029 -0
  129. data/site/pnpm-workspace.yaml +6 -0
  130. data/site/public/playground.js +189 -0
  131. data/site/scripts/verify-site.mjs +42 -0
  132. data/site/src/content/docs/benchmarks.md +8 -0
  133. data/site/src/content/docs/concepts/matching-semantics.md +15 -0
  134. data/site/src/content/docs/concepts/regexp-model.md +18 -0
  135. data/site/src/content/docs/concepts/runtime-vs-generated.md +15 -0
  136. data/site/src/content/docs/concepts/security-model.md +15 -0
  137. data/site/src/content/docs/examples.md +17 -0
  138. data/site/src/content/docs/learn/generation.md +29 -0
  139. data/site/src/content/docs/learn/getting-started.md +56 -0
  140. data/site/src/content/docs/learn/parser-integration.md +27 -0
  141. data/site/src/content/docs/learn/runtime-mode.md +32 -0
  142. data/site/src/content/docs/reference/action-context.md +20 -0
  143. data/site/src/content/docs/reference/cli.md +22 -0
  144. data/site/src/content/docs/reference/diagnostics.md +16 -0
  145. data/site/src/content/docs/reference/dsl.md +19 -0
  146. data/site/src/content/docs/reference/public-api.md +18 -0
  147. data/site/src/content/docs/reference/regexp.md +16 -0
  148. data/site/src/content/docs/reference/runtime.md +16 -0
  149. data/site/src/content/docs/reference/tokens-and-locations.md +16 -0
  150. data/site/src/content.config.ts +12 -0
  151. data/site/src/env.d.ts +1 -0
  152. data/site/src/layouts/SiteLayout.astro +39 -0
  153. data/site/src/pages/index.astro +174 -0
  154. data/site/src/pages/playground.astro +64 -0
  155. data/site/src/styles/custom.css +711 -0
  156. data/site/tsconfig.json +5 -0
  157. data/tools/coverage.rb +32 -0
  158. data/tools/docs_verify.rb +116 -0
  159. data/tools/gen_unicode_tables.rb +202 -0
  160. data/tools/regexp_tokenizer_reference.rb +60 -0
  161. metadata +205 -0
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ module Regexp
5
+ module AST
6
+ Empty = Struct.new(:loc, keyword_init: true)
7
+ ByteRange = Struct.new(:lo, :hi, :loc, keyword_init: true)
8
+ CodepointRange = Struct.new(:lo, :hi, :loc, keyword_init: true)
9
+ Seq = Struct.new(:children, :loc, keyword_init: true)
10
+ Alt = Struct.new(:children, :loc, keyword_init: true)
11
+ Star = Struct.new(:child, :loc, keyword_init: true)
12
+ Anchor = Struct.new(:kind, :loc, keyword_init: true)
13
+ TrailMark = Struct.new(:rule_id, :loc, keyword_init: true)
14
+ CharClass = Struct.new(:ranges, :negated, :loc, keyword_init: true)
15
+ Property = Module.new
16
+
17
+ module Formatting
18
+ def to_s
19
+ name = self.class.name.split("::").last
20
+ case name
21
+ when "Empty" then "Empty"
22
+ when "ByteRange" then "ByteRange(#{lo}..#{hi})"
23
+ when "CodepointRange" then "CodepointRange(#{lo}..#{hi})"
24
+ when "Anchor" then "Anchor(#{kind})"
25
+ when "Star" then "Star(#{child})"
26
+ when "Seq", "Alt" then "#{name}(#{children.join(', ')})"
27
+ else super
28
+ end
29
+ end
30
+ end
31
+
32
+ [Empty, ByteRange, CodepointRange, Seq, Alt, Star, Anchor, TrailMark, CharClass].each do |node_class|
33
+ node_class.include(Formatting)
34
+ end
35
+
36
+ class Node
37
+ attr_reader :loc
38
+
39
+ def initialize(loc = nil)
40
+ @loc = loc
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ module Regexp
5
+ CharClass = AST::CharClass
6
+ end
7
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ module Regexp
5
+ class Normalizer
6
+ def initialize(ast, encoding: Encoding::UTF_8, options: 0)
7
+ @ast = ast
8
+ @encoding = encoding
9
+ @options = options
10
+ @byte_mode = [Encoding::BINARY, Encoding::US_ASCII].include?(encoding)
11
+ end
12
+
13
+ def normalize
14
+ normalize_node(@ast)
15
+ end
16
+
17
+ private
18
+
19
+ def normalize_node(node)
20
+ case node
21
+ when AST::Empty, AST::ByteRange, AST::CodepointRange, AST::Anchor
22
+ normalize_leaf(node)
23
+ when AST::CharClass
24
+ char_class(node)
25
+ when AST::Seq
26
+ sequence(node.children.map { |child| normalize_node(child) })
27
+ when AST::Alt
28
+ alternatives(node.children.map { |child| normalize_node(child) })
29
+ when AST::Star
30
+ AST::Star.new(child: normalize_node(node.child), loc: node.loc)
31
+ else
32
+ raise CompileError, "unknown regexp AST node: #{node.class}"
33
+ end
34
+ end
35
+
36
+ def normalize_leaf(node)
37
+ case node
38
+ when AST::CodepointRange
39
+ byte_sequences_for_ranges(casefold_ranges(node.lo, node.hi))
40
+ else
41
+ node
42
+ end
43
+ end
44
+
45
+ def char_class(node)
46
+ ranges = node.ranges.flat_map do |range|
47
+ if range.first == AST::Property
48
+ Unicode::Property.ranges(range.last, negate: range[1])
49
+ else
50
+ casefold_ranges(*range)
51
+ end
52
+ end
53
+ ranges = complement(ranges) if node.negated
54
+ byte_sequences_for_ranges(ranges)
55
+ end
56
+
57
+ def byte_sequences_for_ranges(ranges)
58
+ if @byte_mode
59
+ byte_ranges = ranges.filter_map do |lo, hi|
60
+ next if lo > 255
61
+
62
+ [lo, [hi, 255].min]
63
+ end
64
+ return AST::Empty.new(loc: nil) if byte_ranges.empty?
65
+
66
+ return alternatives(byte_ranges.map { |lo, hi| AST::ByteRange.new(lo: lo, hi: hi, loc: nil) })
67
+ end
68
+
69
+ sequences = ranges.flat_map { |lo, hi| Unicode::Utf8Splitter.split(lo, hi) }
70
+ trie(sequences, 0)
71
+ end
72
+
73
+ def trie(sequences, index)
74
+ return AST::Empty.new(loc: nil) if sequences.empty?
75
+ return AST::Empty.new(loc: nil) if sequences.first.length == index
76
+
77
+ groups = sequences.group_by { |sequence| sequence[index] }
78
+ alternatives(groups.map do |(lo, hi), group|
79
+ child = trie(group, index + 1)
80
+ AST::Seq.new(children: [AST::ByteRange.new(lo: lo, hi: hi, loc: nil), child], loc: nil)
81
+ end)
82
+ end
83
+
84
+ def sequence(children)
85
+ flattened = children.flat_map { |child| child.is_a?(AST::Seq) ? child.children : [child] }
86
+ return AST::Empty.new(loc: nil) if flattened.empty?
87
+ return flattened.first if flattened.length == 1
88
+
89
+ AST::Seq.new(children: flattened, loc: nil)
90
+ end
91
+
92
+ def alternatives(children)
93
+ flattened = children.flat_map { |child| child.is_a?(AST::Alt) ? child.children : [child] }
94
+ return AST::Empty.new(loc: nil) if flattened.empty?
95
+ return flattened.first if flattened.length == 1
96
+
97
+ AST::Alt.new(children: flattened, loc: nil)
98
+ end
99
+
100
+ def complement(ranges)
101
+ result = []
102
+ cursor = 0
103
+ ranges.sort_by(&:first).each do |lo, hi|
104
+ result << [cursor, lo - 1] if cursor < lo
105
+ cursor = [cursor, hi + 1].max
106
+ end
107
+ result << [cursor, 0x10ffff] if cursor <= 0x10ffff
108
+ result
109
+ end
110
+
111
+ def casefold_ranges(lo, hi)
112
+ return [[lo, hi]] if @options.nobits?(::Regexp::IGNORECASE)
113
+ Unicode::CaseFold.ranges(lo, hi)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,517 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flexr
4
+ module Regexp
5
+ class Parser
6
+ ESCAPES = {
7
+ "n" => 0x0a, "t" => 0x09, "r" => 0x0d, "f" => 0x0c,
8
+ "v" => 0x0b, "a" => 0x07, "e" => 0x1b, "0" => 0
9
+ }.freeze
10
+ PROPERTY_ALIASES = {
11
+ "digit" => "Nd", "alpha" => "Alphabetic", "alnum" => "Alnum",
12
+ "word" => "Word", "space" => "Space"
13
+ }.freeze
14
+ POSIX_CLASSES = %w[alnum alpha blank cntrl digit graph lower print punct space upper xdigit].freeze
15
+
16
+ attr_reader :source
17
+
18
+ def initialize(source, options: 0, encoding: Encoding::UTF_8, unicode: false)
19
+ @source = source
20
+ @options = options
21
+ @encoding = encoding
22
+ @unicode = unicode
23
+ @index = 0
24
+ @class_depth = 0
25
+ end
26
+
27
+ def parse
28
+ node = parse_expression
29
+ raise_syntax("unexpected `#{current}`") unless eof?
30
+ validate_anchor_positions(node)
31
+ node
32
+ end
33
+
34
+ private
35
+
36
+ def parse_expression
37
+ branches = [parse_sequence]
38
+ branches << parse_sequence while consume?("|")
39
+ return branches.first if branches.length == 1
40
+
41
+ AST::Alt.new(children: branches, loc: nil)
42
+ end
43
+
44
+ def parse_sequence
45
+ children = []
46
+ children << parse_quantified until eof? || [")", "|"].include?(current)
47
+ return AST::Empty.new(loc: nil) if children.empty?
48
+ return children.first if children.length == 1
49
+
50
+ AST::Seq.new(children: children, loc: nil)
51
+ end
52
+
53
+ def parse_quantified
54
+ atom = parse_atom
55
+ return atom unless ["*", "+", "?", "{"].include?(current)
56
+
57
+ if consume?("*")
58
+ reject_postfix_quantifier
59
+ return AST::Star.new(child: atom, loc: nil)
60
+ end
61
+ if consume?("+")
62
+ reject_postfix_quantifier
63
+ return AST::Seq.new(children: [atom, AST::Star.new(child: atom, loc: nil)], loc: nil)
64
+ end
65
+ if consume?("?")
66
+ reject_postfix_quantifier
67
+ return AST::Alt.new(children: [atom, AST::Empty.new(loc: nil)], loc: nil)
68
+ end
69
+
70
+ parse_repetition(atom)
71
+ end
72
+
73
+ def parse_repetition(atom)
74
+ consume?("{")
75
+ min = read_number
76
+ max = if consume?(",")
77
+ read_number unless current == "}"
78
+ else
79
+ min
80
+ end
81
+ expect("}")
82
+ raise_syntax("invalid repetition") if min.nil? || (!max.nil? && max < min)
83
+ raise_syntax("open repetition is not supported") if max.nil?
84
+ if max > 1000
85
+ raise_diagnostic(
86
+ diagnostic("FLEXR-E007", "repetition limit exceeds 1000",
87
+ help: "split the rule or use a smaller bounded repetition")
88
+ )
89
+ end
90
+ reject_postfix_quantifier
91
+
92
+ required = Array.new(min) { atom }
93
+ optional = Array.new(max - min) do
94
+ AST::Alt.new(children: [atom, AST::Empty.new(loc: nil)], loc: nil)
95
+ end
96
+ children = required + optional
97
+ return AST::Empty.new(loc: nil) if children.empty?
98
+ return children.first if children.length == 1
99
+
100
+ AST::Seq.new(children: children, loc: nil)
101
+ end
102
+
103
+ def parse_atom
104
+ @escaped_value = false
105
+ @last_ranges = nil
106
+ return parse_group if consume?("(")
107
+ return parse_class if consume?("[")
108
+ return parse_anchor if ["^", "$"].include?(current)
109
+
110
+ if consume?(".")
111
+ upper = @options.nobits?(::Regexp::MULTILINE) ? 0x0a - 1 : 0x10ffff
112
+ return AST::CharClass.new(ranges: [[0, upper], [0x0b, 0x10ffff]], negated: false, loc: nil)
113
+ end
114
+
115
+ if consume?("\\")
116
+ parse_escape
117
+ return AST::CharClass.new(ranges: @last_ranges, negated: false, loc: nil) if @last_ranges
118
+ return codepoint_node(read_codepoint) if @escaped_value
119
+ end
120
+
121
+ char = advance
122
+ raise_syntax("unexpected end of expression") unless char
123
+ codepoint_node(char.ord)
124
+ end
125
+
126
+ def parse_group
127
+ saved_options = @options
128
+ if consume?("?")
129
+ prefix = parse_group_prefix
130
+ return AST::Empty.new(loc: nil) if prefix == :global
131
+ else
132
+ warn_capture
133
+ end
134
+ node = parse_expression
135
+ expect(")")
136
+ @options = saved_options if prefix
137
+ node
138
+ end
139
+
140
+ def parse_group_prefix
141
+ return true if consume?(":")
142
+ if peek_prefix?("-mix:")
143
+ @index += 5
144
+ @options &= ~(::Regexp::IGNORECASE | ::Regexp::MULTILINE | ::Regexp::EXTENDED)
145
+ return true
146
+ end
147
+ if ["i", "m", "x", "-"].include?(current)
148
+ add = true
149
+ flags = []
150
+ while ["i", "m", "x", "-"].include?(current)
151
+ if consume?("-")
152
+ add = false
153
+ else
154
+ flags << [advance, add]
155
+ end
156
+ end
157
+ if consume?(":")
158
+ flags.each { |flag, enabled| update_option(flag, enabled) }
159
+ return true
160
+ end
161
+ if consume?(")")
162
+ flags.each { |flag, enabled| update_option(flag, enabled) }
163
+ return :global
164
+ end
165
+ raise_syntax("invalid inline option group")
166
+ end
167
+ raise unsupported("look-around", "use followed_by: or a state instead") if peek_prefix?("=") || peek_prefix?("!") || peek_prefix?("<=") || peek_prefix?("<!")
168
+ raise unsupported("atomic groups", "rewrite the expression as a DFA-compatible expression") if consume?(">")
169
+ raise unsupported("unsupported group syntax", "use a non-capturing group (?:...)")
170
+ end
171
+
172
+ def update_option(flag, enabled)
173
+ bit = { "i" => ::Regexp::IGNORECASE, "m" => ::Regexp::MULTILINE, "x" => ::Regexp::EXTENDED }.fetch(flag)
174
+ @options = enabled ? (@options | bit) : (@options & ~bit)
175
+ end
176
+
177
+ def codepoint_node(codepoint)
178
+ return AST::CodepointRange.new(lo: codepoint, hi: codepoint, loc: nil) if @options.nobits?(::Regexp::IGNORECASE)
179
+
180
+ AST::CharClass.new(ranges: fold_ranges([[codepoint, codepoint]]), negated: false, loc: nil)
181
+ end
182
+
183
+ def fold_ranges(ranges)
184
+ folded = ranges.flat_map do |range|
185
+ next [range] if range.first.is_a?(Module)
186
+
187
+ Unicode::CaseFold.ranges(range.first, range.last)
188
+ end
189
+ merge_ranges(folded)
190
+ end
191
+
192
+ def parse_class
193
+ negated = consume?("^")
194
+ ranges = []
195
+ @class_depth += 1
196
+ until eof? || current == "]"
197
+ if current == "[" && @source[@index, 2] == "[:"
198
+ ranges.concat(parse_posix_class)
199
+ next
200
+ end
201
+ first = parse_class_atom
202
+ if consume?("-") && current != "]"
203
+ last = parse_class_atom
204
+ ranges.concat(expand_class_range(first, last))
205
+ else
206
+ ranges.concat(first)
207
+ end
208
+ end
209
+ expect("]")
210
+ @class_depth -= 1
211
+ ranges = merge_ranges(ranges)
212
+ ranges = fold_ranges(ranges) if @options.anybits?(::Regexp::IGNORECASE)
213
+ AST::CharClass.new(ranges: ranges, negated: negated, loc: nil)
214
+ ensure
215
+ @class_depth -= 1 if @class_depth.positive? && current != "]"
216
+ end
217
+
218
+ def parse_posix_class
219
+ expect("[:")
220
+ raw_name = read_until(":]")
221
+ inner_negated = raw_name.start_with?("^")
222
+ name = raw_name.delete_prefix("^")
223
+ return [[AST::Property, inner_negated, "POSIX_#{name}"]] if
224
+ @encoding != Encoding::BINARY && POSIX_CLASSES.include?(name)
225
+
226
+ ranges = case name
227
+ when "alnum" then [[48, 57], [65, 90], [97, 122]]
228
+ when "alpha" then [[65, 90], [97, 122]]
229
+ when "blank" then [[9, 9], [32, 32]]
230
+ when "cntrl" then [[0, 31], [127, 127]]
231
+ when "digit" then [[48, 57]]
232
+ when "graph" then [[33, 126]]
233
+ when "lower" then [[97, 122]]
234
+ when "print" then [[32, 126]]
235
+ when "punct" then [[33, 47], [58, 64], [91, 96], [123, 126]]
236
+ when "space" then [[9, 13], [32, 32]]
237
+ when "upper" then [[65, 90]]
238
+ when "xdigit" then [[48, 57], [65, 70], [97, 102]]
239
+ else
240
+ raise_syntax("unknown POSIX character class: #{name}")
241
+ end
242
+ inner_negated ? complement_ranges(ranges) : ranges
243
+ end
244
+
245
+ def parse_class_atom
246
+ if consume?("\\")
247
+ parse_escape
248
+ return [[@last_codepoint, @last_codepoint]] if @escaped_value
249
+ return @last_ranges
250
+ end
251
+ char = advance
252
+ raise_syntax("unterminated character class") unless char
253
+ [[char.ord, char.ord]]
254
+ end
255
+
256
+ def expand_class_range(first, last)
257
+ raise_syntax("character class range endpoints must be single characters") if first.length != 1 || last.length != 1
258
+ lo = first.first.first
259
+ hi = last.first.first
260
+ raise_syntax("invalid character class range") if lo > hi
261
+ [[lo, hi]]
262
+ end
263
+
264
+ def parse_escape
265
+ @escaped_value = false
266
+ @last_ranges = nil
267
+ char = advance_raw
268
+ raise_syntax("trailing backslash") unless char
269
+ if ESCAPES.key?(char)
270
+ @escaped_value = true
271
+ @last_codepoint = ESCAPES.fetch(char)
272
+ return
273
+ end
274
+ case char
275
+ when "d", "D", "w", "W", "s", "S", "h", "H"
276
+ ranges = shorthand_ranges(char)
277
+ @last_ranges = ranges
278
+ nil
279
+ when "p", "P"
280
+ expect("{")
281
+ name = read_until("}")
282
+ ranges = [[AST::Property, char == "P", name]]
283
+ @last_ranges = ranges
284
+ nil
285
+ when "x"
286
+ digits = if consume?("{")
287
+ read_until("}")
288
+ else
289
+ read_exact(2)
290
+ end
291
+ assign_codepoint(digits)
292
+ nil
293
+ when "u"
294
+ digits = if consume?("{")
295
+ value = read_until("}")
296
+ value
297
+ else
298
+ read_exact(4)
299
+ end
300
+ assign_codepoint(digits)
301
+ nil
302
+ when "G", "K", "b", "B", "A", "z", "Z", "1", "2", "3", "4", "5", "6", "7", "8", "9"
303
+ raise unsupported("\\#{char}", "use a state or followed_by: instead")
304
+ when "k"
305
+ read_until(">") if consume?("<")
306
+ raise unsupported("backreferences", "split the rule into DFA-compatible states")
307
+ else
308
+ @escaped_value = true
309
+ @last_codepoint = char.ord
310
+ end
311
+ end
312
+
313
+ def shorthand_ranges(char)
314
+ if @unicode && @encoding != Encoding::BINARY
315
+ property = { "d" => "Nd", "w" => "Word", "s" => "Space" }.fetch(char.downcase, nil)
316
+ return [[AST::Property, char == char.upcase, property]] if property
317
+ end
318
+
319
+ base = {
320
+ "d" => [[48, 57]],
321
+ "w" => [[48, 57], [65, 90], [95, 95], [97, 122]],
322
+ "s" => [[9, 13], [32, 32]],
323
+ "h" => [[9, 9], [32, 32]]
324
+ }.fetch(char.downcase)
325
+ return base unless char == char.upcase
326
+
327
+ complement_ranges(base)
328
+ end
329
+
330
+ def complement_ranges(ranges)
331
+ out = []
332
+ cursor = 0
333
+ ranges.sort.each do |lo, hi|
334
+ out << [cursor, lo - 1] if cursor < lo
335
+ cursor = hi + 1
336
+ end
337
+ out << [cursor, 0x10ffff] if cursor <= 0x10ffff
338
+ out
339
+ end
340
+
341
+ def parse_anchor
342
+ char = advance
343
+ return AST::Anchor.new(kind: :bol, loc: nil) if char == "^"
344
+
345
+ AST::Anchor.new(kind: :eol, loc: nil)
346
+ end
347
+
348
+ def validate_anchor_positions(node)
349
+ anchors = anchor_nodes(node)
350
+ return if anchors.empty?
351
+
352
+ sequence = flatten_sequence(node)
353
+ allowed = [sequence.first, sequence.last].compact
354
+ boundaries_valid = if sequence.length == 1 && sequence.first.is_a?(AST::Anchor)
355
+ %i[bol eol].include?(sequence.first.kind)
356
+ else
357
+ (!sequence.first.is_a?(AST::Anchor) || sequence.first.kind == :bol) &&
358
+ (!sequence.last.is_a?(AST::Anchor) || sequence.last.kind == :eol)
359
+ end
360
+ valid = !anchor_nested_in_alternative?(node) && anchors.all? { |anchor| allowed.include?(anchor) } && boundaries_valid
361
+ return if valid
362
+
363
+ raise_diagnostic(
364
+ diagnostic("FLEXR-E009", "anchors are only valid at the outermost pattern boundaries",
365
+ help: "split alternatives into separate rules or move ^/$ outside the alternation")
366
+ )
367
+ end
368
+
369
+ def flatten_sequence(node)
370
+ return [] if node.is_a?(AST::Empty)
371
+ return node.children.flat_map { |child| flatten_sequence(child) } if node.is_a?(AST::Seq)
372
+
373
+ [node]
374
+ end
375
+
376
+ def anchor_nodes(node)
377
+ return [node] if node.is_a?(AST::Anchor)
378
+ return anchor_nodes(node.child) if node.is_a?(AST::Star)
379
+ return [] unless node.respond_to?(:children)
380
+
381
+ node.children.flat_map { |child| anchor_nodes(child) }
382
+ end
383
+
384
+ def anchor_nested_in_alternative?(node)
385
+ return node.children.any? { |child| anchor_nodes(child).any? } if node.is_a?(AST::Alt)
386
+ return anchor_nested_in_alternative?(node.child) if node.is_a?(AST::Star)
387
+ return false unless node.respond_to?(:children)
388
+
389
+ node.children.any? { |child| anchor_nested_in_alternative?(child) }
390
+ end
391
+
392
+ def warn_capture
393
+ # The parser intentionally treats captures as non-capturing. Diagnostics
394
+ # are exposed by the source compiler where a source location is known.
395
+ end
396
+
397
+ def reject_postfix_quantifier
398
+ return unless ["?", "+"].include?(current)
399
+
400
+ raise unsupported("lazy or possessive quantifier", "use a negated character class")
401
+ end
402
+
403
+ def merge_ranges(ranges)
404
+ properties, concrete = ranges.partition { |range| range.first.is_a?(Module) }
405
+ merged = concrete.sort_by(&:first).each_with_object([]) do |range, result|
406
+ if result.empty? || range.first > result.last.last + 1
407
+ result << range.dup
408
+ else
409
+ result.last[1] = [result.last.last, range.last].max
410
+ end
411
+ end
412
+ merged + properties
413
+ end
414
+
415
+ def current
416
+ skip_extended_space if @options.anybits?(::Regexp::EXTENDED) && @class_depth.zero?
417
+ @source[@index]
418
+ end
419
+
420
+ def advance
421
+ skip_extended_space if @options.anybits?(::Regexp::EXTENDED) && @class_depth.zero?
422
+ advance_raw
423
+ end
424
+
425
+ def advance_raw
426
+ char = @source[@index]
427
+ @index += 1 if char
428
+ char
429
+ end
430
+
431
+ def consume?(value)
432
+ return false unless @source[@index, value.length] == value
433
+
434
+ @index += value.length
435
+ true
436
+ end
437
+
438
+ def expect(value)
439
+ return if consume?(value)
440
+
441
+ raise_syntax("expected `#{value}`")
442
+ end
443
+
444
+ def peek_prefix?(value)
445
+ @source[@index, value.length] == value
446
+ end
447
+
448
+ def read_number
449
+ start = @index
450
+ advance while current&.match?(/[0-9]/)
451
+ return nil if start == @index
452
+
453
+ @source[start...@index].to_i
454
+ end
455
+
456
+ def read_exact(count)
457
+ value = @source[@index, count]
458
+ raise_syntax("invalid escape") unless value&.length == count && value.match?(/\A[0-9a-fA-F]+\z/)
459
+ @index += count
460
+ value
461
+ end
462
+
463
+ def read_until(terminator)
464
+ start = @index
465
+ finish = @source.index(terminator, @index)
466
+ raise_syntax("unterminated escape") unless finish
467
+ @index = finish + terminator.length
468
+ @source[start...finish]
469
+ end
470
+
471
+ def eof?
472
+ @index >= @source.length
473
+ end
474
+
475
+ def diagnostic(code, message, help: nil)
476
+ Diagnostics.error(code, message, help: help)
477
+ end
478
+
479
+ def raise_diagnostic(diagnostic)
480
+ raise CompileError.new(diagnostic.message, diagnostic: diagnostic)
481
+ end
482
+
483
+ def raise_syntax(message)
484
+ raise CompileError.new(message, diagnostic: diagnostic("FLEXR-E001", message))
485
+ end
486
+
487
+ def unsupported(feature, help)
488
+ diagnostic = Diagnostics.error("FLEXR-E014", "#{feature} is not supported by flexr", help: help)
489
+ raise UnsupportedRegexpError.new(diagnostic.message, diagnostic: diagnostic)
490
+ end
491
+
492
+ def read_codepoint
493
+ @last_codepoint
494
+ end
495
+
496
+ def assign_codepoint(digits)
497
+ raise_syntax("invalid escape") unless digits.match?(/\A[0-9a-fA-F]+\z/)
498
+
499
+ codepoint = digits.to_i(16)
500
+ raise_syntax("invalid Unicode codepoint") if codepoint > 0x10ffff || codepoint.between?(0xd800, 0xdfff)
501
+
502
+ @escaped_value = true
503
+ @last_codepoint = codepoint
504
+ end
505
+
506
+ def skip_extended_space
507
+ loop do
508
+ @index += 1 while @source[@index]&.match?(/\s/)
509
+ break unless @source[@index] == "#"
510
+
511
+ @index += 1
512
+ @index += 1 while @source[@index] && @source[@index] != "\n"
513
+ end
514
+ end
515
+ end
516
+ end
517
+ end