diff --git a/Sources/FoundationEssentials/AttributedString/AttributedString+Guts.swift b/Sources/FoundationEssentials/AttributedString/AttributedString+Guts.swift index 030ce9cf2..d27b2d70f 100644 --- a/Sources/FoundationEssentials/AttributedString/AttributedString+Guts.swift +++ b/Sources/FoundationEssentials/AttributedString/AttributedString+Guts.swift @@ -31,12 +31,14 @@ extension AttributedString { var string: BigString var runs: _InternalRuns + var trackedRanges: [Range] // 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.string = string self.runs = runs + self.trackedRanges = [] } // 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 { func _prepareStringMutation( in range: Range - ) -> (oldUTF8Count: Int, invalidationRange: Range) { + ) -> (mutationStartUTF8Offset: Int, isInsertion: Bool, oldUTF8Count: Int, invalidationRange: Range) { let utf8TargetRange = range._utf8OffsetRange let invalidationRange = self.enforceAttributeConstraintsBeforeMutation(to: utf8TargetRange) + self._prepareTrackedIndicesUpdate(mutationRange: range) assert(invalidationRange.lowerBound <= utf8TargetRange.lowerBound) assert(invalidationRange.upperBound >= utf8TargetRange.upperBound) - return (self.string.utf8.count, invalidationRange) + return (utf8TargetRange.lowerBound, utf8TargetRange.isEmpty, self.string.utf8.count, invalidationRange) } func _finalizeStringMutation( - _ state: (oldUTF8Count: Int, invalidationRange: Range) + _ state: (mutationStartUTF8Offset: Int, isInsertion: Bool, oldUTF8Count: Int, invalidationRange: Range) ) { let utf8Delta = self.string.utf8.count - state.oldUTF8Count + self._finalizeTrackedIndicesUpdate(mutationStartOffset: state.mutationStartUTF8Offset, isInsertion: state.isInsertion, utf8LengthDelta: utf8Delta) let lower = state.invalidationRange.lowerBound let upper = state.invalidationRange.upperBound + utf8Delta self.enforceAttributeConstraintsAfterMutation( diff --git a/Sources/FoundationEssentials/AttributedString/AttributedString+IndexTracking.swift b/Sources/FoundationEssentials/AttributedString/AttributedString+IndexTracking.swift new file mode 100644 index 000000000..9670effa7 --- /dev/null +++ b/Sources/FoundationEssentials/AttributedString/AttributedString+IndexTracking.swift @@ -0,0 +1,131 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 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 FOUNDATION_FRAMEWORK +@_spi(Unstable) internal import CollectionsInternal +#elseif canImport(_RopeModule) +internal import _RopeModule +#elseif canImport(_FoundationCollections) +internal import _FoundationCollections +#endif + +// MARK: - Internal Index Updating + +extension AttributedString.Guts { + func _prepareTrackedIndicesUpdate(mutationRange: Range) { + // 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 + for idx in 0 ..< trackedRanges.count { + let lowerBoundWithinMutation = trackedRanges[idx].lowerBound > mutationRange.lowerBound && trackedRanges[idx].lowerBound < mutationRange.upperBound + let upperBoundWithinMutation = trackedRanges[idx].upperBound > mutationRange.lowerBound && trackedRanges[idx].upperBound < mutationRange.upperBound + switch (lowerBoundWithinMutation, upperBoundWithinMutation) { + case (true, true): + // Range is fully within mutation, collapse it to the start of the mutation + trackedRanges[idx] = Range(uncheckedBounds: (mutationRange.lowerBound, mutationRange.lowerBound)) + case (true, false): + // Range starts within mutation but extends beyond mutation - remove portion within mutation + trackedRanges[idx] = Range(uncheckedBounds: (mutationRange.upperBound, trackedRanges[idx].upperBound)) + case (false, true): + // Range starts before mutation but extends into mutation - remove portion within mutation + trackedRanges[idx] = Range(uncheckedBounds: (trackedRanges[idx].lowerBound, mutationRange.lowerBound)) + case (false, false): + // Neither endpoint of range is within mutation, leave as-is + break + } + } + } + + func _finalizeTrackedIndicesUpdate(mutationStartOffset: Int, isInsertion: Bool, utf8LengthDelta: Int) { + // Update indices to point to the correct offsets based on the mutation deltas + for idx in 0 ..< trackedRanges.count { + var lowerBound = trackedRanges[idx].lowerBound + var upperBound = trackedRanges[idx].upperBound + + // Shift the lower bound if either: + // A) The lower bound is greater than the start of the mutation (meaning it must be after the mutation due to the prepare step) + // 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) + if lowerBound.utf8Offset > mutationStartOffset || (lowerBound.utf8Offset == mutationStartOffset && isInsertion), utf8LengthDelta != 0 { + lowerBound = string.utf8.index(string.startIndex, offsetBy: lowerBound.utf8Offset + utf8LengthDelta) + } else { + // Form new indices even if the offsets don't change to ensure the indices are valid in the newly-mutated rope + string.formIndex(&lowerBound, offsetBy: 0) + } + // Shift the upper bound if either: + // - The upper bound is greater than the start of the mutation (meaning it must be after the mutation due to the prepare step) + // - 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 + if upperBound.utf8Offset > mutationStartOffset || lowerBound != trackedRanges[idx].lowerBound, utf8LengthDelta != 0 { + upperBound = string.utf8.index(string.startIndex, offsetBy: upperBound.utf8Offset + utf8LengthDelta) + } else { + // Form new indices even if the offsets don't change to ensure the indices are valid in the newly-mutated rope + string.formIndex(&lowerBound, offsetBy: 0) + } + + trackedRanges[idx] = Range(uncheckedBounds: (lowerBound, upperBound)) + } + } +} + +// MARK: - Public API + +@available(FoundationPreview 6.2, *) +extension AttributedString { + /// 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 + /// - Parameters: + /// - range: a range to track throughout the `mutation` block + /// - mutation: a mutating operation, or set of operations, to perform on this `AttributedString` + /// - 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) + public mutating func transform(updating range: Range, mutation: (inout AttributedString) throws(E) -> Void) throws(E) -> Range? { + try self.transform(updating: [range], mutation: mutation)?.first + } + + /// 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 + /// - Parameters: + /// - index: an index to track throughout the `mutation` block + /// - mutation: a mutating operation, or set of operations, to perform on this `AttributedString` + /// - 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. + public mutating func transform(updating ranges: [Range], mutation: (inout AttributedString) throws(E) -> Void) throws(E) -> [Range]? { + precondition(!ranges.isEmpty, "Cannot update an empty array of ranges") + + // Ensure we are uniquely referenced and mutate the tracked ranges to include the new ranges + ensureUniqueReference() + let originalCount = _guts.trackedRanges.count + for range in ranges { + precondition(range.lowerBound >= self.startIndex && range.lowerBound <= self.endIndex && range.upperBound >= self.startIndex && range.upperBound <= self.endIndex, "AttributedString index is out of bounds") + _guts.trackedRanges.append(range._bstringRange) + } + + // Ensure cleanup is performed regardless of whether the mutation closure throws or succeeds + defer { + // 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) + ensureUniqueReference() + + + // If the `trackedRanges` state is inconsistent, tracking has been lost - simply return nil to indicate ranges are no longer available + if _guts.trackedRanges.count != originalCount + ranges.count { + // Clear the ranges to prevent any future lingering issues with this AttributedString + _guts.trackedRanges = [] + } else { + // Tracking state is consistent, so remove only the ranges we added earlier (to support recursive tracking) + _guts.trackedRanges.removeSubrange(originalCount...) + } + } + + try mutation(&self) + + guard _guts.trackedRanges.count == originalCount + ranges.count else { + // Tracking state is inconsistent - return nil (defer block will handle cleanup) + return nil + } + + // Return the (mapped) array of ranges added above (defer block will handle cleanup) + return _guts.trackedRanges.suffix(from: originalCount).map(\._attrStrRange) + } +} diff --git a/Sources/FoundationEssentials/AttributedString/AttributedString.swift b/Sources/FoundationEssentials/AttributedString/AttributedString.swift index 568f4d1fa..7a0b3d441 100644 --- a/Sources/FoundationEssentials/AttributedString/AttributedString.swift +++ b/Sources/FoundationEssentials/AttributedString/AttributedString.swift @@ -377,4 +377,8 @@ extension Range where Bound == BigString.Index { internal var _utf8OffsetRange: Range { Range(uncheckedBounds: (lowerBound.utf8Offset, upperBound.utf8Offset)) } + + internal var _attrStrRange: Range { + Range(uncheckedBounds: (.init(lowerBound), .init(upperBound))) + } } diff --git a/Sources/FoundationEssentials/AttributedString/CMakeLists.txt b/Sources/FoundationEssentials/AttributedString/CMakeLists.txt index aa77795a0..8eba443f4 100644 --- a/Sources/FoundationEssentials/AttributedString/CMakeLists.txt +++ b/Sources/FoundationEssentials/AttributedString/CMakeLists.txt @@ -17,6 +17,7 @@ target_sources(FoundationEssentials PRIVATE AttributedString+AttributeTransformation.swift AttributedString+CharacterView.swift AttributedString+Guts.swift + AttributedString+IndexTracking.swift AttributedString+Runs+AttributeSlices.swift AttributedString+Runs+Run.swift AttributedString+Runs.swift diff --git a/Tests/FoundationEssentialsTests/AttributedString/AttributedStringCOWTests.swift b/Tests/FoundationEssentialsTests/AttributedString/AttributedStringCOWTests.swift index 82084cd2e..1498114b0 100644 --- a/Tests/FoundationEssentialsTests/AttributedString/AttributedStringCOWTests.swift +++ b/Tests/FoundationEssentialsTests/AttributedString/AttributedStringCOWTests.swift @@ -45,6 +45,14 @@ final class TestAttributedStringCOW: XCTestCase { XCTAssertNotEqual(str, copy, "Mutation operation did not copy when multiple references exist", file: file, line: line) } + func assertCOWCopyManual(file: StaticString = #filePath, line: UInt = #line, _ operation: (inout AttributedString) -> Void) { + var str = createAttributedString() + let gutsPtr = Unmanaged.passUnretained(str._guts) + operation(&str) + let newGutsPtr = Unmanaged.passUnretained(str._guts) + XCTAssertNotEqual(gutsPtr.toOpaque(), newGutsPtr.toOpaque(), "Mutation operation with manual copy did not perform copy", file: file, line: line) + } + func assertCOWNoCopy(file: StaticString = #filePath, line: UInt = #line, _ operation: (inout AttributedString) -> Void) { var str = createAttributedString() let gutsPtr = Unmanaged.passUnretained(str._guts) @@ -169,4 +177,60 @@ final class TestAttributedStringCOW: XCTestCase { $0[makeSubrange($0)].genericSetAttribute() } } + + func testIndexTracking() { + assertCOWBehavior { + _ = $0.transform(updating: $0.startIndex ..< $0.endIndex) { + $0.testInt = 2 + } + } + assertCOWBehavior { + _ = $0.transform(updating: $0.startIndex ..< $0.endIndex) { + $0.insert(AttributedString("_"), at: $0.startIndex) + } + } + assertCOWBehavior { + _ = $0.transform(updating: [$0.startIndex ..< $0.endIndex]) { + $0.testInt = 2 + } + } + assertCOWBehavior { + _ = $0.transform(updating: [$0.startIndex ..< $0.endIndex]) { + $0.insert(AttributedString("_"), at: $0.startIndex) + } + } + + // Ensure that creating a reference in the transformation closure still causes a copy to happen during post-mutation index updates + var storage = AttributedString() + assertCOWCopyManual { + _ = $0.transform(updating: $0.startIndex ..< $0.endIndex) { + $0.insert(AttributedString("_"), at: $0.startIndex) + // Store a reference after performing the mutation so the mutation doesn't cause an inherent copy + storage = $0 + } + } + XCTAssertNotEqual(storage, "") + + storage = AttributedString() + assertCOWCopyManual { + _ = try? $0.transform(updating: $0.startIndex ..< $0.endIndex) { + $0.insert(AttributedString("_"), at: $0.startIndex) + // Store a reference after performing the mutation so the mutation doesn't cause an inherent copy + storage = $0 + throw CocoaError(.fileReadUnknown) + } + } + XCTAssertNotEqual(storage, "") + + storage = AttributedString() + assertCOWCopyManual { + _ = try? $0.transform(updating: $0.startIndex ..< $0.endIndex) { + $0.insert(AttributedString("_"), at: $0.startIndex) + // Store a reference after performing the mutation so the mutation doesn't cause an inherent copy + storage = $0 + throw CocoaError(.fileReadUnknown) + } + } + XCTAssertNotEqual(storage, "") + } } diff --git a/Tests/FoundationEssentialsTests/AttributedString/AttributedStringIndexTrackingTests.swift b/Tests/FoundationEssentialsTests/AttributedString/AttributedStringIndexTrackingTests.swift new file mode 100644 index 000000000..4c1875232 --- /dev/null +++ b/Tests/FoundationEssentialsTests/AttributedString/AttributedStringIndexTrackingTests.swift @@ -0,0 +1,213 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2020 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(TestSupport) +import TestSupport +#endif + +final class AttributedStringIndexTrackingTests: XCTestCase { + func testBasic() throws { + var text = AttributedString("ABC. Hello, world!") + let original = text + let helloRange = try XCTUnwrap(text.range(of: "Hello")) + let worldRange = try XCTUnwrap(text.range(of: "world")) + + let updatedRanges = try XCTUnwrap(text.transform(updating: [helloRange, worldRange]) { + $0.insert(AttributedString("Goodbye. "), at: $0.startIndex) + }) + + XCTAssertEqual(updatedRanges.count, 2) + XCTAssertEqual(text[updatedRanges[0]], original[helloRange]) + XCTAssertEqual(text[updatedRanges[1]], original[worldRange]) + } + + func testInsertionWithinRange() throws { + var text = AttributedString("Hello, world") + let helloRange = try XCTUnwrap(text.range(of: "Hello")) + + let updatedRange = try XCTUnwrap(text.transform(updating: helloRange) { + $0.insert(AttributedString("_Goodbye_"), at: $0.index($0.startIndex, offsetByCharacters: 3)) + }) + + XCTAssertEqual(String(text[updatedRange].characters), "Hel_Goodbye_lo") + } + + func testInsertionAtStartOfRange() throws { + var text = AttributedString("Hello, world") + let helloRange = try XCTUnwrap(text.range(of: "llo")) + + let updatedRange = try XCTUnwrap(text.transform(updating: helloRange) { + $0.insert(AttributedString("_"), at: helloRange.lowerBound) + }) + + XCTAssertEqual(String(text[updatedRange].characters), "llo") + } + + func testInsertionAtEndOfRange() throws { + var text = AttributedString("Hello, world") + let helloRange = try XCTUnwrap(text.range(of: "llo")) + + let updatedRange = try XCTUnwrap(text.transform(updating: helloRange) { + $0.insert(AttributedString("_"), at: helloRange.upperBound) + }) + + XCTAssertEqual(String(text[updatedRange].characters), "llo") + } + + func testInsertionAtEmptyRange() throws { + var text = AttributedString("ABCDE") + let idx = text.index(text.startIndex, offsetByCharacters: 3) + + let updatedRange = try XCTUnwrap(text.transform(updating: idx ..< idx) { + $0.insert(AttributedString("_"), at: idx) + }) + + XCTAssertEqual(updatedRange.lowerBound, updatedRange.upperBound) + XCTAssertEqual(text.characters[updatedRange.lowerBound], "D") + } + + func testRemovalWithinRange() throws { + var text = AttributedString("Hello, world") + let helloRange = try XCTUnwrap(text.range(of: "Hello")) + + let updatedRange = try XCTUnwrap(text.transform(updating: helloRange) { + $0.removeSubrange(try XCTUnwrap($0.range(of: "ll"))) + }) + + XCTAssertEqual(String(text[updatedRange].characters), "Heo") + } + + func testFullCollapse() throws { + do { + var text = AttributedString("Hello, world") + let helloRange = try XCTUnwrap(text.range(of: "Hello")) + + let updatedRange = try XCTUnwrap(text.transform(updating: helloRange) { + $0.removeSubrange($0.startIndex ..< $0.endIndex) + }) + + XCTAssertEqual(String(text[updatedRange].characters), "") + } + + do { + var text = AttributedString("Hello, world") + let helloRange = try XCTUnwrap(text.range(of: "Hello")) + + let updatedRange = try XCTUnwrap(text.transform(updating: helloRange) { + $0.removeSubrange(helloRange) + }) + + XCTAssertEqual(String(text[updatedRange].characters), "") + } + + do { + var text = AttributedString("Hello, world") + let helloRange = try XCTUnwrap(text.range(of: ", ")) + + let updatedRange = try XCTUnwrap(text.transform(updating: helloRange) { + $0.removeSubrange(try XCTUnwrap($0.range(of: "o, w"))) + }) + + XCTAssertEqual(String(text[updatedRange].characters), "") + let collapsedIdx = text.index(text.startIndex, offsetByCharacters: 4) + XCTAssertEqual(updatedRange, collapsedIdx ..< collapsedIdx) + } + } + + func testCollapseLeft() throws { + var text = AttributedString("Hello, world") + let helloRange = try XCTUnwrap(text.range(of: "Hello")) + + let updatedRange = try XCTUnwrap(text.transform(updating: helloRange) { + $0.removeSubrange(try XCTUnwrap($0.range(of: "llo, wo"))) + }) + + XCTAssertEqual(String(text[updatedRange].characters), "He") + } + + func testCollapseRight() throws { + var text = AttributedString("Hello, world") + let worldRange = try XCTUnwrap(text.range(of: "world")) + + let updatedRange = try XCTUnwrap(text.transform(updating: worldRange) { + $0.removeSubrange(try XCTUnwrap($0.range(of: "llo, wo"))) + }) + + XCTAssertEqual(String(text[updatedRange].characters), "rld") + } + + func testNesting() throws { + var text = AttributedString("Hello, world") + let helloRange = try XCTUnwrap(text.range(of: "Hello")) + let updatedHelloRange = try XCTUnwrap(text.transform(updating: [helloRange]) { + let worldRange = try XCTUnwrap($0.range(of: "world")) + let updatedWorldRange = try XCTUnwrap($0.transform(updating: [worldRange]) { + $0.removeSubrange(try XCTUnwrap($0.range(of: "llo, wo"))) + }) + XCTAssertEqual(updatedWorldRange.count, 1) + XCTAssertEqual(String($0[updatedWorldRange[0]].characters), "rld") + }) + XCTAssertEqual(updatedHelloRange.count, 1) + XCTAssertEqual(String(text[updatedHelloRange[0]].characters), "He") + } + + func testTrackingLost() throws { + let text = AttributedString("Hello, world") + let helloRange = try XCTUnwrap(text.range(of: "Hello")) + + do { + var copy = text + XCTAssertNil(copy.transform(updating: helloRange) { + $0 = AttributedString("Foo") + }) + } + + do { + var copy = text + XCTAssertNil(copy.transform(updating: helloRange) { + $0 = AttributedString("Hello world") + }) + } + + do { + var copy = text + XCTAssertNotNil(copy.transform(updating: helloRange) { + $0 = $0 + }) + } + + do { + var copy = text + XCTAssertNotNil(copy.transform(updating: helloRange) { + var reference = $0 + reference.testInt = 2 + $0 = $0 + }) + XCTAssertNil(copy.testInt) + } + } + + func testAttributeMutation() throws { + var text = AttributedString("Hello, world!") + let original = text + let helloRange = try XCTUnwrap(text.range(of: "Hello")) + let worldRange = try XCTUnwrap(text.range(of: "world")) + + let updatedRanges = try XCTUnwrap(text.transform(updating: [helloRange, worldRange]) { + $0.testInt = 2 + }) + + XCTAssertEqual(updatedRanges.count, 2) + XCTAssertEqual(AttributedString(text[updatedRanges[0]]), original[helloRange].settingAttributes(AttributeContainer.testInt(2))) + XCTAssertEqual(AttributedString(text[updatedRanges[1]]), original[worldRange].settingAttributes(AttributeContainer.testInt(2))) + } +}