Skip to content

AttributedString Index Validity APIs #1177

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Feb 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,11 @@ extension AttributedString.CharacterView: BidirectionalCollection {
public typealias Index = AttributedString.Index

public var startIndex: AttributedString.Index {
.init(_range.lowerBound)
.init(_range.lowerBound, version: _guts.version)
}

public var endIndex: AttributedString.Index {
.init(_range.upperBound)
.init(_range.upperBound, version: _guts.version)
}

@_alwaysEmitIntoClient
Expand All @@ -131,14 +131,14 @@ extension AttributedString.CharacterView: BidirectionalCollection {

public func index(before i: AttributedString.Index) -> AttributedString.Index {
precondition(i >= startIndex && i <= endIndex, "AttributedString index out of bounds")
let j = Index(_guts.string.index(before: i._value))
let j = Index(_guts.string.index(before: i._value), version: _guts.version)
precondition(j >= startIndex, "Can't advance AttributedString index before start index")
return j
}

public func index(after i: AttributedString.Index) -> AttributedString.Index {
precondition(i >= startIndex && i <= endIndex, "AttributedString index out of bounds")
let j = Index(_guts.string.index(after: i._value))
let j = Index(_guts.string.index(after: i._value), version: _guts.version)
precondition(j <= endIndex, "Can't advance AttributedString index after end index")
return j
}
Expand All @@ -157,7 +157,7 @@ extension AttributedString.CharacterView: BidirectionalCollection {
@usableFromInline
internal func _index(_ i: AttributedString.Index, offsetBy distance: Int) -> AttributedString.Index {
precondition(i >= startIndex && i <= endIndex, "AttributedString index out of bounds")
let j = Index(_guts.string.index(i._value, offsetBy: distance))
let j = Index(_guts.string.index(i._value, offsetBy: distance), version: _guts.version)
precondition(j >= startIndex && j <= endIndex, "AttributedString index out of bounds")
return j
}
Expand Down Expand Up @@ -192,7 +192,7 @@ extension AttributedString.CharacterView: BidirectionalCollection {
}
precondition(j >= startIndex._value && j <= endIndex._value,
"AttributedString index out of bounds")
return Index(j)
return Index(j, version: _guts.version)
}

@_alwaysEmitIntoClient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ extension AttributedString {
typealias _AttributeValue = AttributedString._AttributeValue
typealias _AttributeStorage = AttributedString._AttributeStorage

var version: Version
var string: BigString
var runs: _InternalRuns

// Note: the caller is responsible for performing attribute fix-ups if needed based on the source of the runs
init(string: BigString, runs: _InternalRuns) {
precondition(string.isEmpty == runs.isEmpty, "An empty attributed string should not contain any runs")
self.version = Self.createNewVersion()
self.string = string
self.runs = runs
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

#if canImport(Synchronization)
Copy link
Contributor

Choose a reason for hiding this comment

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

In other places we have #if canImport(Synchronization) && FOUNDATION_FRAMEWORK. Is that wrong?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Previously we needed the && FOUNDATION_FRAMEWORK because non-FOUNDATION_FRAMEWORK builds ran on a very old version of macOS in CI where we back deployed back to a version that didn't have Synchronization (even though the canImport(Synchronization) check passes because it was in the SDK). Now that we've bumped the deployment target of the package, we can safely use the Synchronization module on the macOS version CI is back deployed to, so no need to guard with FOUNDATION_FRAMEWORK (but there are still some edge case situations where the module doesn't exist at all that we need to guard for with the canImport check)

internal import Synchronization
#endif

extension AttributedString.Guts {
typealias Version = UInt

#if canImport(Synchronization)
private static let _nextVersion = Atomic<Version>(0)
#else
private static let _nextVersion = LockedState<Version>(initialState: 0)
#endif

static func createNewVersion() -> Version {
#if canImport(Synchronization)
_nextVersion.wrappingAdd(1, ordering: .relaxed).oldValue
#else
_nextVersion.withLock { value in
defer {
value &+= 1
}
return value
}
#endif
}

func incrementVersion() {
self.version = Self.createNewVersion()
}
}

// MARK: - Public API

@available(FoundationPreview 6.2, *)
extension AttributedString.Index {
public func isValid(within text: some AttributedStringProtocol) -> Bool {
self._version == text.__guts.version &&
self >= text.startIndex &&
self < text.endIndex
}

public func isValid(within text: DiscontiguousAttributedSubstring) -> Bool {
self._version == text._guts.version &&
text._indices.contains(self._value)
}
}

@available(FoundationPreview 6.2, *)
extension Range<AttributedString.Index> {
public func isValid(within text: some AttributedStringProtocol) -> Bool {
// Note: By nature of Range's lowerBound <= upperBound requirement, this is also sufficient to determine that lowerBound <= endIndex && upperBound >= startIndex
self.lowerBound._version == text.__guts.version &&
self.lowerBound >= text.startIndex &&
self.upperBound._version == text.__guts.version &&
self.upperBound <= text.endIndex
}

public func isValid(within text: DiscontiguousAttributedSubstring) -> Bool {
let endIndex = text._indices.ranges.last?.upperBound
return self.lowerBound._version == text._guts.version &&
(self.lowerBound._value == endIndex || text._indices.contains(self.lowerBound._value)) &&
self.upperBound._version == text._guts.version &&
(self.upperBound._value == endIndex || text._indices.contains(self.upperBound._value))
}
}

@available(FoundationPreview 6.2, *)
extension RangeSet<AttributedString.Index> {
public func isValid(within text: some AttributedStringProtocol) -> Bool {
self.ranges.allSatisfy {
$0.isValid(within: text)
}
}

public func isValid(within text: DiscontiguousAttributedSubstring) -> Bool {
self.ranges.allSatisfy {
$0.isValid(within: text)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,11 @@ extension AttributedString.Runs {
}

public var startIndex: Index {
Index(runs.startIndex._stringIndex!)
Index(runs.startIndex._stringIndex!, version: runs._guts.version)
}

public var endIndex: Index {
Index(runs.endIndex._stringIndex!)
Index(runs.endIndex._stringIndex!, version: runs._guts.version)
}

public func index(before i: Index) -> Index {
Expand Down Expand Up @@ -223,11 +223,11 @@ extension AttributedString.Runs {
}

public var startIndex: Index {
Index(runs.startIndex._stringIndex!)
Index(runs.startIndex._stringIndex!, version: runs._guts.version)
}

public var endIndex: Index {
Index(runs.endIndex._stringIndex!)
Index(runs.endIndex._stringIndex!, version: runs._guts.version)
}

public func index(before i: Index) -> Index {
Expand Down Expand Up @@ -385,11 +385,11 @@ extension AttributedString.Runs {
}

public var startIndex: Index {
Index(runs.startIndex._stringIndex!)
Index(runs.startIndex._stringIndex!, version: runs._guts.version)
}

public var endIndex: Index {
Index(runs.endIndex._stringIndex!)
Index(runs.endIndex._stringIndex!, version: runs._guts.version)
}

public func index(before i: Index) -> Index {
Expand Down Expand Up @@ -557,11 +557,11 @@ extension AttributedString.Runs {
}

public var startIndex: Index {
Index(runs.startIndex._stringIndex!)
Index(runs.startIndex._stringIndex!, version: runs._guts.version)
}

public var endIndex: Index {
Index(runs.endIndex._stringIndex!)
Index(runs.endIndex._stringIndex!, version: runs._guts.version)
}

public func index(before i: Index) -> Index {
Expand Down Expand Up @@ -745,11 +745,11 @@ extension AttributedString.Runs {
}

public var startIndex: Index {
Index(runs.startIndex._stringIndex!)
Index(runs.startIndex._stringIndex!, version: runs._guts.version)
}

public var endIndex: Index {
Index(runs.endIndex._stringIndex!)
Index(runs.endIndex._stringIndex!, version: runs._guts.version)
}

public func index(before i: Index) -> Index {
Expand Down Expand Up @@ -895,11 +895,11 @@ extension AttributedString.Runs {
}

public var startIndex: Index {
Index(_runs.startIndex._stringIndex!)
Index(_runs.startIndex._stringIndex!, version: _runs._guts.version)
}

public var endIndex: Index {
Index(_runs.endIndex._stringIndex!)
Index(_runs.endIndex._stringIndex!, version: _runs._guts.version)
}

public func index(before i: Index) -> Index {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ extension AttributedString.Runs.Run: CustomStringConvertible {
@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
extension AttributedString.Runs.Run {
public var range: Range<AttributedString.Index> {
let lower = AttributedString.Index(_range.lowerBound)
let upper = AttributedString.Index(_range.upperBound)
let lower = AttributedString.Index(_range.lowerBound, version: _guts.version)
let upper = AttributedString.Index(_range.upperBound, version: _guts.version)
return Range(uncheckedBounds: (lower, upper))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,22 +486,22 @@ extension AttributedString.Runs {
let currentRange = _strBounds.ranges[currentRangeIdx]
if strIndexEnd < currentRange.upperBound {
// The coalesced run ends within the current range, so just look for the next break in the coalesced run
return .init(_guts.string._firstConstraintBreak(in: i._value ..< strIndexEnd, with: constraints))
return .init(_guts.string._firstConstraintBreak(in: i._value ..< strIndexEnd, with: constraints), version: _guts.version)
} else {
// The coalesced run extends beyond our range
// First determine if there's a constraint break to handle
let constraintBreak = _guts.string._firstConstraintBreak(in: i._value ..< currentRange.upperBound, with: constraints)
if constraintBreak == currentRange.upperBound {
if endOfCurrent { return .init(currentRange.upperBound) }
if endOfCurrent { return .init(currentRange.upperBound, version: _guts.version) }
// No constraint break, return the next subrange start or the end index
if currentRangeIdx == _strBounds.ranges.count - 1 {
return .init(currentRange.upperBound)
return .init(currentRange.upperBound, version: _guts.version)
} else {
return .init(_strBounds.ranges[currentRangeIdx + 1].lowerBound)
return .init(_strBounds.ranges[currentRangeIdx + 1].lowerBound, version: _guts.version)
}
} else {
// There is a constraint break before the end of the subrange, so return that break
return .init(constraintBreak)
return .init(constraintBreak, version: _guts.version)
}
}

Expand Down Expand Up @@ -533,18 +533,18 @@ extension AttributedString.Runs {
currentRangeIdx -= 1
currentRange = _strBounds.ranges[currentRangeIdx]
currentStringIdx = currentRange.upperBound
if endOfPrevious { return .init(currentStringIdx) }
if endOfPrevious { return .init(currentStringIdx, version: _guts.version) }
}
let beforeStringIdx = _guts.string.utf8.index(before: currentStringIdx)
let r = _guts.runs.index(atUTF8Offset: beforeStringIdx.utf8Offset)
let startRun = _firstOfMatchingRuns(with: r.index, comparing: attributeNames)
if startRun.utf8Offset >= currentRange.lowerBound.utf8Offset {
// The coalesced run begins within the current range, so just look for the next break in the coalesced run
let runStartStringIdx = _guts.string.utf8.index(beforeStringIdx, offsetBy: startRun.utf8Offset - beforeStringIdx.utf8Offset)
return .init(_guts.string._lastConstraintBreak(in: runStartStringIdx ..< currentStringIdx, with: constraints))
return .init(_guts.string._lastConstraintBreak(in: runStartStringIdx ..< currentStringIdx, with: constraints), version: _guts.version)
} else {
// The coalesced run starts before the current range, and we've already looked back once so we shouldn't look back again
return .init(_guts.string._lastConstraintBreak(in: currentRange.lowerBound ..< currentStringIdx, with: constraints))
return .init(_guts.string._lastConstraintBreak(in: currentRange.lowerBound ..< currentStringIdx, with: constraints), version: _guts.version)
}
}

Expand All @@ -569,7 +569,7 @@ extension AttributedString.Runs {

let j = _guts.string.unicodeScalars.index(after: i._value)
let last = _guts.string._lastConstraintBreak(in: stringStart ..< j, with: constraints)
return (.init(last), r.runIndex)
return (.init(last, version: _guts.version), r.runIndex)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@ extension AttributedString.UTF16View: BidirectionalCollection {
public typealias Subsequence = Self

public var startIndex: AttributedString.Index {
.init(_range.lowerBound)
.init(_range.lowerBound, version: _guts.version)
}

public var endIndex: AttributedString.Index {
.init(_range.upperBound)
.init(_range.upperBound, version: _guts.version)
}

public var count: Int {
Expand All @@ -74,21 +74,21 @@ extension AttributedString.UTF16View: BidirectionalCollection {

public func index(before i: AttributedString.Index) -> AttributedString.Index {
precondition(i > startIndex && i <= endIndex, "AttributedString index out of bounds")
let j = Index(_guts.string.utf16.index(before: i._value))
let j = Index(_guts.string.utf16.index(before: i._value), version: _guts.version)
precondition(j >= startIndex, "Can't advance AttributedString index before start index")
return j
}

public func index(after i: AttributedString.Index) -> AttributedString.Index {
precondition(i >= startIndex && i < endIndex, "AttributedString index out of bounds")
let j = Index(_guts.string.utf16.index(after: i._value))
let j = Index(_guts.string.utf16.index(after: i._value), version: _guts.version)
precondition(j <= endIndex, "Can't advance AttributedString index after end index")
return j
}

public func index(_ i: AttributedString.Index, offsetBy distance: Int) -> AttributedString.Index {
precondition(i >= startIndex && i <= endIndex, "AttributedString index out of bounds")
let j = Index(_guts.string.utf16.index(i._value, offsetBy: distance))
let j = Index(_guts.string.utf16.index(i._value, offsetBy: distance), version: _guts.version)
precondition(j >= startIndex && j <= endIndex, "AttributedString index out of bounds")
return j
}
Expand All @@ -107,7 +107,7 @@ extension AttributedString.UTF16View: BidirectionalCollection {
}
precondition(j >= startIndex._value && j <= endIndex._value,
"AttributedString index out of bounds")
return Index(j)
return Index(j, version: _guts.version)
}

public func distance(
Expand Down
Loading