Skip to content

Commit f580ef5

Browse files
LSP Semantic Token Decoder, Improve LSP-CodeFileDoc Arch (#1951)
1 parent a9feb42 commit f580ef5

20 files changed

+495
-131
lines changed

CodeEdit.xcodeproj/project.pbxproj

+40-12
Large diffs are not rendered by default.

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
"kind" : "remoteSourceControl",
3434
"location" : "https://github.com/CodeEditApp/CodeEditSourceEditor",
3535
"state" : {
36-
"revision" : "bfcde1fc536e4159ca3d596fa5b8bbbeb1524362",
37-
"version" : "0.9.0"
36+
"revision" : "b0688fa59fb8060840fb013afb4d6e6a96000f14",
37+
"version" : "0.9.1"
3838
}
3939
},
4040
{

CodeEdit/AppDelegate.swift

-9
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
2020
@LazyService var lspService: LSPService
2121

2222
func applicationDidFinishLaunching(_ notification: Notification) {
23-
setupServiceContainer()
2423
enableWindowSizeSaveOnQuit()
2524
Settings.shared.preferences.general.appAppearance.applyAppearance()
2625
checkForFilesToOpen()
@@ -271,14 +270,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
271270
workspace.taskManager?.stopAllTasks()
272271
}
273272
}
274-
275-
/// Setup all the services into a ServiceContainer for the application to use.
276-
@MainActor
277-
private func setupServiceContainer() {
278-
ServiceContainer.register(
279-
LSPService()
280-
)
281-
}
282273
}
283274

284275
extension AppDelegate {

CodeEdit/CodeEditApp.swift

+5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ struct CodeEditApp: App {
1515
let updater: SoftwareUpdater = SoftwareUpdater()
1616

1717
init() {
18+
// Register singleton services before anything else
19+
ServiceContainer.register(
20+
LSPService()
21+
)
22+
1823
_ = CodeEditDocumentController.shared
1924
NSMenuItem.swizzle()
2025
NSSplitViewItem.swizzle()

CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift

+10-18
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ final class CodeFileDocument: NSDocument, ObservableObject {
2929

3030
static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "CodeFileDocument")
3131

32-
@Service var lspService: LSPService
32+
/// Sent when the document is opened. The document will be sent in the notification's object.
33+
static let didOpenNotification = Notification.Name(rawValue: "CodeFileDocument.didOpen")
34+
/// Sent when the document is closed. The document's `fileURL` will be sent in the notification's object.
35+
static let didCloseNotification = Notification.Name(rawValue: "CodeFileDocument.didClose")
3336

3437
/// The text content of the document, stored as a text storage
3538
///
@@ -47,11 +50,8 @@ final class CodeFileDocument: NSDocument, ObservableObject {
4750
/// See ``CodeEditSourceEditor/CombineCoordinator``.
4851
@Published var contentCoordinator: CombineCoordinator = CombineCoordinator()
4952

50-
lazy var languageServerCoordinator: LSPContentCoordinator = {
51-
let coordinator = LSPContentCoordinator()
52-
coordinator.uri = self.languageServerURI
53-
return coordinator
54-
}()
53+
/// Set by ``LanguageServer`` when initialized.
54+
@Published var lspCoordinator: LSPContentCoordinator?
5555

5656
/// Used to override detected languages.
5757
@Published var language: CodeLanguage?
@@ -84,7 +84,7 @@ final class CodeFileDocument: NSDocument, ObservableObject {
8484
}
8585

8686
/// A stable string to use when identifying documents with language servers.
87-
var languageServerURI: String? { fileURL?.languageServerURI }
87+
var languageServerURI: String? { fileURL?.absolutePath }
8888

8989
/// Specify options for opening the file such as the initial cursor positions.
9090
/// Nulled by ``CodeFileView`` on first load.
@@ -161,6 +161,7 @@ final class CodeFileDocument: NSDocument, ObservableObject {
161161
} else {
162162
Self.logger.error("Failed to read file from data using encoding: \(rawEncoding)")
163163
}
164+
NotificationCenter.default.post(name: Self.didOpenNotification, object: self)
164165
}
165166

166167
/// Triggered when change occurred
@@ -187,7 +188,7 @@ final class CodeFileDocument: NSDocument, ObservableObject {
187188

188189
override func close() {
189190
super.close()
190-
lspService.closeDocument(self)
191+
NotificationCenter.default.post(name: Self.didCloseNotification, object: fileURL)
191192
}
192193

193194
func getLanguage() -> CodeLanguage {
@@ -202,15 +203,6 @@ final class CodeFileDocument: NSDocument, ObservableObject {
202203
}
203204

204205
func findWorkspace() -> WorkspaceDocument? {
205-
CodeEditDocumentController.shared.documents.first(where: { doc in
206-
guard let workspace = doc as? WorkspaceDocument, let path = self.languageServerURI else { return false }
207-
// createIfNotFound is safe here because it will still exit if the file and the workspace
208-
// do not share a path prefix
209-
return workspace
210-
.workspaceFileManager?
211-
.getFile(path, createIfNotFound: true)?
212-
.fileDocument?
213-
.isEqual(self) ?? false
214-
}) as? WorkspaceDocument
206+
fileURL?.findWorkspace()
215207
}
216208
}

CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift

+1-8
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ final class CodeEditDocumentController: NSDocumentController {
1212
@Environment(\.openWindow)
1313
private var openWindow
1414

15-
@LazyService var lspService: LSPService
15+
@Service var lspService: LSPService
1616

1717
private let fileManager = FileManager.default
1818

@@ -92,13 +92,6 @@ final class CodeEditDocumentController: NSDocumentController {
9292
}
9393
}
9494
}
95-
96-
override func addDocument(_ document: NSDocument) {
97-
super.addDocument(document)
98-
if let document = document as? CodeFileDocument {
99-
lspService.openDocument(document)
100-
}
101-
}
10295
}
10396

10497
extension NSDocumentController {

CodeEdit/Features/Editor/Views/CodeFileView.swift

+3-5
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,9 @@ struct CodeFileView: View {
5656

5757
init(codeFile: CodeFileDocument, textViewCoordinators: [TextViewCoordinator] = [], isEditable: Bool = true) {
5858
self._codeFile = .init(wrappedValue: codeFile)
59-
self.textViewCoordinators = textViewCoordinators + [
60-
codeFile.contentCoordinator,
61-
codeFile.languageServerCoordinator
62-
]
59+
self.textViewCoordinators = textViewCoordinators
60+
+ [codeFile.contentCoordinator]
61+
+ [codeFile.lspCoordinator].compactMap({ $0 })
6362
self.isEditable = isEditable
6463

6564
if let openOptions = codeFile.openOptions {
@@ -138,7 +137,6 @@ struct CodeFileView: View {
138137
undoManager: undoManager,
139138
coordinators: textViewCoordinators
140139
)
141-
142140
.id(codeFile.fileURL)
143141
.background {
144142
if colorScheme == .dark {

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

+7-5
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,12 @@ class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate {
3333
private var task: Task<Void, Never>?
3434

3535
weak var languageServer: LanguageServer?
36-
var uri: String?
36+
var documentURI: String
3737

38-
init() {
38+
/// Initializes a content coordinator, and begins an async stream of updates
39+
init(documentURI: String, languageServer: LanguageServer) {
40+
self.documentURI = documentURI
41+
self.languageServer = languageServer
3942
self.stream = AsyncStream { continuation in
4043
self.sequenceContinuation = continuation
4144
}
@@ -71,12 +74,11 @@ class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate {
7174
}
7275

7376
func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) {
74-
guard let uri,
75-
let lspRange = editedRange else {
77+
guard let lspRange = editedRange else {
7678
return
7779
}
7880
self.editedRange = nil
79-
self.sequenceContinuation?.yield(SequenceElement(uri: uri, range: lspRange, string: string))
81+
self.sequenceContinuation?.yield(SequenceElement(uri: documentURI, range: lspRange, string: string))
8082
}
8183

8284
func destroy() {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//
2+
// SemanticTokenMap.swift
3+
// CodeEdit
4+
//
5+
// Created by Khan Winter on 11/10/24.
6+
//
7+
8+
import LanguageClient
9+
import LanguageServerProtocol
10+
import CodeEditSourceEditor
11+
import CodeEditTextView
12+
13+
// swiftlint:disable line_length
14+
/// Creates a mapping from a language server's semantic token options to a format readable by CodeEdit.
15+
/// Provides a convenience method for mapping tokens received from the server to highlight ranges suitable for
16+
/// highlighting in the editor.
17+
///
18+
/// Use this type to handle the initially received semantic highlight capabilities structures. This type will figure
19+
/// out how to read it into a format it can use.
20+
///
21+
/// After initialization, the map is static until the server is reinitialized. Consequently, this type is `Sendable`
22+
/// and immutable after initialization.
23+
///
24+
/// This type is not coupled to any text system via the use of the ``SemanticTokenMapRangeProvider``. When decoding to
25+
/// highlight ranges, provide a type that can provide ranges for highlighting.
26+
///
27+
/// [LSP Spec](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#semanticTokensLegend)
28+
struct SemanticTokenMap: Sendable { // swiftlint:enable line_length
29+
private let tokenTypeMap: [CaptureName?]
30+
private let modifierMap: [CaptureModifier?]
31+
32+
init(semanticCapability: TwoTypeOption<SemanticTokensOptions, SemanticTokensRegistrationOptions>) {
33+
let legend: SemanticTokensLegend
34+
switch semanticCapability {
35+
case .optionA(let tokensOptions):
36+
legend = tokensOptions.legend
37+
case .optionB(let tokensRegistrationOptions):
38+
legend = tokensRegistrationOptions.legend
39+
}
40+
41+
tokenTypeMap = legend.tokenTypes.map { CaptureName.fromString($0) }
42+
modifierMap = legend.tokenModifiers.map { CaptureModifier.fromString($0) }
43+
}
44+
45+
/// Decodes the compressed semantic token data into a `HighlightRange` type for use in an editor.
46+
/// This is marked main actor to prevent runtime errors, due to the use of the actor-isolated `rangeProvider`.
47+
/// - Parameters:
48+
/// - tokens: Semantic tokens from a language server.
49+
/// - rangeProvider: The provider to use to translate token ranges to text view ranges.
50+
/// - Returns: An array of decoded highlight ranges.
51+
@MainActor
52+
func decode(tokens: SemanticTokens, using rangeProvider: SemanticTokenMapRangeProvider) -> [HighlightRange] {
53+
tokens.decode().compactMap { token in
54+
guard let range = rangeProvider.nsRangeFrom(line: token.line, char: token.char, length: token.length) else {
55+
return nil
56+
}
57+
58+
let modifiers = decodeModifier(token.modifiers)
59+
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
62+
let capture = tokenTypeMap.indices.contains(type) ? tokenTypeMap[type] : nil
63+
64+
return HighlightRange(
65+
range: range,
66+
capture: capture,
67+
modifiers: modifiers
68+
)
69+
}
70+
}
71+
72+
/// Decodes a raw modifier value into a set of capture modifiers.
73+
/// - Parameter raw: The raw modifier integer to decode.
74+
/// - Returns: A set of modifiers for highlighting.
75+
func decodeModifier(_ raw: UInt32) -> CaptureModifierSet {
76+
var modifiers: CaptureModifierSet = []
77+
var raw = raw
78+
while raw > 0 {
79+
let idx = raw.trailingZeroBitCount
80+
raw &= ~(1 << idx)
81+
// We don't use `[safe:]` because it creates a double optional here. If someone knows how to extend
82+
// a collection of optionals to make that return only a single optional this could be updated.
83+
guard let modifier = modifierMap.indices.contains(idx) ? modifierMap[idx] : nil else {
84+
continue
85+
}
86+
modifiers.insert(modifier)
87+
}
88+
return modifiers
89+
}
90+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// SemanticTokenMapRangeProvider.swift
3+
// CodeEdit
4+
//
5+
// Created by Khan Winter on 12/19/24.
6+
//
7+
8+
import Foundation
9+
10+
@MainActor
11+
protocol SemanticTokenMapRangeProvider {
12+
func nsRangeFrom(line: UInt32, char: UInt32, length: UInt32) -> NSRange?
13+
}

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

+5-5
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ extension LanguageServer {
1919
}
2020
logger.debug("Opening Document \(content.uri, privacy: .private)")
2121

22-
self.openFiles.addDocument(document)
22+
openFiles.addDocument(document, for: self)
2323

2424
let textDocument = TextDocumentItem(
2525
uri: content.uri,
@@ -28,7 +28,8 @@ extension LanguageServer {
2828
text: content.string
2929
)
3030
try await lspInstance.textDocumentDidOpen(DidOpenTextDocumentParams(textDocument: textDocument))
31-
await updateIsolatedTextCoordinator(for: document)
31+
32+
await updateIsolatedDocument(document, coordinator: openFiles.contentCoordinator(for: document))
3233
} catch {
3334
logger.warning("addDocument: Error \(error)")
3435
throw error
@@ -118,10 +119,9 @@ extension LanguageServer {
118119
return DocumentContent(uri: uri, language: language, string: content)
119120
}
120121

121-
/// Updates the actor-isolated document's text coordinator to map to this server.
122122
@MainActor
123-
fileprivate func updateIsolatedTextCoordinator(for document: CodeFileDocument) {
124-
document.languageServerCoordinator.languageServer = self
123+
private func updateIsolatedDocument(_ document: CodeFileDocument, coordinator: LSPContentCoordinator?) {
124+
document.lspCoordinator = coordinator
125125
}
126126

127127
// swiftlint:disable line_length

CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift

+20-5
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,14 @@ class LanguageServer {
2222
/// A cache to hold responses from the server, to minimize duplicate server requests
2323
let lspCache = LSPCache()
2424

25+
/// Tracks documents and their associated objects.
26+
/// Use this property when adding new objects that need to track file data, or have a state associated with the
27+
/// language server and a document. For example, the content coordinator.
2528
let openFiles: LanguageServerFileMap
2629

30+
/// Maps the language server's highlight config to one CodeEdit can read. See ``SemanticTokenMap``.
31+
let highlightMap: SemanticTokenMap?
32+
2733
/// The configuration options this server supports.
2834
var serverCapabilities: ServerCapabilities
2935

@@ -49,6 +55,11 @@ class LanguageServer {
4955
subsystem: Bundle.main.bundleIdentifier ?? "",
5056
category: "LanguageServer.\(languageId.rawValue)"
5157
)
58+
if let semanticTokensProvider = serverCapabilities.semanticTokensProvider {
59+
self.highlightMap = SemanticTokenMap(semanticCapability: semanticTokensProvider)
60+
} else {
61+
self.highlightMap = nil // Server doesn't support semantic highlights
62+
}
5263
}
5364

5465
/// Creates and initializes a language server.
@@ -82,6 +93,8 @@ class LanguageServer {
8293
)
8394
}
8495

96+
// MARK: - Make Local Server Connection
97+
8598
/// Creates a data channel for sending and receiving data with an LSP.
8699
/// - Parameters:
87100
/// - languageId: The ID of the language to create the channel for.
@@ -105,6 +118,8 @@ class LanguageServer {
105118
}
106119
}
107120

121+
// MARK: - Get Init Params
122+
108123
// swiftlint:disable function_body_length
109124
static func getInitParams(workspacePath: String) -> InitializingServer.InitializeParamsProvider {
110125
let provider: InitializingServer.InitializeParamsProvider = {
@@ -136,15 +151,15 @@ class LanguageServer {
136151
// swiftlint:disable:next line_length
137152
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#semanticTokensClientCapabilities
138153
semanticTokens: SemanticTokensClientCapabilities(
139-
dynamicRegistration: true,
140-
requests: .init(range: true, delta: false),
141-
tokenTypes: [],
142-
tokenModifiers: [],
154+
dynamicRegistration: false,
155+
requests: .init(range: true, delta: true),
156+
tokenTypes: SemanticTokenTypes.allStrings,
157+
tokenModifiers: SemanticTokenModifiers.allStrings,
143158
formats: [.relative],
144159
overlappingTokenSupport: true,
145160
multilineTokenSupport: true,
146161
serverCancelSupport: true,
147-
augmentsSyntaxTokens: false
162+
augmentsSyntaxTokens: true
148163
)
149164
)
150165

0 commit comments

Comments
 (0)