From 23b2a7d04c650ac5a7c0bff1a3d9db6d58d908b9 Mon Sep 17 00:00:00 2001 From: nicholas evans Date: Sat, 28 Jan 2023 14:01:25 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20Set/Range/Enum/etc=20methods?= =?UTF-8?q?=20to=20SequenceSet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version of SequenceSet in net-imap prior to this commit was merely a placeholder, needed in order to complete `tagged-ext` for #225. This updates it with a full API, inspired by Set, Range, and Array. This allows it to be more broadly useful, e.g. for storing and working with mailbox state. In addition to Integer, Range, and enumerables, any object with `#to_sequence_set` can now be used to create a sequence set. For compatibility with MessageSet, `ThreadMember#to_sequence_set` collects all child seqno into a SequenceSet. Because mailbox state can be _very_ large, inputs are stored in an internal sorted array of ranges. These are stored as `[start, stop]` tuples, not Range objects, for simpler manipulation. A future optimization could convert all tuples to a flat one-dimensional Array (to reduce object allocations). Storing the data in sorted range tuples allows many of the important operations to be `O(lg n)`. Although updates do use `Array#insert` and `Array#slice!`—which are technically `O(n)`—they tend to be fast until the number of elements is very large. Count and index-based methods are also `O(n)`. A future optimization could cache the count and compose larger sets from a sorted tree of smaller sets, to preserve `O(lg n)` for most operations. SequenceSet can be used to replace MessageSet (which is used internally to validate, format, and send certain command args). Some notable differences between the two: * Most validation is done up-front, when initializing or adding values. * A ThreadMember to `sequence-set` bug has been fixed. * The generated string is sorted and adjacent ranges are combined. TODO in future PRs: * #index_lte => get the index of a number in the set, or if the number isn't in the set, the number before it. * Replace or supplement the UID set implementation in UIDPlusData. * fully replace MessageSet (probably not before v0.5.0) --- lib/net/imap/response_data.rb | 13 + lib/net/imap/response_parser.rb | 2 +- lib/net/imap/sequence_set.rb | 1354 ++++++++++++++++- .../response_parser/status_responses.yml | 7 +- test/net/imap/test_imap.rb | 38 +- test/net/imap/test_imap_response_data.rb | 19 + test/net/imap/test_sequence_set.rb | 772 ++++++++++ 7 files changed, 2157 insertions(+), 48 deletions(-) create mode 100644 test/net/imap/test_sequence_set.rb 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