Skip to content

Commit b0f8465

Browse files
committed
Merge swift-6: Generalize Buffer protocols over Range, Content, and Position
Migrate the buffer protocol hierarchy from concrete Foundation types to fully generic associated types, enabling non-String storage backends (e.g. ropes, attributed strings) and non-NSRange index schemes. - Bump to swift-tools-version 6.2 with Swift 6 language mode - Use SE-0470 isolated conformances for @mainactor Buffer - Add AsyncBuffer protocol for actor-based conformers - Promote Range and Location to generic associated types, then derive Location from Range.Position to make Range the single source of truth - Extract TextAnalysisCapable as a Buffer refinement for word/line ranges - Introduce BufferRange protocol with default shifted(by:)/contains(_:) via AdditiveArithmetic on Position - Introduce BufferContent protocol; constrain AsyncBuffer.Content to BufferContent<Range.Position> - Generalize write signatures from String to Content and relax Undoable's Base.Content == String constraint - Generalize BufferAccessFailure factories over any BufferRange - All 102 tests passing
2 parents 5da262f + f0c370e commit b0f8465

26 files changed

Lines changed: 838 additions & 274 deletions

Package.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
// swift-tools-version: 5.9
2-
// The swift-tools-version declares the minimum version of Swift required to build this package.
1+
// swift-tools-version: 6.2
32

43
import PackageDescription
54

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright © 2024 Christian Tietze. All rights reserved. Distributed under the MIT License.
2+
3+
/// Base protocol for asynchronous text buffer access, refined by ``Buffer`` for synchronous use.
4+
///
5+
/// Buffers are reference types (`AnyObject`) with mutable state. The primary associated type
6+
/// `Range` determines the range representation (e.g., `NSRange` for UTF-16 code unit offsets).
7+
/// `Location` is derived as `Range.Position`.
8+
///
9+
/// ## SIL Compiler Bug Workaround
10+
///
11+
/// Property-like requirements use method names (`getContent()`, `getRange()`, `getSelectedRange()`)
12+
/// instead of matching ``Buffer``'s property names. When `Buffer: AsyncBuffer` redeclares the same
13+
/// property names (e.g., both have `var range: Range`), SILGen incorrectly resolves **all** witness
14+
/// lookups through `AsyncBuffer`'s async entries, crashing the compiler with:
15+
///
16+
/// ```
17+
/// SIL verification failed: cannot call an async function from a non async function
18+
/// ```
19+
///
20+
/// Affected Swift versions: 6.2.3, 6.3-dev (2026-02-19).
21+
/// Bug report: <https://github.com/swiftlang/swift/issues/62221>
22+
///
23+
/// - TODO: Retry property-style requirements with Swift 6.4.
24+
public protocol AsyncBuffer<Range>: AnyObject {
25+
/// The range type used for addressing spans within the buffer.
26+
associatedtype Range: BufferRange
27+
28+
/// The position/index type, derived from ``Range``.
29+
typealias Location = Range.Position
30+
31+
/// The type used for length measurements, derived from ``Range``'s ``BufferRange/Position``.
32+
typealias Length = Range.Position
33+
34+
/// The content type for read and write operations. Conformers set this to their backing store type
35+
/// (e.g., `String`, `NSAttributedString`, rope).
36+
associatedtype Content: BufferContent<Range.Position>
37+
38+
/// Returns the full text content of the buffer.
39+
func getContent() async -> Content
40+
/// Returns the full range of the buffer's content.
41+
func getRange() async -> Range
42+
/// Returns the currently selected range.
43+
func getSelectedRange() async -> Range
44+
45+
/// Sets the selected range.
46+
func setSelectedRange(_ range: Range) async
47+
/// Returns the current insertion location (start of the selected range).
48+
func getInsertionLocation() async -> Location
49+
/// Sets the insertion location.
50+
func setInsertionLocation(_ location: Location) async
51+
52+
/// Changes the selected range.
53+
func select(_ range: Range) async
54+
/// Returns a character-wide slice of content at `location`.
55+
func character(at location: Location) async throws(BufferAccessFailure) -> Content
56+
/// Returns a slice of content within `subrange`.
57+
func content(in subrange: Range) async throws(BufferAccessFailure) -> Content
58+
/// Returns a character at `location` without bounds checking.
59+
func unsafeCharacter(at location: Location) async -> Content
60+
/// Inserts `content` at `location` without affecting the selected range.
61+
func insert(_ content: Content, at location: Location) async throws(BufferAccessFailure)
62+
/// Inserts `content` like typing at the current insertion location.
63+
func insert(_ content: Content) async throws(BufferAccessFailure)
64+
/// Deletes content in `deletedRange`.
65+
func delete(in deletedRange: Range) async throws(BufferAccessFailure)
66+
/// Replaces content in `replacementRange` with `content`.
67+
func replace(range replacementRange: Range, with content: Content) async throws(BufferAccessFailure)
68+
/// Wraps changes to `affectedRange` inside `block` to bundle updates.
69+
func modifying<T>(affectedRange: Range, _ block: () -> T) async throws(BufferAccessFailure) -> T
70+
}
71+
72+
import Foundation
73+
74+
extension AsyncBuffer {
75+
@inlinable
76+
public func getInsertionLocation() async -> Location {
77+
await getSelectedRange().location
78+
}
79+
80+
@inlinable
81+
public func setInsertionLocation(_ location: Location) async {
82+
await setSelectedRange(Range(location: location, length: 0))
83+
}
84+
85+
@inlinable
86+
public func select(_ range: Range) async {
87+
await setSelectedRange(range)
88+
}
89+
90+
@inlinable
91+
public func insert(_ content: Content) async throws(BufferAccessFailure) {
92+
try await replace(range: await getSelectedRange(), with: content)
93+
}
94+
95+
@inlinable
96+
public func character(at location: Location) async throws(BufferAccessFailure) -> Content {
97+
try await self.content(in: Range(location: location, length: 1))
98+
}
99+
100+
@inlinable
101+
public func getIsSelectingText() async -> Bool {
102+
await getSelectedRange().length > 0
103+
}
104+
}

Sources/TextBuffer/Buffer/Buffer+contains.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,18 @@
33
import Foundation // For inlining isSelectingText as long as Buffer.Range is a typealias
44

55
extension Buffer {
6+
/// Returns `true` if `range` lies within the buffer's bounds. Works for all ``BufferRange`` types.
67
@inlinable @inline(__always)
78
public func contains(
8-
range: Buffer.Range
9+
range: Range
910
) -> Bool {
1011
return self.range.contains(range)
1112
}
1213
}
14+
15+
extension AsyncBuffer {
16+
@inlinable
17+
public func contains(range: Range) async -> Bool {
18+
return await self.getRange().contains(range)
19+
}
20+
}

Sources/TextBuffer/Buffer/Buffer+wordRange.swift

Lines changed: 89 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -13,117 +13,107 @@ extension CharacterSet {
1313
static let nonWhitespaceOrNewlines: CharacterSet = .whitespacesAndNewlines.inverted
1414
}
1515

16-
extension Buffer {
17-
@inlinable
18-
public func wordRange(
19-
for baseRange: Buffer.Range
20-
) throws -> Buffer.Range {
21-
guard self.contains(range: baseRange)
22-
else { throw BufferAccessFailure.outOfRange(requested: baseRange, available: self.range) }
23-
24-
// This bridging overhead isn't ideal while we operate on `Swift.String` as the `Buffer.Content`. It makes NSRange-based string enumeration easier. As long as `wordRange(for:)` is used to apply commands on the user's behalf, we should be okay in practice even for longer document. Repeated calls to this function, e.g. in loops, could be a disaster, though. See commit d434030e6d9366941c5cc3fa9c6de860afb74710 for an approach that uses two while loops instead.
25-
let nsContent = (self.content as NSString)
26-
27-
func expanding(
28-
range searchRange: NSRange,
29-
upToCharactersFrom characterSet: CharacterSet
30-
) -> Buffer.Range {
31-
var expandedRange = searchRange
32-
expandedRange = expanding(range: expandedRange, upToCharactersFrom: characterSet, direction: .upstream)
33-
expandedRange = expanding(range: expandedRange, upToCharactersFrom: characterSet, direction: .downstream)
34-
return expandedRange
35-
}
16+
@usableFromInline
17+
func computeWordRange(
18+
for baseRange: NSRange,
19+
in nsContent: NSString,
20+
contentRange: NSRange
21+
) -> NSRange {
22+
func expanding(
23+
range searchRange: NSRange,
24+
upToCharactersFrom characterSet: CharacterSet
25+
) -> NSRange {
26+
var expandedRange = searchRange
27+
expandedRange = expanding(range: expandedRange, upToCharactersFrom: characterSet, direction: .upstream)
28+
expandedRange = expanding(range: expandedRange, upToCharactersFrom: characterSet, direction: .downstream)
29+
return expandedRange
30+
}
3631

37-
func expanding(
38-
range searchRange: NSRange,
39-
upToCharactersFrom characterSet: CharacterSet,
40-
direction: StringTraversalDirection
41-
) -> Buffer.Range {
42-
switch direction {
43-
case .upstream:
44-
let matchedLocation = nsContent.locationUpToCharacter(
45-
from: characterSet,
46-
direction: .upstream,
47-
in: self.range.prefix(upTo: searchRange)
48-
)
49-
return Buffer.Range(
50-
startLocation: matchedLocation ?? self.range.location, // If nothing was found, expand to start of the available range.
51-
endLocation: searchRange.endLocation
52-
)
53-
case .downstream:
54-
let matchedLocation = nsContent.locationUpToCharacter(
55-
from: characterSet,
56-
direction: .downstream,
57-
in: self.range.suffix(after: searchRange)
58-
)
59-
return Buffer.Range(
60-
startLocation: searchRange.location,
61-
endLocation: matchedLocation ?? self.range.endLocation // If nothing was found, expand to end of the available range.
62-
)
63-
}
32+
func expanding(
33+
range searchRange: NSRange,
34+
upToCharactersFrom characterSet: CharacterSet,
35+
direction: StringTraversalDirection
36+
) -> NSRange {
37+
switch direction {
38+
case .upstream:
39+
let matchedLocation = nsContent.locationUpToCharacter(
40+
from: characterSet,
41+
direction: .upstream,
42+
in: contentRange.prefix(upTo: searchRange)
43+
)
44+
return NSRange(
45+
startLocation: matchedLocation ?? contentRange.location,
46+
endLocation: searchRange.endLocation
47+
)
48+
case .downstream:
49+
let matchedLocation = nsContent.locationUpToCharacter(
50+
from: characterSet,
51+
direction: .downstream,
52+
in: contentRange.suffix(after: searchRange)
53+
)
54+
return NSRange(
55+
startLocation: searchRange.location,
56+
endLocation: matchedLocation ?? contentRange.endLocation
57+
)
6458
}
59+
}
6560

66-
func trimmingWhitespace(range: Buffer.Range) -> Buffer.Range {
67-
var result = range
61+
func trimmingWhitespace(range: NSRange) -> NSRange {
62+
var result = range
63+
64+
if let newEndLocation = nsContent.locationUpToCharacter(
65+
from: .nonWhitespaceOrNewlines,
66+
direction: .upstream,
67+
in: result.expanded(to: contentRange, direction: .upstream))
68+
{
69+
result = NSRange(
70+
startLocation: result.location,
71+
endLocation: max(newEndLocation, result.location)
72+
)
73+
}
6874

69-
// Trim trailing whitespace first, favoring upstream selection affinity, e.g. if `baseRange` is all whitespace.
70-
if let newEndLocation = nsContent.locationUpToCharacter(
71-
from: .nonWhitespaceOrNewlines,
72-
direction: .upstream,
73-
in: result.expanded(to: self.range, direction: .upstream))
74-
{
75-
result = Buffer.Range(
76-
startLocation: result.location,
77-
endLocation: max(newEndLocation, result.location) // If newEndLocation < location, the whole of searchRange is whitespace.
78-
)
79-
}
80-
81-
// Trim leading whitespace
82-
if let newStartLocation = nsContent.locationUpToCharacter(
83-
from: .nonWhitespaceOrNewlines,
84-
direction: .downstream,
85-
in: result.expanded(to: self.range, direction: .downstream))
86-
{
87-
result = Buffer.Range(
88-
startLocation: min(newStartLocation, result.endLocation), // If newStartLocation > endLocation, the whole searchRange is whitespace.
89-
endLocation: result.endLocation
90-
)
91-
}
92-
93-
return result
75+
if let newStartLocation = nsContent.locationUpToCharacter(
76+
from: .nonWhitespaceOrNewlines,
77+
direction: .downstream,
78+
in: result.expanded(to: contentRange, direction: .downstream))
79+
{
80+
result = NSRange(
81+
startLocation: min(newStartLocation, result.endLocation),
82+
endLocation: result.endLocation
83+
)
9484
}
9585

96-
func nonWhitespaceLocation(closestTo location: Buffer.Location) -> Buffer.Location? {
97-
let downstreamNonWhitespaceLocation = nsContent.locationUpToCharacter(from: .nonWhitespaceOrNewlines, direction: .downstream, in: self.range.suffix(after: location))
98-
let upstreamNonWhitespaceLocation = nsContent.locationUpToCharacter(from: .nonWhitespaceOrNewlines, direction: .upstream, in: self.range.prefix(upTo: location))
86+
return result
87+
}
9988

100-
// Prioritize look-behind over look-ahead iff the location is downstream of a non-whitespace character (non-whitespace to the left of it) and the look-ahead is further away.
101-
if let upstreamNonWhitespaceLocation,
102-
let downstreamNonWhitespaceLocation,
103-
(upstreamNonWhitespaceLocation ..< location).count == 0,
104-
downstreamNonWhitespaceLocation > location {
105-
return upstreamNonWhitespaceLocation
106-
}
89+
func nonWhitespaceLocation(closestTo location: Int) -> Int? {
90+
let downstreamNonWhitespaceLocation = nsContent.locationUpToCharacter(from: .nonWhitespaceOrNewlines, direction: .downstream, in: contentRange.suffix(after: location))
91+
let upstreamNonWhitespaceLocation = nsContent.locationUpToCharacter(from: .nonWhitespaceOrNewlines, direction: .upstream, in: contentRange.prefix(upTo: location))
10792

108-
return downstreamNonWhitespaceLocation ?? upstreamNonWhitespaceLocation
93+
if let upstreamNonWhitespaceLocation,
94+
let downstreamNonWhitespaceLocation,
95+
(upstreamNonWhitespaceLocation ..< location).count == 0,
96+
downstreamNonWhitespaceLocation > location {
97+
return upstreamNonWhitespaceLocation
10998
}
11099

111-
var resultRange = expanding(
112-
range: trimmingWhitespace(range: baseRange),
113-
upToCharactersFrom: wordBoundary
114-
)
100+
return downstreamNonWhitespaceLocation ?? upstreamNonWhitespaceLocation
101+
}
115102

116-
// If the result is an empty range, characters adjacent to the location were all `wordBoundary` characters. Then we need to try again with relaxed conditions, skipping over whitespace first. Try forward search, then backward.
117-
if resultRange.length == 0,
118-
let closestNonWhitespaceLocation = nonWhitespaceLocation(closestTo: resultRange.location) {
119-
resultRange = expanding(range: .init(location: closestNonWhitespaceLocation, length: 0), upToCharactersFrom: .whitespacesAndNewlines)
120-
}
103+
var resultRange = expanding(
104+
range: trimmingWhitespace(range: baseRange),
105+
upToCharactersFrom: wordBoundary
106+
)
121107

122-
// When the input range covered only whitespace and nothing was found, discard the resulting empty range in favor of the original.
123-
if resultRange.length == 0, resultRange != baseRange {
124-
return baseRange
125-
}
108+
if resultRange.length == 0,
109+
let closestNonWhitespaceLocation = nonWhitespaceLocation(closestTo: resultRange.location) {
110+
resultRange = expanding(range: .init(location: closestNonWhitespaceLocation, length: 0), upToCharactersFrom: .whitespacesAndNewlines)
111+
}
126112

127-
return resultRange
113+
if resultRange.length == 0, resultRange != baseRange {
114+
return baseRange
128115
}
116+
117+
return resultRange
129118
}
119+

0 commit comments

Comments
 (0)