Skip to content

Commit 81745d8

Browse files
committed
Impl
1 parent f580ef5 commit 81745d8

File tree

16 files changed

+467
-32
lines changed

16 files changed

+467
-32
lines changed

CodeEdit.xcodeproj/project.pbxproj

Lines changed: 69 additions & 22 deletions
Large diffs are not rendered by default.

CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ final class CodeFileDocument: NSDocument, ObservableObject {
5353
/// Set by ``LanguageServer`` when initialized.
5454
@Published var lspCoordinator: LSPContentCoordinator?
5555

56+
/// Set by ``LanguageServer`` when initialized.
57+
@Published var lspHighlightProvider: SemanticTokenHighlightProvider<ConcreteSemanticTokenStorage>?
58+
5659
/// Used to override detected languages.
5760
@Published var language: CodeLanguage?
5861

CodeEdit/Features/Editor/Views/CodeFileView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ struct CodeFileView: View {
2222
/// Any coordinators passed to the view.
2323
private var textViewCoordinators: [TextViewCoordinator]
2424

25+
private var highlightProviders: [any HighlightProviding]
26+
2527
@AppSettings(\.textEditing.defaultTabWidth)
2628
var defaultTabWidth
2729
@AppSettings(\.textEditing.indentOption)
@@ -59,6 +61,7 @@ struct CodeFileView: View {
5961
self.textViewCoordinators = textViewCoordinators
6062
+ [codeFile.contentCoordinator]
6163
+ [codeFile.lspCoordinator].compactMap({ $0 })
64+
self.highlightProviders = [TreeSitterClient()] + [codeFile.lspHighlightProvider].compactMap({ $0 })
6265
self.isEditable = isEditable
6366

6467
if let openOptions = codeFile.openOptions {
@@ -129,6 +132,7 @@ struct CodeFileView: View {
129132
wrapLines: codeFile.wrapLines ?? wrapLinesToEditorWidth,
130133
cursorPositions: $cursorPositions,
131134
useThemeBackground: useThemeBackground,
135+
highlightProviders: highlightProviders,
132136
contentInsets: edgeInsets.nsEdgeInsets,
133137
isEditable: isEditable,
134138
letterSpacing: letterSpacing,
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//
2+
// SemanticTokenHighlightProvider.swift
3+
// CodeEdit
4+
//
5+
// Created by Khan Winter on 12/26/24.
6+
//
7+
8+
import Foundation
9+
import LanguageServerProtocol
10+
import CodeEditSourceEditor
11+
import CodeEditTextView
12+
import CodeEditLanguages
13+
14+
/// Provides semantic token information from a language server for a source editor view.
15+
///
16+
/// This class works in tangent with the ``LanguageServer`` class to ensure we don't unnecessarily request new tokens
17+
/// if the document isn't updated. The ``LanguageServer`` will call the
18+
/// ``SemanticTokenHighlightProvider/documentDidChange`` method, which in turn refreshes the semantic token storage.
19+
///
20+
/// That behavior may not be intuitive due to the
21+
/// ``SemanticTokenHighlightProvider/applyEdit(textView:range:delta:completion:)`` method. One might expect this class
22+
/// to respond to that method immediately, but it does not. It instead stores the completion passed in that method until
23+
/// it can respond to the edit with invalidated indices.
24+
final class SemanticTokenHighlightProvider<Storage: SemanticTokenStorage>: HighlightProviding {
25+
enum HighlightError: Error {
26+
case lspRangeFailure
27+
}
28+
29+
typealias EditCallback = @MainActor (Result<IndexSet, any Error>) -> Void
30+
31+
private let tokenMap: SemanticTokenMap
32+
private weak var languageServer: LanguageServer?
33+
private weak var textView: TextView?
34+
35+
private var lastEditCallback: EditCallback?
36+
private var storage: Storage
37+
38+
var documentRange: NSRange {
39+
textView?.documentRange ?? .zero
40+
}
41+
42+
init(tokenMap: SemanticTokenMap, languageServer: LanguageServer) {
43+
self.tokenMap = tokenMap
44+
self.languageServer = languageServer
45+
self.storage = Storage()
46+
}
47+
48+
func documentDidChange(documentURI: String) async throws {
49+
guard let languageServer, let textView, let lastEditCallback else { return }
50+
51+
// The document was updated. Update our cache and send the invalidated ranges for the editor to handle.
52+
if let lastRequestId = storage.lastRequestId {
53+
guard let response = try await languageServer.requestSemanticTokens( // Not sure why these are optional...
54+
for: documentURI,
55+
previousResultId: lastRequestId
56+
) else {
57+
return
58+
}
59+
switch response {
60+
case let .optionA(tokenData):
61+
await applyEntireResponse(tokenData, callback: lastEditCallback)
62+
case let .optionB(deltaData):
63+
await applyDeltaResponse(deltaData, callback: lastEditCallback, textView: textView)
64+
}
65+
} else {
66+
guard let response = try await languageServer.requestSemanticTokens(for: documentURI) else {
67+
return
68+
}
69+
await applyEntireResponse(response, callback: lastEditCallback)
70+
}
71+
}
72+
73+
func setUp(textView: TextView, codeLanguage: CodeLanguage) {
74+
// Send off a request to get the initial token data
75+
self.textView = textView
76+
}
77+
78+
func applyEdit(textView: TextView, range: NSRange, delta: Int, completion: @escaping EditCallback) {
79+
if let lastEditCallback {
80+
lastEditCallback(.success(IndexSet())) // Don't throw a cancellation error
81+
}
82+
lastEditCallback = completion
83+
}
84+
85+
func queryHighlightsFor(
86+
textView: TextView,
87+
range: NSRange,
88+
completion: @escaping @MainActor (Result<[HighlightRange], any Error>) -> Void
89+
) {
90+
guard let lspRange = textView.lspRangeFrom(nsRange: range) else {
91+
completion(.failure(HighlightError.lspRangeFailure))
92+
return
93+
}
94+
let rawTokens = storage.getTokensFor(range: lspRange)
95+
let highlights = tokenMap.decode(tokens: rawTokens, using: textView)
96+
completion(.success(highlights))
97+
}
98+
99+
// MARK: - Apply Response
100+
101+
private func applyDeltaResponse(_ data: SemanticTokensDelta, callback: EditCallback, textView: TextView?) async {
102+
let lspRanges = storage.applyDelta(data, requestId: data.resultId)
103+
await MainActor.run {
104+
let ranges = lspRanges.compactMap { textView?.nsRangeFrom($0) }
105+
callback(.success(IndexSet(ranges: ranges)))
106+
}
107+
lastEditCallback = nil // Don't use this callback again.
108+
}
109+
110+
private func applyEntireResponse(_ data: SemanticTokens, callback: EditCallback) async {
111+
storage.setData(data)
112+
await callback(.success(IndexSet(integersIn: documentRange)))
113+
lastEditCallback = nil // Don't use this callback again.
114+
}
115+
116+
}

CodeEdit/Features/LSP/Editor/SemanticTokenMap.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,23 @@ struct SemanticTokenMap: Sendable { // swiftlint:enable line_length
4545
/// Decodes the compressed semantic token data into a `HighlightRange` type for use in an editor.
4646
/// This is marked main actor to prevent runtime errors, due to the use of the actor-isolated `rangeProvider`.
4747
/// - Parameters:
48-
/// - tokens: Semantic tokens from a language server.
48+
/// - tokens: Encoded semantic tokens type from a language server.
4949
/// - rangeProvider: The provider to use to translate token ranges to text view ranges.
5050
/// - Returns: An array of decoded highlight ranges.
5151
@MainActor
5252
func decode(tokens: SemanticTokens, using rangeProvider: SemanticTokenMapRangeProvider) -> [HighlightRange] {
53-
tokens.decode().compactMap { token in
53+
return decode(tokens: tokens.decode(), using: rangeProvider)
54+
}
55+
56+
/// Decodes the compressed semantic token data into a `HighlightRange` type for use in an editor.
57+
/// This is marked main actor to prevent runtime errors, due to the use of the actor-isolated `rangeProvider`.
58+
/// - Parameters:
59+
/// - tokens: Decoded semantic tokens from a language server.
60+
/// - rangeProvider: The provider to use to translate token ranges to text view ranges.
61+
/// - Returns: An array of decoded highlight ranges.
62+
@MainActor
63+
func decode(tokens: [SemanticToken], using rangeProvider: SemanticTokenMapRangeProvider) -> [HighlightRange] {
64+
tokens.compactMap { token in
5465
guard let range = rangeProvider.nsRangeFrom(line: token.line, char: token.char, length: token.length) else {
5566
return nil
5667
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
//
2+
// ConcreteSemanticTokenStorage.swift
3+
// CodeEdit
4+
//
5+
// Created by Khan Winter on 12/26/24.
6+
//
7+
8+
import Foundation
9+
import LanguageServerProtocol
10+
import CodeEditSourceEditor
11+
12+
/// This class provides an efficient storage mechanism for semantic token data.
13+
///
14+
/// The LSP spec requires that clients keep the original compressed data to apply delta edits to. The delta updates may
15+
/// come as a delta to a single number in the compressed array. This class maintains a current state of compressed
16+
/// tokens and their decoded counterparts. It supports applying delta updates from the language server.
17+
///
18+
/// See ``SemanticTokenHighlightProvider`` for it's connection to the editor view.
19+
final class ConcreteSemanticTokenStorage: SemanticTokenStorage {
20+
struct CurrentState {
21+
let requestId: String?
22+
let tokenData: [UInt32]
23+
let tokens: [SemanticToken]
24+
}
25+
26+
var lastRequestId: String? {
27+
state?.requestId
28+
}
29+
30+
var state: CurrentState?
31+
32+
init() {
33+
state = nil
34+
}
35+
36+
// MARK: - Storage Conformance
37+
38+
func getTokensFor(range: LSPRange) -> [SemanticToken] {
39+
guard let state = state, !state.tokens.isEmpty else {
40+
return []
41+
}
42+
var tokens: [SemanticToken] = []
43+
44+
var idx = findIndex(of: range.start, data: state.tokens[...])
45+
while idx < state.tokens.count && state.tokens[idx].startPosition > range.end {
46+
tokens.append(state.tokens[idx])
47+
idx += 1
48+
}
49+
50+
return tokens
51+
}
52+
53+
func setData(_ data: borrowing SemanticTokens) {
54+
state = CurrentState(requestId: nil, tokenData: data.data, tokens: data.decode())
55+
}
56+
57+
/// Apply a delta object from a language server and returns all token ranges that may need re-drawing.
58+
///
59+
/// To calculate invalidated ranges:
60+
/// - Grabs all semantic tokens that *will* be updated and invalidates their ranges
61+
/// - Loops over all inserted tokens and invalidates their ranges
62+
/// This may result in duplicated ranges. It's up to the object using this method to de-duplicate if necessary.
63+
///
64+
/// - Parameter deltas: The deltas to apply.
65+
/// - Returns: All ranges invalidated by the applied deltas.
66+
func applyDelta(_ deltas: SemanticTokensDelta, requestId: String?) -> [SemanticTokenRange] {
67+
assert(state != nil, "State should be set before applying any deltas.")
68+
guard var tokenData = state?.tokenData else { return [] }
69+
var invalidatedSet: [SemanticTokenRange] = []
70+
71+
// Apply in reverse order (end to start)
72+
for edit in deltas.edits.sorted(by: { $0.start > $1.start }) {
73+
invalidatedSet.append(
74+
contentsOf: invalidatedRanges(startIdx: edit.start, length: edit.deleteCount, data: tokenData[...])
75+
)
76+
77+
// Apply to our copy of the tokens array
78+
if edit.deleteCount > 0 {
79+
tokenData.replaceSubrange(Int(edit.start)..<Int(edit.start + edit.deleteCount), with: edit.data ?? [])
80+
} else {
81+
tokenData.insert(contentsOf: edit.data ?? [], at: Int(edit.start))
82+
}
83+
84+
if edit.data != nil {
85+
invalidatedSet.append(
86+
contentsOf: invalidatedRanges(
87+
startIdx: edit.start,
88+
length: UInt(edit.data?.count ?? 0),
89+
data: tokenData[...]
90+
)
91+
)
92+
}
93+
}
94+
95+
// Set the current state and decode the new token data
96+
var decodedTokens: [SemanticToken] = []
97+
for idx in stride(from: 0, to: tokenData.count, by: 5) {
98+
decodedTokens.append(SemanticToken(
99+
line: tokenData[idx],
100+
char: tokenData[idx + 1],
101+
length: tokenData[idx + 2],
102+
type: tokenData[idx + 3],
103+
modifiers: tokenData[idx + 4]
104+
))
105+
}
106+
state = CurrentState(requestId: requestId, tokenData: tokenData, tokens: decodedTokens)
107+
108+
return invalidatedSet
109+
}
110+
111+
// MARK: - Invalidated Indices
112+
113+
func invalidatedRanges(startIdx: UInt, length: UInt, data: ArraySlice<UInt32>) -> [SemanticTokenRange] {
114+
var ranges: [SemanticTokenRange] = []
115+
var idx = startIdx - (startIdx % 5)
116+
while idx < startIdx + length {
117+
ranges.append(
118+
SemanticTokenRange(
119+
line: data[Int(idx)],
120+
char: data[Int(idx + 1)],
121+
length: data[Int(idx + 2)]
122+
)
123+
)
124+
idx += 5
125+
}
126+
return ranges
127+
}
128+
129+
// MARK: - Binary Search
130+
131+
/// Perform a binary search to find the given position
132+
func findIndex(of position: Position, data: ArraySlice<SemanticToken>) -> Int {
133+
var lower = 0
134+
var upper = data.count
135+
var idx = 0
136+
while lower <= upper {
137+
idx = lower + upper / 2
138+
if data[idx].startPosition < position {
139+
lower = idx + 1
140+
} else if data[idx].startPosition > position {
141+
upper = idx
142+
} else {
143+
return idx
144+
}
145+
}
146+
147+
return idx
148+
}
149+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// SemanticTokenRange.swift
3+
// CodeEdit
4+
//
5+
// Created by Khan Winter on 12/26/24.
6+
//
7+
8+
struct SemanticTokenRange {
9+
let line: UInt32
10+
let char: UInt32
11+
let length: UInt32
12+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// SemanticTokenStorage.swift
3+
// CodeEdit
4+
//
5+
// Created by Khan Winter on 12/26/24.
6+
//
7+
8+
import Foundation
9+
import LanguageServerProtocol
10+
import CodeEditSourceEditor
11+
12+
/// Defines a protocol for an object to provide a storage mechanism for semantic tokens.
13+
///
14+
/// There is only one concrete type that conforms to this in CE, but this protocol is used in testing.
15+
/// See ``ConcreteSemanticTokenStorage`` for use.
16+
protocol SemanticTokenStorage: AnyObject {
17+
var lastRequestId: String? { get }
18+
19+
init()
20+
21+
func getTokensFor(range: LSPRange) -> [SemanticToken]
22+
func setData(_ data: borrowing SemanticTokens)
23+
func applyDelta(_ deltas: SemanticTokensDelta, requestId: String?) -> [SemanticTokenRange]
24+
}

CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ extension LanguageServer {
2929
)
3030
try await lspInstance.textDocumentDidOpen(DidOpenTextDocumentParams(textDocument: textDocument))
3131

32-
await updateIsolatedDocument(document, coordinator: openFiles.contentCoordinator(for: document))
32+
await updateIsolatedDocument(document)
3333
} catch {
3434
logger.warning("addDocument: Error \(error)")
3535
throw error
@@ -120,8 +120,9 @@ extension LanguageServer {
120120
}
121121

122122
@MainActor
123-
private func updateIsolatedDocument(_ document: CodeFileDocument, coordinator: LSPContentCoordinator?) {
124-
document.lspCoordinator = coordinator
123+
private func updateIsolatedDocument(_ document: CodeFileDocument) {
124+
document.lspCoordinator = openFiles.contentCoordinator(for: document)
125+
document.lspHighlightProvider = openFiles.semanticHighlighter(for: document)
125126
}
126127

127128
// swiftlint:disable line_length

0 commit comments

Comments
 (0)