Skip to content

Commit f068f07

Browse files
committed
AttributedString Index Tracking
1 parent 2162fc9 commit f068f07

File tree

6 files changed

+388
-3
lines changed

6 files changed

+388
-3
lines changed

Sources/FoundationEssentials/AttributedString/AttributedString+Guts.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,14 @@ extension AttributedString {
3131

3232
var string: BigString
3333
var runs: _InternalRuns
34+
var trackedRanges: [Range<BigString.Index>]
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")
3839
self.string = string
3940
self.runs = runs
41+
self.trackedRanges = []
4042
}
4143

4244
// Note: the caller is responsible for performing attribute fix-ups if needed based on the source of the runs
@@ -418,18 +420,20 @@ extension AttributedString.Guts {
418420

419421
func _prepareStringMutation(
420422
in range: Range<BigString.Index>
421-
) -> (oldUTF8Count: Int, invalidationRange: Range<Int>) {
423+
) -> (mutationStartUTF8Offset: Int, isInsertion: Bool, oldUTF8Count: Int, invalidationRange: Range<Int>) {
422424
let utf8TargetRange = range._utf8OffsetRange
423425
let invalidationRange = self.enforceAttributeConstraintsBeforeMutation(to: utf8TargetRange)
426+
self._prepareTrackedIndicesUpdate(mutationRange: range)
424427
assert(invalidationRange.lowerBound <= utf8TargetRange.lowerBound)
425428
assert(invalidationRange.upperBound >= utf8TargetRange.upperBound)
426-
return (self.string.utf8.count, invalidationRange)
429+
return (utf8TargetRange.lowerBound, utf8TargetRange.isEmpty, self.string.utf8.count, invalidationRange)
427430
}
428431

429432
func _finalizeStringMutation(
430-
_ state: (oldUTF8Count: Int, invalidationRange: Range<Int>)
433+
_ state: (mutationStartUTF8Offset: Int, isInsertion: Bool, oldUTF8Count: Int, invalidationRange: Range<Int>)
431434
) {
432435
let utf8Delta = self.string.utf8.count - state.oldUTF8Count
436+
self._finalizeTrackedIndicesUpdate(mutationStartOffset: state.mutationStartUTF8Offset, isInsertion: state.isInsertion, utf8LengthDelta: utf8Delta)
433437
let lower = state.invalidationRange.lowerBound
434438
let upper = state.invalidationRange.upperBound + utf8Delta
435439
self.enforceAttributeConstraintsAfterMutation(
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2024 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 FOUNDATION_FRAMEWORK
14+
@_spi(Unstable) internal import CollectionsInternal
15+
#elseif canImport(_RopeModule)
16+
internal import _RopeModule
17+
#elseif canImport(_FoundationCollections)
18+
internal import _FoundationCollections
19+
#endif
20+
21+
// MARK: - Internal Index Updating
22+
23+
extension AttributedString.Guts {
24+
func _prepareTrackedIndicesUpdate(mutationRange: Range<BigString.Index>) {
25+
// Move any range endpoints inside of the mutation range to outside of the mutation range since a range should never end up splitting a mutation
26+
for idx in 0 ..< trackedRanges.count {
27+
let lowerBoundWithinMutation = trackedRanges[idx].lowerBound > mutationRange.lowerBound && trackedRanges[idx].lowerBound < mutationRange.upperBound
28+
let upperBoundWithinMutation = trackedRanges[idx].upperBound > mutationRange.lowerBound && trackedRanges[idx].upperBound < mutationRange.upperBound
29+
switch (lowerBoundWithinMutation, upperBoundWithinMutation) {
30+
case (true, true):
31+
// Range is fully within mutation, collapse it to the start of the mutation
32+
trackedRanges[idx] = Range(uncheckedBounds: (mutationRange.lowerBound, mutationRange.lowerBound))
33+
case (true, false):
34+
// Range starts within mutation but extends beyond mutation - remove portion within mutation
35+
trackedRanges[idx] = Range(uncheckedBounds: (mutationRange.upperBound, trackedRanges[idx].upperBound))
36+
case (false, true):
37+
// Range starts before mutation but extends into mutation - remove portion within mutation
38+
trackedRanges[idx] = Range(uncheckedBounds: (trackedRanges[idx].lowerBound, mutationRange.lowerBound))
39+
case (false, false):
40+
// Neither endpoint of range is within mutation, leave as-is
41+
break
42+
}
43+
}
44+
}
45+
46+
func _finalizeTrackedIndicesUpdate(mutationStartOffset: Int, isInsertion: Bool, utf8LengthDelta: Int) {
47+
// Update indices to point to the correct offsets based on the mutation deltas
48+
for idx in 0 ..< trackedRanges.count {
49+
var lowerBound = trackedRanges[idx].lowerBound
50+
var upperBound = trackedRanges[idx].upperBound
51+
52+
// Shift the lower bound if either:
53+
// A) The lower bound is greater than the start of the mutation (meaning it must be after the mutation due to the prepare step)
54+
// B) The lower bound is equal to the start of the mutation, but the mutation is an insertion (meaning the text is inserted before the start offset)
55+
if lowerBound.utf8Offset > mutationStartOffset || (lowerBound.utf8Offset == mutationStartOffset && isInsertion), utf8LengthDelta != 0 {
56+
lowerBound = string.utf8.index(string.startIndex, offsetBy: lowerBound.utf8Offset + utf8LengthDelta)
57+
} else {
58+
// Form new indices even if the offsets don't change to ensure the indices are valid in the newly-mutated rope
59+
string.formIndex(&lowerBound, offsetBy: 0)
60+
}
61+
// Shift the upper bound if either:
62+
// - The upper bound is greater than the start of the mutation (meaning it must be after the mutation due to the prepare step)
63+
// - The lower bound is shifted in any way (which therefore requires the upper bound to be shifted). This is the case when the tracked range is empty and is at the location of an insertion mutation
64+
if upperBound.utf8Offset > mutationStartOffset || lowerBound != trackedRanges[idx].lowerBound, utf8LengthDelta != 0 {
65+
upperBound = string.utf8.index(string.startIndex, offsetBy: upperBound.utf8Offset + utf8LengthDelta)
66+
} else {
67+
// Form new indices even if the offsets don't change to ensure the indices are valid in the newly-mutated rope
68+
string.formIndex(&lowerBound, offsetBy: 0)
69+
}
70+
71+
trackedRanges[idx] = Range(uncheckedBounds: (lowerBound, upperBound))
72+
}
73+
}
74+
}
75+
76+
// MARK: - Public API
77+
78+
@available(FoundationPreview 6.2, *)
79+
extension AttributedString {
80+
/// Tracks the location of the provided range throughout the mutation closure, returning a new, updated range that represents the same effective locations after the mutation
81+
/// - Parameters:
82+
/// - range: a range to track throughout the `mutation` block
83+
/// - mutation: a mutating operation, or set of operations, to perform on this `AttributedString`
84+
/// - Returns: the updated `Range` that is valid after the mutation has been performed, or `nil` if the mutation performed does not allow for tracking to succeed (such as replacing the provided inout variable with an entirely different AttributedString)
85+
public mutating func transform<E>(updating range: Range<Index>, mutation: (inout AttributedString) throws(E) -> Void) throws(E) -> Range<Index>? {
86+
try self.transform(updating: [range], mutation: mutation)?.first
87+
}
88+
89+
/// Tracks the location of the provided ranges throughout the mutation closure, returning a new, updated range that represents the same effective locations after the mutation
90+
/// - Parameters:
91+
/// - index: an index to track throughout the `mutation` block
92+
/// - mutation: a mutating operation, or set of operations, to perform on this `AttributedString`
93+
/// - Returns: the updated `Range`s that is valid after the mutation has been performed, or `nil` if the mutation performed does not allow for tracking to succeed (such as replacing the provided inout variable with an entirely different AttributedString). When the return value is non-nil, the returned array is guaranteed to be the same size as the provided array with updated ranges at the same Array indices as their respective original ranges in the input array.
94+
public mutating func transform<E>(updating ranges: [Range<Index>], mutation: (inout AttributedString) throws(E) -> Void) throws(E) -> [Range<Index>]? {
95+
precondition(!ranges.isEmpty, "Cannot update an empty array of ranges")
96+
97+
// Ensure we are uniquely referenced and mutate the tracked ranges to include the new ranges
98+
ensureUniqueReference()
99+
let originalCount = _guts.trackedRanges.count
100+
for range in ranges {
101+
precondition(range.lowerBound >= self.startIndex && range.lowerBound <= self.endIndex && range.upperBound >= self.startIndex && range.upperBound <= self.endIndex, "AttributedString index is out of bounds")
102+
_guts.trackedRanges.append(range._bstringRange)
103+
}
104+
105+
// Perform the user-supplied mutation on `self`
106+
try mutation(&self)
107+
108+
// Ensure we are still uniquely referenced (it's possible we may have been uniquely referenced before, but the mutation closure created a new reference and we are no longer unique)
109+
ensureUniqueReference()
110+
111+
// If the `trackedRanges` state is inconsistent, tracking has been lost - simply return nil to indicate ranges are no longer available
112+
guard _guts.trackedRanges.count == originalCount + ranges.count else {
113+
return nil
114+
}
115+
116+
// Collect and remove updated ranges
117+
let resultingRanges = _guts.trackedRanges[originalCount...].map(\._attrStrRange)
118+
_guts.trackedRanges.removeSubrange(originalCount...)
119+
return resultingRanges
120+
}
121+
}

Sources/FoundationEssentials/AttributedString/AttributedString.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,4 +377,8 @@ extension Range where Bound == BigString.Index {
377377
internal var _utf8OffsetRange: Range<Int> {
378378
Range<Int>(uncheckedBounds: (lowerBound.utf8Offset, upperBound.utf8Offset))
379379
}
380+
381+
internal var _attrStrRange: Range<AttributedString.Index> {
382+
Range<AttributedString.Index>(uncheckedBounds: (.init(lowerBound), .init(upperBound)))
383+
}
380384
}

Sources/FoundationEssentials/AttributedString/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ target_sources(FoundationEssentials PRIVATE
1717
AttributedString+AttributeTransformation.swift
1818
AttributedString+CharacterView.swift
1919
AttributedString+Guts.swift
20+
AttributedString+IndexTracking.swift
2021
AttributedString+Runs+AttributeSlices.swift
2122
AttributedString+Runs+Run.swift
2223
AttributedString+Runs.swift

Tests/FoundationEssentialsTests/AttributedString/AttributedStringCOWTests.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ final class TestAttributedStringCOW: XCTestCase {
4545
XCTAssertNotEqual(str, copy, "Mutation operation did not copy when multiple references exist", file: file, line: line)
4646
}
4747

48+
func assertCOWCopyManual(file: StaticString = #filePath, line: UInt = #line, _ operation: (inout AttributedString) -> Void) {
49+
var str = createAttributedString()
50+
let gutsPtr = Unmanaged.passUnretained(str._guts)
51+
operation(&str)
52+
let newGutsPtr = Unmanaged.passUnretained(str._guts)
53+
XCTAssertNotEqual(gutsPtr.toOpaque(), newGutsPtr.toOpaque(), "Mutation operation with manual copy did not perform copy", file: file, line: line)
54+
}
55+
4856
func assertCOWNoCopy(file: StaticString = #filePath, line: UInt = #line, _ operation: (inout AttributedString) -> Void) {
4957
var str = createAttributedString()
5058
let gutsPtr = Unmanaged.passUnretained(str._guts)
@@ -169,4 +177,38 @@ final class TestAttributedStringCOW: XCTestCase {
169177
$0[makeSubrange($0)].genericSetAttribute()
170178
}
171179
}
180+
181+
func testIndexTracking() {
182+
assertCOWBehavior {
183+
_ = $0.transform(updating: $0.startIndex ..< $0.endIndex) {
184+
$0.testInt = 2
185+
}
186+
}
187+
assertCOWBehavior {
188+
_ = $0.transform(updating: $0.startIndex ..< $0.endIndex) {
189+
$0.insert(AttributedString("_"), at: $0.startIndex)
190+
}
191+
}
192+
assertCOWBehavior {
193+
_ = $0.transform(updating: [$0.startIndex ..< $0.endIndex]) {
194+
$0.testInt = 2
195+
}
196+
}
197+
assertCOWBehavior {
198+
_ = $0.transform(updating: [$0.startIndex ..< $0.endIndex]) {
199+
$0.insert(AttributedString("_"), at: $0.startIndex)
200+
}
201+
}
202+
203+
// Ensure that creating a reference in the transformation closure still causes a copy to happen during post-mutation index updates
204+
var storage = AttributedString()
205+
assertCOWCopyManual {
206+
_ = $0.transform(updating: $0.startIndex ..< $0.endIndex) {
207+
$0.insert(AttributedString("_"), at: $0.startIndex)
208+
// Store a reference after performing the mutation so the mutation doesn't cause an inherent copy
209+
storage = $0
210+
}
211+
}
212+
XCTAssertNotEqual(storage, "")
213+
}
172214
}

0 commit comments

Comments
 (0)