Skip to content

Commit f2850b0

Browse files
Language Server Syntax Highlights (#1985)
### Description Adds semantic token syntax highlighting to the code file view. When an LSP is installed and configured for a language type, and has semantic highlights support, CodeEdit will install a new highlight provider on the source editor and begin processing syntax tokens for the file. Token processing happens asynchronously, and does **not** replace tree-sitter highlights. This builds off recent work in the source editor to support a hierarchy of highlight providers. Language server highlights are slow but more accurate, so we process them slowly and apply them when they become available. #### Details - Adds a new generic 'language server document' protocol that includes only what the language server code needs to know about a code document. This should solve the coupling issue we had with CodeFileDocument and the language server code. In the future, if we replace `CodeFileDocument`, it'll be a matter of conforming the new type to the protocol for it to work with the lsp code. - Reorganizes slightly to group lsp features into their own "Features" folder. - Adds a new `SemanticTokenHighlightProvider` type - Conforms to the `HighlightProviding` protocol. - Manages receiving edit notifications from the editor and forwards them to the language server service. - Adds a `SemanticTokenMap` type - Maps LSP semantic token data to a format CodeEdit can read. - Reads a LSP's capabilities to determine how to decode that data. - Adds `SemanticTokenStorage` - Provides an API for applying token deltas, and entire file token data. - Manages decoding, re-decoding (when dealing with deltas) and storing semantic tokens. - Provides an API for finding semantic tokens quickly. ### Related Issues * closes #1950 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots ![Screenshot 2025-02-14 at 10 20 09 AM](https://github.com/user-attachments/assets/14ee65a3-058c-4f9c-b816-ae258aca96be) Live editing demo, note the highlights on the variable types and switch cases. https://github.com/user-attachments/assets/e70bf93c-779d-412b-9b34-c68e46898921
1 parent 6689fd3 commit f2850b0

23 files changed

+982
-159
lines changed

CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift

+17-6
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,6 @@ final class CodeFileDocument: NSDocument, ObservableObject {
5050
/// See ``CodeEditSourceEditor/CombineCoordinator``.
5151
@Published var contentCoordinator: CombineCoordinator = CombineCoordinator()
5252

53-
/// Set by ``LanguageServer`` when initialized.
54-
@Published var lspCoordinator: LSPContentCoordinator?
55-
5653
/// Used to override detected languages.
5754
@Published var language: CodeLanguage?
5855

@@ -65,6 +62,9 @@ final class CodeFileDocument: NSDocument, ObservableObject {
6562
/// Document-specific overridden line wrap preference.
6663
@Published var wrapLines: Bool?
6764

65+
/// Set up by ``LanguageServer``, conforms this type to ``LanguageServerDocument``.
66+
@Published var languageServerObjects: LanguageServerDocumentObjects<CodeFileDocument> = .init()
67+
6868
/// The type of data this file document contains.
6969
///
7070
/// If its text content is not nil, a `text` UTType is returned.
@@ -83,9 +83,6 @@ final class CodeFileDocument: NSDocument, ObservableObject {
8383
return type
8484
}
8585

86-
/// A stable string to use when identifying documents with language servers.
87-
var languageServerURI: String? { fileURL?.absolutePath }
88-
8986
/// Specify options for opening the file such as the initial cursor positions.
9087
/// Nulled by ``CodeFileView`` on first load.
9188
var openOptions: OpenOptions?
@@ -208,6 +205,10 @@ final class CodeFileDocument: NSDocument, ObservableObject {
208205
}
209206
}
210207

208+
/// Determines the code language of the document.
209+
/// Use ``CodeFileDocument/language`` for the default value before using this. That property is used to override
210+
/// the file's language.
211+
/// - Returns: The detected code language.
211212
func getLanguage() -> CodeLanguage {
212213
guard let url = fileURL else {
213214
return .default
@@ -223,3 +224,13 @@ final class CodeFileDocument: NSDocument, ObservableObject {
223224
fileURL?.findWorkspace()
224225
}
225226
}
227+
228+
// MARK: LanguageServerDocument
229+
230+
extension CodeFileDocument: LanguageServerDocument {
231+
/// A stable string to use when identifying documents with language servers.
232+
/// Needs to be a valid URI, so always returns with the `file://` prefix to indicate it's a file URI.
233+
var languageServerURI: String? {
234+
fileURL?.lspURI
235+
}
236+
}

CodeEdit/Features/Editor/Views/CodeFileView.swift

+19-2
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,13 @@ struct CodeFileView: View {
1919
/// The current cursor positions in the view
2020
@State private var cursorPositions: [CursorPosition] = []
2121

22+
@State private var treeSitterClient: TreeSitterClient = TreeSitterClient()
23+
2224
/// Any coordinators passed to the view.
2325
private var textViewCoordinators: [TextViewCoordinator]
2426

27+
@State private var highlightProviders: [any HighlightProviding] = []
28+
2529
@AppSettings(\.textEditing.defaultTabWidth)
2630
var defaultTabWidth
2731
@AppSettings(\.textEditing.indentOption)
@@ -62,16 +66,19 @@ struct CodeFileView: View {
6266

6367
init(codeFile: CodeFileDocument, textViewCoordinators: [TextViewCoordinator] = [], isEditable: Bool = true) {
6468
self._codeFile = .init(wrappedValue: codeFile)
69+
6570
self.textViewCoordinators = textViewCoordinators
6671
+ [codeFile.contentCoordinator]
67-
+ [codeFile.lspCoordinator].compactMap({ $0 })
72+
+ [codeFile.languageServerObjects.textCoordinator].compactMap({ $0 })
6873
self.isEditable = isEditable
6974

7075
if let openOptions = codeFile.openOptions {
7176
codeFile.openOptions = nil
7277
self.cursorPositions = openOptions.cursorPositions
7378
}
7479

80+
updateHighlightProviders()
81+
7582
codeFile
7683
.contentCoordinator
7784
.textUpdatePublisher
@@ -119,7 +126,7 @@ struct CodeFileView: View {
119126
editorOverscroll: overscroll.overscrollPercentage,
120127
cursorPositions: $cursorPositions,
121128
useThemeBackground: useThemeBackground,
122-
highlightProviders: [treeSitter],
129+
highlightProviders: highlightProviders,
123130
contentInsets: edgeInsets.nsEdgeInsets,
124131
additionalTextInsets: NSEdgeInsets(top: 2, left: 0, bottom: 0, right: 0),
125132
isEditable: isEditable,
@@ -144,6 +151,10 @@ struct CodeFileView: View {
144151
.onChange(of: settingsFont) { newFontSetting in
145152
font = newFontSetting.current
146153
}
154+
.onReceive(codeFile.$languageServerObjects) { languageServerObjects in
155+
// This will not be called in single-file views (for now) but is safe to listen to either way
156+
updateHighlightProviders(lspHighlightProvider: languageServerObjects.highlightProvider)
157+
}
147158
}
148159

149160
/// Determines the style of bracket emphasis based on the `bracketEmphasis` setting and the current theme.
@@ -166,6 +177,12 @@ struct CodeFileView: View {
166177
return .underline(color: color)
167178
}
168179
}
180+
181+
/// Updates the highlight providers array.
182+
/// - Parameter lspHighlightProvider: The language server provider, if available.
183+
private func updateHighlightProviders(lspHighlightProvider: HighlightProviding? = nil) {
184+
highlightProviders = [lspHighlightProvider].compactMap({ $0 }) + [treeSitterClient]
185+
}
169186
}
170187

171188
// This extension is kept here because it should not be used elsewhere in the app and may cause confusion

CodeEdit/Features/LSP/Editor/LSPContentCoordinator.swift renamed to CodeEdit/Features/LSP/Features/DocumentSync/LSPContentCoordinator.swift

+10-8
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import LanguageServerProtocol
1919
/// Language servers expect edits to be sent in chunks (and it helps reduce processing overhead). To do this, this class
2020
/// keeps an async stream around for the duration of its lifetime. The stream is sent edit notifications, which are then
2121
/// chunked into 250ms timed groups before being sent to the ``LanguageServer``.
22-
class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate {
22+
class LSPContentCoordinator<DocumentType: LanguageServerDocument>: TextViewCoordinator, TextViewDelegate {
2323
// Required to avoid a large_tuple lint error
2424
private struct SequenceElement: Sendable {
2525
let uri: String
@@ -28,25 +28,27 @@ class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate {
2828
}
2929

3030
private var editedRange: LSPRange?
31-
private var stream: AsyncStream<SequenceElement>?
3231
private var sequenceContinuation: AsyncStream<SequenceElement>.Continuation?
3332
private var task: Task<Void, Never>?
3433

35-
weak var languageServer: LanguageServer?
34+
weak var languageServer: LanguageServer<DocumentType>?
3635
var documentURI: String
3736

3837
/// Initializes a content coordinator, and begins an async stream of updates
39-
init(documentURI: String, languageServer: LanguageServer) {
38+
init(documentURI: String, languageServer: LanguageServer<DocumentType>) {
4039
self.documentURI = documentURI
4140
self.languageServer = languageServer
42-
self.stream = AsyncStream { continuation in
43-
self.sequenceContinuation = continuation
44-
}
41+
42+
setUpUpdatesTask()
4543
}
4644

4745
func setUpUpdatesTask() {
4846
task?.cancel()
49-
guard let stream else { return }
47+
// Create this stream here so it's always set up when the text view is set up, rather than only once on init.
48+
let stream = AsyncStream { continuation in
49+
self.sequenceContinuation = continuation
50+
}
51+
5052
task = Task.detached { [weak self] in
5153
// Send edit events every 250ms
5254
for await events in stream.chunked(by: .repeating(every: .milliseconds(250), clock: .continuous)) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
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<
25+
Storage: GenericSemanticTokenStorage,
26+
DocumentType: LanguageServerDocument
27+
>: HighlightProviding {
28+
enum HighlightError: Error {
29+
case lspRangeFailure
30+
}
31+
32+
typealias EditCallback = @MainActor (Result<IndexSet, any Error>) -> Void
33+
typealias HighlightCallback = @MainActor (Result<[HighlightRange], any Error>) -> Void
34+
35+
private let tokenMap: SemanticTokenMap
36+
private let documentURI: String
37+
private weak var languageServer: LanguageServer<DocumentType>?
38+
private weak var textView: TextView?
39+
40+
private var lastEditCallback: EditCallback?
41+
private var pendingHighlightCallbacks: [HighlightCallback] = []
42+
private var storage: Storage
43+
44+
var documentRange: NSRange {
45+
textView?.documentRange ?? .zero
46+
}
47+
48+
init(tokenMap: SemanticTokenMap, languageServer: LanguageServer<DocumentType>, documentURI: String) {
49+
self.tokenMap = tokenMap
50+
self.languageServer = languageServer
51+
self.documentURI = documentURI
52+
self.storage = Storage()
53+
}
54+
55+
// MARK: - Language Server Content Lifecycle
56+
57+
/// Called when the language server finishes sending a document update.
58+
///
59+
/// This method first checks if this object has any semantic tokens. If not, requests new tokens and responds to the
60+
/// `pendingHighlightCallbacks` queue with cancellation errors, causing the highlighter to re-query those indices.
61+
///
62+
/// If this object already has some tokens, it determines whether or not we can request a token delta and
63+
/// performs the request.
64+
func documentDidChange() async throws {
65+
guard let languageServer, let textView else {
66+
return
67+
}
68+
69+
guard storage.hasReceivedData else {
70+
// We have no semantic token info, request it!
71+
try await requestTokens(languageServer: languageServer, textView: textView)
72+
await MainActor.run {
73+
for callback in pendingHighlightCallbacks {
74+
callback(.failure(HighlightProvidingError.operationCancelled))
75+
}
76+
pendingHighlightCallbacks.removeAll()
77+
}
78+
return
79+
}
80+
81+
// The document was updated. Update our token cache and send the invalidated ranges for the editor to handle.
82+
if let lastResultId = storage.lastResultId {
83+
try await requestDeltaTokens(languageServer: languageServer, textView: textView, lastResultId: lastResultId)
84+
return
85+
}
86+
87+
try await requestTokens(languageServer: languageServer, textView: textView)
88+
}
89+
90+
// MARK: - LSP Token Requests
91+
92+
/// Requests and applies a token delta. Requires a previous response identifier.
93+
private func requestDeltaTokens(
94+
languageServer: LanguageServer<DocumentType>,
95+
textView: TextView,
96+
lastResultId: String
97+
) async throws {
98+
guard let response = try await languageServer.requestSemanticTokens(
99+
for: documentURI,
100+
previousResultId: lastResultId
101+
) else {
102+
return
103+
}
104+
switch response {
105+
case let .optionA(tokenData):
106+
await applyEntireResponse(tokenData, callback: lastEditCallback)
107+
case let .optionB(deltaData):
108+
await applyDeltaResponse(deltaData, callback: lastEditCallback, textView: textView)
109+
}
110+
}
111+
112+
/// Requests and applies tokens for an entire document. This does not require a previous response id, and should be
113+
/// used in place of `requestDeltaTokens` when that's the case.
114+
private func requestTokens(languageServer: LanguageServer<DocumentType>, textView: TextView) async throws {
115+
guard let response = try await languageServer.requestSemanticTokens(for: documentURI) else {
116+
return
117+
}
118+
await applyEntireResponse(response, callback: lastEditCallback)
119+
}
120+
121+
// MARK: - Apply LSP Response
122+
123+
/// Applies a delta response from the LSP to our storage.
124+
private func applyDeltaResponse(_ data: SemanticTokensDelta, callback: EditCallback?, textView: TextView?) async {
125+
let lspRanges = storage.applyDelta(data)
126+
lastEditCallback = nil // Don't use this callback again.
127+
await MainActor.run {
128+
let ranges = lspRanges.compactMap { textView?.nsRangeFrom($0) }
129+
callback?(.success(IndexSet(ranges: ranges)))
130+
}
131+
}
132+
133+
private func applyEntireResponse(_ data: SemanticTokens, callback: EditCallback?) async {
134+
storage.setData(data)
135+
lastEditCallback = nil // Don't use this callback again.
136+
await callback?(.success(IndexSet(integersIn: documentRange)))
137+
}
138+
139+
// MARK: - Highlight Provider Conformance
140+
141+
func setUp(textView: TextView, codeLanguage: CodeLanguage) {
142+
// Send off a request to get the initial token data
143+
self.textView = textView
144+
Task {
145+
try await self.documentDidChange()
146+
}
147+
}
148+
149+
func applyEdit(textView: TextView, range: NSRange, delta: Int, completion: @escaping EditCallback) {
150+
if let lastEditCallback {
151+
lastEditCallback(.success(IndexSet())) // Don't throw a cancellation error
152+
}
153+
lastEditCallback = completion
154+
}
155+
156+
func queryHighlightsFor(textView: TextView, range: NSRange, completion: @escaping HighlightCallback) {
157+
guard storage.hasReceivedData else {
158+
pendingHighlightCallbacks.append(completion)
159+
return
160+
}
161+
162+
guard let lspRange = textView.lspRangeFrom(nsRange: range) else {
163+
completion(.failure(HighlightError.lspRangeFailure))
164+
return
165+
}
166+
let rawTokens = storage.getTokensFor(range: lspRange)
167+
let highlights = tokenMap
168+
.decode(tokens: rawTokens, using: textView)
169+
.filter({ $0.capture != nil || !$0.modifiers.isEmpty })
170+
completion(.success(highlights))
171+
}
172+
}

CodeEdit/Features/LSP/Editor/SemanticTokenMap.swift renamed to CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMap.swift

+15-4
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,31 @@ 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
}
5768

69+
// Only modifiers are bit packed, capture types are given as a simple index into the ``tokenTypeMap``
5870
let modifiers = decodeModifier(token.modifiers)
5971

60-
// Capture types are indicated by the index of the set bit.
61-
let type = token.type > 0 ? Int(token.type.trailingZeroBitCount) : -1 // Don't try to decode 0
72+
let type = Int(token.type)
6273
let capture = tokenTypeMap.indices.contains(type) ? tokenTypeMap[type] : nil
6374

6475
return HighlightRange(

0 commit comments

Comments
 (0)