hegeltest 0.3.4 → 0.4.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +182 -0
  3. data/README.ja.md +408 -0
  4. data/README.md +208 -102
  5. data/Rakefile +6 -3
  6. data/docs/README.md +9 -0
  7. data/docs/adr/0027-report-each-runs-test-location-from-the-callers-frame.md +63 -0
  8. data/docs/adr/0028-expose-settings-profiles-as-a-keyword-and-module-functions.md +91 -0
  9. data/docs/adr/0029-keep-notes-local-and-defer-run-error-kinds.md +72 -0
  10. data/docs/adr/0030-represent-a-duration-as-integer-nanoseconds.md +48 -0
  11. data/docs/adr/0031-take-the-alphabet-options-from-hegel-rust.md +52 -0
  12. data/docs/adr/0032-suppress-span-closes-while-an-abandoned-recursion-attempt-unwinds.md +147 -0
  13. data/docs/adr/0033-cache-compiled-grammars-for-the-length-of-a-run.md +80 -0
  14. data/docs/adr/0034-repeat-a-test-body-with-a-ruby-side-indent.md +120 -0
  15. data/docs/adr/0035-defer-concurrent-state-machines-and-the-engine-printer.md +136 -0
  16. data/docs/architecture.md +16 -1
  17. data/lib/hegel/errors.rb +24 -0
  18. data/lib/hegel/generator.rb +52 -25
  19. data/lib/hegel/generators.rb +715 -45
  20. data/lib/hegel/grammar_cache.rb +36 -0
  21. data/lib/hegel/lib_hegel/real.rb +320 -17
  22. data/lib/hegel/lib_hegel.rb +31 -6
  23. data/lib/hegel/libhegel_version.rb +1 -1
  24. data/lib/hegel/report.rb +13 -10
  25. data/lib/hegel/runner.rb +61 -27
  26. data/lib/hegel/settings.rb +92 -16
  27. data/lib/hegel/state_machine.rb +17 -4
  28. data/lib/hegel/stateful/pool.rb +52 -0
  29. data/lib/hegel/stateful.rb +2 -2
  30. data/lib/hegel/syntax/methods.rb +99 -15
  31. data/lib/hegel/test_case.rb +256 -35
  32. data/lib/hegel/test_location.rb +49 -0
  33. data/lib/hegel/version.rb +1 -1
  34. data/lib/hegel.rb +21 -5
  35. data/sig/hegel.rbs +295 -42
  36. data/skills/hegel-ruby/references/ruby/reference.md +463 -41
  37. metadata +13 -1
@@ -2,8 +2,10 @@
2
2
 
3
3
  require "date"
4
4
  require "ipaddr"
5
+ require "pathname"
5
6
  require_relative "errors"
6
7
  require_relative "generator"
8
+ require_relative "grammar_cache"
7
9
  require_relative "lib_hegel"
8
10
 
9
11
  module Hegel
@@ -19,6 +21,15 @@ module Hegel
19
21
  # generator built once and drawn from many times fails the same way
20
22
  # hegel-rust's does.
21
23
  module Generators
24
+ # hegel-rust's DEFAULT_MAX_SIZE for text and binary. A min_size above it
25
+ # moves the default to min_size + 100, so a lone min_size never makes
26
+ # the range empty.
27
+ DEFAULT_MAX_SIZE = 100
28
+
29
+ def self.default_max_size(min_size)
30
+ (min_size > DEFAULT_MAX_SIZE) ? min_size + DEFAULT_MAX_SIZE : DEFAULT_MAX_SIZE
31
+ end
32
+
22
33
  # Hegel::Syntax::Methods#booleans. A boolean, true with probability +p+.
23
34
  class BooleanGenerator < Generator
24
35
  def initialize(p:)
@@ -75,21 +86,26 @@ module Hegel
75
86
  #
76
87
  # The two checks below come with those defaults: hegel-rust and
77
88
  # hegel-go each raise on the same two combinations, and the messages
78
- # here carry the substring theirs do. smallest_nonzero_magnitude and
79
- # allow_subnormal, which hegel-rust also exposes, stay unexposed; a
80
- # keyword can be added later without breaking a caller.
89
+ # here carry the substring theirs do.
90
+ #
91
+ # allow_subnormal is three-valued too. nil allows subnormals whenever
92
+ # the bounds admit one, as hegel-rust's does, and the keyword maps to
93
+ # hegel_generate_float's smallest_nonzero_magnitude: the smallest
94
+ # subnormal when allowed, the smallest normal otherwise.
81
95
  class FloatGenerator < Generator
82
96
  # hegel_generate_float's width; Ruby has one Float type, the 64-bit
83
97
  # IEEE 754 double, so this is never anything else.
84
98
  WIDTH = 64
85
99
 
86
- def initialize(min_value:, max_value:, allow_nan:, allow_infinity:, exclude_min:, exclude_max:)
100
+ def initialize(min_value:, max_value:, allow_nan:, allow_infinity:, exclude_min:, exclude_max:,
101
+ allow_subnormal:)
87
102
  @min_value = min_value
88
103
  @max_value = max_value
89
104
  @allow_nan = allow_nan
90
105
  @allow_infinity = allow_infinity
91
106
  @exclude_min = exclude_min
92
107
  @exclude_max = exclude_max
108
+ @allow_subnormal = allow_subnormal
93
109
  end
94
110
 
95
111
  def do_draw(tc)
@@ -113,24 +129,126 @@ module Hegel
113
129
  WIDTH, min_value, max_value,
114
130
  allow_nan: allow_nan, allow_infinity: allow_infinity,
115
131
  exclude_min: @exclude_min, exclude_max: @exclude_max,
116
- smallest_nonzero_magnitude: LibHegel::HEGEL_FLOAT64_SMALLEST_NONZERO_MAGNITUDE_UNRESTRICTED
132
+ smallest_nonzero_magnitude: smallest_nonzero_magnitude
117
133
  )
118
134
  end
135
+
136
+ private
137
+
138
+ def smallest_nonzero_magnitude
139
+ allow_subnormal = @allow_subnormal.nil? ? subnormal_default : @allow_subnormal
140
+ if allow_subnormal
141
+ if !@min_value.nil? && @min_value >= Float::MIN
142
+ raise Hegel::Error, "floats: InvalidArgument: allow_subnormal=true, but min_value excludes " \
143
+ "all values below the smallest positive normal #{Float::MIN}"
144
+ end
145
+ if !@max_value.nil? && @max_value <= -Float::MIN
146
+ raise Hegel::Error, "floats: InvalidArgument: allow_subnormal=true, but max_value excludes " \
147
+ "all values above the smallest negative normal -#{Float::MIN}"
148
+ end
149
+
150
+ LibHegel::HEGEL_FLOAT64_SMALLEST_NONZERO_MAGNITUDE_UNRESTRICTED
151
+ else
152
+ if only_subnormals_between_bounds?
153
+ raise Hegel::Error, "floats: InvalidArgument: allow_subnormal=false leaves no #{WIDTH}-bit " \
154
+ "floating-point values in [#{@min_value}, #{@max_value}]"
155
+ end
156
+
157
+ Float::MIN
158
+ end
159
+ end
160
+
161
+ def only_subnormals_between_bounds?
162
+ return false if @min_value.nil? || @max_value.nil? || 0.0.between?(@min_value, @max_value)
163
+
164
+ @max_value < Float::MIN && @min_value > -Float::MIN
165
+ end
166
+
167
+ def subnormal_default
168
+ (@min_value.nil? || @min_value < Float::MIN) && (@max_value.nil? || @max_value > -Float::MIN)
169
+ end
170
+ end
171
+
172
+ # The codecs hegel_string_generator_text accepts. Checked here as well so
173
+ # the message matches hegel-rust's, which rejects an unknown codec before
174
+ # the engine sees it.
175
+ CODECS = %w[ascii latin-1 iso-8859-1 utf-8].freeze
176
+
177
+ # The categories that contain surrogate codepoints. hegel-rust rejects
178
+ # them in +categories+ because its strings cannot hold a surrogate.
179
+ SURROGATE_CATEGORIES = %w[Cs C].freeze
180
+
181
+ # Hegel::Syntax::Methods#characters. A String of exactly one character.
182
+ # It also carries the alphabet options that TextGenerator and
183
+ # FromRegexGenerator share, so all three build their text handle from the
184
+ # same arguments.
185
+ class CharactersGenerator < Generator
186
+ def initialize(codec: nil, min_codepoint: nil, max_codepoint: nil, categories: nil, exclude_categories: nil,
187
+ include_characters: nil, exclude_characters: nil)
188
+ @options = {
189
+ codec: codec, min_codepoint: min_codepoint, max_codepoint: max_codepoint, categories: categories,
190
+ exclude_categories: exclude_categories, include_characters: include_characters,
191
+ exclude_characters: exclude_characters
192
+ }.freeze
193
+ end
194
+
195
+ # The alphabet options as given, nil for each one left out.
196
+ attr_reader :options
197
+
198
+ def do_draw(tc)
199
+ with_alphabet(tc, "characters", 1, 1) { |generator| tc.generate_string(generator) }
200
+ end
201
+
202
+ # Whether any alphabet option was passed, which TextGenerator needs to
203
+ # refuse an +alphabet+ given beside one.
204
+ def options_given?
205
+ @options.each_value.any? { |value| !value.nil? }
206
+ end
207
+
208
+ # Yields a text handle over this alphabet for strings of
209
+ # [min_size, max_size] characters, freed when the block returns.
210
+ # +name+ is the factory method whose draw this is, for the messages.
211
+ def with_alphabet(tc, name, min_size, max_size, &block)
212
+ tc.with_text_generator(min_size: min_size, max_size: max_size, **engine_options(name), &block)
213
+ end
214
+
215
+ private
216
+
217
+ def engine_options(name)
218
+ codec = @options[:codec]
219
+ raise Hegel::Error, "#{name}: invalid codec: #{codec}" if codec && !CODECS.include?(codec)
220
+
221
+ categories = @options[:categories]
222
+ exclude_categories = @options[:exclude_categories]
223
+ if categories
224
+ surrogate = categories.find { |category| SURROGATE_CATEGORIES.include?(category) }
225
+ if surrogate
226
+ raise Hegel::Error,
227
+ "#{name}: Category \"#{surrogate}\" includes surrogate codepoints (Cs), " \
228
+ "which a valid UTF-8 String cannot hold."
229
+ end
230
+ # hegel-rust drops exclude_categories when categories is given.
231
+ exclude_categories = nil
232
+ end
233
+ @options.merge(categories: categories, exclude_categories: exclude_categories).compact
234
+ end
119
235
  end
120
236
 
121
237
  # Hegel::Syntax::Methods#text. A Unicode string of [min_size, max_size]
122
- # characters, unbounded above by default.
238
+ # characters, at most 100 by default. +alphabet+ is a String of the
239
+ # permitted characters, and cannot be combined with any of the character
240
+ # options.
123
241
  class TextGenerator < Generator
124
- def initialize(min_size:, max_size:, codec:, min_codepoint:, max_codepoint:)
242
+ def initialize(min_size:, max_size:, alphabet: nil, **character_options)
125
243
  @min_size = min_size
126
244
  @max_size = max_size
127
- @codec = codec
128
- @min_codepoint = min_codepoint
129
- @max_codepoint = max_codepoint
245
+ @alphabet = alphabet
246
+ @characters = CharactersGenerator.new(**character_options)
130
247
  end
131
248
 
132
249
  def do_draw(tc)
133
- max_size = @max_size || LibHegel::HEGEL_COLLECTION_MAX_SIZE_UNBOUNDED
250
+ alphabet = alphabet_generator
251
+ max_size = @max_size || Generators.default_max_size(@min_size)
134
252
  raise Hegel::Error, "text: max_size < min_size" if max_size < @min_size
135
253
 
136
254
  # A fresh generator handle every draw, freed before this method
@@ -144,20 +262,33 @@ module Hegel
144
262
  # free at a fixed, known scope exit; Ruby has no equivalent
145
263
  # lifetime to hang a cache on, so this pays the alphabet-building
146
264
  # cost every draw instead.
147
- tc.with_text_generator(
148
- min_size: @min_size, max_size: max_size, codec: @codec,
149
- min_codepoint: @min_codepoint || 0, max_codepoint: @max_codepoint || 0xFFFFFFFF
150
- ) { |generator| tc.generate_string(generator) }
265
+ alphabet.with_alphabet(tc, "text", @min_size, max_size) { |generator| tc.generate_string(generator) }
266
+ end
267
+
268
+ private
269
+
270
+ def alphabet_generator
271
+ return @characters if @alphabet.nil?
272
+
273
+ raise Hegel::Error, "text: alphabet must be a String, not a #{@alphabet.class}" unless @alphabet.is_a?(String)
274
+
275
+ raise Hegel::Error, "text: Cannot combine .alphabet() with character methods." if @characters.options_given?
276
+
277
+ CharactersGenerator.new(categories: [], include_characters: @alphabet)
151
278
  end
152
279
  end
153
280
 
154
281
  # Hegel::Syntax::Methods#arrays. An Array of values from +elements+,
155
- # with [min_size, max_size] entries, unbounded above by default.
282
+ # with [min_size, max_size] entries, unbounded above by default. With
283
+ # +unique+, an element equal to one already drawn is rejected through
284
+ # #collection_reject, as SetGenerator does, so min_size still counts
285
+ # distinct elements.
156
286
  class ArrayGenerator < Generator
157
- def initialize(elements, min_size:, max_size:)
287
+ def initialize(elements, min_size:, max_size:, unique: false)
158
288
  @elements = elements
159
289
  @min_size = min_size
160
290
  @max_size = max_size
291
+ @unique = unique
161
292
  @label = combined_label(@elements)
162
293
  end
163
294
 
@@ -189,7 +320,14 @@ module Hegel
189
320
  collection = tc.new_collection(@min_size, max_size)
190
321
  begin
191
322
  result = []
192
- result << draw_element(tc) while tc.collection_more(collection)
323
+ while tc.collection_more(collection)
324
+ value = draw_element(tc)
325
+ if @unique && result.include?(value)
326
+ tc.collection_reject(collection, why: "duplicate element")
327
+ else
328
+ result << value
329
+ end
330
+ end
193
331
  result
194
332
  ensure
195
333
  tc.collection_free(collection)
@@ -429,28 +567,150 @@ module Hegel
429
567
  end
430
568
  end
431
569
 
432
- # Hegel::Syntax::Methods#characters. A String of exactly one character,
433
- # sharing TextGenerator's own alphabet options (codec, min_codepoint,
434
- # max_codepoint) by delegating to a TextGenerator built with min_size
435
- # and max_size both fixed at 1 -- the same alphabet-building logic,
436
- # just bounded to a single character instead of a run of them. Opens
437
- # no span of its own for the same reason TextGenerator does not: the
438
- # delegated #do_draw is the whole draw, not a composition of several.
439
- class CharactersGenerator < Generator
440
- def initialize(codec:, min_codepoint:, max_codepoint:)
441
- @text = TextGenerator.new(
442
- min_size: 1, max_size: 1, codec: codec,
443
- min_codepoint: min_codepoint, max_codepoint: max_codepoint
444
- )
570
+ # What permutations, subsequences, and samples share: a draw of distinct
571
+ # indices into the caller's sequence, and the span around the whole draw.
572
+ module SequenceDrawing
573
+ private
574
+
575
+ # Raises for a size bound no draw can honour. The max_size check
576
+ # reads as hegel-rust's does; the negative min_size check has no
577
+ # hegel-rust counterpart, since a usize cannot be negative.
578
+ def check_sizes(name, min_size, max_size)
579
+ raise Hegel::Error, "#{name}: min_size must not be negative" if min_size.negative?
580
+ raise Hegel::Error, "#{name}: Cannot have max_size < min_size" if max_size && max_size < min_size
581
+ end
582
+
583
+ def with_span(tc)
584
+ tc.start_span(label)
585
+ begin
586
+ yield
587
+ ensure
588
+ tc.stop_span(discard: false)
589
+ end
590
+ end
591
+
592
+ # Between +min_size+ and +max_size+ (nil, or anything above +size+,
593
+ # means +size+) distinct indices below +size+, in draw order. Each draw is the index into what is still unchosen, not
594
+ # into the whole sequence, so the shrinker's smallest value is the
595
+ # earliest index not yet taken: the minimal sample is 0...min_size in
596
+ # increasing order.
597
+ def draw_index_sample(tc, size, min_size, max_size)
598
+ remaining = (0...size).to_a
599
+ chosen = []
600
+ collection = tc.new_collection(min_size, [max_size || size, size].min)
601
+ begin
602
+ chosen << remaining.delete_at(tc.generate_integer(0, remaining.size - 1)) while !remaining.empty? && tc.collection_more(collection)
603
+ chosen
604
+ ensure
605
+ tc.collection_free(collection)
606
+ end
607
+ end
608
+ end
609
+
610
+ # Hegel::Syntax::Methods#permutations. Every element of +elements+, in an
611
+ # order the engine picks and shrinks towards the original one.
612
+ class PermutationsGenerator < Generator
613
+ include SequenceDrawing
614
+
615
+ def initialize(elements)
616
+ @elements = elements
617
+ end
618
+
619
+ def do_draw(tc)
620
+ elements = @elements.to_a
621
+ with_span(tc) do
622
+ draw_index_sample(tc, elements.size, elements.size, elements.size).map { |index| elements[index] }
623
+ end
624
+ end
625
+ end
626
+
627
+ # Hegel::Syntax::Methods#subsequences. A subset of +elements+ with
628
+ # [min_size, max_size] entries, in their original order. Duplicate
629
+ # elements count as distinct.
630
+ class SubsequencesGenerator < Generator
631
+ include SequenceDrawing
632
+
633
+ def initialize(elements, min_size:, max_size:)
634
+ @elements = elements
635
+ @min_size = min_size
636
+ @max_size = max_size
445
637
  end
446
638
 
447
639
  def do_draw(tc)
448
- @text.do_draw(tc)
640
+ elements = @elements.to_a
641
+ check_sizes("subsequences", @min_size, @max_size)
642
+ if @min_size > elements.size
643
+ raise Hegel::Error,
644
+ "subsequences: Cannot generate a subsequence: min_size #{@min_size} is larger than " \
645
+ "the #{elements.size} elements in the sequence"
646
+ end
647
+
648
+ with_span(tc) do
649
+ indices = draw_index_sample(tc, elements.size, @min_size, @max_size)
650
+ indices.sort.map { |index| elements[index] }
651
+ end
652
+ end
653
+ end
654
+
655
+ # Hegel::Syntax::Methods#samples. Between min_size and max_size elements
656
+ # of +elements+, drawn with or without replacement. Without replacement
657
+ # each element is used at most once, and duplicates in +elements+ count
658
+ # as distinct. With replacement the size is unbounded unless max_size
659
+ # bounds it.
660
+ class SamplesGenerator < Generator
661
+ include SequenceDrawing
662
+
663
+ def initialize(elements, min_size:, max_size:, replacement:)
664
+ @elements = elements
665
+ @min_size = min_size
666
+ @max_size = max_size
667
+ @replacement = replacement
668
+ end
669
+
670
+ def do_draw(tc)
671
+ elements = @elements.to_a
672
+ check_sizes("samples", @min_size, @max_size)
673
+ validate_for(elements.size)
674
+
675
+ with_span(tc) do
676
+ if @replacement
677
+ draw_with_replacement(tc, elements)
678
+ else
679
+ indices = draw_index_sample(tc, elements.size, @min_size, @max_size)
680
+ indices.map { |index| elements[index] }
681
+ end
682
+ end
683
+ end
684
+
685
+ private
686
+
687
+ def validate_for(size)
688
+ if @replacement
689
+ if size.zero? && @min_size.positive?
690
+ raise Hegel::Error, "samples: Cannot generate a non-empty sample from an empty sequence"
691
+ end
692
+ elsif @min_size > size
693
+ raise Hegel::Error,
694
+ "samples: Cannot generate a sample without replacement: min_size #{@min_size} is larger than " \
695
+ "the #{size} elements in the sequence"
696
+ end
697
+ end
698
+
699
+ def draw_with_replacement(tc, elements)
700
+ max_size = elements.empty? ? 0 : @max_size || LibHegel::HEGEL_COLLECTION_MAX_SIZE_UNBOUNDED
701
+ collection = tc.new_collection(@min_size, max_size)
702
+ begin
703
+ result = []
704
+ result << elements[tc.generate_integer(0, elements.size - 1)] while tc.collection_more(collection)
705
+ result
706
+ ensure
707
+ tc.collection_free(collection)
708
+ end
449
709
  end
450
710
  end
451
711
 
452
712
  # Hegel::Syntax::Methods#binary. A byte String of [min_size, max_size]
453
- # bytes, unbounded above by default.
713
+ # bytes, at most 100 by default.
454
714
  class BinaryGenerator < Generator
455
715
  def initialize(min_size:, max_size:)
456
716
  @min_size = min_size
@@ -458,7 +718,7 @@ module Hegel
458
718
  end
459
719
 
460
720
  def do_draw(tc)
461
- max_size = @max_size || LibHegel::HEGEL_COLLECTION_MAX_SIZE_UNBOUNDED
721
+ max_size = @max_size || Generators.default_max_size(@min_size)
462
722
  raise Hegel::Error, "binary: max_size < min_size" if max_size < @min_size
463
723
 
464
724
  # hegel_generate_bytes already returns an Encoding::BINARY String
@@ -473,8 +733,8 @@ module Hegel
473
733
  # the header documents as Python `re` syntax -- a different grammar
474
734
  # from Ruby's own Regexp, even though the two agree on simple patterns
475
735
  # (character classes, quantifiers, alternation). fullmatch defaults to
476
- # false, matching hegel_string_generator_regex's own documented
477
- # default: the drawn string only has to contain a match, not equal one.
736
+ # true, as hegel-rust's from_regex does: the whole string matches,
737
+ # rather than merely containing a match.
478
738
  #
479
739
  # +pattern+ must be a String, checked at draw time like every other
480
740
  # validation in this file. A Ruby Regexp is rejected rather than
@@ -489,25 +749,245 @@ module Hegel
489
749
  # confirmed a pattern needs no flags and uses only syntax the two
490
750
  # grammars share can still pass `my_regexp.source` explicitly.
491
751
  #
492
- # alphabet is not exposed here: the header's third argument accepts a
493
- # text generator to constrain the wildcard/padding characters this
494
- # regex can produce. nil, the header's documented "no particular
495
- # alphabet" default, is passed in its place: exposing it means deciding
496
- # how a Ruby caller writes an alphabet that constrains a Python regex,
497
- # and that question is worth answering on its own rather than in
498
- # passing here.
752
+ # +alphabet+ is a CharactersGenerator that constrains every character of
753
+ # the drawn string, padding included. Its text handle is sized 0..0, so an
754
+ # alphabet with no characters still builds: the engine then only rejects
755
+ # a pattern that needs one.
499
756
  class FromRegexGenerator < Generator
500
- def initialize(pattern, fullmatch:)
757
+ def initialize(pattern, fullmatch:, alphabet: nil)
501
758
  @pattern = pattern
502
759
  @fullmatch = fullmatch
760
+ @alphabet = alphabet
503
761
  end
504
762
 
505
763
  def do_draw(tc)
506
764
  unless @pattern.is_a?(String)
507
765
  raise Hegel::Error, "from_regex: pattern must be a String in Python re syntax, not a #{@pattern.class}"
508
766
  end
767
+ unless @alphabet.nil? || @alphabet.is_a?(CharactersGenerator)
768
+ raise Hegel::Error, "from_regex: alphabet must be a characters generator, not a #{@alphabet.class}"
769
+ end
770
+
771
+ return draw_regex(tc, nil) if @alphabet.nil?
772
+
773
+ # Built fresh each draw, like TextGenerator's handle, and freed after
774
+ # the regex handle that was built from it.
775
+ @alphabet.with_alphabet(tc, "from_regex", 0, 0) { |alphabet| draw_regex(tc, alphabet) }
776
+ end
777
+
778
+ private
779
+
780
+ def draw_regex(tc, alphabet)
781
+ tc.with_regex_generator(@pattern, fullmatch: @fullmatch, alphabet: alphabet) do |generator|
782
+ tc.generate_string(generator)
783
+ end
784
+ end
785
+ end
786
+
787
+ # Hegel::Syntax::Methods#from_lark. A String that a grammar in Lark's
788
+ # syntax accepts, ported from hegel-rust's LarkGenerator.
789
+ #
790
+ # The compiled grammar lives in the run's Hegel::GrammarCache, under a key
791
+ # holding everything the compilation reads, so a generator built inside
792
+ # the test body compiles once per run as well (docs/adr/0033). The key is
793
+ # built here because a draw writes nothing to the generator (ADR 0020).
794
+ class FromLarkGenerator < Generator
795
+ DEFAULT_MAX_SIZE = 8192
796
+ ATTEMPTS = 3
797
+ ATTEMPT_LABEL = LibHegel.label_from_name("hegel-ruby.Hegel::Generators::FromLarkGenerator.attempt")
798
+
799
+ # The widest values hegel_grammar_new takes, so a larger one reaches
800
+ # the engine as a value it reports on rather than failing to marshal.
801
+ MAX_SIZE_LIMIT = (2**64) - 1
802
+ MAX_DEPTH_LIMIT = (2**32) - 1
803
+
804
+ def initialize(grammar, start:, explicit:, alphabet:, max_size:, max_depth:, import_path:)
805
+ @grammar = frozen(grammar)
806
+ @start = frozen(start)
807
+ @explicit = explicit
808
+ @alphabet = alphabet
809
+ @max_size = max_size
810
+ @max_depth = max_depth
811
+ @import_path = frozen(import_path.is_a?(Pathname) ? import_path.to_s : import_path)
812
+ names = explicit.keys.map { |name| frozen(name.is_a?(Symbol) ? name.to_s : name) } if explicit.is_a?(Hash)
813
+ @label = lark_label(names)
814
+ @key = [
815
+ @grammar, @start, max_size, max_depth, alphabet.is_a?(CharactersGenerator) ? alphabet.options : alphabet,
816
+ names, @import_path
817
+ ].freeze
818
+ end
819
+
820
+ attr_reader :label
821
+
822
+ def do_draw(tc)
823
+ terminals = explicit_terminals
824
+ names = terminals.map(&:first)
825
+ tc.with_grammar(@key, -> { compile(tc, names) }) do |compiled|
826
+ next tc.generate_grammar(compiled.grammar, 0) if terminals.empty?
827
+
828
+ tc.start_span(label)
829
+ begin
830
+ draw_with_values(tc, compiled, terminals)
831
+ ensure
832
+ tc.stop_span(discard: false)
833
+ end
834
+ end
835
+ end
836
+
837
+ private
838
+
839
+ def frozen(value)
840
+ value.is_a?(String) ? -value : value
841
+ end
842
+
843
+ # hegel-rust's ingredients: the grammar, the start rule, and each
844
+ # explicit terminal's name and generator.
845
+ def lark_label(names)
846
+ labels = [self.class.label, text_label(@grammar), text_label(@start || "start")]
847
+ names&.zip(@explicit.values)&.each { |name, generator| labels << text_label(name) << Generator.label_of(generator) }
848
+ LibHegel.label_combine(labels)
849
+ end
850
+
851
+ def text_label(value)
852
+ value.is_a?(String) ? LibHegel.label_from_name(value) : 0
853
+ end
854
+
855
+ # The explicit terminals as [name, generator] pairs, once every option
856
+ # has been checked.
857
+ def explicit_terminals
858
+ raise Hegel::Error, "from_lark: grammar must be a String, not a #{@grammar.class}" unless @grammar.is_a?(String)
859
+ unless @start.nil? || @start.is_a?(String)
860
+ raise Hegel::Error, "from_lark: start must be a String, not a #{@start.class}"
861
+ end
862
+ raise Hegel::Error, "from_lark: max_size must be at least 1" if @max_size && @max_size < 1
863
+ raise Hegel::Error, "from_lark: max_depth must be at least 1" if @max_depth && @max_depth < 1
864
+ raise Hegel::Error, "from_lark: explicit must be a Hash, not a #{@explicit.class}" unless @explicit.is_a?(Hash)
865
+
866
+ terminals = @explicit.map { |name, generator| explicit_terminal(name, generator) }
867
+ names = terminals.map(&:first)
868
+ duplicate = names.find { |name| names.count(name) > 1 }
869
+ raise Hegel::Error, "from_lark: explicit terminal '#{duplicate}' is given more than one generator" if duplicate
870
+ unless @alphabet.nil? || @alphabet.is_a?(CharactersGenerator)
871
+ raise Hegel::Error, "from_lark: alphabet must be a characters generator, not a #{@alphabet.class}"
872
+ end
873
+ unless @import_path.nil? || @import_path.is_a?(String)
874
+ raise Hegel::Error, "from_lark: import_path must be a String or a Pathname, not a #{@import_path.class}"
875
+ end
876
+
877
+ terminals
878
+ end
879
+
880
+ def explicit_terminal(name, generator)
881
+ unless name.is_a?(String) || name.is_a?(Symbol)
882
+ raise Hegel::Error, "from_lark: an explicit terminal name must be a String or a Symbol, not a #{name.class}"
883
+ end
884
+ unless generator.is_a?(Generator)
885
+ raise Hegel::Error, "from_lark: explicit terminal '#{name}' must be given a generator, not a #{generator.class}"
886
+ end
887
+
888
+ [name.to_s, generator]
889
+ end
890
+
891
+ def compile(tc, names)
892
+ return compile_with(tc, names, nil) if @alphabet.nil?
893
+
894
+ # Sized 0..0, as hegel-rust builds a grammar's alphabet, and freed
895
+ # once the grammar is built from it.
896
+ @alphabet.with_alphabet(tc, "from_lark", 0, 0) { |alphabet| compile_with(tc, names, alphabet) }
897
+ end
898
+
899
+ def compile_with(tc, names, alphabet)
900
+ builder = tc.grammar_builder_new(@grammar)
901
+ begin
902
+ provide_files(tc, builder)
903
+ grammar = tc.grammar_new(
904
+ builder, @start, [@max_size || DEFAULT_MAX_SIZE, MAX_SIZE_LIMIT].min, [@max_depth || 0, MAX_DEPTH_LIMIT].min,
905
+ alphabet, names
906
+ )
907
+ ensure
908
+ tc.grammar_builder_free(builder)
909
+ end
910
+ begin
911
+ GrammarCache::Compiled.new(grammar: grammar, markers: names.map { |name| tc.grammar_placeholder(grammar, name) })
912
+ rescue
913
+ tc.grammar_free(grammar)
914
+ raise
915
+ end
916
+ end
509
917
 
510
- tc.with_regex_generator(@pattern, fullmatch: @fullmatch) { |generator| tc.generate_string(generator) }
918
+ def provide_files(tc, builder)
919
+ index = 0
920
+ while (path = tc.grammar_builder_file(builder, index))
921
+ tc.grammar_builder_provide(builder, index, @import_path && read_import(File.join(@import_path, path)))
922
+ index += 1
923
+ end
924
+ end
925
+
926
+ # The text of +file+, or nil when there is no such file, which lets a
927
+ # bundled grammar stand in for it. hegel-rust reads it the same way.
928
+ def read_import(file)
929
+ text = File.binread(file).force_encoding(Encoding::UTF_8)
930
+ return text if text.valid_encoding?
931
+
932
+ raise Hegel::Error, "from_lark: Could not read grammar `#{file}`: stream did not contain valid UTF-8"
933
+ rescue Errno::ENOENT, Errno::ENOTDIR
934
+ nil
935
+ rescue SystemCallError => e
936
+ raise Hegel::Error, "from_lark: Could not read grammar `#{file}`: #{e.message}"
937
+ end
938
+
939
+ # Draws the grammar's string under a shrinking budget until the string
940
+ # with its values spliced in fits max_size, or rejects the case, as
941
+ # hegel-rust does.
942
+ def draw_with_values(tc, compiled, terminals)
943
+ max_size = @max_size || DEFAULT_MAX_SIZE
944
+ pattern = Regexp.union(compiled.markers)
945
+ budget = max_size
946
+ ATTEMPTS.times do
947
+ oversize = false
948
+ tc.start_span(ATTEMPT_LABEL)
949
+ begin
950
+ out, own, markers, size = splice(tc, compiled, pattern, terminals, budget)
951
+ oversize = size > max_size
952
+ ensure
953
+ tc.stop_span(discard: oversize)
954
+ end
955
+ return out unless oversize
956
+
957
+ budget = [max_size * (own + markers) / size, budget - 1].min.clamp(1..)
958
+ end
959
+ tc.reject
960
+ end
961
+
962
+ # The grammar's string with a drawn value in place of each marker, how
963
+ # many of its characters the grammar drew, how many markers it held,
964
+ # and how many characters it has. One leftmost pass that does not
965
+ # rescan a spliced value, as in hegel-rust.
966
+ def splice(tc, compiled, pattern, terminals, budget)
967
+ drawn = tc.generate_grammar(compiled.grammar, budget)
968
+ markers = 0
969
+ marker_chars = 0
970
+ value_chars = 0
971
+ out = drawn.gsub(pattern) do |marker|
972
+ name, generator = terminals[compiled.markers.index(marker)]
973
+ value = draw_value(tc, name, generator)
974
+ markers += 1
975
+ marker_chars += marker.length
976
+ value_chars += value.length
977
+ value
978
+ end
979
+ own = drawn.length - marker_chars
980
+ [out, own, markers, own + value_chars]
981
+ end
982
+
983
+ def draw_value(tc, name, generator)
984
+ tc.start_span(generator.label)
985
+ value = generator.do_draw(tc)
986
+ return value if value.is_a?(String)
987
+
988
+ raise Hegel::Error, "from_lark: explicit terminal '#{name}' drew a #{value.class}, not a String"
989
+ ensure
990
+ tc.stop_span(discard: false)
511
991
  end
512
992
  end
513
993
 
@@ -768,6 +1248,83 @@ module Hegel
768
1248
  end
769
1249
  end
770
1250
 
1251
+ # Formats a drawn [year, month, day] as hegel-rust's format_date does,
1252
+ # "YYYY-MM-DD" with the year zero-padded to four digits.
1253
+ def self.format_date(date)
1254
+ format("%04d-%02d-%02d", *date)
1255
+ end
1256
+
1257
+ # Formats a drawn [hour, minute, second, nanosecond] as hegel-rust's
1258
+ # format_time does: nine fractional digits, present only when the
1259
+ # nanosecond is not zero.
1260
+ def self.format_time(time)
1261
+ hour, minute, second, nanosecond = time
1262
+ base = format("%02d:%02d:%02d", hour, minute, second)
1263
+ nanosecond.zero? ? base : format("%s.%09d", base, nanosecond)
1264
+ end
1265
+
1266
+ # Hegel::Syntax::Methods#date_strings. A "YYYY-MM-DD" String over the
1267
+ # years 1 through 9999, the range hegel-rust passes. Takes no options,
1268
+ # as in hegel-rust.
1269
+ #
1270
+ # Opens no span, for the reason UuidsGenerator gives: one native call.
1271
+ class DateStringsGenerator < Generator
1272
+ def do_draw(tc)
1273
+ Generators.format_date(tc.generate_date([1, 1, 1], [9999, 12, 31]))
1274
+ end
1275
+ end
1276
+
1277
+ # Hegel::Syntax::Methods#time_strings. An "HH:MM:SS" String over the
1278
+ # whole day, with ".fffffffff" appended when the drawn nanosecond is not
1279
+ # zero. Unlike #times, it keeps the engine's nanoseconds, as hegel-rust
1280
+ # does (see docs/adr/0022).
1281
+ #
1282
+ # Opens no span, for the reason UuidsGenerator gives: one native call.
1283
+ class TimeStringsGenerator < Generator
1284
+ def do_draw(tc)
1285
+ Generators.format_time(tc.generate_time([0, 0, 0, 0], [23, 59, 59, 999_999_999]))
1286
+ end
1287
+ end
1288
+
1289
+ # Hegel::Syntax::Methods#datetime_strings. A "YYYY-MM-DDTHH:MM:SS" String
1290
+ # over years 1 through 9999, formatted as #date_strings and #time_strings
1291
+ # are.
1292
+ #
1293
+ # Opens no span, for the reason UuidsGenerator gives: one native call.
1294
+ class DatetimeStringsGenerator < Generator
1295
+ def do_draw(tc)
1296
+ date, time = tc.generate_datetime([1, 1, 1], [0, 0, 0, 0], [9999, 12, 31], [23, 59, 59, 999_999_999])
1297
+ "#{Generators.format_date(date)}T#{Generators.format_time(time)}"
1298
+ end
1299
+ end
1300
+
1301
+ # Hegel::Syntax::Methods#durations. A span of time as whole nanoseconds
1302
+ # in [min_value, max_value], at most u64::MAX (about 584 years) as in
1303
+ # hegel-rust, whose generator is the model for the two messages below
1304
+ # (docs/adr/0030).
1305
+ class DurationsGenerator < Generator
1306
+ MAX_NANOSECONDS = (2**64) - 1
1307
+
1308
+ def initialize(min_value:, max_value:)
1309
+ @min_value = min_value
1310
+ @max_value = max_value
1311
+ end
1312
+
1313
+ def do_draw(tc)
1314
+ raise Hegel::Error, "durations: min_value must not be negative" if @min_value.negative?
1315
+ if @min_value > MAX_NANOSECONDS
1316
+ raise Hegel::Error, "durations: min_value exceeds the largest generatable Duration " \
1317
+ "(u64::MAX nanoseconds, about 584 years)"
1318
+ end
1319
+
1320
+ # Saturates rather than raises, as hegel-rust's max_value does.
1321
+ max_value = [@max_value, MAX_NANOSECONDS].min
1322
+ raise Hegel::Error, "durations: Cannot have max_value < min_value" if max_value < @min_value
1323
+
1324
+ IntegerGenerator.new(min_value: @min_value, max_value: max_value).do_draw(tc)
1325
+ end
1326
+ end
1327
+
771
1328
  # Hegel::Syntax::Methods#composite. Builds a generator from imperative
772
1329
  # code, the way Hypothesis's @composite decorator and hegel-rust's
773
1330
  # compose!/hegel-go's Composite let a caller assemble one value out of
@@ -893,5 +1450,118 @@ module Hegel
893
1450
  @inner.do_draw(tc)
894
1451
  end
895
1452
  end
1453
+
1454
+ # Hegel::Syntax::Methods#recursive. A value built from +leaf+ values by
1455
+ # +branch+, which receives a SubtreeGenerator for the sub-values and
1456
+ # returns a generator combining them. The engine decides every
1457
+ # leaf-or-branch choice and owns the depth and leaf budgets, as
1458
+ # hegel-rust's generators/recursive.rs leaves them to it.
1459
+ class RecursiveGenerator < Generator
1460
+ UINT64_MAX = (2**64) - 1
1461
+
1462
+ def initialize(leaf, max_depth:, max_leaves:, &branch)
1463
+ @leaf = leaf
1464
+ @max_depth = max_depth
1465
+ @max_leaves = max_leaves
1466
+ @branch = branch
1467
+ @label = combined_label(@leaf)
1468
+ end
1469
+
1470
+ attr_reader :label
1471
+
1472
+ def do_draw(tc)
1473
+ raise Hegel::Error, "recursive: block is required" unless @branch
1474
+ raise Hegel::Error, "recursive: leaf must be a generator, got #{@leaf.inspect}" unless @leaf.is_a?(Generator)
1475
+
1476
+ validate_bound("max_depth", @max_depth)
1477
+ validate_bound("max_leaves", @max_leaves)
1478
+
1479
+ tc.start_span(label)
1480
+ begin
1481
+ draw_recursion(tc)
1482
+ ensure
1483
+ tc.stop_span(discard: false)
1484
+ end
1485
+ end
1486
+
1487
+ private
1488
+
1489
+ def validate_bound(name, value)
1490
+ return if value.is_a?(Integer) && value.between?(0, UINT64_MAX)
1491
+
1492
+ raise Hegel::Error, "recursive: #{name} must be an Integer between 0 and 2**64 - 1, got #{value.inspect}"
1493
+ end
1494
+
1495
+ # An attempt abandoned over the leaf budget is retried even when a
1496
+ # body rescued the exception and returned a value instead: that value
1497
+ # was built from draws the engine is about to discard.
1498
+ def draw_recursion(tc)
1499
+ recursion = tc.new_recursion(@max_depth, @max_leaves)
1500
+ begin
1501
+ root = SubtreeGenerator.new(@leaf, @branch, recursion, 0, label)
1502
+ loop do
1503
+ begin
1504
+ value = root.draw_subtree(tc)
1505
+ rescue LeafBudgetExceeded => e
1506
+ raise unless e.recursion.equal?(recursion)
1507
+ end
1508
+ if tc.abandoned_by?(recursion)
1509
+ tc.recursion_retry(recursion)
1510
+ elsif tc.recursion_finish(recursion)
1511
+ return value
1512
+ end
1513
+ end
1514
+ ensure
1515
+ tc.recursion_free(recursion)
1516
+ end
1517
+ end
1518
+ end
1519
+
1520
+ # What a #recursive block receives: a generator of one sub-value, itself
1521
+ # a leaf or a further branch. Every depth shares the recursive
1522
+ # generator's label, which is what lets the shrinker replace a tree with
1523
+ # one of its own subtrees.
1524
+ class SubtreeGenerator < Generator
1525
+ def initialize(leaf, branch, recursion, depth, label)
1526
+ @leaf = leaf
1527
+ @branch = branch
1528
+ @recursion = recursion
1529
+ @depth = depth
1530
+ @label = label
1531
+ end
1532
+
1533
+ attr_reader :label
1534
+
1535
+ def do_draw(tc)
1536
+ if @recursion.freed
1537
+ raise Hegel::Error, "recursive: a subtree generator was drawn outside the draw that made it"
1538
+ end
1539
+
1540
+ tc.start_span(label)
1541
+ begin
1542
+ draw_subtree(tc)
1543
+ ensure
1544
+ tc.stop_span(discard: false)
1545
+ end
1546
+ end
1547
+
1548
+ # Called directly for the root, which needs no span of its own: the
1549
+ # recursive generator's span around it carries the same label. The
1550
+ # leaf and the branch generator get no span here either, unlike
1551
+ # hegel-rust's draw_silent; docs/adr/0032 has the measurement.
1552
+ def draw_subtree(tc)
1553
+ if tc.recursion_branch(@recursion, @depth)
1554
+ generator = @branch.call(SubtreeGenerator.new(@leaf, @branch, @recursion, @depth + 1, label))
1555
+ unless generator.is_a?(Generator)
1556
+ raise Hegel::Error, "recursive: the block must return a generator, got #{generator.inspect}"
1557
+ end
1558
+
1559
+ generator.do_draw(tc)
1560
+ else
1561
+ tc.recursion_leaf(@recursion)
1562
+ @leaf.do_draw(tc)
1563
+ end
1564
+ end
1565
+ end
896
1566
  end
897
1567
  end