From 86c0ddb3ad17a8c4ed31938d1415025f3b4c51e7 Mon Sep 17 00:00:00 2001 From: nicholas evans Date: Fri, 22 Dec 2023 22:08:17 -0500 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Add=20`SequenceSet#overlap=3F`?= =?UTF-8?q?=20alias=20for=20`intersect=3F`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This matches `Range#overlap?`, which has been added to ruby 3.3. --- lib/net/imap/sequence_set.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/net/imap/sequence_set.rb b/lib/net/imap/sequence_set.rb index 48b7eaf1..5336caf1 100644 --- a/lib/net/imap/sequence_set.rb +++ b/lib/net/imap/sequence_set.rb @@ -151,7 +151,7 @@ class IMAP # +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?: + # - #intersect? (aliased as #overlap?): # Returns whether +self+ and a given object have any common elements. # - #disjoint?: # Returns whether +self+ and a given object have no common elements. @@ -502,6 +502,7 @@ def include_star?; @tuples.last&.last == STAR_INT end def intersect?(other) valid? && input_to_tuples(other).any? { intersect_tuple? _1 } end + alias overlap? intersect? # Returns +true+ if the set and a given object have no common elements, # +false+ otherwise. From 2df91064adcd12de7270fdee911e0accad072a75 Mon Sep 17 00:00:00 2001 From: nicholas evans Date: Fri, 22 Dec 2023 22:51:54 -0500 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20Add=20SequenceSet#entries=20for?= =?UTF-8?q?=20unsorted=20iteration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unsorted access is especially important for using with the ESORT or CONTEXT=SORT extensions. --- lib/net/imap/sequence_set.rb | 63 +++++++++++++++++++++++------- test/net/imap/test_sequence_set.rb | 34 ++++++++++++++++ 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/lib/net/imap/sequence_set.rb b/lib/net/imap/sequence_set.rb index 5336caf1..3218a427 100644 --- a/lib/net/imap/sequence_set.rb +++ b/lib/net/imap/sequence_set.rb @@ -186,10 +186,14 @@ class IMAP # # === 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_element: Yields each number and range in the set, sorted and + # coalesced, and returns +self+. + # - #elements (aliased as #to_a): Returns an Array of every number and range + # in the set, sorted and coalesced. + # - #each_entry: Yields each number and range in the set, unsorted and + # without deduplicating numbers or coalescing ranges, and returns +self+. + # - #entries: Returns an Array of every number and range in the set, + # unsorted and without deduplicating numbers or coalescing ranges. # - #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 @@ -788,7 +792,18 @@ def subtract(*objects) normalize! end - # Returns an array of ranges and integers. + # Returns an array of ranges and integers and :*. + # + # The entries are in the same order they appear in #string, with no + # sorting, deduplication, or coalescing. When #string is in its + # normalized form, this will return the same result as #elements. + # This is useful when the given order is significant, for example in a + # ESEARCH response to IMAP#sort. + # + # Related: #each_entry, #elements + def entries; each_entry.to_a end + + # Returns an array of ranges and integers and :*. # # The returned elements are sorted and coalesced, even when the input # #string is not. * will sort last. See #normalize. @@ -855,22 +870,42 @@ def ranges; each_range.to_a end # 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. + # Yields each number or range in #string to the block and returns +self+. # Returns an enumerator when called without a block. # - # Related: #elements + # The entries are yielded in the same order they appear in #tring, with no + # sorting, deduplication, or coalescing. When #string is in its + # normalized form, this will yield the same values as #each_element. + # + # Related: #entries, #each_element + def each_entry(&block) + return to_enum(__method__) unless block_given? + return each_element(&block) unless @string + @string.split(",").each do yield tuple_to_entry str_to_tuple _1 end + self + end + + # Yields each number or range (or :*) in #elements to the block + # and returns self. Returns an enumerator when called without a block. + # + # The returned numbers are sorted and de-duplicated, even when the input + # #string is not. See #normalize. + # + # Related: #elements, #each_entry 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 + @tuples.each do yield tuple_to_entry _1 end self end + private def tuple_to_entry((min, max)) + if min == STAR_INT then :* + elsif max == STAR_INT then min.. + elsif min == max then min + else min..max + end + end + # Yields each range in #ranges to the block and returns self. # Returns an enumerator when called without a block. # diff --git a/test/net/imap/test_sequence_set.rb b/test/net/imap/test_sequence_set.rb index 4f607fe2..f713c16b 100644 --- a/test/net/imap/test_sequence_set.rb +++ b/test/net/imap/test_sequence_set.rb @@ -525,6 +525,7 @@ def test_inspect((expected, input, freeze)) data "single number", { input: "123456", elements: [123_456], + entries: [123_456], ranges: [123_456..123_456], numbers: [123_456], to_s: "123456", @@ -536,6 +537,7 @@ def test_inspect((expected, input, freeze)) data "single range", { input: "1:3", elements: [1..3], + entries: [1..3], ranges: [1..3], numbers: [1, 2, 3], to_s: "1:3", @@ -547,6 +549,7 @@ def test_inspect((expected, input, freeze)) data "simple numbers list", { input: "1,3,5", elements: [ 1, 3, 5], + entries: [ 1, 3, 5], ranges: [1..1, 3..3, 5..5], numbers: [ 1, 3, 5], to_s: "1,3,5", @@ -558,6 +561,7 @@ def test_inspect((expected, input, freeze)) data "numbers and ranges list", { input: "1:3,5,7:9,46", elements: [1..3, 5, 7..9, 46], + entries: [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", @@ -569,6 +573,7 @@ def test_inspect((expected, input, freeze)) data "just *", { input: "*", elements: [:*], + entries: [:*], ranges: [:*..], numbers: RangeError, to_s: "*", @@ -580,6 +585,7 @@ def test_inspect((expected, input, freeze)) data "range with *", { input: "4294967000:*", elements: [4_294_967_000..], + entries: [4_294_967_000..], ranges: [4_294_967_000..], numbers: RangeError, to_s: "4294967000:*", @@ -591,6 +597,7 @@ def test_inspect((expected, input, freeze)) data "* sorts last", { input: "5,*,7", elements: [5, 7, :*], + entries: [5, :*, 7], ranges: [5..5, 7..7, :*..], numbers: RangeError, to_s: "5,*,7", @@ -602,6 +609,7 @@ def test_inspect((expected, input, freeze)) data "out of order", { input: "46,7:6,15,3:1", elements: [1..3, 6..7, 15, 46], + entries: [46, 6..7, 15, 1..3], 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", @@ -613,6 +621,7 @@ def test_inspect((expected, input, freeze)) data "adjacent", { input: "1,2,3,5,7:9,10:11", elements: [1..3, 5, 7..11], + entries: [1, 2, 3, 5, 7..9, 10..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", @@ -624,6 +633,7 @@ def test_inspect((expected, input, freeze)) data "overlapping", { input: "1:5,3:7,10:9,10:11", elements: [1..7, 9..11], + entries: [1..5, 3..7, 9..10, 10..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", @@ -635,6 +645,7 @@ def test_inspect((expected, input, freeze)) data "contained", { input: "1:5,3:4,9:11,10", elements: [1..5, 9..11], + entries: [1..5, 3..4, 9..11, 10], ranges: [1..5, 9..11], numbers: [1, 2, 3, 4, 5, 9, 10, 11], to_s: "1:5,3:4,9:11,10", @@ -646,6 +657,7 @@ def test_inspect((expected, input, freeze)) data "array", { input: ["1:5,3:4", 9..11, "10", 99, :*], elements: [1..5, 9..11, 99, :*], + entries: [1..5, 9..11, 99, :*], ranges: [1..5, 9..11, 99..99, :*..], numbers: RangeError, to_s: "1:5,9:11,99,*", @@ -657,6 +669,7 @@ def test_inspect((expected, input, freeze)) data "nested array", { input: [["1:5", [3..4], [[[9..11, "10"], 99], :*]]], elements: [1..5, 9..11, 99, :*], + entries: [1..5, 9..11, 99, :*], ranges: [1..5, 9..11, 99..99, :*..], numbers: RangeError, to_s: "1:5,9:11,99,*", @@ -668,6 +681,7 @@ def test_inspect((expected, input, freeze)) data "empty", { input: nil, elements: [], + entries: [], ranges: [], numbers: [], to_s: "", @@ -680,6 +694,26 @@ def test_inspect((expected, input, freeze)) assert_equal data[:elements], SequenceSet.new(data[:input]).elements end + test "#each_element" do |data| + seqset = SequenceSet.new(data[:input]) + array = [] + assert_equal seqset, seqset.each_element { array << _1 } + assert_equal data[:elements], array + assert_equal data[:elements], seqset.each_element.to_a + end + + test "#entries" do |data| + assert_equal data[:entries], SequenceSet.new(data[:input]).entries + end + + test "#each_entry" do |data| + seqset = SequenceSet.new(data[:input]) + array = [] + assert_equal seqset, seqset.each_entry { array << _1 } + assert_equal data[:entries], array + assert_equal data[:entries], seqset.each_entry.to_a + end + test "#ranges" do |data| assert_equal data[:ranges], SequenceSet.new(data[:input]).ranges end From db3809c4f41db53f742be66e86c9162eb5fe2369 Mon Sep 17 00:00:00 2001 From: nicholas evans Date: Sat, 23 Dec 2023 00:05:04 -0500 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8=20Add=20SequenceSet#append,=20to?= =?UTF-8?q?=20keep=20unsorted=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/net/imap/sequence_set.rb | 14 ++++++++++++++ test/net/imap/test_sequence_set.rb | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/lib/net/imap/sequence_set.rb b/lib/net/imap/sequence_set.rb index 3218a427..0ea42f7a 100644 --- a/lib/net/imap/sequence_set.rb +++ b/lib/net/imap/sequence_set.rb @@ -226,6 +226,8 @@ class IMAP # - #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+. + # - #append: Adds a given object to the set, appending it to the existing + # string, and 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. @@ -660,6 +662,18 @@ def add(object) end alias << add + # Adds a range or number to the set and returns +self+. + # + # Unlike #add, #merge, or #union, the new value is appended to #string. + # This may result in a #string which has duplicates or is out-of-order. + def append(object) + tuple = input_to_tuple object + entry = tuple_to_str tuple + tuple_add tuple + @string = -(string ? "#{@string},#{entry}" : entry) + self + end + # :call-seq: add?(object) -> self or nil # # Adds a range or number to the set and returns +self+. Returns +nil+ diff --git a/test/net/imap/test_sequence_set.rb b/test/net/imap/test_sequence_set.rb index f713c16b..7cee2946 100644 --- a/test/net/imap/test_sequence_set.rb +++ b/test/net/imap/test_sequence_set.rb @@ -288,6 +288,14 @@ def compare_to_reference_set(nums, set, seqset) assert_equal SequenceSet["1:*"], SequenceSet.new("5:*") << (1..4) end + test "#append" do + assert_equal "1,5", SequenceSet.new("1").append("5").string + assert_equal "*,1", SequenceSet.new("*").append(1).string + assert_equal "1:6,4:9", SequenceSet.new("1:6").append("4:9").string + assert_equal "1:4,5:*", SequenceSet.new("1:4").append(5..).string + assert_equal "5:*,1:4", SequenceSet.new("5:*").append(1..4).string + end + test "#merge" do seqset = -> { SequenceSet.new _1 } assert_equal seqset["1,5"], seqset["1"].merge("5") From 3e9ee5a85f4db93e48aa5f2747044642918043ef Mon Sep 17 00:00:00 2001 From: nicholas evans Date: Sat, 23 Dec 2023 00:06:07 -0500 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=93=9A=20Document=20SequenceSet=20nor?= =?UTF-8?q?malized=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/net/imap/sequence_set.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/net/imap/sequence_set.rb b/lib/net/imap/sequence_set.rb index 0ea42f7a..16ca6125 100644 --- a/lib/net/imap/sequence_set.rb +++ b/lib/net/imap/sequence_set.rb @@ -60,6 +60,21 @@ class IMAP # set = Net::IMAP::SequenceSet[1, 2, [3..7, 5], 6..10, 2048, 1024] # set.valid_string #=> "1:10,55,1024:2048" # + # == Normalized form + # + # When a sequence set is created with a single String value, that #string + # representation is preserved. SequenceSet's internal representation + # implicitly sorts all entries, de-duplicates numbers, and coalesces + # adjacent or overlapping ranges. Most enumeration methods and offset-based + # methods use this normalized representation. Most modification methods + # will convert #string to its normalized form. + # + # In some cases the order of the string representation is significant, such + # as the +ESORT+, CONTEXT=SORT, and +UIDPLUS+ extensions. Use + # #entries or #each_entry to enumerate the set in its original order. To + # preserve #string order while modifying a set, use #append, #string=, or + # #replace. + # # == Using * # # \IMAP sequence sets may contain a special value "*", which