Skip to content
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,20 @@ 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 }

guard let foregroundColor = textStorage.attribute(
.foregroundColor,
at: position,
longestEffectiveRange: &longestRange,
in: NSRange(start: position, end: max)
in: NSRange(start: position, end: limit)
) as? NSColor else {
continue
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,17 @@
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(

Check warning on line 65 in LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLine/TextLine.swift

View workflow job for this annotation

GitHub Actions / Build for testing

initialization of immutable value 'maxWidth' was never used; consider replacing with assignment to '_' or removing it

Check warning on line 65 in LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLine/TextLine.swift

View workflow job for this annotation

GitHub Actions / Build for testing

constant 'maxWidth' inferred to have type '()', which may be unexpected
string,
documentRange: range,
documentRange: documentRange,
displayData: displayData,
markedRanges: markedRanges,
attachments: attachments
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading