diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d63020df..7283d2c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed a crash on macOS 26 and later when the editor redrew a diagnostic underline or search highlight whose text had been edited away. - Fixed a crash when an input method, dictation or Look Up asked the editor about text that had already been edited away. (#2339) +- Fixed crashes when the editor's layout, syntax highlighting or accessibility read text that a newer edit had already removed. (#2340) - The XLSX, MQL and SQL Import plugins linked to a documentation page that did not exist. They now point at Import & Export. ## [0.67.0] - 2026-08-21 diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index f2cb18cd7..63d1040f3 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -271,13 +271,21 @@ extension Highlighter: StyledRangeContainerDelegate { let storage = textView.textStorage + // The style runs are tracked against the storage's length as of the last edit, so a run can + // still name text that a newer edit removed. `setAttributes` raises for that rather than + // ignoring it, and this runs inside an editing transaction on the storage. + let storageLength = storage?.length ?? 0 var offset = range.location for run in styleContainer.runsIn(range: range) { - guard let range = NSRange(location: offset, length: run.length).intersection(range) else { + guard let runRange = NSRange(location: offset, length: run.length).intersection(range) else { continue } - storage?.setAttributes(attributeProvider.attributesFor(run.value?.capture), range: range) - offset += range.length + offset += runRange.length + guard let styledRange = runRange.resolved(inDocumentOfLength: storageLength), + styledRange == runRange else { + continue + } + storage?.setAttributes(attributeProvider.attributesFor(run.value?.capture), range: styledRange) } textView.textStorage.endEditing() diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift index 819e1d673..2a964839f 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift @@ -77,7 +77,12 @@ final class MinimapLineFragmentView: LineFragmentView { textStorage: NSTextStorage, fragmentRange: NSRange ) { - while position < max { + // The minimap runs its own layout manager, so its fragments can name text a newer edit has + // already removed. Every one of the reads below raises on an index past the storage. + let limit = Swift.min(max, textStorage.length) + position = Swift.max(position, 0) + + while position < limit { var longestRange: NSRange = .notFound defer { position = longestRange.max } @@ -85,7 +90,7 @@ final class MinimapLineFragmentView: LineFragmentView { .foregroundColor, at: position, longestEffectiveRange: &longestRange, - in: NSRange(start: position, end: max) + in: NSRange(start: position, end: limit) ) as? NSColor else { continue } diff --git a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+clamped.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+clamped.swift index 527b9f0b6..241614a97 100644 --- a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+clamped.swift +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+clamped.swift @@ -16,22 +16,4 @@ extension NSRange { let end = Swift.min(Swift.max(self.max, 0), length) return NSRange(location: start, length: Swift.max(0, end - start)) } - - /// Returns the range resolved against a document of `length`, or `nil` when it names no - /// position in that document. - /// - /// Use this for a range that came from outside the text view: an input service, an - /// accessibility client, or state stored before an edit. Those may send `NSNotFound`, a - /// negative value, or a length that overflows when added to the location, none of which - /// ``clamped(toLength:)`` can move inside the document, and the second of which traps when - /// `max` is computed. - func resolved(inDocumentOfLength length: Int) -> NSRange? { - guard location != NSNotFound, - location >= 0, - self.length >= 0, - location <= Int.max - self.length else { - return nil - } - return clamped(toLength: length) - } } diff --git a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+resolved.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+resolved.swift new file mode 100644 index 000000000..f905812c7 --- /dev/null +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+resolved.swift @@ -0,0 +1,26 @@ +// +// NSRange+resolved.swift +// CodeEditTextView +// + +import Foundation + +public extension NSRange { + /// Returns the range resolved against a document of `length`, or `nil` when it names no + /// position in that document. + /// + /// Use this for a range that came from outside the text view: an input service, an + /// accessibility client, or state stored before an edit. Those may send `NSNotFound`, a + /// negative value, or a length that overflows when added to the location, none of which + /// ``clamped(toLength:)`` can move inside the document, and the second of which traps when + /// `max` is computed. + func resolved(inDocumentOfLength length: Int) -> NSRange? { + guard location != NSNotFound, + location >= 0, + self.length >= 0, + location <= Int.max - self.length else { + return nil + } + return clamped(toLength: length) + } +} diff --git a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLine/TextLine.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLine/TextLine.swift index b9038a6f3..3f88b9e69 100644 --- a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -54,10 +54,17 @@ public final class TextLine: Identifiable, Equatable { markedRanges: MarkedRanges?, attachments: [AnyTextAttachment] ) { - let string = stringRef.attributedSubstring(from: range) + // The line storage can be longer than the string it indexes for as long as an edit is in + // flight, and slicing with a range from the far side of that raises. Leaving `needsLayout` + // set means the line is typeset again once the two agree. + guard let documentRange = range.resolved(inDocumentOfLength: stringRef.length), + documentRange == range else { + return + } + let string = stringRef.attributedSubstring(from: documentRange) let maxWidth = typesetter.typeset( string, - documentRange: range, + documentRange: documentRange, displayData: displayData, markedRanges: markedRanges, attachments: attachments diff --git a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Accessibility.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Accessibility.swift index 87ebe2f0a..717e4f19c 100644 --- a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Accessibility.swift +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Accessibility.swift @@ -138,7 +138,7 @@ extension TextView { } override open func accessibilityRange(for index: Int) -> NSRange { - guard index < documentRange.length else { return .notFound } + guard index >= 0, index < documentRange.length else { return .notFound } return textStorage.mutableString.rangeOfComposedCharacterSequence(at: index) } @@ -148,7 +148,8 @@ extension TextView { /// The line index for a given character offset. override open func accessibilityLine(for index: Int) -> Int { - guard index <= textStorage.length, + guard index >= 0, + index <= textStorage.length, let textLine = layoutManager.textLineForOffset(index) else { return -1 } diff --git a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/AccessibilityTests.swift b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/AccessibilityTests.swift index ed526054b..d7b674d06 100644 --- a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/AccessibilityTests.swift +++ b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/AccessibilityTests.swift @@ -96,6 +96,18 @@ struct AccessibilityTests { #expect(range == .notFound) } + /// An accessibility client computes an index against the document as it was, so it can send one + /// that is now negative. `rangeOfComposedCharacterSequence(at:)` raises for those. + @Test + func accessibilityRangeForNegativeIndex() { + #expect(textView.accessibilityRange(for: -1) == .notFound) + } + + @Test + func accessibilityLineForNegativeIndex() { + #expect(textView.accessibilityLine(for: -1) == -1) + } + // MARK: - Selection Tests @Test diff --git a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/TextLineDisplayRangeTests.swift b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/TextLineDisplayRangeTests.swift new file mode 100644 index 000000000..54e1324bb --- /dev/null +++ b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/TextLineDisplayRangeTests.swift @@ -0,0 +1,44 @@ +import AppKit +import Testing +@testable import CodeEditTextView + +/// The line storage is updated from the edited range and can be longer than the string it indexes +/// while an edit is still in flight. Slicing the storage with a range from the far side of that +/// raises `NSRangeException`, and this runs inside the layout pass. +@Suite() +struct TextLineDisplayRangeTests { + @Test() + @MainActor + func aLineWhoseRangeOutrunsTheStringIsLeftForTheNextLayoutPass() { + let storage = NSTextStorage(string: "Hello") + let line = TextLine() + + line.prepareForDisplay( + displayData: TextLine.DisplayData(maxWidth: 1_000, lineHeightMultiplier: 1.0, estimatedLineHeight: 14), + range: NSRange(location: 3, length: 40), + stringRef: storage, + markedRanges: nil, + attachments: [] + ) + + #expect(line.needsLayout(maxWidth: 1_000)) + #expect(line.lineFragments.isEmpty) + } + + @Test() + @MainActor + func aLineWhoseRangeFitsIsTypeset() { + let storage = NSTextStorage(string: "Hello") + let line = TextLine() + + line.prepareForDisplay( + displayData: TextLine.DisplayData(maxWidth: 1_000, lineHeightMultiplier: 1.0, estimatedLineHeight: 14), + range: NSRange(location: 0, length: 5), + stringRef: storage, + markedRanges: nil, + attachments: [] + ) + + #expect(line.lineFragments.isEmpty == false) + } +}