diff --git a/Benchmarks/Sources/WebURLBenchmark/KeyValuePairs.swift b/Benchmarks/Sources/WebURLBenchmark/KeyValuePairs.swift new file mode 100644 index 000000000..6629b193a --- /dev/null +++ b/Benchmarks/Sources/WebURLBenchmark/KeyValuePairs.swift @@ -0,0 +1,286 @@ +// Copyright The swift-url Contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Benchmark +import WebURL + +#if swift(>=5.7) + + /// Benchmarks the WebURL `KeyValuePairs` view. + /// + let KeyValuePairs = BenchmarkSuite(name: "KeyValuePairs") { suite in + + // Iteration. + + suite.benchmark("Iteration.Small.Forwards") { state in + var url = WebURL("http://example.com/?foo=bar&baz=qux&format=json&client=mobile")! + try state.measure { + for component in url.queryParams { + blackHole(component) + } + } + blackHole(url) + } + + // Get (non-encoded). + + suite.benchmark("Get.NonEncoded") { state in + var url = WebURL("http://example.com/?foo=bar&baz=qux&format=json&client=mobile")! + try state.measure { + blackHole(url.queryParams["format"]!) + } + blackHole(url) + } + + // Get2 (non-encoded). + + suite.benchmark("Get2.NonEncoded") { state in + var url = WebURL("http://example.com/?foo=bar&baz=qux&format=json&client=mobile")! + try state.measure { + blackHole(url.queryParams["format", "client"]) + } + blackHole(url) + } + + // Get3 (non-encoded). + + suite.benchmark("Get3.NonEncoded") { state in + var url = WebURL("http://example.com/?foo=bar&baz=qux&format=json&client=mobile")! + try state.measure { + blackHole(url.queryParams["format", "client", "baz"]) + } + blackHole(url) + } + + // Get4 (non-encoded). + + suite.benchmark("Get4.NonEncoded") { state in + var url = WebURL("http://example.com/?foo=bar&baz=qux&format=json&client=mobile")! + try state.measure { + blackHole(url.queryParams["format", "client", "baz", "client"]) + } + blackHole(url) + } + + // Get (encoded). + + suite.benchmark("Get.Encoded") { state in + var url = WebURL("http://example.com/?foo=bar&baz=qux&form%61t=json&client=mobile")! + try state.measure { + blackHole(url.queryParams["format"]!) + } + blackHole(url) + } + + // Get2 (encoded). + + suite.benchmark("Get2.Encoded") { state in + var url = WebURL("http://example.com/?foo=bar&baz=qux&form%61t=json&cli%65nt=mobile")! + try state.measure { + blackHole(url.queryParams["format", "client"]) + } + blackHole(url) + } + + // Get3 (encoded). + + suite.benchmark("Get3.Encoded") { state in + var url = WebURL("http://example.com/?foo=bar&%62az=qux&form%61t=json&cli%65nt=mobile")! + try state.measure { + blackHole(url.queryParams["format", "client", "baz"]) + } + blackHole(url) + } + + // Get4 (encoded). + + suite.benchmark("Get4.Encoded") { state in + var url = WebURL("http://example.com/?foo=bar&%62az=qux&form%61t=json&cli%65nt=mobile")! + try state.measure { + blackHole(url.queryParams["format", "client", "baz", "client"]) + } + blackHole(url) + } + + // Get (long-keys)(non-encoded). + + suite.benchmark("Get.LongKeys.NonEncoded") { state in + var url = WebURL("http://example.com/?foofoofoofoofoofoo=bar&bazbazbazbazbaz=qux&formatformatformatformat=json&clientclientclientclientclient=mobile")! + try state.measure { + blackHole(url.queryParams["formatformatformatformat"]!) + } + blackHole(url) + } + + // Get2 (long-keys)(non-encoded). + + suite.benchmark("Get2.LongKeys.NonEncoded") { state in + var url = WebURL("http://example.com/?foofoofoofoofoofoo=bar&bazbazbazbazbaz=qux&formatformatformatformat=json&clientclientclientclientclient=mobile")! + try state.measure { + blackHole(url.queryParams["formatformatformatformat", "clientclientclientclientclient"]) + } + blackHole(url) + } + + // Get (long-keys)(encoded). + + suite.benchmark("Get.LongKeys.Encoded") { state in + var url = WebURL("http://example.com/?foofoofoofoofoofoo=bar&bazbazbazbazbaz=qux&form%61tform%61tform%61tform%61t=json&clientclientclientclientclient=mobile")! + try state.measure { + blackHole(url.queryParams["formatformatformatformat"]!) + } + blackHole(url) + } + + // Get2 (long-keys)(encoded). + + suite.benchmark("Get2.LongKeys.Encoded") { state in + var url = WebURL("http://example.com/?foofoofoofoofoofoo=bar&bazbazbazbazbaz=qux&form%61tform%61tform%61tform%61t=json&client%63lientclientclientclient=mobile")! + try state.measure { + blackHole(url.queryParams["formatformatformatformat", "clientclientclientclientclient"]) + } + blackHole(url) + } + + // Append One. + + suite.benchmark("Append.One.Encoded") { state in + var url = WebURL("http://example.com/#f")! + try state.measure { + url.queryParams.append(key: "format", value: "๐Ÿฆ†") + } + blackHole(url) + } + + suite.benchmark("Append.One.NonEncoded") { state in + var url = WebURL("http://example.com/#f")! + try state.measure { + url.queryParams.append(key: "format", value: "json") + } + blackHole(url) + } + + // Append Many. + + suite.benchmark("Append.Many.Encoded") { state in + var url = WebURL("http://example.com/#f")! + try state.measure { + url.queryParams += [ + ("foo", "bar"), + ("format", "๐Ÿฆ†"), + ("client", "mobile") + ] + } + blackHole(url) + } + + suite.benchmark("Append.Many.NonEncoded") { state in + var url = WebURL("http://example.com/#f")! + try state.measure { + url.queryParams += [ + ("foo", "bar"), + ("format", "json"), + ("client", "mobile") + ] + } + blackHole(url) + } + + // Remove All Where. + + suite.benchmark("RemoveAllWhere") { state in + var url = WebURL("http://example.com/?foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple#f")! + try state.measure { + url.queryParams.removeAll(where: { $0.key.hasPrefix("f") }) + } + blackHole(url) + } + + suite.benchmark("RemoveAllWhere-2") { state in + var url = WebURL("http://example.com/?foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple&foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple#f")! + try state.measure { + url.queryParams.removeAll(where: { $0.key.hasPrefix("f") }) + } + blackHole(url) + } + + suite.benchmark("RemoveAllWhere-4") { state in + var url = WebURL("http://example.com/?foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple&foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple&foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple&foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple#f")! + try state.measure { + url.queryParams.removeAll(where: { $0.key.hasPrefix("f") }) + } + blackHole(url) + } + + suite.benchmark("RemoveAllWhere-8") { state in + var url = WebURL("http://example.com/?foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple&foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple&foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple&foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple&foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple&foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple&foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple&foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple#f")! + try state.measure { + url.queryParams.removeAll(where: { $0.key.hasPrefix("f") }) + } + blackHole(url) + } + + + // Set. + + suite.benchmark("Set.Single") { state in + var url = WebURL("http://example.com/?foo=bar&client=mobile&format=json&cream=cheese&fish&chips&fruit=apple#f")! + try state.measure { + url.queryParams.set(key: "format", to: "xml") + } + blackHole(url) + } + + suite.benchmark("Set.Multiple") { state in + var url = WebURL("http://example.com/?foo=bar&client=mobile&format=json&cream=cheese&fish&chips&format=plist&format#f")! + try state.measure { + url.queryParams.set(key: "format", to: "xml") + } + blackHole(url) + } + + suite.benchmark("Set.Append") { state in + var url = WebURL("http://example.com/?foo=bar&client=mobile&format=json&cream=cheese&fish&chips&format=plist&format#f")! + try state.measure { + url.queryParams.set(key: "new", to: "appended") + } + blackHole(url) + } + + suite.benchmark("Set.Remove.Single") { state in + var url = WebURL("http://example.com/?foo=bar&client=mobile&format=json&cream=cheese&fish&chips&format=plist&format#f")! + try state.measure { + url.queryParams["cream"] = nil + } + blackHole(url) + } + + suite.benchmark("Set.Remove.Multiple") { state in + var url = WebURL("http://example.com/?foo=bar&client=mobile&format=json&cream=cheese&fish&chips&format=plist&format#f")! + try state.measure { + url.queryParams["format"] = nil + } + blackHole(url) + } + + suite.benchmark("Set.Remove.None") { state in + var url = WebURL("http://example.com/?foo=bar&client=mobile&format=json&cream=cheese&fish&chips&format=plist&format#f")! + try state.measure { + url.queryParams["doesNotExist"] = nil + } + blackHole(url) + } + } + +#endif diff --git a/Benchmarks/Sources/WebURLBenchmark/main.swift b/Benchmarks/Sources/WebURLBenchmark/main.swift index 3da647608..a1d623198 100644 --- a/Benchmarks/Sources/WebURLBenchmark/main.swift +++ b/Benchmarks/Sources/WebURLBenchmark/main.swift @@ -27,11 +27,23 @@ internal func blackHole(_ x: T) { // - Cannot-be-a-base URLs // - file: URLs -Benchmark.main([ - Constructor.HTTP, - ComponentSetters, - PathComponents, - PercentEncoding, - FoundationCompat.NSURLToWeb, - FoundationCompat.WebToNSURL, -]) +#if swift(>=5.7) + Benchmark.main([ + Constructor.HTTP, + ComponentSetters, + PathComponents, + PercentEncoding, + KeyValuePairs, + FoundationCompat.NSURLToWeb, + FoundationCompat.WebToNSURL, + ]) +#else + Benchmark.main([ + Constructor.HTTP, + ComponentSetters, + PathComponents, + PercentEncoding, + FoundationCompat.NSURLToWeb, + FoundationCompat.WebToNSURL, + ]) +#endif diff --git a/Package.swift b/Package.swift index fe967fa18..c5b9be1ae 100644 --- a/Package.swift +++ b/Package.swift @@ -83,7 +83,7 @@ let package = Package( .target( name: "WebURL", dependencies: ["IDNA"], - exclude: ["WebURL.docc"] + exclude: ["WebURL.docc", "WebURL+KeyValuePairs.swift"] ), .target( name: "WebURLTestSupport", @@ -92,7 +92,8 @@ let package = Package( ), .testTarget( name: "WebURLTests", - dependencies: ["WebURL", "WebURLTestSupport", "Checkit"] + dependencies: ["WebURL", "WebURLTestSupport", "Checkit"], + exclude: ["KeyValuePairs"] ), .testTarget( name: "WebURLDeprecatedAPITests", diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift index 215916357..5edf53cc7 100644 --- a/Package@swift-5.5.swift +++ b/Package@swift-5.5.swift @@ -82,7 +82,8 @@ let package = Package( .target( name: "WebURL", - dependencies: ["IDNA"] + dependencies: ["IDNA"], + exclude: ["WebURL+KeyValuePairs.swift"] ), .target( name: "WebURLTestSupport", @@ -91,7 +92,8 @@ let package = Package( ), .testTarget( name: "WebURLTests", - dependencies: ["WebURL", "WebURLTestSupport", "Checkit"] + dependencies: ["WebURL", "WebURLTestSupport", "Checkit"], + exclude: ["KeyValuePairs"] ), .testTarget( name: "WebURLDeprecatedAPITests", diff --git a/Package@swift-5.7.swift b/Package@swift-5.7.swift new file mode 100644 index 000000000..04971b154 --- /dev/null +++ b/Package@swift-5.7.swift @@ -0,0 +1,130 @@ +// swift-tools-version:5.7 + +// Copyright The swift-url Contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import PackageDescription + +let package = Package( + name: "swift-url", + products: [ + + // ๐Ÿงฉ Core functionality. + // The WebURL type. You definitely want this. + .library(name: "WebURL", targets: ["WebURL"]), + + // ๐Ÿ”— Integration with swift-system. + // Adds WebURL <-> FilePath conversions. + .library(name: "WebURLSystemExtras", targets: ["WebURLSystemExtras"]), + + // ๐Ÿ”— Integration with Foundation. + // Adds WebURL <-> Foundation.URL conversions, and URLSession integration. + .library(name: "WebURLFoundationExtras", targets: ["WebURLFoundationExtras"]), + + // ๐Ÿงฐ Support Libraries (internal use only). + // ========================================= + // These libraries expose some convenient hooks for testing, benchmarking, and other tools + // - either in this repo or at . + .library(name: "_WebURLIDNA", targets: ["IDNA"]), + .library(name: "_WebURLTestSupport", targets: ["WebURLTestSupport"]), + + ], + dependencies: [ + + // ๐Ÿ”— Integrations. + // ================ + // WebURLSystemExtras supports swift-system 1.0+. + .package(url: "https://github.com/apple/swift-system.git", .upToNextMajor(from: "1.0.0")), + + // ๐Ÿงช Test-Only Dependencies. + // ========================== + // Checkit - Exercises for stdlib protocol conformances. + .package(url: "https://github.com/karwa/swift-checkit.git", from: "0.0.2"), + + ], + targets: [ + + // ๐Ÿ—บ Unicode and IDNA. + // ==================== + + .target( + name: "UnicodeDataStructures", + swiftSettings: [.define("WEBURL_UNICODE_PARSE_N_PRINT", .when(configuration: .debug))] + ), + .testTarget( + name: "UnicodeDataStructuresTests", + dependencies: ["UnicodeDataStructures"], + resources: [.copy("GenerateData/TableDefinitions")] + ), + + .target( + name: "IDNA", + dependencies: ["UnicodeDataStructures"] + ), + .testTarget( + name: "IDNATests", + dependencies: ["IDNA", "WebURLTestSupport"] + ), + + // ๐ŸŒ WebURL. + // ========== + + .target( + name: "WebURL", + dependencies: ["IDNA"] + ), + .target( + name: "WebURLTestSupport", + dependencies: ["WebURL", "IDNA"], + resources: [.copy("TestFilesData")] + ), + .testTarget( + name: "WebURLTests", + dependencies: ["WebURL", "WebURLTestSupport", .product(name: "Checkit", package: "swift-checkit")] + ), + .testTarget( + name: "WebURLDeprecatedAPITests", + dependencies: ["WebURL"] + ), + + // ๐Ÿ”— WebURLSystemExtras. + // ====================== + + .target( + name: "WebURLSystemExtras", + dependencies: ["WebURL", .product(name: "SystemPackage", package: "swift-system")] + ), + .testTarget( + name: "WebURLSystemExtrasTests", + dependencies: ["WebURLSystemExtras", "WebURL", .product(name: "SystemPackage", package: "swift-system")] + ), + + // ๐Ÿ”— WebURLFoundationExtras. + // ========================== + + .target( + name: "WebURLFoundationExtras", + dependencies: ["WebURL"] + ), + .testTarget( + name: "WebURLFoundationExtrasTests", + dependencies: ["WebURLFoundationExtras", "WebURLTestSupport", "WebURL"], + resources: [.copy("URLConversion/Resources")] + ), + .testTarget( + name: "WebURLFoundationEndToEndTests", + dependencies: ["WebURLFoundationExtras", "WebURL"] + ), + ] +) diff --git a/Sources/WebURL/URLStorage.swift b/Sources/WebURL/URLStorage.swift index 44598b6eb..8f4e36e6e 100644 --- a/Sources/WebURL/URLStorage.swift +++ b/Sources/WebURL/URLStorage.swift @@ -25,7 +25,10 @@ internal struct URLStorage { @usableFromInline - internal var codeUnits: ManagedArrayBuffer + internal typealias CodeUnits = ManagedArrayBuffer + + @usableFromInline + internal var codeUnits: CodeUnits /// The type used to represent dimensions of the URL string and its components. /// @@ -209,6 +212,14 @@ extension Range where Bound == Int { } } +extension URLStorage.SizeType { + + @inlinable + internal init(truncatingBitPatternIfNeeded value: Int) { + self = URLStorage.SizeType(bitPattern: .init(truncatingIfNeeded: value)) + } +} + extension ManagedArrayBuffer where Header == URLStorage.Header { @inlinable @@ -223,6 +234,49 @@ extension ManagedArrayBuffer where Header == URLStorage.Header { } +// -------------------------------------------- +// MARK: - Bounds Checking +// -------------------------------------------- + + +extension URLStorage { + + /// Checks that the given locations represent a well-formed range + /// which is entirely contained within a particular region of the URL. + /// + /// This is only used for diagnostic purposes, to ensure the URL API is being used correctly. + /// ManagedArrayBuffer already bounds-checks accesses for memory safety purposes. + /// + @inlinable @inline(__always) + internal static func verifyRange( + from lower: URLStorage.SizeType, + to upper: URLStorage.SizeType, + inBounds bounds: Range + ) { + precondition( + lower <= upper + && lower >= bounds.lowerBound && lower <= bounds.upperBound + && upper >= bounds.lowerBound && upper <= bounds.upperBound, + "Malformed range or out of bounds" + ) + } + + /// Checks that the given range is well-formed, + /// and is entirely contained within a particular region of the URL. + /// + /// This is only used for diagnostic purposes, to ensure the URL API is being used correctly. + /// ManagedArrayBuffer already bounds-checks accesses for memory safety purposes. + /// + @inlinable @inline(__always) + internal static func verifyRange( + _ range: Range, + inBounds bounds: Range + ) { + verifyRange(from: range.lowerBound, to: range.upperBound, inBounds: bounds) + } +} + + // -------------------------------------------- // MARK: - Combined Modifications // -------------------------------------------- diff --git a/Sources/WebURL/WebURL+KeyValuePairs.swift b/Sources/WebURL/WebURL+KeyValuePairs.swift new file mode 100644 index 000000000..41617f984 --- /dev/null +++ b/Sources/WebURL/WebURL+KeyValuePairs.swift @@ -0,0 +1,3106 @@ +// Copyright The swift-url Contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if swift(<5.7) + #error("WebURL.KeyValuePairs requires Swift 5.7 or newer") +#endif + +// TODO: Some open questions: +// +// - Should we, by default, escape non-URL code-points? And if so, how? How would one opt-out? +// -> Is it important that we create 'valid' URLs? +// -> To be fair, other component setters don't care about that. +// For example, the query setter does not percent-encode square brackets. +// -> Then again, the component setters are supposed to take already-escaped data; +// we take unescaped data. +// + +extension WebURL { + + /// A view of a URL component as a list of key-value pairs, using a given schema. + /// + /// Some URL components, such as the ``WebURL/WebURL/query`` and ``WebURL/WebURL/fragment``, + /// are entirely free to use for custom data - they are simply opaque strings, without any prescribed format. + /// A popular convention is to write a list of key-value pairs in these components, + /// as it is a highly versatile way to encode arbitrary data. + /// + /// For example, a website for a search engine might support key-value pairs in its query component, + /// containing the user's search query, a start offset, the number of results to return, + /// and other filters or options. + /// + /// ``` + /// key value + /// โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ” + /// http://www.example.com/search?q=hello+world&start=2&num=5&safe=on&as_rights=cc_publicdomain + /// โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”ฌโ”€โ”€โ”˜ โ””โ”€โ”ฌโ”€โ”˜ โ””โ”€โ”€โ”ฌโ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + /// pair pair pair pair pair + /// ``` + /// + /// The `KeyValuePairs` view provides a rich set of APIs for reading and modifying lists of key-value pairs + /// in opaque URL components, and supports a variety of options and formats. + /// + /// ### Collection API + /// + /// At its most fundamental level, `KeyValuePairs` is a list - it conforms to `Collection`, + /// which allows working with the list of pairs similarly to how you might work with an `Array`. + /// You can iterate over all key-value pairs, identify a pair by its location (index), + /// and perform modifications such as removing or editing a particular pair, or inserting pairs at a location. + /// + /// ```swift + /// var url = WebURL("http://example/students?class=8&sort=age")! + /// + /// // Find the location of the first existing 'sort' key. + /// let sortIdx = url.queryParams.firstIndex(where: { $0.key == "sort" }) ?? url.queryParams.endIndex + /// + /// // Insert an additional 'sort' key before it. + /// url.queryParams.insert(key: "sort", value: "name", at: sortIdx) + /// // โœ… "http://example/students?class=8&sort=name&sort=age" + /// // ^^^^^^^^^ + /// ``` + /// + /// The concept of "a list of key-value pairs" is quite broad, + /// and for some URLs the position of a key-value pair might be significant. + /// + /// ### Map/MultiMap API + /// + /// In addition to identifying locations within the list, it is natural to read and write values + /// associated with a particular key. + /// + /// In general, `KeyValuePairs` is a kind of multi-map - multiple pairs may have the same key. + /// To retrieve all values associated with a key, call ``WebURL/WebURL/KeyValuePairs/allValues(forKey:)``. + /// + /// ```swift + /// let url = WebURL("http://example/students?class=12&sort=name&sort=age")! + /// url.queryParams.allValues(forKey: "sort") + /// // โœ… ["name", "age"] + /// ``` + /// + /// However, many keys are expected to be unique, so it is often useful to treat the list of pairs + /// as a plain map/`Dictionary` (at least with respect to those keys). + /// + /// `KeyValuePairs` includes a key-based subscript, which allows getting and setting the first value + /// associated with a key. When setting a value, other pairs with the same key will be removed. + /// + /// ```swift + /// var url = WebURL("http://example/search?q=quick+recipes&start=10&limit=20")! + /// + /// // Use the subscript to get/set values associated with keys. + /// + /// url.queryParams["q"] + /// // โœ… "quick recipes" + /// + /// url.queryParams["q"] = "some query" + /// url.queryParams["safe"] = "on" + /// // โœ… "http://example/search?q=some%20query&start=10&limit=20&safe=on" + /// // ^^^^^^^^^^^^^^ ^^^^^^^ + /// + /// url.queryParams["limit"] = nil + /// // โœ… "http://example/search?q=some%20query&start=10&safe=on" + /// // ^^^ + /// ``` + /// + /// > Tip: + /// > + /// > Even though this API feels like working with a `Dictionary`, it is just a _view_ of a URL component, + /// > so the key-value pairs are stored encoded in a URL string rather than a hash-table. + /// > To reduce some of the costs of searching for multiple keys, `KeyValuePairs` supports batched lookups: + /// > + /// > ```swift + /// > let (searchQuery, start, limit, safeSearch) = url.queryParams["q", "start", "limit", "safe"] + /// > // โœ… ("some query", "10", nil, "on") + /// > ``` + /// + /// ### In-Place Mutation + /// + /// `KeyValuePairs` has value semantics - if you assign it to `var` variable and modify it, + /// everything will work, but the URL it came from will be unaffected by those changes. + /// In order to modify the URL component, use the ``WebURL/WebURL/withMutableKeyValuePairs(in:schema:_:)`` function + /// or the ``WebURL/WebURL/queryParams`` property: + /// + /// ```swift + /// var url = WebURL("http://example.com/gallery")! + /// url.withMutableKeyValuePairs(in: .fragment, schema: .percentEncoded) { kvps in + /// kvps["image"] = "14" + /// kvps["origin"] = "100,100" + /// kvps["zoom"] = "200" + /// } + /// // โœ… "http://example.com/gallery#image=14&origin=100,100&zoom=200" + /// // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + /// ``` + /// + /// ### Specifying the Format with Schemas + /// + /// The schema describes how key-value pairs should be read from- and written to- the URL component string. + /// WebURL includes two built-in schemas, for the most common kind of key-value strings: + /// + /// - ``WebURL/KeyValueStringSchema/formEncoded``, and + /// - ``WebURL/KeyValueStringSchema/percentEncoded`` + /// + /// The former is for interpreting `application/x-www-form-urlencoded` content - + /// it considers `"+"` characters in the URL component to be escaped spaces, + /// and is more restrictive about which characters may be used unescaped in the URL string. + /// It should be used for HTML forms, and other strings which explicitly require form-encoding. + /// + /// The latter is very similar to `formEncoded`, but uses conventional percent-encoding - + /// it considers a `"+"` character to simply be a plus sign, with no special meaning, + /// and is more lenient about which characters may be used unescaped in the URL string. + /// This schema should be used for interpreting `mailto:` URLs, media fragments, and other strings which + /// do not require form-encoding. + /// + /// It is also possible to define your own schema, which can be useful when working with key-value strings + /// using alternative delimiters, or which need to control other aspects of how pairs are interpreted or written. + /// See ``WebURL/KeyValueStringSchema`` for more details. + /// + /// > Tip: + /// > + /// > WebURL's ``WebURL/WebURL/queryParams`` property is equivalent to + /// > + /// > ```swift + /// > url.keyValuePairs(in: .query, schema: .formEncoded) + /// > // or + /// > url.withMutableKeyValuePairs(in: .query, schema: .formEncoded) { ... } + /// > ``` + /// + /// ## Topics + /// + /// ### Finding Values By Key + /// + /// - ``WebURL/WebURL/KeyValuePairs/subscript(_:)-7blvp`` + /// - ``WebURL/WebURL/KeyValuePairs/subscript(_:_:)`` + /// - ``WebURL/WebURL/KeyValuePairs/subscript(_:_:_:)`` + /// - ``WebURL/WebURL/KeyValuePairs/subscript(_:_:_:_:)`` + /// - ``WebURL/WebURL/KeyValuePairs/allValues(forKey:)`` + /// + /// ### Setting Values By Key + /// + /// - ``WebURL/WebURL/KeyValuePairs/set(key:to:)`` + /// + /// ### Appending Pairs + /// + /// - ``WebURL/WebURL/KeyValuePairs/append(key:value:)`` + /// - ``WebURL/WebURL/KeyValuePairs/append(contentsOf:)-84bng`` + /// - ``WebURL/WebURL/KeyValuePairs/+=(_:_:)-4gres`` + /// + /// ### Inserting Pairs at a Location + /// + /// - ``WebURL/WebURL/KeyValuePairs/insert(key:value:at:)`` + /// - ``WebURL/WebURL/KeyValuePairs/insert(contentsOf:at:)`` + /// + /// ### Removing Pairs by Location + /// + /// - ``WebURL/WebURL/KeyValuePairs/removeAll(in:where:)`` + /// - ``WebURL/WebURL/KeyValuePairs/removeAll(where:)`` + /// - ``WebURL/WebURL/KeyValuePairs/remove(at:)`` + /// - ``WebURL/WebURL/KeyValuePairs/removeSubrange(_:)`` + /// + /// ### Replacing Pairs by Location + /// + /// - ``WebURL/WebURL/KeyValuePairs/replaceKey(at:with:)`` + /// - ``WebURL/WebURL/KeyValuePairs/replaceValue(at:with:)`` + /// - ``WebURL/WebURL/KeyValuePairs/replaceSubrange(_:with:)`` + /// + /// ### Appending Pairs (Overloads) + /// + /// Overloads of `append(contentsOf:)` allow appending dictionaries and permit variations in tuple labels. + /// + /// - ``WebURL/WebURL/KeyValuePairs/append(contentsOf:)-9lkdx`` + /// - ``WebURL/WebURL/KeyValuePairs/+=(_:_:)-54t4m`` + /// + /// - ``WebURL/WebURL/KeyValuePairs/append(contentsOf:)-5nok5`` + /// - ``WebURL/WebURL/KeyValuePairs/+=(_:_:)-8zyqk`` + /// + /// ### Schemas + /// + /// - ``WebURL/KeyValueStringSchema/formEncoded`` + /// - ``WebURL/KeyValueStringSchema/percentEncoded`` + /// - ``WebURL/KeyValueStringSchema`` + /// + /// ### Supported URL Components + /// + /// - ``WebURL/KeyValuePairsSupportedComponent`` + /// + /// ### View Type + /// + /// - ``WebURL/WebURL/KeyValuePairs`` + /// + @inlinable + public func keyValuePairs( + in component: KeyValuePairsSupportedComponent, schema: Schema + ) -> KeyValuePairs { + KeyValuePairs(storage: storage, component: component, schema: schema) + } + + /// A read-write view of a URL component as a list of key-value pairs, using a given schema. + /// + /// This function is a mutating version of ``WebURL/WebURL/keyValuePairs(in:schema:)``. + /// It executes the `body` closure with a mutable `KeyValuePairs` view, + /// which can be used to modify a URL component in-place. + /// + /// See the documentation for ``WebURL/WebURL/keyValuePairs(in:schema:)`` for more information about the + /// operations exposed by the `KeyValuePairs` view. + /// + /// ```swift + /// var url = WebURL("http://www.example.com/example.ogv#track=french&t=10,20")! + /// url.withMutableKeyValuePairs(in: .fragment, schema: .percentEncoded) { kvps in + /// kvps["track"] = "german" + /// } + /// // โœ… "http://www.example.com/example.ogv#track=german&t=10,20" + /// // ^^^^^^ + /// ``` + /// + /// Do not reassign the given `KeyValuePairs` to a view from another URL or component. + /// + /// > Tip: + /// > + /// > WebURL's ``WebURL/WebURL/queryParams`` property is equivalent to + /// > + /// > ```swift + /// > url.keyValuePairs(in: .query, schema: .formEncoded) + /// > // or + /// > url.withMutableKeyValuePairs(in: .query, schema: .formEncoded) { ... } + /// > ``` + /// + @inlinable + public mutating func withMutableKeyValuePairs( + in component: KeyValuePairsSupportedComponent, + schema: Schema, + _ body: (inout KeyValuePairs) throws -> Result + ) rethrows -> Result { + + // Since the entire URL storage is being moved in to the view, we cannot allow reassignment, + // otherwise when we pick up the storage at the end of the scope, we would adopt the storage + // of an entirely different URL, and other components such as the scheme/hostname/etc could change. + // To protect against this, each time a mutating view is offered in a scope, it is assigned a unique token, + // which is checked again when the scope ends. + // + // Unfortunately, producing fully unique tokens would require syscalls (expensive, non-portable), + // or atomics (not built-in to Swift). A cheaper alternative is to use the storage address at view creation. + // This can alias with other views created from the same URL, so reassignment is still possible, + // but it at least allows us to guarantee that other components (e.g. scheme/hostname) + // cannot change if reassignment is successful. + + let token = storage.codeUnits.withUnsafeBufferPointer { UInt(bitPattern: $0.baseAddress) } + var view = KeyValuePairs(storage: storage, component: component, schema: schema, mutatingScopeID: token) + storage = _tempStorage + defer { + precondition( + view.mutatingScopeID == token && view.component.value == component.value, + "KeyValuePairs was reassigned from another URL or component" + ) + storage = view.storage + } + return try body(&view) + } +} + +extension WebURL { + + /// A read-write view of the URL's query as a list of key-value pairs + /// in the `application/x-www-form-urlencoded` format. + /// + /// This property exposes a `KeyValuePairs` view of the URL's query, + /// which can be used to modify the component in-place. + /// + /// `KeyValuePairs` conforms to `Collection`, so you can iterate over all pairs in the list, + /// and it includes subscripts to get and set values associated with a key: + /// + /// ```swift + /// var url = WebURL("http://example.com/convert?from=EUR&to=USD")! + /// + /// // ๐Ÿšฉ Use subscripts to get/set values associated with a key. + /// + /// url.queryParams["from"] // โœ… "EUR" + /// url.queryParams["to"] // โœ… "USD" + /// + /// url.queryParams["from"] = "GBP" + /// // โœ… "http://example.com/convert?from=GBP&to=USD" + /// // ^^^^^^^^ + /// url.queryParams["amount"] = "20" + /// // โœ… "http://example.com/convert?from=GBP&to=USD&amount=20" + /// // ^^^^^^^^^ + /// + /// // ๐Ÿšฉ Look up multiple keys in a single operation. + /// + /// let (amount, from, to) = url.queryParams["amount", "from", "to"] + /// // โœ… ("20", "GBP", "USD") + /// ``` + /// + /// Additionally, you can build query strings by appending pairs using the `+=` operator. + /// + /// ```swift + /// var url = WebURL("http://example.com/convert") + /// url.queryParams += [ + /// ("amount", "200"), + /// ("from", "EUR"), + /// ("to", "GBP") + /// ] + /// // โœ… "http://example.com/convert?amount=200&from=EUR&to=GBP" + /// // ^^^^^^^^^^^^^^^^^^^^^^^^^^ + /// ``` + /// + /// See the documentation for ``WebURL/WebURL/keyValuePairs(in:schema:)`` for more information about the + /// operations exposed by the `KeyValuePairs` view. + /// + @inlinable + public var queryParams: KeyValuePairs { + get { + KeyValuePairs(storage: storage, component: .query, schema: .formEncoded) + } + _modify { + let token = storage.codeUnits.withUnsafeBufferPointer { UInt(bitPattern: $0.baseAddress) } + var view = KeyValuePairs(storage: storage, component: .query, schema: .formEncoded, mutatingScopeID: token) + storage = _tempStorage + defer { + precondition( + view.mutatingScopeID == token && view.component.value == .query, + "KeyValuePairs view was reassigned from another URL or component" + ) + storage = view.storage + } + yield &view + } + set { + try! storage.utf8.setQuery(newValue.storage.utf8.query) + } + } +} + +extension WebURL.UTF8View { + + /// A pair of slices, containing the contents of the given key-value pair's key and value. + /// + /// ```swift + /// let url = WebURL("http://example/convert?amount=200&from=EUR&to=USD")! + /// + /// if let match = url.queryParams.firstIndex(where: { $0.key == "from" }) { + /// let valueSlice = url.utf8.keyValuePair(match).value + /// assert(valueSlice.elementsEqual("EUR".utf8)) // โœ… + /// } + /// ``` + /// + @inlinable + public func keyValuePair( + _ i: WebURL.KeyValuePairs.Index + ) -> (key: SubSequence, value: SubSequence) { + (self[i.rangeOfKey], self[i.rangeOfValue]) + } +} + + +// -------------------------------------------- +// MARK: - Key-Value String Schemas +// -------------------------------------------- + + +/// A specification for encoding and decoding a list of key-value pairs in a URL component. +/// +/// Some URL components, such as the ``WebURL/WebURL/query`` and ``WebURL/WebURL/fragment``, +/// are opaque strings with no prescribed format. A popular convention is to write a list of key-value pairs +/// in these components, as it is a highly versatile way to encode arbitrary data. +/// +/// The details can vary, but by convention keys and values are delimited using an equals sign (`=`), +/// and each key-value pair is delimited from the next one using an ampersand (`&`). +/// For example: +/// +/// ``` +/// key1=value1&key2=value2 +/// โ””โ”ฌโ”€โ”˜ โ””โ”€โ”ฌโ”€โ”€โ”˜ โ””โ”ฌโ”€โ”˜ โ””โ”€โ”ฌโ”€โ”€โ”˜ +/// key value key value +/// ``` +/// +/// The built-in ``WebURL/KeyValueStringSchema/formEncoded`` and ``WebURL/KeyValueStringSchema/percentEncoded`` schemas +/// are appropriate for the most common key-value strings. +/// +/// However, some systems require variations in the string format and may define a custom schema. +/// +/// ## Topics +/// +/// ### Customizing Delimiters +/// +/// - ``isKeyValueDelimiter(_:)-4o6s0`` +/// - ``isPairDelimiter(_:)-5cnsh`` +/// - ``preferredKeyValueDelimiter`` +/// - ``preferredPairDelimiter`` +/// +/// ### Customizing Encoding of Spaces +/// +/// - ``decodePlusAsSpace`` +/// - ``encodeSpaceAsPlus-4i9g2`` +/// +/// ### Customizing Percent-encoding +/// +/// - ``shouldPercentEncode(ascii:)-3p9fx`` +/// +/// ### Verifying Custom Schemas +/// +/// - ``verify(for:)`` +/// +public protocol KeyValueStringSchema { + + // - Delimiters. + + /// Whether a given ASCII code-point is a delimiter between key-value pairs. + /// + /// Schemas may accept more than one delimiter between key-value pairs. + /// For instance, some systems must allow both semicolons and ampersands between pairs: + /// + /// ``` + /// key1=value1&key2=value2;key3=value3 + /// ^ ^ + /// ``` + /// + /// The default implementation returns `true` if `codePoint` is equal to ``preferredPairDelimiter``. + /// + func isPairDelimiter(_ codePoint: UInt8) -> Bool + + /// Whether a given ASCII code-point is a delimiter between a key and a value. + /// + /// Schemas may accept more than one delimiter between keys and values. + /// For instance, a system may decide to accept both equals-signs and colons between keys and values: + /// + /// ``` + /// key1=value1&key2:value2 + /// ^ ^ + /// ``` + /// + /// The default implementation returns `true` if `codePoint` is equal to ``preferredKeyValueDelimiter``. + /// + func isKeyValueDelimiter(_ codePoint: UInt8) -> Bool + + /// The delimiter to write between key-value pairs when they are inserted in a string. + /// + /// ``` + /// // delimiter = ampersand: + /// key1=value1&key2=value2&key3=value3 + /// ^ ^ + /// + /// // delimiter = comma: + /// key1=value1,key2=value2,key3=value3 + /// ^ ^ + /// ``` + /// + /// The delimiter: + /// + /// - Must be an ASCII code-point, + /// - Must not be the percent sign (`%`), plus sign (`+`), space, or a hex digit, and + /// - Must not require escaping in the URL component(s) used with this schema. + /// + /// For the schema to be well-formed, ``WebURL/KeyValueStringSchema/isPairDelimiter(_:)-5cnsh`` + /// must recognize the code-point as a pair delimiter. + /// + var preferredPairDelimiter: UInt8 { get } + + /// The delimiter to write between the key and value when they are inserted in a string. + /// + /// ``` + /// // delimiter = equals: + /// key1=value1&key2=value2 + /// ^ ^ + /// + /// // delimiter = colon: + /// key1:value1&key2:value2 + /// ^ ^ + /// ``` + /// + /// The delimiter: + /// + /// - Must be an ASCII code-point, + /// - Must not be the percent sign (`%`), plus sign (`+`), space, or a hex digit, and + /// - Must not require escaping in the URL component(s) used with this schema. + /// + /// For the schema to be well-formed, ``WebURL/KeyValueStringSchema/isKeyValueDelimiter(_:)-4o6s0`` + /// must recognize the code-point as a key-value delimiter. + /// + var preferredKeyValueDelimiter: UInt8 { get } + + // - Escaping. + + /// Whether the given ASCII code-point should be percent-encoded. + /// + /// Some characters which occur in keys and values must be escaped + /// because they have a reserved purpose for the key-value string or URL component: + /// + /// - Pair delimiters, as determined by ``WebURL/KeyValueStringSchema/isPairDelimiter(_:)-5cnsh``. + /// - Key-value delimiters, as determined by ``WebURL/KeyValueStringSchema/isKeyValueDelimiter(_:)-4o6s0``. + /// - The percent sign (`%`), as it is required for URL percent-encoding. + /// - The plus sign (`+`), as it is sometimes used to encode spaces. + /// - Any other code-points which must be escaped in the URL component. + /// + /// All other characters are written to the URL component without escaping. + /// + /// If this function returns `true` for a code-point, that code-point will _also_ be considered reserved and, + /// if it occurs within a key or value, will be escaped when written to the key-value string. + /// + /// The default implementation returns `false`, so no additional code-points are escaped. + /// + func shouldPercentEncode(ascii codePoint: UInt8) -> Bool + + /// Whether a non-escaped plus sign (`+`) is decoded as a space. + /// + /// Some key-value strings support encoding spaces using the plus sign (`+`), + /// as a shorthand alternative to percent-encoding them. + /// This property must return `true` in order to accurately decode such strings. + /// + /// An example of key-value strings using this shorthand are `application/x-www-form-urlencoded` + /// ("form encoded") strings, as used by HTML forms. + /// + /// ``` + /// Encoded: name=Johnny+Appleseed + /// // ^ + /// Decoded: (key: "name", value: "Johnny Appleseed") + /// // ^ + /// ``` + /// + /// Other key-value strings give no special meaning to the plus sign. + /// This property must return `false` in order to accurately decode _those_ strings. + /// + /// An example of key-value strings for which plus signs are defined to simply mean literal plus signs + /// are the query components of `mailto:` URLs. + /// + /// ``` + /// Encoded: cc=bob+swift@example.com + /// // ^ + /// Decoded: (key: "cc", value: "bob+swift@example.com") + /// // ^ + /// ``` + /// + /// Unfortunately, you need to know in advance which interpretation is appropriate + /// for a particular key-value string. + /// + var decodePlusAsSpace: Bool { get } + + /// Whether spaces should be encoded as plus signs (`+`). + /// + /// Some key-value strings support encoding spaces using the plus sign (`+`), + /// as a shorthand alternative to percent-encoding them. + /// + /// If this property returns `true`, the shorthand will be used. + /// Otherwise, spaces will be percent-encoded, as all other disallowed characters are. + /// + /// ``` + /// Pair: (key: "text", value: "hello, world") + /// + /// True: text=hello,+world + /// // ^ + /// False: text=hello,%20world + /// // ^^^ + /// ``` + /// + /// The default value is `false`. Use of the shorthand is **not recommended**, + /// as the receiving system may not know that these encoded values require special decoding logic. + /// The version without the shorthand can be accurately decoded by any system, + /// even without that prior knowledge. + /// + /// If this property returns `true`, ``WebURL/KeyValueStringSchema/decodePlusAsSpace`` must also return `true`. + /// + var encodeSpaceAsPlus: Bool { get } +} + +extension KeyValueStringSchema { + + @inlinable @inline(__always) + public func isPairDelimiter(_ byte: UInt8) -> Bool { + // Default: single delimiter. + byte == preferredPairDelimiter + } + + @inlinable @inline(__always) + public func isKeyValueDelimiter(_ byte: UInt8) -> Bool { + // Default: single delimiter. + byte == preferredKeyValueDelimiter + } + + @inlinable @inline(__always) + public func shouldPercentEncode(ascii codePoint: UInt8) -> Bool { + // Default: no additional percent-encoding. + false + } + + @inlinable @inline(__always) + public var encodeSpaceAsPlus: Bool { + // Default: encode spaces as "%20", not "+". + false + } +} + +extension KeyValueStringSchema { + + // TODO: Expose this as a customization point? e.g. for non-UTF8 encodings? + @inlinable + internal func unescapeAsUTF8String(_ source: some Collection) -> String { + if decodePlusAsSpace { + return source.percentDecodedString(substitutions: .formEncoding) + } else { + return source.percentDecodedString(substitutions: .none) + } + } +} + +internal enum KeyValueStringSchemaVerificationFailure: Error, CustomStringConvertible { + + case preferredKeyValueDelimiterIsInvalid + case preferredKeyValueDelimiterNotRecognized + case preferredPairDelimiterIsInvalid + case preferredPairDelimiterNotRecognized + case invalidKeyValueDelimiterIsRecognized + case invalidPairDelimiterIsRecognized + case inconsistentSpaceEncoding + + public var description: String { + switch self { + case .preferredKeyValueDelimiterIsInvalid: + return "Schema's preferred key-value delimiter is invalid" + case .preferredKeyValueDelimiterNotRecognized: + return "Schema does not recognize its preferred key-value delimiter as a key-value delimiter" + case .preferredPairDelimiterIsInvalid: + return "Schema's preferred pair delimiter is invalid" + case .preferredPairDelimiterNotRecognized: + return "Schema does not recognize its preferred pair delimiter as a pair delimiter" + case .invalidKeyValueDelimiterIsRecognized: + return "isKeyValueDelimiter recognizes an invalid delimiter" + case .invalidPairDelimiterIsRecognized: + return "isPairDelimiter recognizes an invalid delimiter" + case .inconsistentSpaceEncoding: + return "encodeSpaceAsPlus is true, so decodePlusAsSpace must also be true" + } + } +} + +extension KeyValueStringSchema { + + /// Checks this schema for consistency. + /// + /// This function allows authors of custom schemas to verify their implementations + /// for use in a particular URL component. If a schema fails verification, a fatal error will be triggered. + /// + /// ```swift + /// struct HashBetweenPairsSchema: KeyValueStringSchema { + /// var preferredPairDelimiter: UInt8 { UInt8(ascii: "#") } + /// // ... + /// } + /// + /// // We cannot write a key-value string like "http://ex/?key=value#key=value", + /// // because the "#" is the sigil for the start of the fragment. + /// + /// HashBetweenPairsSchema().verify(for: .query) + /// // โ—๏ธ fatal error: "Schema's preferred pair delimiter may not be used in the query" + /// + /// // But we *can* use it in the fragment itself - "http://ex/#key=value#key=value" + /// + /// HashBetweenPairsSchema().verify(for: .fragment) + /// // โœ… OK + /// ``` + /// + /// > Tip: + /// > + /// > Most developers will not have to define custom schemas. + /// > For those that do, it is recommended to run this verification + /// > as part of your regular unit tests. + /// + public func verify(for component: KeyValuePairsSupportedComponent) throws { + + // Preferred delimiters must not require escaping. + + let preferredDelimiters = verifyDelimitersDoNotNeedEscaping(in: component) + + if preferredDelimiters.keyValue == .max { + throw KeyValueStringSchemaVerificationFailure.preferredKeyValueDelimiterIsInvalid + } + if preferredDelimiters.pair == .max { + throw KeyValueStringSchemaVerificationFailure.preferredPairDelimiterIsInvalid + } + + // isKeyValueDelimiter/isPairDelimiter must recognize preferred delimiters, + // and must not recognize other reserved characters (e.g. %, ASCII hex digits, +). + + if !isKeyValueDelimiter(preferredDelimiters.keyValue) { + throw KeyValueStringSchemaVerificationFailure.preferredKeyValueDelimiterNotRecognized + } + if !isPairDelimiter(preferredDelimiters.pair) { + throw KeyValueStringSchemaVerificationFailure.preferredPairDelimiterNotRecognized + } + + func delimiterPredicateIsInvalid(_ isDelimiter: (UInt8) -> Bool) -> Bool { + "0123456789abcdefABCDEF%+".utf8.contains(where: isDelimiter) + } + + if delimiterPredicateIsInvalid(isKeyValueDelimiter) { + throw KeyValueStringSchemaVerificationFailure.invalidKeyValueDelimiterIsRecognized + } + if delimiterPredicateIsInvalid(isPairDelimiter) { + throw KeyValueStringSchemaVerificationFailure.invalidPairDelimiterIsRecognized + } + + // Space encoding must be consistent. + + if encodeSpaceAsPlus, !decodePlusAsSpace { + throw KeyValueStringSchemaVerificationFailure.inconsistentSpaceEncoding + } + + // All checks passed. + } +} + + +// -------------------------------------------- +// MARK: - Built-in Schemas +// -------------------------------------------- + + +extension KeyValueStringSchema where Self == FormCompatibleKeyValueString { + + /// A key-value string compatible with the `application/x-www-form-urlencoded` format. + /// + /// **Specification:** + /// + /// - Pair delimiter: U+0026 AMPERSAND (&) + /// - Key-value delimiter: U+003D EQUALS SIGN (=) + /// - Decode `+` as space: `true` + /// - Encode space as `+`: `false` + /// - Escapes all characters except: + /// - ASCII alphanumerics + /// - U+002A (\*), U+002D (-), U+002E (.), and U+005F (\_) + /// + /// This schema is used to read `application/x-www-form-urlencoded` content, + /// such as that produced by HTML forms or Javascript's `URLSearchParams` class. + /// + /// Unlike Javascript's `URLSearchParams`, spaces in inserted key-value pairs + /// are written using regular percent-encoding, rather than using the shorthand + /// which escapes them using plus signs. + /// + /// This removes a potential source of ambiguity for other systems processing the string. + /// + /// ## Topics + /// + /// ### Schema Type + /// + /// - ``WebURL/FormCompatibleKeyValueString`` + /// + @inlinable + public static var formEncoded: Self { Self() } +} + +/// A key-value string compatible with the `application/x-www-form-urlencoded` format. +/// +/// See ``WebURL/KeyValueStringSchema/formEncoded`` for more information. +/// +public struct FormCompatibleKeyValueString: KeyValueStringSchema, Sendable { + + @inlinable + public init() {} + + @inlinable + public var preferredPairDelimiter: UInt8 { + ASCII.ampersand.codePoint + } + + @inlinable + public var preferredKeyValueDelimiter: UInt8 { + ASCII.equalSign.codePoint + } + + @inlinable + public var decodePlusAsSpace: Bool { + true + } + + @inlinable + public func shouldPercentEncode(ascii codePoint: UInt8) -> Bool { + URLEncodeSet.FormEncoding().shouldPercentEncode(ascii: codePoint) + } +} + +extension KeyValueStringSchema where Self == PercentEncodedKeyValueString { + + /// A key-value string which uses conventional percent-encoding. + /// + /// **Specification:** + /// + /// - Pair delimiter: U+0026 AMPERSAND (&) + /// - Key-value delimiter: U+003D EQUALS SIGN (=) + /// - Decode `+` as space: `false` + /// - Encode space as `+`: `false` + /// - Escapes all characters except: + /// - Characters allowed by the URL component + /// + /// The differences between this schema and ``WebURL/FormCompatibleKeyValueString`` are: + /// + /// - Plus signs in the URL component have no special meaning, + /// and are interpreted as literal plus signs rather than escaped spaces. + /// + /// - All characters allowed in the URL component may be used without escaping. + /// + /// ## Topics + /// + /// ### Schema Type + /// + /// - ``WebURL/PercentEncodedKeyValueString`` + /// + @inlinable + public static var percentEncoded: Self { Self() } +} + +/// A key-value string which uses conventional percent-encoding. +/// +/// See ``WebURL/KeyValueStringSchema/percentEncoded`` for more information. +/// +public struct PercentEncodedKeyValueString: KeyValueStringSchema, Sendable { + + @inlinable + public init() {} + + @inlinable + public var preferredPairDelimiter: UInt8 { + ASCII.ampersand.codePoint + } + + @inlinable + public var preferredKeyValueDelimiter: UInt8 { + ASCII.equalSign.codePoint + } + + @inlinable + public var decodePlusAsSpace: Bool { + false + } +} + + +// -------------------------------------------- +// MARK: - WebURL.KeyValuePairs +// -------------------------------------------- + + +/// A URL component which can contain a key-value string. +/// +public struct KeyValuePairsSupportedComponent { + + @usableFromInline + internal enum _Value { + case query + case fragment + + // TODO: We could support other opaque components. + // + // - 'mongodb:' URLs encode a comma-separated list in an opaque host. + // I can't find any that use key-value strings, + // but there is precendent for having some structured information in the host. + // + // - 'proxy:' URLs have key-value pairs in opaque paths. + // https://web.archive.org/web/20130620014949/http://getfoxyproxy.org/proxyprotocol.html + // This seems like it would be useful for many applications, and quite easy to support. + } + + @usableFromInline + internal var value: _Value + + @inlinable + internal init(_ value: _Value) { + self.value = value + } + + /// The query component. + /// + /// See ``WebURL/WebURL/query`` for more information about this component. + /// + @inlinable + public static var query: Self { Self(.query) } + + /// The fragment component. + /// + /// See ``WebURL/WebURL/fragment`` for more information about this component. + /// + @inlinable + public static var fragment: Self { Self(.fragment) } +} + +extension KeyValuePairsSupportedComponent: CaseIterable { + + public static var allCases: [KeyValuePairsSupportedComponent] { + [.query, .fragment] + } +} + +extension WebURL { + + /// A view of a URL component as a list of key-value pairs, using a given schema. + /// + /// To access the view for a component, use the ``WebURL/WebURL/keyValuePairs(in:schema:)`` function. + /// The ``WebURL/WebURL/queryParams`` property is a convenient shorthand for accessing pairs + /// in a URL's query using the ``WebURL/FormCompatibleKeyValueString/formEncoded`` schema. + /// + /// ```swift + /// var url = WebURL("http://example.com/convert?from=EUR&to=USD")! + /// + /// url.queryParams["from"] // โœ… "EUR" + /// url.queryParams["to"] // โœ… "USD" + /// + /// url.queryParams["from"] = "GBP" + /// // โœ… "http://example.com/convert?from=GBP&to=USD" + /// // ^^^^^^^^ + /// url.queryParams["amount"] = "20" + /// // โœ… "http://example.com/convert?from=GBP&to=USD&amount=20" + /// // ^^^^^^^^^ + /// ``` + /// + /// > Tip: + /// > + /// > The documentation for this type can be found at: ``WebURL/WebURL/keyValuePairs(in:schema:)`` + /// + public struct KeyValuePairs { + + /// The URL. + /// + @usableFromInline + internal var storage: URLStorage + + /// The component of `storage` containing the key-value string. + /// + @usableFromInline + internal var component: KeyValuePairsSupportedComponent + + /// The specification of the key-value string. + /// + @usableFromInline + internal var schema: Schema + + /// A token for mutable views in to which storage has been temporarily moved. + /// + /// See ``WebURL/WebURL/withMutableKeyValuePairs(in:schema:_:)`` for usage. + /// Views which do not contain moved storage should set this to 0. + /// + @usableFromInline + internal let mutatingScopeID: UInt + + /// Data about the key-value string which is derived from the other properties, + /// stored to accelerate certain operations which may access it frequently. + /// + /// > Important: + /// > This value must be updated after the URL is modified. + /// + @usableFromInline + internal var cache: Cache + + @inlinable + internal init( + storage: URLStorage, + component: KeyValuePairsSupportedComponent, + schema: Schema, + mutatingScopeID: UInt = 0 + ) { + self.storage = storage + self.component = component + self.schema = schema + self.mutatingScopeID = mutatingScopeID + self.cache = .calculate(storage: storage, component: component, schema: schema) + } + } +} + +extension WebURL.KeyValuePairs { + + @usableFromInline + internal struct Cache { + + /// The range of code-units containing the key-value string. + /// + /// This range does not include any leading or trailing URL component delimiters; + /// for example, the leading `"?"` in the query is **NOT** included in this range. + /// + /// > Important: + /// > This value must be updated after the URL is modified. + /// + @usableFromInline + internal var componentContents: Range + + /// The index of the initial key-value pair. + /// + /// > Important: + /// > This value must be updated after the URL is modified. + /// + @usableFromInline + internal var startIndex: Index + + @inlinable + internal init(componentContents: Range, startIndex: WebURL.KeyValuePairs.Index) { + self.componentContents = componentContents + self.startIndex = startIndex + } + + @inlinable + internal static func _componentContents( + startingAt contentStart: URLStorage.SizeType, + storage: URLStorage, + component: KeyValuePairsSupportedComponent + ) -> Range { + + var contentLength: URLStorage.SizeType + + switch component.value { + case .query: + contentLength = storage.structure.queryLength + case .fragment: + contentLength = storage.structure.fragmentLength + } + if contentLength > 0 { + contentLength &-= 1 + } + + return Range(uncheckedBounds: (contentStart, contentStart &+ contentLength)) + } + + /// Returns updated info about the key-value string. + /// + /// This function requires no prior knowledge of the URL component or key-value string, + /// and calculates everything from scratch. + /// + /// | `startIndex` | Component content location | Component content length | + /// | ------------ | -------------------------- | ------------------------ | + /// | unknown | unknown | unknown | + /// + @inlinable + internal static func calculate( + storage: URLStorage, + component: KeyValuePairsSupportedComponent, + schema: Schema + ) -> Cache { + + let component = component.urlComponent + let contents = storage.structure.rangeForReplacingCodeUnits(of: component).dropFirst() + let startIndex = Index.nextFrom(contents.lowerBound, in: storage.utf8[contents], schema: schema) + return Cache(componentContents: contents, startIndex: startIndex) + } + + /// Returns updated info about the key-value string. + /// + /// This function requires the caller to know the key-value string's startIndex, + /// as well as the start location of the URL component's content. + /// + /// > Important: + /// > The _content_ start location is required, not the _component_ start location. + /// > If the component could have been set to/from nil, it can be difficult to know the content start location. + /// + /// | `startIndex` | Component content location | Component content length | + /// | ------------ | -------------------------- | ------------------------ | + /// | known | known | unknown | + /// + @inlinable + internal static func withKnownStartIndex( + startIndex: Index, + contentStart: URLStorage.SizeType, + storage: URLStorage, + component: KeyValuePairsSupportedComponent + ) -> Cache { + + let contents = _componentContents(startingAt: contentStart, storage: storage, component: component) + return Cache(componentContents: contents, startIndex: startIndex) + } + + @inlinable + internal mutating func updateAfterAppend( + storage: URLStorage, + component: KeyValuePairsSupportedComponent, + schema: Schema + ) { + + // If the list was previously empty, we don't know startIndex, + // and we don't know what the component's starting location is (it may have been nil before). + + if startIndex.keyValuePair.lowerBound == componentContents.upperBound { + self = .calculate(storage: storage, component: component, schema: schema) + return + } + + // If the list was not empty, the URL component cannot be empty/nil. + // startIndex and content start location are unchanged by append, but the URL component length will change. + + componentContents = Cache._componentContents( + startingAt: componentContents.lowerBound, + storage: storage, + component: component + ) + } + } +} + + +// -------------------------------------------- +// MARK: - Standard Protocols +// -------------------------------------------- + + +extension KeyValuePairsSupportedComponent: Sendable {} +extension KeyValuePairsSupportedComponent._Value: Sendable {} +extension WebURL.KeyValuePairs: Sendable where Schema: Sendable {} +extension WebURL.KeyValuePairs.Index: Sendable where Schema: Sendable {} +extension WebURL.KeyValuePairs.Cache: Sendable where Schema: Sendable {} +extension WebURL.KeyValuePairs.Element: Sendable where Schema: Sendable {} + +extension WebURL.KeyValuePairs: CustomStringConvertible { + + public var description: String { + String(decoding: storage.utf8[cache.componentContents], as: UTF8.self) + } +} + + +// -------------------------------------------- +// MARK: - Reading: By Location. +// -------------------------------------------- + + +extension WebURL.KeyValuePairs: Collection { + + @inlinable + public var startIndex: Index { + cache.startIndex + } + + @inlinable + public var endIndex: Index { + .endIndex(cache.componentContents.upperBound) + } + + @inlinable + public func index(after i: Index) -> Index { + assert(i < endIndex, "Attempt to advance endIndex") + return .nextFrom(i.keyValuePair.upperBound &+ 1, in: storage.utf8[cache.componentContents], schema: schema) + } + + public struct Index: Comparable { + + @usableFromInline + internal typealias Source = WebURL.UTF8View.SubSequence + + /// The range of the entire key-value pair. + /// + /// - lowerBound = startIndex of key + /// - upperBound = endIndex of value (either a pair delimiter or overall endIndex) + /// + @usableFromInline + internal let keyValuePair: Range + + /// The position of the key-value delimiter. + /// + /// Either: + /// - Index of the byte which separates the key from the value, or + /// - keyValuePair.upperBound, if there is no delimiter in the key-value pair. + /// + @usableFromInline + internal let keyValueDelimiter: URLStorage.SizeType + + @inlinable + internal init( + keyValuePair: Range, + keyValueDelimiter: URLStorage.SizeType + ) { + self.keyValuePair = keyValuePair + self.keyValueDelimiter = keyValueDelimiter + } + + /// Returns an Index containing an empty key-value pair at the given source location. + /// Intended to be used when creating an endIndex. + /// + @inlinable + internal static func endIndex(_ i: URLStorage.SizeType) -> Index { + return Index(keyValuePair: i.. Index { + + precondition(i >= source.startIndex) + guard i < source.endIndex else { + return .endIndex(URLStorage.SizeType(source.endIndex)) + } + + var cursor = i + var kvpStart = i + var keyValueDelimiter = Optional.none + + while cursor < source.endIndex { + let byte = source.base[cursor] + if schema.isKeyValueDelimiter(byte), keyValueDelimiter == nil { + keyValueDelimiter = cursor + } else if schema.isPairDelimiter(byte) { + // If the KVP is empty, skip it. + guard kvpStart != cursor else { + cursor &+= 1 + kvpStart = cursor + assert(keyValueDelimiter == nil) + continue + } + break + } + cursor &+= 1 + } + + return Index( + keyValuePair: Range(uncheckedBounds: (kvpStart, cursor)), + keyValueDelimiter: keyValueDelimiter ?? cursor + ) + } + + /// Returns an index with the same key and value lengths as this pair, but beginning at `newStartOfKey`. + /// + @inlinable + internal func rebased(newStartOfKey: URLStorage.SizeType) -> Index { + let rebasedPairEnd = newStartOfKey &+ (keyValuePair.upperBound &- keyValuePair.lowerBound) + let rebasedDelimit = newStartOfKey &+ (keyValueDelimiter &- keyValuePair.lowerBound) + return Index( + keyValuePair: Range(uncheckedBounds: (newStartOfKey, rebasedPairEnd)), + keyValueDelimiter: rebasedDelimit + ) + } + + /// The region of the source collection containing this pair's key. + /// + @inlinable + internal var rangeOfKey: Range { + Range(uncheckedBounds: (keyValuePair.lowerBound, keyValueDelimiter)) + } + + /// Whether this pair contains a key-value delimiter. + /// + @inlinable + internal var hasKeyValueDelimiter: Bool { + keyValueDelimiter != keyValuePair.upperBound + } + + /// The region of the source collection containing this pair's value. + /// + @inlinable + internal var rangeOfValue: Range { + let upper = keyValuePair.upperBound + let lower = hasKeyValueDelimiter ? (keyValueDelimiter &+ 1) : upper + return Range(uncheckedBounds: (lower, upper)) + } + + @inlinable + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.keyValuePair == rhs.keyValuePair && lhs.keyValueDelimiter == rhs.keyValueDelimiter + } + + @inlinable + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.keyValuePair.lowerBound < rhs.keyValuePair.lowerBound + } + + @inlinable + public static func > (lhs: Self, rhs: Self) -> Bool { + lhs.keyValuePair.lowerBound > rhs.keyValuePair.lowerBound + } + } +} + + +// MARK: TODO: BidirectionalCollection. + + +extension WebURL.KeyValuePairs { + + @inlinable + public subscript(position: Index) -> Element { + assert(position >= startIndex && position < endIndex, "Attempt to access an element at an invalid index") + return Element(source: storage.codeUnits, position: position, schema: schema) + } + + /// A slice of a URL component, containing a single key-value pair. + /// + /// Use the ``key`` and ``value`` properties to access the pair's decoded components. + /// + /// ```swift + /// let url = WebURL("http://example/?name=Johnny+Appleseed&age=28")! + /// // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + /// + /// for kvp in url.queryParams { + /// print(kvp.key, "-", kvp.value) + /// } + /// + /// // Prints: + /// // "name - Johnny Appleseed" + /// // "age - 28" + /// ``` + /// + /// Because these key-value pairs are slices, they share storage with the URL and decode the key/value on-demand. + /// + public struct Element: CustomStringConvertible { + + @usableFromInline + internal var source: URLStorage.CodeUnits + + @usableFromInline + internal var position: Index + + @usableFromInline + internal let schema: Schema + + @inlinable + internal init(source: URLStorage.CodeUnits, position: Index, schema: Schema) { + self.source = source + self.position = position + self.schema = schema + } + + /// The key component, as decoded by the schema. + /// + @inlinable + public var key: String { + schema.unescapeAsUTF8String(source[position.rangeOfKey]) + } + + /// The value component, as decoded by the schema. + /// + @inlinable + public var value: String { + schema.unescapeAsUTF8String(source[position.rangeOfValue]) + } + + /// The key component, as written in the URL (without decoding). + /// + @inlinable + public var encodedKey: String { + String(decoding: source[position.rangeOfKey], as: UTF8.self) + } + + /// The value component, as written in the URL (without decoding). + /// + @inlinable + public var encodedValue: String { + String(decoding: source[position.rangeOfValue], as: UTF8.self) + } + + @inlinable + public var description: String { + "(key: \"\(key)\", value: \"\(value)\")" + } + } +} + + +// -------------------------------------------- +// MARK: - Writing: By Location. +// -------------------------------------------- + + +extension WebURL.KeyValuePairs { + + @inlinable + internal mutating func replaceSubrange( + _ bounds: Range, withUTF8 newPairs: some Collection<(some Collection, some Collection)> + ) -> Range { + + let newBounds = try! storage.replaceKeyValuePairs( + bounds, + in: component, + schema: schema, + startOfFirstPair: startIndex.keyValuePair.lowerBound, + with: newPairs + ).get() + + // TODO: Optimize cache update. + cache = .calculate(storage: storage, component: component, schema: schema) + + return newBounds + } + + /// Replaces the key-value pairs at the given locations. + /// + /// The number of new pairs does not need to equal the number of pairs being replaced. + /// Keys and values will automatically be encoded to preserve their content exactly as given. + /// + /// The following example demonstrates replacing a range of pairs in the middle of the list. + /// + /// ```swift + /// var url = WebURL("http://example/?q=test&sort=fieldA&sort=fieldB&sort=fieldC&limit=10")! + /// + /// // Find consecutive pairs named "sort". + /// let sortFields = url.queryParams.drop { $0.key != "sort" }.prefix { $0.key == "sort" } + /// + /// // Combine their values. + /// let combinedFields = sortFields.lazy.map { $0.value }.joined(separator: ",") + /// + /// // Replace the original pairs with the combined pair. + /// url.queryParams.replaceSubrange( + /// sortFields.startIndex.. Note: + /// > This function invalidates any existing indexes for this URL. + /// + /// - returns: The locations of the inserted pairs. + /// If `newPairs` is empty, the result is an empty range at the new location of `bounds.upperBound`. + /// + @inlinable + @discardableResult + public mutating func replaceSubrange( + _ bounds: some RangeExpression, with newPairs: some Collection<(some StringProtocol, some StringProtocol)> + ) -> Range { + replaceSubrange(bounds.relative(to: self), withUTF8: newPairs.lazy.map { ($0.0.utf8, $0.1.utf8) }) + } + + /// Inserts a collection of key-value pairs at a given location. + /// + /// The new pairs are inserted before the pair currently at `location`. + /// Keys and values will automatically be encoded to preserve their content exactly as given. + /// + /// Some services allow multiple pairs with the same key, and the location of those pairs can be significant. + /// The following example demonstrates inserting a group of pairs, all with the key "sort", + /// immediately before an existing pair with the same name. + /// + /// ```swift + /// var url = WebURL("http://example/articles?q=test&sort=length&limit=10")! + /// + /// // Find the location of the existing 'sort' key. + /// let sortKey = url.queryParams.firstIndex(where: { $0.key == "sort" }) ?? url.queryParams.endIndex + /// + /// // Insert additional 'sort' keys before it. + /// url.queryParams.insert( + /// contentsOf: [("sort", "date"), ("sort", "title")], + /// at: sortKey + /// ) + /// // โœ… "http://example/articles?q=test&sort=date&sort=title&sort=length&limit=10" + /// // ^^^^^^^^^^^^^^^^^^^^ + /// + /// url.queryParams.allValues(forKey: "sort") + /// // โœ… ["date", "title", "length"] + /// ``` + /// + /// If `location` is the list's `endIndex`, the new components are appended to the list. + /// Calling ``append(contentsOf:)-84bng`` instead is preferred. + /// + /// > Note: + /// > This function invalidates any existing indexes for this URL. + /// + /// - returns: The locations of the inserted pairs. + /// + @inlinable + @discardableResult + public mutating func insert( + contentsOf newPairs: some Collection<(some StringProtocol, some StringProtocol)>, at location: Index + ) -> Range { + replaceSubrange(Range(uncheckedBounds: (location, location)), with: newPairs) + } + + /// Removes the key-value pairs at the given locations. + /// + /// The following example removes all pairs from a URL's query, + /// with the exception of the first pair. + /// + /// ``` + /// var url = WebURL("http://example/search?q=test&sort=updated&limit=10&page=3")! + /// + /// // Find the location of the second pair. + /// let secondPosition = url.queryParams.index(after: url.queryParams.startIndex) + /// + /// // Remove the second pair, and all subsequent pairs. + /// url.queryParams.removeSubrange(secondPosition...) + /// // โœ… "http://example/search?q=test" + /// ``` + /// + /// > Note: + /// > This function invalidates any existing indexes for this URL. + /// + /// - returns: The new location of `bounds.upperBound`. + /// + @inlinable + @discardableResult + public mutating func removeSubrange( + _ bounds: some RangeExpression + ) -> Index { + replaceSubrange( + bounds.relative(to: self), + withUTF8: EmptyCollection<(EmptyCollection, EmptyCollection)>() + ).upperBound + } + + /// Inserts a collection of key-value pairs at the end of this list. + /// + /// This function is equivalent to the `+=` operator. + /// Keys and values will automatically be encoded to preserve their content exactly as given. + /// + /// The following example demonstrates building a URL's query by appending collections of key-value pairs. + /// + /// ```swift + /// var url = WebURL("https://example.com/convert")! + /// + /// url.queryParams += [ + /// ("amount", "200"), + /// ("from", "USD"), + /// ("to", "EUR"), + /// ] + /// // โœ… "https://example.com/convert?amount=200&from=USD&to=EUR" + /// // ^^^^^^^^^^^^^^^^^^^^^^^^^^ + /// + /// url.queryParams.append(contentsOf: [ + /// ("lang", "en"), + /// ("client", "app"), + /// ]) + /// // โœ… "https://example.com/convert?amount=200&from=USD&to=EUR&lang=en&client=app" + /// // ^^^^^^^^^^^^^^^^^^ + /// ``` + /// + /// > Note: + /// > This function invalidates any existing indexes for this URL. + /// + /// - returns: The locations of the inserted pairs. + /// + @inlinable + @discardableResult + public mutating func append( + contentsOf newPairs: some Collection<(some StringProtocol, some StringProtocol)> + ) -> Range { + + let inserted = try! storage.replaceKeyValuePairs( + Range(uncheckedBounds: (endIndex, endIndex)), + in: component, + schema: schema, + startOfFirstPair: startIndex.keyValuePair.lowerBound, + with: newPairs.lazy.map { ($0.0.utf8, $0.1.utf8) } + ).get() + + cache.updateAfterAppend(storage: storage, component: component, schema: schema) + + return inserted + } + + /// Inserts a collection of key-value pairs at the end of this list. + /// + /// This operator is equivalent to the ``append(contentsOf:)-84bng`` function. + /// + @inlinable + public static func += ( + lhs: inout WebURL.KeyValuePairs, + rhs: some Collection<(some StringProtocol, some StringProtocol)> + ) { + lhs.append(contentsOf: rhs) + } + + // Append Overload: Tuple labels + // ----------------------------- + // Unfortunately, `(String, String)` and `(key: String, value: String)` are treated as different types. + // This can be inconvenient, so we add overloads which include the tuple labels "key" and "value" - + // but only for `append(contentsOf:)` and the `+=` operator. + + /// Inserts a collection of key-value pairs at the end of this list. + /// + /// This function is equivalent to the `+=` operator. + /// Keys and values will automatically be encoded to preserve their content exactly as given. + /// + /// The following example demonstrates building a URL's query by appending collections of key-value pairs. + /// + /// ```swift + /// var url = WebURL("https://example.com/convert")! + /// + /// url.queryParams += [ + /// (key: "amount", value: "200"), + /// (key: "from", value: "USD"), + /// (key: "to", value: "EUR"), + /// ] + /// // โœ… "https://example.com/convert?amount=200&from=USD&to=EUR" + /// // ^^^^^^^^^^^^^^^^^^^^^^^^^^ + /// + /// url.queryParams.append(contentsOf: [ + /// (key: "lang", value: "en"), + /// (key: "client", value: "app"), + /// ]) + /// // โœ… "https://example.com/convert?amount=200&from=USD&to=EUR&lang=en&client=app" + /// // ^^^^^^^^^^^^^^^^^^ + /// ``` + /// + /// > Note: + /// > This function invalidates any existing indexes for this URL. + /// + /// - returns: The locations of the inserted pairs. + /// + @inlinable + @discardableResult + public mutating func append( + contentsOf newPairs: some Collection<(key: some StringProtocol, value: some StringProtocol)> + ) -> Range { + + let inserted = try! storage.replaceKeyValuePairs( + Range(uncheckedBounds: (endIndex, endIndex)), + in: component, + schema: schema, + startOfFirstPair: startIndex.keyValuePair.lowerBound, + with: newPairs.lazy.map { ($0.key.utf8, $0.value.utf8) } + ).get() + + cache.updateAfterAppend(storage: storage, component: component, schema: schema) + + return inserted + } + + /// Inserts a collection of key-value pairs at the end of this list. + /// + /// This operator is equivalent to the ``append(contentsOf:)-9lkdx`` function. + /// + @inlinable + public static func += ( + lhs: inout WebURL.KeyValuePairs, + rhs: some Collection<(key: some StringProtocol, value: some StringProtocol)> + ) { + lhs.append(contentsOf: rhs) + } + + // Append Overload: Dictionary + // --------------------------- + // Dictionary is interesting because it is an unordered collection, but users will want to append + // dictionaries using, say, the `+=` operator, and be displeased when it results in a different order each time. + // To make the experience less unpleasant, add an overload which sorts the keys. + + /// Inserts the key-value pairs from a `Dictionary` at the end of this list. + /// + /// This function is equivalent to the `+=` operator. + /// Keys and values will automatically be encoded to preserve their content exactly as given. + /// + /// The following example demonstrates building a URL's query by appending dictionaries. + /// + /// ```swift + /// var url = WebURL("https://example.com/convert")! + /// + /// url.queryParams.append += [ + /// "amount" : "200", + /// "from" : "USD", + /// "to" : "EUR" + /// ] + /// // โœ… "https://example.com/convert?amount=200&from=USD&to=EUR" + /// // ^^^^^^^^^^^^^^^^^^^^^^^^^^ + /// + /// url.queryParams.append(contentsOf: [ + /// "lang" : "en", + /// "client" : "app" + /// ]) + /// // โœ… "https://example.com/convert?amount=200&from=USD&to=EUR&client=app&lang=en" + /// // ^^^^^^^^^^^^^^^^^^ + /// ``` + /// + /// Since a `Dictionary`'s contents are unordered, this method will sort the new pairs + /// by key before appending, in order to produce a predictable result. + /// + /// > Note: + /// > This function invalidates any existing indexes for this URL. + /// + /// - returns: The locations of the inserted pairs. + /// + @inlinable + @discardableResult + public mutating func append(contentsOf newPairs: [some StringProtocol: some StringProtocol]) -> Range { + append(contentsOf: newPairs.sorted(by: { lhs, rhs in lhs.key < rhs.key })) + } + + /// Inserts the key-value pairs from a `Dictionary` at the end of this list. + /// + /// This operator is equivalent to the ``append(contentsOf:)-5nok5`` function. + /// + @inlinable + public static func += (lhs: inout WebURL.KeyValuePairs, rhs: [some StringProtocol: some StringProtocol]) { + lhs.append(contentsOf: rhs) + } +} + +extension WebURL.KeyValuePairs { + + /// Inserts a key-value pair at the end of this list. + /// + /// The key and value will automatically be encoded to preserve their content exactly as given. + /// + /// The following example demonstrates adding a single pair to a URL's query. + /// + /// ```swift + /// var url = WebURL("https://example.com/articles?q=football")! + /// + /// url.queryParams.append(key: "limit", value: "10") + /// // โœ… "https://example.com/articles?q=football&limit=10" + /// // ^^^^^^^^ + /// ``` + /// + /// > Note: + /// > This function invalidates any existing indexes for this URL. + /// + /// - returns: The location of the inserted pair. + /// + @inlinable + @discardableResult + public mutating func append(key: some StringProtocol, value: some StringProtocol) -> Index { + + let inserted = key._withContiguousUTF8 { unsafeKey in + value._withContiguousUTF8 { unsafeValue in + try! storage.replaceKeyValuePairs( + Range(uncheckedBounds: (endIndex, endIndex)), + in: component, + schema: schema, + startOfFirstPair: startIndex.keyValuePair.lowerBound, + with: CollectionOfOne((unsafeKey.boundsChecked, unsafeValue.boundsChecked)) + ).get() + } + } + + cache.updateAfterAppend(storage: storage, component: component, schema: schema) + + return inserted.lowerBound + } + + /// Inserts a key-value pair at a given location. + /// + /// The pair is inserted before the pair currently at `location`. + /// The key and value will automatically be encoded to preserve their content exactly as given. + /// + /// Some services support multiple pairs with the same key, and the order of those pairs can be important. + /// The following example demonstrates inserting a pair with the key "sort", + /// immediately before an existing pair with the same name. + /// + /// ```swift + /// var url = WebURL("http://example/students?class=12&sort=name")! + /// + /// // Find the location of the existing 'sort' key. + /// let sortIdx = url.queryParams.firstIndex(where: { $0.key == "sort" }) ?? url.queryParams.endIndex + /// + /// // Insert an additional 'sort' key before it. + /// url.queryParams.insert(key: "sort", value: "age", at: sortIdx) + /// // โœ… "http://example/students?class=12&sort=age&sort=name" + /// // ^^^^^^^^ + /// + /// url.queryParams.allValues(forKey: "sort") + /// // โœ… ["age", "name"] + /// ``` + /// + /// If `location` is the list's `endIndex`, the new component is appended to the list. + /// Calling ``append(key:value:)`` instead is preferred. + /// + /// > Note: + /// > This function invalidates any existing indexes for this URL. + /// + /// - returns: A `Range` containing a single index. + /// The range's `lowerBound` is the location of the inserted pair, + /// and its `upperBound` points to the pair previously at `location`. + /// + @inlinable + @discardableResult + public mutating func insert( + key: some StringProtocol, value: some StringProtocol, at location: Index + ) -> Range { + + let inserted = key._withContiguousUTF8 { unsafeKey in + value._withContiguousUTF8 { unsafeValue in + try! storage.replaceKeyValuePairs( + Range(uncheckedBounds: (location, location)), + in: component, + schema: schema, + startOfFirstPair: startIndex.keyValuePair.lowerBound, + with: CollectionOfOne((unsafeKey.boundsChecked, unsafeValue.boundsChecked)) + ).get() + } + } + + // TODO: Optimize cache update. + cache = .calculate(storage: storage, component: component, schema: schema) + + return inserted + } + + /// Removes the key-value pair at a given location. + /// + /// The following example demonstrates a simple toggle for faceted search - + /// when a user deselects brand "B", we identify the specific key-value pair for that brand and remove it. + /// Other pairs remain intact, even though they share the key "brand". + /// + /// ```swift + /// extension WebURL.KeyValuePairs { + /// mutating func toggleFacet(name: String, value: String) { + /// if let i = firstIndex(where: { $0.key == name && $0.value == value }) { + /// remove(at: i) + /// } else { + /// append(key: name, value: value) + /// } + /// } + /// } + /// + /// var url = WebURL("http://example.com/products?brand=A&brand=B")! + /// + /// url.queryParams.toggleFacet(name: "brand", value: "C") + /// // โœ… "http://example.com/products?brand=A&brand=B&brand=C" + /// // ^^^^^^^ + /// + /// url.queryParams.toggleFacet(name: "brand", value: "B") + /// // โœ… "http://example.com/products?brand=A&brand=C" + /// // ^^^ + /// ``` + /// + /// > Note: + /// > This function invalidates any existing indexes for this URL. + /// + /// - returns: The new location of the pair which followed the given `location`. + /// + @inlinable + @discardableResult + public mutating func remove(at location: Index) -> Index { + removeSubrange(Range(uncheckedBounds: (location, index(after: location)))) + } +} + +extension WebURL.KeyValuePairs { + + /// Replaces the 'key' portion of the pair at a given location. + /// + /// The new key will automatically be encoded to preserve its content exactly as given. + /// + /// The following example finds the first pair with key "flag", and replaces its key with "other\_flag". + /// + /// ```swift + /// var url = WebURL("http://example/?q=test&flag=1&limit=10")! + /// + /// if let flagIdx = url.queryParams.firstIndex(where: { $0.key == "flag" }) { + /// url.queryParams.replaceKey(at: flagIdx, with: "other_flag") + /// } + /// // โœ… "http://example/?q=test&other_flag=1&limit=10" + /// // ^^^^^^^^^^^^ + /// ``` + /// + /// > Note: + /// > This function invalidates any existing indexes for this URL. + /// + /// - returns: The new index for the modified pair. + /// + @inlinable + @discardableResult + public mutating func replaceKey(at location: Index, with newKey: some StringProtocol) -> Index { + + // If there is no key-value delimiter, and we are setting this pair to the empty key, + // we will have to insert a key-value delimiter. + + let insertDelimiter = !location.hasKeyValueDelimiter && newKey.isEmpty + + let newKeyLength = newKey._withContiguousUTF8 { unsafeNewKey in + try! storage.replaceKeyValuePairComponent( + location.rangeOfKey, + in: component, + bounds: cache.componentContents, + schema: schema, + insertDelimiter: insertDelimiter, + newContent: unsafeNewKey.boundsChecked + ).get() + } + + let didModifyStartIndex = (location == startIndex) + let newKeyEnd = location.keyValuePair.lowerBound &+ newKeyLength + let valueLength = location.rangeOfValue.upperBound &- location.rangeOfValue.lowerBound + let delimiterLength: URLStorage.SizeType = (location.hasKeyValueDelimiter || insertDelimiter) ? 1 : 0 + + let adjustedIndex = Index( + keyValuePair: location.keyValuePair.lowerBound..<(newKeyEnd &+ delimiterLength &+ valueLength), + keyValueDelimiter: newKeyEnd + ) + + cache = .withKnownStartIndex( + startIndex: didModifyStartIndex ? adjustedIndex : startIndex, + contentStart: cache.componentContents.lowerBound, + storage: storage, + component: component + ) + + return adjustedIndex + } + + /// Replaces the 'value' portion of the pair at a given location. + /// + /// The new value will automatically be encoded to preserve its content exactly as given. + /// + /// The following example finds the first pair with key "offset", and increments its value. + /// + /// ```swift + /// extension WebURL.KeyValuePairs { + /// mutating func incrementOffset(by amount: Int = 10) { + /// if let i = firstIndex(where: { $0.key == "offset" }) { + /// let newValue = Int(self[i].value).map { $0 + amount } ?? 0 + /// replaceValue(at: i, with: String(newValue)) + /// } else { + /// append(key: "offset", value: "0") + /// } + /// } + /// } + /// + /// var url = WebURL("http://example/?q=test&offset=0")! + /// + /// url.queryParams.incrementOffset() + /// // โœ… "http://example/?q=test&offset=10" + /// // ^^^^^^^^^ + /// + /// url.queryParams.append(key: "limit", value: "20") + /// // โœ… "http://example/?q=test&offset=10&limit=20" + /// // ^^^^^^^^^ + /// + /// url.queryParams.incrementOffset(by: 7) + /// // โœ… "http://example/?q=test&offset=17&limit=20" + /// // ^^^^^^^^^ + /// + /// url.queryParams.incrementOffset() + /// // โœ… "http://example/?q=test&offset=27&limit=20" + /// // ^^^^^^^^^ + /// ``` + /// + /// > Note: + /// > This function invalidates any existing indexes for this URL. + /// + /// - returns: The new index for the modified pair. + /// + @inlinable + @discardableResult + public mutating func replaceValue(at location: Index, with newValue: some StringProtocol) -> Index { + + // If there is no key-value delimiter, and we are inserting a non-empty value, + // we will have to add a delimiter. + + let insertDelimiter = !location.hasKeyValueDelimiter && !newValue.isEmpty + + let newValueLength = newValue._withContiguousUTF8 { unsafeNewValue in + try! storage.replaceKeyValuePairComponent( + location.rangeOfValue, + in: component, + bounds: cache.componentContents, + schema: schema, + insertDelimiter: insertDelimiter, + newContent: unsafeNewValue.boundsChecked + ).get() + } + + let didModifyStartIndex = (location == startIndex) + let delimiterLen: URLStorage.SizeType = (location.hasKeyValueDelimiter || insertDelimiter) ? 1 : 0 + let adjustedIndex = Index( + keyValuePair: location.keyValuePair.lowerBound..<(location.keyValueDelimiter &+ delimiterLen &+ newValueLength), + keyValueDelimiter: location.keyValueDelimiter + ) + + cache = .withKnownStartIndex( + startIndex: didModifyStartIndex ? adjustedIndex : startIndex, + contentStart: cache.componentContents.lowerBound, + storage: storage, + component: component + ) + + return adjustedIndex + } +} + +extension WebURL.KeyValuePairs { + + /// Removes all key-value pairs in the given range which match a predicate. + /// + /// The following example removes some key-value pairs commonly used to track marketing campaigns. + /// + /// ```swift + /// let trackingKeys: Set = [ + /// "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", /* ... */ + /// ] + /// + /// var url = WebURL("http://example/p?sort=new&utm_source=swift.org&utm_campaign=example&version=2")! + /// + /// url.queryParams.removeAll { trackingKeys.contains($0.key) } + /// // โœ… "http://example/p?sort=new&version=2" + /// ``` + /// + /// > Note: + /// > This function invalidates any existing indexes for this URL. + /// + /// - parameters: + /// - bounds: The locations of the key-value pairs to be checked. + /// Pairs outside of this range will not be passed to `predicate` and will not be removed. + /// + /// - predicate: A closure which decides whether the key-value pair should be removed. + /// + @inlinable + public mutating func removeAll(in bounds: some RangeExpression, where predicate: (Element) -> Bool) { + _removeAll(in: bounds.relative(to: self), where: predicate) + } + + /// Removes all key-value pairs which match a predicate. + /// + /// The following example removes some key-value pairs commonly used to track marketing campaigns. + /// + /// ```swift + /// let trackingKeys: Set = [ + /// "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", /* ... */ + /// ] + /// + /// var url = WebURL("http://example/p?sort=new&utm_source=swift.org&utm_campaign=example&version=2")! + /// + /// url.queryParams.removeAll { trackingKeys.contains($0.key) } + /// // โœ… "http://example/p?sort=new&version=2" + /// ``` + /// + /// > Note: + /// > This function invalidates any existing indexes for this URL. + /// + /// - parameters: + /// - predicate: A closure which decides whether the key-value pair should be removed. + /// + @inlinable + public mutating func removeAll(where predicate: (Element) -> Bool) { + _removeAll(in: Range(uncheckedBounds: (startIndex, endIndex)), where: predicate) + } + + @inlinable + internal mutating func _removeAll(in bounds: Range, where predicate: (Element) -> Bool) { + + let lowerBound = + (bounds.lowerBound == startIndex) ? cache.componentContents.lowerBound : bounds.lowerBound.keyValuePair.lowerBound + let upperBound = bounds.upperBound.keyValuePair.lowerBound + URLStorage.verifyRange(from: lowerBound, to: upperBound, inBounds: cache.componentContents) + + let bounds = (lowerBound.. Note: + /// > Keys are matched using Unicode canonical equivalence, as is standard for Strings in Swift. + /// + /// - complexity: O(*n*) + /// + /// - parameters: + /// - key: The key to search for. + /// + @inlinable + public subscript( + _ key: some StringProtocol + ) -> String? { + get { + var i = startIndex + while i < endIndex { + let pair = self[i] + if pair.key == key { return pair.value } + formIndex(after: &i) + } + return nil + } + set { + guard let newValue = newValue else { return _remove(key: key) } + set(key: key, to: newValue) + } + } + + /// Returns the first value associated with each of the given keys. + /// + /// ```swift + /// let url = WebURL("http://example.com/?category=shoes&page=4&num=20")! + /// + /// let (category, page, numResults) = url.queryParams["category", "page", "num"] + /// // โœ… ("shoes", "4", "20") + /// ``` + /// + /// In general, `KeyValuePairs` are multi-maps, so multiple pairs may have the same key. + /// This subscript returns the value component of the first pair to match each key, + /// but the ``allValues(forKey:)`` function can be used to retrieve the values from all matching pairs. + /// + /// Unlike key lookups in a `Dictionary` (which are performed in constant time), + /// the cost of looking up a value by key scales _linearly_ with the number of pairs in the list. + /// In order to reduce the number of lookup operations which need to be performed, + /// this subscript looks up multiple keys in a single pass. + /// + /// > Note: + /// > Keys are matched using Unicode canonical equivalence, as is standard for Strings in Swift. + /// + /// - complexity: O(*n*) + /// + /// - parameters: + /// - key: The key to search for. + /// + @inlinable + public subscript( + _ key0: some StringProtocol, _ key1: some StringProtocol + ) -> (String?, String?) { + + var result: (Index?, Index?) = (nil, nil) + var i = startIndex + while i < endIndex { + let key = self[i].key + if result.0 == nil, key == key0 { result.0 = i } + if result.1 == nil, key == key1 { result.1 = i } + + if result.0 != nil, result.1 != nil { break } + formIndex(after: &i) + } + return ( + result.0.map { self[$0].value }, + result.1.map { self[$0].value } + ) + } + + /// Returns the values associated with each of the given keys. + /// + /// ```swift + /// let url = WebURL("http://example.com/?category=shoes&page=4&num=20")! + /// + /// let (category, page, numResults) = url.queryParams["category", "page", "num"] + /// // โœ… ("shoes", "4", "20") + /// ``` + /// + /// In general, `KeyValuePairs` are multi-maps, so multiple pairs may have the same key. + /// This subscript returns the value component of the first pair to match each key, + /// but the ``allValues(forKey:)`` function can be used to retrieve the values from all matching pairs. + /// + /// Unlike key lookups in a `Dictionary` (which are performed in constant time), + /// the cost of looking up a value by key scales _linearly_ with the number of pairs in the list. + /// In order to reduce the number of lookup operations which need to be performed, + /// this subscript looks up multiple keys in a single pass. + /// + /// > Note: + /// > Keys are matched using Unicode canonical equivalence, as is standard for Strings in Swift. + /// + /// - complexity: O(*n*) + /// + /// - parameters: + /// - key: The key to search for. + /// + @inlinable + public subscript( + _ key0: some StringProtocol, _ key1: some StringProtocol, _ key2: some StringProtocol + ) -> (String?, String?, String?) { + + var result: (Index?, Index?, Index?) = (nil, nil, nil) + var i = startIndex + while i < endIndex { + let key = self[i].key + if result.0 == nil, key == key0 { result.0 = i } + if result.1 == nil, key == key1 { result.1 = i } + if result.2 == nil, key == key2 { result.2 = i } + + if result.0 != nil, result.1 != nil, result.2 != nil { break } + formIndex(after: &i) + } + return ( + result.0.map { self[$0].value }, + result.1.map { self[$0].value }, + result.2.map { self[$0].value } + ) + } + + /// Returns the values associated with each of the given keys. + /// + /// ```swift + /// let url = WebURL("http://example.com/?category=shoes&page=4&num=20")! + /// + /// let (category, page, numResults) = url.queryParams["category", "page", "num"] + /// // โœ… ("shoes", "4", "20") + /// ``` + /// + /// In general, `KeyValuePairs` are multi-maps, so multiple pairs may have the same key. + /// This subscript returns the value component of the first pair to match each key, + /// but the ``allValues(forKey:)`` function can be used to retrieve the values from all matching pairs. + /// + /// Unlike key lookups in a `Dictionary` (which are performed in constant time), + /// the cost of looking up a value by key scales _linearly_ with the number of pairs in the list. + /// In order to reduce the number of lookup operations which need to be performed, + /// this subscript looks up multiple keys in a single pass. + /// + /// > Note: + /// > Keys are matched using Unicode canonical equivalence, as is standard for Strings in Swift. + /// + /// - complexity: O(*n*) + /// + /// - parameters: + /// - key: The key to search for. + /// + @inlinable + public subscript( + _ key0: some StringProtocol, _ key1: some StringProtocol, _ key2: some StringProtocol, _ key3: some StringProtocol + ) -> (String?, String?, String?, String?) { + + var result: (Index?, Index?, Index?, Index?) = (nil, nil, nil, nil) + var i = startIndex + while i < endIndex { + let key = self[i].key + if result.0 == nil, key == key0 { result.0 = i } + if result.1 == nil, key == key1 { result.1 = i } + if result.2 == nil, key == key2 { result.2 = i } + if result.3 == nil, key == key3 { result.3 = i } + + if result.0 != nil, result.1 != nil, result.2 != nil, result.3 != nil { break } + formIndex(after: &i) + } + return ( + result.0.map { self[$0].value }, + result.1.map { self[$0].value }, + result.2.map { self[$0].value }, + result.3.map { self[$0].value } + ) + } +} + +extension WebURL.KeyValuePairs { + + /// Returns all values associated with the given key. + /// + /// The returned values have the same order as they do in the key-value string. + /// + /// ```swift + /// let url = WebURL("http://example.com/articles?sort=date&sort=length")! + /// + /// url.queryParams.allValues(forKey: "sort") + /// // โœ… ["date", "length"] + /// ``` + /// + /// > Note: + /// > Keys are matched using Unicode canonical equivalence, as is standard for Strings in Swift. + /// + /// - parameters: + /// - key: The key to search for. + /// + @inlinable + public func allValues(forKey key: some StringProtocol) -> [String] { + compactMap { $0.key == key ? $0.value : nil } + } +} + + +// -------------------------------------------- +// MARK: - Writing: By Key. +// -------------------------------------------- + + +extension WebURL.KeyValuePairs { + + /// Associates a value with a given key. + /// + /// This function finds the first pair which matches the given key, + /// and replaces its value component with the given value. If there is no existing match, + /// the key and value are appended to the list. **All other matching pairs are removed.** + /// + /// Inserted keys and values will automatically be encoded to preserve their content exactly as given. + /// + /// ```swift + /// var url = WebURL("https://example.com/shop?category=shoes&page=1&num=20")! + /// // ^^^^^^ + /// + /// url.queryParams.set(key: "page", to: "3") + /// // โœ… "https://example.com/shop?category=shoes&page=3&num=20" + /// // ^^^^^^ + /// + /// url.queryParams["page"] = "14" + /// // โœ… "https://example.com/shop?category=shoes&page=14&num=20" + /// // ^^^^^^^ + /// + /// url.queryParams["sort"] = "price-asc" + /// // โœ… "https://example.com/shop?category=shoes&page=14&num=20&sort=price-asc" + /// // ^^^^^^^^^^^^^^ + /// ``` + /// + /// > Note: + /// > Keys are matched using Unicode canonical equivalence, as is standard for Strings in Swift. + /// + /// > Note: + /// > This function invalidates any existing indexes for this URL. + /// + /// - parameters: + /// - key: The key to search for. + /// - newValue: The value to associate with `key`. + /// + /// - returns: The index of the modified or inserted pair. + /// + @inlinable + @discardableResult + public mutating func set(key: some StringProtocol, to newValue: some StringProtocol) -> Index { + + guard var firstMatch = fastFirstIndex(where: { $0.key == key }) else { + return append(key: key, value: newValue) + } + firstMatch = replaceValue(at: firstMatch, with: newValue) + + if let secondMatch = fastFirstIndex(from: index(after: firstMatch), to: endIndex, where: { $0.key == key }) { + removeAll(in: Range(uncheckedBounds: (secondMatch, endIndex)), where: { $0.key == key }) + } + return firstMatch + } + + @inlinable + internal mutating func _remove(key: some StringProtocol) { + + guard var firstMatch = fastFirstIndex(where: { $0.key == key }) else { return } + firstMatch = remove(at: firstMatch) + + if let secondMatch = fastFirstIndex(from: firstMatch, to: endIndex, where: { $0.key == key }) { + removeAll(in: Range(uncheckedBounds: (secondMatch, endIndex)), where: { $0.key == key }) + } + } +} + + +// -------------------------------------------- +// MARK: - URLStorage + KeyValuePairs +// -------------------------------------------- + + +@usableFromInline +internal enum KeyValuePairsSetterError: Error, CustomStringConvertible { + case exceedsMaximumSize + + @usableFromInline + internal var description: String { + switch self { + case .exceedsMaximumSize: + return #""" + The operation would exceed the maximum supported size of a URL string (\#(URLStorage.SizeType.self).max). + """# + } + } +} + +/// A `PercentEncodeSet` for escaping a subcomponent (key or value) of a key-value pair. +/// +/// The escaped contents are valid for writing directly to the URL component `component`, +/// without additional escaping. +/// +@usableFromInline +internal struct KeyValuePairComponentEncodeSet: PercentEncodeSet where Schema: KeyValueStringSchema { + + @usableFromInline + internal var schema: Schema + + @usableFromInline + internal var component: KeyValuePairsSupportedComponent + + @inlinable + public init(schema: Schema, component: KeyValuePairsSupportedComponent) { + self.schema = schema + self.component = component + } + + @inlinable + internal func shouldPercentEncode(ascii codePoint: UInt8) -> Bool { + + // Non-ASCII must always be escaped. + guard let ascii = ASCII(codePoint) else { + return true + } + + // The percent sign must always be escaped. It is reserved for percent-encoding. + if ascii == .percentSign { + return true + } + + // We choose to always escape the plus sign. It can be ambiguous. + if ascii == .plus { + return true + } + + // Characters which the schema reserves as delimiters must be escaped. + if schema.isPairDelimiter(ascii.codePoint) || schema.isKeyValueDelimiter(ascii.codePoint) { + return true + } + + // The schema can opt to substitute spaces rather than percent-encoding them. + if ascii == .space { + return !schema.encodeSpaceAsPlus + } + + // The schema can opt in to additional escaping. + if schema.shouldPercentEncode(ascii: ascii.codePoint) { + return true + } + + // Finally, escape anything else required by the URL component. + switch component.value { + case .query: + return URLEncodeSet.SpecialQuery().shouldPercentEncode(ascii: ascii.codePoint) + case .fragment: + return URLEncodeSet.Fragment().shouldPercentEncode(ascii: ascii.codePoint) + } + } + + @usableFromInline + internal struct Substitutions: SubstitutionMap { + + @usableFromInline + internal var schema: Schema + + @inlinable + internal init(schema: Schema) { + self.schema = schema + } + + @inlinable + internal func substitute(ascii codePoint: UInt8) -> UInt8? { + if schema.encodeSpaceAsPlus, codePoint == ASCII.space.codePoint { + return ASCII.plus.codePoint + } + return nil + } + + @inlinable + internal func unsubstitute(ascii codePoint: UInt8) -> UInt8? { + // See: `KeyValueStringSchema.unescapeAsUTF8String` + fatalError("This encode set is not used for unescaping") + } + } + + @inlinable + internal var substitutions: Substitutions { + Substitutions(schema: schema) + } +} + +extension KeyValueStringSchema { + + @inlinable @inline(__always) + internal func escape( + _ source: Source, for component: KeyValuePairsSupportedComponent + ) -> LazilyPercentEncoded> { + source.lazy.percentEncoded(using: KeyValuePairComponentEncodeSet(schema: self, component: component)) + } + + /// Returns this schema's preferred delimiters. + /// + /// This function checks that the preferred delimiters are valid and may be written unescaped + /// in the given URL component. If the delimiters are not valid, a runtime error is triggered. + /// + @inlinable + internal func verifyDelimitersDoNotNeedEscaping( + in component: KeyValuePairsSupportedComponent + ) -> (keyValue: UInt8, pair: UInt8) { + + let (keyValueDelimiter, pairDelimiter) = (preferredKeyValueDelimiter, preferredPairDelimiter) + + // The delimiter: + // + // - Must be an ASCII code-point, + // - Must not be the percent sign (`%`), plus sign (`+`), space, or a hex digit, and + // - Must not require escaping in the URL component(s) used with this schema. + + guard + ASCII(keyValueDelimiter)?.isHexDigit == false, + keyValueDelimiter != ASCII.percentSign.codePoint, + keyValueDelimiter != ASCII.plus.codePoint + else { + return (.max, 0) + } + guard + ASCII(pairDelimiter)?.isHexDigit == false, + pairDelimiter != ASCII.percentSign.codePoint, + pairDelimiter != ASCII.plus.codePoint + else { + return (0, .max) + } + + switch component.value { + case .query: + let encodeSet = URLEncodeSet.SpecialQuery() + guard !encodeSet.shouldPercentEncode(ascii: keyValueDelimiter) else { + return (.max, 0) + } + guard !encodeSet.shouldPercentEncode(ascii: pairDelimiter) else { + return (0, .max) + } + case .fragment: + let encodeSet = URLEncodeSet.Fragment() + guard !encodeSet.shouldPercentEncode(ascii: keyValueDelimiter) else { + return (.max, 0) + } + guard !encodeSet.shouldPercentEncode(ascii: pairDelimiter) else { + return (0, .max) + } + } + + return (keyValueDelimiter, pairDelimiter) + } + + @inlinable + internal func isPairBoundary(_ first: UInt8, _ second: UInt8) -> Bool { + isPairDelimiter(first) && !isPairDelimiter(second) + } +} + +extension KeyValuePairsSupportedComponent { + + @inlinable + internal var urlComponent: WebURL.Component { + switch value { + case .query: return .query + case .fragment: return .fragment + } + } + + @inlinable + internal var leadingDelimiter: ASCII { + switch value { + case .query: + return .questionMark + case .fragment: + return .numberSign + } + } +} + +@inlinable +internal func _trapOnInvalidDelimiters( + _ delimiters: (keyValue: UInt8, pair: UInt8) +) -> (keyValue: UInt8, pair: UInt8) { + precondition( + delimiters.keyValue != .max && delimiters.pair != .max, + "Schema has invalid delimiters" + ) + return delimiters +} + +extension URLStorage { + + /// Replaces the given range of key-value pairs with a new collection of key-value pairs. + /// Inserted content will be escaped as required by both the schema and URL component. + /// + /// - parameters: + /// - oldPairs: The range of key-value pairs to replace. + /// - component: The URL component containing `oldPairs`. + /// - schema: The ``KeyValueStringSchema`` specifying the format of the key-value string. + /// - startOfFirstPair: The location of the first code-unit of the first non-empty key in `component`. + /// In other words, `startIndex.keyValuePair.lowerBound`. + /// - newPairs: The pairs to insert in the space that `oldPairs` currently occupies. + /// + /// - returns: The locations of the inserted pairs. + /// If the pairs in `oldPairs` are removed without replacement, + /// this function returns an empty range at the new location of `oldPairs.upperBound`. + /// + @inlinable + internal mutating func replaceKeyValuePairs( + _ oldPairs: Range.Index>, + in component: KeyValuePairsSupportedComponent, + schema: Schema, + startOfFirstPair: URLStorage.SizeType, + with newPairs: some Collection<(some Collection, some Collection)> + ) -> Result.Index>, KeyValuePairsSetterError> { + + let componentAndDelimiter = structure.rangeForReplacingCodeUnits(of: component.urlComponent) + let componentContent = componentAndDelimiter.dropFirst() + + let rangeToReplace: Range + do { + // Replacements from startIndex snap to the start of the URL component. + // This ensures any content preceding the first non-empty pair also gets replaced. + let lowerBound = + (oldPairs.lowerBound.keyValuePair.lowerBound == startOfFirstPair) + ? componentContent.lowerBound + : oldPairs.lowerBound.keyValuePair.lowerBound + let upperBound = oldPairs.upperBound.keyValuePair.lowerBound + URLStorage.verifyRange(from: lowerBound, to: upperBound, inBounds: componentContent) + // TODO: Validate age. The URL must not have been mutated after the indexes were created. + + rangeToReplace = lowerBound..= 0 && firstValLength >= 0) + let newLowerPairStart = rangeToReplace.lowerBound &+ (leadingDelimiter != nil ? 1 : 0) + let newLowerKeyLength = SizeType(firstKeyLength) + let newLowerEndOfPair = newLowerPairStart &+ newLowerKeyLength &+ 1 &+ SizeType(firstValLength) + let newLower = WebURL.KeyValuePairs.Index( + keyValuePair: Range(uncheckedBounds: (newLowerPairStart, newLowerEndOfPair)), + keyValueDelimiter: newLowerPairStart &+ newLowerKeyLength + ) + let newUpper = oldPairs.upperBound.rebased(newStartOfKey: rangeToReplace.lowerBound + bytesToWrite) + return .success(newLower.. Important: + /// > This function should only be called by `replaceKeyValuePairs`. + /// + @usableFromInline + internal mutating func _replaceKeyValuePairs_withEmptyCollection( + _ rangeToReplace: Range, + in component: KeyValuePairsSupportedComponent, + bounds componentAndDelimiter: Range, + isPairBoundary: (UInt8, UInt8) -> Bool + ) -> URLStorage.SizeType { + + guard !componentAndDelimiter.isEmpty else { + assert(rangeToReplace == componentAndDelimiter) + return 0 + } + + var rangeToReplace = rangeToReplace + let componentContent = componentAndDelimiter.dropFirst() + + if rangeToReplace.upperBound == componentContent.upperBound { + + if rangeToReplace.lowerBound == componentContent.lowerBound { + + // Removing entire component, so also remove its leading delimiter. + assert(rangeToReplace.lowerBound - 1 == componentAndDelimiter.lowerBound) + rangeToReplace = componentAndDelimiter + + } else if rangeToReplace.lowerBound != componentContent.upperBound { + + // Removing some (not all) pairs from the end of the component. + // Widen the bounds so we don't leave a trailing delimiter behind. + + // TODO: Lower to an assertion once we validate Index age. + precondition( + isPairBoundary(codeUnits[rangeToReplace.lowerBound &- 1], codeUnits[rangeToReplace.lowerBound]), + "Invalid index or schema - lowerBound is not aligned to the start of a key-value pair" + ) + rangeToReplace = (rangeToReplace.lowerBound &- 1).. 0 else { + return 0 + } + + var newStructure = structure + + switch component.value { + case .query: + newStructure.queryLength &-= bytesToReplace + assert(newStructure.queryLength != 1, "Component should never be left non-nil but empty") + if newStructure.queryLength <= 1 { + assert(rangeToReplace == componentAndDelimiter) + newStructure.queryIsKnownFormEncoded = true + } + + case .fragment: + newStructure.fragmentLength &-= bytesToReplace + assert(newStructure.fragmentLength != 1, "Component should never be left non-nil but empty") + } + + removeSubrange(rangeToReplace, newStructure: newStructure) + + return bytesToReplace + } +} + +extension URLStorage { + + /// Replaces a key or value within a key-value pair. + /// + /// - parameters: + /// - oldContent: The range of code-units to replace. Must not include any key-value or pair delimiters. + /// - component: The URL component containing `oldContent`. + /// - bounds: The bounds of `component`. + /// - schema: The ``KeyValueStringSchema`` specifying the format of the key-value string. + /// - insertDelimiter: Whether a key-value delimiter should be inserted before the new contents. + /// - newContent: The bytes of the new key or value. + /// + /// - returns: The length of `newContent`, as it was written to the URL (including percent-encoding). + /// Note that in order to calculate an adjusted `KeyValuePairs.Index`, + /// you will also need to consider whether a delimiter was inserted. + /// + @inlinable + internal mutating func replaceKeyValuePairComponent( + _ oldContent: Range, + in component: KeyValuePairsSupportedComponent, + bounds componentContent: Range, + schema: some KeyValueStringSchema, + insertDelimiter: Bool, + newContent: some Collection + ) -> Result { + + // Diagnose invalid indexes. + // - They must point to a range within this URL component. + // - TODO: Validate age. The URL must not have been mutated after the indexes were created. + + URLStorage.verifyRange(oldContent, inBounds: componentContent) + + // Calculate the new structure. + + let bytesToReplace = oldContent.upperBound - oldContent.lowerBound + let newContentInfo = schema.escape(newContent, for: component).unsafeEncodedLength + + guard let bytesToWrite = URLStorage.SizeType(exactly: newContentInfo.count + (insertDelimiter ? 1 : 0)) else { + return .failure(.exceedsMaximumSize) + } + + var newStructure = structure + + switch component.value { + case .query: + guard let newLength = newStructure.queryLength.subtracting(bytesToReplace, adding: bytesToWrite) else { + return .failure(.exceedsMaximumSize) + } + assert(newLength > 0, "replaceKeyValuePairComponent should never set the URL component to nil") + newStructure.queryLength = newLength + + case .fragment: + guard let newLength = newStructure.fragmentLength.subtracting(bytesToReplace, adding: bytesToWrite) else { + return .failure(.exceedsMaximumSize) + } + assert(newLength > 0, "replaceKeyValuePairComponent should never set the URL component to nil") + newStructure.fragmentLength = newLength + } + + // Replace the code-units. + + let result = replaceSubrange( + oldContent, + withUninitializedSpace: bytesToWrite, + newStructure: newStructure + ) { buffer in + + let baseAddress = buffer.baseAddress! + var buffer = buffer + + if insertDelimiter { + precondition(!buffer.isEmpty) + buffer[0] = _trapOnInvalidDelimiters(schema.verifyDelimitersDoNotNeedEscaping(in: component)).keyValue + buffer = UnsafeMutableBufferPointer( + start: buffer.baseAddress! + 1, + count: buffer.count &- 1 + ) + } + + let bytesWritten = + newContentInfo.needsEncoding + ? buffer.fastInitialize(from: schema.escape(newContent, for: component)) + : buffer.fastInitialize(from: newContent) + buffer = UnsafeMutableBufferPointer( + start: buffer.baseAddress! + bytesWritten, + count: buffer.count &- bytesWritten + ) + + return baseAddress.distance(to: buffer.baseAddress!) + } + + switch result { + case .success: + return .success(bytesToWrite &- (insertDelimiter ? 1 : 0)) + + case .failure(let error): + assert(error == .exceedsMaximumSize) + return .failure(.exceedsMaximumSize) + } + } +} diff --git a/Sources/WebURL/WebURL+UTF8View.swift b/Sources/WebURL/WebURL+UTF8View.swift index 9f64d8575..0d34ae59e 100644 --- a/Sources/WebURL/WebURL+UTF8View.swift +++ b/Sources/WebURL/WebURL+UTF8View.swift @@ -108,6 +108,7 @@ extension WebURL { /// - ``WebURL/UTF8View/query`` /// - ``WebURL/UTF8View/fragment`` /// - ``WebURL/UTF8View/pathComponent(_:)`` + /// - ``WebURL/UTF8View/keyValuePair(_:)`` /// /// ### Replacing a URL's Components /// @@ -782,4 +783,9 @@ extension WebURL.UTF8View { internal subscript(bounds: Range) -> Slice { self[bounds.toCodeUnitsIndices()] } + + @inlinable @inline(__always) + internal subscript(storageIndex: URLStorage.SizeType) -> Element { + self[Int(storageIndex)] + } } diff --git a/Sources/WebURL/WebURL.docc/Deprecated.md b/Sources/WebURL/WebURL.docc/Deprecated.md index e04dfb0de..e7591fd70 100644 --- a/Sources/WebURL/WebURL.docc/Deprecated.md +++ b/Sources/WebURL/WebURL.docc/Deprecated.md @@ -2,6 +2,7 @@ These APIs will be removed in a future version of WebURL. + + +## Topics + +### Form Params (WebURL 0.5.0) + +The `formParams` view has been replaced by the `WebURL.KeyValuePairs` view +(available via the ``WebURL/WebURL/queryParams`` property or ``WebURL/WebURL/keyValuePairs(in:schema:)`` function). +The new API requires Swift 5.7+. + +- ``WebURL/WebURL/formParams`` diff --git a/Sources/WebURL/WebURL.docc/FoundationInterop.md b/Sources/WebURL/WebURL.docc/FoundationInterop.md index a099321b8..4ee42e63b 100644 --- a/Sources/WebURL/WebURL.docc/FoundationInterop.md +++ b/Sources/WebURL/WebURL.docc/FoundationInterop.md @@ -397,13 +397,15 @@ Using `WebURL` for parsing and URL manipulation comes with a lot of additional b 3. ๐Ÿ˜Œ Rich, expressive APIs. - `WebURL`'s `.pathComponents` and `.formParams` properties give you efficient access to the URL's path and query. - The `.pathComponents` view conforms to `BidirectionalCollection`, so you have immediate access to a huge number of - features and algorithms - such as `map`, `filter`, and `reduce`, not to mention slicing, such as `dropLast()`. - And you can even modify through this view, using indexes to perform complex operations super-efficiently. + `WebURL`'s `.pathComponents` and `.queryParams` properties give you efficient access to the URL's path and query. + They are Collection views over the URL's existing storage, so you always have immediate access to a huge number of + features and algorithms. You can even modify URL components through these views, using familiar APIs + from types such as `Array`, and they take full advantage of features such as generics so you don't need to + write awkward code to convert types. ```swift let url = WebURL("https://github.com/karwa/swift-url/issues/63")! + if url.pathComponents.dropLast().last == "issues", let issueNumber = url.pathComponents.last.flatMap(Int.init) { // โœ… issueNumber = 63 @@ -411,26 +413,22 @@ Using `WebURL` for parsing and URL manipulation comes with a lot of additional b ``` ```swift var url = WebURL("https://info.myapp.com")! + url.pathComponents += ["music", "bands" "AC/DC"] // โœ… "https://info.myapp.com/music/bands/AC%2FDC" ``` - The `.formParams` view takes query parameters to the next level, with dynamic member lookup. - You just get and set values as if they were properties. Zero fuss: - ```swift var url = WebURL("https://example.com/search?category=food&client=mobile")! - url.formParams.category // "food" - url.formParams.client // "mobile" - url.formParams.format = "json" + url.queryParams["category"] // "food" + url.queryParams["client"] // "mobile" + + url.queryParams["format"] = "json" // โœ… "https://example.com/search?category=food&client=mobile&format=json" // ^^^^^^^^^^^ ``` - Here's a challenge: with WebURL, that was 3 lines of super obvious code. - Now try doing that with `Foundation.URL`. Yeah. - The `.host` API is less frequently used, but even here we can offer a breakthrough in expressive, robust code. With WebURL, the URL type tells applications _directly_ which computer the URL points to. I really love this. I think this is _exactly_ what a Swift URL API should be; it takes a complex, nebulous string diff --git a/Sources/WebURL/WebURL.docc/WebURLStruct.md b/Sources/WebURL/WebURL.docc/WebURLStruct.md index 0ff959f50..f47ded69b 100644 --- a/Sources/WebURL/WebURL.docc/WebURLStruct.md +++ b/Sources/WebURL/WebURL.docc/WebURLStruct.md @@ -34,8 +34,8 @@ String(url) // Same as above. ### URL Components -URLs have structure; they can be split in to components such as a ``scheme``, ``hostname``, and ``path``. -The scheme identifies the kind of URL, and determines how other components should be interpreted. +URLs have structure - they can be split in to components such as a ``scheme``, ``hostname``, and ``path``. +The scheme identifies the kind of URL, and determines how the other components should be interpreted. ``` authority @@ -46,7 +46,7 @@ The scheme identifies the kind of URL, and determines how other components shoul ``` You can read and write a component's string value using its respective property. -The documentation page for each property contains more information about its corresponding URL component. +The documentation page for each property contains more information about its URL component. ```swift var url = WebURL("https://www.example.com/forum/questions/?tag=networking&order=newest")! @@ -63,24 +63,19 @@ String(url) // โœ… "https://github.com/karwa/swift-url/search?q=struct" // scheme hostname path query ``` -Just as URLs have structure, some components can have _their own_ internal structure; -notably, the path is usually a list of segments (`"/forum/questions"`), and a popular convention -is to write a list of key-value pairs in the URL's query (`"tag=networking&order=newest"`). +Just as URLs have structure, some components can have their own internal structure - +such as path components (`"/forum/questions"`) and query parameters (`"tag=networking&order=newest"`). +WebURL also offers rich, expressive APIs for reading and modifying this substructure. -WebURL takes advantage of Swift language features such as enumerations with associated values -and write-through views to offer more expressive APIs than is possible with flat component strings. - -- ``host-swift.property`` is an enum, explaining how the standard interprets the URL's hostname. - Not only can it help libraries understand precisely which _kind of host_ is expressed by the URL, - it includes the actual network address value which can be used to establish a connection. +- ``host-swift.property`` explains how the standard interprets the URL's hostname. + It communicates precisely which _kind_ of host is expressed by the URL, + and includes network address values that can be used to directly establish a connection. - ``pathComponents-swift.property`` is a `Collection` view, containing the segments in the URL's path. - It comes with a full complement of APIs to add, remove, or replace any part of the path, and transparently - handles details such as percent-encoding. _(Demonstrated below)_ + It includes APIs to add, remove, or replace any part of the path. _(Demonstrated below)_ -- ``formParams`` is also a view, containing the key-value pairs in the URL's query. - Getting and setting values is simple - just access a key as if it were the name of a property, - or insert whole groups of values using dictionaries. _(Demonstrated below)_ +- ``queryParams`` is also a `Collection` view, containing the list of key-value pairs in the URL's query. + It includes APIs to work with pairs as a list, map, or multi-map. _(Demonstrated below. Requires Swift 5.7+)_ ### Path Components and Query Parameters @@ -123,7 +118,7 @@ The path components view includes a full set of APIs for modifying the path, for - ``PathComponents-swift.struct/append(_:)`` and the `+=` operator, - ``PathComponents-swift.struct/insert(_:at:)``, which is great for inserting prefixes, -- ``PathComponents-swift.struct/replaceSubrange(_:with:)`` for full, arbitrary replacements. +- ``PathComponents-swift.struct/replaceSubrange(_:with:)`` for arbitrary replacements. These will be familiar to developers who have used the standard library's `RangeReplaceableCollection` protocol. Together they offer a powerful set of tools, available at any time simply by accessing `.pathComponents`. @@ -137,7 +132,7 @@ let Endpoint = WebURL("https://api.myapp.com/v1")! func URLForBand(_ bandName: String) -> WebURL { var url = Endpoint url.pathComponents += ["music", "bands", bandName] - url.formParams.format = "json" + url.queryParams["format"] = "json" return url } @@ -157,57 +152,45 @@ URLForBand("The Rolling Stones") // ^^^^^^^^^^^^^^^^^^^^^^ ``` -In the previous example, we used ``formParams`` to add a key-value pair to the URL's query component. -`formParams` is another write-through view, and it makes use of Swift's dynamic-member syntax to access -key-value pairs as though they were properties. +In the previous example, we used ``queryParams`` to add a key-value pair to the URL's query component. +`queryParams` is another write-through view, and allows us to easily read and write the URL's query parameters. ```swift -// ๐Ÿšฉ Read query parameters from a URL. - -let url = WebURL("https://example.com/search?category=food&client=mobile")! -url.formParams.category // โœ… "food" -url.formParams.client // โœ… "mobile" - -// ๐Ÿšฉ Create a URL by setting query parameters. - -var url = WebURL("https://example.com/search")! -url.formParams.format = "json" -// โœ… "https://example.com/search?format=json" -// ^^^^^^^^^^^ - -url.formParams += [ - "category" : "sports", - "client" : "web" -] -// โœ… "https://example.com/search?format=json&category=sports&client=web" -// ^^^^^^^^^^^^^^^^^^^^^^^^^^ -``` +// ๐Ÿšฉ Use subscripts to get/set values associated with a key. +var url = WebURL("http://example.com/convert?from=EUR&to=USD")! -### Normalization +url.queryParams["from"] // โœ… "EUR" +url.queryParams["to"] // โœ… "USD" +url.queryParams["from"] = "GBP" +// โœ… "http://example.com/convert?from=GBP&to=USD" +// ^^^^^^^^ +url.queryParams["amount"] = "20" +// โœ… "http://example.com/convert?from=GBP&to=USD&amount=20" +// ^^^^^^^^^ -Another feature of WebURL is that it is always normalized - every component is simplified according to the standard. -For example, URL scheme names and some hostnames are normalized to lowercase, and paths containing `".."` segments -are automatically compacted. There is no `normalize()` or `standardize()` function, so you'll never forget to call it. +let (amount, from, to) = url.queryParams["amount", "from", "to"] +// โœ… ("20", "GBP", "USD") -This makes processing URLs simpler and more robust - both for your code, and other systems who later process the URL. +// ๐Ÿšฉ Or build a query by appending key-value pairs. -```swift -var url = WebURL("HTTPS://GITHUB.COM/")! -print(url) // โœ… "https://github.com/" +var url = WebURL("http://example.com/convert") -url.path = "/apple/swift/pulls/../../swift-package-manager" -print(url.path) // โœ… "/apple/swift-package-manager" - -print(url) // โœ… "https://github.com/apple/swift-package-manager" +url.queryParams += [ + ("amount", "200"), + ("from", "EUR"), + ("to", "GBP") +] +// โœ… "http://example.com/convert?amount=200&from=EUR&to=GBP" +// ^^^^^^^^^^^^^^^^^^^^^^^^^^ ``` ### Integration Libraries -To ensure you can actually use WebURL today, the package includes a number of integration libraries +To help you use WebURL in your projects _today_, the package includes a number of integration libraries for popular first-party and third-party libraries. - `WebURLSystemExtras` integrates with **swift-system** (and **System.framework** on Apple platforms) to offer @@ -251,9 +234,11 @@ for popular first-party and third-party libraries. - ``pathComponents-swift.property`` -### Query Parameters +### Query Parameters and Key-Value Pairs -- ``formParams`` +- ``queryParams`` +- ``keyValuePairs(in:schema:)`` +- ``withMutableKeyValuePairs(in:schema:_:)`` ### Host and Origin diff --git a/Sources/WebURL/WebURL.swift b/Sources/WebURL/WebURL.swift index 270399d47..85920e0a4 100644 --- a/Sources/WebURL/WebURL.swift +++ b/Sources/WebURL/WebURL.swift @@ -667,7 +667,7 @@ extension WebURL { /// ``` /// /// > Tip: - /// > To read or modify query parameters _within_ the query string, use the ``formParams`` view. + /// > To read or modify key-value pairs _within_ the query string, use the ``queryParams`` view. /// /// When modifying this component, percent-encoding in the new value is preserved. /// Additionally, should the value contain any characters that are disallowed in this component, @@ -685,7 +685,7 @@ extension WebURL { /// /// ## See Also /// - /// - ``WebURL/formParams`` + /// - ``WebURL/queryParams`` /// - ``WebURL/setQuery(_:)`` /// public var query: String? { diff --git a/Tests/WebURLFoundationExtrasTests/URLConversion/WebToFoundationTests.swift b/Tests/WebURLFoundationExtrasTests/URLConversion/WebToFoundationTests.swift index c246faee4..3faea9f83 100644 --- a/Tests/WebURLFoundationExtrasTests/URLConversion/WebToFoundationTests.swift +++ b/Tests/WebURLFoundationExtrasTests/URLConversion/WebToFoundationTests.swift @@ -303,43 +303,45 @@ extension WebToFoundationTests { } // Complex URL with percent encoding. - test: do { - // 1. Build up a complex URL via the WebURL API. - var url = WebURL("http://example.com/")! - url.pathComponents += ["p1[%]", "^_^", "๐Ÿฆ†", "p4|R|G|B|50%"] - url.pathComponents.replaceSubrange( - url.pathComponents.startIndex..=5.7) + test: do { + // 1. Build up a complex URL via the WebURL API. + var url = WebURL("http://example.com/")! + url.pathComponents += ["p1[%]", "^_^", "๐Ÿฆ†", "p4|R|G|B|50%"] + url.pathComponents.replaceSubrange( + url.pathComponents.startIndex..=5.7) + test: do { + // 1. Build up a complex URL via the WebURL API. + var url = WebURL("scheme://example.com/")! + url.pathComponents += ["p1[%]", "^_^", "๐Ÿฆ†", "p4|R|G|B|50%"] + url.pathComponents.replaceSubrange( + url.pathComponents.startIndex...Element) { + self.init(key: kvp.key, value: kvp.value) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.key.utf8.elementsEqual(rhs.key.utf8) && lhs.value.utf8.elementsEqual(rhs.value.utf8) + } +} + +/// Asserts that the given `WebURL.KeyValuePairs` (or slice thereof) +/// contains the same pairs as the given list. +/// +/// Keys and values from the `WebURL.KeyValuePairs` are decoded before checking +/// for equality. The lists must match at the code-unit/code-point level. +/// +func XCTAssertEqualKeyValuePairs( + _ left: some Collection.Element>, + _ right: some Collection, + file: StaticString = #fileID, + line: UInt = #line +) { + XCTAssertEqualElements(left.map { KeyValuePair($0) }, right, file: file, line: line) +} + +/// Asserts that the given `WebURL.KeyValuePairs` (or slice thereof) +/// contains the same pairs as the given Array. +/// +/// Keys and values from the `WebURL.KeyValuePairs` are decoded before checking +/// for equality. The lists must match at the code-unit/code-point level. +/// +func XCTAssertEqualKeyValuePairs( + _ left: some Collection.Element>, + _ right: [(key: String, value: String)], + file: StaticString = #fileID, + line: UInt = #line +) { + XCTAssertEqualKeyValuePairs(left, right.map { KeyValuePair(key: $0.key, value: $0.value) }, file: file, line: line) +} + +/// A key-value string schema with non-standard delimiters. +/// +/// ``` +/// key1:value1,key2:value2 +/// ``` +/// +/// Other than delimiters, it should match `PercentEncodedKeyValueString`. +/// +struct CommaSeparated: KeyValueStringSchema { + + var preferredPairDelimiter: UInt8 { UInt8(ascii: ",") } + var preferredKeyValueDelimiter: UInt8 { UInt8(ascii: ":") } + + var decodePlusAsSpace: Bool { false } + + func shouldPercentEncode(ascii codePoint: UInt8) -> Bool { + PercentEncodedKeyValueString().shouldPercentEncode(ascii: codePoint) + } +} + +/// A key-value string schema which supports additional options for form-encoding. +/// +struct ExtendedForm: KeyValueStringSchema { + + var semicolonIsPairDelimiter = false + var encodeSpaceAsPlus = false + + func isPairDelimiter(_ codePoint: UInt8) -> Bool { + codePoint == UInt8(ascii: "&") || (semicolonIsPairDelimiter && codePoint == UInt8(ascii: ";")) + } + + var preferredPairDelimiter: UInt8 { UInt8(ascii: "&") } + var preferredKeyValueDelimiter: UInt8 { UInt8(ascii: "=") } + + var decodePlusAsSpace: Bool { true } + + func shouldPercentEncode(ascii codePoint: UInt8) -> Bool { + FormCompatibleKeyValueString().shouldPercentEncode(ascii: codePoint) + } +} + +/// Asserts that information in the `WebURL.KeyValuePairs` cache +/// is consistent with a freshly-recalculated cache. +/// +func XCTAssertKeyValuePairCacheIsAccurate(_ kvps: WebURL.KeyValuePairs) { + + let expectedCache = type(of: kvps).Cache.calculate( + storage: kvps.storage, + component: kvps.component, + schema: kvps.schema + ) + XCTAssertEqual(kvps.cache.startIndex, expectedCache.startIndex) + XCTAssertEqual(kvps.cache.componentContents, expectedCache.componentContents) +} diff --git a/Tests/WebURLTests/KeyValuePairs/KeyValuePairsTests.swift b/Tests/WebURLTests/KeyValuePairs/KeyValuePairsTests.swift new file mode 100644 index 000000000..3b29dad1c --- /dev/null +++ b/Tests/WebURLTests/KeyValuePairs/KeyValuePairsTests.swift @@ -0,0 +1,4054 @@ +// Copyright The swift-url Contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Checkit +import XCTest + +@testable import WebURL + +#if swift(<5.7) + #error("WebURL.KeyValuePairs requires Swift 5.7 or newer") +#endif + + +final class KeyValuePairsTests: XCTestCase {} + +// swift-format-ignore +extension KeyValuePairsTests { + + static let SpecialCharacters = "\u{0000}\u{0001}\u{0009}\u{000A}\u{000D} !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + static let SpecialCharacters_Escaped_Form = "%00%01%09%0A%0D%20%21%22%23%24%25%26%27%28%29*%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D%7E" + static let SpecialCharacters_Escaped_Form_Plus = "%00%01%09%0A%0D+%21%22%23%24%25%26%27%28%29*%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D%7E" + static let SpecialCharacters_Escaped_PrctEnc_Query = #"%00%01%09%0A%0D%20!%22%23$%25%26%27()*%2B,-./:;%3C%3D%3E?@[\]^_`{|}~"# + static let SpecialCharacters_Escaped_PrctEnc_Query_NoSemiColon = #"%00%01%09%0A%0D%20!%22%23$%25%26%27()*%2B,-./:%3B%3C%3D%3E?@[\]^_`{|}~"# + static let SpecialCharacters_Escaped_CommaSep_Frag = #"%00%01%09%0A%0D%20!%22#$%25&'()*%2B%2C-./%3A;%3C=%3E?@[\]^_%60{|}~"# + + // For PercentEncodedKeyValueString.shouldPercentEncode == true when !isNonURLCodePoint: + // static let SpecialCharacters_Escaped_PrctEnc_Query = #"%00%01%09%0A%0D%20!%22%23$%25%26%27()*%2B,-./:;%3C%3D%3E?@%5B%5C%5D%5E_%60%7B%7C%7D~"# + // static let SpecialCharacters_Escaped_CommaSep_Frag = #"%00%01%09%0A%0D%20!%22%23$%25&'()*%2B%2C-./%3A;%3C=%3E?@%5B%5C%5D%5E_%60%7B%7C%7D~"# +} + + +// -------------------------------------------- +// MARK: - Reading: By Location +// -------------------------------------------- + + +extension KeyValuePairsTests { + + /// Tests the KeyValuePairs conformance to Collection. + /// + /// This includes testing that the view contains the expected elements on multiple passes, + /// and that indexing works as required by the protocol (via `swift-checkit`). + /// + /// Many of the key-value pairs have interesting features, such as special characters, + /// pairs with the same key, pairs whose key differs in Unicode normalization, + /// empty key names or values, etc. + /// + /// Tests are repeated: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, and + /// 3. With empty pairs inserted at various points in the URL component. + /// + func testCollectionConformance() { + + func _testCollectionConformance( + url urlString: String, component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema + ) { + + let url = WebURL(urlString)! + XCTAssertEqual(url.serialized(), urlString) + + let kvps = url.keyValuePairs(in: component, schema: schema) + + let expectedList = [ + (key: "a", value: "b"), + (key: "sp ac & es", value: "d"), + (key: "dup", value: "e"), + (key: "", value: "foo"), + (key: "noval", value: ""), + (key: "emoji", value: "๐Ÿ‘€"), + (key: "jalapen\u{0303}os", value: "nfd"), + (key: "specials", value: Self.SpecialCharacters), + (key: "dup", value: "f"), + (key: "jalape\u{00F1}os", value: "nfc"), + (key: "1+1", value: "2"), + ] + + XCTAssertEqualKeyValuePairs(kvps, expectedList) + CollectionChecker.check(kvps) + XCTAssertEqualKeyValuePairs(kvps, expectedList) + } + + // Empty key-value pairs should be skipped. There is no Index which covers that range of the string, + // so they should be transparent as far as the Collection side of things is concerned. + + // Form encoding in the query. + + do { + let injections = [ + (start: "", middle: "", end: ""), + (start: "&&&", middle: "", end: ""), + (start: "", middle: "&&&", end: ""), + (start: "", middle: "", end: "&&&"), + (start: "&&&&", middle: "&&&&", end: "&&&&"), + ] + for (start, middle, end) in injections { + // swift-format-ignore + _testCollectionConformance( + url: "http://example/?\(start)a=b&sp%20ac+%26+es=d&dup=e\(middle)&=foo&noval&emoji=%F0%9F%91%80&jalapen%CC%83os=nfd&specials=\(Self.SpecialCharacters_Escaped_Form)&dup=f&jalape%C3%B1os=nfc&1%2B1=2\(end)#a=z", + component: .query, schema: .formEncoded + ) + } + } + + // Form encoding in the query (2). + // Semicolons are also allowed as pair delimiters. + + do { + let injections = [ + (start: "", middle: "", end: ""), + (start: "&;&", middle: "", end: ""), + (start: "", middle: "&&;", end: ""), + (start: "", middle: "", end: ";&&"), + (start: "&&;&", middle: "&;&&", end: "&&;;"), + ] + for (start, middle, end) in injections { + // swift-format-ignore + _testCollectionConformance( + url: "http://example/?\(start)a=b&sp%20ac+%26+es=d;dup=e\(middle)&=foo&noval&emoji=%F0%9F%91%80;jalapen%CC%83os=nfd;specials=\(Self.SpecialCharacters_Escaped_Form)&dup=f&jalape%C3%B1os=nfc&1%2B1=2\(end)#a=z", + component: .query, schema: ExtendedForm(semicolonIsPairDelimiter: true) + ) + } + } + + // Custom schema in the fragment. + // Note the use of unescaped '&' and '+' characters. + + do { + let injections = [ + (start: "", middle: "", end: ""), + (start: ",,,", middle: "", end: ""), + (start: "", middle: ",,,", end: ""), + (start: "", middle: "", end: ",,,"), + (start: ",,,,", middle: ",,,,", end: ",,,,"), + ] + for (start, middle, end) in injections { + // swift-format-ignore + _testCollectionConformance( + url: "http://example/?a:z#\(start)a:b,sp%20ac%20&%20es:d,dup:e\(middle),:foo,noval,emoji:%F0%9F%91%80,jalapen%CC%83os:nfd,specials:\(Self.SpecialCharacters_Escaped_CommaSep_Frag),dup:f,jalape%C3%B1os:nfc,1+1:2\(end)", + component: .fragment, schema: CommaSeparated() + ) + } + } + } + + /// Tests situations where KeyValuePairs is expected to be empty. + /// + /// Also checks that a list of pairs, each of which has an empty key and value, + /// is not the same as an empty list. + /// + /// Tests are repeated: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, and + /// + func testEmptyCollection() { + + func _testEmptyCollection( + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { + + func assertKeyValuePairsIsEmptyList(_ url: WebURL) { + let kvps = url.keyValuePairs(in: component, schema: schema) + XCTAssertEqual(kvps.isEmpty, true) + CollectionChecker.check(kvps) + for kvp in kvps { + XCTFail("KeyValuePairs was not empty - found \(kvp)") + } + } + + let componentKeyPath: WritableKeyPath + switch component.value { + case .query: componentKeyPath = \.query + case .fragment: componentKeyPath = \.fragment + } + + // URL Component is nil. Should be an empty list. + + do { + let url = WebURL(checkpoints[0])! + XCTAssertEqual(url.serialized(), checkpoints[0]) + XCTAssertEqual(url[keyPath: componentKeyPath], nil) + assertKeyValuePairsIsEmptyList(url) + } + + // URL component is the empty string. Should be an empty list. + + do { + let url = WebURL(checkpoints[1])! + XCTAssertEqual(url.serialized(), checkpoints[1]) + XCTAssertEqual(url[keyPath: componentKeyPath], "") + assertKeyValuePairsIsEmptyList(url) + } + + // URL component consists only of empty key-value pairs (e.g. "&&&"). Should be an empty list. (x4) + + for i in 2..<6 { + let url = WebURL(checkpoints[i])! + XCTAssertEqual(url.serialized(), checkpoints[i]) + XCTAssertFalse(url[keyPath: componentKeyPath]!.contains(where: { !schema.isPairDelimiter($0.asciiValue!) })) + assertKeyValuePairsIsEmptyList(url) + } + + // URL component consists of a string of pairs with empty keys and values (e.g. "=&=&="). + // Should NOT be an empty list. (x4) + + for i in 6..<10 { + let url = WebURL(checkpoints[i])! + XCTAssertEqual(url.serialized(), checkpoints[i]) + + let kvps = url.keyValuePairs(in: component, schema: schema) + XCTAssertEqual(kvps.count, 1 + i - 6) + for pair in kvps { + XCTAssertEqual(pair.encodedKey, "") + XCTAssertEqual(pair.encodedValue, "") + XCTAssertEqual(pair.key, "") + XCTAssertEqual(pair.value, "") + } + CollectionChecker.check(kvps) + } + } + + // Form encoding in the query. + + _testEmptyCollection( + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/", + "http://example/?", + + "http://example/?&", + "http://example/?&&", + "http://example/?&&&", + "http://example/?&&&&", + + "http://example/?=", + "http://example/?=&=", + "http://example/?=&=&=", + "http://example/?=&=&=&=", + ]) + + // Form encoding in the query (2). + // Semicolons are also allowed as pair delimiters. + + _testEmptyCollection( + component: .query, schema: ExtendedForm(semicolonIsPairDelimiter: true), + checkpoints: [ + "http://example/", + "http://example/?", + + "http://example/?;", + "http://example/?&;", + "http://example/?&;&", + "http://example/?;&;&", + + "http://example/?=", + "http://example/?=;=", + "http://example/?=&=;=", + "http://example/?=;=;=;=", + ]) + + // Custom schema in the fragment. + + _testEmptyCollection( + component: .fragment, schema: CommaSeparated(), + checkpoints: [ + "http://example/", + "http://example/#", + + "http://example/#,", + "http://example/#,,", + "http://example/#,,,", + "http://example/#,,,,", + + "http://example/#:", + "http://example/#:,:", + "http://example/#:,:,:", + "http://example/#:,:,:,:", + ]) + } +} + + +// ------------------------------- +// MARK: - Writing: By Location +// ------------------------------- + + +// replaceSubrange(_:with:) + +extension KeyValuePairsTests { + + /// Tests using `replaceSubrange(_:with:)` to replace a contiguous region of key-value pairs. + /// + /// This test only covers partial replacements: the list of pairs does not start empty, + /// and some of its initial elements will be present in the result. + /// + /// **Test ranges**: + /// + /// - Anchored to the start + /// - Not anchored (floating in the middle) + /// - Anchored to the end + /// + /// **Operations**: + /// + /// | Operation | Removing | Inserting | + /// |-----------|---------------|----------------| + /// | No-Op | No elements | No elements | + /// | Insertion | No elements | Some elements | + /// |-----------|---------------|----------------| + /// | Deletion | Some elements | No elements | + /// | Shrink | Some elements | Fewer elements | + /// | Grow | Some elements | More elements | + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, and + /// 3. With empty pairs inserted at various points in the URL component. + /// + func testReplaceSubrange_partialReplacement() { + + func _testReplaceSubrange( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { + + let url = WebURL(urlString)! + XCTAssertEqual(url.serialized(), urlString) + + let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } + let initialCount = initialPairs.count + precondition(initialCount >= 4, "URL component must contain at least 4 key-value pairs to run this test") + + func replace(offsets: Range, with newPairs: [(String, String)]) -> WebURL { + + var url = url + let newPairIndexes = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let replacementStart = kvps.index(kvps.startIndex, offsetBy: offsets.lowerBound) + let replacementEnd = kvps.index(kvps.startIndex, offsetBy: offsets.upperBound) + let newPairIndexes = kvps.replaceSubrange(replacementStart.., + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList.replaceSubrange(offsets, with: expectedNewPairs) + XCTAssertEqualKeyValuePairs(kvps, expectedList) + + return url + } + + var result: WebURL + + // Do nothing at the start. + result = replace(offsets: 0..<0, with: []) + XCTAssertEqual(result.serialized(), checkpoints[0]) + + // Do nothing in the middle. + result = replace(offsets: 2..<2, with: []) + XCTAssertEqual(result.serialized(), checkpoints[1]) + + // Do nothing at the end. + result = replace(offsets: initialCount.. (remove middle pair) -> "url:/?one=a&&&&&&&three=c" + // + // b) Replace the region of the string from the start of x's key, up to the start of y's key, + // and removing any empty pairs in that space. + // "url:/?one=a&&&two=b&&&&three=c" -> (remove middle pair) -> "url:/?one=a&&&three=c" + // | x | | y | + // | x.. (append "bar") -> "url:/?foo&bar" NOT "url:/?foo&&bar" + // ^ ^^ + // + // Examples of this happening can be seen in the strings below, annotated [1], [2], [3], and [4]. + // + // - Special Characters in existing content. + // + // Notwithstanding [3] above, content outside of the range `x..()) + XCTAssertKeyValuePairCacheIsAccurate(kvps) + return range + } + XCTAssertURLIsIdempotent(url) + + let kvps = url.keyValuePairs(in: component, schema: schema) + XCTAssertEqual(kvps.startIndex.. WebURL { + + var url = url + let newPairIndexes = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let newPairIndexes = kvps.insert(contentsOf: newPairs, at: kvps.index(kvps.startIndex, offsetBy: offset)) + XCTAssertKeyValuePairCacheIsAccurate(kvps) + return newPairIndexes + } + XCTAssertURLIsIdempotent(url) + + let kvps = url.keyValuePairs(in: component, schema: schema) + let expectedNewPairs = newPairs.map { KeyValuePair(key: $0.0, value: $0.1) } + + // Check that the returned indexes are at the expected offsets, + // and contain the expected new pairs. + + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset), newPairIndexes.lowerBound) + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset + newPairs.count), newPairIndexes.upperBound) + XCTAssertEqualKeyValuePairs(kvps[newPairIndexes], expectedNewPairs) + + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList.insert(contentsOf: expectedNewPairs, at: offset) + XCTAssertEqualKeyValuePairs(kvps, expectedList) + + return url + } + + var result: WebURL + + let pairsToInsert = [ + ("inserted", "some value"), + (Self.SpecialCharacters, Self.SpecialCharacters), + ("", ""), + ("cafe\u{0301}", "caf\u{00E9}"), + ] + + // Insert multiple elements at the front. + result = insert(pairsToInsert, atOffset: 0) + XCTAssertEqual(result.serialized(), checkpoints[0]) + + // Insert multiple elements in the middle. + result = insert(pairsToInsert, atOffset: min(initialPairs.count, 1)) + XCTAssertEqual(result.serialized(), checkpoints[1]) + + // Insert multiple elements at the end. + result = insert(pairsToInsert, atOffset: initialPairs.count) + XCTAssertEqual(result.serialized(), checkpoints[2]) + + // Insert single element at the front. + result = insert([("some", "element")], atOffset: 0) + XCTAssertEqual(result.serialized(), checkpoints[3]) + + // Insert single element in the middle. + result = insert([("some", "element")], atOffset: min(initialPairs.count, 1)) + XCTAssertEqual(result.serialized(), checkpoints[4]) + + // Insert single element at the end. + result = insert([("some", "element")], atOffset: initialPairs.count) + XCTAssertEqual(result.serialized(), checkpoints[5]) + + // Insert empty collection at the front. + result = insert([], atOffset: 0) + XCTAssertEqual(result.serialized(), checkpoints[6]) + + // Insert empty collection in the middle. + result = insert([], atOffset: min(initialPairs.count, 1)) + XCTAssertEqual(result.serialized(), checkpoints[7]) + + // Insert empty collection at the end. + result = insert([], atOffset: initialPairs.count) + XCTAssertEqual(result.serialized(), checkpoints[8]) + } + + // Form-encoded in the query. + // Initial query = nil. + + _testInsertCollection( + url: "http://example/#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + + "http://example/?some=element#frag", + "http://example/?some=element#frag", + "http://example/?some=element#frag", + + "http://example/#frag", + "http://example/#frag", + "http://example/#frag", + ] + ) + + // Form-encoded in the query (2). + // Initial query = empty string. + + _testInsertCollection( + url: "http://example/?#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + + "http://example/?some=element#frag", + "http://example/?some=element#frag", + "http://example/?some=element#frag", + + "http://example/#frag", + "http://example/#frag", + "http://example/#frag", + ] + ) + + // Form-encoded in the query (3). + // Initial query = non-empty string, empty list of pairs. + + _testInsertCollection( + url: "http://example/?&&&#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + + "http://example/?some=element#frag", + "http://example/?some=element#frag", + "http://example/?some=element#frag", + + "http://example/#frag", + "http://example/#frag", + "http://example/#frag", + ] + ) + + // Form-encoded in the query (4). + // Initial query = non-empty list of pairs. Inserted spaces are encoded using "+" signs. + + _testInsertCollection( + url: "http://example/?foo=bar&baz=qux#frag", + component: .query, schema: ExtendedForm(encodeSpaceAsPlus: true), + checkpoints: [ + "http://example/?inserted=some+value&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)&=&cafe%CC%81=caf%C3%A9&foo=bar&baz=qux#frag", + "http://example/?foo=bar&inserted=some+value&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)&=&cafe%CC%81=caf%C3%A9&baz=qux#frag", + "http://example/?foo=bar&baz=qux&inserted=some+value&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)&=&cafe%CC%81=caf%C3%A9#frag", + + "http://example/?some=element&foo=bar&baz=qux#frag", + "http://example/?foo=bar&some=element&baz=qux#frag", + "http://example/?foo=bar&baz=qux&some=element#frag", + + "http://example/?foo=bar&baz=qux#frag", + "http://example/?foo=bar&baz=qux#frag", + "http://example/?foo=bar&baz=qux#frag", + ] + ) + + // Form-encoded in the query (5). + // Initial query = non-empty list of pairs. Component includes empty pairs. + + _testInsertCollection( + url: "http://example/?&&&foo=bar&&&baz=qux&&&#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + + "http://example/?some=element&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&some=element&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&some=element#frag", + + "http://example/?foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&#frag", + ] + ) + + // Custom schema in the fragment. + + _testInsertCollection( + url: "http://example/?srch#frag:ment,stuff", + component: .fragment, schema: CommaSeparated(), + checkpoints: [ + "http://example/?srch#inserted:some%20value,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag),:,cafe%CC%81:caf%C3%A9,frag:ment,stuff", + "http://example/?srch#frag:ment,inserted:some%20value,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag),:,cafe%CC%81:caf%C3%A9,stuff", + "http://example/?srch#frag:ment,stuff,inserted:some%20value,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag),:,cafe%CC%81:caf%C3%A9", + + "http://example/?srch#some:element,frag:ment,stuff", + "http://example/?srch#frag:ment,some:element,stuff", + "http://example/?srch#frag:ment,stuff,some:element", + + "http://example/?srch#frag:ment,stuff", + "http://example/?srch#frag:ment,stuff", + "http://example/?srch#frag:ment,stuff", + ] + ) + } + + /// Tests using `insert(key:value:at:)` to insert a single key-value pair. + /// + /// The inserted pairs have interesting features, such as special characters, + /// empty key names or values, Unicode, etc. + /// + /// **Insertion Points**: + /// + /// 1. At the start of the list + /// 2. In the middle of the list + /// 3. At the end of the list (appending) + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, and + /// 3. With empty pairs inserted at various points in the URL component. + /// + func testInsertOne() { + + func _testInsertOne( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { + + let url = WebURL(urlString)! + XCTAssertEqual(url.serialized(), urlString) + + let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } + + func insert(_ newPair: (String, String), atOffset offset: Int) -> WebURL { + + var url = url + let newPairIndexes = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let insertionPoint = kvps.index(kvps.startIndex, offsetBy: offset) + let newPairIndexes = kvps.insert(key: newPair.0, value: newPair.1, at: insertionPoint) + XCTAssertKeyValuePairCacheIsAccurate(kvps) + return newPairIndexes + } + XCTAssertURLIsIdempotent(url) + + let kvps = url.keyValuePairs(in: component, schema: schema) + let expectedNewPair = KeyValuePair(key: newPair.0, value: newPair.1) + + // Check that the returned indexes are at the expected offsets, + // and contain the expected new pairs. + + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset), newPairIndexes.lowerBound) + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset + 1), newPairIndexes.upperBound) + XCTAssertEqualKeyValuePairs(kvps[newPairIndexes], [expectedNewPair]) + + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList.insert(expectedNewPair, at: offset) + XCTAssertEqualKeyValuePairs(kvps, expectedList) + + return url + } + + var checkpointIdx = 0 + + let pairsToTest = [ + ("inserted", "some value"), + ("cafe\u{0301}", "caf\u{00E9}"), + ("", Self.SpecialCharacters), + (Self.SpecialCharacters, ""), + ("", ""), + ] + + for pair in pairsToTest { + // Insert at the front + var result = insert(pair, atOffset: 0) + XCTAssertEqual(result.serialized(), checkpoints[checkpointIdx]) + + // Insert in the middle + result = insert(pair, atOffset: min(initialPairs.count, 1)) + XCTAssertEqual(result.serialized(), checkpoints[checkpointIdx + 1]) + + // Insert at the end. + result = insert(pair, atOffset: initialPairs.count) + XCTAssertEqual(result.serialized(), checkpoints[checkpointIdx + 2]) + + checkpointIdx += 3 + } + } + + // Form-encoded in the query. + // Initial query = nil. + + _testInsertOne( + url: "http://example/#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?inserted=some%20value#frag", + "http://example/?inserted=some%20value#frag", + "http://example/?inserted=some%20value#frag", + + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + + "http://example/?=#frag", + "http://example/?=#frag", + "http://example/?=#frag", + ] + ) + + // Form-encoded in the query (2). + // Initial query = empty string. + + _testInsertOne( + url: "http://example/?#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?inserted=some%20value#frag", + "http://example/?inserted=some%20value#frag", + "http://example/?inserted=some%20value#frag", + + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + + "http://example/?=#frag", + "http://example/?=#frag", + "http://example/?=#frag", + ] + ) + + // Form-encoded in the query (3). + // Initial query = non-empty string, empty list of pairs. + + _testInsertOne( + url: "http://example/?&&&#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?inserted=some%20value#frag", + "http://example/?inserted=some%20value#frag", + "http://example/?inserted=some%20value#frag", + + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + + "http://example/?=#frag", + "http://example/?=#frag", + "http://example/?=#frag", + ] + ) + + // Form-encoded in the query (4). + // Initial query = non-empty list of pairs. Inserted spaces are encoded using "+" signs. + + _testInsertOne( + url: "http://example/?foo=bar&baz=qux#frag", + component: .query, schema: ExtendedForm(encodeSpaceAsPlus: true), + checkpoints: [ + "http://example/?inserted=some+value&foo=bar&baz=qux#frag", + "http://example/?foo=bar&inserted=some+value&baz=qux#frag", + "http://example/?foo=bar&baz=qux&inserted=some+value#frag", + + "http://example/?cafe%CC%81=caf%C3%A9&foo=bar&baz=qux#frag", + "http://example/?foo=bar&cafe%CC%81=caf%C3%A9&baz=qux#frag", + "http://example/?foo=bar&baz=qux&cafe%CC%81=caf%C3%A9#frag", + + "http://example/?=\(Self.SpecialCharacters_Escaped_Form_Plus)&foo=bar&baz=qux#frag", + "http://example/?foo=bar&=\(Self.SpecialCharacters_Escaped_Form_Plus)&baz=qux#frag", + "http://example/?foo=bar&baz=qux&=\(Self.SpecialCharacters_Escaped_Form_Plus)#frag", + + "http://example/?\(Self.SpecialCharacters_Escaped_Form_Plus)=&foo=bar&baz=qux#frag", + "http://example/?foo=bar&\(Self.SpecialCharacters_Escaped_Form_Plus)=&baz=qux#frag", + "http://example/?foo=bar&baz=qux&\(Self.SpecialCharacters_Escaped_Form_Plus)=#frag", + + "http://example/?=&foo=bar&baz=qux#frag", + "http://example/?foo=bar&=&baz=qux#frag", + "http://example/?foo=bar&baz=qux&=#frag", + ] + ) + + // Form-encoded in the query (5). + // Initial query = non-empty list of pairs. Component includes empty pairs. + + _testInsertOne( + url: "http://example/?&&&foo=bar&&&baz=qux&&&#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?inserted=some%20value&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&inserted=some%20value&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&inserted=some%20value#frag", + + "http://example/?cafe%CC%81=caf%C3%A9&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&cafe%CC%81=caf%C3%A9&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&cafe%CC%81=caf%C3%A9#frag", + + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&=\(Self.SpecialCharacters_Escaped_Form)&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&=\(Self.SpecialCharacters_Escaped_Form)#frag", + + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&\(Self.SpecialCharacters_Escaped_Form)=&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&\(Self.SpecialCharacters_Escaped_Form)=#frag", + + "http://example/?=&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&=&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&=#frag", + ] + ) + + // Custom schema in the fragment. + + _testInsertOne( + url: "http://example/?srch#frag:ment,stuff", + component: .fragment, schema: CommaSeparated(), + checkpoints: [ + "http://example/?srch#inserted:some%20value,frag:ment,stuff", + "http://example/?srch#frag:ment,inserted:some%20value,stuff", + "http://example/?srch#frag:ment,stuff,inserted:some%20value", + + "http://example/?srch#cafe%CC%81:caf%C3%A9,frag:ment,stuff", + "http://example/?srch#frag:ment,cafe%CC%81:caf%C3%A9,stuff", + "http://example/?srch#frag:ment,stuff,cafe%CC%81:caf%C3%A9", + + "http://example/?srch#:\(Self.SpecialCharacters_Escaped_CommaSep_Frag),frag:ment,stuff", + "http://example/?srch#frag:ment,:\(Self.SpecialCharacters_Escaped_CommaSep_Frag),stuff", + "http://example/?srch#frag:ment,stuff,:\(Self.SpecialCharacters_Escaped_CommaSep_Frag)", + + "http://example/?srch#\(Self.SpecialCharacters_Escaped_CommaSep_Frag):,frag:ment,stuff", + "http://example/?srch#frag:ment,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):,stuff", + "http://example/?srch#frag:ment,stuff,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):", + + "http://example/?srch#:,frag:ment,stuff", + "http://example/?srch#frag:ment,:,stuff", + "http://example/?srch#frag:ment,stuff,:", + ] + ) + } +} + +// removeSubrange(_:), remove(at:) + +extension KeyValuePairsTests { + + /// Tests using `removeSubrange(_:)` to remove a contiguous region of key-value pairs. + /// + /// **Range locations**: + /// + /// - Anchored to the start + /// - Not anchored (floating in the middle) + /// - Anchored to the end + /// + /// **Range sizes**: + /// + /// - Empty + /// - Non-empty + /// - All elements (`startIndex..) -> WebURL { + + var url = url + let idx = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let lowerBound = kvps.index(kvps.startIndex, offsetBy: offsets.lowerBound) + let upperBound = kvps.index(kvps.startIndex, offsetBy: offsets.upperBound) + let idx = kvps.removeSubrange(lowerBound.., + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList.removeSubrange(offsets) + XCTAssertEqualKeyValuePairs(kvps, expectedList) + + return url + } + + var result: WebURL + + // Remove non-empty range from the front. + result = remove(offsets: 0..<2) + XCTAssertEqual(result.serialized(), checkpoints[0]) + + // Remove non-empty range from the middle. + result = remove(offsets: 2..<3) + XCTAssertEqual(result.serialized(), checkpoints[1]) + + // Remove non-empty range from the end. + result = remove(offsets: max(initialPairs.count - 2, 0).. WebURL { + + var url = url + let idx = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let idx = kvps.remove(at: kvps.index(kvps.startIndex, offsetBy: offset)) + XCTAssertKeyValuePairCacheIsAccurate(kvps) + return idx + } + XCTAssertURLIsIdempotent(url) + + let kvps = url.keyValuePairs(in: component, schema: schema) + + // Check that the returned indexes are at the expected offsets, + // and contain the expected new pairs. + + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset), idx) + + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList.remove(at: offset) + XCTAssertEqualKeyValuePairs(kvps, expectedList) + + return url + } + + var result: WebURL + + // Remove from the front. + result = remove(offset: 0) + XCTAssertEqual(result.serialized(), checkpoints[0]) + + // Remove from the middle. + result = remove(offset: 1) + XCTAssertEqual(result.serialized(), checkpoints[1]) + + // Remove from the end. + result = remove(offset: initialPairs.count - 1) + XCTAssertEqual(result.serialized(), checkpoints[2]) + } + + // Form-encoding in the query. + // Initial query = non-empty list of pairs. + + _testRemoveOne( + url: "http://example/?first=0&second=1&third=2&fourth=3#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?second=1&third=2&fourth=3#frag", + "http://example/?first=0&third=2&fourth=3#frag", + "http://example/?first=0&second=1&third=2#frag", + ] + ) + + // Form-encoding in the query (2). + // Initial query = non-empty list of pairs. Component includes empty pairs. + + _testRemoveOne( + url: "http://example/?&&&first=0&&&second=1&&&third=2&&&fourth=3&&&#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?second=1&&&third=2&&&fourth=3&&&#frag", + "http://example/?&&&first=0&&&third=2&&&fourth=3&&&#frag", + "http://example/?&&&first=0&&&second=1&&&third=2&&#frag", + ] + ) + + // Custom schema in the fragment. + + _testRemoveOne( + url: "http://example/?srch#first:0,second:1,third:2,fourth:3", + component: .fragment, schema: CommaSeparated(), + checkpoints: [ + "http://example/?srch#second:1,third:2,fourth:3", + "http://example/?srch#first:0,third:2,fourth:3", + "http://example/?srch#first:0,second:1,third:2", + ] + ) + } +} + +// removeAll(in:where:) + +extension KeyValuePairsTests { + + /// Tests using `removeAll(in:where:)` to remove key-value pairs in a given range which match a predicate. + /// + /// Tests a variety of empty and non-empty ranges at various points within the list, + /// including full- and single-element ranges, with always-true and always-false predicates. + /// Also tests some more interesting predicates which depend on the decoded content of the key-value pairs. + /// + /// Finally, tests that the predicate visits only elements within the given range, and in the correct order. + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, + /// 3. With empty pairs inserted at various points in the URL component, and + /// 4. With over-encoded content. + /// + func testRemoveAllWhere() { + + func _testRemoveWhereElement( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] + ) where Schema: KeyValueStringSchema { + + let url = WebURL(urlString)! + XCTAssertEqual(url.serialized(), urlString) + + let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } + let initialCount = initialPairs.count + precondition(initialCount >= 4, "Minimum 4 pairs needed for this test") + + func remove(in range: Range, where predicate: (WebURL.KeyValuePairs.Element) -> Bool) -> WebURL { + + var copy = url + copy.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let lower = kvps.index(kvps.startIndex, offsetBy: range.lowerBound) + let upper = kvps.index(kvps.startIndex, offsetBy: range.upperBound) + kvps.removeAll(in: lower..) { + var seen: [KeyValuePair] = [] + var copy = url + copy.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let lower = kvps.index(kvps.startIndex, offsetBy: offsets.lowerBound) + let upper = kvps.index(kvps.startIndex, offsetBy: offsets.upperBound) + kvps.removeAll(in: lower..= 3, "Minimum 3 pairs required for this test") + + func removeWhere(_ offset: Range) -> WebURL { + var copy = url + copy.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let lower = kvps.index(kvps.startIndex, offsetBy: offset.lowerBound) + let upper = kvps.index(kvps.startIndex, offsetBy: offset.upperBound) + kvps.removeAll(in: lower..) -> WebURL { + var copy = url + copy.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let lower = kvps.index(kvps.startIndex, offsetBy: offset.lowerBound) + let upper = kvps.index(kvps.startIndex, offsetBy: offset.upperBound) + kvps.removeSubrange(lower..( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] + ) where Schema: KeyValueStringSchema { + + let url = WebURL(urlString)! + XCTAssertEqual(url.serialized(), urlString) + + let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } + let initialCount = initialPairs.count + + func checkAppendSingleOverload( + _ newPairs: Input, + operation: (inout WebURL.KeyValuePairs, Input) -> Range.Index>?, + expectedResult: String, + expectedNewPairs: [KeyValuePair] + ) where Input: Collection { + + var url = url + let insertedPairIndexes = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let insertedPairIndexes = operation(&kvps, newPairs) + XCTAssertKeyValuePairCacheIsAccurate(kvps) + return insertedPairIndexes + } + XCTAssertURLIsIdempotent(url) + + XCTAssertEqual(url.serialized(), expectedResult) + let kvps = url.keyValuePairs(in: component, schema: schema) + + // Check that the returned indexes are at the expected offsets, + // and contain the expected new pairs. + + if let insertedPairIndexes = insertedPairIndexes { + XCTAssertEqual( + kvps.index(kvps.startIndex, offsetBy: initialCount), + insertedPairIndexes.lowerBound + ) + XCTAssertEqual( + kvps.index(kvps.startIndex, offsetBy: initialCount + expectedNewPairs.count), + insertedPairIndexes.upperBound + ) + XCTAssertEqual(kvps.endIndex, insertedPairIndexes.upperBound) + XCTAssertEqualKeyValuePairs(kvps[insertedPairIndexes], expectedNewPairs) + } + + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList.append(contentsOf: expectedNewPairs) + XCTAssertEqualKeyValuePairs(kvps, expectedList) + } + + func checkAppendAllOverloads(_ newPairs: [(String, String)], checkpointIndex: inout Int) { + + let expectedNewPairs_Tuple = newPairs.map { KeyValuePair(key: $0.0, value: $0.1) } + + // Overload: Unlabelled Tuples. + + checkAppendSingleOverload( + newPairs, + operation: { kvps, pairs in kvps.append(contentsOf: pairs) }, + expectedResult: checkpoints[checkpointIndex], + expectedNewPairs: expectedNewPairs_Tuple + ) + + checkAppendSingleOverload( + newPairs, + operation: { kvps, pairs in + kvps += pairs + return nil + }, + expectedResult: checkpoints[checkpointIndex], + expectedNewPairs: expectedNewPairs_Tuple + ) + + // Overload: Labelled Tuples. + // Don't use Array because it has special magic which allows implicit stripping/inserting of tuple labels. + + checkAppendSingleOverload( + newPairs.lazy.map { (key: $0.0, value: $0.1) }, + operation: { kvps, pairs in kvps.append(contentsOf: pairs) }, + expectedResult: checkpoints[checkpointIndex], + expectedNewPairs: expectedNewPairs_Tuple + ) + + checkAppendSingleOverload( + newPairs.lazy.map { (key: $0.0, value: $0.1) }, + operation: { kvps, pairs in + kvps += pairs + return nil + }, + expectedResult: checkpoints[checkpointIndex], + expectedNewPairs: expectedNewPairs_Tuple + ) + + // Overload: Dictionary. + // If there are duplicate key-value pairs, the first one is kept. + + let dict = Dictionary(newPairs, uniquingKeysWith: { first, _ in first }) + let expectedNewPairs_dict = dict.sorted { $0.key < $1.key }.map { KeyValuePair(key: $0.key, value: $0.value) } + + checkAppendSingleOverload( + dict, + operation: { kvps, pairs in kvps.append(contentsOf: pairs) }, + expectedResult: checkpoints[checkpointIndex + 1], + expectedNewPairs: expectedNewPairs_dict + ) + + checkAppendSingleOverload( + dict, + operation: { kvps, pairs in + kvps += pairs + return nil + }, + expectedResult: checkpoints[checkpointIndex + 1], + expectedNewPairs: expectedNewPairs_dict + ) + + checkpointIndex += 2 + } + + var checkpointIndex = 0 + + // Empty collection. + checkAppendAllOverloads( + [], + checkpointIndex: &checkpointIndex + ) + + // Non-empty collection. + checkAppendAllOverloads( + [ + ("foo", "bar"), + ("foo", "baz"), + ("the key", "sp ace"), + ("", "emptykey"), + ("emptyval", ""), + ("", ""), + ("cafe\u{0301}", "caf\u{00E9}"), + (Self.SpecialCharacters, Self.SpecialCharacters), + ("CAT", "x"), + ], + checkpointIndex: &checkpointIndex + ) + } + + // Form encoded in the query. + // Initial query = nil. + + _testAppendCollection( + url: "http://example/#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/#frag", + "http://example/#frag", + + "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x#frag", + "http://example/?=emptykey&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the%20key=sp%20ace#frag", + ] + ) + + // Form encoded in the query (2). + // Initial query = empty string. Inserted spaces are encoded using "+" signs. + + _testAppendCollection( + url: "http://example/?#frag", + component: .query, schema: ExtendedForm(encodeSpaceAsPlus: true), + checkpoints: [ + "http://example/#frag", + "http://example/#frag", + + "http://example/?foo=bar&foo=baz&the+key=sp+ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)&CAT=x#frag", + "http://example/?=emptykey&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the+key=sp+ace#frag", + ] + ) + + // Form encoded in the query (3). + // Initial query = non-empty list of pairs. Component includes empty pairs and special characters. + + _testAppendCollection( + url: "http://example/?&test[+x%?]~=val^_`&&&x:y#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?&test[+x%?]~=val^_`&&&x:y#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y#frag", + + "http://example/?&test[+x%?]~=val^_`&&&x:y&foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y&=emptykey&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the%20key=sp%20ace#frag", + ] + ) + + // Form encoded in the query (4). + // Initial query = non-empty string, empty list of pairs. + + _testAppendCollection( + url: "http://example/?&&&#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/#frag", + "http://example/#frag", + + "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x#frag", + "http://example/?=emptykey&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the%20key=sp%20ace#frag", + ] + ) + + // Form encoded in the query (5). + // Initial query = non-empty list of pairs. Component ends with a single trailing pair delimiter. + // The single delimiter is re-used when appending new pairs. + + _testAppendCollection( + url: "http://example/?test=ok&#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?test=ok&#frag", + "http://example/?test=ok&#frag", + + "http://example/?test=ok&foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x#frag", + "http://example/?test=ok&=emptykey&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the%20key=sp%20ace#frag", + ] + ) + + // Form encoded in the query (6). + // Initial query = non-empty list of pairs. Component ends with a single trailing key-value delimiter. + // Since it is not a pair delimiter, it should not be reused. + + _testAppendCollection( + url: "http://example/?test=#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?test=#frag", + "http://example/?test=#frag", + + "http://example/?test=&foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x#frag", + "http://example/?test=&=emptykey&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the%20key=sp%20ace#frag", + ] + ) + + // Form encoded in the query (7). + // Initial query = non-empty list of pairs. Component ends with multiple trailing delimiters. + // Only one trailing delimiter is re-used when appending new pairs. + + _testAppendCollection( + url: "http://example/?test=ok&&&#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?test=ok&&&#frag", + "http://example/?test=ok&&&#frag", + + "http://example/?test=ok&&&foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x#frag", + "http://example/?test=ok&&&=emptykey&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the%20key=sp%20ace#frag", + ] + ) + + // Percent encoded in the query. + // Initial query = nil. + + _testAppendCollection( + url: "http://example/#frag", + component: .query, schema: .percentEncoded, + checkpoints: [ + "http://example/#frag", + "http://example/#frag", + + "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_PrctEnc_Query)=\(Self.SpecialCharacters_Escaped_PrctEnc_Query)&CAT=x#frag", + "http://example/?=emptykey&\(Self.SpecialCharacters_Escaped_PrctEnc_Query)=\(Self.SpecialCharacters_Escaped_PrctEnc_Query)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the%20key=sp%20ace#frag", + ] + ) + + // Custom schema in the fragment. + + _testAppendCollection( + url: "http://example/?srch", + component: .fragment, schema: CommaSeparated(), + checkpoints: [ + "http://example/?srch", + "http://example/?srch", + + "http://example/?srch#foo:bar,foo:baz,the%20key:sp%20ace,:emptykey,emptyval:,:,cafe%CC%81:caf%C3%A9,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag),CAT:x", + "http://example/?srch#:emptykey,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag),CAT:x,cafe%CC%81:caf%C3%A9,emptyval:,foo:bar,the%20key:sp%20ace", + ] + ) + + // Custom schema in the fragment (2). + // Component ends with single trailing delimiter. + + _testAppendCollection( + url: "http://example/?srch#test:ok,", + component: .fragment, schema: CommaSeparated(), + checkpoints: [ + "http://example/?srch#test:ok,", + "http://example/?srch#test:ok,", + + "http://example/?srch#test:ok,foo:bar,foo:baz,the%20key:sp%20ace,:emptykey,emptyval:,:,cafe%CC%81:caf%C3%A9,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag),CAT:x", + "http://example/?srch#test:ok,:emptykey,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag),CAT:x,cafe%CC%81:caf%C3%A9,emptyval:,foo:bar,the%20key:sp%20ace", + ] + ) + } + + /// Tests using `append(key:value:)` to append a single key-value pair to the list. + /// + /// The appended pair will have interesting features, such as special characters, + /// empty key name or value, Unicode, etc. + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, + /// 3. With empty pairs inserted at various points in the URL component, and + /// 4. With one or more trailing delimiters. + /// + func testAppendOne() { + + func _testAppendOne( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { + + let url = WebURL(urlString)! + XCTAssertEqual(url.serialized(), urlString) + + let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } + let initialCount = initialPairs.count + + func append(key: String, value: String) -> WebURL { + + var url = url + let appendedPairIndex = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let appendedPairIndex = kvps.append(key: key, value: value) + XCTAssertKeyValuePairCacheIsAccurate(kvps) + return appendedPairIndex + } + XCTAssertURLIsIdempotent(url) + + let kvps = url.keyValuePairs(in: component, schema: schema) + let expectedPair = KeyValuePair(key: key, value: value) + + // Check that the returned index is at the expected offset, + // and contains the expected new pair. + + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: initialCount), appendedPairIndex) + XCTAssertEqual(KeyValuePair(kvps[appendedPairIndex]), expectedPair) + + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList.append(expectedPair) + XCTAssertEqualKeyValuePairs(kvps, expectedList) + + return url + } + + var result: WebURL + + result = append(key: "foo", value: "bar") + XCTAssertEqual(result.serialized(), checkpoints[0]) + + result = append(key: "the key", value: "sp ace") + XCTAssertEqual(result.serialized(), checkpoints[1]) + + result = append(key: "", value: "emptykey") + XCTAssertEqual(result.serialized(), checkpoints[2]) + + result = append(key: "emptyval", value: "") + XCTAssertEqual(result.serialized(), checkpoints[3]) + + result = append(key: "", value: "") + XCTAssertEqual(result.serialized(), checkpoints[4]) + + result = append(key: "cafe\u{0301}", value: "caf\u{00E9}") + XCTAssertEqual(result.serialized(), checkpoints[5]) + + result = append(key: Self.SpecialCharacters, value: Self.SpecialCharacters) + XCTAssertEqual(result.serialized(), checkpoints[6]) + } + + // Form encoded in the query. + // Initial query = nil. + + _testAppendOne( + url: "http://example/#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?foo=bar#frag", + "http://example/?the%20key=sp%20ace#frag", + "http://example/?=emptykey#frag", + "http://example/?emptyval=#frag", + "http://example/?=#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag", + ] + ) + + // Form encoded in the query (2). + // Initial query = empty string. Inserted spaces are encoded using "+" signs. + + _testAppendOne( + url: "http://example/?#frag", + component: .query, schema: ExtendedForm(encodeSpaceAsPlus: true), + checkpoints: [ + "http://example/?foo=bar#frag", + "http://example/?the+key=sp+ace#frag", + "http://example/?=emptykey#frag", + "http://example/?emptyval=#frag", + "http://example/?=#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)#frag", + ] + ) + + // Form encoded in the query (3). + // Initial query = non-empty list of pairs. Component includes empty pairs and special characters. + + _testAppendOne( + url: "http://example/?&test[+x%?]~=val^_`&&&x:y#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?&test[+x%?]~=val^_`&&&x:y&foo=bar#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y&the%20key=sp%20ace#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y&=emptykey#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y&emptyval=#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y&=#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y&cafe%CC%81=caf%C3%A9#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag", + ] + ) + + // Form encoded in the query (4). + // Initial query = non-empty string, empty list of pairs. + + _testAppendOne( + url: "http://example/?&&&#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?foo=bar#frag", + "http://example/?the%20key=sp%20ace#frag", + "http://example/?=emptykey#frag", + "http://example/?emptyval=#frag", + "http://example/?=#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag", + ] + ) + + // Form encoded in the query (5). + // Initial query = non-empty list of pairs. Component ends with a single trailing pair delimiter. + // The single delimiter is re-used when appending new pairs. + + _testAppendOne( + url: "http://example/?test=ok&#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?test=ok&foo=bar#frag", + "http://example/?test=ok&the%20key=sp%20ace#frag", + "http://example/?test=ok&=emptykey#frag", + "http://example/?test=ok&emptyval=#frag", + "http://example/?test=ok&=#frag", + "http://example/?test=ok&cafe%CC%81=caf%C3%A9#frag", + "http://example/?test=ok&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag", + ] + ) + + // Form encoded in the query (6). + // Initial query = non-empty list of pairs. Component ends with a single trailing key-value delimiter. + // Since it is not a pair delimiter, it should not be reused. + + _testAppendOne( + url: "http://example/?test=#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?test=&foo=bar#frag", + "http://example/?test=&the%20key=sp%20ace#frag", + "http://example/?test=&=emptykey#frag", + "http://example/?test=&emptyval=#frag", + "http://example/?test=&=#frag", + "http://example/?test=&cafe%CC%81=caf%C3%A9#frag", + "http://example/?test=&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag", + ] + ) + + // Form encoded in the query (7). + // Initial query = non-empty list of pairs. Component ends with multiple trailing delimiters. + // Only one trailing delimiter is re-used when appending new pairs. + + _testAppendOne( + url: "http://example/?test=ok&&&#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?test=ok&&&foo=bar#frag", + "http://example/?test=ok&&&the%20key=sp%20ace#frag", + "http://example/?test=ok&&&=emptykey#frag", + "http://example/?test=ok&&&emptyval=#frag", + "http://example/?test=ok&&&=#frag", + "http://example/?test=ok&&&cafe%CC%81=caf%C3%A9#frag", + "http://example/?test=ok&&&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag", + ] + ) + + // Percent encoded in the query. + // Initial query = nil. + + _testAppendOne( + url: "http://example/#frag", + component: .query, schema: .percentEncoded, + checkpoints: [ + "http://example/?foo=bar#frag", + "http://example/?the%20key=sp%20ace#frag", + "http://example/?=emptykey#frag", + "http://example/?emptyval=#frag", + "http://example/?=#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_PrctEnc_Query)=\(Self.SpecialCharacters_Escaped_PrctEnc_Query)#frag", + ] + ) + + // Custom schema in the fragment. + + _testAppendOne( + url: "http://example/?srch", + component: .fragment, schema: CommaSeparated(), + checkpoints: [ + "http://example/?srch#foo:bar", + "http://example/?srch#the%20key:sp%20ace", + "http://example/?srch#:emptykey", + "http://example/?srch#emptyval:", + "http://example/?srch#:", + "http://example/?srch#cafe%CC%81:caf%C3%A9", + "http://example/?srch#\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag)", + ] + ) + + // Custom schema in the fragment (2). + // Component ends with single trailing delimiter. + + _testAppendOne( + url: "http://example/?srch#test:ok,", + component: .fragment, schema: CommaSeparated(), + checkpoints: [ + "http://example/?srch#test:ok,foo:bar", + "http://example/?srch#test:ok,the%20key:sp%20ace", + "http://example/?srch#test:ok,:emptykey", + "http://example/?srch#test:ok,emptyval:", + "http://example/?srch#test:ok,:", + "http://example/?srch#test:ok,cafe%CC%81:caf%C3%A9", + "http://example/?srch#test:ok,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag)", + ] + ) + } +} + +// replaceKey(at:with:), replaceValue(at:with:) + +extension KeyValuePairsTests { + + /// Tests using `replaceKey(at:with:)` to replace the key component of a single key-value pair. + /// + /// The new key is either an empty string, or an interesting non-empty string (special characters, Unicode, etc). + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, + /// 3. With empty pairs inserted at various points in the URL component, and + /// 4. With pairs containing empty keys and/or values. + /// + func testReplaceKeyAt() { + + func _testReplaceKeyAt( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { + + let url = WebURL(urlString)! + XCTAssertEqual(url.serialized(), urlString) + + let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } + let initialCount = initialPairs.count + precondition(initialCount >= 3, "Minimum 3 pairs required for this test") + + func replaceKey(atOffset offset: Int, with newKey: String) -> WebURL { + + var url = url + let (valueComponent, returnedIndex) = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let index = kvps.index(kvps.startIndex, offsetBy: offset) + let oldValue = kvps[index].encodedValue + let result = (kvps[index].value, kvps.replaceKey(at: index, with: newKey)) + XCTAssertKeyValuePairCacheIsAccurate(kvps) + XCTAssertEqual(kvps[result.1].encodedValue, oldValue) + return result + } + XCTAssertURLIsIdempotent(url) + + let kvps = url.keyValuePairs(in: component, schema: schema) + let expectedPair = KeyValuePair(key: newKey, value: valueComponent) + + // Check that the returned index is at the expected offset, + // and contains the expected new pair. + + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset), returnedIndex) + XCTAssertEqual(KeyValuePair(kvps[returnedIndex]), expectedPair) + + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList[offset].key = newKey + XCTAssertEqualKeyValuePairs(kvps, expectedList) + + return url + } + + var result: WebURL + + // Replace at the front. + result = replaceKey(atOffset: 0, with: "some replacement") + XCTAssertEqual(result.serialized(), checkpoints[0]) + + // Replace in the middle. + result = replaceKey(atOffset: 1, with: Self.SpecialCharacters) + XCTAssertEqual(result.serialized(), checkpoints[1]) + + // Replace at the end. + result = replaceKey(atOffset: initialCount - 1, with: "end key") + XCTAssertEqual(result.serialized(), checkpoints[2]) + + // Replace at the front (empty). + result = replaceKey(atOffset: 0, with: "") + XCTAssertEqual(result.serialized(), checkpoints[3]) + + // Replace in the middle (empty). + result = replaceKey(atOffset: 1, with: "") + XCTAssertEqual(result.serialized(), checkpoints[4]) + + // Replace at the end (empty). + result = replaceKey(atOffset: initialCount - 1, with: "") + XCTAssertEqual(result.serialized(), checkpoints[5]) + } + + // Form encoded in the query. + + _testReplaceKeyAt( + url: "http://example/?foo=bar&baz=qux&another=value#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?some%20replacement=bar&baz=qux&another=value#frag", + "http://example/?foo=bar&\(Self.SpecialCharacters_Escaped_Form)=qux&another=value#frag", + "http://example/?foo=bar&baz=qux&end%20key=value#frag", + + "http://example/?=bar&baz=qux&another=value#frag", + "http://example/?foo=bar&=qux&another=value#frag", + "http://example/?foo=bar&baz=qux&=value#frag", + ] + ) + + // Form encoded in the query (2). + // Inserted spaces are encoded using "+" signs. + + _testReplaceKeyAt( + url: "http://example/?foo=bar&baz=qux&another=value#frag", + component: .query, schema: ExtendedForm(encodeSpaceAsPlus: true), + checkpoints: [ + "http://example/?some+replacement=bar&baz=qux&another=value#frag", + "http://example/?foo=bar&\(Self.SpecialCharacters_Escaped_Form_Plus)=qux&another=value#frag", + "http://example/?foo=bar&baz=qux&end+key=value#frag", + + "http://example/?=bar&baz=qux&another=value#frag", + "http://example/?foo=bar&=qux&another=value#frag", + "http://example/?foo=bar&baz=qux&=value#frag", + ] + ) + + // Form encoded in the query (3). + // Component contains empty pairs. Unlike some other APIs, empty pairs are never removed. + + let s = "[+^x%`/?]~" + _testReplaceKeyAt( + url: "http://example/?&&&first\(s)=0&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?&&&some%20replacement=0&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&\(Self.SpecialCharacters_Escaped_Form)=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&second\(s)=1&&&third\(s)=2&&&end%20key=3&&&#frag", + + "http://example/?&&&=0&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&second\(s)=1&&&third\(s)=2&&&=3&&&#frag", + ] + ) + + // Form encoded in the query (4). + // Pairs have empty keys and values. + + _testReplaceKeyAt( + url: "http://example/?=&=&=#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?some%20replacement=&=&=#frag", + "http://example/?=&\(Self.SpecialCharacters_Escaped_Form)=&=#frag", + "http://example/?=&=&end%20key=#frag", + + "http://example/?=&=&=#frag", + "http://example/?=&=&=#frag", + "http://example/?=&=&=#frag", + ] + ) + + // Form encoded in the query (5). + // Pairs do not have any key-value delimiters. + // When setting the key to the empty string, a delimiter must be inserted. + + _testReplaceKeyAt( + url: "http://example/?foo&baz&another#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?some%20replacement&baz&another#frag", + "http://example/?foo&\(Self.SpecialCharacters_Escaped_Form)&another#frag", + "http://example/?foo&baz&end%20key#frag", + + "http://example/?=&baz&another#frag", + "http://example/?foo&=&another#frag", + "http://example/?foo&baz&=#frag", + ] + ) + + // Custom schema in the fragment. + + _testReplaceKeyAt( + url: "http://example/?srch#foo:bar,baz:qux,another:value", + component: .fragment, schema: CommaSeparated(), + checkpoints: [ + "http://example/?srch#some%20replacement:bar,baz:qux,another:value", + "http://example/?srch#foo:bar,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):qux,another:value", + "http://example/?srch#foo:bar,baz:qux,end%20key:value", + + "http://example/?srch#:bar,baz:qux,another:value", + "http://example/?srch#foo:bar,:qux,another:value", + "http://example/?srch#foo:bar,baz:qux,:value", + ] + ) + } + + /// Tests using `replaceValue(at:with:)` to replace the value component of a single key-value pair. + /// + /// The new value is either an empty string, or an interesting non-empty string (special characters, Unicode, etc). + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, + /// 3. With empty pairs inserted at various points in the URL component, and + /// 4. With pairs containing empty keys and/or values. + /// + func testReplaceValueAt() { + + func _testReplaceValueAt( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { + + let url = WebURL(urlString)! + XCTAssertEqual(url.serialized(), urlString) + + let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } + let initialCount = initialPairs.count + precondition(initialCount >= 3, "Minimum 3 pairs required for this test") + + func replaceValue(atOffset offset: Int, with newValue: String) -> WebURL { + + var url = url + let (keyComponent, returnedIndex) = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let index = kvps.index(kvps.startIndex, offsetBy: offset) + let oldKey = kvps[index].encodedKey + let result = (kvps[index].key, kvps.replaceValue(at: index, with: newValue)) + XCTAssertKeyValuePairCacheIsAccurate(kvps) + XCTAssertEqual(kvps[result.1].encodedKey, oldKey) + return result + } + XCTAssertURLIsIdempotent(url) + + let kvps = url.keyValuePairs(in: component, schema: schema) + let expectedPair = KeyValuePair(key: keyComponent, value: newValue) + + // Check that the returned index is at the expected offset, + // and contains the expected new pair. + + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset), returnedIndex) + XCTAssertEqual(KeyValuePair(kvps[returnedIndex]), expectedPair) + + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList[offset].value = newValue + XCTAssertEqualKeyValuePairs(kvps, expectedList) + + return url + } + + var result: WebURL + + // Replace at the front. + result = replaceValue(atOffset: 0, with: "some replacement") + XCTAssertEqual(result.serialized(), checkpoints[0]) + + // Replace in the middle. + result = replaceValue(atOffset: 1, with: Self.SpecialCharacters) + XCTAssertEqual(result.serialized(), checkpoints[1]) + + // Replace at the end. + result = replaceValue(atOffset: initialCount - 1, with: "end value") + XCTAssertEqual(result.serialized(), checkpoints[2]) + + // Replace at the front (empty). + result = replaceValue(atOffset: 0, with: "") + XCTAssertEqual(result.serialized(), checkpoints[3]) + + // Replace in the middle (empty). + result = replaceValue(atOffset: 1, with: "") + XCTAssertEqual(result.serialized(), checkpoints[4]) + + // Replace at the end (empty). + result = replaceValue(atOffset: initialCount - 1, with: "") + XCTAssertEqual(result.serialized(), checkpoints[5]) + } + + // Form-encoded in the query. + + _testReplaceValueAt( + url: "http://example/?foo=bar&baz=qux&another=value#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?foo=some%20replacement&baz=qux&another=value#frag", + "http://example/?foo=bar&baz=\(Self.SpecialCharacters_Escaped_Form)&another=value#frag", + "http://example/?foo=bar&baz=qux&another=end%20value#frag", + + "http://example/?foo=&baz=qux&another=value#frag", + "http://example/?foo=bar&baz=&another=value#frag", + "http://example/?foo=bar&baz=qux&another=#frag", + ] + ) + + // Form encoded in the query (2). + // Inserted spaces are encoded using "+" signs. + + _testReplaceValueAt( + url: "http://example/?foo=bar&baz=qux&another=value#frag", + component: .query, schema: ExtendedForm(encodeSpaceAsPlus: true), + checkpoints: [ + "http://example/?foo=some+replacement&baz=qux&another=value#frag", + "http://example/?foo=bar&baz=\(Self.SpecialCharacters_Escaped_Form_Plus)&another=value#frag", + "http://example/?foo=bar&baz=qux&another=end+value#frag", + + "http://example/?foo=&baz=qux&another=value#frag", + "http://example/?foo=bar&baz=&another=value#frag", + "http://example/?foo=bar&baz=qux&another=#frag", + ] + ) + + // Form encoded in the query (3). + // Component contains empty pairs. Unlike some other APIs, empty pairs are never removed. + + let s = "[+^x%`/?]~" + _testReplaceValueAt( + url: "http://example/?&&&first\(s)=0&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?&&&first\(s)=some%20replacement&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&second\(s)=\(Self.SpecialCharacters_Escaped_Form)&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=end%20value&&&#frag", + + "http://example/?&&&first\(s)=&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&second\(s)=&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=&&&#frag", + ] + ) + + // Form encoded in the query (4). + // Pairs have empty keys and values. + + _testReplaceValueAt( + url: "http://example/?=&=&=#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?=some%20replacement&=&=#frag", + "http://example/?=&=\(Self.SpecialCharacters_Escaped_Form)&=#frag", + "http://example/?=&=&=end%20value#frag", + + "http://example/?=&=&=#frag", + "http://example/?=&=&=#frag", + "http://example/?=&=&=#frag", + ] + ) + + // Form encoded in the query (5). + // Pairs do not have any key-value delimiters. + + _testReplaceValueAt( + url: "http://example/?foo&baz&another#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/?foo=some%20replacement&baz&another#frag", + "http://example/?foo&baz=\(Self.SpecialCharacters_Escaped_Form)&another#frag", + "http://example/?foo&baz&another=end%20value#frag", + + "http://example/?foo&baz&another#frag", + "http://example/?foo&baz&another#frag", + "http://example/?foo&baz&another#frag", + ] + ) + + // Custom schema in the fragment. + + _testReplaceValueAt( + url: "http://example/?srch#foo:bar,baz:qux,another:value", + component: .fragment, schema: CommaSeparated(), + checkpoints: [ + "http://example/?srch#foo:some%20replacement,baz:qux,another:value", + "http://example/?srch#foo:bar,baz:\(Self.SpecialCharacters_Escaped_CommaSep_Frag),another:value", + "http://example/?srch#foo:bar,baz:qux,another:end%20value", + + "http://example/?srch#foo:,baz:qux,another:value", + "http://example/?srch#foo:bar,baz:,another:value", + "http://example/?srch#foo:bar,baz:qux,another:", + ] + ) + } +} + + +// -------------------------------------------- +// MARK: - Reading: By Key +// -------------------------------------------- + + +extension KeyValuePairsTests { + + /// Tests using the key lookup subscripts to find the first value associated with one or more keys. + /// + /// Checks looking up: + /// + /// - Unique keys + /// - Duplicate keys + /// - Not-present keys + /// - Empty keys + /// - Keys which need unescaping, and + /// - Unicode keys + /// + /// With the single and batched lookup subscripts. + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, and + /// 2. Using a variety of schemas with different escaping rules and delimiters. + /// + func testKeyLookupSubscript() { + + func _testKeyLookupSubscript( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema + ) { + + let url = WebURL(urlString)! + XCTAssertEqual(url.serialized(), urlString) + + let kvps = url.keyValuePairs(in: component, schema: schema) + + // Check the overall list of pairs is what we expect. + + let expected = [ + (key: "a", value: "b"), + (key: "sp ac & es", value: "d"), + (key: "dup", value: "e"), + (key: "", value: "foo"), + (key: "noval", value: ""), + (key: "emoji", value: "๐Ÿ‘€"), + (key: "jalapen\u{0303}os", value: "nfd"), + (key: Self.SpecialCharacters, value: "specials"), + (key: "dup", value: "f"), + (key: "jalape\u{00F1}os", value: "nfc"), + (key: "1+1", value: "2"), + ] + XCTAssertEqualKeyValuePairs(kvps, expected) + + // Single key lookup. + // This should be equivalent to 'kvps.first { $0.key == TheKey }?.value' + // (in other words, Unicode canonical equivalence of the percent-decoded key, interpreted as UTF-8). + + // Unique key (unescaped). + XCTAssertEqual(kvps["a"], "b") + XCTAssertEqual(kvps["emoji"], "๐Ÿ‘€") + + // Duplicate key (unescaped). + // Lookup returns the first value. + XCTAssertEqual(kvps["dup"], "e") + + // Not-present key. + XCTAssertEqual(kvps["doesNotExist"], nil) + XCTAssertEqual(kvps["jalapenos"], nil) + XCTAssertEqual(kvps["DUP"], nil) + + // Empty key/value. + XCTAssertEqual(kvps[""], "foo") + XCTAssertEqual(kvps["noval"], "") + + // Keys which need unescaping. + XCTAssertEqual(kvps["sp ac & es"], "d") + XCTAssertEqual(kvps["1+1"], "2") + XCTAssertEqual(kvps[Self.SpecialCharacters], "specials") + + // Unicode keys. + // Lookup uses canonical equivalence, so these match the same pair. + XCTAssertEqual(kvps["jalapen\u{0303}os"], "nfd") + XCTAssertEqual(kvps["jalape\u{00F1}os"], "nfd") + + // Multiple key lookup. + // Each key should be looked up as above. + + XCTAssertEqual(kvps["dup", "dup"], ("e", "e")) + XCTAssertEqual(kvps["jalapen\u{0303}os", "emoji", "jalape\u{00F1}os"], ("nfd", "๐Ÿ‘€", "nfd")) + XCTAssertEqual(kvps["1+1", "dup", "", "sp ac & es"], ("2", "e", "foo", "d")) + XCTAssertEqual(kvps["noval", "doesNotExist", "DUP"], ("", nil, nil)) + } + + // Form encoding in the query. + + // swift-format-ignore + _testKeyLookupSubscript( + url: "http://example/?a=b&sp%20ac+%26+es=d&dup=e&=foo&noval&emoji=%F0%9F%91%80&jalapen%CC%83os=nfd&\(Self.SpecialCharacters_Escaped_Form)=specials&dup=f&jalape%C3%B1os=nfc&1%2B1=2#frag", + component: .query, schema: .formEncoded + ) + + // Form encoding in the query (2). + // Semicolons are also allowed as pair delimiters. + + // swift-format-ignore + _testKeyLookupSubscript( + url: "http://example/?a=b&sp%20ac+%26+es=d;dup=e&=foo&noval&emoji=%F0%9F%91%80;jalapen%CC%83os=nfd;\(Self.SpecialCharacters_Escaped_Form)=specials&dup=f&jalape%C3%B1os=nfc&1%2B1=2#frag", + component: .query, schema: ExtendedForm(semicolonIsPairDelimiter: true) + ) + + // Custom schema in the fragment. + + // swift-format-ignore + _testKeyLookupSubscript( + url: "http://example/?srch#a:b,sp%20ac%20&%20es:d,dup:e,:foo,noval,emoji:%F0%9F%91%80,jalapen%CC%83os:nfd,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):specials,dup:f,jalape%C3%B1os:nfc,1+1:2", + component: .fragment, schema: CommaSeparated() + ) + } + + /// Tests using `allValues(forKey:)` to find all values associated with a given key. + /// + /// Checks looking up values for: + /// + /// - Unique keys + /// - Duplicate keys + /// - Not-present keys + /// - Empty keys + /// - Keys which need unescaping, and + /// - Unicode keys + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, and + /// 2. Using a variety of schemas with different escaping rules and delimiters. + /// + func testAllValuesForKey() { + + func _testAllValuesForKey( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema + ) { + + let url = WebURL(urlString)! + XCTAssertEqual(url.serialized(), urlString) + + let kvps = url.keyValuePairs(in: component, schema: schema) + + // Check the overall list of pairs is what we expect. + + let expected = [ + (key: "a", value: "b"), + (key: "sp ac & es", value: "d"), + (key: "dup", value: "e"), + (key: "", value: "foo"), + (key: "noval", value: ""), + (key: "emoji", value: "๐Ÿ‘€"), + (key: "jalapen\u{0303}os", value: "nfd"), + (key: Self.SpecialCharacters, value: "specials"), + (key: "dup", value: "f"), + (key: "jalape\u{00F1}os", value: "nfc"), + (key: "1+1", value: "2"), + (key: "DUP", value: "no"), + ] + XCTAssertEqualKeyValuePairs(kvps, expected) + + // Unique keys. + XCTAssertEqual(kvps.allValues(forKey: "sp ac & es"), ["d"]) + XCTAssertEqual(kvps.allValues(forKey: "1+1"), ["2"]) + XCTAssertEqual(kvps.allValues(forKey: Self.SpecialCharacters), ["specials"]) + + // Duplicate keys. + // Values must be in the same order as in the list. + XCTAssertEqual(kvps.allValues(forKey: "dup"), ["e", "f"]) + + // Not-present keys. + XCTAssertEqual(kvps.allValues(forKey: "doesNotExist"), []) + XCTAssertEqual(kvps.allValues(forKey: "EMOJI"), []) + + // Empty keys/values. + XCTAssertEqual(kvps.allValues(forKey: ""), ["foo"]) + XCTAssertEqual(kvps.allValues(forKey: "noval"), [""]) + + // Unicode keys. + // Lookup uses canonical equivalence. + XCTAssertEqual(kvps.allValues(forKey: "jalapen\u{0303}os"), ["nfd", "nfc"]) + XCTAssertEqual(kvps.allValues(forKey: "jalape\u{00F1}os"), ["nfd", "nfc"]) + } + + // Form encoding in the query. + + // swift-format-ignore + _testAllValuesForKey( + url: "http://example/?a=b&sp%20ac+%26+es=d&dup=e&=foo&noval&emoji=%F0%9F%91%80&jalapen%CC%83os=nfd&\(Self.SpecialCharacters_Escaped_Form)=specials&dup=f&jalape%C3%B1os=nfc&1%2B1=2&DUP=no#frag", + component: .query, schema: .formEncoded + ) + + // Form encoding in the query (2). + // Semi-colons allowed as pair delimiters. + + // swift-format-ignore + _testAllValuesForKey( + url: "http://example/?a=b&sp%20ac+%26+es=d;dup=e&=foo&noval&emoji=%F0%9F%91%80;jalapen%CC%83os=nfd;\(Self.SpecialCharacters_Escaped_Form)=specials&dup=f&jalape%C3%B1os=nfc&1%2B1=2&DUP=no#frag", + component: .query, schema: ExtendedForm(semicolonIsPairDelimiter: true) + ) + + // Custom schema in the fragment. + + // swift-format-ignore + _testAllValuesForKey( + url: "http://example/?srch#a:b,sp%20ac%20&%20es:d,dup:e,:foo,noval,emoji:%F0%9F%91%80,jalapen%CC%83os:nfd,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):specials,dup:f,jalape%C3%B1os:nfc,1+1:2,DUP:no", + component: .fragment, schema: CommaSeparated() + ) + } +} + + +// ------------------------------- +// MARK: - Writing: By Key. +// ------------------------------- + +// set(key:to:) + +extension KeyValuePairsTests { + + /// Tests using `set(key:to:)` and key-based subscripts to replace the values associated with a key. + /// + /// Checks replacing the values for: + /// + /// - Unique keys + /// - Duplicate keys + /// - Unicode keys + /// - Not-present keys + /// - Empty keys, and + /// - Keys which need unescaping + /// + /// For each of the above, checks using a replacement value of: + /// + /// - Non-nil (`set(key:to:)` and subscript) + /// - Nil (subscript only) + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, and + /// 3. With empty pairs inserted at various points in the URL component. + /// + func testSet() { + + func _testSet( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { + + let url = WebURL(urlString)! + XCTAssertEqual(url.serialized(), urlString) + + /// Simulates the 'set' operation using an array of key-value pairs, + /// and returns the expected resulting list of pairs. + /// + func _expectedListAfterSetting(key: String, to newValue: String?) -> [KeyValuePair] { + + var expectedList = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } + + // Setting a nil value should remove all entries with a canonically-equivalent key. + + guard let newValue = newValue else { + expectedList.removeAll(where: { $0.key == key }) + return expectedList + } + + // Setting a non-nil value should replace the value of the first matching pair, + // and remove all other entries. If no pairs match, it should append a new pair. + + if let i = expectedList.firstIndex(where: { $0.key == key }) { + expectedList[i].value = newValue + expectedList[(i + 1)...].removeAll(where: { $0.key == key }) + } else { + expectedList.append(KeyValuePair(key: key, value: newValue)) + } + + return expectedList + } + + /// Sets a key-value pair using the `set(key:to:)` method. + /// + /// The method only accepts non-nil values and returns the index of the matching pair. + /// + func _setViaMethod(key: String, to newValue: String, expectedList: [KeyValuePair]) -> WebURL { + + var url = url + + let expectedOffset = url.keyValuePairs(in: component, schema: schema).prefix(while: { $0.key != key }).count + let returnedIndex = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let returnedIndex = kvps.set(key: key, to: newValue) + XCTAssertKeyValuePairCacheIsAccurate(kvps) + return returnedIndex + } + XCTAssertURLIsIdempotent(url) + + let kvps = url.keyValuePairs(in: component, schema: schema) + + // Check that the returned index is at the expected offset, + // and contains the expected new pair. + + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: expectedOffset), returnedIndex) + XCTAssertEqual(kvps[returnedIndex].key, key) + XCTAssertTrue(kvps[returnedIndex].value.utf8.elementsEqual(newValue.utf8)) + + // Check that the list has the expected contents. + + XCTAssertEqualKeyValuePairs(kvps, expectedList) + + return url + } + + /// Sets a key-value pair using the key-based subscript. + /// + /// The subscript accepts nil as a new value and does not return the index of the matching pair. + /// + func _setViaSubscript(key: String, to newValue: String?, expectedList: [KeyValuePair]) -> WebURL { + + var url = url + url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + kvps[key] = newValue + XCTAssertKeyValuePairCacheIsAccurate(kvps) + } + XCTAssertURLIsIdempotent(url) + + // Check that the list has the expected contents. + + let kvps = url.keyValuePairs(in: component, schema: schema) + XCTAssertEqualKeyValuePairs(kvps, expectedList) + + return url + } + + /// Tests setting a key to a non-nil value, using both the `set` method and keyed subscript. + /// + func setBothWays(key: String, to newValue: String, expectedURL: String) { + + let expectedList = _expectedListAfterSetting(key: key, to: newValue) + + let result_method = _setViaMethod(key: key, to: newValue, expectedList: expectedList) + let result_subsct = _setViaSubscript(key: key, to: newValue, expectedList: expectedList) + + XCTAssertEqual(result_method, result_subsct) + XCTAssertEqual(result_method.serialized(), expectedURL) + XCTAssertEqual(result_subsct.serialized(), expectedURL) + } + + /// Tests setting a key to nil value, using the keyed subscript only. + /// + func setToNil(key: String, expectedURL: String) { + + let expectedList = _expectedListAfterSetting(key: key, to: nil) + + let result = _setViaSubscript(key: key, to: nil, expectedList: expectedList) + + XCTAssertEqual(result.serialized(), expectedURL) + } + + // Unique key. + + setBothWays(key: Self.SpecialCharacters, to: "found", expectedURL: checkpoints[0]) + setToNil(key: Self.SpecialCharacters, expectedURL: checkpoints[1]) + + // First key. + + let firstKey = url.keyValuePairs(in: component, schema: schema).first!.key + setBothWays(key: firstKey, to: "first", expectedURL: checkpoints[2]) + setToNil(key: firstKey, expectedURL: checkpoints[3]) + + // Duplicate key. + + setBothWays(key: "dup", to: Self.SpecialCharacters, expectedURL: checkpoints[4]) + setToNil(key: "dup", expectedURL: checkpoints[5]) + + // Unicode key. + // Both ways of writing the key match the same pairs. + // Since the key already exists, they produce the same result. + + setBothWays(key: "cafe\u{0301}", to: "unicode", expectedURL: checkpoints[6]) + setBothWays(key: "caf\u{00E9}", to: "unicode", expectedURL: checkpoints[6]) + setToNil(key: "cafe\u{0301}", expectedURL: checkpoints[7]) + setToNil(key: "caf\u{00E9}", expectedURL: checkpoints[7]) + + // Non-present key. + + setBothWays(key: "inserted-" + Self.SpecialCharacters, to: "yes", expectedURL: checkpoints[8]) + setToNil(key: "doesNotExist", expectedURL: checkpoints[9]) + + // Empty key. + + setBothWays(key: "", to: "empty", expectedURL: checkpoints[10]) + setToNil(key: "", expectedURL: checkpoints[11]) + + // New value is the empty string. + + setBothWays(key: Self.SpecialCharacters, to: "", expectedURL: checkpoints[12]) + + // Set empty key to the empty string. + + setBothWays(key: "", to: "", expectedURL: checkpoints[13]) + + // Remove all keys (subscript only). + + do { + var result = url + while let key = result.keyValuePairs(in: component, schema: schema).first?.key { + result.withMutableKeyValuePairs(in: component, schema: schema) { + $0[key] = nil + XCTAssertKeyValuePairCacheIsAccurate($0) + } + XCTAssertURLIsIdempotent(result) + } + XCTAssertEqual(result.serialized(), checkpoints[14]) + } + } + + // Form-encoded in the query. + // Unicode key uses the "cafe\u{0301}" formulation. + + // swift-format-ignore + _testSet( + url: "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&cafe%CC%81=cheese&dup#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form_Plus)=found&cafe%CC%81=cheese&dup#frag", + "http://example/p?foo=bar&dup&=x&dup&cafe%CC%81=cheese&dup#frag", + + "http://example/p?foo=first&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&cafe%CC%81=cheese&dup#frag", + "http://example/p?dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&cafe%CC%81=cheese&dup#frag", + + "http://example/p?foo=bar&dup=\(Self.SpecialCharacters_Escaped_Form)&=x&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&cafe%CC%81=cheese#frag", + "http://example/p?foo=bar&=x&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&cafe%CC%81=cheese#frag", + + "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&cafe%CC%81=unicode&dup#frag", + "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&dup#frag", + + "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&cafe%CC%81=cheese&dup&inserted-\(Self.SpecialCharacters_Escaped_Form)=yes#frag", + "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&cafe%CC%81=cheese&dup#frag", + + "http://example/p?foo=bar&dup&=empty&dup&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&cafe%CC%81=cheese&dup#frag", + "http://example/p?foo=bar&dup&dup&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&cafe%CC%81=cheese&dup#frag", + + "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form_Plus)=&cafe%CC%81=cheese&dup#frag", + "http://example/p?foo=bar&dup&=&dup&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&cafe%CC%81=cheese&dup#frag", + + "http://example/p#frag", + ] + ) + + // Percent-encoded in the query. + // Unicode key uses the "caf\u{00E9}" formulation. + + _testSet( + url: "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese&dup#frag", + component: .query, schema: .percentEncoded, + checkpoints: [ + "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form)=found&caf%C3%A9=cheese&dup#frag", + "http://example/p?foo=bar&dup&=x&dup&caf%C3%A9=cheese&dup#frag", + + "http://example/p?foo=first&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese&dup#frag", + "http://example/p?dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese&dup#frag", + + "http://example/p?foo=bar&dup=\(Self.SpecialCharacters_Escaped_PrctEnc_Query)&=x&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese#frag", + "http://example/p?foo=bar&=x&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese#frag", + + "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=unicode&dup#frag", + "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form)&dup#frag", + + "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese&dup&inserted-\(Self.SpecialCharacters_Escaped_PrctEnc_Query)=yes#frag", + "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese&dup#frag", + + "http://example/p?foo=bar&dup&=empty&dup&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese&dup#frag", + "http://example/p?foo=bar&dup&dup&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese&dup#frag", + + "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese&dup#frag", + "http://example/p?foo=bar&dup&=&dup&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese&dup#frag", + + "http://example/p#frag", + ] + ) + + // Form-encoded in the query (2). + // Component contains empty pairs. + // + // Some empty pairs are removed, while others are not. + // Unfortunately, this exposes some implementation details. + + // swift-format-ignore + _testSet( + url: "http://example/p?&&&foo=bar&&&dup&&&=x&&&dup&&&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&&&cafe%CC%81=cheese&&&dup&&&#frag", + component: .query, schema: .formEncoded, + checkpoints: [ + // Unique key. + // - Non-nil: Behaves like 'replaceValue(at:)' - replaces value component, without removing any empty pairs. + // - Nil: Behaves like 'remove(at:) - removes empty pairs only until next index. + "http://example/p?&&&foo=bar&&&dup&&&=x&&&dup&&&\(Self.SpecialCharacters_Escaped_Form_Plus)=found&&&cafe%CC%81=cheese&&&dup&&&#frag", + "http://example/p?&&&foo=bar&&&dup&&&=x&&&dup&&&cafe%CC%81=cheese&&&dup&&&#frag", + + // First key. + // - Nil: Behaves like 'remove(at:)'/'replaceSubrange' - removes leading empty pairs. + "http://example/p?&&&foo=first&&&dup&&&=x&&&dup&&&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&&&cafe%CC%81=cheese&&&dup&&&#frag", + "http://example/p?dup&&&=x&&&dup&&&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&&&cafe%CC%81=cheese&&&dup&&&#frag", + + // Duplicate key. + // - Non-nil: Behaves like 'removeAll(where:)' from the second match - removes empty pairs. + // - Nil: Behaves like 'removeAll(where:)' from the second match - removes empty pairs. + // Behaves like 'remove(at:)' for the first match though. + "http://example/p?&&&foo=bar&&&dup=\(Self.SpecialCharacters_Escaped_Form)&&&=x&&&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&cafe%CC%81=cheese#frag", + "http://example/p?&&&foo=bar&&&=x&&&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&cafe%CC%81=cheese#frag", + + // Unicode key. + "http://example/p?&&&foo=bar&&&dup&&&=x&&&dup&&&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&&&cafe%CC%81=unicode&&&dup&&&#frag", + "http://example/p?&&&foo=bar&&&dup&&&=x&&&dup&&&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&&&dup&&&#frag", + + // Non-present key. + // - Non-nil: Behaves like 'append' - reuses one trailing pair delimiter. + "http://example/p?&&&foo=bar&&&dup&&&=x&&&dup&&&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&&&cafe%CC%81=cheese&&&dup&&&inserted-\(Self.SpecialCharacters_Escaped_Form)=yes#frag", + "http://example/p?&&&foo=bar&&&dup&&&=x&&&dup&&&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&&&cafe%CC%81=cheese&&&dup&&&#frag", + + // Empty key. + "http://example/p?&&&foo=bar&&&dup&&&=empty&&&dup&&&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&&&cafe%CC%81=cheese&&&dup&&&#frag", + "http://example/p?&&&foo=bar&&&dup&&&dup&&&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&&&cafe%CC%81=cheese&&&dup&&&#frag", + + // Empty value. + "http://example/p?&&&foo=bar&&&dup&&&=x&&&dup&&&\(Self.SpecialCharacters_Escaped_Form_Plus)=&&&cafe%CC%81=cheese&&&dup&&&#frag", + "http://example/p?&&&foo=bar&&&dup&&&=&&&dup&&&\(Self.SpecialCharacters_Escaped_Form_Plus)=test&&&cafe%CC%81=cheese&&&dup&&&#frag", + + // Remove all keys. + "http://example/p#frag", + ] + ) + + // Custom schema in the fragment. + + _testSet( + url: "http://example/p?q#foo:bar,dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese,dup", + component: .fragment, schema: CommaSeparated(), + checkpoints: [ + "http://example/p?q#foo:bar,dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):found,caf%C3%A9:cheese,dup", + "http://example/p?q#foo:bar,dup,:x,dup,caf%C3%A9:cheese,dup", + + "http://example/p?q#foo:first,dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese,dup", + "http://example/p?q#dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese,dup", + + "http://example/p?q#foo:bar,dup:\(Self.SpecialCharacters_Escaped_CommaSep_Frag),:x,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese", + "http://example/p?q#foo:bar,:x,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese", + + "http://example/p?q#foo:bar,dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:unicode,dup", + "http://example/p?q#foo:bar,dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):test,dup", + + "http://example/p?q#foo:bar,dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese,dup,inserted-\(Self.SpecialCharacters_Escaped_CommaSep_Frag):yes", + "http://example/p?q#foo:bar,dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese,dup", + + "http://example/p?q#foo:bar,dup,:empty,dup,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese,dup", + "http://example/p?q#foo:bar,dup,dup,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese,dup", + + "http://example/p?q#foo:bar,dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):,caf%C3%A9:cheese,dup", + "http://example/p?q#foo:bar,dup,:,dup,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese,dup", + + "http://example/p?q", + ] + ) + } +} + + +// ------------------------------- +// MARK: - Additional tests +// ------------------------------- + + +extension KeyValuePairsTests { + + /// Tests using `WebURL.UTF8View.keyValuePair(_:)` to determine the UTF-8 range of a key-value pair. + /// + /// Checks a variety of pair formats: + /// + /// - Standard pairs with escaping + /// - Pairs with no key-value delimiter + /// - Pairs with an empty key or value + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, and + /// 3. With empty pairs inserted at various points in the URL component. + /// + func testUTF8Slice() { + + func _testUTF8Slice( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, ranges: [(Range, Range)] + ) { + + let url = WebURL(urlString)! + XCTAssertEqual(url.serialized(), urlString) + + // Check that the component has the expected pairs. + + let kvps = url.keyValuePairs(in: component, schema: schema) + let expectedPairs = [ + (key: "sp ac & es", value: "foo-bar"), + (key: "nodelim", value: ""), + (key: "emptyval", value: ""), + (key: "", value: "emptykey"), + (key: "jalapen\u{0303}os", value: "nfd"), + ] + XCTAssertEqualKeyValuePairs(kvps, expectedPairs) + + // Check each pair. + + var kvpIndex = kvps.startIndex + var rangeIndex = ranges.startIndex + while kvpIndex < kvps.endIndex { + let utf8Slice = url.utf8.keyValuePair(kvpIndex) + + // The slice should cover the expected portion of the string. + + let keyRange = utf8Slice.key.startIndex..(_ x: inout T, to y: T) { + x = y + } + + assign(&dst.queryParams, to: src.queryParams) + XCTFail("Should have trapped") + } + + // Reassignment via modify accessor. Same source URLs, different components. + + if check == 1 { + let url1 = WebURL("http://xyz/")! + var url2 = url1 + + @inline(never) + func assign(_ x: inout T, to y: T) { + x = y + } + + assign(&url2.queryParams, to: url1.keyValuePairs(in: .fragment, schema: .formEncoded)) + XCTFail("Should have trapped") + } + + // Reassignment via scoped method. Different source URLs. + + if check == 2 { + let src = WebURL("http://src/")! + var dst = WebURL("file://dst/")! + + dst.withMutableKeyValuePairs(in: .query, schema: .formEncoded) { $0 = src.queryParams } + XCTFail("Should have trapped") + } + + // Reassignment via scoped method. Same source URLs, different components. + + if check == 3 { + var url1 = WebURL("file://xyz/")! + var url2 = url1 + + url1.withMutableKeyValuePairs(in: .query, schema: .formEncoded) { kvps1 in + url2.withMutableKeyValuePairs(in: .fragment, schema: .formEncoded) { kvps2 in + kvps2 += [("hello", "world")] + kvps1 = kvps2 + } + } + XCTFail("Should have trapped") + } + + #endif + } +} diff --git a/Tests/WebURLTests/KeyValuePairs/KeyValueStringSchemaTests.swift b/Tests/WebURLTests/KeyValuePairs/KeyValueStringSchemaTests.swift new file mode 100644 index 000000000..f45d0bbb6 --- /dev/null +++ b/Tests/WebURLTests/KeyValuePairs/KeyValueStringSchemaTests.swift @@ -0,0 +1,183 @@ +// Copyright The swift-url Contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest + +@testable import WebURL + +#if swift(<5.7) + #error("WebURL.KeyValuePairs requires Swift 5.7 or newer") +#endif + +final class KeyValueStringSchemaTests: XCTestCase { + + /// Verifies known schemas. + /// + func testKnownSchemas() { + + for component in KeyValuePairsSupportedComponent.allCases { + // Built-in schemas support all components. + XCTAssertNoThrow(try FormCompatibleKeyValueString().verify(for: component)) + XCTAssertNoThrow(try PercentEncodedKeyValueString().verify(for: component)) + + // Custom schemas defined in 'KeyValuePairs/Helpers.swift' support all components. + XCTAssertNoThrow(try CommaSeparated().verify(for: component)) + XCTAssertNoThrow(try ExtendedForm(semicolonIsPairDelimiter: true).verify(for: component)) + XCTAssertNoThrow(try ExtendedForm(encodeSpaceAsPlus: true).verify(for: component)) + } + } + + /// Tests that `KeyValueStringSchema.verify(for:)` detects mistakes in custom schemas. + /// + func testSchemaVerification() { + + struct VariableSchema: KeyValueStringSchema { + + var preferredKeyValueDelimiter = UInt8(ascii: "=") + var preferredPairDelimiter = UInt8(ascii: "&") + var decodePlusAsSpace = false + var encodeSpaceAsPlus = false + + var _isPairDelimiter: (Self, UInt8) -> Bool = { schema, codePoint in + codePoint == schema.preferredPairDelimiter + } + var _isKeyValueDelimiter: (Self, UInt8) -> Bool = { schema, codePoint in + codePoint == schema.preferredKeyValueDelimiter + } + var _shouldPercentEncode: (Self, UInt8) -> Bool = { _, _ in + false + } + + func isPairDelimiter(_ codePoint: UInt8) -> Bool { + _isPairDelimiter(self, codePoint) + } + func isKeyValueDelimiter(_ codePoint: UInt8) -> Bool { + _isKeyValueDelimiter(self, codePoint) + } + func shouldPercentEncode(ascii codePoint: UInt8) -> Bool { + _shouldPercentEncode(self, codePoint) + } + } + + struct TestCase { + var keyPath: WritableKeyPath + var error: KeyValueStringSchemaVerificationFailure + } + + for component in KeyValuePairsSupportedComponent.allCases { + + XCTAssertNoThrow(try VariableSchema().verify(for: component)) + + // Invalid preferred delimiters. + // Preferred delimiters are special because we have to be able to write them unescaped to the URL string. + + let preferredDelimiterTests = [ + TestCase(keyPath: \.preferredKeyValueDelimiter, error: .preferredKeyValueDelimiterIsInvalid), + TestCase(keyPath: \.preferredPairDelimiter, error: .preferredPairDelimiterIsInvalid), + ] + for testcase in preferredDelimiterTests { + for v in UInt8.min...UInt8.max { + let error: KeyValueStringSchemaVerificationFailure? + do { + var schema = VariableSchema() + schema[keyPath: testcase.keyPath] = v + try schema.verify(for: component) + error = nil + } catch let e { + error = (e as! KeyValueStringSchemaVerificationFailure) + } + + // The delimiter: + // + // - Must be an ASCII code-point, + guard let ascii = ASCII(v) else { + XCTAssertEqual(error, testcase.error) + continue + } + switch ascii { + // - Must not be the percent sign (`%`), plus sign (`+`), space, or a hex digit, and + case ASCII.percentSign, ASCII.plus, ASCII.space: + XCTAssertEqual(error, testcase.error) + case _ where ascii.isHexDigit: + XCTAssertEqual(error, testcase.error) + // - Must not require escaping in the URL component(s) used with this schema. + default: + switch component.value { + case .query: + let needsEscaping = + URLEncodeSet.Query().shouldPercentEncode(ascii: v) + || URLEncodeSet.SpecialQuery().shouldPercentEncode(ascii: v) + XCTAssertEqual(error, needsEscaping ? testcase.error : nil) + case .fragment: + let needsEscaping = URLEncodeSet.Fragment().shouldPercentEncode(ascii: v) + XCTAssertEqual(error, needsEscaping ? testcase.error : nil) + } + } + } + } + + // Preferred delimiter not recognized by 'is(KeyValue/Pair)Delimter'. + // These must be consistent, else the view will produce unexpected results + // when writing entries and reading them back. + + let delimiterPredicateTests_0 = [ + TestCase(keyPath: \._isKeyValueDelimiter, error: .preferredKeyValueDelimiterNotRecognized), + TestCase(keyPath: \._isPairDelimiter, error: .preferredPairDelimiterNotRecognized), + ] + for testcase in delimiterPredicateTests_0 { + var schema = VariableSchema() + schema[keyPath: testcase.keyPath] = { _, codePoint in false } + XCTAssertThrowsSpecific(testcase.error, { try schema.verify(for: component) }) + } + + // Invalid characters recognized as delimiters. + // We cannot allow '%' or ASCII hex digits to be interpreted as delimiters, + // otherwise they would collide with percent-encoding. + // '+' is also disallowed, even when 'decodePlusAsSpace=false', because it's just a bad idea. + + let delimiterPredicateTests_1 = [ + TestCase(keyPath: \._isKeyValueDelimiter, error: .invalidKeyValueDelimiterIsRecognized), + TestCase(keyPath: \._isPairDelimiter, error: .invalidPairDelimiterIsRecognized), + ] + for testcase in delimiterPredicateTests_1 { + for disallowedChar in "0123456789abcdefABCDEF%+" { + var schema = VariableSchema() + schema[keyPath: testcase.keyPath] = { schema, codePoint in + codePoint == schema.preferredKeyValueDelimiter + || codePoint == schema.preferredPairDelimiter + || codePoint == disallowedChar.asciiValue! + } + XCTAssertThrowsSpecific(testcase.error, { try schema.verify(for: component) }) + } + } + + // Inconsistent space encoding. + // If spaces are encoded as plus, pluses must also be decoded as space. + // All other combinations are allowed. + + for decode in [true, false] { + for encode in [true, false] { + let schema = VariableSchema(decodePlusAsSpace: decode, encodeSpaceAsPlus: encode) + if encode == true, decode == false { + XCTAssertThrowsSpecific(KeyValueStringSchemaVerificationFailure.inconsistentSpaceEncoding) { + try schema.verify(for: component) + } + } else { + XCTAssertNoThrow(try schema.verify(for: component)) + } + } + } + } + } +} diff --git a/Tests/WebURLTests/Utils.swift b/Tests/WebURLTests/Utils.swift index 3dc27d7a1..ec98705fe 100644 --- a/Tests/WebURLTests/Utils.swift +++ b/Tests/WebURLTests/Utils.swift @@ -21,7 +21,49 @@ import XCTest func XCTAssertEqualElements( _ left: Left, _ right: Right, file: StaticString = #file, line: UInt = #line ) where Left.Element == Right.Element, Left.Element: Equatable { - XCTAssertTrue(left.elementsEqual(right), file: file, line: line) + + var message = "Sequences are not equal:\n" + + var mismatch = false + var position = 0 + var leftIter = left.makeIterator() + var rightIter = right.makeIterator() + + while let leftElement = leftIter.next() { + guard let rightElement = rightIter.next() else { + mismatch = true + message += + """ + [\(position)...] + L: \(leftElement) + R: <>\n + """ + break + } + if leftElement != rightElement { + mismatch = true + message += + """ + [\(position)] + L: \(leftElement) + R: \(rightElement)\n + """ + } + position += 1 + } + if let rightElement = rightIter.next() { + mismatch = true + message += + """ + [\(position)...] + L: <> + R: \(rightElement)\n + """ + } + + if mismatch { + XCTFail(message, file: file, line: line) + } } /// Asserts that a closure throws a particular error. @@ -54,12 +96,22 @@ let stringWithEveryASCIICharacter: String = { // -------------------------------------------- +typealias Tuple2 = (T, T) +typealias Tuple3 = (T, T, T) typealias Tuple4 = (T, T, T, T) typealias Tuple8 = (T, T, T, T, T, T, T, T) typealias Tuple16 = (T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T) extension Array { + init(elements tuple: Tuple2) { + self = [tuple.0, tuple.1] + } + + init(elements tuple: Tuple3) { + self = [tuple.0, tuple.1, tuple.2] + } + init(elements tuple: Tuple4) { self = [tuple.0, tuple.1, tuple.2, tuple.3] } @@ -79,14 +131,43 @@ extension Array { // One day, when tuples are Equatable, we won't need these. // https://github.com/apple/swift-evolution/blob/main/proposals/0283-tuples-are-equatable-comparable-hashable.md func XCTAssertEqual( - _ expression1: @autoclosure () throws -> Tuple4?, - _ expression2: @autoclosure () throws -> Tuple4?, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line -) rethrows where T: Equatable { - let left = try expression1() - let right = try expression2() + _ left: Tuple2?, _ right: Tuple2?, _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) where T: Equatable { + switch (left, right) { + case (.none, .none): + return + case (.some, .none), (.none, .some): + XCTFail( + "XCTAssertEqual failed. \(String(describing: left)) is not equal to \(String(describing: right)). \(message())", + file: file, line: line + ) + case (.some(let left), .some(let right)): + XCTAssertEqual(Array(elements: left), Array(elements: right), message(), file: file, line: line) + } +} + +func XCTAssertEqual( + _ left: Tuple3?, _ right: Tuple3?, _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) where T: Equatable { + switch (left, right) { + case (.none, .none): + return + case (.some, .none), (.none, .some): + XCTFail( + "XCTAssertEqual failed. \(String(describing: left)) is not equal to \(String(describing: right)). \(message())", + file: file, line: line + ) + case (.some(let left), .some(let right)): + XCTAssertEqual(Array(elements: left), Array(elements: right), message(), file: file, line: line) + } +} + +func XCTAssertEqual( + _ left: Tuple4?, _ right: Tuple4?, _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) where T: Equatable { switch (left, right) { case (.none, .none): return @@ -101,14 +182,9 @@ func XCTAssertEqual( } func XCTAssertEqual( - _ expression1: @autoclosure () throws -> Tuple8?, - _ expression2: @autoclosure () throws -> Tuple8?, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line -) rethrows where T: Equatable { - let left = try expression1() - let right = try expression2() + _ left: Tuple8?, _ right: Tuple8?, _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) where T: Equatable { switch (left, right) { case (.none, .none): return @@ -123,14 +199,9 @@ func XCTAssertEqual( } func XCTAssertEqual( - _ expression1: @autoclosure () throws -> Tuple16?, - _ expression2: @autoclosure () throws -> Tuple16?, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line -) rethrows where T: Equatable { - let left = try expression1() - let right = try expression2() + _ left: Tuple16?, _ right: Tuple16?, _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) where T: Equatable { switch (left, right) { case (.none, .none): return diff --git a/Tests/WebURLTests/WebURLPercentEncodingUtilsTests.swift b/Tests/WebURLTests/WebURLPercentEncodingUtilsTests.swift index 70fc7994e..cdb064d8f 100644 --- a/Tests/WebURLTests/WebURLPercentEncodingUtilsTests.swift +++ b/Tests/WebURLTests/WebURLPercentEncodingUtilsTests.swift @@ -149,7 +149,6 @@ extension WebURLPercentEncodingUtilsTests { expected: "foo://host/p%5B%251%5D/p%5E2%5E" ) { encodedURL in XCTAssertEqual(encodedURL.path, "/p%5B%251%5D/p%5E2%5E") - XCTAssertEqualElements(encodedURL.pathComponents, ["p[%1]", "p^2^"]) } // Query @@ -158,9 +157,6 @@ extension WebURLPercentEncodingUtilsTests { expected: "foo://host?color%5BR%5D=100&color%7B%25G%7D=233&color%7CB%7C=42" ) { encodedURL in XCTAssertEqual(encodedURL.query, "color%5BR%5D=100&color%7B%25G%7D=233&color%7CB%7C=42") - XCTAssertEqual(encodedURL.formParams.get("color[R]"), "100") - XCTAssertEqual(encodedURL.formParams.get("color{%G}"), "233") - XCTAssertEqual(encodedURL.formParams.get("color|B|"), "42") } // Fragment diff --git a/Tests/WebURLTests/WebURLTests.swift b/Tests/WebURLTests/WebURLTests.swift index c374a4467..3129169cf 100644 --- a/Tests/WebURLTests/WebURLTests.swift +++ b/Tests/WebURLTests/WebURLTests.swift @@ -383,7 +383,9 @@ extension WebURLTests { _requiresSendable(url.utf8) _requiresSendable(url.origin) _requiresSendable(url.pathComponents) - _requiresSendable(url.formParams) + #if swift(>=5.7) + _requiresSendable(url.queryParams) + #endif } }