Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Language Server Syntax Highlights #1985

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
129 changes: 107 additions & 22 deletions CodeEdit.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"originHash" : "aef43d6aa0c467418565c574c33495a50d6e24057eb350c17704ab4ae2aead6c",
"originHash" : "454498edc6f3f47f3616318caf54005bbbfd026d4f4355edda503b072bfe9814",
"pins" : [
{
"identity" : "anycodable",
Expand Down Expand Up @@ -28,15 +28,6 @@
"version" : "0.1.20"
}
},
{
"identity" : "codeeditsourceeditor",
"kind" : "remoteSourceControl",
"location" : "https://github.com/CodeEditApp/CodeEditSourceEditor",
"state" : {
"revision" : "6b2c945501f0a5c15d8aa6d159fb2550c391bdd0",
"version" : "0.10.0"
}
},
{
"identity" : "codeeditsymbols",
"kind" : "remoteSourceControl",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,6 @@ final class CodeFileDocument: NSDocument, ObservableObject {
/// See ``CodeEditSourceEditor/CombineCoordinator``.
@Published var contentCoordinator: CombineCoordinator = CombineCoordinator()

/// Set by ``LanguageServer`` when initialized.
@Published var lspCoordinator: LSPContentCoordinator?

/// Used to override detected languages.
@Published var language: CodeLanguage?

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

/// Set up by ``LanguageServer``, conforms this type to ``LanguageServerDocument``.
@Published var languageServerObjects: LanguageServerDocumentObjects<CodeFileDocument> = .init()

/// The type of data this file document contains.
///
/// If its text content is not nil, a `text` UTType is returned.
Expand All @@ -83,9 +83,6 @@ final class CodeFileDocument: NSDocument, ObservableObject {
return type
}

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

/// Specify options for opening the file such as the initial cursor positions.
/// Nulled by ``CodeFileView`` on first load.
var openOptions: OpenOptions?
Expand Down Expand Up @@ -191,6 +188,10 @@ final class CodeFileDocument: NSDocument, ObservableObject {
NotificationCenter.default.post(name: Self.didCloseNotification, object: fileURL)
}

/// Determines the code language of the document.
/// Use ``CodeFileDocument/language`` for the default value before using this. That property is used to override
/// the file's language.
/// - Returns: The detected code language.
func getLanguage() -> CodeLanguage {
guard let url = fileURL else {
return .default
Expand All @@ -206,3 +207,17 @@ final class CodeFileDocument: NSDocument, ObservableObject {
fileURL?.findWorkspace()
}
}

// MARK: LanguageServerDocument

extension CodeFileDocument: LanguageServerDocument {
/// A stable string to use when identifying documents with language servers.
/// Needs to be a valid URI, so always returns with the `file://` prefix to indicate it's a file URI.
var languageServerURI: String? {
if let path = fileURL?.absolutePath {
return "file://" + path
} else {
return nil
}
}
}
20 changes: 19 additions & 1 deletion CodeEdit/Features/Editor/Views/CodeFileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ struct CodeFileView: View {
/// The current cursor positions in the view
@State private var cursorPositions: [CursorPosition] = []

@State private var treeSitterClient: TreeSitterClient = TreeSitterClient()

/// Any coordinators passed to the view.
private var textViewCoordinators: [TextViewCoordinator]

@State private var highlightProviders: [any HighlightProviding] = []

@AppSettings(\.textEditing.defaultTabWidth)
var defaultTabWidth
@AppSettings(\.textEditing.indentOption)
Expand Down Expand Up @@ -56,16 +60,19 @@ struct CodeFileView: View {

init(codeFile: CodeFileDocument, textViewCoordinators: [TextViewCoordinator] = [], isEditable: Bool = true) {
self._codeFile = .init(wrappedValue: codeFile)

self.textViewCoordinators = textViewCoordinators
+ [codeFile.contentCoordinator]
+ [codeFile.lspCoordinator].compactMap({ $0 })
+ [codeFile.languageServerObjects.textCoordinator].compactMap({ $0 })
self.isEditable = isEditable

if let openOptions = codeFile.openOptions {
codeFile.openOptions = nil
self.cursorPositions = openOptions.cursorPositions
}

updateHighlightProviders()

codeFile
.contentCoordinator
.textUpdatePublisher
Expand Down Expand Up @@ -129,6 +136,7 @@ struct CodeFileView: View {
wrapLines: codeFile.wrapLines ?? wrapLinesToEditorWidth,
cursorPositions: $cursorPositions,
useThemeBackground: useThemeBackground,
highlightProviders: highlightProviders,
contentInsets: edgeInsets.nsEdgeInsets,
isEditable: isEditable,
letterSpacing: letterSpacing,
Expand All @@ -154,6 +162,10 @@ struct CodeFileView: View {
.onChange(of: bracketHighlight) { _ in
bracketPairHighlight = getBracketPairHighlight()
}
.onReceive(codeFile.$languageServerObjects) { languageServerObjects in
// This will not be called in single-file views (for now) but is safe to listen to either way
updateHighlightProviders(lspHighlightProvider: languageServerObjects.highlightProvider)
}
}

private func getBracketPairHighlight() -> BracketPairHighlight? {
Expand All @@ -174,6 +186,12 @@ struct CodeFileView: View {
return .underline(color: color)
}
}

/// Updates the highlight providers array.
/// - Parameter lspHighlightProvider: The language server provider, if available.
private func updateHighlightProviders(lspHighlightProvider: HighlightProviding? = nil) {
highlightProviders = [lspHighlightProvider].compactMap({ $0 }) + [treeSitterClient]
}
}

// This extension is kept here because it should not be used elsewhere in the app and may cause confusion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import LanguageServerProtocol
/// Language servers expect edits to be sent in chunks (and it helps reduce processing overhead). To do this, this class
/// keeps an async stream around for the duration of its lifetime. The stream is sent edit notifications, which are then
/// chunked into 250ms timed groups before being sent to the ``LanguageServer``.
class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate {
class LSPContentCoordinator<DocumentType: LanguageServerDocument>: TextViewCoordinator, TextViewDelegate {
// Required to avoid a large_tuple lint error
private struct SequenceElement: Sendable {
let uri: String
Expand All @@ -28,25 +28,27 @@ class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate {
}

private var editedRange: LSPRange?
private var stream: AsyncStream<SequenceElement>?
private var sequenceContinuation: AsyncStream<SequenceElement>.Continuation?
private var task: Task<Void, Never>?

weak var languageServer: LanguageServer?
weak var languageServer: LanguageServer<DocumentType>?
var documentURI: String

/// Initializes a content coordinator, and begins an async stream of updates
init(documentURI: String, languageServer: LanguageServer) {
init(documentURI: String, languageServer: LanguageServer<DocumentType>) {
self.documentURI = documentURI
self.languageServer = languageServer
self.stream = AsyncStream { continuation in
self.sequenceContinuation = continuation
}

setUpUpdatesTask()
}

func setUpUpdatesTask() {
task?.cancel()
guard let stream else { return }
// Create this stream here so it's always set up when the text view is set up, rather than only once on init.
let stream = AsyncStream { continuation in
self.sequenceContinuation = continuation
}

task = Task.detached { [weak self] in
// Send edit events every 250ms
for await events in stream.chunked(by: .repeating(every: .milliseconds(250), clock: .continuous)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//
// SemanticTokenHighlightProvider.swift
// CodeEdit
//
// Created by Khan Winter on 12/26/24.
//

import Foundation
import LanguageServerProtocol
import CodeEditSourceEditor
import CodeEditTextView
import CodeEditLanguages

/// Provides semantic token information from a language server for a source editor view.
///
/// This class works in tangent with the ``LanguageServer`` class to ensure we don't unnecessarily request new tokens
/// if the document isn't updated. The ``LanguageServer`` will call the
/// ``SemanticTokenHighlightProvider/documentDidChange`` method, which in turn refreshes the semantic token storage.
///
/// That behavior may not be intuitive due to the
/// ``SemanticTokenHighlightProvider/applyEdit(textView:range:delta:completion:)`` method. One might expect this class
/// to respond to that method immediately, but it does not. It instead stores the completion passed in that method until
/// it can respond to the edit with invalidated indices.
final class SemanticTokenHighlightProvider<
Storage: GenericSemanticTokenStorage,
DocumentType: LanguageServerDocument
>: HighlightProviding {
enum HighlightError: Error {
case lspRangeFailure
}

typealias EditCallback = @MainActor (Result<IndexSet, any Error>) -> Void
typealias HighlightCallback = @MainActor (Result<[HighlightRange], any Error>) -> Void

private let tokenMap: SemanticTokenMap
private let documentURI: String
private weak var languageServer: LanguageServer<DocumentType>?
private weak var textView: TextView?

private var lastEditCallback: EditCallback?
private var pendingHighlightCallbacks: [HighlightCallback] = []
private var storage: Storage

var documentRange: NSRange {
textView?.documentRange ?? .zero
}

init(tokenMap: SemanticTokenMap, languageServer: LanguageServer<DocumentType>, documentURI: String) {
self.tokenMap = tokenMap
self.languageServer = languageServer
self.documentURI = documentURI
self.storage = Storage()
}

// MARK: - Language Server Content Lifecycle

/// Called when the language server finishes sending a document update.
///
/// This method first checks if this object has any semantic tokens. If not, requests new tokens and responds to the
/// `pendingHighlightCallbacks` queue with cancellation errors, causing the highlighter to re-query those indices.
///
/// If this object already has some tokens, it determines whether or not we can request a token delta and
/// performs the request.
func documentDidChange() async throws {
guard let languageServer, let textView else {
return
}

guard storage.hasTokens else {
// We have no semantic token info, request it!
try await requestTokens(languageServer: languageServer, textView: textView)
await MainActor.run {
for callback in pendingHighlightCallbacks {
callback(.failure(HighlightProvidingError.operationCancelled))
}
pendingHighlightCallbacks.removeAll()
}
return
}

// The document was updated. Update our token cache and send the invalidated ranges for the editor to handle.
if let lastResultId = storage.lastResultId {
try await requestDeltaTokens(languageServer: languageServer, textView: textView, lastResultId: lastResultId)
return
}

try await requestTokens(languageServer: languageServer, textView: textView)
}

// MARK: - LSP Token Requests

/// Requests and applies a token delta. Requires a previous response identifier.
private func requestDeltaTokens(
languageServer: LanguageServer<DocumentType>,
textView: TextView,
lastResultId: String
) async throws {
guard let response = try await languageServer.requestSemanticTokens(
for: documentURI,
previousResultId: lastResultId
) else {
return
}
switch response {
case let .optionA(tokenData):
await applyEntireResponse(tokenData, callback: lastEditCallback)
case let .optionB(deltaData):
await applyDeltaResponse(deltaData, callback: lastEditCallback, textView: textView)
}
}

/// Requests and applies tokens for an entire document. This does not require a previous response id, and should be
/// used in place of `requestDeltaTokens` when that's the case.
private func requestTokens(languageServer: LanguageServer<DocumentType>, textView: TextView) async throws {
guard let response = try await languageServer.requestSemanticTokens(for: documentURI) else {
return
}
await applyEntireResponse(response, callback: lastEditCallback)
}

// MARK: - Apply LSP Response

/// Applies a delta response from the LSP to our storage.
private func applyDeltaResponse(_ data: SemanticTokensDelta, callback: EditCallback?, textView: TextView?) async {
let lspRanges = storage.applyDelta(data)
lastEditCallback = nil // Don't use this callback again.
await MainActor.run {
let ranges = lspRanges.compactMap { textView?.nsRangeFrom($0) }
callback?(.success(IndexSet(ranges: ranges)))
}
}

private func applyEntireResponse(_ data: SemanticTokens, callback: EditCallback?) async {
storage.setData(data)
lastEditCallback = nil // Don't use this callback again.
await callback?(.success(IndexSet(integersIn: documentRange)))
}

// MARK: - Highlight Provider Conformance

func setUp(textView: TextView, codeLanguage: CodeLanguage) {
// Send off a request to get the initial token data
self.textView = textView
Task {
try await self.documentDidChange()
}
}

func applyEdit(textView: TextView, range: NSRange, delta: Int, completion: @escaping EditCallback) {
if let lastEditCallback {
lastEditCallback(.success(IndexSet())) // Don't throw a cancellation error
}
lastEditCallback = completion
}

func queryHighlightsFor(textView: TextView, range: NSRange, completion: @escaping HighlightCallback) {
guard storage.hasTokens else {
pendingHighlightCallbacks.append(completion)
return
}

guard let lspRange = textView.lspRangeFrom(nsRange: range) else {
completion(.failure(HighlightError.lspRangeFailure))
return
}
let rawTokens = storage.getTokensFor(range: lspRange)
let highlights = tokenMap
.decode(tokens: rawTokens, using: textView)
.filter({ $0.capture != nil || !$0.modifiers.isEmpty })
completion(.success(highlights))
}
}
Loading
Loading