From d89e90b7be459ecd8b14d40f5166011764a4cc24 Mon Sep 17 00:00:00 2001 From: Mads Odgaard Date: Thu, 26 Feb 2026 11:19:18 +0100 Subject: [PATCH 1/7] remove replacingOccurences of and percent encoding --- .../Conversion/Converter+Client.swift | 9 +- .../Conversion/FoundationExtensions.swift | 151 ++++++++++++++++++ .../Conversion/ServerVariable.swift | 4 + .../URICoder/Parsing/URIParser.swift | 6 +- .../Serialization/URISerializer.swift | 22 +-- .../Test_FoundationExtensions.swift | 107 +++++++++++++ 6 files changed, 279 insertions(+), 20 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift index 181d06d4..6c5b45f5 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift @@ -12,7 +12,11 @@ // //===----------------------------------------------------------------------===// +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif public import HTTPTypes extension Converter { @@ -47,8 +51,9 @@ extension Converter { ) for parameter in parameters { let value = try encoder.encode(parameter, forKey: "") - if let range = renderedString.range(of: "{}") { - renderedString = renderedString.replacingOccurrences(of: "{}", with: value, range: range) + if renderedString.contains("{}") { + // Only replacing one at a time + renderedString = renderedString.replacingOccurrences(of: "{}", with: value, maxReplacements: 1) } } return renderedString diff --git a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift index 50dba1f2..9225b0c5 100644 --- a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift @@ -31,4 +31,155 @@ extension StringProtocol { return String(self[start...end]) } + + /// Returns a new string in which all occurrences of a target + /// string are replaced by another given string. + @inlinable func replacingOccurrences( + of target: Target, + with replacement: Replacement, + maxReplacements: Int = .max + ) -> String { + guard !target.isEmpty, maxReplacements > 0 else { return String(self) } + + var result = "" + result.reserveCapacity(self.count) + + var currentIndex = self.startIndex + let end = self.endIndex + var replacementsCount = 0 + + // Keeps track of the last un-appended chunk + var chunkStartIndex = currentIndex + + while currentIndex < end { + if replacementsCount == maxReplacements { break } + + let remainder = self[currentIndex...] + + if remainder.starts(with: target) { + result.append(contentsOf: self[chunkStartIndex.. String? { + let percent = UInt8(ascii: "%") + let space = UInt8(ascii: " ") + let utf8Buffer = self.utf8 + let maxLength = utf8Buffer.count * 3 + return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: maxLength) { outputBuffer in + var i = 0 + for byte in utf8Buffer { + if byte.isUnreserved || byte == space { + outputBuffer[i] = byte + i += 1 + } else { + outputBuffer[i] = percent + outputBuffer[i + 1] = hexToAscii(byte >> 4) + outputBuffer[i + 2] = hexToAscii(byte & 0xF) + i += 3 + } + } + return String(decoding: outputBuffer[.. String? { + let percent = UInt8(ascii: "%") + let utf8Buffer = self.utf8 + let maxLength = utf8Buffer.count + + return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: maxLength) { outputBuffer -> String? in + var i = 0 + var byte: UInt8 = 0 + var hexDigitsRequired = 0 + + for v in utf8Buffer { + if v == percent { + guard hexDigitsRequired == 0 else { return nil } + hexDigitsRequired = 2 + } else if hexDigitsRequired > 0 { + guard let hex = asciiToHex(v) else { return nil } + + if hexDigitsRequired == 2 { + byte = hex << 4 + } else if hexDigitsRequired == 1 { + byte += hex + outputBuffer[i] = byte + i += 1 + byte = 0 + } + hexDigitsRequired -= 1 + } else { + outputBuffer[i] = v + i += 1 + } + } + + guard hexDigitsRequired == 0 else { return nil } + + return String(bytes: outputBuffer[.. UInt8? { + switch ascii { + case UInt8(ascii: "0")...UInt8(ascii: "9"): return ascii - UInt8(ascii: "0") + case UInt8(ascii: "A")...UInt8(ascii: "F"): return ascii - UInt8(ascii: "A") + 10 + case UInt8(ascii: "a")...UInt8(ascii: "f"): return ascii - UInt8(ascii: "a") + 10 + default: return nil + } +} + +@inline(__always) private func hexToAscii(_ hex: UInt8) -> UInt8 { + switch hex { + case 0x0: return UInt8(ascii: "0") + case 0x1: return UInt8(ascii: "1") + case 0x2: return UInt8(ascii: "2") + case 0x3: return UInt8(ascii: "3") + case 0x4: return UInt8(ascii: "4") + case 0x5: return UInt8(ascii: "5") + case 0x6: return UInt8(ascii: "6") + case 0x7: return UInt8(ascii: "7") + case 0x8: return UInt8(ascii: "8") + case 0x9: return UInt8(ascii: "9") + case 0xA: return UInt8(ascii: "A") + case 0xB: return UInt8(ascii: "B") + case 0xC: return UInt8(ascii: "C") + case 0xD: return UInt8(ascii: "D") + case 0xE: return UInt8(ascii: "E") + case 0xF: return UInt8(ascii: "F") + default: fatalError("Invalid hex digit: \(hex)") + } +} + +extension UInt8 { + /// Checks if a byte is an unreserved character per RFC 3986. + fileprivate var isUnreserved: Bool { + switch self { + case UInt8(ascii: "0")...UInt8(ascii: "9"), UInt8(ascii: "A")...UInt8(ascii: "Z"), + UInt8(ascii: "a")...UInt8(ascii: "z"), UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: "_"), + UInt8(ascii: "~"): + return true + default: return false + } + } } diff --git a/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift b/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift index 69bf219d..3c7f631c 100644 --- a/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift +++ b/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift @@ -12,7 +12,11 @@ // //===----------------------------------------------------------------------===// +#if canImport(FoundationEssentials) +public import FoundationEssentials +#else public import Foundation +#endif extension URL { /// Returns a validated server URL created from the URL template, or diff --git a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift index 9e1da427..67881a7c 100644 --- a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift +++ b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift @@ -12,7 +12,11 @@ // //===----------------------------------------------------------------------===// +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif /// A type that can parse a primitive, array, and a dictionary from a URI-encoded string. struct URIParser: Sendable { @@ -337,7 +341,7 @@ extension URIParser { ) -> Raw { // The inverse of URISerializer.computeSafeString. let partiallyDecoded = escapedValue.replacingOccurrences(of: spaceEscapingCharacter.rawValue, with: " ") - return (partiallyDecoded.removingPercentEncoding ?? "")[...] + return (partiallyDecoded.removingPercentEncoding() ?? "")[...] } } diff --git a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift index aa606381..57106ae0 100644 --- a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift +++ b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift @@ -12,7 +12,11 @@ // //===----------------------------------------------------------------------===// +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif /// A type that serializes a `URIEncodedNode` to a URI-encoded string. struct URISerializer { @@ -46,22 +50,6 @@ struct URISerializer { } } -extension CharacterSet { - - /// A character set of unreserved symbols only from RFC 6570 (excludes - /// alphanumeric characters). - fileprivate static let unreservedSymbols: CharacterSet = .init(charactersIn: "-._~") - - /// A character set of unreserved characters from RFC 6570. - fileprivate static let unreserved: CharacterSet = .alphanumerics.union(unreservedSymbols) - - /// A character set with only the space character. - fileprivate static let space: CharacterSet = .init(charactersIn: " ") - - /// A character set of unreserved characters and a space. - fileprivate static let unreservedAndSpace: CharacterSet = .unreserved.union(space) -} - extension URISerializer { /// A serializer error. @@ -105,7 +93,7 @@ extension URISerializer { // The space character needs to be encoded based on the config, // so first allow it to be unescaped, and then we'll do a second // pass and only encode the space based on the config. - let partiallyEncoded = unsafeString.addingPercentEncoding(withAllowedCharacters: .unreservedAndSpace) ?? "" + let partiallyEncoded = unsafeString.addingPercentEncodingAllowingUnreservedAndSpace() ?? "" let fullyEncoded = partiallyEncoded.replacingOccurrences( of: " ", with: configuration.spaceEscapingCharacter.rawValue diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift index 1a21a271..8d45a67a 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift @@ -46,4 +46,111 @@ final class Test_FoundationExtensions: Test_Runtime { let puncResult = punctuationString.trimming(while: { $0.isPunctuation }) XCTAssertEqual(puncResult, "Hello") } + + func testReplacingOccurencesMatchesFoundationBehaviour() { + // Tuple format: (Input String, Target to replace, Replacement string) + let testCases: [(input: String, target: String, replacement: String)] = [ + ("Hello World", "World", "Swift"), // Standard replacement + ("banana", "a", "o"), // Multiple occurrences + ("aaaa", "aa", "b"), // Overlapping occurrences (should result in "bb") + ("Nothing here", "xyz", "abc"), // Target not found + ("Case sensitive", "case", "X"), // Case sensitivity check (should not replace) + ("👨‍👩‍👧‍👦 Family", "👨‍👩‍👧‍👦", "👪"), // Complex Emoji / Grapheme clusters + ("Café", "é", "e"), // Accented characters + ("", "target", "replacement"), // Empty input string + ("exact match", "exact match", "success"), // Exact full string match + ] + + for testCase in testCases { + let foundationResult = testCase.input.replacingOccurrences(of: testCase.target, with: testCase.replacement) + let customResult = testCase.input.replacingOccurrences( + of: testCase.target, + with: testCase.replacement, + maxReplacements: .max + ) + XCTAssertEqual( + customResult, + foundationResult, + "Mismatch for input: '\(testCase.input)'. Expected '\(foundationResult)', got '\(customResult)'" + ) + } + } + + func testReplacingOccurencesMaxReplacementsLogic() { + let input = "a b a b a" + + let replaceOne = input.replacingOccurrences(of: "a", with: "c", maxReplacements: 1) + XCTAssertEqual(replaceOne, "c b a b a", "Failed to limit replacement to 1") + + let replaceTwo = input.replacingOccurrences(of: "a", with: "c", maxReplacements: 2) + XCTAssertEqual(replaceTwo, "c b c b a", "Failed to limit replacement to 2") + + let replaceTen = input.replacingOccurrences(of: "a", with: "c", maxReplacements: 10) + XCTAssertEqual(replaceTen, "c b c b c", "Failed when maxReplacements is greater than occurrences") + + let replaceZero = input.replacingOccurrences(of: "a", with: "c", maxReplacements: 0) + XCTAssertEqual(replaceZero, input, "String should remain unchanged when maxReplacements is 0") + } + + private var unreservedAndSpace: CharacterSet { + var charset = CharacterSet.alphanumerics + charset.insert(charactersIn: "-._~ ") + return charset + } + + func testAddingPercentEncodingAllowingUnreservedAndSpace() { + let testCases = [ + "HelloWorld", // Alphanumerics (No encoding needed) + "Hello World", // Space (Should NOT be encoded based on requirements) + "user@email.com", // '@' is reserved (Should be encoded) + "price=$100&tax=yes", // '=', '$', '&' are reserved (Should be encoded) + "café", // Non-ASCII character (Should be encoded) + "👨‍💻 swift", // Emoji and space + "~_.-", // Unreserved punctuation (Should NOT be encoded) + "", // Empty string + "100% coverage", // '%' symbol itself (Must be encoded to %25) + ] + + for input in testCases { + let foundationResult = input.addingPercentEncoding(withAllowedCharacters: unreservedAndSpace) + let customResult = input.addingPercentEncodingAllowingUnreservedAndSpace() + XCTAssertEqual( + customResult, + foundationResult, + "Encoding mismatch for input: '\(input)'. Expected '\(String(describing: foundationResult))', got '\(String(describing: customResult))'" + ) + } + } + + // MARK: - Removing Encoding Tests + + func testRemovingPercentEncoding() { + let testCases = [ + "HelloWorld", // Nothing to decode + "Hello%20World", // Standard space decoding + "user%40email.com", // Reserved character decoding ('@') + "caf%C3%A9", // UTF-8 multibyte decoding ('é') + "%F0%9F%91%A8%E2%80%8D%F0%9F%92%BB", // Complex Emoji decoding + "~_.-", // Unencoded unreserved characters + "", // Empty string + "100%25%20coverage", // Decodes to "100% coverage" + + // Edge Cases & Invalid Inputs + "Hello%2World", // Malformed percent encoding (missing second hex digit) + "%ZZ", // Invalid hex characters + "%FF", // Valid hex, but invalid UTF-8 byte sequence + ] + + for input in testCases { + let foundationResult = input.removingPercentEncoding + let customResult = input.removingPercentEncoding() + + // 3. Compare + XCTAssertEqual( + customResult, + foundationResult, + "Decoding mismatch for input: '\(input)'. Expected '\(String(describing: foundationResult))', got '\(String(describing: customResult))'" + ) + } + } } From 087d010726888e56efffba53616f12fdc5e43477 Mon Sep 17 00:00:00 2001 From: Mads Odgaard Date: Thu, 26 Feb 2026 11:22:25 +0100 Subject: [PATCH 2/7] remove comment --- .../Conversion/Test_FoundationExtensions.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift index 8d45a67a..8f0ee9ea 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift @@ -144,8 +144,6 @@ final class Test_FoundationExtensions: Test_Runtime { for input in testCases { let foundationResult = input.removingPercentEncoding let customResult = input.removingPercentEncoding() - - // 3. Compare XCTAssertEqual( customResult, foundationResult, From 379a12229acd264c19ae50275e5bd330d76b054e Mon Sep 17 00:00:00 2001 From: Mads Odgaard Date: Fri, 27 Feb 2026 20:49:03 +0100 Subject: [PATCH 3/7] PR feedback --- .../Conversion/FoundationExtensions.swift | 53 +++++++------------ .../Serialization/URISerializer.swift | 2 +- .../Test_FoundationExtensions.swift | 2 - 3 files changed, 20 insertions(+), 37 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift index 9225b0c5..07f6fd6d 100644 --- a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift @@ -34,50 +34,35 @@ extension StringProtocol { /// Returns a new string in which all occurrences of a target /// string are replaced by another given string. - @inlinable func replacingOccurrences( - of target: Target, + func replacingOccurrences( + of target: String, with replacement: Replacement, maxReplacements: Int = .max ) -> String { guard !target.isEmpty, maxReplacements > 0 else { return String(self) } - var result = "" result.reserveCapacity(self.count) - - var currentIndex = self.startIndex - let end = self.endIndex - var replacementsCount = 0 - - // Keeps track of the last un-appended chunk - var chunkStartIndex = currentIndex - - while currentIndex < end { - if replacementsCount == maxReplacements { break } - - let remainder = self[currentIndex...] - - if remainder.starts(with: target) { - result.append(contentsOf: self[chunkStartIndex.. String? { + func addingPercentEncodingAllowingUnreservedAndSpace() -> String { + guard !self.isEmpty else { return String(self) } + let percent = UInt8(ascii: "%") let space = UInt8(ascii: " ") let utf8Buffer = self.utf8 @@ -140,7 +125,7 @@ extension StringProtocol { } } -@inline(__always) private func asciiToHex(_ ascii: UInt8) -> UInt8? { +private func asciiToHex(_ ascii: UInt8) -> UInt8? { switch ascii { case UInt8(ascii: "0")...UInt8(ascii: "9"): return ascii - UInt8(ascii: "0") case UInt8(ascii: "A")...UInt8(ascii: "F"): return ascii - UInt8(ascii: "A") + 10 @@ -149,7 +134,7 @@ extension StringProtocol { } } -@inline(__always) private func hexToAscii(_ hex: UInt8) -> UInt8 { +private func hexToAscii(_ hex: UInt8) -> UInt8 { switch hex { case 0x0: return UInt8(ascii: "0") case 0x1: return UInt8(ascii: "1") diff --git a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift index 57106ae0..b2e62dca 100644 --- a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift +++ b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift @@ -93,7 +93,7 @@ extension URISerializer { // The space character needs to be encoded based on the config, // so first allow it to be unescaped, and then we'll do a second // pass and only encode the space based on the config. - let partiallyEncoded = unsafeString.addingPercentEncodingAllowingUnreservedAndSpace() ?? "" + let partiallyEncoded = unsafeString.addingPercentEncodingAllowingUnreservedAndSpace() let fullyEncoded = partiallyEncoded.replacingOccurrences( of: " ", with: configuration.spaceEscapingCharacter.rawValue diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift index 8f0ee9ea..53e75337 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift @@ -122,8 +122,6 @@ final class Test_FoundationExtensions: Test_Runtime { } } - // MARK: - Removing Encoding Tests - func testRemovingPercentEncoding() { let testCases = [ "HelloWorld", // Nothing to decode From f6b6bee711bf72d8c5e6aa3d0b80f718b71c3636 Mon Sep 17 00:00:00 2001 From: Mads Odgaard Date: Fri, 27 Feb 2026 20:53:22 +0100 Subject: [PATCH 4/7] revert replacingOccurrences --- .../Conversion/FoundationExtensions.swift | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift index 07f6fd6d..c953edda 100644 --- a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift @@ -34,30 +34,37 @@ extension StringProtocol { /// Returns a new string in which all occurrences of a target /// string are replaced by another given string. - func replacingOccurrences( - of target: String, + @inlinable func replacingOccurrences( + of target: Target, with replacement: Replacement, maxReplacements: Int = .max ) -> String { guard !target.isEmpty, maxReplacements > 0 else { return String(self) } var result = "" result.reserveCapacity(self.count) - var searchStart = self.startIndex + var currentIndex = self.startIndex + let end = self.endIndex var replacements = 0 - while - replacements < maxReplacements, - let foundRange = self.range(of: target, range: searchStart.. String { From aa5e736e7acb860dbbbbb973cec1da242f1f2439 Mon Sep 17 00:00:00 2001 From: Mads Odgaard Date: Sat, 28 Feb 2026 19:44:26 +0100 Subject: [PATCH 5/7] add `range(of:)` --- .../Conversion/FoundationExtensions.swift | 46 +++++++++++-------- .../Test_FoundationExtensions.swift | 44 ++++++++++++++++++ 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift index c953edda..efee87d7 100644 --- a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift @@ -34,37 +34,43 @@ extension StringProtocol { /// Returns a new string in which all occurrences of a target /// string are replaced by another given string. - @inlinable func replacingOccurrences( - of target: Target, + @inlinable func replacingOccurrences( + of target: String, with replacement: Replacement, maxReplacements: Int = .max ) -> String { guard !target.isEmpty, maxReplacements > 0 else { return String(self) } var result = "" result.reserveCapacity(self.count) - var currentIndex = self.startIndex - let end = self.endIndex + var searchStart = self.startIndex var replacements = 0 - - // Keeps track of the last un-appended chunk - var chunkStartIndex = currentIndex - while replacements < maxReplacements, currentIndex < end { - let remainder = self[currentIndex...] - if remainder.starts(with: target) { - result.append(contentsOf: self[chunkStartIndex..? = nil) -> Range? { + guard !aString.isEmpty else { return nil } + + var current = searchRange?.lowerBound ?? self.startIndex + let end = searchRange?.upperBound ?? self.endIndex + let targetCount = aString.count + guard let lastPossibleStart = self.index(end, offsetBy: -targetCount, limitedBy: current) else { return nil } + + while current <= lastPossibleStart { + if self[current...].starts(with: aString) { return current.. String { diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift index 53e75337..8dd27ad3 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_FoundationExtensions.swift @@ -149,4 +149,48 @@ final class Test_FoundationExtensions: Test_Runtime { ) } } + + func testStringRangeOf() { + // Tuple format: (Input, Target, Optional bounds for the search rangess + let testCases: [(input: String, target: String, bounds: (start: Int, end: Int)?)] = [ + ("Hello World", "World", nil), // Standard match, full string + ("Hello World", "Hello", nil), // Standard match at the start + ("banana", "na", nil), // Multiple occurrences, full string + ("banana", "na", (0, 4)), // Bounded search: "bana" (should find first "na") + ("banana", "na", (3, 6)), // Bounded search: "ana" (should find second "na") + ("banana", "na", (0, 3)), // Bounded search: "ban" (should return nil, "na" cut off) + ("aaaa", "aa", nil), // Overlapping occurrences + ("Case Sensitive", "case", nil), // Case mismatch + ("Missing text", "xyz", nil), // Target not in string + ("👨‍👩‍👧‍👦 Family", "Family", nil), // Emoji / Grapheme clusters + ("Café menu", "é", (0, 5)), // Accented characters within bounds "Café " + ("exact match", "exact match", nil), // Target equals entire string + ("Short", "Longer target string", nil), // Target is longer than the input + ("Empty target test", "", nil), // Empty target string + ("", "Target in empty string", nil), // Empty input string + ("", "", nil), // Both empty + ] + + for testCase in testCases { + var searchRange: Range? = nil + if let bounds = testCase.bounds { + let start = testCase.input.index(testCase.input.startIndex, offsetBy: bounds.start) + let end = testCase.input.index(testCase.input.startIndex, offsetBy: bounds.end) + searchRange = start.. Date: Sat, 28 Feb 2026 22:17:24 +0100 Subject: [PATCH 6/7] rewrite a bit to use string comparison --- Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift index efee87d7..74f7073c 100644 --- a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift @@ -65,7 +65,9 @@ extension StringProtocol { guard let lastPossibleStart = self.index(end, offsetBy: -targetCount, limitedBy: current) else { return nil } while current <= lastPossibleStart { - if self[current...].starts(with: aString) { return current.. Date: Mon, 2 Mar 2026 09:54:07 +0100 Subject: [PATCH 7/7] remove index(offsetBy:) --- .../Conversion/FoundationExtensions.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift index 74f7073c..9197647b 100644 --- a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift @@ -61,13 +61,16 @@ extension StringProtocol { var current = searchRange?.lowerBound ?? self.startIndex let end = searchRange?.upperBound ?? self.endIndex - let targetCount = aString.count - guard let lastPossibleStart = self.index(end, offsetBy: -targetCount, limitedBy: current) else { return nil } - while current <= lastPossibleStart { - let windowEnd = self.index(current, offsetBy: targetCount) + while current < end { + let searchSlice = self[current..