Skip to content

Commit de6af53

Browse files
authored
AttributedString Index Validity APIs (#1177)
* AttributedString index validity APIs * Move canImport(Synchronization) into function body * Remove unnecessary bounds checks from validity expression * Update FOUNDATION_FRAMEWORK code to provide Index versions
1 parent c2d30f4 commit de6af53

16 files changed

+393
-73
lines changed

Sources/FoundationEssentials/AttributedString/AttributedString+CharacterView.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,11 @@ extension AttributedString.CharacterView: BidirectionalCollection {
106106
public typealias Index = AttributedString.Index
107107

108108
public var startIndex: AttributedString.Index {
109-
.init(_range.lowerBound)
109+
.init(_range.lowerBound, version: _guts.version)
110110
}
111111

112112
public var endIndex: AttributedString.Index {
113-
.init(_range.upperBound)
113+
.init(_range.upperBound, version: _guts.version)
114114
}
115115

116116
@_alwaysEmitIntoClient
@@ -131,14 +131,14 @@ extension AttributedString.CharacterView: BidirectionalCollection {
131131

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

139139
public func index(after i: AttributedString.Index) -> AttributedString.Index {
140140
precondition(i >= startIndex && i <= endIndex, "AttributedString index out of bounds")
141-
let j = Index(_guts.string.index(after: i._value))
141+
let j = Index(_guts.string.index(after: i._value), version: _guts.version)
142142
precondition(j <= endIndex, "Can't advance AttributedString index after end index")
143143
return j
144144
}
@@ -157,7 +157,7 @@ extension AttributedString.CharacterView: BidirectionalCollection {
157157
@usableFromInline
158158
internal func _index(_ i: AttributedString.Index, offsetBy distance: Int) -> AttributedString.Index {
159159
precondition(i >= startIndex && i <= endIndex, "AttributedString index out of bounds")
160-
let j = Index(_guts.string.index(i._value, offsetBy: distance))
160+
let j = Index(_guts.string.index(i._value, offsetBy: distance), version: _guts.version)
161161
precondition(j >= startIndex && j <= endIndex, "AttributedString index out of bounds")
162162
return j
163163
}
@@ -192,7 +192,7 @@ extension AttributedString.CharacterView: BidirectionalCollection {
192192
}
193193
precondition(j >= startIndex._value && j <= endIndex._value,
194194
"AttributedString index out of bounds")
195-
return Index(j)
195+
return Index(j, version: _guts.version)
196196
}
197197

198198
@_alwaysEmitIntoClient

Sources/FoundationEssentials/AttributedString/AttributedString+Guts.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@ extension AttributedString {
2929
typealias _AttributeValue = AttributedString._AttributeValue
3030
typealias _AttributeStorage = AttributedString._AttributeStorage
3131

32+
var version: Version
3233
var string: BigString
3334
var runs: _InternalRuns
3435

3536
// Note: the caller is responsible for performing attribute fix-ups if needed based on the source of the runs
3637
init(string: BigString, runs: _InternalRuns) {
3738
precondition(string.isEmpty == runs.isEmpty, "An empty attributed string should not contain any runs")
39+
self.version = Self.createNewVersion()
3840
self.string = string
3941
self.runs = runs
4042
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
#if canImport(Synchronization)
14+
internal import Synchronization
15+
#endif
16+
17+
extension AttributedString.Guts {
18+
typealias Version = UInt
19+
20+
#if canImport(Synchronization)
21+
private static let _nextVersion = Atomic<Version>(0)
22+
#else
23+
private static let _nextVersion = LockedState<Version>(initialState: 0)
24+
#endif
25+
26+
static func createNewVersion() -> Version {
27+
#if canImport(Synchronization)
28+
_nextVersion.wrappingAdd(1, ordering: .relaxed).oldValue
29+
#else
30+
_nextVersion.withLock { value in
31+
defer {
32+
value &+= 1
33+
}
34+
return value
35+
}
36+
#endif
37+
}
38+
39+
func incrementVersion() {
40+
self.version = Self.createNewVersion()
41+
}
42+
}
43+
44+
// MARK: - Public API
45+
46+
@available(FoundationPreview 6.2, *)
47+
extension AttributedString.Index {
48+
public func isValid(within text: some AttributedStringProtocol) -> Bool {
49+
self._version == text.__guts.version &&
50+
self >= text.startIndex &&
51+
self < text.endIndex
52+
}
53+
54+
public func isValid(within text: DiscontiguousAttributedSubstring) -> Bool {
55+
self._version == text._guts.version &&
56+
text._indices.contains(self._value)
57+
}
58+
}
59+
60+
@available(FoundationPreview 6.2, *)
61+
extension Range<AttributedString.Index> {
62+
public func isValid(within text: some AttributedStringProtocol) -> Bool {
63+
// Note: By nature of Range's lowerBound <= upperBound requirement, this is also sufficient to determine that lowerBound <= endIndex && upperBound >= startIndex
64+
self.lowerBound._version == text.__guts.version &&
65+
self.lowerBound >= text.startIndex &&
66+
self.upperBound._version == text.__guts.version &&
67+
self.upperBound <= text.endIndex
68+
}
69+
70+
public func isValid(within text: DiscontiguousAttributedSubstring) -> Bool {
71+
let endIndex = text._indices.ranges.last?.upperBound
72+
return self.lowerBound._version == text._guts.version &&
73+
(self.lowerBound._value == endIndex || text._indices.contains(self.lowerBound._value)) &&
74+
self.upperBound._version == text._guts.version &&
75+
(self.upperBound._value == endIndex || text._indices.contains(self.upperBound._value))
76+
}
77+
}
78+
79+
@available(FoundationPreview 6.2, *)
80+
extension RangeSet<AttributedString.Index> {
81+
public func isValid(within text: some AttributedStringProtocol) -> Bool {
82+
self.ranges.allSatisfy {
83+
$0.isValid(within: text)
84+
}
85+
}
86+
87+
public func isValid(within text: DiscontiguousAttributedSubstring) -> Bool {
88+
self.ranges.allSatisfy {
89+
$0.isValid(within: text)
90+
}
91+
}
92+
}

Sources/FoundationEssentials/AttributedString/AttributedString+Runs+AttributeSlices.swift

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,11 @@ extension AttributedString.Runs {
8585
}
8686

8787
public var startIndex: Index {
88-
Index(runs.startIndex._stringIndex!)
88+
Index(runs.startIndex._stringIndex!, version: runs._guts.version)
8989
}
9090

9191
public var endIndex: Index {
92-
Index(runs.endIndex._stringIndex!)
92+
Index(runs.endIndex._stringIndex!, version: runs._guts.version)
9393
}
9494

9595
public func index(before i: Index) -> Index {
@@ -223,11 +223,11 @@ extension AttributedString.Runs {
223223
}
224224

225225
public var startIndex: Index {
226-
Index(runs.startIndex._stringIndex!)
226+
Index(runs.startIndex._stringIndex!, version: runs._guts.version)
227227
}
228228

229229
public var endIndex: Index {
230-
Index(runs.endIndex._stringIndex!)
230+
Index(runs.endIndex._stringIndex!, version: runs._guts.version)
231231
}
232232

233233
public func index(before i: Index) -> Index {
@@ -385,11 +385,11 @@ extension AttributedString.Runs {
385385
}
386386

387387
public var startIndex: Index {
388-
Index(runs.startIndex._stringIndex!)
388+
Index(runs.startIndex._stringIndex!, version: runs._guts.version)
389389
}
390390

391391
public var endIndex: Index {
392-
Index(runs.endIndex._stringIndex!)
392+
Index(runs.endIndex._stringIndex!, version: runs._guts.version)
393393
}
394394

395395
public func index(before i: Index) -> Index {
@@ -557,11 +557,11 @@ extension AttributedString.Runs {
557557
}
558558

559559
public var startIndex: Index {
560-
Index(runs.startIndex._stringIndex!)
560+
Index(runs.startIndex._stringIndex!, version: runs._guts.version)
561561
}
562562

563563
public var endIndex: Index {
564-
Index(runs.endIndex._stringIndex!)
564+
Index(runs.endIndex._stringIndex!, version: runs._guts.version)
565565
}
566566

567567
public func index(before i: Index) -> Index {
@@ -745,11 +745,11 @@ extension AttributedString.Runs {
745745
}
746746

747747
public var startIndex: Index {
748-
Index(runs.startIndex._stringIndex!)
748+
Index(runs.startIndex._stringIndex!, version: runs._guts.version)
749749
}
750750

751751
public var endIndex: Index {
752-
Index(runs.endIndex._stringIndex!)
752+
Index(runs.endIndex._stringIndex!, version: runs._guts.version)
753753
}
754754

755755
public func index(before i: Index) -> Index {
@@ -895,11 +895,11 @@ extension AttributedString.Runs {
895895
}
896896

897897
public var startIndex: Index {
898-
Index(_runs.startIndex._stringIndex!)
898+
Index(_runs.startIndex._stringIndex!, version: _runs._guts.version)
899899
}
900900

901901
public var endIndex: Index {
902-
Index(_runs.endIndex._stringIndex!)
902+
Index(_runs.endIndex._stringIndex!, version: _runs._guts.version)
903903
}
904904

905905
public func index(before i: Index) -> Index {

Sources/FoundationEssentials/AttributedString/AttributedString+Runs+Run.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ extension AttributedString.Runs.Run: CustomStringConvertible {
6060
@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
6161
extension AttributedString.Runs.Run {
6262
public var range: Range<AttributedString.Index> {
63-
let lower = AttributedString.Index(_range.lowerBound)
64-
let upper = AttributedString.Index(_range.upperBound)
63+
let lower = AttributedString.Index(_range.lowerBound, version: _guts.version)
64+
let upper = AttributedString.Index(_range.upperBound, version: _guts.version)
6565
return Range(uncheckedBounds: (lower, upper))
6666
}
6767

Sources/FoundationEssentials/AttributedString/AttributedString+Runs.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -486,22 +486,22 @@ extension AttributedString.Runs {
486486
let currentRange = _strBounds.ranges[currentRangeIdx]
487487
if strIndexEnd < currentRange.upperBound {
488488
// The coalesced run ends within the current range, so just look for the next break in the coalesced run
489-
return .init(_guts.string._firstConstraintBreak(in: i._value ..< strIndexEnd, with: constraints))
489+
return .init(_guts.string._firstConstraintBreak(in: i._value ..< strIndexEnd, with: constraints), version: _guts.version)
490490
} else {
491491
// The coalesced run extends beyond our range
492492
// First determine if there's a constraint break to handle
493493
let constraintBreak = _guts.string._firstConstraintBreak(in: i._value ..< currentRange.upperBound, with: constraints)
494494
if constraintBreak == currentRange.upperBound {
495-
if endOfCurrent { return .init(currentRange.upperBound) }
495+
if endOfCurrent { return .init(currentRange.upperBound, version: _guts.version) }
496496
// No constraint break, return the next subrange start or the end index
497497
if currentRangeIdx == _strBounds.ranges.count - 1 {
498-
return .init(currentRange.upperBound)
498+
return .init(currentRange.upperBound, version: _guts.version)
499499
} else {
500-
return .init(_strBounds.ranges[currentRangeIdx + 1].lowerBound)
500+
return .init(_strBounds.ranges[currentRangeIdx + 1].lowerBound, version: _guts.version)
501501
}
502502
} else {
503503
// There is a constraint break before the end of the subrange, so return that break
504-
return .init(constraintBreak)
504+
return .init(constraintBreak, version: _guts.version)
505505
}
506506
}
507507

@@ -533,18 +533,18 @@ extension AttributedString.Runs {
533533
currentRangeIdx -= 1
534534
currentRange = _strBounds.ranges[currentRangeIdx]
535535
currentStringIdx = currentRange.upperBound
536-
if endOfPrevious { return .init(currentStringIdx) }
536+
if endOfPrevious { return .init(currentStringIdx, version: _guts.version) }
537537
}
538538
let beforeStringIdx = _guts.string.utf8.index(before: currentStringIdx)
539539
let r = _guts.runs.index(atUTF8Offset: beforeStringIdx.utf8Offset)
540540
let startRun = _firstOfMatchingRuns(with: r.index, comparing: attributeNames)
541541
if startRun.utf8Offset >= currentRange.lowerBound.utf8Offset {
542542
// The coalesced run begins within the current range, so just look for the next break in the coalesced run
543543
let runStartStringIdx = _guts.string.utf8.index(beforeStringIdx, offsetBy: startRun.utf8Offset - beforeStringIdx.utf8Offset)
544-
return .init(_guts.string._lastConstraintBreak(in: runStartStringIdx ..< currentStringIdx, with: constraints))
544+
return .init(_guts.string._lastConstraintBreak(in: runStartStringIdx ..< currentStringIdx, with: constraints), version: _guts.version)
545545
} else {
546546
// The coalesced run starts before the current range, and we've already looked back once so we shouldn't look back again
547-
return .init(_guts.string._lastConstraintBreak(in: currentRange.lowerBound ..< currentStringIdx, with: constraints))
547+
return .init(_guts.string._lastConstraintBreak(in: currentRange.lowerBound ..< currentStringIdx, with: constraints), version: _guts.version)
548548
}
549549
}
550550

@@ -569,7 +569,7 @@ extension AttributedString.Runs {
569569

570570
let j = _guts.string.unicodeScalars.index(after: i._value)
571571
let last = _guts.string._lastConstraintBreak(in: stringStart ..< j, with: constraints)
572-
return (.init(last), r.runIndex)
572+
return (.init(last, version: _guts.version), r.runIndex)
573573
}
574574
}
575575

Sources/FoundationEssentials/AttributedString/AttributedString+UTF16View.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,11 @@ extension AttributedString.UTF16View: BidirectionalCollection {
6161
public typealias Subsequence = Self
6262

6363
public var startIndex: AttributedString.Index {
64-
.init(_range.lowerBound)
64+
.init(_range.lowerBound, version: _guts.version)
6565
}
6666

6767
public var endIndex: AttributedString.Index {
68-
.init(_range.upperBound)
68+
.init(_range.upperBound, version: _guts.version)
6969
}
7070

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

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

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

8989
public func index(_ i: AttributedString.Index, offsetBy distance: Int) -> AttributedString.Index {
9090
precondition(i >= startIndex && i <= endIndex, "AttributedString index out of bounds")
91-
let j = Index(_guts.string.utf16.index(i._value, offsetBy: distance))
91+
let j = Index(_guts.string.utf16.index(i._value, offsetBy: distance), version: _guts.version)
9292
precondition(j >= startIndex && j <= endIndex, "AttributedString index out of bounds")
9393
return j
9494
}
@@ -107,7 +107,7 @@ extension AttributedString.UTF16View: BidirectionalCollection {
107107
}
108108
precondition(j >= startIndex._value && j <= endIndex._value,
109109
"AttributedString index out of bounds")
110-
return Index(j)
110+
return Index(j, version: _guts.version)
111111
}
112112

113113
public func distance(

0 commit comments

Comments
 (0)