solana-ruby-kit 8.2.0 → 8.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +164 -12
  3. data/lib/solana/ruby/kit/accounts/maybe_account.rb +1 -1
  4. data/lib/solana/ruby/kit/addresses/address.rb +1 -1
  5. data/lib/solana/ruby/kit/addresses/curve.rb +1 -1
  6. data/lib/solana/ruby/kit/addresses/program_derived_address.rb +1 -1
  7. data/lib/solana/ruby/kit/addresses/public_key.rb +1 -1
  8. data/lib/solana/ruby/kit/codecs/codec.rb +115 -5
  9. data/lib/solana/ruby/kit/codecs/data_structures.rb +246 -47
  10. data/lib/solana/ruby/kit/codecs/numbers.rb +96 -61
  11. data/lib/solana/ruby/kit/codecs/strings.rb +132 -6
  12. data/lib/solana/ruby/kit/encoding/base58.rb +1 -1
  13. data/lib/solana/ruby/kit/errors.rb +18 -1
  14. data/lib/solana/ruby/kit/fast_stable_stringify.rb +1 -1
  15. data/lib/solana/ruby/kit/functional.rb +1 -1
  16. data/lib/solana/ruby/kit/instruction_plans/instruction_plan.rb +31 -26
  17. data/lib/solana/ruby/kit/instruction_plans/message_packer_errors.rb +131 -0
  18. data/lib/solana/ruby/kit/instruction_plans/transaction_plan.rb +1 -1
  19. data/lib/solana/ruby/kit/instruction_plans/transaction_plan_executor.rb +2 -2
  20. data/lib/solana/ruby/kit/instruction_plans/transaction_plan_result.rb +1 -1
  21. data/lib/solana/ruby/kit/instruction_plans/transaction_planner.rb +11 -14
  22. data/lib/solana/ruby/kit/instruction_plans.rb +1 -1
  23. data/lib/solana/ruby/kit/instructions/accounts.rb +1 -1
  24. data/lib/solana/ruby/kit/instructions/instruction.rb +1 -1
  25. data/lib/solana/ruby/kit/instructions/roles.rb +1 -1
  26. data/lib/solana/ruby/kit/keys/key_pair.rb +1 -1
  27. data/lib/solana/ruby/kit/keys/private_key.rb +1 -1
  28. data/lib/solana/ruby/kit/keys/public_key.rb +1 -1
  29. data/lib/solana/ruby/kit/keys/signatures.rb +1 -1
  30. data/lib/solana/ruby/kit/offchain_messages/codec.rb +1 -1
  31. data/lib/solana/ruby/kit/offchain_messages/message_v1.rb +1 -1
  32. data/lib/solana/ruby/kit/options/option.rb +1 -1
  33. data/lib/solana/ruby/kit/plugin_core.rb +1 -1
  34. data/lib/solana/ruby/kit/programs/associated_token_account.rb +1 -1
  35. data/lib/solana/ruby/kit/programs/stake_program.rb +1 -1
  36. data/lib/solana/ruby/kit/programs/system_program.rb +1 -1
  37. data/lib/solana/ruby/kit/programs.rb +1 -1
  38. data/lib/solana/ruby/kit/promises.rb +1 -1
  39. data/lib/solana/ruby/kit/resource_limit_estimation.rb +1 -1
  40. data/lib/solana/ruby/kit/rpc_parsed_types/address_lookup_table.rb +1 -1
  41. data/lib/solana/ruby/kit/rpc_parsed_types/nonce_account.rb +1 -1
  42. data/lib/solana/ruby/kit/rpc_parsed_types/stake_account.rb +1 -1
  43. data/lib/solana/ruby/kit/rpc_parsed_types/token_account.rb +1 -1
  44. data/lib/solana/ruby/kit/rpc_parsed_types/vote_account.rb +1 -1
  45. data/lib/solana/ruby/kit/rpc_types/cluster_url.rb +1 -1
  46. data/lib/solana/ruby/kit/rpc_types/commitment.rb +1 -1
  47. data/lib/solana/ruby/kit/rpc_types/lamports.rb +1 -1
  48. data/lib/solana/ruby/kit/rpc_types/sol.rb +1 -1
  49. data/lib/solana/ruby/kit/signers/keypair_signer.rb +1 -1
  50. data/lib/solana/ruby/kit/subscribable/async_iterable.rb +1 -1
  51. data/lib/solana/ruby/kit/subscribable/bridge_store_to_async_iterable.rb +1 -1
  52. data/lib/solana/ruby/kit/subscribable/reactive_action_store.rb +1 -1
  53. data/lib/solana/ruby/kit/subscribable/reactive_stream_store.rb +1 -1
  54. data/lib/solana/ruby/kit/sysvars/clock.rb +1 -1
  55. data/lib/solana/ruby/kit/sysvars/epoch_schedule.rb +1 -1
  56. data/lib/solana/ruby/kit/sysvars/last_restart_slot.rb +1 -1
  57. data/lib/solana/ruby/kit/sysvars/rent.rb +1 -1
  58. data/lib/solana/ruby/kit/sysvars.rb +1 -1
  59. data/lib/solana/ruby/kit/transaction_confirmation.rb +1 -1
  60. data/lib/solana/ruby/kit/transaction_introspection/compiled_transaction_message.rb +8 -7
  61. data/lib/solana/ruby/kit/transaction_introspection/decode_rpc_transaction.rb +1 -1
  62. data/lib/solana/ruby/kit/transaction_introspection/get_inner_instructions.rb +1 -1
  63. data/lib/solana/ruby/kit/transaction_introspection/get_instructions.rb +1 -1
  64. data/lib/solana/ruby/kit/transaction_introspection/walk_instructions.rb +1 -1
  65. data/lib/solana/ruby/kit/transaction_messages/compute_budget.rb +1 -1
  66. data/lib/solana/ruby/kit/transaction_messages/resource_limit_validation.rb +1 -1
  67. data/lib/solana/ruby/kit/transaction_messages/transaction_message.rb +1 -1
  68. data/lib/solana/ruby/kit/transactions/compiler.rb +2 -2
  69. data/lib/solana/ruby/kit/transactions/transaction.rb +30 -10
  70. data/lib/solana/ruby/kit/version.rb +1 -1
  71. data/lib/solana/ruby/kit/wallet_standard.rb +10 -9
  72. data/solana-ruby-kit.gemspec +1 -1
  73. metadata +6 -6
  74. data/lib/solana/ruby/kit/instruction_plans/max_instructions.rb +0 -59
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba7d2659d83b01af6565c7af483364d61e61fcc9761cbca9d63d8d41f6b4d73a
4
- data.tar.gz: bd8402f845e9d8db5845be0e358676d00e308b2a0777afc1973e8814175e1c1c
3
+ metadata.gz: 874d4a76636d15a05ab0474d28d1ea069b1a8c1d50b18d9178a9f0639747fd92
4
+ data.tar.gz: 28cfc822396c2c7156daa6da853e25be997b4febbcbdec66e71aa687ad05c782
5
5
  SHA512:
6
- metadata.gz: 589ade790ebb62b97f9905e8fe98113efc93deb5a6fad37239d377ab427915838eaa3d461644d213944ee8b80796134b28d5a6da43529a3920bb5037940a20a1
7
- data.tar.gz: 3c03649c3b4314b93e7321f74b77150cd19bd775535b8431c14b678fdf35d3e7d4703fac43f341c3480ec5031b0c78bd0c0e3b7e385bb01f1822fc57fa5afaec
6
+ metadata.gz: ca743643e0257eadc50274add4ce4f843dcb19f4c63ca9a4c1b4e9ebf3310e02945eee1d6b5cfe2d91fb153452e8d03ea6a44b6becad3a210c8f92f04350c2f5
7
+ data.tar.gz: 162cdeb79aee32775b4e35bc1fb80be095e11cb65f15217c3b93076de5172c76181ed94bb26b7d3b16a7b65394a28676b352cca03cee16ecb62ac96ab218b188
data/README.md CHANGED
@@ -360,28 +360,148 @@ Binary encoder/decoder framework for Solana on-chain data.
360
360
  ```ruby
361
361
  Codecs = Solana::Ruby::Kit::Codecs
362
362
 
363
- # Numbers
364
- u8 = Codecs.u8
365
- u16 = Codecs.u16_le # little-endian (default for Solana)
366
- u32 = Codecs.u32_le
367
- u64 = Codecs.u64_le
368
- i8 = Codecs.i8
369
- f32 = Codecs.f32_le
370
-
371
- u16.encode(1000) # => "\xe8\x03"
372
- u16.decode("\xe8\x03") # => 1000
363
+ # Numbers. Every multi-byte codec takes `endian:`, defaulting to :little
364
+ # (which is what Solana uses on-chain).
365
+ u8 = Codecs.u8_codec
366
+ u16 = Codecs.u16_codec
367
+ u32 = Codecs.u32_codec
368
+ u64 = Codecs.u64_codec
369
+ u128 = Codecs.u128_codec
370
+ u256 = Codecs.u256_codec # 32 bytes
371
+ i8 = Codecs.i8_codec
372
+ i256 = Codecs.i256_codec # 32 bytes, two's complement
373
+ f32 = Codecs.f32_codec
374
+ be = Codecs.u16_codec(endian: :big)
375
+
376
+ u16.encode(1000) # => "\xe8\x03"
377
+ u16.decode("\xe8\x03") # => [1000, 2] # [value, bytes consumed]
373
378
 
374
379
  # Strings
375
- utf8 = Codecs.utf8
376
- bytes = Codecs.bytes_codec
380
+ utf8 = Codecs.utf8_codec
381
+ bytes = Codecs.bytes_codec(32)
382
+ # A borsh-style string: a u32 byte length, then that many UTF-8 bytes.
383
+ name = Codecs.add_codec_size_prefix(utf8, u32)
377
384
 
378
385
  # Data structures
379
386
  struct_codec = Codecs.struct_codec([
380
387
  ['amount', u64],
388
+ ['name', name],
381
389
  ['mint', bytes]
382
390
  ])
391
+ struct_codec.decode(struct_codec.encode({ amount: 5, name: 'gold', mint: "\x01".b * 32 }))
392
+ # => [{ amount: 5, name: "gold", mint: "\x01\x01..." }, 48]
393
+ ```
394
+
395
+ #### UTF-8 options
396
+
397
+ By default the UTF-8 codec substitutes `U+FFFD` for malformed input, strips a
398
+ leading byte order mark, and strips null characters - the last of which is what
399
+ makes fixed-size padded strings read back cleanly, at the cost of being lossy
400
+ for strings that legitimately contain nulls.
401
+
402
+ ```ruby
403
+ # Reject invalid UTF-8 instead of substituting the replacement character.
404
+ strict = Codecs.utf8_codec(fatal: true)
405
+ strict.encode("a\xC0\x80") # raises SolanaError (invalid UTF-8 string)
406
+ strict.decode("\xFF".b) # raises SolanaError (invalid UTF-8 bytes)
407
+
408
+ # Decode losslessly, keeping null characters and a leading byte order mark.
409
+ lossless = Codecs.utf8_codec(ignore_bom: true, remove_null_characters: false)
410
+
411
+ # The assertions are usable on their own.
412
+ Codecs.find_malformed_utf8_sequence_offset("a\xC0\x80".b) # => 1 (overlong)
413
+ Codecs.assert_is_well_formed_utf8_bytes("\xE8\xAA\x9E".b) # passes ("語")
414
+ Codecs.remove_null_characters("a\x00b") # => "ab"
415
+ ```
416
+
417
+ Note that `ignore_bom` follows `TextDecoder`'s confusing spelling: the default,
418
+ `false`, *strips* the mark; `true` keeps it.
419
+
420
+ #### Collection sizes
421
+
422
+ `array_codec`, `map_codec` and `set_codec` take a `size:` that sets how the item
423
+ count is stored:
424
+
425
+ ```ruby
426
+ Codecs.array_codec(u8).encode([1, 2, 3]) # => "\x03\x00\x00\x00\x01\x02\x03" (u32 count)
427
+ Codecs.array_codec(u8, size: u8).encode([1, 2, 3]) # => "\x03\x01\x02\x03" (any number codec as the count)
428
+ Codecs.array_codec(u8, size: Codecs.compact_u16_codec).encode([1]) # => "\x01\x01" (Solana's shortU16)
429
+ Codecs.array_codec(u8, size: 3).encode([1, 2, 3]) # => "\x01\x02\x03" (fixed count, no prefix)
430
+ Codecs.array_codec(u8, size: 3).encode([1, 2]) # raises SolanaError (wrong number of items)
431
+ Codecs.array_codec(u8, size: :remainder).encode([1, 2, 3]) # => "\x01\x02\x03" (no prefix...)
432
+ Codecs.array_codec(u8, size: :remainder).decode("\x01\x02\x03".b) # => [[1, 2, 3], 3] (...reads to the end)
433
+ ```
434
+
435
+ A `:remainder` array must be the last thing in the buffer. Upstream spells it
436
+ `'remainder'`; Ruby uses the Symbol. The fifth option, a `Codecs::SentinelSize`,
437
+ is described below.
438
+
439
+ #### Requiring a size prefix
440
+
441
+ A prefixed collection decodes an exhausted buffer to an empty collection rather
442
+ than failing, so a program can append a collection to an existing account layout
443
+ and still read accounts written before the change. Formats that cannot accept
444
+ that leniency - borsh requires the prefix to be present - can opt into failing:
445
+
446
+ ```ruby
447
+ Codecs.array_codec(u8).decode(''.b) # => [[], 0]
448
+ Codecs.array_codec(u8, require_size_prefix: true).decode(''.b) # raises SolanaError
449
+ ```
450
+
451
+ `map_codec` and `set_codec` take the same option, and it applies to a custom
452
+ count codec too. It has no effect on fixed-count, `:remainder` or
453
+ sentinel-terminated collections, none of which carries a prefix.
454
+
455
+ #### Sentinel-terminated collections
456
+
457
+ Instead of a length prefix, `size:` can be a `Codecs::SentinelSize`: the
458
+ collection ends where the bytes at the next item position match the sentinel.
459
+ It is compared at item boundaries only, so its bytes may appear *inside* an item.
460
+
461
+ ```ruby
462
+ zero = Codecs::SentinelSize.new(sentinel: "\x00".b)
463
+ list = Codecs.array_codec(u8, size: zero)
464
+ list.encode([1, 2, 3]) # => "\x01\x02\x03\x00"
465
+ list.decode("\x01\x02\x03\x00".b) # => [[1, 2, 3], 4]
466
+ list.decode("\x01\x02".b) # raises SolanaError (sentinel missing)
467
+
468
+ # :optional still writes the sentinel but tolerates its absence on decode;
469
+ # :omitted never writes it. Both stop at the end of the bytes.
470
+ lenient = Codecs.array_codec(u8, size: Codecs::SentinelSize.new(sentinel: "\x00".b, strategy: :optional))
471
+ lenient.decode("\x01\x02".b) # => [[1, 2], 2]
383
472
  ```
384
473
 
474
+ `map_codec` and `set_codec` accept the same `size:`. The codec does **not**
475
+ check two invariants, so it is up to you to make sure they hold. First, no item
476
+ may *begin* with the sentinel's bytes, or decoding stops early at that item.
477
+ Second, under `:optional` / `:omitted` the sentinel must be no wider than the
478
+ smallest item, or a short trailing item is silently dropped.
479
+
480
+ #### Tap combinators
481
+
482
+ Wrap a codec in one that observes a value - or the bytes - without changing it,
483
+ for validation guards or logging. A tap that raises aborts the operation.
484
+
485
+ ```ruby
486
+ guarded = Codecs.tap_encoder(u8.encoder) do |value|
487
+ raise ArgumentError, 'Value must not exceed 100' if value > 100
488
+ end
489
+ guarded.encode(42) # => "\x2a"
490
+ guarded.encode(200) # raises ArgumentError
491
+
492
+ # Both sides at once; decode_tap is optional.
493
+ Codecs.tap_codec(u8, encode_tap: ->(v) { log(v) }, decode_tap: ->(v) { log(v) })
494
+
495
+ # Observe the bytes instead of the value.
496
+ Codecs.tap_decoder_bytes(u8.decoder) do |bytes, offset|
497
+ raise ArgumentError, 'Expected a 0 or a 1' if bytes.bytes[offset] > 1
498
+ end
499
+ ```
500
+
501
+ Upstream's byte taps take `(bytes, pre_offset, post_offset)` because encoders
502
+ there write into a shared buffer. A Ruby `Encoder` returns a standalone byte
503
+ String, so the encode-side taps receive exactly the bytes that were written.
504
+
385
505
  ### `Solana::Ruby::Kit::RpcSubscriptions` — `@solana/rpc-subscriptions`
386
506
 
387
507
  WebSocket-based subscription client.
@@ -675,6 +795,38 @@ Plans.get_linear_message_packer_instruction_plan(
675
795
  total_length: data.bytesize,
676
796
  get_instruction: ->(offset, length) { build_write_ix(offset, data[offset, length]) }
677
797
  )
798
+
799
+ # ── 5. Custom message packers ────────────────────────────────────────────────
800
+ # A custom packer enforces the same limits the built-in ones do, and can refuse a
801
+ # message for a reason of its own with MESSAGE_REJECTED_BY_PACKER. The planner
802
+ # treats all three errors as "try another message"
803
+ # (Plans.message_packer_error_that_requires_new_candidate?).
804
+ one_per_message = Plans::MessagePackerInstructionPlan.new(
805
+ get_message_packer: -> {
806
+ remaining = [ix1, ix2]
807
+ Plans::MessagePacker.new(
808
+ done_proc: -> { remaining.empty? },
809
+ pack_proc: ->(message, max_instructions) {
810
+ max = Plans.resolve_max_instructions_per_transaction(max_instructions)
811
+ Plans.assert_max_instructions_per_transaction(message.instructions.length + 1, max)
812
+ unless message.instructions.empty?
813
+ raise Kit::SolanaError.new(
814
+ Kit::SolanaError::INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER,
815
+ { reason: 'these instructions must each have a transaction to themselves' }
816
+ )
817
+ end
818
+ next_message = Kit::TransactionMessages.append_instructions(message, [remaining.first])
819
+ Plans.assert_message_can_accommodate_size(
820
+ current_size: Kit::Transactions.get_transaction_message_size(message),
821
+ next_size: Kit::Transactions.get_transaction_message_size(next_message),
822
+ size_limit: Kit::Transactions::TRANSACTION_SIZE_LIMIT
823
+ )
824
+ remaining.shift
825
+ next_message
826
+ }
827
+ )
828
+ }
829
+ )
678
830
  ```
679
831
 
680
832
  ### `Solana::Ruby::Kit::WalletStandard` — `@solana/wallet-standard`
@@ -28,7 +28,7 @@ module Solana::Ruby::Kit
28
28
  const :space, T.nilable(Integer)
29
29
  end
30
30
 
31
- module_function
31
+ extend self
32
32
 
33
33
  # Builds a MaybeAccount representing a found account.
34
34
  sig { params(account: Account).returns(MaybeAccount) }
@@ -61,7 +61,7 @@ module Solana::Ruby::Kit
61
61
  ADDRESS_MIN_STR_LEN = T.let(32, Integer)
62
62
  ADDRESS_MAX_STR_LEN = T.let(44, Integer)
63
63
 
64
- module_function
64
+ extend self
65
65
 
66
66
  # Encodes raw bytes (binary String, length == 32) to a base58 address string.
67
67
  # Mirrors `getAddressEncoder()` in TypeScript.
@@ -27,7 +27,7 @@ module Solana::Ruby::Kit
27
27
  # sqrt(−1) mod p = 2^((p−1)/4) mod p
28
28
  CURVE_SQRT_M1 = T.let(2.pow((CURVE_P - 1) / 4, CURVE_P), Integer)
29
29
 
30
- module_function
30
+ extend self
31
31
 
32
32
  # Returns true if the 32-byte binary string represents a point on the
33
33
  # Ed25519 twisted-Edwards curve.
@@ -38,7 +38,7 @@ module Solana::Ruby::Kit
38
38
  # Marker bytes appended during hashing: UTF-8 "ProgramDerivedAddress".
39
39
  PDA_MARKER_BYTES = T.let('ProgramDerivedAddress'.b, String)
40
40
 
41
- module_function
41
+ extend self
42
42
 
43
43
  # Returns true if the value is a well-formed ProgramDerivedAddress.
44
44
  # Mirrors `isProgramDerivedAddress()` in TypeScript.
@@ -8,7 +8,7 @@ require_relative '../errors'
8
8
  module Solana::Ruby::Kit
9
9
  module Addresses
10
10
  extend T::Sig
11
- module_function
11
+ extend self
12
12
 
13
13
  # Given an RbNaCl::VerifyKey (Ed25519 public key), returns its Solana Address.
14
14
  #
@@ -64,7 +64,7 @@ module Solana::Ruby::Kit
64
64
 
65
65
  # ── Combinators ─────────────────────────────────────────────────────────────
66
66
 
67
- module_function
67
+ extend self
68
68
 
69
69
  # Return a Codec whose output is always exactly +size+ bytes
70
70
  # (zero-padded on right, truncated if too large).
@@ -88,17 +88,33 @@ module Solana::Ruby::Kit
88
88
 
89
89
  # Prefix encoded data with its byte length using +prefix_codec+
90
90
  # (typically a u32 little-endian codec).
91
+ #
92
+ # On decode the inner codec sees exactly the prefixed number of bytes, as
93
+ # upstream's `addDecoderSizePrefix` does - so a variable-size inner codec
94
+ # such as +utf8_codec+ stops at its own end instead of reading the rest of
95
+ # the buffer. Fewer bytes than the prefix promises raise
96
+ # +CODECS__INVALID_BYTE_LENGTH+.
91
97
  sig { params(codec: Codec, prefix_codec: Codec).returns(Codec) }
92
98
  def add_codec_size_prefix(codec, prefix_codec)
93
- enc = Encoder.new do |value|
99
+ inner_size = codec.fixed_size
100
+ fixed = inner_size && prefix_codec.fixed_size && (inner_size + T.must(prefix_codec.fixed_size))
101
+ enc = Encoder.new(fixed_size: fixed) do |value|
94
102
  data = codec.encode(value)
95
103
  prefix = prefix_codec.encode(data.bytesize)
96
104
  prefix + data
97
105
  end
98
- dec = Decoder.new do |bytes, offset|
106
+ dec = Decoder.new(fixed_size: fixed) do |bytes, offset|
99
107
  len, prefix_size = prefix_codec.decode(bytes, offset: offset)
100
- value, data_size = codec.decode(bytes, offset: offset + prefix_size)
101
- [value, prefix_size + data_size]
108
+ len = Kernel.Integer(len)
109
+ data = bytes.byteslice(offset + prefix_size, len) || ''.b
110
+ if data.bytesize < len
111
+ Kernel.raise SolanaError.new(
112
+ SolanaError::CODECS__INVALID_BYTE_LENGTH,
113
+ { codec_description: 'addDecoderSizePrefix', expected: len, actual: data.bytesize }
114
+ )
115
+ end
116
+ value, = codec.decode(data)
117
+ [value, prefix_size + len]
102
118
  end
103
119
  Codec.new(enc, dec)
104
120
  end
@@ -117,6 +133,100 @@ module Solana::Ruby::Kit
117
133
  Codec.new(enc, dec)
118
134
  end
119
135
 
136
+ # ── Tap combinators ─────────────────────────────────────────────────────────
137
+ #
138
+ # Each of these wraps a codec in one that observes a value (or the bytes)
139
+ # without changing it, for validation guards, logging or other read-only
140
+ # side effects. A tap that raises aborts the operation and the error
141
+ # propagates to the caller.
142
+ #
143
+ # Where upstream's byte taps take +(bytes, pre_offset, post_offset)+ over a
144
+ # shared output buffer, a Ruby Encoder returns a standalone byte String, so
145
+ # the encode-side taps below receive exactly the bytes that were written -
146
+ # the window upstream's tap has to slice out for itself. The decode-side
147
+ # taps keep +(bytes, offset)+, which Ruby Decoders already receive.
148
+
149
+ # Observe each value before it is encoded, leaving it unchanged.
150
+ sig do
151
+ params(encoder: Encoder, tap_fn: T.proc.params(value: T.untyped).void).returns(Encoder)
152
+ end
153
+ def tap_encoder(encoder, &tap_fn)
154
+ Encoder.new(fixed_size: encoder.fixed_size, max_size: encoder.max_size) do |value|
155
+ tap_fn.call(value)
156
+ encoder.encode(value)
157
+ end
158
+ end
159
+
160
+ # Observe each decoded value after it is decoded, leaving it unchanged.
161
+ sig do
162
+ params(decoder: Decoder, tap_fn: T.proc.params(value: T.untyped).void).returns(Decoder)
163
+ end
164
+ def tap_decoder(decoder, &tap_fn)
165
+ Decoder.new(fixed_size: decoder.fixed_size) do |bytes, offset|
166
+ value, consumed = decoder.decode(bytes, offset: offset)
167
+ tap_fn.call(value)
168
+ [value, consumed]
169
+ end
170
+ end
171
+
172
+ # Observe a codec's values on both sides. +decode_tap+ is optional.
173
+ sig do
174
+ params(
175
+ codec: Codec,
176
+ encode_tap: T.proc.params(value: T.untyped).void,
177
+ decode_tap: T.nilable(T.proc.params(value: T.untyped).void)
178
+ ).returns(Codec)
179
+ end
180
+ def tap_codec(codec, encode_tap:, decode_tap: nil)
181
+ enc = tap_encoder(codec.encoder) { |value| encode_tap.call(value) }
182
+ dec = decode_tap ? tap_decoder(codec.decoder) { |value| decode_tap.call(value) } : codec.decoder
183
+ Codec.new(enc, dec)
184
+ end
185
+
186
+ # Observe the bytes an encoder produced, after they are written.
187
+ sig do
188
+ params(encoder: Encoder, tap_fn: T.proc.params(bytes: String).void).returns(Encoder)
189
+ end
190
+ def tap_encoder_bytes(encoder, &tap_fn)
191
+ Encoder.new(fixed_size: encoder.fixed_size, max_size: encoder.max_size) do |value|
192
+ bytes = encoder.encode(value)
193
+ tap_fn.call(bytes)
194
+ bytes
195
+ end
196
+ end
197
+
198
+ # Observe the raw bytes a decoder is about to read, before decoding.
199
+ sig do
200
+ params(
201
+ decoder: Decoder,
202
+ tap_fn: T.proc.params(bytes: String, offset: Integer).void
203
+ ).returns(Decoder)
204
+ end
205
+ def tap_decoder_bytes(decoder, &tap_fn)
206
+ Decoder.new(fixed_size: decoder.fixed_size) do |bytes, offset|
207
+ tap_fn.call(bytes, offset)
208
+ decoder.decode(bytes, offset: offset)
209
+ end
210
+ end
211
+
212
+ # Observe a codec's raw bytes on both sides. +decode_tap+ is optional.
213
+ sig do
214
+ params(
215
+ codec: Codec,
216
+ encode_tap: T.proc.params(bytes: String).void,
217
+ decode_tap: T.nilable(T.proc.params(bytes: String, offset: Integer).void)
218
+ ).returns(Codec)
219
+ end
220
+ def tap_codec_bytes(codec, encode_tap:, decode_tap: nil)
221
+ enc = tap_encoder_bytes(codec.encoder) { |bytes| encode_tap.call(bytes) }
222
+ dec = if decode_tap
223
+ tap_decoder_bytes(codec.decoder) { |bytes, offset| decode_tap.call(bytes, offset) }
224
+ else
225
+ codec.decoder
226
+ end
227
+ Codec.new(enc, dec)
228
+ end
229
+
120
230
  # Reverse the byte order of the encoded output (and input).
121
231
  sig { params(codec: Codec).returns(Codec) }
122
232
  def reverse_codec(codec)