diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 5e95f41c..8234dbc4 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -3276,6 +3276,7 @@ def self.saslprep(string, **opts) require_relative "imap/config" require_relative "imap/command_data" require_relative "imap/data_encoding" +require_relative "imap/data_lite" require_relative "imap/flags" require_relative "imap/response_data" require_relative "imap/response_parser" diff --git a/lib/net/imap/data_lite.rb b/lib/net/imap/data_lite.rb new file mode 100644 index 00000000..12b238de --- /dev/null +++ b/lib/net/imap/data_lite.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +# Some of the code in this file was copied from the polyfill-data gem. +# +# MIT License +# +# Copyright (c) 2023 Jim Gay, Joel Drapper, Nicholas Evans +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +module Net + class IMAP + data_or_object = RUBY_VERSION >= "3.2.0" ? ::Data : Object + class DataLite < data_or_object + def encode_with(coder) coder.map = attributes.transform_keys(&:to_s) end + def init_with(coder) initialize(**coder.map.transform_keys(&:to_sym)) end + end + + Data = DataLite + end +end + +# :nocov: +# Need to skip test coverage for the rest, because it isn't loaded by ruby 3.2+. +return if RUBY_VERSION >= "3.2.0" + +module Net + class IMAP + # DataLite is a temporary substitute for ruby 3.2's +Data+ class. DataLite + # is aliased as Net::IMAP::Data, so that code using it won't need to be + # updated when it is removed. + # + # See {ruby 3.2's documentation for Data}[https://docs.ruby-lang.org/en/3.2/Data.html]. + # + # [When running ruby 3.1] + # This class reimplements the API for ruby 3.2's +Data+, and should be + # compatible for nearly all use-cases. This reimplementation will be + # removed in +net-imap+ 0.6, when support for ruby 3.1 is dropped. + # + # _NOTE:_ +net-imap+ no longer supports ruby versions prior to 3.1. + # [When running ruby >= 3.2] + # This class inherits from +Data+ and _only_ defines the methods needed + # for YAML serialization. This will be dropped when +psych+ adds support + # for +Data+. + # + # Some of the code in this class was copied or adapted from the + # {polyfill-data gem}[https://rubygems.org/gems/polyfill-data], by Jim Gay + # and Joel Drapper, under the MIT license terms. + class DataLite + singleton_class.undef_method :new + + TYPE_ERROR = "%p is not a symbol nor a string" + ATTRSET_ERROR = "invalid data member: %p" + DUP_ERROR = "duplicate member: %p" + ARITY_ERROR = "wrong number of arguments (given %d, expected %s)" + private_constant :TYPE_ERROR, :ATTRSET_ERROR, :DUP_ERROR, :ARITY_ERROR + + # Defines a new Data class. + # + # _NOTE:_ Unlike ruby 3.2's +Data.define+, DataLite.define only supports + # member names which are valid local variable names. Member names can't + # be keywords (e.g: +next+ or +class+) or start with capital letters, "@", + # etc. + def self.define(*args, &block) + members = args.each_with_object({}) do |arg, members| + arg = arg.to_str unless arg in Symbol | String if arg.respond_to?(:to_str) + arg = arg.to_sym if arg in String + arg in Symbol or raise TypeError, TYPE_ERROR % [arg] + arg in %r{=} and raise ArgumentError, ATTRSET_ERROR % [arg] + members.key?(arg) and raise ArgumentError, DUP_ERROR % [arg] + members[arg] = true + end + members = members.keys.freeze + + klass = ::Class.new(self) + + klass.singleton_class.undef_method :define + klass.define_singleton_method(:members) { members } + + def klass.new(*args, **kwargs, &block) + if kwargs.size.positive? + if args.size.positive? + raise ArgumentError, ARITY_ERROR % [args.size, 0] + end + elsif members.size < args.size + expected = members.size.zero? ? 0 : 0..members.size + raise ArgumentError, ARITY_ERROR % [args.size, expected] + else + kwargs = Hash[members.take(args.size).zip(args)] + end + allocate.tap do |instance| + instance.__send__(:initialize, **kwargs, &block) + end.freeze + end + + klass.singleton_class.alias_method :[], :new + klass.attr_reader(*members) + + # Dynamically defined initializer methods are in an included module, + # rather than directly on DataLite (like in ruby 3.2+): + # * simpler to handle required kwarg ArgumentErrors + # * easier to ensure consistent ivar assignment order (object shape) + # * faster than instance_variable_set + klass.include(Module.new do + if members.any? + kwargs = members.map{"#{_1.name}:"}.join(", ") + params = members.map(&:name).join(", ") + ivars = members.map{"@#{_1.name}"}.join(", ") + attrs = members.map{"attrs[:#{_1.name}]"}.join(", ") + module_eval <<~RUBY, __FILE__, __LINE__ + 1 + protected + def initialize(#{kwargs}) #{ivars} = #{params}; freeze end + def marshal_load(attrs) #{ivars} = #{attrs}; freeze end + RUBY + end + end) + + klass.module_eval do _1.module_eval(&block) end if block_given? + + klass + end + + ## + # singleton-method: new + # call-seq: + # new(*args) -> instance + # new(**kwargs) -> instance + # + # Constuctor for classes defined with ::define. + # + # Aliased as ::[]. + + ## + # singleton-method: [] + # call-seq: + # ::[](*args) -> instance + # ::[](**kwargs) -> instance + # + # Constuctor for classes defined with ::define. + # + # Alias for ::new + + ## + def members; self.class.members end + def attributes; Hash[members.map {|m| [m, send(m)] }] end + def to_h(&block) attributes.to_h(&block) end + def hash; [self.class, attributes].hash end + def ==(other) self.class == other.class && to_h == other.to_h end + def eql?(other) self.class == other.class && hash == other.hash end + def deconstruct; attributes.values end + + def deconstruct_keys(keys) + raise TypeError unless keys.is_a?(Array) || keys.nil? + return attributes if keys&.first.nil? + attributes.slice(*keys) + end + + def with(**kwargs) + return self if kwargs.empty? + self.class.new(**attributes.merge(kwargs)) + end + + def inspect + __inspect_guard__(self) do |seen| + return "#" if seen + attrs = attributes.map {|kv| "%s=%p" % kv }.join(", ") + display = ["data", self.class.name, attrs].compact.join(" ") + "#<#{display}>" + end + end + alias_method :to_s, :inspect + + private + + def initialize_copy(source) super.freeze end + def marshal_dump; attributes end + + # Yields +true+ if +obj+ has been seen already, +false+ if it hasn't. + # Marks +obj+ as seen inside the block, so circuler references don't + # recursively trigger a SystemStackError (stack level too deep). + # + # Making circular references inside a Data object _should_ be very + # uncommon, but we'll support them for the sake of completeness. + def __inspect_guard__(obj) + preexisting = Thread.current[:__net_imap_data__inspect__] + Thread.current[:__net_imap_data__inspect__] ||= {}.compare_by_identity + inspect_guard = Thread.current[:__net_imap_data__inspect__] + if inspect_guard.include?(obj) + yield true + else + begin + inspect_guard[obj] = true + yield false + ensure + inspect_guard.delete(obj) + end + end + ensure + unless preexisting.equal?(inspect_guard) + Thread.current[:__net_imap_data__inspect__] = preexisting + end + end + + end + + end +end +# :nocov: diff --git a/test/net/imap/test_data_lite.rb b/test/net/imap/test_data_lite.rb new file mode 100644 index 00000000..54c57b89 --- /dev/null +++ b/test/net/imap/test_data_lite.rb @@ -0,0 +1,345 @@ +# frozen_string_literal: false + +require "net/imap" +require "test/unit" + +# This test file was copied from the polyfill-data gem. +# +# MIT License +# +# Copyright (c) 2023 Jim Gay +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +module Net + class IMAP + class TestData < Test::Unit::TestCase + def test_define + klass = Data.define(:foo, :bar) + assert_kind_of(Class, klass) + assert_equal(%i[foo bar], klass.members) + + assert_raise(NoMethodError) { Data.new(:foo) } + assert_raise(TypeError) { Data.define(0) } + + # Because some code is shared with Struct, check we don't share unnecessary functionality + assert_raise(TypeError) { Data.define(:foo, keyword_init: true) } + + refute_respond_to(Data.define, :define, "Cannot define from defined Data class") + end + + def test_define_edge_cases + # non-ascii + klass = Data.define(:"r\u{e9}sum\u{e9}") + o = klass.new(1) + assert_equal(1, o.send(:"r\u{e9}sum\u{e9}")) + + assert_raise(ArgumentError) { Data.define(:x=) } + assert_raise(ArgumentError, /duplicate member/) { Data.define(:x, :x) } + end + + def test_define_with_block + klass = Data.define(:a, :b) do + def c + a + b + end + end + + assert_equal(3, klass.new(1, 2).c) + end + + def test_initialize + klass = Data.define(:foo, :bar) + + # Regular + test = klass.new(1, 2) + assert_equal(1, test.foo) + assert_equal(2, test.bar) + assert_equal(test, klass.new(1, 2)) + assert_predicate(test, :frozen?) + + # Keywords + test_kw = klass.new(foo: 1, bar: 2) + assert_equal(1, test_kw.foo) + assert_equal(2, test_kw.bar) + assert_equal(test_kw, klass.new(foo: 1, bar: 2)) + assert_equal(test_kw, test) + + # Wrong protocol + assert_raise(ArgumentError) { klass.new(1) } + assert_raise(ArgumentError) { klass.new(1, 2, 3) } + assert_raise(ArgumentError) { klass.new(foo: 1) } + assert_raise(ArgumentError) { klass.new(foo: 1, bar: 2, baz: 3) } + # Could be converted to foo: 1, bar: 2, but too smart is confusing + assert_raise(ArgumentError) { klass.new(1, bar: 2) } + end + + def test_initialize_redefine + klass = Data.define(:foo, :bar) do + attr_reader :passed + + def initialize(*args, **kwargs) + @passed = [args, kwargs] + + super(foo: 1, bar: 2) # so we can experiment with passing wrong numbers of args + end + end + + assert_equal([[], {foo: 1, bar: 2}], klass.new(foo: 1, bar: 2).passed) + + # Positional arguments are converted to keyword ones + assert_equal([[], {foo: 1, bar: 2}], klass.new(1, 2).passed) + + # Missing arguments can be fixed in initialize + assert_equal([[], {foo: 1}], klass.new(foo: 1).passed) + + # Extra keyword arguments can be dropped in initialize + assert_equal([[], {foo: 1, bar: 2, baz: 3}], klass.new(foo: 1, bar: 2, baz: 3).passed) + end + + def test_instance_behavior + klass = Data.define(:foo, :bar) + + test = klass.new(1, 2) + assert_equal(1, test.foo) + assert_equal(2, test.bar) + assert_equal(%i[foo bar], test.members) + assert_equal(1, test.public_send(:foo)) + assert_equal(0, test.method(:foo).arity) + assert_equal([], test.method(:foo).parameters) + + assert_equal({foo: 1, bar: 2}, test.to_h) + assert_equal({"foo"=>"1", "bar"=>"2"}, test.to_h { [_1.to_s, _2.to_s] }) + + assert_equal({foo: 1, bar: 2}, test.deconstruct_keys(nil)) + assert_equal({foo: 1}, test.deconstruct_keys(%i[foo])) + assert_equal({foo: 1}, test.deconstruct_keys(%i[foo baz])) + assert_raise(TypeError) { test.deconstruct_keys(0) } + + test = klass.new(bar: 2, foo: 1) + assert_equal([1, 2], test.deconstruct) + + assert_kind_of(Integer, test.hash) + end + + def test_inspect + klass = Data.define(:a) + o = klass.new(1) + assert_equal("#", o.inspect) + + Object.const_set(:Foo, klass) + assert_equal("#", o.inspect) + Object.instance_eval { remove_const(:Foo) } + + klass = Data.define(:one, :two) + o = klass.new(1,2) + assert_equal("#", o.inspect) + assert_equal("#", o.to_s) + end + + def test_recursive_inspect + klass = Data.define(:value, :head, :tail) do + def initialize(value:, head: nil, tail: nil) + case tail + in Array if tail.empty? + tail = nil + in Array + succ, *rest = *tail + tail = self.class[head: self, value: succ, tail: rest] + in [tailprev, _, _] if tail.class == self.class && tailprev == self + # noop + in [tailprev, succ, rest] if tail.class == self.class + tail = self.class[head: self, value: succ, tail: rest] + in nil + else + tail = self.class[head: self, value: tail, tail: nil] + end + super(head:, value:, tail:) + end + end + + # anonymous class + list = klass[value: 1, tail: [2, 3, 4]] + seen = "#" + assert_equal( + "#>>>", + list.inspect + ) + + # named class + Object.const_set(:DoubleLinkList, klass) + list = DoubleLinkList[value: 1, tail: [2, 3, 4]] + seen = "#" + assert_equal( + "#>>>", + list.inspect + ) + ensure + Object.instance_eval { remove_const(:DoubleLinkList) } rescue nil + end + + def test_equal + klass1 = Data.define(:a) + klass2 = Data.define(:a) + o1 = klass1.new(1) + o2 = klass1.new(1) + o3 = klass2.new(1) + assert_equal(o1, o2) + refute_equal(o1, o3) + end + + def test_eql + klass1 = Data.define(:a) + klass2 = Data.define(:a) + o1 = klass1.new(1) + o2 = klass1.new(1) + o3 = klass2.new(1) + assert_operator(o1, :eql?, o2) + refute_operator(o1, :eql?, o3) + end + + def test_with + klass = Data.define(:foo, :bar) + source = klass.new(foo: 1, bar: 2) + + # Simple + test = source.with + assert_equal(source.object_id, test.object_id) + + # Changes + test = source.with(foo: 10) + + assert_equal(1, source.foo) + assert_equal(2, source.bar) + assert_equal(source, klass.new(foo: 1, bar: 2)) + + assert_equal(10, test.foo) + assert_equal(2, test.bar) + assert_equal(test, klass.new(foo: 10, bar: 2)) + + test = source.with(foo: 10, bar: 20) + + assert_equal(1, source.foo) + assert_equal(2, source.bar) + assert_equal(source, klass.new(foo: 1, bar: 2)) + + assert_equal(10, test.foo) + assert_equal(20, test.bar) + assert_equal(test, klass.new(foo: 10, bar: 20)) + + # Keyword splat + changes = { foo: 10, bar: 20 } + test = source.with(**changes) + + assert_equal(1, source.foo) + assert_equal(2, source.bar) + assert_equal(source, klass.new(foo: 1, bar: 2)) + + assert_equal(10, test.foo) + assert_equal(20, test.bar) + assert_equal(test, klass.new(foo: 10, bar: 20)) + + # Wrong protocol + assert_raise(ArgumentError, "wrong number of arguments (given 1, expected 0)") do + source.with(10) + end + assert_raise(ArgumentError, "unknown keywords: :baz, :quux") do + source.with(foo: 1, bar: 2, baz: 3, quux: 4) + end + assert_raise(ArgumentError, "wrong number of arguments (given 1, expected 0)") do + source.with(1, bar: 2) + end + assert_raise(ArgumentError, "wrong number of arguments (given 2, expected 0)") do + source.with(1, 2) + end + assert_raise(ArgumentError, "wrong number of arguments (given 1, expected 0)") do + source.with({ bar: 2 }) + end unless RUBY_VERSION < "2.8.0" + end + + def test_memberless + klass = Data.define + + test = klass.new + + assert_equal(klass.new, test) + refute_equal(Data.define.new, test) + + assert_equal('#', test.inspect) + assert_equal([], test.members) + assert_equal({}, test.to_h) + end + + def test_square_braces + klass = Data.define(:amount, :unit) + + distance = klass[10, 'km'] + + assert_equal(10, distance.amount) + assert_equal('km', distance.unit) + end + + def test_dup + klass = Data.define(:foo, :bar) + test = klass.new(foo: 1, bar: 2) + assert_equal(klass.new(foo: 1, bar: 2), test.dup) + assert_predicate(test.dup, :frozen?) + end + + Klass = Data.define(:foo, :bar) + + def test_marshal + test = Klass.new(foo: 1, bar: 2) + loaded = Marshal.load(Marshal.dump(test)) + assert_equal(test, loaded) + refute_same(test, loaded) + assert_predicate(loaded, :frozen?) + end + + def test_member_precedence + name_mod = Module.new do + def name + "default name" + end + + def other + "other" + end + end + + klass = Data.define(:name) do + include name_mod + end + + data = klass.new("test") + + assert_equal("test", data.name) + assert_equal("other", data.other) + end + end + end +end