protocol-media 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcbab91700c5745af1309a24fa474c1c6b05a491bf995c657eb3dbcc739d1b8e
4
- data.tar.gz: bf8a3fd2b20cdf4f54695333c33effdb509cf458cd1386d277294541bb1df2a7
3
+ metadata.gz: 41cc93afe36dc9af35b5ca77721b8859543b270627a3cb9f7f6cc2df2334d83e
4
+ data.tar.gz: 306bf200c34635b7eba643945121d6150054759ffa7ffa9ce5f08bf0668aa3b0
5
5
  SHA512:
6
- metadata.gz: 92c4bac474b6aa04042b6152449c11dcab50d82dd1a0ca9c1dcb4d34878b7362c533b62bdc680ec9b33697748e8934c8598224102dc6b7a4989a55d76aef87d1
7
- data.tar.gz: ce4ff11c22723981cdd2d09ae0c4cecdce7144c807bc73bf6cf0f72cf870d5cd3b7aa1fdbe5f6944e46f152c7d06a4d11f4bfd164e715edbdbec53dbc87fe2b8
6
+ metadata.gz: 7ab4060ead68e7b62f6486e298155e23060019bf7eb4bc9d2524efcb5cfe65e0a018945d46305730359b0c7e6bca158b44632ce5b7ca06ffd6c3c0bc2b8d327c
7
+ data.tar.gz: 84ccb4d5c42a6431cdc7da7d5ba6d1c776581bb561f0bde140d002c7cc5549f13304023c5d55bf4385c3e1de5a17be86c9d0aefcd2f708969aef3476772faa39
@@ -3,88 +3,114 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
- require_relative "range"
6
+ require_relative "type"
7
7
 
8
8
  module Protocol
9
9
  module Media
10
- # Maps media types and ranges to objects using type/subtype compatibility.
10
+ # An immutable map from concrete media types to objects for range-based lookup.
11
11
  class Map
12
- # Initialize an empty media map.
13
- def initialize
14
- @entries = {}
12
+ # Incrementally constructs an immutable media map.
13
+ class Builder
14
+ # Initialize an empty media map builder.
15
+ def initialize
16
+ @index = {}
17
+ end
18
+
19
+ # Associate a concrete media type with an object.
20
+ #
21
+ # @parameter media_type [String | Object] The concrete media type or compatible object.
22
+ # @parameter object [Object] The object associated with the media type.
23
+ def []=(media_type, object)
24
+ media_type = Type.for(media_type)
25
+
26
+ # Preserve the first registration as the default for wildcard queries:
27
+ @index["*/*"] ||= object
28
+ @index["#{media_type.type}/*"] ||= object
29
+ @index[media_type.name] = object
30
+ end
31
+
32
+ # Compile the current registrations into an immutable map.
33
+ # @returns [Map] The immutable media map.
34
+ def build
35
+ return Map.new(@index).freeze
36
+ end
15
37
  end
16
38
 
17
- # Associate a media type or range with an object.
18
- #
19
- # @parameter range [String | Object] The media type or compatible range.
20
- # @parameter object [Object] The object associated with the range.
21
- def []=(range, object)
22
- range = Range.for(range)
39
+ # Construct an immutable map using a builder.
40
+ # @yields {|builder| ...} The mutable builder.
41
+ # @returns [Map] The immutable media map.
42
+ def self.build
43
+ builder = Builder.new
44
+
45
+ if block_given?
46
+ yield builder
47
+ end
23
48
 
24
- @entries[name(range)] = [range, object]
49
+ return builder.build
25
50
  end
26
51
 
27
- # Find the object matching a media type or range.
52
+ # Convert keyed entries into a media map.
28
53
  #
29
- # Exact type/subtype registrations take priority, followed by the first compatible registration.
54
+ # Existing maps are returned unchanged, including their frozen state.
30
55
  #
31
- # @parameter range [String | Object] The media type or compatible range.
32
- # @returns [Object | nil] The matching object, if one exists.
33
- def [](range)
34
- if entry = lookup(Range.for(range))
35
- entry.last
56
+ # @parameter entries [Map | Enumerable] The existing map or keyed media range entries.
57
+ # @returns [Map] The existing map or a newly constructed immutable map.
58
+ def self.for(entries)
59
+ return entries if entries.instance_of?(self)
60
+
61
+ return build do |builder|
62
+ entries.each do |media_type, object|
63
+ builder[media_type] = object
64
+ end
36
65
  end
37
66
  end
38
67
 
39
- # Find the first object matching an ordered sequence of media ranges.
68
+ # Initialize a new media map with a given index.
40
69
  #
41
- # @parameter ranges [Enumerable] The media types or ranges in preference order.
42
- # @returns [Array(Object, Range | String) | nil] The matching object and original range, if one exists.
43
- def for(ranges)
44
- ranges.each do |range|
45
- if entry = lookup(Range.for(range))
46
- return [entry.last, range]
47
- end
48
- end
49
-
50
- return nil
70
+ # The index is retained without copying or freezing it.
71
+ #
72
+ # @parameter index [Hash] The keyed media range entries.
73
+ def initialize(index)
74
+ @index = index
51
75
  end
52
76
 
53
- # Freeze the map and its internal entries.
54
- #
55
- # @returns [self] The frozen map.
77
+ # Freeze the media map and its index.
78
+ # @returns [self]
56
79
  def freeze
57
- unless frozen?
58
- @entries.each_value(&:freeze)
59
- @entries.freeze
60
- end
80
+ return self if frozen?
81
+
82
+ @index.freeze
61
83
 
62
- super
84
+ return super
63
85
  end
64
86
 
65
- private
66
-
67
- def name(range)
68
- "#{range.type.downcase}/#{range.subtype.downcase}"
87
+ # Find the object matching a media type or range.
88
+ #
89
+ # @parameter media_range [String | Object] The media type or compatible range.
90
+ # @returns [Object | nil] The matching object, if one exists.
91
+ def [](media_range)
92
+ return lookup(Range.for(media_range))
69
93
  end
70
94
 
71
- def lookup(range)
72
- range_name = name(range)
73
- return @entries[range_name] if @entries.key?(range_name)
74
-
75
- @entries.each_value do |entry|
76
- return entry if match?(range, entry.first)
95
+ # Find the first object matching an ordered sequence of media ranges.
96
+ #
97
+ # @parameter media_ranges [Enumerable] The media types or ranges in preference order.
98
+ # @returns [Array(Object, Range | String) | nil] The matching object and original range, if one exists.
99
+ def for(media_ranges)
100
+ media_ranges.each do |media_range|
101
+ if object = lookup(Range.for(media_range))
102
+ return [object, media_range]
103
+ end
77
104
  end
78
105
 
79
106
  return nil
80
107
  end
81
108
 
82
- def match?(left, right)
83
- match_component?(left.type, right.type) && match_component?(left.subtype, right.subtype)
84
- end
109
+ private
85
110
 
86
- def match_component?(left, right)
87
- left == "*" || right == "*" || left.casecmp?(right)
111
+ def lookup(media_range)
112
+ media_range = Range.build(media_range.type, media_range.subtype)
113
+ return @index[media_range.name]
88
114
  end
89
115
  end
90
116
  end
@@ -16,6 +16,26 @@ module Protocol
16
16
  MEDIA_TYPE = /(?<type>#{TOKEN})\/(?<subtype>#{TOKEN})/
17
17
  PARAMETER = /\s*;\s*(?<key>#{TOKEN})=((?<value>#{TOKEN})|(?<quoted_value>#{QUOTED_STRING}))/
18
18
 
19
+ # Build a normalized media range.
20
+ #
21
+ # @parameter type [String] The top-level type.
22
+ # @parameter subtype [String] The subtype.
23
+ # @parameter parameters [Hash] The media range parameters.
24
+ # @returns [Range] The normalized media range.
25
+ def self.build(type, subtype = "*", parameters = {})
26
+ type = type.downcase
27
+ subtype = subtype.downcase
28
+
29
+ # A wildcard type requires a wildcard subtype:
30
+ if type == "*"
31
+ if subtype != "*"
32
+ raise ArgumentError, "Invalid wildcards in media range: #{type}/#{subtype}"
33
+ end
34
+ end
35
+
36
+ return new(type, subtype, parameters)
37
+ end
38
+
19
39
  # Parse strings into media ranges while preserving compatible objects.
20
40
  #
21
41
  # @parameter value [String | Object] A media range string or compatible object.
@@ -48,7 +68,7 @@ module Protocol
48
68
  raise ArgumentError, "Invalid media range: #{text.inspect}"
49
69
  end
50
70
 
51
- new(type, subtype, parameters)
71
+ return build(type, subtype, parameters)
52
72
  end
53
73
 
54
74
  # Parse media type parameters from the scanner.
@@ -85,12 +105,8 @@ module Protocol
85
105
  # @parameter subtype [String] The subtype.
86
106
  # @parameter parameters [Hash] The media range parameters.
87
107
  def initialize(type, subtype = "*", parameters = {})
88
- unless valid_wildcard?(type, subtype)
89
- raise ArgumentError, "Invalid wildcards in media range: #{type}/#{subtype}"
90
- end
91
-
92
- @type = type.downcase
93
- @subtype = subtype.downcase
108
+ @type = type
109
+ @subtype = subtype
94
110
  @parameters = parameters
95
111
  end
96
112
 
@@ -114,13 +130,34 @@ module Protocol
114
130
 
115
131
  alias to_str to_s
116
132
 
133
+ # Freeze the media range and its direct values.
134
+ # @returns [self]
135
+ def freeze
136
+ return self if frozen?
137
+
138
+ @type.freeze
139
+ @subtype.freeze
140
+ @parameters.freeze
141
+
142
+ return super
143
+ end
144
+
117
145
  # Whether this range matches the given media type or range.
118
146
  #
119
- # @parameter other [Range] The media type or range to match.
147
+ # @parameter media_range [Range] The media type or range to match.
120
148
  # @returns [Boolean] Whether the type and subtype are compatible.
121
- def match?(other)
122
- (@type == "*" || other.type == "*" || @type == other.type) &&
123
- (@subtype == "*" || other.subtype == "*" || @subtype == other.subtype)
149
+ def match?(media_range)
150
+ # Both type components must be compatible:
151
+ unless component_match?(@type, media_range.type)
152
+ return false
153
+ end
154
+
155
+ # Both subtype components must be compatible:
156
+ unless component_match?(@subtype, media_range.subtype)
157
+ return false
158
+ end
159
+
160
+ return true
124
161
  end
125
162
 
126
163
  alias === match?
@@ -129,11 +166,11 @@ module Protocol
129
166
  #
130
167
  # @parameter other [Object] The object to compare.
131
168
  # @returns [Boolean] Whether the values are equal.
132
- def ==(other)
133
- other.instance_of?(self.class) && @type == other.type && @subtype == other.subtype && @parameters == other.parameters
169
+ def eql?(other)
170
+ other.instance_of?(self.class) && @type.eql?(other.type) && @subtype.eql?(other.subtype) && @parameters.eql?(other.parameters)
134
171
  end
135
172
 
136
- alias eql? ==
173
+ alias == eql?
137
174
 
138
175
  # Generate a hash key consistent with {#eql?}.
139
176
  #
@@ -144,11 +181,17 @@ module Protocol
144
181
 
145
182
  private
146
183
 
147
- def valid_wildcard?(type, subtype)
148
- return subtype == "*" if type == "*"
149
- return false if type.include?("*")
184
+ def component_match?(component, other_component)
185
+ # A wildcard on either side matches any component:
186
+ if component == "*"
187
+ return true
188
+ end
189
+
190
+ if other_component == "*"
191
+ return true
192
+ end
150
193
 
151
- subtype == "*" || !subtype.include?("*")
194
+ return component == other_component
152
195
  end
153
196
 
154
197
  def self.unquote(value, normalize_whitespace)
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "range"
7
+
8
+ require "set"
9
+
10
+ module Protocol
11
+ module Media
12
+ # An immutable set of compatible media ranges.
13
+ class Set
14
+ include Enumerable
15
+
16
+ # An immutable set which matches any media range.
17
+ class Any
18
+ include Enumerable
19
+
20
+ RANGE = Range.new("*", "*").freeze
21
+ private_constant :RANGE
22
+
23
+ # Whether the set contains a compatible media type or range.
24
+ # @parameter media_range [String | Object] The media type or range to validate.
25
+ # @returns [Boolean]
26
+ def include?(media_range)
27
+ media_range = Range.for(media_range)
28
+
29
+ Range.build(media_range.type, media_range.subtype)
30
+
31
+ return true
32
+ end
33
+
34
+ alias === include?
35
+ alias match? include?
36
+
37
+ # Enumerate the universal media range.
38
+ # @yields {|media_range| ...} The universal media range.
39
+ # @returns [Enumerator | self]
40
+ def each
41
+ return to_enum unless block_given?
42
+
43
+ yield RANGE
44
+
45
+ return self
46
+ end
47
+
48
+ # The number of membership ranges.
49
+ # @returns [Integer]
50
+ def size
51
+ return 1
52
+ end
53
+
54
+ # Whether there are no membership ranges.
55
+ # @returns [Boolean]
56
+ def empty?
57
+ return false
58
+ end
59
+ end
60
+
61
+ ANY = Any.new.freeze
62
+
63
+ private_constant :Any, :ANY
64
+
65
+ # Incrementally constructs an immutable media set.
66
+ class Builder
67
+ # Initialize an empty media set builder.
68
+ def initialize
69
+ @types = {}
70
+ end
71
+
72
+ # Add a media range to the set under construction.
73
+ # @parameter media_range [String | Object] The media range or compatible object.
74
+ # @returns [self]
75
+ def add(media_range)
76
+ if @types.instance_of?(Any)
77
+ return self
78
+ end
79
+
80
+ media_range = Range.for(media_range)
81
+ media_range = Range.build(media_range.type, media_range.subtype)
82
+ type = media_range.type.freeze
83
+ subtype = media_range.subtype.freeze
84
+
85
+ if type == "*"
86
+ @types = ANY
87
+ elsif subtype == "*"
88
+ @types[type] = ANY
89
+ elsif subtypes = @types[type]
90
+ unless subtypes.instance_of?(Any)
91
+ subtypes.add(subtype)
92
+ end
93
+ else
94
+ @types[type] = ::Set.new([subtype])
95
+ end
96
+
97
+ return self
98
+ end
99
+
100
+ alias << add
101
+
102
+ # Compile the current ranges into an immutable set.
103
+ # @returns [Set] The immutable media set.
104
+ def build
105
+ if @types.instance_of?(Any)
106
+ return @types
107
+ end
108
+
109
+ return Set.new(@types).freeze
110
+ end
111
+ end
112
+
113
+ # Construct an immutable set using a builder.
114
+ # @yields {|builder| ...} The mutable builder.
115
+ # @returns [Set] The immutable media set.
116
+ def self.build
117
+ builder = Builder.new
118
+
119
+ if block_given?
120
+ yield builder
121
+ end
122
+
123
+ return builder.build
124
+ end
125
+
126
+ # Convert a sequence of ranges into a media set.
127
+ #
128
+ # Existing sets are returned unchanged, including their frozen state.
129
+ #
130
+ # @parameter media_ranges [Set | Enumerable] The existing set or media ranges.
131
+ # @returns [Set] The existing set or a newly constructed immutable set.
132
+ def self.for(media_ranges)
133
+ # Preserve an existing media set:
134
+ if media_ranges.instance_of?(self)
135
+ return media_ranges
136
+ end
137
+
138
+ # Preserve the universal media set:
139
+ if media_ranges.instance_of?(Any)
140
+ return media_ranges
141
+ end
142
+
143
+ return build do |builder|
144
+ media_ranges.each do |media_range|
145
+ builder << media_range
146
+ end
147
+ end
148
+ end
149
+
150
+ # Initialize a new media set with the given type index.
151
+ #
152
+ # The index is retained without copying or freezing it.
153
+ #
154
+ # @parameter types [Hash] The indexed media ranges.
155
+ def initialize(types)
156
+ @types = types
157
+ end
158
+
159
+ # Freeze the media set and its index.
160
+ # @returns [self]
161
+ def freeze
162
+ return self if frozen?
163
+
164
+ @types.each_value(&:freeze)
165
+ @types.freeze
166
+
167
+ return super
168
+ end
169
+
170
+ # Whether the set contains a compatible media type or range.
171
+ # @parameter media_range [String | Object] The media type or range to match.
172
+ # @returns [Boolean]
173
+ def include?(media_range)
174
+ media_range = normalize(media_range)
175
+ type = media_range.type
176
+ subtype = media_range.subtype
177
+
178
+ # A wildcard type matches any non-empty set:
179
+ if type == "*"
180
+ return !@types.empty?
181
+ end
182
+
183
+ subtypes = @types[type]
184
+
185
+ # An unknown type cannot match:
186
+ unless subtypes
187
+ return false
188
+ end
189
+
190
+ # A wildcard subtype matches any known type:
191
+ if subtype == "*"
192
+ return true
193
+ end
194
+
195
+ # A type-wide wildcard matches every subtype:
196
+ if subtypes.instance_of?(Any)
197
+ return true
198
+ end
199
+
200
+ return subtypes.include?(subtype)
201
+ end
202
+
203
+ alias === include?
204
+ alias match? include?
205
+
206
+ # Enumerate the canonical media ranges which define membership.
207
+ # @yields {|media_range| ...} Each canonical media range.
208
+ # @returns [Enumerator | self]
209
+ def each
210
+ return to_enum unless block_given?
211
+
212
+ @types.each do |type, subtypes|
213
+ if subtypes.instance_of?(Any)
214
+ yield Range.new(type, "*")
215
+ else
216
+ subtypes.each do |subtype|
217
+ yield Range.new(type, subtype)
218
+ end
219
+ end
220
+ end
221
+
222
+ return self
223
+ end
224
+
225
+ # The number of canonical membership ranges.
226
+ # @returns [Integer]
227
+ def size
228
+ return @types.sum do |_type, subtypes|
229
+ subtypes.size
230
+ end
231
+ end
232
+
233
+ # Whether the set contains no media ranges.
234
+ # @returns [Boolean]
235
+ def empty?
236
+ return @types.empty?
237
+ end
238
+
239
+ private
240
+
241
+ def normalize(media_range)
242
+ media_range = Range.for(media_range)
243
+ return Range.build(media_range.type, media_range.subtype)
244
+ end
245
+ end
246
+ end
247
+ end
@@ -9,16 +9,36 @@ module Protocol
9
9
  module Media
10
10
  # A concrete media type and its parameters.
11
11
  class Type < Range
12
+ # Parse strings and normalize compatible media type objects.
13
+ #
14
+ # @parameter value [String | Object] A media type string or compatible object.
15
+ # @returns [Type] The normalized concrete media type.
16
+ def self.for(value)
17
+ if value.is_a?(String)
18
+ return parse(value)
19
+ end
20
+
21
+ return build(value.type, value.subtype, value.parameters)
22
+ end
12
23
 
24
+ # Build a normalized concrete media type.
25
+ #
13
26
  # @parameter type [String] The top-level type.
14
27
  # @parameter subtype [String] The subtype.
15
28
  # @parameter parameters [Hash] The media type parameters.
16
- def initialize(type, subtype, parameters = {})
17
- if type.include?("*") || subtype.include?("*")
29
+ # @returns [Type] The normalized concrete media type.
30
+ def self.build(type, subtype, parameters = {})
31
+ # Concrete media types cannot use a wildcard type:
32
+ if type == "*"
33
+ raise ArgumentError, "Media types cannot contain wildcards: #{type}/#{subtype}"
34
+ end
35
+
36
+ # Concrete media types cannot use a wildcard subtype:
37
+ if subtype == "*"
18
38
  raise ArgumentError, "Media types cannot contain wildcards: #{type}/#{subtype}"
19
39
  end
20
40
 
21
- super
41
+ return super
22
42
  end
23
43
  end
24
44
  end
@@ -7,6 +7,6 @@
7
7
  module Protocol
8
8
  # Models media types used by internet protocols and data formats.
9
9
  module Media
10
- VERSION = "0.1.0"
10
+ VERSION = "0.2.0"
11
11
  end
12
12
  end
data/releases.md ADDED
@@ -0,0 +1,6 @@
1
+ # Releases
2
+
3
+ ## Unreleased
4
+
5
+ - Introduce `Protocol::Media::Set` for compatible media range membership.
6
+ - Make `Protocol::Media::Map` and `Protocol::Media::Set` immutable, with builders for incremental construction and indexed lookups.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: protocol-media
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -15,10 +15,12 @@ extra_rdoc_files: []
15
15
  files:
16
16
  - lib/protocol/media/map.rb
17
17
  - lib/protocol/media/range.rb
18
+ - lib/protocol/media/set.rb
18
19
  - lib/protocol/media/type.rb
19
20
  - lib/protocol/media/version.rb
20
21
  - license.md
21
22
  - readme.md
23
+ - releases.md
22
24
  homepage: https://github.com/socketry/protocol-media
23
25
  licenses:
24
26
  - MIT