diff --git a/lib/net/imap/sequence_set.rb b/lib/net/imap/sequence_set.rb index c1a488e6..9bf1814c 100644 --- a/lib/net/imap/sequence_set.rb +++ b/lib/net/imap/sequence_set.rb @@ -62,17 +62,27 @@ class SequenceSet 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 [](*values) - seqset = new.merge(*values) + def [](first, *rest) + if rest.empty? && first.is_a?(SequenceSet) && first.frozen? + seqset = first + else + seqset = new.merge(first, *rest) + end seqset.validate seqset.freeze 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+. @@ -81,7 +91,7 @@ def [](*values) # raised. def try_convert(obj) return obj if obj.is_a?(SequenceSet) - return unless respond_to?(:to_sequence_set) + 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" @@ -89,15 +99,15 @@ def try_convert(obj) end - # Create a new SequenceSet object from +input, which may be another - # SequenceSet, or it may be an IMAP +sequence-set+ string, a number, a - # range, *, or an enumerable of these. + # 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, @str = [], ""; self end + def clear; @tuples, @string = [], -""; self end # Replace the contents of the set with the contents of +other+ and returns # self. @@ -109,38 +119,39 @@ def clear; @tuples, @str = [], ""; self end def replace(other) case other = object_try_convert(other) when SequenceSet then initialize_dup(other) - when String then self.atom = other + when String then self.string = other else clear << other end self end - # Returns the IMAP string representation. In the IMAP grammar, - # +sequence-set+ is a subset of +atom+ which is a subset of +astring+. + # Returns the IMAP string representation. # # Raises a DataFormatError when the set is empty. Use #to_s to return an # empty string without error. # # If the set was created from a single string, that string is returned # without calling #normalize. When a new value is added to the set, the - # atom string is automatically #normalized. - def atom + # string is automatically #normalized. + def imap_string raise DataFormatError, "empty sequence-set" if empty? - @str.clone + string end - # Returns #atom. In the IMAP grammar, +atom+ is a subset of +astring+. - alias astring atom - - # Returns the value of #atom, or an empty string when the set is empty. - def to_s; @str.clone end + # Returns the IMAP string representation, or an empty string when the set + # is empty. Note that an empty set is invalid in the IMAP grammar. + # + # Related: #imap_string + def string; @string.clone end + alias to_s string - # Assigns a new string to #atom and resets #elements to match. + # Assigns a new string to #string and resets #elements to match. # # Use #add or #merge to add a string to the existing set. - def atom=(str) + def string=(str) + str = String.try_convert(str) or raise ArgumentError, "not a string" tuples = str_to_tuples str - @tuples, @str = [], -str.to_str + @tuples, @string = [], -str tuples_add tuples end @@ -148,10 +159,12 @@ def atom=(str) def freeze return if frozen? @tuples.each(&:freeze).freeze - @str = -@str + @string = -@string super end + # :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. @@ -162,11 +175,14 @@ def freeze # Net::IMAP::SequenceSet["9,1:*"] == Net::IMAP::SequenceSet["1:*"] # => true # # Related: #eql?, #normalize - def ==(rhs) - self.class == rhs.class && (to_s == rhs.to_s || tuples == rhs.tuples) + 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 + # + # Hash equality requires the same encoded #string representation. # # 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 @@ -174,15 +190,17 @@ def ==(rhs) # Net::IMAP::SequenceSet["9,1:*"].eql? Net::IMAP::SequenceSet["1:*"] # => false # # Related: #==, #normalize - def eql?(other) self.class == other.class && atom == other.atom end + 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 or false or nil + # # Returns the result of #cover? Returns +nil+ if #cover? raises a # StandardError exception. - def ===(rhs) - cover?(rhs) + def ===(other) + cover?(other) rescue nil end @@ -195,11 +213,10 @@ def ===(rhs) def cover?(obj) obj = object_try_convert(obj) case obj - when VALID then include? obj + when STAR, VALID then include? obj when Range then range_cover? obj when SequenceSet then seqset_cover? obj - when COERCIBLE then seqset_cover? SequenceSet.try_convert obj - when Set, Array, String then seqset_cover? SequenceSet.new obj + when Set, Array, String then seqset_cover? SequenceSet.new obj end end @@ -216,7 +233,7 @@ def include?(number) # Returns +true+ when the set contains *. def include_star?; @tuples.last&.last == STAR end - # :call-seq: max(star: :*) => Integer | star | nil + # :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. @@ -224,41 +241,65 @@ def max(star: :*) (val = @tuples.last&.last) && val == STAR ? star : val end - # :call-seq: min => Integer | nil + # :call-seq: min(star: :*) => integer or star or nil # - # Returns the minimum value in +self+, or +nil+ if empty. + # 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 ? star : val end - # :call-seq: minmax(star: :*) => [Integer, Integer | star] | nil + # :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 + # :call-seq: + # self + other -> sequence set + # self | other -> sequence set + # union(other) -> sequence set + # # Returns a new sequence set that is the union of both sequence sets. # # Related: #add, #merge - def +(rhs) dup.add rhs end + def +(other) dup.add 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 the +rhs+ object. + # every number that appears in the +other+ object. # # Related: #subtract - def -(rhs) dup.subtract rhs end + def -(other) dup.subtract other end alias difference :- - def &(rhs) self - SequenceSet.new(rhs).complement! end + # :call-seq: + # self & other -> sequence set + # intersection(other) -> sequence set + # + # Returns a new sequence set from the intersection of both sets. + def &(other) self - SequenceSet.new(other).complement! end alias intersection :& - def ^(rhs) (self | rhs).subtract(self & rhs) end + # :call-seq: + # self ^ other -> sequence set + # xor(other) -> sequence set + # + # Returns a new sequence set from the XOR (exclusive or) of both sets. + def ^(other) (self | other).subtract(self & other) end alias xor :^ - # Adds a range, number, or string to the set and returns self. The #atom - # will be regenerated. Use #merge to add many elements at once. + # :call-seq: + # add(object) -> self + # self << other -> self + # + # Adds a range, number, or string to the set and returns self. The + # #string will be regenerated. Use #merge to add many elements at once. def add(object) tuples_add input_to_tuples object normalize! @@ -271,7 +312,7 @@ def add(object) def add?(obj) add(obj) unless cover?(obj) end # Merges the elements in each object to the set and returns self. The - # #atom will be regenerated after all inputs have been merged. + # #string will be regenerated after all inputs have been merged. def merge(*inputs) tuples_add inputs.flat_map { input_to_tuples _1 } normalize! @@ -280,7 +321,7 @@ def merge(*inputs) # Deletes every number that appears in +object+ and returns self. +object # can be a range, a number, or an enumerable of ranges and numbers. The - # #atom will be regenerated. + # #string will be regenerated. def subtract(object) tuples_subtract input_to_tuples object normalize! @@ -290,7 +331,7 @@ def subtract(object) # Returns an array of ranges and integers. # # The returned elements are sorted and deduplicated, even when the input - # #atom is not. * will sort last. See #normalize. + # #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 @@ -317,14 +358,14 @@ def each_element # Returns an array of ranges # # The returned elements are sorted and deduplicated, even when the input - # #atom is not. * will sort last. See #normalize. + # #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 - # #atom is not. * will sort last. See #normalize. + # #string is not. * will sort last. See #normalize. # # Net::IMAP::SequenceSet["2,5:9,6,*,12:11"].ranges # # => [2..2, 5..9, 11..12, :*..] @@ -346,7 +387,7 @@ def each_range # Returns a sorted array of all of the number values in the sequence set. # # The returned numbers are sorted and deduplicated, even when the input - # #atom is not. * will sort last. See #normalize. + # #string is not. * will sort last. See #normalize. # # Net::IMAP::SequenceSet["2,5:9,6,*,12:11"].numbers # # => [2, 5, 6, 7, 8, 9, 11, 12, :*] @@ -432,22 +473,35 @@ def limit!(max:) self 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]] end + # :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. + # + # Related: #complement! def complement; dup.complement! end - alias ~ complement + alias :~ complement + # :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(VALID) if empty? - return clear if full? + return replace(VALID) << STAR 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 < flat.last then flat.pop else flat.push STAR end @@ -458,15 +512,15 @@ def complement! # Returns a new SequenceSet with a normalized string representation. # - # The returned set's #atom string are sorted and deduplicated. Adjacent - # or overlapping elements will be merged into a single larger range. + # 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"] # # => Net::IMAP::SequenceSet["1:7,9:11"] def normalize; dup.normalize! end - # Sorts, deduplicates, and merges the #atom string, as appropriate - def normalize!; @str = @tuples.map { tuple_to_str _1 }.join(",") end + # Sorts, deduplicates, and merges the #string, as appropriate + def normalize!; @string = -@tuples.map { tuple_to_str _1 }.join(",") end def inspect if frozen? @@ -479,21 +533,20 @@ def inspect # Returns self alias to_sequence_set itself - # Unstable API, for internal use only (Net::IMAP#validate_data) + # Unstable API, currently for internal use only (Net::IMAP#validate_data) def validate # :nodoc: empty? and raise DataFormatError, "empty sequence-set is invalid" - validate_tuples # validated during input; only raises when there's a bug true end # 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, string) end protected - attr_reader :tuples + attr_reader :tuples # :nodoc: private @@ -504,7 +557,7 @@ def initialize_clone(other) def initialize_dup(other) @tuples = other.tuples.map(&:dup) - @str = -other.to_s + @string = -other.to_s super end @@ -525,6 +578,8 @@ def enum_to_tuples(enum) enum.flat_map {|obj| object_to_tuples!(obj) } end + # unlike SequenceSet#trykconvert, this can return an Integer, Range, + # String, Set, Array, or... any type of object. def object_try_convert(input) SequenceSet.try_convert(input) || STARS.include?(input) && STAR || @@ -537,7 +592,7 @@ def object_try_convert(input) def object_to_tuples(obj) obj = object_try_convert obj case obj - when VALID then [[obj, obj]] + when STAR, VALID then [[obj, obj]] when Range then [range_to_tuple(obj)] when String then str_to_tuples obj when SequenceSet then obj.tuples @@ -554,8 +609,8 @@ def raise_invalid(obj) def el_to_nums(elem) elem.is_a?(Range) ? elem.to_a : elem end def valid_int(obj) - if VALID.cover?(obj) then obj - elsif STARS.include?(obj) then STAR + if STARS.include?(obj) then STAR + elsif VALID.cover?(obj) then obj else nz_number(obj) end end @@ -609,7 +664,7 @@ def tuple_to_str(tuple) tuple.uniq.map{ _1 == STAR ? "*" : _1 }.join(":") end - def tuples_add(tuples) tuples.each do tuple_add _1 end; self 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 end # @@ -704,22 +759,6 @@ def range_gte_to(num) first..last if first end - def validate_tuples - tuples.each do validate_tuple _1 end - if (a, b = tuples.each_cons(2).find {|a, b| b.first <= (a.last + 1) }) - raise DataFormatError, "sequence-set failed to merge %p and %p" % [ - a, b, - ] - end - end - - def validate_tuple(tuple) - min, max = tuple - unless VALID.cover?(min) && VALID.cover?(max) && min <= max - raise DataFormatError, "invalid sequence-set range: %p" % [tuple] - end - end - def nz_number(num) /\A[1-9]\d*\z/.match?(num) or raise DataFormatError, "%p is not a valid nz-number" % [num] diff --git a/test/net/imap/test_sequence_set.rb b/test/net/imap/test_sequence_set.rb index 8ba03c6f..21205dd3 100644 --- a/test/net/imap/test_sequence_set.rb +++ b/test/net/imap/test_sequence_set.rb @@ -91,18 +91,19 @@ class IMAPSequenceSetTest < Test::Unit::TestCase end test ".[] must not be empty" do - assert_raise DataFormatError do SequenceSet[] end + assert_raise ArgumentError do SequenceSet[] end assert_raise DataFormatError do SequenceSet[nil] end + assert_raise DataFormatError do SequenceSet[""] end 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 + 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 @@ -250,6 +251,10 @@ def test_include assert SequenceSet["2:4"].include?(3) assert SequenceSet["2,4:7,9,12:*"] === 2 assert SequenceSet["2,4:7,9,12:*"].cover?(2222) + assert SequenceSet["2,*:12"].include_star? + assert SequenceSet["2,*:12"].include? :* + assert SequenceSet["2,*:12"].include?(-1) + refute SequenceSet["12"].include_star? set = SequenceSet.new Array.new(1_000) { rand(1..1500) } set.numbers .each do assert set.include?(_1) end @@ -426,15 +431,13 @@ def test_count(data) assert_equal data[:count], SequenceSet.new(data[:input]).count end - %i[atom astring].each do |method| - define_method :"test_#{method}" do |data| - if (expected = data[:to_s]).empty? - assert_raise DataFormatError do - SequenceSet.new(data[:input]).send(method) - end - else - assert_equal data[:to_s], SequenceSet.new(data[:input]).send(method) + test "#imap_string" do |data| + if (expected = data[:to_s]).empty? + assert_raise DataFormatError do + SequenceSet.new(data[:input]).imap_string end + else + assert_equal data[:to_s], SequenceSet.new(data[:input]).imap_string end end