diff --git a/lib/net/imap/response_data.rb b/lib/net/imap/response_data.rb index 614288d2..daeb0957 100644 --- a/lib/net/imap/response_data.rb +++ b/lib/net/imap/response_data.rb @@ -763,6 +763,19 @@ class ThreadMember < Struct.new(:seqno, :children) # # An array of Net::IMAP::ThreadMember objects for mail items that are # children of this in the thread. + + # Returns a SequenceSet containing #seqno and all #children's seqno, + # recursively. + def to_sequence_set + SequenceSet.new all_seqnos + end + + protected + + def all_seqnos(node = self) + [node.seqno].concat node.children.flat_map { _1.all_seqnos } + end + end # Net::IMAP::BodyStructure is included by all of the structs that can be diff --git a/lib/net/imap/response_parser.rb b/lib/net/imap/response_parser.rb index 23bec7ce..25baa087 100644 --- a/lib/net/imap/response_parser.rb +++ b/lib/net/imap/response_parser.rb @@ -464,7 +464,7 @@ def unescape_quoted(quoted) def sequence_set str = combine_adjacent(*SEQUENCE_SET_TOKENS) if Patterns::SEQUENCE_SET_STR.match?(str) - SequenceSet.new(str) + SequenceSet[str] else parse_error("unexpected atom %p, expected sequence-set", str) end diff --git a/lib/net/imap/sequence_set.rb b/lib/net/imap/sequence_set.rb index 9b533633..48b7eaf1 100644 --- a/lib/net/imap/sequence_set.rb +++ b/lib/net/imap/sequence_set.rb @@ -4,64 +4,1346 @@ module Net class IMAP ## - # An IMAP {sequence - # set}[https://www.rfc-editor.org/rfc/rfc9051.html#section-4.1.1], - # is a set of message sequence numbers or unique identifier numbers - # ("UIDs"). It contains numbers and ranges of numbers. The numbers are all - # non-zero unsigned 32-bit integers and one special value, *, that - # represents the largest value in the mailbox. - # - # *NOTE:* This SequenceSet class is currently a placeholder for unhandled - # extension data. All it does now is validate. It will be expanded to a - # full API in a future release. + # An \IMAP sequence set is a set of message sequence numbers or unique + # identifier numbers ("UIDs"). It contains numbers and ranges of numbers. + # The numbers are all non-zero unsigned 32-bit integers and one special + # value ("*") that represents the largest value in the mailbox. + # + # Certain types of \IMAP responses will contain a SequenceSet, for example + # the data for a "MODIFIED" ResponseCode. Some \IMAP commands may + # receive a SequenceSet as an argument, for example IMAP#search, IMAP#fetch, + # and IMAP#store. + # + # == EXPERIMENTAL API + # + # SequenceSet is currently experimental. Only two methods, ::[] and + # #valid_string, are considered stable. Although the API isn't expected to + # change much, any other methods may be removed or changed without + # deprecation. + # + # == Creating sequence sets + # + # SequenceSet.new with no arguments creates an empty sequence set. Note + # that an empty sequence set is invalid in the \IMAP grammar. + # + # set = Net::IMAP::SequenceSet.new + # set.empty? #=> true + # set.valid? #=> false + # set.valid_string #!> raises DataFormatError + # set << 1..10 + # set.empty? #=> false + # set.valid? #=> true + # set.valid_string #=> "1:10" + # + # SequenceSet.new may receive a single optional argument: a non-zero 32 bit + # unsigned integer, a range, a sequence-set formatted string, + # another sequence set, or an enumerable containing any of these. + # + # set = Net::IMAP::SequenceSet.new(1) + # set.valid_string #=> "1" + # set = Net::IMAP::SequenceSet.new(1..100) + # set.valid_string #=> "1:100" + # set = Net::IMAP::SequenceSet.new(1...100) + # set.valid_string #=> "1:99" + # set = Net::IMAP::SequenceSet.new([1, 2, 5..]) + # set.valid_string #=> "1:2,5:*" + # set = Net::IMAP::SequenceSet.new("1,2,3:7,5,6:10,2048,1024") + # set.valid_string #=> "1,2,3:7,5,6:10,2048,1024" + # set = Net::IMAP::SequenceSet.new(1, 2, 3..7, 5, 6..10, 2048, 1024) + # set.valid_string #=> "1:10,55,1024:2048" + # + # Use ::[] with one or more arguments to create a frozen SequenceSet. An + # invalid (empty) set cannot be created with ::[]. + # + # set = Net::IMAP::SequenceSet["1,2,3:7,5,6:10,2048,1024"] + # set.valid_string #=> "1,2,3:7,5,6:10,2048,1024" + # set = Net::IMAP::SequenceSet[1, 2, [3..7, 5], 6..10, 2048, 1024] + # set.valid_string #=> "1:10,55,1024:2048" + # + # == Using * + # + # \IMAP sequence sets may contain a special value "*", which + # represents the largest number in use. From +seq-number+ in + # {RFC9051 §9}[https://www.rfc-editor.org/rfc/rfc9051.html#section-9-5]: + # >>> + # In the case of message sequence numbers, it is the number of messages + # in a non-empty mailbox. In the case of unique identifiers, it is the + # unique identifier of the last message in the mailbox or, if the + # mailbox is empty, the mailbox's current UIDNEXT value. + # + # When creating a SequenceSet, * may be input as -1, + # "*", :*, an endless range, or a range ending in + # -1. When converting to #elements, #ranges, or #numbers, it will + # output as either :* or an endless range. For example: + # + # Net::IMAP::SequenceSet["1,3,*"].to_a #=> [1, 3, :*] + # Net::IMAP::SequenceSet["1,234:*"].to_a #=> [1, 234..] + # Net::IMAP::SequenceSet[1234..-1].to_a #=> [1234..] + # Net::IMAP::SequenceSet[1234..].to_a #=> [1234..] + # + # Net::IMAP::SequenceSet[1234..].to_s #=> "1234:*" + # Net::IMAP::SequenceSet[1234..-1].to_s #=> "1234:*" + # + # Use #limit to convert "*" to a maximum value. When a range + # includes "*", the maximum value will always be matched: + # + # Net::IMAP::SequenceSet["9999:*"].limit(max: 25) + # #=> Net::IMAP::SequenceSet["25"] + # + # === Surprising * behavior + # + # When a set includes *, some methods may have surprising behavior. + # + # For example, #complement treats * as its own number. This way, + # the #intersection of a set and its #complement will always be empty. + # This is not how an \IMAP server interprets the set: it will convert + # * to either the number of messages in the mailbox or +UIDNEXT+, + # as appropriate. And there _will_ be overlap between a set and its + # complement after #limit is applied to each: + # + # ~Net::IMAP::SequenceSet["*"] == Net::IMAP::SequenceSet[1..(2**32-1)] + # ~Net::IMAP::SequenceSet[1..5] == Net::IMAP::SequenceSet["6:*"] + # + # set = Net::IMAP::SequenceSet[1..5] + # (set & ~set).empty? => true + # + # (set.limit(max: 4) & (~set).limit(max: 4)).to_a => [4] + # + # When counting the number of numbers in a set, * will be counted + # _except_ when UINT32_MAX is also in the set: + # UINT32_MAX = 2**32 - 1 + # Net::IMAP::SequenceSet["*"].count => 1 + # Net::IMAP::SequenceSet[1..UINT32_MAX - 1, :*].count => UINT32_MAX + # + # Net::IMAP::SequenceSet["1:*"].count => UINT32_MAX + # Net::IMAP::SequenceSet[UINT32_MAX, :*].count => 1 + # Net::IMAP::SequenceSet[UINT32_MAX..].count => 1 + # + # == What's here? + # + # SequenceSet provides methods for: + # * {Creating a SequenceSet}[rdoc-ref:SequenceSet@Methods+for+Creating+a+SequenceSet] + # * {Comparing}[rdoc-ref:SequenceSet@Methods+for+Comparing] + # * {Querying}[rdoc-ref:SequenceSet@Methods+for+Querying] + # * {Iterating}[rdoc-ref:SequenceSet@Methods+for+Iterating] + # * {Set Operations}[rdoc-ref:SequenceSet@Methods+for+Set+Operations] + # * {Assigning}[rdoc-ref:SequenceSet@Methods+for+Assigning] + # * {Deleting}[rdoc-ref:SequenceSet@Methods+for+Deleting] + # * {IMAP String Formatting}[rdoc-ref:SequenceSet@Methods+for+IMAP+String+Formatting] + # + # === Methods for Creating a \SequenceSet + # * ::[]: Creates a validated frozen sequence set from one or more inputs. + # * ::new: Creates a new mutable sequence set, which may be empty (invalid). + # * ::try_convert: Calls +to_sequence_set+ on an object and verifies that + # the result is a SequenceSet. + # * ::empty: Returns a frozen empty (invalid) SequenceSet. + # * ::full: Returns a frozen SequenceSet containing every possible number. + # + # === Methods for Comparing + # + # Comparison to another \SequenceSet: + # - #==: Returns whether a given set contains the same numbers as +self+. + # - #eql?: Returns whether a given set uses the same #string as +self+. + # + # Comparison to objects which are convertible to \SequenceSet: + # - #===: + # Returns whether a given object is fully contained within +self+, or + # +nil+ if the object cannot be converted to a compatible type. + # - #cover? (aliased as #===): + # Returns whether a given object is fully contained within +self+. + # - #intersect?: + # Returns whether +self+ and a given object have any common elements. + # - #disjoint?: + # Returns whether +self+ and a given object have no common elements. + # + # === Methods for Querying + # These methods do not modify +self+. + # + # Set membership: + # - #include? (aliased as #member?): + # Returns whether a given object (nz-number, range, or *) is + # contained by the set. + # - #include_star?: Returns whether the set contains *. + # + # Minimum and maximum value elements: + # - #min: Returns the minimum number in the set. + # - #max: Returns the maximum number in the set. + # - #minmax: Returns the minimum and maximum numbers in the set. + # + # Accessing value by offset: + # - #[] (aliased as #slice): Returns the number or consecutive subset at a + # given offset or range of offsets. + # - #at: Returns the number at a given offset. + # - #find_index: Returns the given number's offset in the set + # + # Set cardinality: + # - #count (aliased as #size): Returns the count of numbers in the set. + # - #empty?: Returns whether the set has no members. \IMAP syntax does not + # allow empty sequence sets. + # - #valid?: Returns whether the set has any members. + # - #full?: Returns whether the set contains every possible value, including + # *. + # + # === Methods for Iterating + # + # - #each_element: Yields each number and range in the set and returns + # +self+. + # - #elements (aliased as #to_a): + # Returns an Array of every number and range in the set. + # - #each_range: + # Yields each element in the set as a Range and returns +self+. + # - #ranges: Returns an Array of every element in the set, converting + # numbers into ranges of a single value. + # - #each_number: Yields each number in the set and returns +self+. + # - #numbers: Returns an Array with every number in the set, expanding + # ranges into all of their contained numbers. + # - #to_set: Returns a Set containing all of the #numbers in the set. + # + # === Methods for \Set Operations + # These methods do not modify +self+. + # + # - #| (aliased as #union and #+): Returns a new set combining all members + # from +self+ with all members from the other object. + # - #& (aliased as #intersection): Returns a new set containing all members + # common to +self+ and the other object. + # - #- (aliased as #difference): Returns a copy of +self+ with all members + # in the other object removed. + # - #^ (aliased as #xor): Returns a new set containing all members from + # +self+ and the other object except those common to both. + # - #~ (aliased as #complement): Returns a new set containing all members + # that are not in +self+ + # - #limit: Returns a copy of +self+ which has replaced * with a + # given maximum value and removed all members over that maximum. + # + # === Methods for Assigning + # These methods add or replace elements in +self+. + # + # - #add (aliased as #<<): Adds a given object to the set; returns +self+. + # - #add?: If the given object is not an element in the set, adds it and + # returns +self+; otherwise, returns +nil+. + # - #merge: Merges multiple elements into the set; returns +self+. + # - #string=: Assigns a new #string value and replaces #elements to match. + # - #replace: Replaces the contents of the set with the contents + # of a given object. + # - #complement!: Replaces the contents of the set with its own #complement. + # + # === Methods for Deleting + # These methods remove elements from +self+. + # + # - #clear: Removes all elements in the set; returns +self+. + # - #delete: Removes a given object from the set; returns +self+. + # - #delete?: If the given object is an element in the set, removes it and + # returns it; otherwise, returns +nil+. + # - #delete_at: Removes the number at a given offset. + # - #slice!: Removes the number or consecutive numbers at a given offset or + # range of offsets. + # - #subtract: Removes each given object from the set; returns +self+. + # - #limit!: Replaces * with a given maximum value and removes all + # members over that maximum; returns +self+. + # + # === Methods for \IMAP String Formatting + # + # - #to_s: Returns the +sequence-set+ string, or an empty string when the + # set is empty. + # - #string: Returns the +sequence-set+ string, or nil when empty. + # - #valid_string: Returns the +sequence-set+ string, or raises + # DataFormatError when the set is empty. + # - #normalized_string: Returns a sequence-set string with its + # elements sorted and coalesced, or nil when the set is empty. + # - #normalize: Returns a new set with this set's normalized +sequence-set+ + # representation. + # - #normalize!: Updates #string to its normalized +sequence-set+ + # representation and returns +self+. + # class SequenceSet + # The largest possible non-zero unsigned 32-bit integer + UINT32_MAX = 2**32 - 1 + + # represents "*" internally, to simplify sorting (etc) + STAR_INT = UINT32_MAX + 1 + private_constant :STAR_INT + + # valid inputs for "*" + STARS = [:*, ?*, -1].freeze + private_constant :STAR_INT, :STARS + + COERCIBLE = ->{ _1.respond_to? :to_sequence_set } + ENUMABLE = ->{ _1.respond_to?(:each) && _1.respond_to?(:empty?) } + private_constant :COERCIBLE, :ENUMABLE + + class << self + + # :call-seq: + # SequenceSet[*values] -> valid frozen sequence set + # + # Returns a frozen SequenceSet, constructed from +values+. + # + # An empty SequenceSet is invalid and will raise a DataFormatError. + # + # Use ::new to create a mutable or empty SequenceSet. + def [](first, *rest) + if rest.empty? + if first.is_a?(SequenceSet) && set.frozen? && set.valid? + first + else + new(first).validate.freeze + end + else + new(first).merge(*rest).validate.freeze + end + end + + # :call-seq: + # SequenceSet.try_convert(obj) -> sequence set or nil + # + # If +obj+ is a SequenceSet, returns +obj+. If +obj+ responds_to + # +to_sequence_set+, calls +obj.to_sequence_set+ and returns the result. + # Otherwise returns +nil+. + # + # If +obj.to_sequence_set+ doesn't return a SequenceSet, an exception is + # raised. + def try_convert(obj) + return obj if obj.is_a?(SequenceSet) + return nil unless respond_to?(:to_sequence_set) + obj = obj.to_sequence_set + return obj if obj.is_a?(SequenceSet) + raise DataFormatError, "invalid object returned from to_sequence_set" + end + + # Returns a frozen empty set singleton. Note that valid \IMAP sequence + # sets cannot be empty, so this set is _invalid_. + def empty; EMPTY end + + # Returns a frozen full set singleton: "1:*" + def full; FULL end + + end + + # Create a new SequenceSet object from +input+, which may be another + # SequenceSet, an IMAP formatted +sequence-set+ string, a number, a + # range, :*, or an enumerable of these. + # + # Use ::[] to create a frozen (non-empty) SequenceSet. + def initialize(input = nil) input ? replace(input) : clear end + + # Removes all elements and returns self. + def clear; @tuples, @string = [], nil; self end - def self.[](str) new(str).freeze end + # Replace the contents of the set with the contents of +other+ and returns + # +self+. + # + # +other+ may be another SequenceSet, or it may be an IMAP +sequence-set+ + # string, a number, a range, *, or an enumerable of these. + def replace(other) + case other + when SequenceSet then initialize_dup(other) + when String then self.string = other + else clear; merge other + end + self + end + + # Returns the \IMAP +sequence-set+ string representation, or raises a + # DataFormatError when the set is empty. + # + # Use #string to return +nil+ or #to_s to return an empty string without + # error. + # + # Related: #string, #normalized_string, #to_s + def valid_string + raise DataFormatError, "empty sequence-set" if empty? + string + end + + # Returns the \IMAP +sequence-set+ string representation, or +nil+ when + # the set is empty. Note that an empty set is invalid in the \IMAP + # syntax. + # + # Use #valid_string to raise an exception when the set is empty, or #to_s + # to return an empty string. + # + # If the set was created from a single string, it is not normalized. If + # the set is updated the string will be normalized. + # + # Related: #valid_string, #normalized_string, #to_s + def string; @string ||= normalized_string if valid? end - def initialize(input) - @atom = -String.try_convert(input) - validate + # Assigns a new string to #string and resets #elements to match. It + # cannot be set to an empty string—assign +nil+ or use #clear instead. + # The string is validated but not normalized. + # + # Use #add or #merge to add a string to an existing set. + # + # Related: #replace, #clear + def string=(str) + if str.nil? + clear + else + str = String.try_convert(str) or raise ArgumentError, "not a string" + tuples = str_to_tuples str + @tuples, @string = [], -str + tuples_add tuples + end end - # Returns the IMAP string representation. In the IMAP grammar, - # +sequence-set+ is a subset of +atom+ which is a subset of +astring+. - attr_accessor :atom + # Returns the \IMAP +sequence-set+ string representation, or an empty + # string when the set is empty. Note that an empty set is invalid in the + # \IMAP syntax. + # + # Related: #valid_string, #normalized_string, #to_s + def to_s; string || "" end - # Returns #atom. In the IMAP grammar, +atom+ is a subset of +astring+. - alias astring atom + # Freezes and returns the set. A frozen SequenceSet is Ractor-safe. + def freeze + return self if frozen? + string + @tuples.each(&:freeze).freeze + super + end - # Returns the value of #atom - alias to_s atom + # :call-seq: self == other -> true or false + # + # Returns true when the other SequenceSet represents the same message + # identifiers. Encoding difference—such as order, overlaps, or + # duplicates—are ignored. + # + # Net::IMAP::SequenceSet["1:3"] == Net::IMAP::SequenceSet["1:3"] + # #=> true + # Net::IMAP::SequenceSet["1,2,3"] == Net::IMAP::SequenceSet["1:3"] + # #=> true + # Net::IMAP::SequenceSet["1,3"] == Net::IMAP::SequenceSet["3,1"] + # #=> true + # Net::IMAP::SequenceSet["9,1:*"] == Net::IMAP::SequenceSet["1:*"] + # #=> true + # + # Related: #eql?, #normalize + def ==(other) + self.class == other.class && + (to_s == other.to_s || tuples == other.tuples) + end - # Hash equality requires the same encoded #atom representation. + # :call-seq: eql?(other) -> true or false # - # Net::IMAP::SequenceSet["1:3"] .eql? Net::IMAP::SequenceSet["1:3"] # => true - # Net::IMAP::SequenceSet["1,2,3"].eql? Net::IMAP::SequenceSet["1:3"] # => false - # Net::IMAP::SequenceSet["1,3"] .eql? Net::IMAP::SequenceSet["3,1"] # => false - # Net::IMAP::SequenceSet["9,1:*"].eql? Net::IMAP::SequenceSet["1:*"] # => false + # Hash equality requires the same encoded #string representation. # - def eql?(other) self.class == other.class && atom == other.atom end - alias == eql? + # Net::IMAP::SequenceSet["1:3"] .eql? Net::IMAP::SequenceSet["1:3"] + # #=> true + # Net::IMAP::SequenceSet["1,2,3"].eql? Net::IMAP::SequenceSet["1:3"] + # #=> false + # Net::IMAP::SequenceSet["1,3"] .eql? Net::IMAP::SequenceSet["3,1"] + # #=> false + # Net::IMAP::SequenceSet["9,1:*"].eql? Net::IMAP::SequenceSet["1:*"] + # #=> false + # + # Related: #==, #normalize + def eql?(other) self.class == other.class && string == other.string end # See #eql? - def hash; [self.class. atom].hash end + def hash; [self.class, string].hash end + + # :call-seq: self === other -> true | false | nil + # + # Returns whether +other+ is contained within the set. Returns +nil+ if a + # StandardError is raised while converting +other+ to a comparable type. + # + # Related: #cover?, #include?, #include_star? + def ===(other) + cover?(other) + rescue + nil + end + + # :call-seq: cover?(other) -> true | false | nil + # + # Returns whether +other+ is contained within the set. +other+ may be any + # object that would be accepted by ::new. + # + # Related: #===, #include?, #include_star? + def cover?(other) input_to_tuples(other).none? { !include_tuple?(_1) } end + + # Returns +true+ when a given number or range is in +self+, and +false+ + # otherwise. Returns +false+ unless +number+ is an Integer, Range, or + # *. + # + # set = Net::IMAP::SequenceSet["5:10,100,111:115"] + # set.include? 1 #=> false + # set.include? 5..10 #=> true + # set.include? 11..20 #=> false + # set.include? 100 #=> true + # set.include? 6 #=> true, covered by "5:10" + # set.include? 4..9 #=> true, covered by "5:10" + # set.include? "4:9" #=> true, strings are parsed + # set.include? 4..9 #=> false, intersection is not sufficient + # set.include? "*" #=> false, use #limit to re-interpret "*" + # set.include? -1 #=> false, -1 is interpreted as "*" + # + # set = Net::IMAP::SequenceSet["5:10,100,111:*"] + # set.include? :* #=> true + # set.include? "*" #=> true + # set.include? -1 #=> true + # set.include? 200.. #=> true + # set.include? 100.. #=> false + # + # Related: #include_star?, #cover?, #=== + def include?(element) include_tuple? input_to_tuple element end + + alias member? include? + + # Returns +true+ when the set contains *. + def include_star?; @tuples.last&.last == STAR_INT end + + # Returns +true+ if the set and a given object have any common elements, + # +false+ otherwise. + # + # Net::IMAP::SequenceSet["5:10"].intersect? "7,9,11" #=> true + # Net::IMAP::SequenceSet["5:10"].intersect? "11:33" #=> false + # + # Related: #intersection, #disjoint? + def intersect?(other) + valid? && input_to_tuples(other).any? { intersect_tuple? _1 } + end + + # Returns +true+ if the set and a given object have no common elements, + # +false+ otherwise. + # + # Net::IMAP::SequenceSet["5:10"].disjoint? "7,9,11" #=> false + # Net::IMAP::SequenceSet["5:10"].disjoint? "11:33" #=> true + # + # Related: #intersection, #intersect? + def disjoint?(other) + empty? || input_to_tuples(other).none? { intersect_tuple? _1 } + end + + # :call-seq: max(star: :*) => integer or star or nil + # + # Returns the maximum value in +self+, +star+ when the set includes + # *, or +nil+ when the set is empty. + def max(star: :*) + (val = @tuples.last&.last) && val == STAR_INT ? star : val + end + + # :call-seq: min(star: :*) => integer or star or nil + # + # Returns the minimum value in +self+, +star+ when the only value in the + # set is *, or +nil+ when the set is empty. + def min(star: :*) + (val = @tuples.first&.first) && val == STAR_INT ? star : val + end + + # :call-seq: minmax(star: :*) => nil or [integer, integer or star] + # + # Returns a 2-element array containing the minimum and maximum numbers in + # +self+, or +nil+ when the set is empty. + def minmax(star: :*); [min(star: star), max(star: star)] unless empty? end + + # Returns false when the set is empty. + def valid?; !empty? end + + # Returns true if the set contains no elements + def empty?; @tuples.empty? end + + # Returns true if the set contains every possible element. + def full?; @tuples == [[1, STAR_INT]] end + + # :call-seq: + # self + other -> sequence set + # self | other -> sequence set + # union(other) -> sequence set + # + # Returns a new sequence set that has every number in the +other+ object + # added. + # + # +other+ may be any object that would be accepted by ::new: a non-zero 32 + # bit unsigned integer, range, sequence-set formatted string, + # another sequence set, or an enumerable containing any of these. + # + # Net::IMAP::SequenceSet["1:5"] | 2 | [4..6, 99] + # #=> Net::IMAP::SequenceSet["1:6,99"] + # + # Related: #add, #merge + def |(other) remain_frozen dup.merge other end + alias :+ :| + alias union :| + + # :call-seq: + # self - other -> sequence set + # difference(other) -> sequence set + # + # Returns a new sequence set built by duplicating this set and removing + # every number that appears in +other+. + # + # +other+ may be any object that would be accepted by ::new: a non-zero 32 + # bit unsigned integer, range, sequence-set formatted string, + # another sequence set, or an enumerable containing any of these. + # + # Net::IMAP::SequenceSet[1..5] - 2 - 4 - 6 + # #=> Net::IMAP::SequenceSet["1,3,5"] + # + # Related: #subtract + def -(other) remain_frozen dup.subtract other end + alias difference :- + + # :call-seq: + # self & other -> sequence set + # intersection(other) -> sequence set + # + # Returns a new sequence set containing only the numbers common to this + # set and +other+. + # + # +other+ may be any object that would be accepted by ::new: a non-zero 32 + # bit unsigned integer, range, sequence-set formatted string, + # another sequence set, or an enumerable containing any of these. + # + # Net::IMAP::SequenceSet[1..5] & [2, 4, 6] + # #=> Net::IMAP::SequenceSet["2,4"] + # + # (seqset & other) is equivalent to (seqset - ~other). + def &(other) + remain_frozen dup.subtract SequenceSet.new(other).complement! + end + alias intersection :& + + # :call-seq: + # self ^ other -> sequence set + # xor(other) -> sequence set + # + # Returns a new sequence set containing numbers that are exclusive between + # this set and +other+. + # + # +other+ may be any object that would be accepted by ::new: a non-zero 32 + # bit unsigned integer, range, sequence-set formatted string, + # another sequence set, or an enumerable containing any of these. + # + # Net::IMAP::SequenceSet[1..5] ^ [2, 4, 6] + # #=> Net::IMAP::SequenceSet["1,3,5:6"] + # + # (seqset ^ other) is equivalent to ((seqset | other) - + # (seqset & other)). + def ^(other) remain_frozen (self | other).subtract(self & other) end + alias xor :^ + + # :call-seq: + # ~ self -> sequence set + # complement -> sequence set + # + # Returns the complement of self, a SequenceSet which contains all numbers + # _except_ for those in this set. + # + # ~Net::IMAP::SequenceSet.full #=> Net::IMAP::SequenceSet.empty + # ~Net::IMAP::SequenceSet.empty #=> Net::IMAP::SequenceSet.full + # ~Net::IMAP::SequenceSet["1:5,100:222"] + # #=> Net::IMAP::SequenceSet["6:99,223:*"] + # ~Net::IMAP::SequenceSet["6:99,223:*"] + # #=> Net::IMAP::SequenceSet["1:5,100:222"] + # + # Related: #complement! + def ~; remain_frozen dup.complement! end + alias complement :~ + + # :call-seq: + # add(object) -> self + # self << other -> self + # + # Adds a range or number to the set and returns +self+. + # + # #string will be regenerated. Use #merge to add many elements at once. + # + # Related: #add?, #merge, #union + def add(object) + tuple_add input_to_tuple object + normalize! + end + alias << add + + # :call-seq: add?(object) -> self or nil + # + # Adds a range or number to the set and returns +self+. Returns +nil+ + # when the object is already included in the set. + # + # #string will be regenerated. Use #merge to add many elements at once. + # + # Related: #add, #merge, #union, #include? + def add?(object) + add object unless include? object + end + + # :call-seq: delete(object) -> self + # + # Deletes the given range or number from the set and returns +self+. + # + # #string will be regenerated after deletion. Use #subtract to remove + # many elements at once. + # + # Related: #delete?, #delete_at, #subtract, #difference + def delete(object) + tuple_subtract input_to_tuple object + normalize! + end + + # :call-seq: + # delete?(number) -> integer or nil + # delete?(star) -> :* or nil + # delete?(range) -> sequence set or nil + # + # Removes a specified value from the set, and returns the removed value. + # Returns +nil+ if nothing was removed. + # + # Returns an integer when the specified +number+ argument was removed: + # set = Net::IMAP::SequenceSet.new [5..10, 20] + # set.delete?(7) #=> 7 + # set #=> # + # set.delete?("20") #=> 20 + # set #=> # + # set.delete?(30) #=> nil + # + # Returns :* when * or -1 is specified and + # removed: + # set = Net::IMAP::SequenceSet.new "5:9,20,35,*" + # set.delete?(-1) #=> :* + # set #=> # + # + # And returns a new SequenceSet when a range is specified: + # + # set = Net::IMAP::SequenceSet.new [5..10, 20] + # set.delete?(9..) #=> # + # set #=> # + # set.delete?(21..) #=> nil + # + # #string will be regenerated after deletion. + # + # Related: #delete, #delete_at, #subtract, #difference, #disjoint? + def delete?(object) + tuple = input_to_tuple object + if tuple.first == tuple.last + return unless include_tuple? tuple + tuple_subtract tuple + normalize! + from_tuple_int tuple.first + else + copy = dup + tuple_subtract tuple + normalize! + copy if copy.subtract(self).valid? + end + end + + # :call-seq: delete_at(index) -> number or :* or nil + # + # Deletes a number the set, indicated by the given +index+. Returns the + # number that was removed, or +nil+ if nothing was removed. + # + # #string will be regenerated after deletion. + # + # Related: #delete, #delete?, #slice!, #subtract, #difference + def delete_at(index) + slice! Integer(index.to_int) + end + + # :call-seq: + # slice!(index) -> integer or :* or nil + # slice!(start, length) -> sequence set or nil + # slice!(range) -> sequence set or nil + # + # Deletes a number or consecutive numbers from the set, indicated by the + # given +index+, +start+ and +length+, or +range+ of offsets. Returns the + # number or sequence set that was removed, or +nil+ if nothing was + # removed. Arguments are interpreted the same as for #slice or #[]. + # + # #string will be regenerated after deletion. + # + # Related: #slice, #delete_at, #delete, #delete?, #subtract, #difference + def slice!(index, length = nil) + deleted = slice(index, length) and subtract deleted + deleted + end + + # Merges all of the elements that appear in any of the +inputs+ into the + # set, and returns +self+. + # + # The +inputs+ may be any objects that would be accepted by ::new: + # non-zero 32 bit unsigned integers, ranges, sequence-set + # formatted strings, other sequence sets, or enumerables containing any of + # these. + # + # #string will be regenerated after all inputs have been merged. + # + # Related: #add, #add?, #union + def merge(*inputs) + tuples_add input_to_tuples inputs + normalize! + end + + # Removes all of the elements that appear in any of the given +objects+ + # from the set, and returns +self+. + # + # The +objects+ may be any objects that would be accepted by ::new: + # non-zero 32 bit unsigned integers, ranges, sequence-set + # formatted strings, other sequence sets, or enumerables containing any of + # these. + # + # Related: #difference + def subtract(*objects) + tuples_subtract input_to_tuples objects + normalize! + end + + # Returns an array of ranges and integers. + # + # The returned elements are sorted and coalesced, even when the input + # #string is not. * will sort last. See #normalize. + # + # By itself, * translates to :*. A range containing + # * translates to an endless range. Use #limit to translate both + # cases to a maximum value. + # + # If the original input was unordered or contains overlapping ranges, the + # returned ranges will be ordered and coalesced. + # + # Net::IMAP::SequenceSet["2,5:9,6,*,12:11"].elements + # #=> [2, 5..9, 11..12, :*] + # + # Related: #each_element, #ranges, #numbers + def elements; each_element.to_a end + alias to_a elements + + # Returns an array of ranges + # + # The returned elements are sorted and coalesced, even when the input + # #string is not. * will sort last. See #normalize. + # + # * translates to an endless range. By itself, * + # translates to :*... Use #limit to set * to a maximum + # value. + # + # The returned ranges will be ordered and coalesced, even when the input + # #string is not. * will sort last. See #normalize. + # + # Net::IMAP::SequenceSet["2,5:9,6,*,12:11"].ranges + # #=> [2..2, 5..9, 11..12, :*..] + # Net::IMAP::SequenceSet["123,999:*,456:789"].ranges + # #=> [123..123, 456..789, 999..] + # + # Related: #each_range, #elements, #numbers, #to_set + def ranges; each_range.to_a end + + # Returns a sorted array of all of the number values in the sequence set. + # + # The returned numbers are sorted and de-duplicated, even when the input + # #string is not. See #normalize. + # + # Net::IMAP::SequenceSet["2,5:9,6,12:11"].numbers + # #=> [2, 5, 6, 7, 8, 9, 11, 12] + # + # If the set contains a *, RangeError is raised. See #limit. + # + # Net::IMAP::SequenceSet["10000:*"].numbers + # #!> RangeError + # + # *WARNING:* Even excluding sets with *, an enormous result can + # easily be created. An array with over 4 billion integers could be + # returned, requiring up to 32GiB of memory on a 64-bit architecture. + # + # Net::IMAP::SequenceSet[10000..2**32-1].numbers + # # ...probably freezes the process for a while... + # #!> NoMemoryError (probably) + # + # For safety, consider using #limit or #intersection to set an upper + # bound. Alternatively, use #each_element, #each_range, or even + # #each_number to avoid allocation of a result array. + # + # Related: #elements, #ranges, #to_set + def numbers; each_number.to_a end + + # Yields each number or range in #elements to the block and returns self. + # Returns an enumerator when called without a block. + # + # Related: #elements + def each_element # :yields: integer or range or :* + return to_enum(__method__) unless block_given? + @tuples.each do |min, max| + if min == STAR_INT then yield :* + elsif max == STAR_INT then yield min.. + elsif min == max then yield min + else yield min..max + end + end + self + end + + # Yields each range in #ranges to the block and returns self. + # Returns an enumerator when called without a block. + # + # Related: #ranges + def each_range # :yields: range + return to_enum(__method__) unless block_given? + @tuples.each do |min, max| + if min == STAR_INT then yield :*.. + elsif max == STAR_INT then yield min.. + else yield min..max + end + end + self + end + + # Yields each number in #numbers to the block and returns self. + # If the set contains a *, RangeError will be raised. + # + # Returns an enumerator when called without a block (even if the set + # contains *). + # + # Related: #numbers + def each_number(&block) # :yields: integer + return to_enum(__method__) unless block_given? + raise RangeError, '%s contains "*"' % [self.class] if include_star? + each_element do |elem| + case elem + when Range then elem.each(&block) + when Integer then block.(elem) + end + end + self + end + + # Returns a Set with all of the #numbers in the sequence set. + # + # If the set contains a *, RangeError will be raised. + # + # See #numbers for the warning about very large sets. + # + # Related: #elements, #ranges, #numbers + def to_set; Set.new(numbers) end + + # Returns the count of #numbers in the set. + # + # If * and 2**32 - 1 (the maximum 32-bit unsigned + # integer value) are both in the set, they will only be counted once. + def count + @tuples.sum(@tuples.count) { _2 - _1 } + + (include_star? && include?(UINT32_MAX) ? -1 : 0) + end + + alias size count + + # Returns the index of +number+ in the set, or +nil+ if +number+ isn't in + # the set. + # + # Related: #[] + def find_index(number) + number = to_tuple_int number + each_tuple_with_index do |min, max, idx_min| + number < min and return nil + number <= max and return from_tuple_int(idx_min + (number - min)) + end + nil + end + + private def each_tuple_with_index + idx_min = 0 + @tuples.each do |min, max| + yield min, max, idx_min, (idx_max = idx_min + (max - min)) + idx_min = idx_max + 1 + end + idx_min + end + + private def reverse_each_tuple_with_index + idx_max = -1 + @tuples.reverse_each do |min, max| + yield min, max, (idx_min = idx_max - (max - min)), idx_max + idx_max = idx_min - 1 + end + idx_max + end + + # :call-seq: at(index) -> integer or nil + # + # Returns a number from +self+, without modifying the set. Behaves the + # same as #[], except that #at only allows a single integer argument. + # + # Related: #[], #slice + def at(index) + index = Integer(index.to_int) + if index.negative? + reverse_each_tuple_with_index do |min, max, idx_min, idx_max| + idx_min <= index and return from_tuple_int(min + (index - idx_min)) + end + else + each_tuple_with_index do |min, _, idx_min, idx_max| + index <= idx_max and return from_tuple_int(min + (index - idx_min)) + end + end + nil + end + + # :call-seq: + # seqset[index] -> integer or :* or nil + # slice(index) -> integer or :* or nil + # seqset[start, length] -> sequence set or nil + # slice(start, length) -> sequence set or nil + # seqset[range] -> sequence set or nil + # slice(range) -> sequence set or nil + # + # Returns a number or a subset from +self+, without modifying the set. + # + # When an Integer argument +index+ is given, the number at offset +index+ + # is returned: + # + # set = Net::IMAP::SequenceSet["10:15,20:23,26"] + # set[0] #=> 10 + # set[5] #=> 15 + # set[10] #=> 26 + # + # If +index+ is negative, it counts relative to the end of +self+: + # set = Net::IMAP::SequenceSet["10:15,20:23,26"] + # set[-1] #=> 26 + # set[-3] #=> 22 + # set[-6] #=> 15 + # + # If +index+ is out of range, +nil+ is returned. + # + # set = Net::IMAP::SequenceSet["10:15,20:23,26"] + # set[11] #=> nil + # set[-12] #=> nil + # + # The result is based on the normalized set—sorted and de-duplicated—not + # on the assigned value of #string. + # + # set = Net::IMAP::SequenceSet["12,20:23,11:16,21"] + # set[0] #=> 11 + # set[-1] #=> 23 + # + def [](index, length = nil) + if length then slice_length(index, length) + elsif index.is_a?(Range) then slice_range(index) + else at(index) + end + end + + alias slice :[] + + private def slice_length(start, length) + start = Integer(start.to_int) + length = Integer(length.to_int) + raise ArgumentError, "length must be positive" unless length.positive? + last = start + length - 1 unless start.negative? && start.abs <= length + slice_range(start..last) + end + + private def slice_range(range) + first = range.begin || 0 + last = range.end || -1 + last -= 1 if range.exclude_end? && range.end && last != STAR_INT + if (first * last).positive? && last < first + SequenceSet.empty + elsif (min = at(first)) + max = at(last) + if max == :* then self & (min..) + elsif min <= max then self & (min..max) + else SequenceSet.empty + end + end + end + + # Returns a frozen SequenceSet with * converted to +max+, numbers + # and ranges over +max+ removed, and ranges containing +max+ converted to + # end at +max+. + # + # Net::IMAP::SequenceSet["5,10:22,50"].limit(max: 20).to_s + # #=> "5,10:20" + # + # * is always interpreted as the maximum value. When the set + # contains *, it will be set equal to the limit. + # + # Net::IMAP::SequenceSet["*"].limit(max: 37) + # #=> Net::IMAP::SequenceSet["37"] + # Net::IMAP::SequenceSet["5:*"].limit(max: 37) + # #=> Net::IMAP::SequenceSet["5:37"] + # Net::IMAP::SequenceSet["500:*"].limit(max: 37) + # #=> Net::IMAP::SequenceSet["37"] + # + def limit(max:) + max = to_tuple_int(max) + if empty? then self.class.empty + elsif !include_star? && max < min then self.class.empty + elsif max(star: STAR_INT) <= max then frozen? ? self : dup.freeze + else dup.limit!(max: max).freeze + end + end + + # Removes all members over +max+ and returns self. If * is a + # member, it will be converted to +max+. + # + # Related: #limit + def limit!(max:) + star = include_star? + max = to_tuple_int(max) + tuple_subtract [max + 1, STAR_INT] + tuple_add [max, max ] if star + normalize! + end + + # :call-seq: complement! -> self + # + # Converts the SequenceSet to its own #complement. It will contain all + # possible values _except_ for those currently in the set. + # + # Related: #complement + def complement! + return replace(self.class.full) if empty? + return clear if full? + flat = @tuples.flat_map { [_1 - 1, _2 + 1] } + if flat.first < 1 then flat.shift else flat.unshift 1 end + if STAR_INT < flat.last then flat.pop else flat.push STAR_INT end + @tuples = flat.each_slice(2).to_a + normalize! + end + + # Returns a new SequenceSet with a normalized string representation. + # + # The returned set's #string is sorted and deduplicated. Adjacent or + # overlapping elements will be merged into a single larger range. + # + # Net::IMAP::SequenceSet["1:5,3:7,10:9,10:11"].normalize + # #=> Net::IMAP::SequenceSet["1:7,9:11"] + # + # Related: #normalize!, #normalized_string + def normalize + str = normalized_string + return self if frozen? && str == string + remain_frozen dup.instance_exec { @string = str&.-@; self } + end + + # Resets #string to be sorted, deduplicated, and coalesced. Returns + # +self+. + # + # Related: #normalize, #normalized_string + def normalize! + @string = nil + self + end + + # Returns a normalized +sequence-set+ string representation, sorted + # and deduplicated. Adjacent or overlapping elements will be merged into + # a single larger range. Returns +nil+ when the set is empty. + # + # Net::IMAP::SequenceSet["1:5,3:7,10:9,10:11"].normalized_string + # #=> "1:7,9:11" + # + # Related: #normalize!, #normalize + def normalized_string + @tuples.empty? ? nil : -@tuples.map { tuple_to_str _1 }.join(",") + end def inspect - (frozen? ? "%s[%p]" : "#<%s %p>") % [self.class, to_s] + if empty? + (frozen? ? "%s.empty" : "#<%s empty>") % [self.class] + elsif frozen? + "%s[%p]" % [self.class, to_s] + else + "#<%s %p>" % [self.class, to_s] + end end - # Unstable API, for internal use only (Net::IMAP#validate_data) + # Returns self + alias to_sequence_set itself + + # Unstable API: currently for internal use only (Net::IMAP#validate_data) def validate # :nodoc: - ResponseParser::Patterns::SEQUENCE_SET_STR.match?(@atom) or - raise ArgumentError, "invalid sequence-set: %p" % [input] - true + empty? and raise DataFormatError, "empty sequence-set is invalid" + self end - # Unstable API, for internal use only (Net::IMAP#send_data) + # Unstable API: for internal use only (Net::IMAP#send_data) def send_data(imap, tag) # :nodoc: - imap.__send__(:put_string, atom) + imap.__send__(:put_string, valid_string) + end + + protected + + attr_reader :tuples # :nodoc: + + private + + def remain_frozen(set) frozen? ? set.freeze : set end + + # frozen clones are shallow copied + def initialize_clone(other) + other.frozen? ? super : initialize_dup(other) end + def initialize_dup(other) + @tuples = other.tuples.map(&:dup) + @string = other.string&.-@ + super + end + + def input_to_tuple(obj) + obj = input_try_convert obj + case obj + when *STARS, Integer then [int = to_tuple_int(obj), int] + when Range then range_to_tuple(obj) + when String then str_to_tuple(obj) + else + raise DataFormatError, "expected number or range, got %p" % [obj] + end + end + + def input_to_tuples(obj) + obj = input_try_convert obj + case obj + when *STARS, Integer, Range then [input_to_tuple(obj)] + when String then str_to_tuples obj + when SequenceSet then obj.tuples + when ENUMABLE then obj.flat_map { input_to_tuples _1 } + when nil then [] + else + raise DataFormatError, + "expected nz-number, range, string, or enumerable; " \ + "got %p" % [obj] + end + end + + # unlike SequenceSet#try_convert, this returns an Integer, Range, + # String, Set, Array, or... any type of object. + def input_try_convert(input) + SequenceSet.try_convert(input) || + # Integer.try_convert(input) || # ruby 3.1+ + input.respond_to?(:to_int) && Integer(input.to_int) || + String.try_convert(input) || + input + end + + def range_to_tuple(range) + first = to_tuple_int(range.begin || 1) + last = to_tuple_int(range.end || :*) + last -= 1 if range.exclude_end? && range.end && last != STAR_INT + unless first <= last + raise DataFormatError, "invalid range for sequence-set: %p" % [range] + end + [first, last] + end + + def to_tuple_int(obj) STARS.include?(obj) ? STAR_INT : nz_number(obj) end + def from_tuple_int(num) num == STAR_INT ? :* : num end + + def tuple_to_str(tuple) tuple.uniq.map{ from_tuple_int _1 }.join(":") end + def str_to_tuples(str) str.split(",", -1).map! { str_to_tuple _1 } end + def str_to_tuple(str) + raise DataFormatError, "invalid sequence set string" if str.empty? + str.split(":", 2).map! { to_tuple_int _1 }.minmax + end + + def include_tuple?((min, max)) range_gte_to(min)&.cover?(min..max) end + + def intersect_tuple?((min, max)) + range = range_gte_to(min) and + range.include?(min) || range.include?(max) || (min..max).cover?(range) + end + + def tuples_add(tuples) tuples.each do tuple_add _1 end; self end + def tuples_subtract(tuples) tuples.each do tuple_subtract _1 end; self end + + # + # --|=====| |=====new tuple=====| append + # ?????????-|=====new tuple=====|-|===lower===|-- insert + # + # |=====new tuple=====| + # ---------??=======lower=======??--------------- noop + # + # ---------??===lower==|--|==| join remaining + # ---------??===lower==|--|==|----|===upper===|-- join until upper + # ---------??===lower==|--|==|--|=====upper===|-- join to upper + def tuple_add(tuple) + min, max = tuple + lower, lower_idx = tuple_gte_with_index(min - 1) + if lower.nil? then tuples << tuple + elsif (max + 1) < lower.first then tuples.insert(lower_idx, tuple) + else tuple_coalesce(lower, lower_idx, min, max) + end + end + + def tuple_coalesce(lower, lower_idx, min, max) + return if lower.first <= min && max <= lower.last + lower[0] = [min, lower.first].min + lower[1] = [max, lower.last].max + lower_idx += 1 + return if lower_idx == tuples.count + tmax_adj = lower.last + 1 + upper, upper_idx = tuple_gte_with_index(tmax_adj) + if upper + tmax_adj < upper.first ? (upper_idx -= 1) : (lower[1] = upper.last) + end + tuples.slice!(lower_idx..upper_idx) + end + + # |====tuple================| + # --|====| no more 1. noop + # --|====|---------------------------|====lower====|-- 2. noop + # -------|======lower================|---------------- 3. split + # --------|=====lower================|---------------- 4. trim beginning + # + # -------|======lower====????????????----------------- trim lower + # --------|=====lower====????????????----------------- delete lower + # + # -------??=====lower===============|----------------- 5. trim/delete one + # -------??=====lower====|--|====| no more 6. delete rest + # -------??=====lower====|--|====|---|====upper====|-- 7. delete until + # -------??=====lower====|--|====|--|=====upper====|-- 8. delete and trim + def tuple_subtract(tuple) + min, max = tuple + lower, idx = tuple_gte_with_index(min) + if lower.nil? then nil # case 1. + elsif max < lower.first then nil # case 2. + elsif max < lower.last then tuple_trim_or_split lower, idx, min, max + else tuples_trim_or_delete lower, idx, min, max + end + end + + def tuple_trim_or_split(lower, idx, tmin, tmax) + if lower.first < tmin # split + tuples.insert(idx, [lower.first, tmin - 1]) + end + lower[0] = tmax + 1 + end + + def tuples_trim_or_delete(lower, lower_idx, tmin, tmax) + if lower.first < tmin # trim lower + lower[1] = tmin - 1 + lower_idx += 1 + end + if tmax == lower.last # case 5 + upper_idx = lower_idx + elsif (upper, upper_idx = tuple_gte_with_index(tmax + 1)) + upper_idx -= 1 # cases 7 and 8 + upper[0] = tmax + 1 if upper.first <= tmax # case 8 (else case 7) + end + tuples.slice!(lower_idx..upper_idx) + end + + def tuple_gte_with_index(num) + idx = tuples.bsearch_index { _2 >= num } and [tuples[idx], idx] + end + + def range_gte_to(num) + first, last = tuples.bsearch { _2 >= num } + first..last if first + end + + def nz_number(num) + case num + when Integer, /\A[1-9]\d*\z/ then num = Integer(num) + else raise DataFormatError, "%p is not a valid nz-number" % [num] + end + NumValidator.ensure_nz_number(num) + num + end + + # intentionally defined after the class implementation + + EMPTY = new.freeze + FULL = self["1:*"] + private_constant :EMPTY, :FULL + end end end diff --git a/test/net/imap/fixtures/response_parser/status_responses.yml b/test/net/imap/fixtures/response_parser/status_responses.yml index a11e83ee..074b56ad 100644 --- a/test/net/imap/fixtures/response_parser/status_responses.yml +++ b/test/net/imap/fixtures/response_parser/status_responses.yml @@ -51,7 +51,12 @@ NUM: 1 SEQ: !ruby/struct:Net::IMAP::ExtensionData data: !ruby/object:Net::IMAP::SequenceSet - atom: 1234:5,*:789654 + string: 1234:5,*:789654 + tuples: + - - 5 + - 1234 + - - 789654 + - 4294967296 COMP-EMPTY: !ruby/struct:Net::IMAP::ExtensionData data: [] COMP-QUOTED: !ruby/struct:Net::IMAP::ExtensionData diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index 6e24aa11..a313939c 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -591,32 +591,40 @@ def test_send_invalid_number sock = server.accept begin sock.print("* OK test server\r\n") - sock.gets + sock.gets # Integer: 0 sock.print("RUBY0001 OK TEST completed\r\n") - sock.gets + sock.gets # Integer: 2**32 - 1 sock.print("RUBY0002 OK TEST completed\r\n") - sock.gets + sock.gets # MessageSet: 1 sock.print("RUBY0003 OK TEST completed\r\n") - sock.gets + sock.gets # MessageSet: 2**32 - 1 sock.print("RUBY0004 OK TEST completed\r\n") - sock.gets + sock.gets # SequenceSet: -1 => "*" + sock.print("RUBY0005 OK TEST completed\r\n") + sock.gets # SequenceSet: 1 + sock.print("RUBY0006 OK TEST completed\r\n") + sock.gets # SequenceSet: 2**32 - 1 + sock.print("RUBY0007 OK TEST completed\r\n") + sock.gets # LOGOUT sock.print("* BYE terminating connection\r\n") - sock.print("RUBY0005 OK LOGOUT completed\r\n") + sock.print("RUBY0008 OK LOGOUT completed\r\n") ensure sock.close server.close end end begin + # regular numbers may be any uint32 imap = Net::IMAP.new(server_addr, :port => port) assert_raise(Net::IMAP::DataFormatError) do imap.__send__(:send_command, "TEST", -1) end imap.__send__(:send_command, "TEST", 0) - imap.__send__(:send_command, "TEST", 4294967295) + imap.__send__(:send_command, "TEST", 2**32 - 1) assert_raise(Net::IMAP::DataFormatError) do - imap.__send__(:send_command, "TEST", 4294967296) + imap.__send__(:send_command, "TEST", 2**32) end + # MessageSet numbers may be non-zero uint32 assert_raise(Net::IMAP::DataFormatError) do imap.__send__(:send_command, "TEST", Net::IMAP::MessageSet.new(-1)) end @@ -624,9 +632,19 @@ def test_send_invalid_number imap.__send__(:send_command, "TEST", Net::IMAP::MessageSet.new(0)) end imap.__send__(:send_command, "TEST", Net::IMAP::MessageSet.new(1)) - imap.__send__(:send_command, "TEST", Net::IMAP::MessageSet.new(4294967295)) + imap.__send__(:send_command, "TEST", Net::IMAP::MessageSet.new(2**32 - 1)) + assert_raise(Net::IMAP::DataFormatError) do + imap.__send__(:send_command, "TEST", Net::IMAP::MessageSet.new(2**32)) + end + # SequenceSet numbers may be non-zero uint3, and -1 is translated to * + imap.__send__(:send_command, "TEST", Net::IMAP::SequenceSet.new(-1)) + assert_raise(Net::IMAP::DataFormatError) do + imap.__send__(:send_command, "TEST", Net::IMAP::SequenceSet.new(0)) + end + imap.__send__(:send_command, "TEST", Net::IMAP::SequenceSet.new(1)) + imap.__send__(:send_command, "TEST", Net::IMAP::SequenceSet.new(2**32-1)) assert_raise(Net::IMAP::DataFormatError) do - imap.__send__(:send_command, "TEST", Net::IMAP::MessageSet.new(4294967296)) + imap.__send__(:send_command, "TEST", Net::IMAP::SequenceSet.new(2**32)) end imap.logout ensure diff --git a/test/net/imap/test_imap_response_data.rb b/test/net/imap/test_imap_response_data.rb index d7ab5b81..dc546432 100644 --- a/test/net/imap/test_imap_response_data.rb +++ b/test/net/imap/test_imap_response_data.rb @@ -35,4 +35,23 @@ def test_uidplus_copyuid__uid_mapping ) end + def test_thread_member_to_sequence_set + # copied from the fourth example in RFC5256: (3 6 (4 23)(44 7 96)) + thmember = Net::IMAP::ThreadMember.method :new + thread = thmember.(3, [ + thmember.(6, [ + thmember.(4, [ + thmember.(23, []) + ]), + thmember.(44, [ + thmember.(7, [ + thmember.(96, []) + ]) + ]) + ]) + ]) + expected = Net::IMAP::SequenceSet.new("3:4,6:7,23,44,96") + assert_equal(expected, thread.to_sequence_set) + end + end diff --git a/test/net/imap/test_sequence_set.rb b/test/net/imap/test_sequence_set.rb new file mode 100644 index 00000000..4f607fe2 --- /dev/null +++ b/test/net/imap/test_sequence_set.rb @@ -0,0 +1,772 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" +require "set" + +class IMAPSequenceSetTest < Test::Unit::TestCase + # alias for convenience + SequenceSet = Net::IMAP::SequenceSet + DataFormatError = Net::IMAP::DataFormatError + + def compare_to_reference_set(nums, set, seqset) + set.merge nums + seqset.merge nums + assert_equal set, seqset.to_set + assert seqset.elements.size <= set.size + sorted = set.to_a.sort + assert_equal sorted, seqset.numbers + Array.new(50) { rand(sorted.count) }.each do |idx| + assert_equal sorted.at(idx), seqset.at(idx) + assert_equal sorted.at(-idx), seqset.at(-idx) + end + assert seqset.cover? sorted.sample 100 + end + + test "compared to reference Set, add many random values" do + set = Set.new + seqset = SequenceSet.new + 10.times do + nums = Array.new(1000) { rand(1..10_000) } + compare_to_reference_set(nums, set, seqset) + end + end + + test "compared to reference Set, add many large ranges" do + set = Set.new + seqset = SequenceSet.new + (1..10_000).each_slice(250) do + compare_to_reference_set _1, set, seqset + assert_equal 1, seqset.elements.size + end + end + + test "#== equality by value (not by identity or representation)" do + assert_equal SequenceSet.new, SequenceSet.new + assert_equal SequenceSet.new("1"), SequenceSet[1] + assert_equal SequenceSet.new("*"), SequenceSet[:*] + assert_equal SequenceSet["2:4"], SequenceSet["4:2"] + end + + test "#freeze" do + set = SequenceSet.new "2:4,7:11,99,999" + assert !set.frozen? + set.freeze + assert set.frozen? + assert Ractor.shareable?(set) if defined?(Ractor) + assert_equal set, set.freeze + end + + %i[clone dup].each do |method| + test "##{method}" do + orig = SequenceSet.new "2:4,7:11,99,999" + copy = orig.send method + assert_equal orig, copy + orig << 123 + copy << 456 + assert_not_equal orig, copy + assert orig.include?(123) + assert copy.include?(456) + assert !copy.include?(123) + assert !orig.include?(456) + end + end + + if defined?(Ractor) + test "#freeze makes ractor sharable (deeply frozen)" do + assert Ractor.shareable? SequenceSet.new("1:9,99,999").freeze + end + + test ".[] returns ractor sharable (deeply frozen)" do + assert Ractor.shareable? SequenceSet["2:8,88,888"] + end + + test "#clone preserves ractor sharability (deeply frozen)" do + assert Ractor.shareable? SequenceSet["3:7,77,777"].clone + end + end + + test ".new, input must be valid" do + assert_raise DataFormatError do SequenceSet.new [0] end + assert_raise DataFormatError do SequenceSet.new "0" end + assert_raise DataFormatError do SequenceSet.new [2**32] end + assert_raise DataFormatError do SequenceSet.new [2**33] end + assert_raise DataFormatError do SequenceSet.new (2**32).to_s end + assert_raise DataFormatError do SequenceSet.new (2**33).to_s end + assert_raise DataFormatError do SequenceSet.new "0:2" end + assert_raise DataFormatError do SequenceSet.new ":2" end + assert_raise DataFormatError do SequenceSet.new " 2" end + assert_raise DataFormatError do SequenceSet.new "2 " end + assert_raise DataFormatError do SequenceSet.new "2," end + assert_raise DataFormatError do SequenceSet.new Time.now end + end + + test ".new, input may be empty" do + assert_empty SequenceSet.new + assert_empty SequenceSet.new [] + assert_empty SequenceSet.new [[]] + assert_empty SequenceSet.new nil + assert_empty SequenceSet.new "" + end + + test ".[] must not be empty" do + assert_raise ArgumentError do SequenceSet[] end + assert_raise DataFormatError do SequenceSet[[]] end + assert_raise DataFormatError do SequenceSet[[[]]] end + assert_raise DataFormatError do SequenceSet[nil] end + assert_raise DataFormatError do SequenceSet[""] end + end + + test "#[non-negative index]" do + assert_nil SequenceSet.empty[0] + assert_equal 1, SequenceSet[1..][0] + assert_equal 1, SequenceSet.full[0] + assert_equal 111, SequenceSet.full[110] + assert_equal 4, SequenceSet[2,4,6,8][1] + assert_equal 8, SequenceSet[2,4,6,8][3] + assert_equal 6, SequenceSet[4..6][2] + assert_nil SequenceSet[4..6][3] + assert_equal 205, SequenceSet["101:110,201:210,301:310"][14] + assert_equal 310, SequenceSet["101:110,201:210,301:310"][29] + assert_nil SequenceSet["101:110,201:210,301:310"][44] + assert_equal :*, SequenceSet["1:10,*"][10] + end + + test "#[negative index]" do + assert_nil SequenceSet.empty[0] + assert_equal :*, SequenceSet[1..][-1] + assert_equal 1, SequenceSet.full[-(2**32)] + assert_equal 111, SequenceSet[1..111][-1] + assert_equal 4, SequenceSet[2,4,6,8][1] + assert_equal 8, SequenceSet[2,4,6,8][3] + assert_equal 6, SequenceSet[4..6][2] + assert_nil SequenceSet[4..6][3] + assert_equal 205, SequenceSet["101:110,201:210,301:310"][14] + assert_equal 310, SequenceSet["101:110,201:210,301:310"][29] + assert_nil SequenceSet["101:110,201:210,301:310"][44] + end + + test "#[start, length]" do + assert_equal SequenceSet[10..99], SequenceSet.full[9, 90] + assert_equal 90, SequenceSet.full[9, 90].count + assert_equal SequenceSet[1000..1099], + SequenceSet[1..100, 1000..1111][100, 100] + assert_equal SequenceSet[11, 21, 31, 41], + SequenceSet[((1..10_000) % 10).to_a][1, 4] + assert_equal SequenceSet[9981, 9971, 9961, 9951], + SequenceSet[((1..10_000) % 10).to_a][-5, 4] + assert_nil SequenceSet[111..222, 888..999][2000, 4] + assert_nil SequenceSet[111..222, 888..999][-2000, 4] + end + + test "#[range]" do + assert_equal SequenceSet[10..100], SequenceSet.full[9..99] + assert_equal SequenceSet[1000..1100], + SequenceSet[1..100, 1000..1111][100..200] + assert_equal SequenceSet[1000..1099], + SequenceSet[1..100, 1000..1111][100...200] + assert_equal SequenceSet[11, 21, 31, 41], + SequenceSet[((1..10_000) % 10).to_a][1..4] + assert_equal SequenceSet[9981, 9971, 9961, 9951], + SequenceSet[((1..10_000) % 10).to_a][-5..-2] + assert_equal SequenceSet[((51..9951) % 10).to_a], + SequenceSet[((1..10_000) % 10).to_a][5..-5] + assert_equal SequenceSet.full, SequenceSet.full[0..] + assert_equal SequenceSet[2..], SequenceSet.full[1..] + assert_equal SequenceSet[:*], SequenceSet.full[-1..] + assert_equal SequenceSet.empty, SequenceSet[1..100][60..50] + assert_equal SequenceSet.empty, SequenceSet[1..100][-50..-60] + assert_equal SequenceSet.empty, SequenceSet[1..100][-10..10] + assert_equal SequenceSet.empty, SequenceSet[1..100][60..-60] + assert_nil SequenceSet.empty[2..4] + assert_nil SequenceSet[101..200][1000..1060] + assert_nil SequenceSet[101..200][-1000..-60] + end + + test "#find_index" do + assert_equal 9, SequenceSet.full.find_index(10) + assert_equal 99, SequenceSet.full.find_index(100) + set = SequenceSet[1..100, 1000..1111] + assert_equal 100, set.find_index(1000) + assert_equal 200, set.find_index(1100) + set = SequenceSet[((1..10_000) % 10).to_a] + assert_equal 0, set.find_index(1) + assert_equal 1, set.find_index(11) + assert_equal 5, set.find_index(51) + assert_nil SequenceSet.empty.find_index(1) + assert_nil SequenceSet[5..9].find_index(4) + assert_nil SequenceSet[5..9,12..24].find_index(10) + assert_nil SequenceSet[5..9,12..24].find_index(11) + assert_equal 1, SequenceSet[1, :*].find_index(-1) + assert_equal 2**32 - 1, SequenceSet.full.find_index(:*) + end + + test "#limit" do + set = SequenceSet["1:100,500"] + assert_equal [1..99], set.limit(max: 99).ranges + assert_equal (1..15).to_a, set.limit(max: 15).numbers + assert_equal SequenceSet["1:100"], set.limit(max: 101) + assert_equal SequenceSet["1:97"], set.limit(max: 97) + assert_equal [1..99], set.limit(max: 99).ranges + assert_equal (1..15).to_a, set.limit(max: 15).numbers + end + + test "#limit with *" do + assert_equal SequenceSet.new("2,4,5,6,7,9,12,13,14,15"), + SequenceSet.new("2,4:7,9,12:*").limit(max: 15) + assert_equal(SequenceSet["37"], + SequenceSet["50,60,99:*"].limit(max: 37)) + assert_equal(SequenceSet["1:100,300"], + SequenceSet["1:100,500:*"].limit(max: 300)) + assert_equal [15], SequenceSet["3967:*"].limit(max: 15).numbers + assert_equal [15], SequenceSet["*:12293456"].limit(max: 15).numbers + end + + test "#limit with empty result" do + assert_equal SequenceSet.empty, SequenceSet["1234567890"].limit(max: 37) + assert_equal SequenceSet.empty, SequenceSet["99:195,458"].limit(max: 37) + end + + test "values for '*'" do + assert_equal "*", SequenceSet[?*].to_s + assert_equal "*", SequenceSet[:*].to_s + assert_equal "*", SequenceSet[-1].to_s + assert_equal "*", SequenceSet[[?*]].to_s + assert_equal "*", SequenceSet[[:*]].to_s + assert_equal "*", SequenceSet[[-1]].to_s + assert_equal "1:*", SequenceSet[1..].to_s + assert_equal "1:*", SequenceSet[1..-1].to_s + end + + test "#empty?" do + refute SequenceSet.new("1:*").empty? + refute SequenceSet.new(:*).empty? + assert SequenceSet.new(nil).empty? + assert SequenceSet.new.empty? + assert SequenceSet.empty.empty? + set = SequenceSet.new "1:1111" + refute set.empty? + set.string = nil + assert set.empty? + end + + test "#full?" do + assert SequenceSet.new("1:*").full? + refute SequenceSet.new(1..2**32-1).full? + refute SequenceSet.new(nil).full? + end + + test "#to_sequence_set" do + assert_equal (set = SequenceSet["*"]), set.to_sequence_set + assert_equal (set = SequenceSet["15:36,5,99,*,2"]), set.to_sequence_set + end + + test "set + other" do + seqset = -> { SequenceSet.new _1 } + assert_equal seqset["1,5"], seqset["1"] + seqset["5"] + assert_equal seqset["1,*"], seqset["*"] + seqset["1"] + assert_equal seqset["1:*"], seqset["1:4"] + seqset["5:*"] + assert_equal seqset["1:*"], seqset["5:*"] + seqset["1:4"] + assert_equal seqset["1:5"], seqset["1,3,5"] + seqset["2,4"] + assert_equal seqset["1:3,5,7:9"], seqset["1,3,5,7:8"] + seqset["2,8:9"] + assert_equal seqset["1:*"], seqset["1,3,5,7:*"] + seqset["2,4:6"] + end + + test "#add" do + assert_equal SequenceSet["1,5"], SequenceSet.new("1").add("5") + assert_equal SequenceSet["1,*"], SequenceSet.new("*").add(1) + assert_equal SequenceSet["1:9"], SequenceSet.new("1:6").add("4:9") + assert_equal SequenceSet["1:*"], SequenceSet.new("1:4").add(5..) + assert_equal SequenceSet["1:*"], SequenceSet.new("5:*").add(1..4) + end + + test "#<<" do + assert_equal SequenceSet["1,5"], SequenceSet.new("1") << "5" + assert_equal SequenceSet["1,*"], SequenceSet.new("*") << 1 + assert_equal SequenceSet["1:9"], SequenceSet.new("1:6") << "4:9" + assert_equal SequenceSet["1:*"], SequenceSet.new("1:4") << (5..) + assert_equal SequenceSet["1:*"], SequenceSet.new("5:*") << (1..4) + end + + test "#merge" do + seqset = -> { SequenceSet.new _1 } + assert_equal seqset["1,5"], seqset["1"].merge("5") + assert_equal seqset["1,*"], seqset["*"].merge(1) + assert_equal seqset["1:*"], seqset["1:4"].merge(5..) + assert_equal seqset["1:3,5,7:9"], seqset["1,3,5,7:8"].merge(seqset["2,8:9"]) + assert_equal seqset["1:*"], seqset["5:*"].merge(1..4) + assert_equal seqset["1:5"], seqset["1,3,5"].merge(seqset["2,4"]) + end + + test "set - other" do + seqset = -> { SequenceSet.new _1 } + assert_equal seqset["1,5"], seqset["1,5"] - 9 + assert_equal seqset["1,5"], seqset["1,5"] - "3" + assert_equal seqset["1,5"], seqset["1,3,5"] - [3] + assert_equal seqset["1,9"], seqset["1,3:9"] - "2:8" + assert_equal seqset["1,9"], seqset["1:7,9"] - (2..8) + assert_equal seqset["1,9"], seqset["1:9"] - (2..8).to_a + assert_equal seqset["1,5"], seqset["1,5:9,11:99"] - "6:999" + assert_equal seqset["1,5,99"], seqset["1,5:9,11:88,99"] - ["6:98"] + assert_equal seqset["1,5,99"], seqset["1,5:6,8:9,11:99"] - "6:98" + assert_equal seqset["1,5,11:99"], seqset["1,5:6,8:9,11:99"] - "6:9" + assert_equal seqset["1:10"], seqset["1:*"] - (11..) + assert_equal seqset[nil], seqset["1,5"] - [1..8, 10..] + end + + test "#intersection" do + seqset = -> { SequenceSet.new _1 } + assert_equal seqset[nil], seqset["1,5"] & "9" + assert_equal seqset["1,5"], seqset["1:5"].intersection([1, 5..9]) + assert_equal seqset["1,5"], seqset["1:5"] & [1, 5, 9, 55] + assert_equal seqset["*"], seqset["9999:*"] & "1,5,9,*" + end + + test "#intersect?" do + set = SequenceSet["1:5,11:20"] + refute set.intersect? "9" + refute set.intersect? 9 + refute set.intersect? 6..10 + refute set.intersect? ~set + assert set.intersect? 6..11 + assert set.intersect? "1,5,11,20" + assert set.intersect? set + end + + test "#disjoint?" do + set = SequenceSet["1:5,11:20"] + assert set.disjoint? "9" + assert set.disjoint? 6..10 + assert set.disjoint? ~set + refute set.disjoint? 6..11 + refute set.disjoint? "1,5,11,20" + refute set.disjoint? set + end + + test "#delete" do + seqset = -> { SequenceSet.new _1 } + assert_equal seqset["1,5"], seqset["1,5"].delete("9") + assert_equal seqset["1,5"], seqset["1,5"].delete("3") + assert_equal seqset["1,5"], seqset["1,3,5"].delete("3") + assert_equal seqset["1,9"], seqset["1,3:9"].delete("2:8") + assert_equal seqset["1,9"], seqset["1:7,9"].delete("2:8") + assert_equal seqset["1,9"], seqset["1:9"].delete("2:8") + assert_equal seqset["1,5"], seqset["1,5:9,11:99"].delete("6:999") + assert_equal seqset["1,5,99"], seqset["1,5:9,11:88,99"].delete("6:98") + assert_equal seqset["1,5,99"], seqset["1,5:6,8:9,11:99"].delete("6:98") + assert_equal seqset["1,5,11:99"], seqset["1,5:6,8:9,11:99"].delete("6:9") + end + + test "#subtract" do + seqset = -> { SequenceSet.new _1 } + assert_equal seqset["1,5"], seqset["1,5"].subtract("9") + assert_equal seqset["1,5"], seqset["1,5"].subtract("3") + assert_equal seqset["1,5"], seqset["1,3,5"].subtract("3") + assert_equal seqset["1,9"], seqset["1,3:9"].subtract("2:8") + assert_equal seqset["1,9"], seqset["1:7,9"].subtract("2:8") + assert_equal seqset["1,9"], seqset["1:9"].subtract("2:8") + assert_equal seqset["1,5"], seqset["1,5:9,11:99"].subtract("6:999") + assert_equal seqset["1,5,99"], seqset["1,5:9,11:88,99"].subtract("6:98") + assert_equal seqset["1,5,99"], seqset["1,5:6,8:9,11:99"].subtract("6:98") + assert_equal seqset["1,5,11:99"], seqset["1,5:6,8:9,11:99"].subtract("6:9") + end + + test "#min" do + assert_equal 3, SequenceSet.new("34:3").min + assert_equal 345, SequenceSet.new("345,678").min + assert_nil SequenceSet.new.min + end + + test "#max" do + assert_equal 34, SequenceSet["34:3"].max + assert_equal 678, SequenceSet["345,678"].max + assert_equal 678, SequenceSet["345:678"].max(star: "unused") + assert_equal :*, SequenceSet["345:*"].max + assert_equal nil, SequenceSet["345:*"].max(star: nil) + assert_equal "*", SequenceSet["345:*"].max(star: "*") + assert_nil SequenceSet.new.max(star: "ignored") + end + + test "#minmax" do + assert_equal [ 3, 3], SequenceSet["3"].minmax + assert_equal [ :*, :*], SequenceSet["*"].minmax + assert_equal [ 99, 99], SequenceSet["*"].minmax(star: 99) + assert_equal [ 3, 34], SequenceSet["34:3"].minmax + assert_equal [345, 678], SequenceSet["345,678"].minmax + assert_equal [345, 678], SequenceSet["345:678"].minmax(star: "unused") + assert_equal [345, :*], SequenceSet["345:*"].minmax + assert_equal [345, nil], SequenceSet["345:*"].minmax(star: nil) + assert_equal [345, "*"], SequenceSet["345:*"].minmax(star: "*") + assert_nil SequenceSet.new.minmax(star: "ignored") + end + + test "#add?" do + assert_equal(SequenceSet.new("1:3,5,7:8"), + SequenceSet.new("1,3,5,7:8").add?("2")) + assert_equal(SequenceSet.new("1,3,5,7:9"), + SequenceSet.new("1,3,5,7:8").add?("8:9")) + assert_nil SequenceSet.new("1,3,5,7:*").add?("3") + assert_nil SequenceSet.new("1,3,5,7:*").add?("9:91") + end + + test "#delete?" do + set = SequenceSet.new [5..10, 20] + assert_nil set.delete?(11) + assert_equal SequenceSet[5..10, 20], set + assert_equal 6, set.delete?(6) + assert_equal SequenceSet[5, 7..10, 20], set + assert_equal SequenceSet[9..10, 20], set.delete?(9..) + assert_equal SequenceSet[5, 7..8], set + assert_nil set.delete?(11..) + end + + test "#slice!" do + set = SequenceSet.new 1..20 + assert_equal SequenceSet[1..4], set.slice!(0, 4) + assert_equal SequenceSet[5..20], set + assert_equal 14, set.slice!(-7) + assert_equal SequenceSet[5..13, 15..20], set + assert_equal 11, set.slice!(6) + assert_equal SequenceSet[5..10, 12..13, 15..20], set + assert_equal SequenceSet[12..13, 15..19], set.slice!(6..12) + assert_equal SequenceSet[5..10, 20], set + assert_nil set.slice!(10) + assert_equal SequenceSet[5..10, 20], set + assert_equal 6, set.slice!(1) + assert_equal SequenceSet[5, 7..10, 20], set + assert_equal SequenceSet[9..10, 20], set.slice!(3..) + assert_equal SequenceSet[5, 7..8], set + assert_nil set.slice!(3) + assert_nil set.slice!(3..) + end + + test "#delete_at" do + set = SequenceSet.new [5..10, 20] + assert_nil set.delete_at(20) + assert_equal SequenceSet[5..10, 20], set + assert_equal 6, set.delete_at(1) + assert_equal 9, set.delete_at(3) + assert_equal 10, set.delete_at(3) + assert_equal 20, set.delete_at(3) + assert_equal nil, set.delete_at(3) + assert_equal SequenceSet[5, 7..8], set + end + + test "#include_star?" do + assert SequenceSet["2,*:12"].include_star? + assert SequenceSet[-1].include_star? + refute SequenceSet["12"].include_star? + end + + test "#include?" do + assert SequenceSet["2:4"].include?(3) + assert SequenceSet["2,*:12"].include? :* + assert SequenceSet["2,*:12"].include?(-1) + set = SequenceSet.new Array.new(100) { rand(1..1500) } + rev = (~set).limit(max: 1_501) + set.numbers.each do assert set.include?(_1) end + rev.numbers.each do refute set.include?(_1) end + end + + test "#cover?" do + assert SequenceSet["2:4"].cover?(3) + assert SequenceSet["2,4:7,9,12:*"] === 2 + assert SequenceSet["2,4:7,9,12:*"].cover?(2222) + assert SequenceSet["2,*:12"].cover? :* + assert SequenceSet["2,*:12"].cover?(-1) + assert SequenceSet["2,*:12"].cover?(99..5000) + refute SequenceSet["2,*:12"].cover?(10) + refute SequenceSet["2,*:12"].cover?(10..13) + assert SequenceSet["2:12"].cover?(10..12) + refute SequenceSet["2:12"].cover?(10..13) + assert SequenceSet["2:12"].cover?(10...13) + set = SequenceSet.new Array.new(100) { rand(1..1500) } + rev = (~set).limit(max: 1_501) + refute set.cover?(rev) + set.each_element do assert set.cover?(_1) end + rev.each_element do refute set.cover?(_1) end + assert SequenceSet["2:4"].cover? [] + assert SequenceSet["2:4"].cover? SequenceSet.empty + assert SequenceSet["2:4"].cover? nil + assert SequenceSet["2:4"].cover? "" + refute SequenceSet["2:4"].cover? "*" + refute SequenceSet["2:4"].cover? SequenceSet.full + assert SequenceSet.full .cover? SequenceSet.full + assert SequenceSet.full .cover? :* + assert SequenceSet.full .cover?(-1) + assert SequenceSet.empty .cover? SequenceSet.empty + refute SequenceSet.empty .cover? SequenceSet[:*] + end + + test "~full == empty" do + assert_equal SequenceSet.new("1:*"), ~SequenceSet.new + assert_equal SequenceSet.new, ~SequenceSet.new("1:*") + assert_equal SequenceSet.new("1:*"), SequenceSet.new .complement + assert_equal SequenceSet.new, SequenceSet.new("1:*").complement + assert_equal SequenceSet.new("1:*"), SequenceSet.new .complement! + assert_equal SequenceSet.new, SequenceSet.new("1:*").complement! + end + + data( + # desc => [expected, input, freeze] + "empty" => ["#", nil], + "frozen empty" => ["Net::IMAP::SequenceSet.empty", nil, true], + "normalized" => ['#', [2, 1]], + "denormalized" => ['#', "2,1"], + "star" => ['#', "*"], + "frozen" => ['Net::IMAP::SequenceSet["1,3,5:*"]', [1, 3, 5..], true], + ) + def test_inspect((expected, input, freeze)) + seqset = SequenceSet.new(input) + seqset = seqset.freeze if freeze + assert_equal expected, seqset.inspect + end + + data "single number", { + input: "123456", + elements: [123_456], + ranges: [123_456..123_456], + numbers: [123_456], + to_s: "123456", + normalize: "123456", + count: 1, + complement: "1:123455,123457:*", + }, keep: true + + data "single range", { + input: "1:3", + elements: [1..3], + ranges: [1..3], + numbers: [1, 2, 3], + to_s: "1:3", + normalize: "1:3", + count: 3, + complement: "4:*", + }, keep: true + + data "simple numbers list", { + input: "1,3,5", + elements: [ 1, 3, 5], + ranges: [1..1, 3..3, 5..5], + numbers: [ 1, 3, 5], + to_s: "1,3,5", + normalize: "1,3,5", + count: 3, + complement: "2,4,6:*", + }, keep: true + + data "numbers and ranges list", { + input: "1:3,5,7:9,46", + elements: [1..3, 5, 7..9, 46], + ranges: [1..3, 5..5, 7..9, 46..46], + numbers: [1, 2, 3, 5, 7, 8, 9, 46], + to_s: "1:3,5,7:9,46", + normalize: "1:3,5,7:9,46", + count: 8, + complement: "4,6,10:45,47:*", + }, keep: true + + data "just *", { + input: "*", + elements: [:*], + ranges: [:*..], + numbers: RangeError, + to_s: "*", + normalize: "*", + count: 1, + complement: "1:%d" % [2**32-1] + }, keep: true + + data "range with *", { + input: "4294967000:*", + elements: [4_294_967_000..], + ranges: [4_294_967_000..], + numbers: RangeError, + to_s: "4294967000:*", + normalize: "4294967000:*", + count: 2**32 - 4_294_967_000, + complement: "1:4294966999", + }, keep: true + + data "* sorts last", { + input: "5,*,7", + elements: [5, 7, :*], + ranges: [5..5, 7..7, :*..], + numbers: RangeError, + to_s: "5,*,7", + normalize: "5,7,*", + complement: "1:4,6,8:%d" % [2**32-1], + count: 3, + }, keep: true + + data "out of order", { + input: "46,7:6,15,3:1", + elements: [1..3, 6..7, 15, 46], + ranges: [1..3, 6..7, 15..15, 46..46], + numbers: [1, 2, 3, 6, 7, 15, 46], + to_s: "46,7:6,15,3:1", + normalize: "1:3,6:7,15,46", + count: 7, + complement: "4:5,8:14,16:45,47:*", + }, keep: true + + data "adjacent", { + input: "1,2,3,5,7:9,10:11", + elements: [1..3, 5, 7..11], + ranges: [1..3, 5..5, 7..11], + numbers: [1, 2, 3, 5, 7, 8, 9, 10, 11], + to_s: "1,2,3,5,7:9,10:11", + normalize: "1:3,5,7:11", + count: 9, + complement: "4,6,12:*", + }, keep: true + + data "overlapping", { + input: "1:5,3:7,10:9,10:11", + elements: [1..7, 9..11], + ranges: [1..7, 9..11], + numbers: [1, 2, 3, 4, 5, 6, 7, 9, 10, 11], + to_s: "1:5,3:7,10:9,10:11", + normalize: "1:7,9:11", + count: 10, + complement: "8,12:*", + }, keep: true + + data "contained", { + input: "1:5,3:4,9:11,10", + elements: [1..5, 9..11], + ranges: [1..5, 9..11], + numbers: [1, 2, 3, 4, 5, 9, 10, 11], + to_s: "1:5,3:4,9:11,10", + normalize: "1:5,9:11", + count: 8, + complement: "6:8,12:*", + }, keep: true + + data "array", { + input: ["1:5,3:4", 9..11, "10", 99, :*], + elements: [1..5, 9..11, 99, :*], + ranges: [1..5, 9..11, 99..99, :*..], + numbers: RangeError, + to_s: "1:5,9:11,99,*", + normalize: "1:5,9:11,99,*", + count: 10, + complement: "6:8,12:98,100:#{2**32 - 1}", + }, keep: true + + data "nested array", { + input: [["1:5", [3..4], [[[9..11, "10"], 99], :*]]], + elements: [1..5, 9..11, 99, :*], + ranges: [1..5, 9..11, 99..99, :*..], + numbers: RangeError, + to_s: "1:5,9:11,99,*", + normalize: "1:5,9:11,99,*", + count: 10, + complement: "6:8,12:98,100:#{2**32 - 1}", + }, keep: true + + data "empty", { + input: nil, + elements: [], + ranges: [], + numbers: [], + to_s: "", + normalize: nil, + count: 0, + complement: "1:*", + }, keep: true + + test "#elements" do |data| + assert_equal data[:elements], SequenceSet.new(data[:input]).elements + end + + test "#ranges" do |data| + assert_equal data[:ranges], SequenceSet.new(data[:input]).ranges + end + + test "#string" do |data| + set = SequenceSet.new(data[:input]) + str = data[:to_s] + str = nil if str.empty? + assert_equal str, set.string + end + + test "#normalized_string" do |data| + set = SequenceSet.new(data[:input]) + assert_equal data[:normalize], set.normalized_string + end + + test "#normalize" do |data| + set = SequenceSet.new(data[:input]) + assert_equal data[:normalize], set.normalize.string + if data[:input] + end + end + + test "#normalize!" do |data| + set = SequenceSet.new(data[:input]) + set.normalize! + assert_equal data[:normalize], set.string + end + + test "#to_s" do |data| + assert_equal data[:to_s], SequenceSet.new(data[:input]).to_s + end + + test "#count" do |data| + assert_equal data[:count], SequenceSet.new(data[:input]).count + end + + test "#valid_string" do |data| + if (expected = data[:to_s]).empty? + assert_raise DataFormatError do + SequenceSet.new(data[:input]).valid_string + end + else + assert_equal data[:to_s], SequenceSet.new(data[:input]).valid_string + end + end + + test "#~ and #complement" do |data| + set = SequenceSet.new(data[:input]) + assert_equal(data[:complement], set.complement.to_s) + assert_equal(data[:complement], (~set).to_s) + end + + test "#numbers" do |data| + expected = data[:numbers] + if expected.is_a?(Class) && expected < Exception + assert_raise expected do SequenceSet.new(data[:input]).numbers end + else + assert_equal expected, SequenceSet.new(data[:input]).numbers + end + end + + test "SequenceSet[input]" do |input| + case (input = data[:input]) + when nil + assert_raise DataFormatError do SequenceSet[input] end + when String + seqset = SequenceSet[input] + assert_equal data[:input], seqset.to_s + assert_equal data[:normalize], seqset.normalized_string + assert seqset.frozen? + else + seqset = SequenceSet[input] + assert_equal data[:normalize], seqset.to_s + assert seqset.frozen? + end + end + + test "set == ~~set" do |data| + set = SequenceSet.new(data[:input]) + assert_equal set, set.complement.complement + assert_equal set, ~~set + end + + test "set | ~set == full" do |data| + set = SequenceSet.new(data[:input]) + assert_equal SequenceSet.new("1:*"), set + set.complement + end + +end