dommy 0.13.0 → 0.14.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.
Files changed (155) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/backend/makiri_adapter.rb +63 -29
  3. data/lib/dommy/backend.rb +32 -9
  4. data/lib/dommy/bridge/constructor.rb +72 -0
  5. data/lib/dommy/bridge/errors.rb +42 -0
  6. data/lib/dommy/bridge/methods.rb +21 -14
  7. data/lib/dommy/bridge/sentinels.rb +39 -0
  8. data/lib/dommy/bridge/values.rb +78 -0
  9. data/lib/dommy/{js → bridge}/wire_tags.rb +14 -11
  10. data/lib/dommy/bridge.rb +22 -250
  11. data/lib/dommy/browser.rb +112 -11
  12. data/lib/dommy/character_data.rb +539 -0
  13. data/lib/dommy/class_list.rb +311 -0
  14. data/lib/dommy/crypto.rb +12 -1
  15. data/lib/dommy/css.rb +12 -0
  16. data/lib/dommy/data_transfer.rb +106 -0
  17. data/lib/dommy/document.rb +307 -369
  18. data/lib/dommy/dom_parser.rb +3 -0
  19. data/lib/dommy/element.rb +199 -1817
  20. data/lib/dommy/encodings/tables.rb +4 -4
  21. data/lib/dommy/encodings.rb +58 -27
  22. data/lib/dommy/event.rb +227 -1
  23. data/lib/dommy/event_source.rb +41 -0
  24. data/lib/dommy/form_data.rb +6 -64
  25. data/lib/dommy/form_entry_list.rb +227 -0
  26. data/lib/dommy/fragment.rb +320 -0
  27. data/lib/dommy/history.rb +3 -0
  28. data/lib/dommy/html_collection.rb +18 -8
  29. data/lib/dommy/html_elements/base.rb +338 -0
  30. data/lib/dommy/html_elements/embedded.rb +237 -0
  31. data/lib/dommy/html_elements/forms/form.rb +1088 -0
  32. data/lib/dommy/html_elements/forms/input.rb +642 -0
  33. data/lib/dommy/html_elements/forms/select.rb +536 -0
  34. data/lib/dommy/html_elements/forms.rb +8 -0
  35. data/lib/dommy/html_elements/interactive.rb +382 -0
  36. data/lib/dommy/html_elements/media.rb +329 -0
  37. data/lib/dommy/html_elements/metadata.rb +353 -0
  38. data/lib/dommy/html_elements/miscellaneous.rb +77 -0
  39. data/lib/dommy/html_elements/tables.rb +468 -0
  40. data/lib/dommy/html_elements.rb +11 -5322
  41. data/lib/dommy/hyperlink_utils.rb +171 -0
  42. data/lib/dommy/interaction/debug.rb +7 -6
  43. data/lib/dommy/interaction/dom_summary.rb +65 -40
  44. data/lib/dommy/interaction/driver.rb +29 -115
  45. data/lib/dommy/interaction/event_synthesis.rb +97 -4
  46. data/lib/dommy/interaction/field_interactor.rb +1 -13
  47. data/lib/dommy/interaction/form_submission.rb +89 -167
  48. data/lib/dommy/interaction/key_sender.rb +121 -0
  49. data/lib/dommy/interaction/mime_types.rb +25 -0
  50. data/lib/dommy/interaction.rb +2 -0
  51. data/lib/dommy/internal/accessibility_tree.rb +32 -9
  52. data/lib/dommy/internal/accessibility_visibility.rb +44 -0
  53. data/lib/dommy/internal/accessible_name.rb +85 -65
  54. data/lib/dommy/internal/aria_role.rb +21 -6
  55. data/lib/dommy/internal/aria_snapshot.rb +5 -0
  56. data/lib/dommy/internal/aria_state.rb +24 -5
  57. data/lib/dommy/internal/autofill.rb +141 -0
  58. data/lib/dommy/internal/backend_prefilter.rb +170 -0
  59. data/lib/dommy/internal/bounded_cache.rb +50 -0
  60. data/lib/dommy/internal/child_node.rb +9 -34
  61. data/lib/dommy/internal/css/calc.rb +143 -145
  62. data/lib/dommy/internal/css/cascade.rb +38 -393
  63. data/lib/dommy/internal/css/cascaded_declarations.rb +245 -0
  64. data/lib/dommy/internal/css/color.rb +61 -34
  65. data/lib/dommy/internal/css/computed_style_builder.rb +205 -0
  66. data/lib/dommy/internal/css/computed_style_declaration.rb +20 -4
  67. data/lib/dommy/internal/css/counters.rb +60 -37
  68. data/lib/dommy/internal/css/custom_properties.rb +87 -39
  69. data/lib/dommy/internal/css/media_query.rb +228 -203
  70. data/lib/dommy/internal/css/parser.rb +29 -12
  71. data/lib/dommy/internal/css/property_registry.rb +32 -56
  72. data/lib/dommy/internal/css/renderability.rb +61 -0
  73. data/lib/dommy/internal/css/rule_index.rb +17 -140
  74. data/lib/dommy/internal/css/selector_dependencies.rb +162 -0
  75. data/lib/dommy/internal/css/style_cache.rb +61 -0
  76. data/lib/dommy/internal/css/ua_stylesheet.rb +40 -1
  77. data/lib/dommy/internal/css/value_tokens.rb +60 -0
  78. data/lib/dommy/internal/directionality.rb +188 -0
  79. data/lib/dommy/internal/document_generations.rb +169 -0
  80. data/lib/dommy/internal/document_interaction_state.rb +47 -0
  81. data/lib/dommy/internal/document_live_ranges.rb +97 -0
  82. data/lib/dommy/internal/dom_matching.rb +28 -12
  83. data/lib/dommy/internal/element_aria.rb +163 -0
  84. data/lib/dommy/internal/element_geometry.rb +69 -0
  85. data/lib/dommy/internal/element_matching.rb +5 -12
  86. data/lib/dommy/internal/element_shadow.rb +117 -0
  87. data/lib/dommy/internal/element_state.rb +232 -0
  88. data/lib/dommy/internal/element_tasks.rb +19 -0
  89. data/lib/dommy/internal/element_top_layer.rb +63 -0
  90. data/lib/dommy/internal/enumerated_keyword_sets.rb +53 -0
  91. data/lib/dommy/internal/exception_report.rb +1 -1
  92. data/lib/dommy/internal/input_type.rb +280 -0
  93. data/lib/dommy/internal/insertion_point.rb +62 -0
  94. data/lib/dommy/internal/mime_type.rb +74 -0
  95. data/lib/dommy/internal/mutation_coordinator.rb +57 -198
  96. data/lib/dommy/internal/namespaces.rb +41 -43
  97. data/lib/dommy/internal/node_adopter.rb +84 -77
  98. data/lib/dommy/internal/node_factory.rb +157 -0
  99. data/lib/dommy/internal/node_identity.rb +31 -0
  100. data/lib/dommy/internal/node_wrapper_cache.rb +61 -207
  101. data/lib/dommy/internal/observer_options.rb +93 -0
  102. data/lib/dommy/internal/parent_node.rb +27 -27
  103. data/lib/dommy/internal/post_insertion_steps.rb +213 -0
  104. data/lib/dommy/internal/reflected_attributes.rb +425 -13
  105. data/lib/dommy/internal/rendered_text/collector.rb +221 -0
  106. data/lib/dommy/internal/rendered_text/computed_box.rb +39 -0
  107. data/lib/dommy/internal/rendered_text/fragment.rb +55 -0
  108. data/lib/dommy/internal/rendered_text/items.rb +57 -0
  109. data/lib/dommy/internal/rendered_text/whitespace_collapser.rb +132 -0
  110. data/lib/dommy/internal/selector_arguments.rb +94 -0
  111. data/lib/dommy/internal/selector_index.rb +3 -1
  112. data/lib/dommy/internal/selector_matcher.rb +23 -383
  113. data/lib/dommy/internal/selector_parser.rb +2 -37
  114. data/lib/dommy/internal/template_content_registry.rb +112 -66
  115. data/lib/dommy/internal/text_flattening.rb +20 -0
  116. data/lib/dommy/internal/text_selection.rb +104 -0
  117. data/lib/dommy/internal/toggle_events.rb +56 -0
  118. data/lib/dommy/internal/toggle_task_tracker.rb +57 -0
  119. data/lib/dommy/internal/url_parser.rb +722 -569
  120. data/lib/dommy/internal/url_record_accessors.rb +90 -0
  121. data/lib/dommy/internal/window_constructors.rb +190 -0
  122. data/lib/dommy/internal/xml_serialization.rb +168 -80
  123. data/lib/dommy/js/crossing_profile.rb +75 -0
  124. data/lib/dommy/js/dom_interfaces.rb +22 -2
  125. data/lib/dommy/js/host_bridge.rb +43 -187
  126. data/lib/dommy/js/host_callbacks.rb +96 -0
  127. data/lib/dommy/js/host_runtime.js +840 -763
  128. data/lib/dommy/js/import_map.rb +10 -1
  129. data/lib/dommy/js/marshaller.rb +94 -65
  130. data/lib/dommy/js/observable_runtime.js +125 -146
  131. data/lib/dommy/js/runtime.rb +10 -43
  132. data/lib/dommy/js/runtime_registry.rb +46 -0
  133. data/lib/dommy/js/script_boot.rb +38 -73
  134. data/lib/dommy/js/webidl_tables.js +619 -0
  135. data/lib/dommy/js/window_wiring.rb +69 -0
  136. data/lib/dommy/location.rb +152 -103
  137. data/lib/dommy/mutation_observer.rb +3 -57
  138. data/lib/dommy/navigation.rb +65 -12
  139. data/lib/dommy/node.rb +46 -20
  140. data/lib/dommy/promise.rb +58 -4
  141. data/lib/dommy/resources.rb +19 -1
  142. data/lib/dommy/shadow_root.rb +11 -5
  143. data/lib/dommy/style_declaration.rb +284 -0
  144. data/lib/dommy/svg_elements.rb +1 -45
  145. data/lib/dommy/url.rb +20 -69
  146. data/lib/dommy/url_pattern/regexp_translator.rb +1 -6
  147. data/lib/dommy/url_pattern/tokenizer.rb +4 -2
  148. data/lib/dommy/validity_state.rb +383 -0
  149. data/lib/dommy/version.rb +1 -1
  150. data/lib/dommy/window.rb +30 -166
  151. data/lib/dommy/xml_http_request.rb +35 -4
  152. data/lib/dommy.rb +23 -3
  153. metadata +74 -7
  154. data/lib/dommy/bridge/constructor_registry.rb +0 -28
  155. data/lib/dommy/internal/css_pseudo_handlers.rb +0 -378
@@ -28,12 +28,17 @@ module Dommy
28
28
  def default_port = SPECIAL[scheme]
29
29
  end
30
30
 
31
- def parse(input, base_input = nil)
31
+ # `encoding` is the Encoding Standard name HTML's "encoding-parse a URL"
32
+ # hands the parser (the document's character encoding); nil means UTF-8,
33
+ # which is what the URL API and every other caller uses. It only changes
34
+ # the query, and only for a special URL that is not ws/wss (see
35
+ # BasicParser#query_encoding).
36
+ def parse(input, base_input = nil, encoding: nil)
32
37
  base = nil
33
38
  if base_input && base_input != ""
34
39
  base = base_input.is_a?(Record) ? base_input : run(base_input.to_s, nil)
35
40
  end
36
- run(input.to_s, base)
41
+ run(input.to_s, base, encoding: encoding)
37
42
  end
38
43
 
39
44
  # The basic URL parser with a state override: `input` is read from
@@ -45,684 +50,832 @@ module Dommy
45
50
  run(input.to_s, nil, url: url, state_override: state)
46
51
  end
47
52
 
48
- # ===== percent-encode sets =====
53
+ # The URL Standard vocabulary the parser is written in: the percent-encode
54
+ # sets, the host and IPv6 parsers, the serializers, and the path helpers.
55
+ # Every one is a function of its arguments alone.
56
+ #
57
+ # It is a separate module because it has two users with nothing else in
58
+ # common: UrlParser extends it, so callers outside this file keep writing
59
+ # `UrlParser.serialize(record)`, and BasicParser includes it, because a
60
+ # state machine is written in this vocabulary. Including UrlParser itself
61
+ # would also hand BasicParser `parse` / `run` — the entry points that build
62
+ # a BasicParser — which is not a vocabulary a parser state should reach for.
63
+ module Syntax
64
+ # ===== percent-encode sets =====
65
+
66
+ def c0?(cp) = cp <= 0x1F || cp > 0x7E
67
+ def fragment_set?(cp) = c0?(cp) || [0x20, 0x22, 0x3C, 0x3E, 0x60].include?(cp)
68
+ def query_set?(cp) = c0?(cp) || [0x20, 0x22, 0x23, 0x3C, 0x3E].include?(cp)
69
+ def special_query_set?(cp) = query_set?(cp) || cp == 0x27
70
+ def path_set?(cp) = query_set?(cp) || [0x3F, 0x5E, 0x60, 0x7B, 0x7D].include?(cp)
71
+ def userinfo_set?(cp) = path_set?(cp) || [0x2F, 0x3A, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x7C].include?(cp)
72
+
73
+ # UTF-8 percent-encode a single code point against `set` (a predicate).
74
+ def pe(char, set)
75
+ return char unless set.call(char.ord)
76
+
77
+ char.b.bytes.map { |b| format("%%%02X", b) }.join
78
+ end
49
79
 
50
- def c0?(cp) = cp <= 0x1F || cp > 0x7E
51
- def fragment_set?(cp) = c0?(cp) || [0x20, 0x22, 0x3C, 0x3E, 0x60].include?(cp)
52
- def query_set?(cp) = c0?(cp) || [0x20, 0x22, 0x23, 0x3C, 0x3E].include?(cp)
53
- def special_query_set?(cp) = query_set?(cp) || cp == 0x27
54
- def path_set?(cp) = query_set?(cp) || [0x3F, 0x5E, 0x60, 0x7B, 0x7D].include?(cp)
55
- def userinfo_set?(cp) = path_set?(cp) || [0x2F, 0x3A, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x7C].include?(cp)
80
+ # Spec: "percent-encode after encoding" (URL §1.3), the query state's
81
+ # flush. The whole buffer is encoded at once — ISO-2022-JP is stateful,
82
+ # so encoding code point by code point would emit a fresh escape per
83
+ # character — then each byte is percent-encoded if it falls in `set`. A
84
+ # code point the encoding cannot represent becomes the spec's numeric
85
+ # character reference escape ("%26%23" + decimal + "%3B").
86
+ def percent_encode_after_encoding(encoding_name, input, set)
87
+ out = +"".b
88
+ encode_bytes(encoding_name, input).each do |byte|
89
+ out << (set.call(byte) ? format("%%%02X", byte) : byte.chr)
90
+ end
91
+ out.force_encoding(::Encoding::UTF_8)
92
+ end
56
93
 
57
- # UTF-8 percent-encode a single code point against `set` (a predicate).
58
- def pe(char, set)
59
- return char unless set.call(char.ord)
94
+ # The bytes `input` encodes to under `encoding_name` (an Encoding
95
+ # Standard name), or its UTF-8 bytes when the name is nil or Ruby has no
96
+ # matching encoding. Only a handful of the standard's names lack one
97
+ # (x-user-defined, replacement), and those are never a document's.
98
+ def encode_bytes(encoding_name, input)
99
+ ruby = begin
100
+ ::Encoding.find(encoding_name.to_s)
101
+ rescue ArgumentError
102
+ nil
103
+ end
104
+ return input.b.bytes if ruby.nil? || ruby == ::Encoding::UTF_8
60
105
 
61
- char.b.bytes.map { |b| format("%%%02X", b) }.join
62
- end
106
+ begin
107
+ input.encode(ruby).b.bytes
108
+ rescue ::Encoding::UndefinedConversionError, ::Encoding::InvalidByteSequenceError
109
+ per_code_point_bytes(ruby, input)
110
+ end
111
+ end
63
112
 
64
- def percent_decode(str)
65
- out = +"".b
66
- bytes = str.b
67
- i = 0
68
- while i < bytes.bytesize
69
- b = bytes.getbyte(i)
70
- if b == 0x25 && i + 2 < bytes.bytesize &&
71
- bytes.byteslice(i + 1, 2) =~ /\A[0-9A-Fa-f]{2}\z/
72
- out << bytes.byteslice(i + 1, 2).to_i(16)
73
- i += 3
74
- else
75
- out << b
76
- i += 1
113
+ # A code point the encoding cannot represent yields the numeric-reference
114
+ # escape, already percent-encoded so the caller's per-byte pass leaves it
115
+ # alone.
116
+ def per_code_point_bytes(ruby, input)
117
+ out = +"".b
118
+ input.each_char do |char|
119
+ out << char.encode(ruby)
120
+ rescue ::Encoding::UndefinedConversionError, ::Encoding::InvalidByteSequenceError
121
+ out << "%26%23#{char.ord}%3B"
77
122
  end
123
+ out.bytes
78
124
  end
79
- out
80
- end
81
125
 
82
- # ===== host parsing =====
126
+ def percent_decode(str)
127
+ out = +"".b
128
+ bytes = str.b
129
+ i = 0
130
+ while i < bytes.bytesize
131
+ b = bytes.getbyte(i)
132
+ if b == 0x25 && i + 2 < bytes.bytesize &&
133
+ bytes.byteslice(i + 1, 2) =~ /\A[0-9A-Fa-f]{2}\z/
134
+ out << bytes.byteslice(i + 1, 2).to_i(16)
135
+ i += 3
136
+ else
137
+ out << b
138
+ i += 1
139
+ end
140
+ end
141
+ out
142
+ end
83
143
 
84
- FORBIDDEN_HOST = [0x00, 0x09, 0x0A, 0x0D, 0x20, 0x23, 0x2F, 0x3A, 0x3C, 0x3E,
85
- 0x3F, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C].freeze
144
+ # ===== host parsing =====
86
145
 
87
- def forbidden_host?(cp) = FORBIDDEN_HOST.include?(cp)
88
- def forbidden_domain?(cp) = forbidden_host?(cp) || cp <= 0x1F || cp == 0x25 || cp == 0x7F
146
+ FORBIDDEN_HOST = [0x00, 0x09, 0x0A, 0x0D, 0x20, 0x23, 0x2F, 0x3A, 0x3C, 0x3E,
147
+ 0x3F, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C].freeze
89
148
 
90
- def parse_host(input, special)
91
- if input.start_with?("[")
92
- raise Failure, "unclosed IPv6 address" unless input.end_with?("]")
149
+ def forbidden_host?(cp) = FORBIDDEN_HOST.include?(cp)
150
+ def forbidden_domain?(cp) = forbidden_host?(cp) || cp <= 0x1F || cp == 0x25 || cp == 0x7F
93
151
 
94
- return "[#{parse_ipv6(input[1...-1])}]"
95
- end
96
- return parse_opaque_host(input) unless special
97
- raise Failure, "empty host" if input.empty?
152
+ def parse_host(input, special)
153
+ if input.start_with?("[")
154
+ raise Failure, "unclosed IPv6 address" unless input.end_with?("]")
98
155
 
99
- # UTF-8-decode-without-BOM the percent-decoded bytes: malformed
100
- # sequences become U+FFFD (which domain-to-ASCII then rejects),
101
- # matching the spec rather than crashing on invalid encoding.
102
- domain = percent_decode(input).force_encoding("UTF-8").scrub("\uFFFD")
103
- # The domain parser (beStrict false): an ASCII domain is only
104
- # lowercased, whatever UTS #46 would make of it, for web
105
- # compatibility; a non-ASCII one goes through ToASCII.
106
- ascii =
107
- if domain.ascii_only?
108
- domain.downcase
109
- else
110
- begin
111
- IDNA.to_ascii(domain, check_hyphens: false, verify_dns_length: false)
112
- rescue IDNA::Error, Punycode::Error => e
113
- raise Failure, "domain to ASCII: #{e.message}"
114
- end
156
+ return "[#{parse_ipv6(input[1...-1])}]"
115
157
  end
116
- raise Failure, "empty domain" if ascii.empty?
117
- raise Failure, "forbidden domain code point" if ascii.each_char.any? { |ch| forbidden_domain?(ch.ord) }
158
+ return parse_opaque_host(input) unless special
159
+ raise Failure, "empty host" if input.empty?
160
+
161
+ # UTF-8-decode-without-BOM the percent-decoded bytes: malformed
162
+ # sequences become U+FFFD (which domain-to-ASCII then rejects),
163
+ # matching the spec rather than crashing on invalid encoding.
164
+ domain = percent_decode(input).force_encoding("UTF-8").scrub("\uFFFD")
165
+ # The domain parser (beStrict false): an ASCII domain is only
166
+ # lowercased, whatever UTS #46 would make of it, for web
167
+ # compatibility; a non-ASCII one goes through ToASCII.
168
+ ascii =
169
+ if domain.ascii_only?
170
+ domain.downcase
171
+ else
172
+ begin
173
+ IDNA.to_ascii(domain, check_hyphens: false, verify_dns_length: false)
174
+ rescue IDNA::Error, Punycode::Error => e
175
+ raise Failure, "domain to ASCII: #{e.message}"
176
+ end
177
+ end
178
+ raise Failure, "empty domain" if ascii.empty?
179
+ raise Failure, "forbidden domain code point" if ascii.each_char.any? { |ch| forbidden_domain?(ch.ord) }
118
180
 
119
- if ends_in_number?(ascii)
120
- ip = Ipv4Parser.parse(ascii)
121
- raise Failure, "invalid IPv4 address" if ip.nil?
181
+ if ends_in_number?(ascii)
182
+ ip = Ipv4Parser.parse(ascii)
183
+ raise Failure, "invalid IPv4 address" if ip.nil?
122
184
 
123
- return ip
185
+ return ip
186
+ end
187
+ ascii
124
188
  end
125
- ascii
126
- end
127
-
128
- def parse_opaque_host(input)
129
- raise Failure, "forbidden host code point" if input.each_char.any? { |ch| forbidden_host?(ch.ord) }
130
-
131
- input.each_char.map { |ch| pe(ch, method(:c0?)) }.join
132
- end
133
189
 
134
- def ends_in_number?(input)
135
- parts = input.split(".", -1)
136
- parts.pop if parts.length > 1 && parts.last == ""
137
- return false if parts.empty?
190
+ def parse_opaque_host(input)
191
+ raise Failure, "forbidden host code point" if input.each_char.any? { |ch| forbidden_host?(ch.ord) }
138
192
 
139
- last = parts.last
140
- return false if last.empty?
141
- return true if last.match?(/\A[0-9]+\z/)
142
-
143
- last.match?(/\A0[xX][0-9A-Fa-f]*\z/)
144
- end
193
+ input.each_char.map { |ch| pe(ch, method(:c0?)) }.join
194
+ end
145
195
 
146
- # WHATWG IPv6 parser -> compressed serialized string (no brackets).
147
- def parse_ipv6(input)
148
- address = [0, 0, 0, 0, 0, 0, 0, 0]
149
- piece_index = 0
150
- compress = nil
151
- chars = input.chars
152
- ptr = 0
153
- c = ->(i) { i < chars.length ? chars[i] : nil }
196
+ def ends_in_number?(input)
197
+ parts = input.split(".", -1)
198
+ parts.pop if parts.length > 1 && parts.last == ""
199
+ return false if parts.empty?
154
200
 
155
- if c.call(ptr) == ":"
156
- raise Failure, "IPv6 starts with single colon" unless c.call(ptr + 1) == ":"
201
+ last = parts.last
202
+ return false if last.empty?
203
+ return true if last.match?(/\A[0-9]+\z/)
157
204
 
158
- ptr += 2
159
- piece_index += 1
160
- compress = piece_index
205
+ last.match?(/\A0[xX][0-9A-Fa-f]*\z/)
161
206
  end
162
207
 
163
- while c.call(ptr)
164
- raise Failure, "too many IPv6 pieces" if piece_index == 8
208
+ # WHATWG IPv6 parser -> compressed serialized string (no brackets).
209
+ def parse_ipv6(input)
210
+ address = [0, 0, 0, 0, 0, 0, 0, 0]
211
+ piece_index = 0
212
+ compress = nil
213
+ chars = input.chars
214
+ ptr = 0
215
+ c = ->(i) { i < chars.length ? chars[i] : nil }
165
216
 
166
217
  if c.call(ptr) == ":"
167
- raise Failure, "multiple IPv6 compressions" unless compress.nil?
218
+ raise Failure, "IPv6 starts with single colon" unless c.call(ptr + 1) == ":"
168
219
 
169
- ptr += 1
220
+ ptr += 2
170
221
  piece_index += 1
171
222
  compress = piece_index
172
- next
173
223
  end
174
224
 
175
- value = 0
176
- length = 0
177
- while length < 4 && c.call(ptr)&.match?(/[0-9A-Fa-f]/)
178
- value = value * 16 + c.call(ptr).to_i(16)
179
- ptr += 1
180
- length += 1
181
- end
225
+ while c.call(ptr)
226
+ raise Failure, "too many IPv6 pieces" if piece_index == 8
182
227
 
183
- if c.call(ptr) == "."
184
- raise Failure, "IPv4-in-IPv6 with no digits" if length.zero?
228
+ if c.call(ptr) == ":"
229
+ raise Failure, "multiple IPv6 compressions" unless compress.nil?
185
230
 
186
- ptr -= length
187
- raise Failure, "too few pieces for embedded IPv4" if piece_index > 6
231
+ ptr += 1
232
+ piece_index += 1
233
+ compress = piece_index
234
+ next
235
+ end
236
+
237
+ value = 0
238
+ length = 0
239
+ while length < 4 && c.call(ptr)&.match?(/[0-9A-Fa-f]/)
240
+ value = value * 16 + c.call(ptr).to_i(16)
241
+ ptr += 1
242
+ length += 1
243
+ end
244
+
245
+ if c.call(ptr) == "."
246
+ raise Failure, "IPv4-in-IPv6 with no digits" if length.zero?
247
+
248
+ ptr -= length
249
+ raise Failure, "too few pieces for embedded IPv4" if piece_index > 6
250
+
251
+ numbers_seen = 0
252
+ while c.call(ptr)
253
+ ipv4_piece = nil
254
+ if numbers_seen.positive?
255
+ if c.call(ptr) == "." && numbers_seen < 4
256
+ ptr += 1
257
+ else
258
+ raise Failure, "invalid embedded IPv4"
259
+ end
260
+ end
261
+ raise Failure, "invalid embedded IPv4 digit" unless c.call(ptr)&.match?(/[0-9]/)
262
+
263
+ while c.call(ptr)&.match?(/[0-9]/)
264
+ number = c.call(ptr).to_i
265
+ if ipv4_piece.nil?
266
+ ipv4_piece = number
267
+ elsif ipv4_piece.zero?
268
+ raise Failure, "leading zero in embedded IPv4"
269
+ else
270
+ ipv4_piece = ipv4_piece * 10 + number
271
+ end
272
+ raise Failure, "embedded IPv4 piece > 255" if ipv4_piece > 255
188
273
 
189
- numbers_seen = 0
190
- while c.call(ptr)
191
- ipv4_piece = nil
192
- if numbers_seen.positive?
193
- if c.call(ptr) == "." && numbers_seen < 4
194
274
  ptr += 1
195
- else
196
- raise Failure, "invalid embedded IPv4"
197
275
  end
276
+ address[piece_index] = address[piece_index] * 0x100 + ipv4_piece
277
+ numbers_seen += 1
278
+ piece_index += 1 if numbers_seen == 2 || numbers_seen == 4
198
279
  end
199
- raise Failure, "invalid embedded IPv4 digit" unless c.call(ptr)&.match?(/[0-9]/)
200
-
201
- while c.call(ptr)&.match?(/[0-9]/)
202
- number = c.call(ptr).to_i
203
- if ipv4_piece.nil?
204
- ipv4_piece = number
205
- elsif ipv4_piece.zero?
206
- raise Failure, "leading zero in embedded IPv4"
207
- else
208
- ipv4_piece = ipv4_piece * 10 + number
209
- end
210
- raise Failure, "embedded IPv4 piece > 255" if ipv4_piece > 255
280
+ raise Failure, "incomplete embedded IPv4" unless numbers_seen == 4
211
281
 
212
- ptr += 1
213
- end
214
- address[piece_index] = address[piece_index] * 0x100 + ipv4_piece
215
- numbers_seen += 1
216
- piece_index += 1 if numbers_seen == 2 || numbers_seen == 4
282
+ break
283
+ elsif c.call(ptr) == ":"
284
+ ptr += 1
285
+ raise Failure, "trailing colon in IPv6" if c.call(ptr).nil?
286
+ elsif c.call(ptr)
287
+ raise Failure, "invalid IPv6 code point"
217
288
  end
218
- raise Failure, "incomplete embedded IPv4" unless numbers_seen == 4
219
289
 
220
- break
221
- elsif c.call(ptr) == ":"
222
- ptr += 1
223
- raise Failure, "trailing colon in IPv6" if c.call(ptr).nil?
224
- elsif c.call(ptr)
225
- raise Failure, "invalid IPv6 code point"
290
+ address[piece_index] = value
291
+ piece_index += 1
226
292
  end
227
293
 
228
- address[piece_index] = value
229
- piece_index += 1
230
- end
231
-
232
- if compress
233
- swaps = piece_index - compress
234
- piece_index = 7
235
- while piece_index != 0 && swaps.positive?
236
- address[piece_index], address[compress + swaps - 1] = address[compress + swaps - 1], address[piece_index]
237
- piece_index -= 1
238
- swaps -= 1
294
+ if compress
295
+ swaps = piece_index - compress
296
+ piece_index = 7
297
+ while piece_index != 0 && swaps.positive?
298
+ address[piece_index], address[compress + swaps - 1] = address[compress + swaps - 1], address[piece_index]
299
+ piece_index -= 1
300
+ swaps -= 1
301
+ end
302
+ elsif piece_index != 8
303
+ raise Failure, "too few IPv6 pieces"
239
304
  end
240
- elsif piece_index != 8
241
- raise Failure, "too few IPv6 pieces"
242
- end
243
305
 
244
- serialize_ipv6(address)
245
- end
306
+ serialize_ipv6(address)
307
+ end
246
308
 
247
- def serialize_ipv6(pieces)
248
- # Find the longest run (length > 1) of zero pieces to compress.
249
- best_start = nil
250
- best_len = 0
251
- i = 0
252
- while i < 8
253
- if pieces[i].zero?
254
- j = i
255
- j += 1 while j < 8 && pieces[j].zero?
256
- if (j - i) > best_len
257
- best_len = j - i
258
- best_start = i
309
+ def serialize_ipv6(pieces)
310
+ # Find the longest run (length > 1) of zero pieces to compress.
311
+ best_start = nil
312
+ best_len = 0
313
+ i = 0
314
+ while i < 8
315
+ if pieces[i].zero?
316
+ j = i
317
+ j += 1 while j < 8 && pieces[j].zero?
318
+ if (j - i) > best_len
319
+ best_len = j - i
320
+ best_start = i
321
+ end
322
+ i = j
323
+ else
324
+ i += 1
259
325
  end
260
- i = j
261
- else
326
+ end
327
+ best_start = nil if best_len < 2
328
+
329
+ out = +""
330
+ i = 0
331
+ while i < 8
332
+ if best_start == i
333
+ out << (i.zero? ? "::" : ":")
334
+ i += best_len
335
+ next
336
+ end
337
+ out << pieces[i].to_s(16)
338
+ out << ":" if i < 7
262
339
  i += 1
263
340
  end
341
+ out
342
+ end
343
+
344
+ # ===== serialization =====
345
+
346
+ def serialize_path(record)
347
+ return record.path if record.opaque_path?
348
+
349
+ record.path.map { |seg| "/#{seg}" }.join
264
350
  end
265
- best_start = nil if best_len < 2
266
351
 
267
- out = +""
268
- i = 0
269
- while i < 8
270
- if best_start == i
271
- out << (i.zero? ? "::" : ":")
272
- i += best_len
273
- next
352
+ def serialize(record, exclude_fragment: false)
353
+ out = +"#{record.scheme}:"
354
+ if record.host
355
+ out << "//"
356
+ if record.includes_credentials?
357
+ out << record.username
358
+ out << ":#{record.password}" unless record.password.empty?
359
+ out << "@"
360
+ end
361
+ out << record.host
362
+ out << ":#{record.port}" if record.port
363
+ elsif !record.opaque_path? && record.path.is_a?(Array) &&
364
+ record.path.length > 1 && record.path[0] == ""
365
+ out << "/."
274
366
  end
275
- out << pieces[i].to_s(16)
276
- out << ":" if i < 7
277
- i += 1
367
+ out << serialize_path(record)
368
+ out << "?#{record.query}" if record.query
369
+ out << "##{record.fragment}" if record.fragment && !exclude_fragment
370
+ out
278
371
  end
279
- out
280
- end
281
372
 
282
- # ===== serialization =====
373
+ # ===== helpers for path normalization =====
283
374
 
284
- def serialize_path(record)
285
- return record.path if record.opaque_path?
375
+ def windows_drive_letter?(seg) = seg.length == 2 && seg[0].match?(/[A-Za-z]/) && [":", "|"].include?(seg[1])
376
+ def normalized_windows_drive_letter?(seg) = seg.length == 2 && seg[0].match?(/[A-Za-z]/) && seg[1] == ":"
377
+ def starts_with_windows_drive_letter?(s)
378
+ s.length >= 2 && s[0].match?(/[A-Za-z]/) && [":", "|"].include?(s[1]) &&
379
+ (s.length == 2 || ["/", "\\", "?", "#"].include?(s[2]))
380
+ end
286
381
 
287
- record.path.map { |seg| "/#{seg}" }.join
288
- end
382
+ def single_dot?(seg) = [".", "%2e"].include?(seg.downcase)
289
383
 
290
- def serialize(record, exclude_fragment: false)
291
- out = +"#{record.scheme}:"
292
- if record.host
293
- out << "//"
294
- if record.includes_credentials?
295
- out << record.username
296
- out << ":#{record.password}" unless record.password.empty?
297
- out << "@"
298
- end
299
- out << record.host
300
- out << ":#{record.port}" if record.port
301
- elsif !record.opaque_path? && record.path.is_a?(Array) &&
302
- record.path.length > 1 && record.path[0] == ""
303
- out << "/."
304
- end
305
- out << serialize_path(record)
306
- out << "?#{record.query}" if record.query
307
- out << "##{record.fragment}" if record.fragment && !exclude_fragment
308
- out
309
- end
384
+ def double_dot?(seg)
385
+ ["..", ".%2e", "%2e.", "%2e%2e"].include?(seg.downcase)
386
+ end
310
387
 
311
- # ===== helpers for path normalization =====
388
+ def shorten_path(record)
389
+ path = record.path
390
+ return if path.empty?
391
+ return if record.scheme == "file" && path.length == 1 && normalized_windows_drive_letter?(path[0])
312
392
 
313
- def windows_drive_letter?(seg) = seg.length == 2 && seg[0].match?(/[A-Za-z]/) && [":", "|"].include?(seg[1])
314
- def normalized_windows_drive_letter?(seg) = seg.length == 2 && seg[0].match?(/[A-Za-z]/) && seg[1] == ":"
315
- def starts_with_windows_drive_letter?(s)
316
- s.length >= 2 && s[0].match?(/[A-Za-z]/) && [":", "|"].include?(s[1]) &&
317
- (s.length == 2 || ["/", "\\", "?", "#"].include?(s[2]))
393
+ path.pop
394
+ end
318
395
  end
319
396
 
320
- def single_dot?(seg) = [".", "%2e"].include?(seg.downcase)
321
-
322
- def double_dot?(seg)
323
- ["..", ".%2e", "%2e.", "%2e%2e"].include?(seg.downcase)
324
- end
397
+ # So `UrlParser.percent_decode` and friends keep answering.
398
+ extend Syntax
325
399
 
326
- def shorten_path(record)
327
- path = record.path
328
- return if path.empty?
329
- return if record.scheme == "file" && path.length == 1 && normalized_windows_drive_letter?(path[0])
400
+ # ===== the basic URL parser state machine =====
330
401
 
331
- path.pop
402
+ def run(input, base, url: nil, state_override: nil, encoding: nil)
403
+ BasicParser.new(input, base, url: url, state_override: state_override, encoding: encoding).run
332
404
  end
333
405
 
334
- # ===== the basic URL parser state machine =====
335
-
336
- def run(input, base, url: nil, state_override: nil)
337
- input = input.dup
338
- # Strip leading/trailing C0 controls and spaces (only for a fresh
339
- # parse; a setter's input keeps them), then remove all ASCII
340
- # tab/newline.
341
- input = input.sub(/\A[\x00-\x20]+/, "").sub(/[\x00-\x20]+\z/, "") if url.nil?
342
- input = input.gsub(/[\t\n\r]/, "")
343
-
344
- chars = input.chars
345
- len = chars.length
346
- state = state_override || :scheme_start
347
- url ||= Record.new("", "", "", nil, nil, [], nil, nil)
348
- buffer = +""
349
- at_sign_seen = false
350
- inside_brackets = false
351
- password_token_seen = false
352
- ptr = 0
353
-
354
- cp = lambda { ptr < len ? chars[ptr] : nil }
355
-
356
- loop do
357
- c = cp.call
358
-
359
- case state
360
- when :scheme_start
361
- if c&.match?(/[A-Za-z]/)
362
- buffer << c.downcase
363
- state = :scheme
364
- elsif state_override
365
- raise Failure, "scheme must start with a letter"
366
- else
367
- state = :no_scheme
368
- next # reprocess (do not advance)
369
- end
406
+ # The basic URL parser's state machine (url.spec.whatwg.org §4.4), one
407
+ # method per state.
408
+ #
409
+ # It was one 390-line method, which is faithful to the spec's shape but
410
+ # leaves eight mutable locals whose writers are scattered over twenty
411
+ # states. As a class the locals are the parser's own state, and each spec
412
+ # state is a method carrying the spec's name for it, so a state can be
413
+ # read without reading the other nineteen.
414
+ #
415
+ # The two non-linear moves the spec makes are throws the loop catches:
416
+ # "do not advance, re-run this position in the new state" is
417
+ # `throw :reprocess`, and a setter that stops early (state_override) is
418
+ # `throw :done`. A `next` inside a nested block still means the next
419
+ # element of that block — see the authority state, which walks the
420
+ # buffer's characters.
421
+ class BasicParser
422
+ include Syntax
423
+ # Syntax is the parser's vocabulary, not part of its interface: a state
424
+ # calls `pe` or `shorten_path` on itself, nobody calls them on a parser.
425
+ private(*Syntax.public_instance_methods)
426
+
427
+ STATES = {
428
+ scheme_start: :state_scheme_start,
429
+ scheme: :state_scheme,
430
+ no_scheme: :state_no_scheme,
431
+ special_relative_or_authority: :state_special_relative_or_authority,
432
+ path_or_authority: :state_path_or_authority,
433
+ relative: :state_relative,
434
+ relative_slash: :state_relative_slash,
435
+ special_authority_slashes: :state_special_authority_slashes,
436
+ special_authority_ignore_slashes: :state_special_authority_ignore_slashes,
437
+ authority: :state_authority,
438
+ host: :state_host,
439
+ hostname: :state_host,
440
+ port: :state_port,
441
+ file: :state_file,
442
+ file_slash: :state_file_slash,
443
+ file_host: :state_file_host,
444
+ path_start: :state_path_start,
445
+ path: :state_path,
446
+ opaque_path: :state_opaque_path,
447
+ query: :state_query,
448
+ fragment: :state_fragment,
449
+ }.freeze
450
+
451
+ def initialize(input, base, url: nil, state_override: nil, encoding: nil)
452
+ input = input.dup
453
+ # Strip leading/trailing C0 controls and spaces (only for a fresh
454
+ # parse; a setter's input keeps them), then remove all ASCII
455
+ # tab/newline.
456
+ input = input.sub(/\A[\x00-\x20]+/, "").sub(/[\x00-\x20]+\z/, "") if url.nil?
457
+ @input = input.gsub(/[\t\n\r]/, "")
458
+ @chars = @input.chars
459
+ @len = @chars.length
460
+ @base = base
461
+ @state_override = state_override
462
+ @encoding = encoding
463
+ @state = state_override || :scheme_start
464
+ @url = url || Record.new("", "", "", nil, nil, [], nil, nil)
465
+ @buffer = +""
466
+ @at_sign_seen = false
467
+ @inside_brackets = false
468
+ @password_token_seen = false
469
+ @ptr = 0
470
+ end
370
471
 
371
- when :scheme
372
- if c&.match?(/[A-Za-z0-9+\-.]/)
373
- buffer << c.downcase
374
- elsif c == ":"
375
- if state_override
376
- # A setter may not move a URL between special and
377
- # non-special, onto file with credentials or a port, or off
378
- # file with an empty host.
379
- return url if SPECIAL.key?(url.scheme) != SPECIAL.key?(buffer)
380
- return url if (url.includes_credentials? || !url.port.nil?) && buffer == "file"
381
- return url if url.scheme == "file" && url.host == ""
382
-
383
- url.scheme = buffer
384
- url.port = nil if url.port == url.default_port
385
- return url
472
+ def run
473
+ catch(:done) do
474
+ loop do
475
+ advanced = catch(:reprocess) do
476
+ send(STATES.fetch(@state), current_char)
477
+ true
386
478
  end
479
+ next unless advanced
480
+ break if @ptr >= @len
387
481
 
388
- url.scheme = buffer
389
- buffer = +""
390
- if url.scheme == "file"
391
- state = :file
392
- elsif url.special? && base && base.scheme == url.scheme
393
- state = :special_relative_or_authority
394
- elsif url.special?
395
- state = :special_authority_slashes
396
- elsif input[(ptr + 1)..].to_s.start_with?("/")
397
- state = :path_or_authority
398
- ptr += 1
399
- else
400
- url.path = ""
401
- state = :opaque_path
402
- end
403
- elsif state_override
404
- raise Failure, "invalid scheme"
405
- else
406
- buffer = +""
407
- state = :no_scheme
408
- ptr = -1 # restart from 0 (advance makes it 0)
482
+ @ptr += 1
409
483
  end
484
+ @url
485
+ end
486
+ end
410
487
 
411
- when :no_scheme
412
- raise Failure, "missing scheme" if base.nil? || (base.opaque_path? && c != "#")
413
-
414
- if base.opaque_path? && c == "#"
415
- url.scheme = base.scheme
416
- url.path = base.path
417
- url.query = base.query
418
- url.fragment = +""
419
- state = :fragment
420
- elsif base.scheme != "file"
421
- state = :relative
422
- next
423
- else
424
- state = :file
425
- next
426
- end
488
+ private
427
489
 
428
- when :special_relative_or_authority
429
- if c == "/" && input[(ptr + 1)..].to_s.start_with?("/")
430
- state = :special_authority_ignore_slashes
431
- ptr += 1
432
- else
433
- state = :relative
434
- next
435
- end
490
+ def current_char = @ptr < @len ? @chars[@ptr] : nil
436
491
 
437
- when :path_or_authority
438
- if c == "/"
439
- state = :authority
440
- else
441
- state = :path
442
- next
443
- end
492
+ def state_scheme_start(c)
493
+ if c&.match?(/[A-Za-z]/)
494
+ @buffer << c.downcase
495
+ @state = :scheme
496
+ elsif @state_override
497
+ raise Failure, "scheme must start with a letter"
498
+ else
499
+ @state = :no_scheme
500
+ throw :reprocess
501
+ end
502
+ end
444
503
 
445
- when :relative
446
- url.scheme = base.scheme
447
- if c == "/"
448
- state = :relative_slash
449
- elsif url.special? && c == "\\"
450
- state = :relative_slash
451
- else
452
- url.username = base.username
453
- url.password = base.password
454
- url.host = base.host
455
- url.port = base.port
456
- url.path = base.path.dup
457
- url.query = base.query
458
- if c == "?"
459
- url.query = +""
460
- state = :query
461
- elsif c == "#"
462
- url.fragment = +""
463
- state = :fragment
464
- elsif c
465
- url.query = nil
466
- shorten_path(url)
467
- state = :path
468
- next
469
- end
504
+ def state_scheme(c)
505
+ if c&.match?(/[A-Za-z0-9+\-.]/)
506
+ @buffer << c.downcase
507
+ elsif c == ":"
508
+ if @state_override
509
+ # A setter may not move a URL between special and
510
+ # non-special, onto file with credentials or a port, or off
511
+ # file with an empty host.
512
+ throw :done, @url if SPECIAL.key?(@url.scheme) != SPECIAL.key?(@buffer)
513
+ throw :done, @url if (@url.includes_credentials? || !@url.port.nil?) && @buffer == "file"
514
+ throw :done, @url if @url.scheme == "file" && @url.host == ""
515
+
516
+ @url.scheme = @buffer
517
+ @url.port = nil if @url.port == @url.default_port
518
+ throw :done, @url
470
519
  end
471
520
 
472
- when :relative_slash
473
- if url.special? && (c == "/" || c == "\\")
474
- state = :special_authority_ignore_slashes
475
- elsif c == "/"
476
- state = :authority
521
+ @url.scheme = @buffer
522
+ @buffer = +""
523
+ if @url.scheme == "file"
524
+ @state = :file
525
+ elsif @url.special? && @base && @base.scheme == @url.scheme
526
+ @state = :special_relative_or_authority
527
+ elsif @url.special?
528
+ @state = :special_authority_slashes
529
+ elsif @input[(@ptr + 1)..].to_s.start_with?("/")
530
+ @state = :path_or_authority
531
+ @ptr += 1
477
532
  else
478
- url.username = base.username
479
- url.password = base.password
480
- url.host = base.host
481
- url.port = base.port
482
- state = :path
483
- next
533
+ @url.path = ""
534
+ @state = :opaque_path
484
535
  end
536
+ elsif @state_override
537
+ raise Failure, "invalid scheme"
538
+ else
539
+ @buffer = +""
540
+ @state = :no_scheme
541
+ @ptr = -1 # restart from 0 (advance makes it 0)
542
+ end
543
+ end
485
544
 
486
- when :special_authority_slashes
487
- if c == "/" && input[(ptr + 1)..].to_s.start_with?("/")
488
- state = :special_authority_ignore_slashes
489
- ptr += 1
490
- else
491
- state = :special_authority_ignore_slashes
492
- next
493
- end
545
+ def state_no_scheme(c)
546
+ raise Failure, "missing scheme" if @base.nil? || (@base.opaque_path? && c != "#")
547
+
548
+ if @base.opaque_path? && c == "#"
549
+ @url.scheme = @base.scheme
550
+ @url.path = @base.path
551
+ @url.query = @base.query
552
+ @url.fragment = +""
553
+ @state = :fragment
554
+ elsif @base.scheme != "file"
555
+ @state = :relative
556
+ throw :reprocess
557
+ else
558
+ @state = :file
559
+ throw :reprocess
560
+ end
561
+ end
494
562
 
495
- when :special_authority_ignore_slashes
496
- if c != "/" && c != "\\"
497
- state = :authority
498
- next
499
- end
563
+ def state_special_relative_or_authority(c)
564
+ if c == "/" && @input[(@ptr + 1)..].to_s.start_with?("/")
565
+ @state = :special_authority_ignore_slashes
566
+ @ptr += 1
567
+ else
568
+ @state = :relative
569
+ throw :reprocess
570
+ end
571
+ end
500
572
 
501
- when :authority
502
- if c == "@"
503
- buffer = "%40#{buffer}" if at_sign_seen
504
- at_sign_seen = true
505
- buffer.each_char do |ch|
506
- if ch == ":" && !password_token_seen
507
- password_token_seen = true
508
- next
509
- end
510
- encoded = pe(ch, method(:userinfo_set?))
511
- if password_token_seen
512
- url.password += encoded
513
- else
514
- url.username += encoded
515
- end
516
- end
517
- buffer = +""
518
- elsif c.nil? || ["/", "?", "#"].include?(c) || (url.special? && c == "\\")
519
- raise Failure, "empty host with credentials" if at_sign_seen && buffer.empty?
573
+ def state_path_or_authority(c)
574
+ if c == "/"
575
+ @state = :authority
576
+ else
577
+ @state = :path
578
+ throw :reprocess
579
+ end
580
+ end
520
581
 
521
- ptr -= (buffer.length + 1)
522
- buffer = +""
523
- state = :host
524
- else
525
- buffer << c
582
+ def state_relative(c)
583
+ @url.scheme = @base.scheme
584
+ if c == "/"
585
+ @state = :relative_slash
586
+ elsif @url.special? && c == "\\"
587
+ @state = :relative_slash
588
+ else
589
+ @url.username = @base.username
590
+ @url.password = @base.password
591
+ @url.host = @base.host
592
+ @url.port = @base.port
593
+ @url.path = @base.path.dup
594
+ @url.query = @base.query
595
+ if c == "?"
596
+ @url.query = +""
597
+ @state = :query
598
+ elsif c == "#"
599
+ @url.fragment = +""
600
+ @state = :fragment
601
+ elsif c
602
+ @url.query = nil
603
+ shorten_path(@url)
604
+ @state = :path
605
+ throw :reprocess
526
606
  end
607
+ end
608
+ end
527
609
 
528
- when :host, :hostname
529
- if state_override && url.scheme == "file"
530
- ptr -= 1
531
- state = :file_host
532
- elsif c == ":" && !inside_brackets
533
- raise Failure, "empty host" if buffer.empty?
534
- raise Failure, "a hostname cannot carry a port" if state_override == :hostname
535
-
536
- url.host = parse_host(buffer, url.special?)
537
- buffer = +""
538
- state = :port
539
- elsif c.nil? || ["/", "?", "#"].include?(c) || (url.special? && c == "\\")
540
- ptr -= 1
541
- raise Failure, "empty special host" if url.special? && buffer.empty?
542
- if state_override && buffer.empty? && (url.includes_credentials? || !url.port.nil?)
543
- raise Failure, "a URL with credentials or a port needs a host"
544
- end
545
-
546
- url.host = parse_host(buffer, url.special?)
547
- return url if state_override
548
-
549
- buffer = +""
550
- state = :path_start
551
- else
552
- inside_brackets = true if c == "["
553
- inside_brackets = false if c == "]"
554
- buffer << c
555
- end
610
+ def state_relative_slash(c)
611
+ if @url.special? && (c == "/" || c == "\\")
612
+ @state = :special_authority_ignore_slashes
613
+ elsif c == "/"
614
+ @state = :authority
615
+ else
616
+ @url.username = @base.username
617
+ @url.password = @base.password
618
+ @url.host = @base.host
619
+ @url.port = @base.port
620
+ @state = :path
621
+ throw :reprocess
622
+ end
623
+ end
556
624
 
557
- when :port
558
- if c&.match?(/[0-9]/)
559
- buffer << c
560
- elsif c.nil? || ["/", "?", "#"].include?(c) || (url.special? && c == "\\") || state_override
561
- unless buffer.empty?
562
- port = buffer.to_i
563
- raise Failure, "port out of range" if port > 65_535
564
-
565
- url.port = (port == url.default_port ? nil : port)
566
- buffer = +""
567
- return url if state_override
568
- end
569
- raise Failure, "empty port" if state_override
625
+ def state_special_authority_slashes(c)
626
+ if c == "/" && @input[(@ptr + 1)..].to_s.start_with?("/")
627
+ @state = :special_authority_ignore_slashes
628
+ @ptr += 1
629
+ else
630
+ @state = :special_authority_ignore_slashes
631
+ throw :reprocess
632
+ end
633
+ end
570
634
 
571
- state = :path_start
572
- next
573
- else
574
- raise Failure, "invalid port"
575
- end
635
+ def state_special_authority_ignore_slashes(c)
636
+ if c != "/" && c != "\\"
637
+ @state = :authority
638
+ throw :reprocess
639
+ end
640
+ end
576
641
 
577
- when :file
578
- url.scheme = "file"
579
- url.host = ""
580
- if c == "/" || c == "\\"
581
- state = :file_slash
582
- elsif base && base.scheme == "file"
583
- url.host = base.host
584
- url.path = base.path.dup
585
- url.query = base.query
586
- if c == "?"
587
- url.query = +""
588
- state = :query
589
- elsif c == "#"
590
- url.fragment = +""
591
- state = :fragment
592
- elsif c
593
- url.query = nil
594
- shorten_path(url) unless starts_with_windows_drive_letter?(input[ptr..].to_s)
595
- url.path = [] if starts_with_windows_drive_letter?(input[ptr..].to_s)
596
- state = :path
642
+ def state_authority(c)
643
+ if c == "@"
644
+ @buffer = "%40#{@buffer}" if @at_sign_seen
645
+ @at_sign_seen = true
646
+ @buffer.each_char do |ch|
647
+ if ch == ":" && !@password_token_seen
648
+ @password_token_seen = true
597
649
  next
598
650
  end
599
- else
600
- state = :path
601
- next
651
+ encoded = pe(ch, method(:userinfo_set?))
652
+ if @password_token_seen
653
+ @url.password += encoded
654
+ else
655
+ @url.username += encoded
656
+ end
602
657
  end
658
+ @buffer = +""
659
+ elsif c.nil? || ["/", "?", "#"].include?(c) || (@url.special? && c == "\\")
660
+ raise Failure, "empty host with credentials" if @at_sign_seen && @buffer.empty?
603
661
 
604
- when :file_slash
605
- if c == "/" || c == "\\"
606
- state = :file_host
607
- else
608
- if base && base.scheme == "file"
609
- url.host = base.host
610
- if !starts_with_windows_drive_letter?(input[ptr..].to_s) &&
611
- base.path[0] && normalized_windows_drive_letter?(base.path[0])
612
- url.path << base.path[0]
613
- end
614
- end
615
- state = :path
616
- next
662
+ @ptr -= (@buffer.length + 1)
663
+ @buffer = +""
664
+ @state = :host
665
+ else
666
+ @buffer << c
667
+ end
668
+ end
669
+
670
+ def state_host(c)
671
+ if @state_override && @url.scheme == "file"
672
+ @ptr -= 1
673
+ @state = :file_host
674
+ elsif c == ":" && !@inside_brackets
675
+ raise Failure, "empty host" if @buffer.empty?
676
+ raise Failure, "a hostname cannot carry a port" if @state_override == :hostname
677
+
678
+ @url.host = parse_host(@buffer, @url.special?)
679
+ @buffer = +""
680
+ @state = :port
681
+ elsif c.nil? || ["/", "?", "#"].include?(c) || (@url.special? && c == "\\")
682
+ @ptr -= 1
683
+ raise Failure, "empty special host" if @url.special? && @buffer.empty?
684
+ if @state_override && @buffer.empty? && (@url.includes_credentials? || !@url.port.nil?)
685
+ raise Failure, "a URL with credentials or a port needs a host"
617
686
  end
618
687
 
619
- when :file_host
620
- if c.nil? || ["/", "\\", "?", "#"].include?(c)
621
- ptr -= 1
622
- if state_override.nil? && buffer.match?(/\A[A-Za-z][:|]\z/)
623
- state = :path
624
- elsif buffer.empty?
625
- url.host = ""
626
- return url if state_override
688
+ @url.host = parse_host(@buffer, @url.special?)
689
+ throw :done, @url if @state_override
627
690
 
628
- state = :path_start
629
- else
630
- host = parse_host(buffer, true)
631
- host = "" if host == "localhost"
632
- url.host = host
633
- return url if state_override
691
+ @buffer = +""
692
+ @state = :path_start
693
+ else
694
+ @inside_brackets = true if c == "["
695
+ @inside_brackets = false if c == "]"
696
+ @buffer << c
697
+ end
698
+ end
634
699
 
635
- buffer = +""
636
- state = :path_start
637
- end
638
- else
639
- buffer << c
700
+ def state_port(c)
701
+ if c&.match?(/[0-9]/)
702
+ @buffer << c
703
+ elsif c.nil? || ["/", "?", "#"].include?(c) || (@url.special? && c == "\\") || @state_override
704
+ unless @buffer.empty?
705
+ port = @buffer.to_i
706
+ raise Failure, "port out of range" if port > 65_535
707
+
708
+ @url.port = (port == @url.default_port ? nil : port)
709
+ @buffer = +""
710
+ throw :done, @url if @state_override
640
711
  end
712
+ raise Failure, "empty port" if @state_override
713
+
714
+ @state = :path_start
715
+ throw :reprocess
716
+ else
717
+ raise Failure, "invalid port"
718
+ end
719
+ end
641
720
 
642
- when :path_start
643
- if url.special?
644
- state = :path
645
- next unless c == "/" || c == "\\"
646
- elsif state_override.nil? && c == "?"
647
- url.query = +""
648
- state = :query
649
- elsif state_override.nil? && c == "#"
650
- url.fragment = +""
651
- state = :fragment
721
+ def state_file(c)
722
+ @url.scheme = "file"
723
+ @url.host = ""
724
+ if c == "/" || c == "\\"
725
+ @state = :file_slash
726
+ elsif @base && @base.scheme == "file"
727
+ @url.host = @base.host
728
+ @url.path = @base.path.dup
729
+ @url.query = @base.query
730
+ if c == "?"
731
+ @url.query = +""
732
+ @state = :query
733
+ elsif c == "#"
734
+ @url.fragment = +""
735
+ @state = :fragment
652
736
  elsif c
653
- state = :path
654
- next unless c == "/"
655
- elsif state_override && url.host.nil?
656
- url.path << ""
737
+ @url.query = nil
738
+ shorten_path(@url) unless starts_with_windows_drive_letter?(@input[@ptr..].to_s)
739
+ @url.path = [] if starts_with_windows_drive_letter?(@input[@ptr..].to_s)
740
+ @state = :path
741
+ throw :reprocess
657
742
  end
743
+ else
744
+ @state = :path
745
+ throw :reprocess
746
+ end
747
+ end
658
748
 
659
- when :path
660
- if c.nil? || c == "/" || (url.special? && c == "\\") ||
661
- (state_override.nil? && (c == "?" || c == "#"))
662
- if double_dot?(buffer)
663
- shorten_path(url)
664
- url.path << "" unless c == "/" || (url.special? && c == "\\")
665
- elsif single_dot?(buffer)
666
- url.path << "" unless c == "/" || (url.special? && c == "\\")
667
- else
668
- if url.scheme == "file" && url.path.empty? && windows_drive_letter?(buffer)
669
- buffer[1] = ":"
670
- end
671
- url.path << buffer
672
- end
673
- buffer = +""
674
- if c == "?"
675
- url.query = +""
676
- state = :query
677
- elsif c == "#"
678
- url.fragment = +""
679
- state = :fragment
749
+ def state_file_slash(c)
750
+ if c == "/" || c == "\\"
751
+ @state = :file_host
752
+ else
753
+ if @base && @base.scheme == "file"
754
+ @url.host = @base.host
755
+ if !starts_with_windows_drive_letter?(@input[@ptr..].to_s) &&
756
+ @base.path[0] && normalized_windows_drive_letter?(@base.path[0])
757
+ @url.path << @base.path[0]
680
758
  end
759
+ end
760
+ @state = :path
761
+ throw :reprocess
762
+ end
763
+ end
764
+
765
+ def state_file_host(c)
766
+ if c.nil? || ["/", "\\", "?", "#"].include?(c)
767
+ @ptr -= 1
768
+ if @state_override.nil? && @buffer.match?(/\A[A-Za-z][:|]\z/)
769
+ @state = :path
770
+ elsif @buffer.empty?
771
+ @url.host = ""
772
+ throw :done, @url if @state_override
773
+
774
+ @state = :path_start
681
775
  else
682
- buffer << pe(c, method(:path_set?))
776
+ host = parse_host(@buffer, true)
777
+ host = "" if host == "localhost"
778
+ @url.host = host
779
+ throw :done, @url if @state_override
780
+
781
+ @buffer = +""
782
+ @state = :path_start
683
783
  end
784
+ else
785
+ @buffer << c
786
+ end
787
+ end
788
+
789
+ def state_path_start(c)
790
+ if @url.special?
791
+ @state = :path
792
+ throw :reprocess unless c == "/" || c == "\\"
793
+ elsif @state_override.nil? && c == "?"
794
+ @url.query = +""
795
+ @state = :query
796
+ elsif @state_override.nil? && c == "#"
797
+ @url.fragment = +""
798
+ @state = :fragment
799
+ elsif c
800
+ @state = :path
801
+ throw :reprocess unless c == "/"
802
+ elsif @state_override && @url.host.nil?
803
+ @url.path << ""
804
+ end
805
+ end
684
806
 
685
- when :opaque_path
807
+ def state_path(c)
808
+ if c.nil? || c == "/" || (@url.special? && c == "\\") ||
809
+ (@state_override.nil? && (c == "?" || c == "#"))
810
+ if double_dot?(@buffer)
811
+ shorten_path(@url)
812
+ @url.path << "" unless c == "/" || (@url.special? && c == "\\")
813
+ elsif single_dot?(@buffer)
814
+ @url.path << "" unless c == "/" || (@url.special? && c == "\\")
815
+ else
816
+ if @url.scheme == "file" && @url.path.empty? && windows_drive_letter?(@buffer)
817
+ @buffer[1] = ":"
818
+ end
819
+ @url.path << @buffer
820
+ end
821
+ @buffer = +""
686
822
  if c == "?"
687
- url.query = +""
688
- state = :query
823
+ @url.query = +""
824
+ @state = :query
689
825
  elsif c == "#"
690
- url.fragment = +""
691
- state = :fragment
692
- elsif c == " "
693
- # A space is only percent-encoded when it abuts the end of
694
- # the opaque path (a following `?`/`#`); an interior space
695
- # stays literal. (Trailing-at-EOF spaces are already gone
696
- # via the leading/trailing strip.)
697
- nxt = chars[ptr + 1]
698
- url.path += (nxt == "?" || nxt == "#") ? "%20" : " "
699
- elsif c
700
- url.path += pe(c, method(:c0?))
826
+ @url.fragment = +""
827
+ @state = :fragment
701
828
  end
829
+ else
830
+ @buffer << pe(c, method(:path_set?))
831
+ end
832
+ end
702
833
 
703
- when :query
704
- if c.nil? || (state_override.nil? && c == "#")
705
- set = url.special? ? method(:special_query_set?) : method(:query_set?)
706
- url.query += buffer.each_char.map { |ch| pe(ch, set) }.join
707
- buffer = +""
708
- if c == "#"
709
- url.fragment = +""
710
- state = :fragment
711
- end
712
- else
713
- buffer << c
714
- end
834
+ def state_opaque_path(c)
835
+ if c == "?"
836
+ @url.query = +""
837
+ @state = :query
838
+ elsif c == "#"
839
+ @url.fragment = +""
840
+ @state = :fragment
841
+ elsif c == " "
842
+ # A space is only percent-encoded when it abuts the end of
843
+ # the opaque path (a following `?`/`#`); an interior space
844
+ # stays literal. (Trailing-at-EOF spaces are already gone
845
+ # via the leading/trailing strip.)
846
+ nxt = @chars[@ptr + 1]
847
+ @url.path += (nxt == "?" || nxt == "#") ? "%20" : " "
848
+ elsif c
849
+ @url.path += pe(c, method(:c0?))
850
+ end
851
+ end
715
852
 
716
- when :fragment
717
- url.fragment += pe(c, method(:fragment_set?)) if c
853
+ def state_query(c)
854
+ if c.nil? || (@state_override.nil? && c == "#")
855
+ set = @url.special? ? method(:special_query_set?) : method(:query_set?)
856
+ @url.query += percent_encode_after_encoding(query_encoding, @buffer, set)
857
+ @buffer = +""
858
+ if c == "#"
859
+ @url.fragment = +""
860
+ @state = :fragment
861
+ end
862
+ else
863
+ @buffer << c
718
864
  end
865
+ end
719
866
 
720
- break if ptr >= len
867
+ # Spec query state step 1: the encoding override is ignored (forced to
868
+ # UTF-8) when the URL is not special, or is ws/wss.
869
+ def query_encoding
870
+ return "UTF-8" if @encoding.nil? || !@url.special?
871
+ return "UTF-8" if @url.scheme == "ws" || @url.scheme == "wss"
721
872
 
722
- ptr += 1
873
+ @encoding
723
874
  end
724
875
 
725
- url
876
+ def state_fragment(c)
877
+ @url.fragment += pe(c, method(:fragment_set?)) if c
878
+ end
726
879
  end
727
880
  end
728
881
  end