flagnetic 0.1.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.
@@ -0,0 +1,647 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "unicode_tables"
4
+
5
+ module Flagnetic
6
+ # Eval is the evaluator; see flagnetic/eval.rb.
7
+ module Eval
8
+ # Regex compiles the pattern of a matches condition. Patterns are RE2
9
+ # syntax, as the reference evaluator (Go's regexp) reads them. Ruby's
10
+ # engine reads a different dialect: ^ and $ are line anchors, \s includes
11
+ # \v, (?m) means what RE2 calls (?s), (?i) folds ß to ss, lookarounds and
12
+ # backreferences exist. So a pattern is never handed to Regexp as is: it
13
+ # is parsed as RE2 and translated into an Onigmo expression that matches
14
+ # exactly the same strings.
15
+ #
16
+ # The policy:
17
+ #
18
+ # - Every RE2 construct is supported with RE2 semantics: literals and
19
+ # escapes, classes with ranges, POSIX and Perl classes (ASCII, as in
20
+ # RE2), \p and \P with Go's category and script names, groups (plain,
21
+ # non-capturing, named), flags i, m, s and U, alternation, greedy and
22
+ # lazy repetition up to 1000, \A, \z, \b, \B and \Q...\E. Case folding
23
+ # uses Go's simple folding orbits, not Ruby's.
24
+ # - What RE2 rejects is rejected: lookarounds, backreferences, atomic
25
+ # groups, possessive or nested repetition, \Z, \G, \h, \K, \R, \C,
26
+ # comments, unknown escapes and flags. The condition is then
27
+ # inconsistent_ruleset, as in every other evaluator.
28
+ # - \p and \P under (?i) match the property's code points and every
29
+ # code point sharing a folding orbit with one, as Go does; whether a
30
+ # code point has the property is Ruby's Unicode data, the orbits are
31
+ # Go's.
32
+ # - A match searches anywhere in the string unless the pattern is
33
+ # anchored. Every byte of invalid UTF-8 in the matched string reads as
34
+ # one U+FFFD, as Go decodes it. On
35
+ # Ruby 3.2 and later a match is cut off after MATCH_TIMEOUT seconds and
36
+ # the condition is false; RE2 is linear time and never needs to.
37
+ module Regex
38
+ # Error is a pattern outside RE2 syntax or outside RE2.
39
+ class Error < StandardError; end
40
+
41
+ MATCH_TIMEOUT = 0.1
42
+
43
+ REPLACEMENT = [0xFFFD].pack("U")
44
+ private_constant :REPLACEMENT
45
+
46
+ TIMEOUT_ERROR = defined?(Regexp::TimeoutError) ? Regexp::TimeoutError : Class.new(StandardError)
47
+ private_constant :TIMEOUT_ERROR
48
+
49
+ # Pattern is a compiled matches pattern.
50
+ class Pattern
51
+ def initialize(regexp)
52
+ @regexp = regexp
53
+ freeze
54
+ end
55
+
56
+ # match? reports whether s contains a match.
57
+ def match?(str)
58
+ @regexp.match?(str.valid_encoding? ? str : str.scrub { |bad| REPLACEMENT * bad.bytesize })
59
+ rescue TIMEOUT_ERROR
60
+ false
61
+ end
62
+ end
63
+
64
+ # compile translates an RE2 pattern into a Pattern, raising Error when
65
+ # it is not one this evaluator can match with RE2 semantics.
66
+ def self.compile(pattern)
67
+ source = Translator.new(pattern).translate
68
+ options = Regexp.respond_to?(:timeout) ? { timeout: MATCH_TIMEOUT } : {}
69
+ Pattern.new(Regexp.new(source, **options))
70
+ rescue RegexpError => e
71
+ raise Error, e.message
72
+ end
73
+
74
+ MAX_RUNE = 0x10FFFF
75
+ SURROGATES = (0xD800..0xDFFF)
76
+ WORD = [[0x30, 0x39], [0x41, 0x5A], [0x5F, 0x5F], [0x61, 0x7A]].freeze
77
+ PERL_CLASSES = {
78
+ "d" => [[0x30, 0x39]],
79
+ "s" => [[0x09, 0x0A], [0x0C, 0x0D], [0x20, 0x20]],
80
+ "w" => WORD
81
+ }.freeze
82
+ POSIX_CLASSES = {
83
+ "alnum" => [[0x30, 0x39], [0x41, 0x5A], [0x61, 0x7A]],
84
+ "alpha" => [[0x41, 0x5A], [0x61, 0x7A]],
85
+ "ascii" => [[0x00, 0x7F]],
86
+ "blank" => [[0x09, 0x09], [0x20, 0x20]],
87
+ "cntrl" => [[0x00, 0x1F], [0x7F, 0x7F]],
88
+ "digit" => [[0x30, 0x39]],
89
+ "graph" => [[0x21, 0x7E]],
90
+ "lower" => [[0x61, 0x7A]],
91
+ "print" => [[0x20, 0x7E]],
92
+ "punct" => [[0x21, 0x2F], [0x3A, 0x40], [0x5B, 0x60], [0x7B, 0x7E]],
93
+ "space" => [[0x09, 0x0D], [0x20, 0x20]],
94
+ "upper" => [[0x41, 0x5A]],
95
+ "word" => WORD,
96
+ "xdigit" => [[0x30, 0x39], [0x41, 0x46], [0x61, 0x66]]
97
+ }.freeze
98
+ SIMPLE_ESCAPES = { "a" => 0x07, "f" => 0x0C, "n" => 0x0A, "r" => 0x0D, "t" => 0x09, "v" => 0x0B }.freeze
99
+ PROPERTY_NAMES = (["Any"] + UnicodeTables::PROPERTY_NAMES).to_h { |n| [n, true] }.freeze
100
+ MAX_REPEAT = 1000
101
+
102
+ W = "[0-9A-Za-z_]"
103
+ WORD_BOUNDARY = "(?:(?<=#{W})(?!#{W})|(?<!#{W})(?=#{W}))".freeze
104
+ NOT_WORD_BOUNDARY = "(?:(?<=#{W})(?=#{W})|(?<!#{W})(?!#{W}))".freeze
105
+ private_constant :W
106
+
107
+ FOLD = 1
108
+ MULTILINE = 2
109
+ DOT_NL = 4
110
+
111
+ ORBIT_OF = UnicodeTables::ORBITS.each_with_object({}) do |orbit, h|
112
+ orbit.each { |c| h[c] = orbit }
113
+ end.freeze
114
+
115
+ FOLD_EXTRA = {} # rubocop:disable Style/MutableConstant
116
+ FOLD_EXTRA_LOCK = Mutex.new
117
+ private_constant :FOLD_EXTRA, :FOLD_EXTRA_LOCK
118
+
119
+ # Sets are lists of inclusive [lo, hi] code point ranges.
120
+ module Sets
121
+ module_function
122
+
123
+ def normalize(ranges)
124
+ merged = []
125
+ ranges.sort.each do |lo, hi|
126
+ if merged.empty? || lo > merged.last[1] + 1
127
+ merged << [lo, hi]
128
+ elsif hi > merged.last[1]
129
+ merged.last[1] = hi
130
+ end
131
+ end
132
+ merged
133
+ end
134
+
135
+ def complement(ranges)
136
+ out = []
137
+ next_lo = 0
138
+ normalize(ranges).each do |lo, hi|
139
+ out << [next_lo, lo - 1] if lo > next_lo
140
+ next_lo = hi + 1
141
+ end
142
+ out << [next_lo, MAX_RUNE] if next_lo <= MAX_RUNE
143
+ out
144
+ end
145
+
146
+ # fold adds every code point sharing a simple case folding orbit with
147
+ # one already in ranges.
148
+ def fold(ranges)
149
+ if ranges.size == 1 && ranges[0][0] == ranges[0][1]
150
+ same = ORBIT_OF[ranges[0][0]]
151
+ return same ? normalize(same.map { |c| [c, c] }) : ranges
152
+ end
153
+
154
+ out = ranges.dup
155
+ UnicodeTables::ORBITS.each do |orbit|
156
+ next unless orbit.any? { |c| ranges.any? { |lo, hi| c.between?(lo, hi) } }
157
+
158
+ orbit.each { |c| out << [c, c] }
159
+ end
160
+ normalize(out)
161
+ end
162
+
163
+ # folded_property is a Onigmo class matching the code points with
164
+ # property name, or without it when negated, and, under (?i), those
165
+ # an orbit of simple case folding ties to one that has it. Go adds
166
+ # the same code points (its FoldCategory and FoldScript tables).
167
+ def folded_property(name, negated)
168
+ extra = fold_extra(name)
169
+ return "\\p{#{"^" if negated}#{name}}" if extra.empty?
170
+
171
+ items = extra.map { |c| format("\\u{%x}", c) }.join
172
+ negated ? "[\\p{^#{name}}&&[^#{items}]]" : "[\\p{#{name}}#{items}]"
173
+ end
174
+
175
+ # fold_extra lists the code points without property name that share
176
+ # a folding orbit with one that has it.
177
+ def fold_extra(name)
178
+ FOLD_EXTRA_LOCK.synchronize do
179
+ FOLD_EXTRA[name] ||= begin
180
+ has = Regexp.new("\\A\\p{#{name}}\\z")
181
+ UnicodeTables::ORBITS.flat_map do |orbit|
182
+ members = orbit.map { |c| [c, has.match?([c].pack("U"))] }
183
+ members.any?(&:last) ? members.reject(&:last).map(&:first) : []
184
+ end.sort.freeze
185
+ end
186
+ end
187
+ end
188
+
189
+ # without_surrogates drops code points no Ruby string can hold; no Go
190
+ # string decodes to them either, so they never match.
191
+ def without_surrogates(ranges)
192
+ normalize(ranges).flat_map do |lo, hi|
193
+ next [[lo, hi]] if hi < SURROGATES.begin || lo > SURROGATES.end
194
+
195
+ [([lo, SURROGATES.begin - 1] if lo < SURROGATES.begin),
196
+ ([SURROGATES.end + 1, hi] if hi > SURROGATES.end)].compact
197
+ end
198
+ end
199
+ end
200
+
201
+ # Translator is a recursive-descent parser of RE2 syntax that emits the
202
+ # equivalent Onigmo source as it goes. Every group is emitted
203
+ # non-capturing: a condition only asks whether the pattern matches.
204
+ class Translator
205
+ def initialize(pattern)
206
+ pattern = pattern.dup.force_encoding(Encoding::UTF_8)
207
+ raise Error, "invalid UTF-8" unless pattern.valid_encoding?
208
+
209
+ @chars = pattern.chars
210
+ @pos = 0
211
+ end
212
+
213
+ def translate
214
+ out = body(0, top: true)
215
+ raise Error, "unexpected )" unless eof?
216
+
217
+ out
218
+ end
219
+
220
+ private
221
+
222
+ def eof? = @pos >= @chars.size
223
+
224
+ def peek(offset = 0) = @chars[@pos + offset]
225
+
226
+ def advance
227
+ c = @chars[@pos]
228
+ @pos += 1
229
+ c
230
+ end
231
+
232
+ def expect(char, message)
233
+ raise Error, message unless peek == char
234
+
235
+ advance
236
+ end
237
+
238
+ # Sequence is the Onigmo source of one group body as it is emitted,
239
+ # with where its last repeatable operand starts.
240
+ class Sequence
241
+ attr_reader :source
242
+
243
+ def initialize
244
+ @source = +""
245
+ @operand = nil
246
+ @repeated = false
247
+ end
248
+
249
+ def operand(src)
250
+ @operand = @source.size
251
+ @repeated = false
252
+ @source << src
253
+ end
254
+
255
+ # separate ends the current operand: after | or a flag group a
256
+ # repetition has no argument.
257
+ def separate(src = "")
258
+ @operand = nil
259
+ @source << src
260
+ end
261
+
262
+ def repeat(quantifier)
263
+ raise Error, "missing argument to repetition operator" if @operand.nil?
264
+ raise Error, "invalid nested repetition operator" if @repeated
265
+
266
+ @source.insert(@operand, "(?:")
267
+ @source << ")" << quantifier
268
+ @repeated = true
269
+ end
270
+ end
271
+
272
+ # body parses alternatives up to an unmatched ) or the end. A flag
273
+ # group without a body changes flags up to the end of the enclosing
274
+ # group, across alternatives, as in RE2.
275
+ def body(flags, top: false)
276
+ seq = Sequence.new
277
+ until eof? || (peek == ")" && !top)
278
+ case peek
279
+ when ")" then raise Error, "unexpected )"
280
+ when "|"
281
+ advance
282
+ seq.separate("|")
283
+ when "*", "+", "?", "{" then repeat_or_literal(seq, flags)
284
+ when "(" then flags = group(seq, flags)
285
+ when "\\" then escape(seq, flags)
286
+ else seq.operand(plain_atom(flags))
287
+ end
288
+ end
289
+ seq.source
290
+ end
291
+
292
+ def repeat_or_literal(seq, flags)
293
+ quantifier = repetition
294
+ return seq.repeat(quantifier) if quantifier
295
+
296
+ advance
297
+ seq.operand(literal("{".ord, flags))
298
+ end
299
+
300
+ # group reads a group or a flag group and returns the flags in force
301
+ # after it.
302
+ def group(seq, flags)
303
+ advance
304
+ inner = group_flags(flags)
305
+ if inner.is_a?(Array)
306
+ seq.separate
307
+ return inner.first
308
+ end
309
+
310
+ seq.operand("(?:#{body(inner)})")
311
+ expect(")", "missing )")
312
+ flags
313
+ end
314
+
315
+ def plain_atom(flags)
316
+ c = advance
317
+ case c
318
+ when "[" then char_class(flags)
319
+ when "." then flags.anybits?(DOT_NL) ? "(?m:.)" : "[^\\n]"
320
+ when "^" then flags.anybits?(MULTILINE) ? "(?:\\A|(?<=\\n))" : "\\A"
321
+ when "$" then flags.anybits?(MULTILINE) ? "(?:\\z|(?=\\n))" : "\\z"
322
+ else literal(c.ord, flags)
323
+ end
324
+ end
325
+
326
+ # repetition consumes *, +, ? or a well-formed {n}, {n,} or {n,m},
327
+ # and a lazy ?, returning the Onigmo quantifier. A { that does not
328
+ # start a well-formed repetition is a literal: it returns nil and
329
+ # consumes nothing. Laziness never changes whether a string matches.
330
+ def repetition
331
+ c = peek
332
+ quantifier =
333
+ if c == "{"
334
+ counts = repeat_counts
335
+ return nil unless counts
336
+
337
+ min, max = counts
338
+ invalid = min.negative? || min > MAX_REPEAT || max > MAX_REPEAT || (max >= 0 && min > max)
339
+ raise Error, "invalid repeat count" if invalid
340
+
341
+ if max == min
342
+ "{#{min}}"
343
+ else
344
+ "{#{min},#{max.negative? ? "" : max}}"
345
+ end
346
+ else
347
+ advance
348
+ c
349
+ end
350
+ advance if peek == "?"
351
+ quantifier
352
+ end
353
+
354
+ # repeat_counts reads {n}, {n,} or {n,m} as [min, max] (max -1 when
355
+ # unbounded) and consumes it, or returns nil. A count of more than
356
+ # eight digits is -1 so that it is invalid, as in RE2.
357
+ def repeat_counts
358
+ saved = @pos
359
+ advance
360
+ min = count
361
+ max = min
362
+ if min && peek == ","
363
+ advance
364
+ max = peek == "}" ? -1 : count
365
+ end
366
+ return [min, max] if min && max && peek == "}" && advance
367
+
368
+ @pos = saved
369
+ nil
370
+ end
371
+
372
+ def count
373
+ digits = +""
374
+ digits << advance while peek&.match?(/\A[0-9]\z/)
375
+ return nil if digits.empty? || (digits.size > 1 && digits.start_with?("0"))
376
+
377
+ digits.size > 8 ? -1 : digits.to_i
378
+ end
379
+
380
+ # group_flags reads what follows (. It returns [flags] for a flag
381
+ # group without a body, or the flags of a group's body.
382
+ def group_flags(flags)
383
+ return flags unless peek == "?"
384
+
385
+ advance
386
+ if peek == "P" || (peek == "<" && ![nil, "=", "!"].include?(peek(1)))
387
+ advance if peek == "P"
388
+ expect("<", "invalid or unsupported Perl syntax")
389
+ group_name
390
+ return flags
391
+ end
392
+ flag_list(flags)
393
+ end
394
+
395
+ def group_name
396
+ name = +""
397
+ name << advance until eof? || peek == ">"
398
+ raise Error, "invalid named capture" if eof? || name.empty? || !name.match?(/\A[0-9A-Za-z_]+\z/)
399
+
400
+ advance
401
+ end
402
+
403
+ def flag_list(flags)
404
+ negated = false
405
+ seen = false
406
+ until eof?
407
+ c = advance
408
+ case c
409
+ when "i", "m", "s", "U"
410
+ bit = { "i" => FOLD, "m" => MULTILINE, "s" => DOT_NL, "U" => 0 }.fetch(c)
411
+ flags = negated ? flags & ~bit : flags | bit
412
+ seen = true
413
+ when "-"
414
+ raise Error, "invalid or unsupported Perl syntax" if negated
415
+
416
+ negated = true
417
+ seen = false
418
+ when ":", ")"
419
+ raise Error, "missing flags" if negated && !seen
420
+
421
+ return c == ")" ? [flags] : flags
422
+ else
423
+ raise Error, "invalid or unsupported Perl syntax"
424
+ end
425
+ end
426
+ raise Error, "missing )"
427
+ end
428
+
429
+ # escape emits what follows a backslash; every character of
430
+ # \Q...\E is an operand of its own.
431
+ def escape(seq, flags)
432
+ atom = escape_atom(flags)
433
+ atom.is_a?(Array) ? atom.each { |a| seq.operand(a) } : seq.operand(atom)
434
+ end
435
+
436
+ # escape_atom translates what follows a backslash outside a class.
437
+ # \Q...\E yields the list of its literals.
438
+ def escape_atom(flags)
439
+ advance
440
+ raise Error, "trailing backslash" if eof?
441
+
442
+ case peek
443
+ when "A" then advance && "\\A"
444
+ when "z" then advance && "\\z"
445
+ when "b" then advance && WORD_BOUNDARY
446
+ when "B" then advance && NOT_WORD_BOUNDARY
447
+ when "Q" then advance && quoted_literals(flags)
448
+ when "d", "s", "w", "D", "S", "W"
449
+ ranges, props = perl_class(flags)
450
+ set(ranges, props, negated: false)
451
+ when "p", "P"
452
+ ranges, props = unicode_class(flags)
453
+ set(ranges, props, negated: false)
454
+ else literal(escaped_rune, flags)
455
+ end
456
+ end
457
+
458
+ def quoted_literals(flags)
459
+ out = []
460
+ until eof?
461
+ if peek == "\\" && peek(1) == "E"
462
+ @pos += 2
463
+ break
464
+ end
465
+ out << literal(advance.ord, flags)
466
+ end
467
+ out
468
+ end
469
+
470
+ # escaped_rune reads an escape denoting one code point, the
471
+ # backslash already consumed.
472
+ def escaped_rune
473
+ c = advance
474
+ case c
475
+ when "1".."7"
476
+ raise Error, "invalid escape sequence" unless peek&.match?(/\A[0-7]\z/)
477
+
478
+ octal(c)
479
+ when "0" then octal(c)
480
+ when "x" then hex
481
+ else
482
+ return SIMPLE_ESCAPES.fetch(c) if SIMPLE_ESCAPES.key?(c)
483
+ return c.ord if c.ord < 0x80 && !c.match?(/\A[0-9A-Za-z]\z/)
484
+
485
+ raise Error, "invalid escape sequence"
486
+ end
487
+ end
488
+
489
+ def octal(first)
490
+ digits = first.dup
491
+ digits << advance while digits.size < 3 && peek&.match?(/\A[0-7]\z/)
492
+ digits.to_i(8)
493
+ end
494
+
495
+ def hex
496
+ raise Error, "invalid escape sequence" if eof?
497
+
498
+ if peek == "{"
499
+ advance
500
+ digits = +""
501
+ digits << advance while peek&.match?(/\A\h\z/)
502
+ raise Error, "invalid escape sequence" if digits.empty? || peek != "}" || digits.to_i(16) > MAX_RUNE
503
+
504
+ advance
505
+ return digits.to_i(16)
506
+ end
507
+ digits = [advance, advance].join
508
+ raise Error, "invalid escape sequence" unless digits.match?(/\A\h\h\z/)
509
+
510
+ digits.to_i(16)
511
+ end
512
+
513
+ # perl_class reads \d, \s, \w or a negation, the backslash consumed,
514
+ # as [ranges, props].
515
+ def perl_class(flags)
516
+ c = advance
517
+ ranges = PERL_CLASSES.fetch(c.downcase)
518
+ ranges = Sets.fold(ranges) if flags.anybits?(FOLD)
519
+ [c == c.upcase ? Sets.complement(ranges) : ranges, []]
520
+ end
521
+
522
+ # unicode_class reads \pN, \p{Name}, \p{^Name} or a \P negation,
523
+ # the backslash consumed, as [ranges, props].
524
+ def unicode_class(flags)
525
+ negated = advance == "P"
526
+ raise Error, "invalid character class range" if eof?
527
+
528
+ name = advance
529
+ if name == "{"
530
+ name = +""
531
+ name << advance until eof? || peek == "}"
532
+ expect("}", "invalid character class range")
533
+ end
534
+ if name.start_with?("^")
535
+ negated = !negated
536
+ name = name[1..]
537
+ end
538
+ raise Error, "invalid character class range" unless PROPERTY_NAMES.key?(name)
539
+ return [[], [Sets.folded_property(name, negated)]] if flags.anybits?(FOLD) && name != "Any"
540
+
541
+ [[], ["\\p{#{"^" if negated}#{name}}"]]
542
+ end
543
+
544
+ # char_class reads a class after its [.
545
+ def char_class(flags)
546
+ negated = peek == "^"
547
+ advance if negated
548
+ ranges = []
549
+ props = []
550
+ first = true
551
+ while eof? || peek != "]" || first
552
+ first = false
553
+ r, p = class_item(flags)
554
+ ranges.concat(r)
555
+ props.concat(p)
556
+ end
557
+ advance
558
+ set(ranges, props, negated: negated)
559
+ end
560
+
561
+ def class_item(flags)
562
+ if peek == "[" && peek(1) == ":"
563
+ posix = posix_class(flags)
564
+ return posix if posix
565
+ end
566
+ if peek == "\\" && %w[p P].include?(peek(1))
567
+ advance
568
+ return unicode_class(flags)
569
+ end
570
+ if peek == "\\" && %w[d s w D S W].include?(peek(1))
571
+ advance
572
+ return perl_class(flags)
573
+ end
574
+ lo = class_char
575
+ hi = lo
576
+ if peek == "-" && ![nil, "]"].include?(peek(1))
577
+ advance
578
+ hi = class_char
579
+ raise Error, "invalid character class range" if hi < lo
580
+ end
581
+ ranges = [[lo, hi]]
582
+ [flags.anybits?(FOLD) ? Sets.fold(ranges) : ranges, []]
583
+ end
584
+
585
+ # posix_class reads [:name:] or [:^name:], or returns nil when no :]
586
+ # follows, making the [ a literal.
587
+ def posix_class(flags)
588
+ close = @chars[(@pos + 2)..].each_cons(2).find_index { |a, b| a == ":" && b == "]" }
589
+ return nil unless close
590
+
591
+ name = @chars[@pos + 2, close].join
592
+ negated = name.start_with?("^")
593
+ ranges = POSIX_CLASSES[negated ? name[1..] : name]
594
+ raise Error, "invalid character class range" unless ranges
595
+
596
+ @pos += close + 4
597
+ ranges = Sets.fold(ranges) if flags.anybits?(FOLD)
598
+ [negated ? Sets.complement(ranges) : ranges, []]
599
+ end
600
+
601
+ def class_char
602
+ raise Error, "missing closing ]" if eof?
603
+
604
+ c = advance
605
+ return c.ord unless c == "\\"
606
+ raise Error, "trailing backslash" if eof?
607
+
608
+ escaped_rune
609
+ end
610
+
611
+ def literal(code_point, flags)
612
+ ranges = [[code_point, code_point]]
613
+ ranges = Sets.fold(ranges) if flags.anybits?(FOLD)
614
+ if ranges.size == 1 && ranges[0][0] == ranges[0][1] && !SURROGATES.cover?(code_point)
615
+ rune(code_point)
616
+ else
617
+ set(
618
+ ranges, [], negated: false
619
+ )
620
+ end
621
+ end
622
+
623
+ # set emits a class. Ruby cannot hold surrogates, so they are dropped
624
+ # from both a set and its negation; neither matches them in RE2.
625
+ def set(ranges, props, negated:)
626
+ ranges = Sets.without_surrogates(ranges)
627
+ if ranges.empty? && props.empty?
628
+ negated ? "(?m:.)" : "(?!)"
629
+ else
630
+ items = ranges.map { |lo, hi| lo == hi ? rune(lo) : "#{rune(lo)}-#{rune(hi)}" }
631
+ "[#{"^" if negated}#{items.join}#{props.join}]"
632
+ end
633
+ end
634
+
635
+ def rune(code_point)
636
+ if code_point < 0x80 && code_point.chr.match?(/\A[0-9A-Za-z]\z/)
637
+ code_point.chr
638
+ else
639
+ format("\\u{%x}", code_point)
640
+ end
641
+ end
642
+ end
643
+ private_constant :Translator, :Sets
644
+ end
645
+ private_constant :Regex, :UnicodeTables
646
+ end
647
+ end