Skip to content

Replace Foundation replacingOccurrences(of:with:) and percent encoding#176

Open
madsodgaard wants to merge 2 commits intoapple:mainfrom
madsodgaard:percent-based-encoding
Open

Replace Foundation replacingOccurrences(of:with:) and percent encoding#176
madsodgaard wants to merge 2 commits intoapple:mainfrom
madsodgaard:percent-based-encoding

Conversation

@madsodgaard
Copy link
Contributor

Part of apple/swift-openapi-generator#868

Introduces a Swift impl of replacingOccurrences(of:with:), addingPercentEncoding and removingPercentEncoding.

The percent encoding were inspired by impl in swift-foundation: https://github.com/swiftlang/swift-foundation/blob/aee1d337039b5b2a1f0c3b7f83baa950dac3a84e/Sources/FoundationEssentials/URL/URLParser.swift

I also added tests to verify behaviour with Foundation.

@madsodgaard madsodgaard changed the title Remove replacingOccurrences(of:with:) and percent encoding Replace Foundation replacingOccurrences(of:with:) and percent encoding Feb 26, 2026
@czechboy0 czechboy0 added the 🔨 semver/patch No public API change. label Feb 27, 2026
Copy link
Contributor

@czechboy0 czechboy0 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @madsodgaard - got some suggestions on this one

Comment on lines +37 to +76
@inlinable func replacingOccurrences<Target: StringProtocol, Replacement: StringProtocol>(
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..<currentIndex])
result.append(contentsOf: replacement)

currentIndex = self.index(currentIndex, offsetBy: target.count)
chunkStartIndex = currentIndex

replacementsCount += 1
} else {
currentIndex = self.index(after: currentIndex)
}
}

// Append any remaining characters after the final match
if chunkStartIndex < end { result.append(contentsOf: self[chunkStartIndex...]) }

return result
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@inlinable func replacingOccurrences<Target: StringProtocol, Replacement: StringProtocol>(
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..<currentIndex])
result.append(contentsOf: replacement)
currentIndex = self.index(currentIndex, offsetBy: target.count)
chunkStartIndex = currentIndex
replacementsCount += 1
} else {
currentIndex = self.index(after: currentIndex)
}
}
// Append any remaining characters after the final match
if chunkStartIndex < end { result.append(contentsOf: self[chunkStartIndex...]) }
return result
}
@inlinable func replacingOccurrences<Replacement: StringProtocol>(
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 searchStart = self.startIndex
var replacements = 0
while
replacements < maxReplacements,
let foundRange = self.range(of: target, range: searchStart..<self.endIndex)
{
result.append(contentsOf: self[searchStart..<foundRange.lowerBound])
result.append(contentsOf: replacement)
searchStart = foundRange.upperBound
replacements += 1
}
result.append(contentsOf: self[searchStart..<self.endIndex])
return result
}

I couldn't convince myself that currentIndex = self.index(currentIndex, offsetBy: target.count) was actually always going to be correct, so how about this alternative that sidesteps that problem, and only ever uses indexes with the string that originated them?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good 👍

Copy link
Contributor Author

@madsodgaard madsodgaard Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops, I was a bit too quick here. range(of:) is not in FoundationEssentials. When are you worried that the above code would be incorrect?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🔨 semver/patch No public API change.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants