net-imap 0.6.3 → 0.6.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4249dc5d175bd3ae3a3b3b79e0f368e674ec96045510a8b504e829feefb54f3d
4
- data.tar.gz: 6adbee15b0303b36ec1c574991d4bdbf518bcd5621c6b668f05df0bc201ba50a
3
+ metadata.gz: 032600f694434b77edab2496c4bff9a7adc5c912301c0a7b386706dfccdc2345
4
+ data.tar.gz: 69dd23250cda6c1eb7a9bbeabe10969389c10047f12610d6bec3407d23e16854
5
5
  SHA512:
6
- metadata.gz: a20c230ac3977d9acc09bc964badc60acbedaca84246d1ce67787c78443cdea350ade6907e58b61a5c9f09bf3b12e5e57cd319ef82a096c3780f97dc7017ed31
7
- data.tar.gz: 68ab8b48664998bbf05d5367fea4e5b06e3ba3b2d4e48ba8ae026069060b23f7dafaf7ecf2681fc5aa4ff0f134e55d9399ca51456ff3a7ec718ba829629866b7
6
+ metadata.gz: 98ede1ee28c608ceea04bd5a00a114029da96eae454dc222cb046308c625241df423ceac7811322e6f641f3899ba1ca7a7d3f1bbfbf839632d2cf1a01c963c59
7
+ data.tar.gz: 810b3514de99b738216d7d49c917a05ee0c73211530bd81fe55540d158736dd7ba84f841d13f6d959b3dca01c3e32d792e500e7ee8f8eb5a2ab6899a53bf095e
data/.document ADDED
@@ -0,0 +1,3 @@
1
+ *.rdoc
2
+ *.md
3
+ lib
data/.rdoc_options ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ charset: UTF-8
3
+ main_page: README.md
4
+ markup: rdoc
5
+ title: net-imap # rake task override's title to add the version number
6
+ op_dir: doc
7
+ # vim:ft=yaml
data/Gemfile CHANGED
@@ -11,7 +11,7 @@ gem "psych", ">= 5.3.0" # 5.2.5 for Data serialization, 5.3.0 for TruffleRuby
11
11
 
12
12
  gem "irb"
13
13
  gem "rake"
14
- gem "rdoc"
14
+ gem "rdoc", ">= 7.2.0"
15
15
  gem "test-unit"
16
16
  gem "test-unit-ruby-core", git: "https://github.com/ruby/test-unit-ruby-core"
17
17
 
data/README.md CHANGED
@@ -61,9 +61,9 @@ imap.search(["BEFORE", "30-Apr-2003", "SINCE", "1-Apr-2003"]).each do |message_i
61
61
  else
62
62
  imap.copy(message_id, "Mail/sent-apr03")
63
63
  imap.store(message_id, "+FLAGS", [:Deleted])
64
+ imap.expunge
64
65
  end
65
66
  end
66
- imap.expunge
67
67
  ```
68
68
 
69
69
  ## Development
@@ -4,6 +4,8 @@ require "date"
4
4
 
5
5
  require_relative "errors"
6
6
 
7
+ # :enddoc:
8
+
7
9
  module Net
8
10
  class IMAP < Protocol
9
11
 
@@ -14,17 +16,19 @@ module Net
14
16
  when nil
15
17
  when String
16
18
  when Integer
17
- NumValidator.ensure_number(data)
19
+ # Covers modseq-valzer, which is the largest valid IMAP integer
20
+ if data.negative?
21
+ raise DataFormatError, "Integer argument must be unsigned: #{data}"
22
+ elsif 0xffff_ffff_ffff_ffff < data
23
+ raise DataFormatError, "Integer argument must fit in 64 bits: #{data}"
24
+ end
18
25
  when Array
19
- if data[0] == 'CHANGEDSINCE'
20
- NumValidator.ensure_mod_sequence_value(data[1])
21
- else
22
- data.each do |i|
23
- validate_data(i)
24
- end
26
+ data.each do |i|
27
+ validate_data(i)
25
28
  end
26
29
  when Time, Date, DateTime
27
30
  when Symbol
31
+ Flag.validate(data)
28
32
  else
29
33
  data.validate
30
34
  end
@@ -45,7 +49,7 @@ module Net
45
49
  when Date
46
50
  send_date_data(data)
47
51
  when Symbol
48
- send_symbol_data(data)
52
+ Flag[data].send_data(self, tag)
49
53
  else
50
54
  data.send_data(self, tag)
51
55
  end
@@ -73,13 +77,33 @@ module Net
73
77
  end
74
78
  end
75
79
 
76
- def send_quoted_string(str)
77
- put_string('"' + str.gsub(/["\\]/, "\\\\\\&") + '"')
78
- end
80
+ def send_quoted_string(str) = QuotedString.new(data: str).send_data(self)
79
81
 
80
- def send_literal(str, tag = nil)
82
+ def send_binary_literal(*, **) = send_literal(*, **, binary: true)
83
+
84
+ # `non_sync` is an optional tri-state flag:
85
+ # * `true` -> Force non-synchronizing `LITERAL+`/`LITERAL-` behavior.
86
+ # NOTE: raises DataFormatError when server doesn't support
87
+ # non-synchronizing literal, or literal is too large for LITERAL-.
88
+ # * `false` -> Force normal synchronizing literal behavior.
89
+ # * `nil` -> (default) Currently behaves like `false` (will be dynamic).
90
+ def send_literal(str, tag = nil, binary: false, non_sync: nil)
91
+ bytesize = str.bytesize
81
92
  synchronize do
82
- put_string("{" + str.bytesize.to_s + "}" + CRLF)
93
+ if non_sync && !non_sync_literal_allowed?(bytesize)
94
+ # TODO: check in Printer, so we don't need to close the connection.
95
+ @sock.close
96
+ raise DataFormatError, "Connection closed: " \
97
+ "Cannot send non-synchronizing literal without known server support"
98
+ end
99
+ non_sync = non_sync_literal?(bytesize) if non_sync.nil?
100
+ prefix = "~" if binary
101
+ plus = "+" if non_sync
102
+ put_string("#{prefix}{#{bytesize}#{plus}}\r\n")
103
+ if non_sync
104
+ put_string(str)
105
+ return
106
+ end
83
107
  @continued_command_tag = tag
84
108
  @continuation_request_exception = nil
85
109
  begin
@@ -94,8 +118,23 @@ module Net
94
118
  end
95
119
  end
96
120
 
121
+ def non_sync_literal?(bytesize)
122
+ bytesize <= config.max_non_synchronizing_literal \
123
+ && non_sync_literal_allowed?(bytesize)
124
+ end
125
+
126
+ def non_sync_literal_allowed?(bytesize)
127
+ return unless capabilities_cached?
128
+ return "+" if capable?("LITERAL+")
129
+ return "-" if capable_literal_minus? && bytesize <= 4096
130
+ false
131
+ end
132
+
133
+ def capable_literal_minus? = capable?("LITERAL-") || capable?("IMAP4rev2")
134
+
135
+ # NOTE: +num+ should already be an Integer
97
136
  def send_number_data(num)
98
- put_string(num.to_s)
137
+ put_string(Integer(num).to_s)
99
138
  end
100
139
 
101
140
  def send_list_data(list, tag = nil)
@@ -115,11 +154,13 @@ module Net
115
154
  def send_date_data(date) put_string Net::IMAP.encode_date(date) end
116
155
  def send_time_data(time) put_string Net::IMAP.encode_time(time) end
117
156
 
118
- def send_symbol_data(symbol)
119
- put_string("\\" + symbol.to_s)
120
- end
121
-
122
157
  CommandData = Data.define(:data) do # :nodoc:
158
+ def self.validate(...)
159
+ data = new(...)
160
+ data.validate
161
+ data
162
+ end
163
+
123
164
  def send_data(imap, tag)
124
165
  raise NoMethodError, "#{self.class} must implement #{__method__}"
125
166
  end
@@ -128,27 +169,176 @@ module Net
128
169
  end
129
170
  end
130
171
 
172
+ # Represents IMAP +text+ or +quoted+ data, which share the same
173
+ # validations of decoded #data, and differ only in how they are formatted.
174
+ #
175
+ # +data+ may contain any 7-bit ASCII character except +NULL+, +CR+, or +LF+.
176
+ # Any multibyte +UTF-8+ character is also allowed when the connection
177
+ # supports UTF8: either +UTF8=ACCEPT+ or +IMAP4rev2+ have been enabled, or
178
+ # the server supports only +IMAP4rev2+ and not earlier IMAP revisions, or
179
+ # the server advertises +UTF8=ONLY+.
180
+ #
181
+ # NOTE: This does not verify whether the connection supports UTF-8, but that
182
+ # may change in future versions.
183
+ #
184
+ # The string's bytes must be valid ASCII or valid UTF-8. The string's
185
+ # reported encoding is ignored, but the string is _not_ transcoded.
186
+ class ValidNonLiteralData < CommandData
187
+ def initialize(data:)
188
+ data = String(data.to_str)
189
+ unless data.encoding in Encoding::ASCII | Encoding::UTF_8
190
+ data = data.dup.force_encoding(data.ascii_only? ? "ASCII" : "UTF-8")
191
+ end
192
+ data = -data
193
+ super
194
+ validate
195
+ end
196
+
197
+ def validate
198
+ if !(data.encoding in Encoding::ASCII | Encoding::UTF_8)
199
+ raise DataFormatError, "must use ASCII or UTF-8 encoding"
200
+ elsif !data.valid_encoding?
201
+ raise DataFormatError, "invalid UTF-8 must be literal encoded"
202
+ elsif data.include?("\0")
203
+ raise DataFormatError, "NULL byte must be binary literal encoded"
204
+ elsif /[\r\n]/.match?(data)
205
+ raise DataFormatError, "CR and LF bytes must be literal encoded"
206
+ end
207
+ end
208
+
209
+ def ascii_only? = data.ascii_only?
210
+
211
+ def send_data(imap, tag = nil) = imap.__send__(:put_string, formatted)
212
+ end
213
+
214
+ # Represents IMAP +text+ data, which covers everything in the IMAP grammar,
215
+ # except for +literal+, +literal8+, and the concluding +CRLF+.
216
+ #
217
+ # NOTE: The current implementation does not verify that the connection
218
+ # supports UTF-8. Future versions may validate this.
219
+ class RawText < ValidNonLiteralData # :nodoc:
220
+ # raw: no formatting necessary
221
+ alias formatted data
222
+ end
223
+
131
224
  class RawData < CommandData # :nodoc:
132
- def send_data(imap, tag)
133
- imap.__send__(:put_string, data)
225
+ def initialize(data:)
226
+ case data
227
+ in String then data = self.class.split(data)
228
+ in Array if data.all? { _1 in RawText | Literal }
229
+ else
230
+ raise TypeError, "expected String or Array[#{RawText} | #{Literal}]"
231
+ end
232
+ super
233
+ validate
134
234
  end
235
+
236
+ def send_data(imap, tag) = data.each do _1.send_data(imap, tag) end
237
+
238
+ def validate
239
+ return unless data.last in RawText(data: text)
240
+ if text.rindex(/\{\d+\+?\}\z/n)
241
+ raise DataFormatError, "RawData cannot end with literal continuation"
242
+ end
243
+ end
244
+
245
+ # Splits an input +string+ into an array of RawText and Literal/Literal8.
246
+ #
247
+ # NOTE: unlike RawData#validate, this does not prevent the final RawText
248
+ # from ending with a literal prefix.
249
+ def self.split(data)
250
+ data = data.b # dups and ensures BINARY encoding
251
+ parts = []
252
+ while data.match(/(~)?\{(0|[1-9]\d*)(\+)?\}\r\n/n)
253
+ text, binary, bytesize, non_sync, data = $`, !!$1, $2, !!$3, $'
254
+ bytesize = NumValidator.coerce_number64 bytesize
255
+ parts << RawText[text] unless text.empty?
256
+ parts << extract_literal(data, binary:, bytesize:, non_sync:)
257
+ data.bytesplice(0, bytesize, "")
258
+ end
259
+ parts << RawText[data] unless data.empty?
260
+ parts
261
+ end
262
+
263
+ def self.extract_literal(data, binary:, bytesize:, non_sync:)
264
+ if data.bytesize < bytesize
265
+ raise DataFormatError, "Too few bytes in string for literal, " \
266
+ "expected: %s, remaining: %s" % [bytesize, data.bytesize]
267
+ end
268
+ literal = data.byteslice(0, bytesize)
269
+ (binary ? Literal8 : Literal).new(data: literal, non_sync:)
270
+ end
271
+ private_class_method :extract_literal
135
272
  end
136
273
 
137
274
  class Atom < CommandData # :nodoc:
275
+ def initialize(**)
276
+ super
277
+ validate
278
+ end
279
+
280
+ def validate
281
+ data.to_s.ascii_only? \
282
+ or raise DataFormatError, "#{self.class} must be ASCII only"
283
+ data.match?(ResponseParser::Patterns::ATOM_SPECIALS) \
284
+ and raise DataFormatError, "#{self.class} must not contain atom-specials"
285
+ data.empty? \
286
+ and raise DataFormatError, "#{self.class} must not be empty"
287
+ end
288
+
138
289
  def send_data(imap, tag)
139
- imap.__send__(:put_string, data)
290
+ imap.__send__(:put_string, data.to_s)
140
291
  end
141
292
  end
142
293
 
143
- class QuotedString < CommandData # :nodoc:
294
+ class Flag < Atom # :nodoc:
144
295
  def send_data(imap, tag)
145
- imap.__send__(:send_quoted_string, data)
296
+ imap.__send__(:put_string, "\\#{data}")
146
297
  end
147
298
  end
148
299
 
149
- class Literal < CommandData # :nodoc:
300
+ # Represents a IMAP +quoted+ string, which can encode any valid ASCII or
301
+ # UTF-8 string, unless it contains any +CR+, +LF+, or +NULL+ bytes.
302
+ #
303
+ # NOTE: The current implementation does not verify that the connection
304
+ # supports UTF-8. Future versions may validate this.
305
+ class QuotedString < ValidNonLiteralData # :nodoc:
306
+ def formatted = %("#{data.gsub(/["\\]/, "\\\\\\&")}")
307
+ end
308
+
309
+ class Literal < Data.define(:data, :non_sync) # :nodoc:
310
+ def self.validate(...)
311
+ data = new(...)
312
+ data.validate
313
+ data
314
+ end
315
+
316
+ def initialize(data:, non_sync: nil)
317
+ data = -String(data.to_str).b or
318
+ raise DataFormatError, "#{self.class} expects string input"
319
+ super
320
+ validate
321
+ end
322
+
323
+ def bytesize = data.bytesize
324
+
325
+ def validate
326
+ if data.include?("\0")
327
+ raise DataFormatError, "NULL byte not allowed in #{self.class}. " \
328
+ "Use #{Literal8} or a null-safe encoding."
329
+ end
330
+ end
331
+
150
332
  def send_data(imap, tag)
151
- imap.__send__(:send_literal, data, tag)
333
+ imap.__send__(:send_literal, data, tag, non_sync:)
334
+ end
335
+ end
336
+
337
+ class Literal8 < Literal # :nodoc:
338
+ def validate = nil # all bytes are okay
339
+
340
+ def send_data(imap, tag)
341
+ imap.__send__(:send_binary_literal, data, tag, non_sync:)
152
342
  end
153
343
  end
154
344
 
@@ -221,6 +411,14 @@ module Net
221
411
 
222
412
  module_function
223
413
 
414
+ def literal_or_literal8(input, name: "argument")
415
+ return input if input in Literal | Literal8
416
+ data = String.try_convert(input) \
417
+ or raise TypeError, "expected #{name} to be String, got #{input.class}"
418
+ type = data.include?("\0") ? Literal8 : Literal
419
+ type.new(data:)
420
+ end
421
+
224
422
  # Allows symbols in addition to strings
225
423
  def valid_string?(str)
226
424
  str.is_a?(Symbol) || str.respond_to?(:to_str)
@@ -281,6 +281,40 @@ module Net
281
281
  0.5r => true,
282
282
  }
283
283
 
284
+ # The maximum bytesize for sending non-synchronizing literals, when the
285
+ # server supports them. To disable non-synchronizing literals, set the
286
+ # value to +-1+.
287
+ #
288
+ # Non-synchronizing literals are only sent when the server's
289
+ # capabilities[rdoc-ref:IMAP#capabilities] have been
290
+ # cached[rdoc-ref:IMAP#capabilities_cached?] and include either
291
+ # <tt>LITERAL+</tt> [RFC7888[https://www.rfc-editor.org/rfc/rfc7888]],
292
+ # <tt>LITERAL-</tt> [RFC7888[https://www.rfc-editor.org/rfc/rfc7888]], or
293
+ # +IMAP4rev2+ [RFC9051[https://www.rfc-editor.org/rfc/rfc9051]].
294
+ #
295
+ # For <tt>LITERAL+</tt>, this value is the only limit on whether a literal
296
+ # value is sent as non-synchronizing literals. For <tt>LITERAL-</tt> and
297
+ # <tt>IMAP4rev2</tt>, non-synchronizing literals must also be smaller than
298
+ # +4096+ bytes.
299
+ #
300
+ # Non-synchronizing literals avoid the latency of waiting for the server
301
+ # to allow continuation. However, if a client sends a non-synchronizing
302
+ # literal that is too large for the server, the server may need to close
303
+ # the connection. Because <tt>LITERAL+</tt> does not directly indicate
304
+ # the server's limits, it's best to avoid sending very large
305
+ # non-synchronized literals.
306
+ #
307
+ # ==== Versioned Defaults
308
+ #
309
+ # max_non_synchronizing_literal <em>was added in +v0.6.4+.</em>
310
+ #
311
+ # * original: +-1+ (_never_ send non-synchronizing literals)
312
+ # * +0.6+: 16 KiB
313
+ attr_accessor :max_non_synchronizing_literal, type: Integer, defaults: {
314
+ 0.0r => -1,
315
+ 0.6r => 16 << 16, # 16 KiB
316
+ }
317
+
284
318
  # The maximum allowed server response size. When +nil+, there is no limit
285
319
  # on response size.
286
320
  #
@@ -155,7 +155,8 @@ module Net
155
155
 
156
156
  # Common validators of number and nz_number types
157
157
  module NumValidator # :nodoc
158
- NUMBER_RE = /\A(?:0|[1-9]\d*)\z/
158
+ NUMBER_RE = /\A\d+\z/
159
+ NZ_NUMBER_RE = /\A[1-9]\d*\z/
159
160
  module_function
160
161
 
161
162
  # Check if argument is a valid 'number' according to RFC 3501
@@ -174,6 +175,22 @@ module Net
174
175
  0 < num && num <= 0xffff_ffff
175
176
  end
176
177
 
178
+ # Check if argument is a valid 'number64' according to RFC 9051
179
+ # number64 = 1*DIGIT
180
+ # ; Unsigned 63-bit integer
181
+ # ; (0 <= n <= 9,223,372,036,854,775,807)
182
+ def valid_number64?(num)
183
+ 0 <= num && num <= 0x7fff_ffff_ffff_ffff
184
+ end
185
+
186
+ # Check if argument is a valid 'number64' according to RFC 9051
187
+ # nz-number64 = digit-nz *DIGIT
188
+ # ; Unsigned 63-bit integer
189
+ # ; (0 < n <= 9,223,372,036,854,775,807)
190
+ def valid_nz_number64?(num)
191
+ 0 < num && num <= 0x7fff_ffff_ffff_ffff
192
+ end
193
+
177
194
  # Check if argument is a valid 'mod-sequence-value' according to RFC 4551
178
195
  # mod-sequence-value = 1*DIGIT
179
196
  # ; Positive unsigned 64-bit integer
@@ -203,6 +220,20 @@ module Net
203
220
  "nz-number must be non-zero unsigned 32-bit integer: #{num}"
204
221
  end
205
222
 
223
+ # Ensure argument is 'number64' or raise DataFormatError
224
+ def ensure_number64(num)
225
+ return num if valid_number64?(num)
226
+ raise DataFormatError,
227
+ "number64 must be unsigned 63-bit integer: #{num}"
228
+ end
229
+
230
+ # Ensure argument is 'nz-number64' or raise DataFormatError
231
+ def ensure_nz_number64(num)
232
+ return num if valid_nz_number64?(num)
233
+ raise DataFormatError,
234
+ "nz-number64 must be non-zero unsigned 63-bit integer: #{num}"
235
+ end
236
+
206
237
  # Ensure argument is 'mod-sequence-value' or raise DataFormatError
207
238
  def ensure_mod_sequence_value(num)
208
239
  return num if valid_mod_sequence_value?(num)
@@ -221,7 +252,7 @@ module Net
221
252
  def coerce_number(num)
222
253
  case num
223
254
  when Integer then ensure_number num
224
- when NUMBER_RE then ensure_number Integer num
255
+ when NUMBER_RE then ensure_number num.to_i
225
256
  else
226
257
  raise DataFormatError, "%p is not a valid number" % [num]
227
258
  end
@@ -230,18 +261,38 @@ module Net
230
261
  # Like #ensure_nz_number, but usable with numeric String input.
231
262
  def coerce_nz_number(num)
232
263
  case num
233
- when Integer then ensure_nz_number num
234
- when NUMBER_RE then ensure_nz_number Integer num
264
+ when Integer then ensure_nz_number num
265
+ when NZ_NUMBER_RE then ensure_nz_number num.to_i
235
266
  else
236
267
  raise DataFormatError, "%p is not a valid nz-number" % [num]
237
268
  end
238
269
  end
239
270
 
271
+ # Like #ensure_number64, but usable with numeric String input.
272
+ def coerce_number64(num)
273
+ case num
274
+ when Integer then ensure_number64 num
275
+ when NUMBER_RE then ensure_number64 num.to_i
276
+ else
277
+ raise DataFormatError, "%p is not a valid number64" % [num]
278
+ end
279
+ end
280
+
281
+ # Like #ensure_nz_number64, but usable with numeric String input.
282
+ def coerce_nz_number64(num)
283
+ case num
284
+ when Integer then ensure_nz_number64 num
285
+ when NZ_NUMBER_RE then ensure_nz_number64 num.to_i
286
+ else
287
+ raise DataFormatError, "%p is not a valid nz-number64" % [num]
288
+ end
289
+ end
290
+
240
291
  # Like #ensure_mod_sequence_value, but usable with numeric String input.
241
292
  def coerce_mod_sequence_value(num)
242
293
  case num
243
294
  when Integer then ensure_mod_sequence_value num
244
- when NUMBER_RE then ensure_mod_sequence_value Integer num
295
+ when NUMBER_RE then ensure_mod_sequence_value num.to_i
245
296
  else
246
297
  raise DataFormatError, "%p is not a valid mod-sequence-value" % [num]
247
298
  end
@@ -251,7 +302,7 @@ module Net
251
302
  def coerce_mod_sequence_valzer(num)
252
303
  case num
253
304
  when Integer then ensure_mod_sequence_valzer num
254
- when NUMBER_RE then ensure_mod_sequence_valzer Integer num
305
+ when NUMBER_RE then ensure_mod_sequence_valzer num.to_i
255
306
  else
256
307
  raise DataFormatError, "%p is not a valid mod-sequence-valzer" % [num]
257
308
  end
@@ -172,18 +172,11 @@ module Net
172
172
  ]
173
173
  end
174
174
  if parser_backtrace
175
- backtrace_locations&.each_with_index do |loc, idx|
176
- next if loc.base_label.include? "parse_error"
177
- break if loc.base_label == "parse"
178
- if loc.label.include?("#") # => Class#method, since ruby 3.4
179
- next unless loc.label&.include?(parser_class.name)
180
- else
181
- next unless loc.path&.include?("net/imap/response_parser")
182
- end
175
+ normalized_parser_backtrace.each do |idx, path, lineno, label, base_label|
183
176
  msg << "\n %s: %s (%s:%d)" % [
184
177
  hl["%{key}caller[%{/key}%{idx}%%2d%{/idx}%{key}]%{/key}"] % idx,
185
- hl["%{label}%%-30s%{/label}"] % loc.base_label,
186
- File.basename(loc.path, ".rb"), loc.lineno
178
+ hl["%{label}%%-30s%{/label}"] % base_label,
179
+ File.basename(path, ".rb"), lineno
187
180
  ]
188
181
  end
189
182
  end
@@ -198,12 +191,56 @@ module Net
198
191
  def processed_string = string && pos && string[...pos]
199
192
  def remaining_string = string && pos && string[pos..]
200
193
 
194
+ # Returns true when all attributes are equal, except for #backtrace and
195
+ # #backtrace_locations which are replaced with #parser_methods. This
196
+ # allows deserialized errors to be compared.
197
+ def ==(other)
198
+ return false if self.class != other.class
199
+ methods = parser_methods
200
+ other_methods = other.parser_methods
201
+ message == other.message &&
202
+ methods == other_methods &&
203
+ string == other.string &&
204
+ pos == other.pos &&
205
+ lex_state == other.lex_state &&
206
+ token == other.token
207
+ end
208
+
209
+ # Lists the methods (from #backtrace_locations or #backtrace) called on
210
+ # parser_class (since ruby 3.4) or which have "net/imap/response_parser"
211
+ # in the path (before ruby 3.4). Most parser method names are based on
212
+ # rules in the IMAP grammar.
213
+ def parser_methods = normalized_parser_backtrace.map(&:last)
214
+
201
215
  private
202
216
 
217
+ def normalized_parser_backtrace
218
+ normalize_backtrace
219
+ .take_while {|_, _, _, _, base_label| base_label != "parse" }
220
+ .reject {|_, _, _, _, base_label| base_label.nil? }
221
+ .reject {|_, _, _, _, base_label| base_label.include? "parse_error" }
222
+ .select {|_, path, _, label, _|
223
+ if label.include?("#") # => Class#method, since ruby 3.4
224
+ label.include?(parser_class.name)
225
+ else
226
+ path.include?("net/imap/response_parser")
227
+ end
228
+ }
229
+ end
230
+
231
+ def normalize_backtrace
232
+ (backtrace_locations&.each_with_index&.map {|loc, idx|
233
+ [idx, loc.path, loc.lineno, loc.label, loc.base_label]
234
+ } || backtrace&.each_with_index&.map {|bt, idx|
235
+ [idx, *bt.match(/\A(\S+):(\d+):in [`'](.*?([\w]+[?!]?))'\z/)&.captures]
236
+ } || [])
237
+ end
238
+
203
239
  def default_highlight_from_env
204
240
  (ENV["FORCE_COLOR"] || "") !~ /\A(?:0|)\z/ ||
205
241
  (ENV["TERM"] || "") !~ /\A(?:dumb|unknown|)\z/i
206
242
  end
243
+
207
244
  end
208
245
 
209
246
  # Superclass of all errors used to encapsulate "fail" responses