diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 233f378ef..b9799ad3c 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -28,10 +28,11 @@ 6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F729CD14D100235D17 /* CodeEditKit */; }; 6C6BD6F929CD14D100235D17 /* CodeEditKit in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F729CD14D100235D17 /* CodeEditKit */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 6C73A6D32D4F1E550012D95C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */; }; - 6C76D6D42E15B91E00EF52C3 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */; }; + 6C77BFBC2E53B6860076827C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C77BFBB2E53B6860076827C /* CodeEditSourceEditor */; }; 6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 6C81916A29B41DD300B75C92 /* DequeModule */; }; 6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB3F2C2105ED00EB5DEF /* CodeEditKit */; }; 6C85BB442C210EFD00EB5DEF /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB432C210EFD00EB5DEF /* SwiftUIIntrospect */; }; + 6C8B56492E2FE62E00DC3F29 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C8B56482E2FE62E00DC3F29 /* CodeEditSourceEditor */; }; 6C9DB9E42D55656300ACD86E /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C9DB9E32D55656300ACD86E /* CodeEditSourceEditor */; }; 6CAAF68A29BC9C2300A1F48A /* (null) in Sources */ = {isa = PBXBuildFile; }; 6CAAF69229BCC71C00A1F48A /* (null) in Sources */ = {isa = PBXBuildFile; }; @@ -176,7 +177,6 @@ 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */, 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */, 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */, - 6C76D6D42E15B91E00EF52C3 /* CodeEditSourceEditor in Frameworks */, 6CCF73D02E26DE3200B94F75 /* SwiftTerm in Frameworks */, 6C315FC82E05E33D0011BFC5 /* CodeEditSourceEditor in Frameworks */, 6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */, @@ -192,10 +192,12 @@ 6C73A6D32D4F1E550012D95C /* CodeEditSourceEditor in Frameworks */, 2816F594280CF50500DD548B /* CodeEditSymbols in Frameworks */, 30CB64942C16CA9100CC8A9E /* LanguageClient in Frameworks */, + 6C8B56492E2FE62E00DC3F29 /* CodeEditSourceEditor in Frameworks */, 5EACE6222DF4BF08005E08B8 /* WelcomeWindow in Frameworks */, 6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */, 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */, 6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */, + 6C77BFBC2E53B6860076827C /* CodeEditSourceEditor in Frameworks */, 6CB94D032CA1205100E8651C /* AsyncAlgorithms in Frameworks */, 6C9DB9E42D55656300ACD86E /* CodeEditSourceEditor in Frameworks */, ); @@ -323,7 +325,6 @@ 6C0617D52BDB4432008C9C42 /* LogStream */, 6C85BB3F2C2105ED00EB5DEF /* CodeEditKit */, 6C85BB432C210EFD00EB5DEF /* SwiftUIIntrospect */, - 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */, 6C0824A02C5C0C9700A0751E /* SwiftTerm */, 6CE21E862C650D2C0031B056 /* SwiftTerm */, 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */, @@ -334,9 +335,10 @@ 5EACE6212DF4BF08005E08B8 /* WelcomeWindow */, 5E4485602DF600D9008BBE69 /* AboutWindow */, 6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */, - 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */, 6CCF6DD22E26D48F00B94F75 /* SwiftTerm */, 6CCF73CF2E26DE3200B94F75 /* SwiftTerm */, + 6C8B56482E2FE62E00DC3F29 /* CodeEditSourceEditor */, + 6C77BFBB2E53B6860076827C /* CodeEditSourceEditor */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -441,8 +443,8 @@ 30ED7B722DD299E600ACC922 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 5EACE6202DF4BF08005E08B8 /* XCRemoteSwiftPackageReference "WelcomeWindow" */, 5E44855F2DF600D9008BBE69 /* XCRemoteSwiftPackageReference "AboutWindow" */, - 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, 6CCF73CE2E26DE3200B94F75 /* XCRemoteSwiftPackageReference "SwiftTerm" */, + 6C77BFBA2E53B6860076827C /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, ); preferredProjectObjectVersion = 55; productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; @@ -1778,7 +1780,7 @@ minimumVersion = 0.2.0; }; }; - 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { + 6C77BFBA2E53B6860076827C /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; requirement = { @@ -1905,9 +1907,9 @@ isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; - 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */ = { + 6C77BFBB2E53B6860076827C /* CodeEditSourceEditor */ = { isa = XCSwiftPackageProductDependency; - package = 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; + package = 6C77BFBA2E53B6860076827C /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; productName = CodeEditSourceEditor; }; 6C7B1C752A1D57CE005CBBFC /* SwiftLint */ = { @@ -1930,6 +1932,10 @@ package = 6C85BB422C210EFD00EB5DEF /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; productName = SwiftUIIntrospect; }; + 6C8B56482E2FE62E00DC3F29 /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + productName = CodeEditSourceEditor; + }; 6C9DB9E32D55656300ACD86E /* CodeEditSourceEditor */ = { isa = XCSwiftPackageProductDependency; package = 6C9DB9E22D55656300ACD86E /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 32dd4e672..9c6e7029c 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mattmassicotte/Queue", "state" : { - "revision" : "8d6f936097888f97011610ced40313655dc5948d", - "version" : "0.1.4" + "revision" : "6adf359a705e3252742905b413bb8f56401043ca", + "version" : "0.2.0" } }, { @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", - "version" : "1.1.3" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -285,8 +285,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tree-sitter/tree-sitter", "state" : { - "revision" : "bf655c0beaf4943573543fa77c58e8006ff34971", - "version" : "0.25.6" + "revision" : "f2f197b6b27ce75c280c20f131d4f71e906b86f7", + "version" : "0.25.8" } }, { diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index bab03a52f..72f81eb7e 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -10,7 +10,6 @@ import Foundation import SwiftUI import UniformTypeIdentifiers import CodeEditSourceEditor -import CodeEditTextView import CodeEditLanguages import Combine import OSLog diff --git a/CodeEdit/Features/Editor/Models/EditorInstance.swift b/CodeEdit/Features/Editor/Models/EditorInstance.swift index fd11333bb..61fba2484 100644 --- a/CodeEdit/Features/Editor/Models/EditorInstance.swift +++ b/CodeEdit/Features/Editor/Models/EditorInstance.swift @@ -27,6 +27,8 @@ class EditorInstance: ObservableObject, Hashable { @Published var replaceText: String? var replaceTextSubject: PassthroughSubject + var autoCompleteCoordinator: AutoCompleteCoordinator? + var rangeTranslator: RangeTranslator = RangeTranslator() private var cancellables: Set = [] @@ -37,6 +39,7 @@ class EditorInstance: ObservableObject, Hashable { self.file = file let url = file.url let editorState = EditorStateRestoration.shared?.restorationState(for: url) + self.autoCompleteCoordinator = AutoCompleteCoordinator(file) findText = workspace?.searchState?.searchQuery findTextSubject = PassthroughSubject() diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index 2aa04497b..5e9bdaa56 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -168,7 +168,8 @@ struct CodeFileView: View { ), highlightProviders: highlightProviders, undoManager: undoRegistration.manager(forFile: editorInstance.file), - coordinators: textViewCoordinators + coordinators: textViewCoordinators, + completionDelegate: editorInstance.autoCompleteCoordinator ) // This view needs to refresh when the codefile changes. The file URL is too stable. .id(ObjectIdentifier(codeFile)) diff --git a/CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteCoordinator.swift b/CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteCoordinator.swift new file mode 100644 index 000000000..547b1e472 --- /dev/null +++ b/CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteCoordinator.swift @@ -0,0 +1,222 @@ +// +// AutoCompleteCoordinator.swift +// CodeEdit +// +// Created by Abe Malla on 9/20/24. +// + +import AppKit +import SwiftTreeSitter +import CodeEditTextView +import CodeEditSourceEditor +import LanguageServerProtocol + +class AutoCompleteCoordinator { + /// A reference to the file we are working with, to be able to query file information + private weak var file: CEWorkspaceFile? + + /// The current TreeSitter node that the main cursor is at + private var currentNode: SwiftTreeSitter.Node? + /// The current filter text based on partial token input + private var currentFilterText: String = "" + /// Stores the unfiltered completion items + private var completionItems: [AutoCompleteItem] = [] + /// Set to true when the server sends an incomplete list, indicating that we should not filter client-side. + private var receivedIncompleteCompletionItems: Bool = false + + init(_ file: CEWorkspaceFile) { + self.file = file + } + + private func fetchCompletions(position: Position) async -> [AutoCompleteItem] { + let workspace = await file?.fileDocument?.findWorkspace() + guard let file, + let workspacePath = workspace?.fileURL?.absoluteURL.path(), + let language = await file.fileDocument?.getLanguage().lspLanguage else { + return [] + } + + @Service var lspService: LSPService + guard let client = await lspService.languageClient( + for: language, + workspacePath: workspacePath + ) else { + return [] + } + + do { + let completions = try await client.requestCompletion( + for: file.url.lspURI, + position: position + ) + + // Extract the completion items list + switch completions { + case .optionA(let completionItems): + return completionItems.map { AutoCompleteItem($0) }.sorted() + case .optionB(let completionList): + receivedIncompleteCompletionItems = receivedIncompleteCompletionItems || completionList.isIncomplete + return completionList.items.map { AutoCompleteItem($0) }.sorted() + case .none: + return [] + } + } catch { + return [] + } + } + + /// Filters completion items based on the current partial token input + private func filterCompletionItems(_ items: [AutoCompleteItem]) -> [AutoCompleteItem] { + guard !currentFilterText.isEmpty, !receivedIncompleteCompletionItems else { + return items.sorted() + } + + let items = items + .map { ($0.fuzzyMatch(query: currentFilterText), $0) } + .compactMap { $0.0.weight > 0 ? $0.1 : nil } + + return items.sorted() + } +} + +extension AutoCompleteCoordinator: CodeSuggestionDelegate { + @MainActor + func completionSuggestionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition + ) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? { + currentFilterText = "" + let tokenSubstringCount = findTreeSitterNodeAtPosition(textView: textView, cursorPosition: cursorPosition) + + let textPosition = Position(line: cursorPosition.start.line - 1, character: cursorPosition.start.column - 1) + + // If we are asking for completions in the middle of a token, then + // query the language server for completion items at the start of the token + // but *only* if we haven't received an incomplete response. + let queryPosition = if currentNode != nil && !receivedIncompleteCompletionItems { + Position( + line: cursorPosition.start.line - 1, + character: cursorPosition.start.column - tokenSubstringCount - 1 + ) + } else { + textPosition + } + completionItems = await fetchCompletions(position: queryPosition) + + if receivedIncompleteCompletionItems && queryPosition != textPosition { + // We need to re-request this. We've requested the wrong location and since know that the server + // returns incomplete items (meaning we can't filter them ourselves). + return await completionSuggestionsRequested(textView: textView, cursorPosition: cursorPosition) + } + + // If we can detect that we're in a node, we still want to adjust the panel to be in the correct position + let cursorPosition: CursorPosition = if currentNode != nil { + CursorPosition( + line: cursorPosition.start.line, + column: cursorPosition.start.column - tokenSubstringCount + ) + } else { + CursorPosition( + line: queryPosition.line + 1, + column: queryPosition.character + 1 + ) + } + + return (cursorPosition, filterCompletionItems(completionItems)) + } + + func findTreeSitterNodeAtPosition(textView: TextViewController, cursorPosition: CursorPosition) -> Int { + var tokenSubstringCount = 0 + let prefixRange = NSRange(location: cursorPosition.range.location - 1, length: 1) + guard prefixRange.location >= 0 else { return 0 } + do { + if let token = try textView.treeSitterClient?.nodesAt(range: prefixRange).first, + token.node.isNamed { + currentNode = token.node + + // Get the string from the start of the token to the location of the cursor + if cursorPosition.range.location > token.node.range.location { + let selectedRange = NSRange( + location: token.node.range.location, + length: cursorPosition.range.location - token.node.range.location + ) + if let tokenSubstring = textView.textView.textStorage?.substring(from: selectedRange) { + currentFilterText = tokenSubstring + tokenSubstringCount = tokenSubstring.count + } + } + } + } catch { + print("Error getting TreeSitter node: \(error)") + } + return tokenSubstringCount + } + + func completionOnCursorMove( + textView: TextViewController, + cursorPosition: CursorPosition + ) -> [CodeSuggestionEntry]? { + guard var currentNode = currentNode, !completionItems.isEmpty, !receivedIncompleteCompletionItems else { + return nil + } + _ = findTreeSitterNodeAtPosition(textView: textView, cursorPosition: cursorPosition) + guard let refreshedNode = self.currentNode else { return nil } + if refreshedNode.range.intersection(currentNode.range) == nil { + return nil + } + currentNode = refreshedNode + + // Moving to a new token requires a new call to the language server + // We extend the range so that the `contains` can include the end value of + // the token, since its check is exclusive. + if !currentNode.range.contains(cursorPosition.range.location) + && currentNode.range.max != cursorPosition.range.location { + return nil + } + + // Check if cursor is at the start of the token + if cursorPosition.range.location == currentNode.range.location { + currentFilterText = "" + return completionItems + } + + // Filter through the completion items based on how far the cursor is in the token + if cursorPosition.range.location > currentNode.range.location { + let selectedRange = NSRange( + location: currentNode.range.location, + length: cursorPosition.range.location - currentNode.range.location + ) + if let tokenSubstring = textView.textView.textStorage?.substring(from: selectedRange) { + currentFilterText = tokenSubstring + return filterCompletionItems(completionItems) + } + } + + return nil + } + + /// Takes a `CompletionItem` and modifies the text view with the new string + func completionWindowApplyCompletion( + item: CodeSuggestionEntry, + textView: TextViewController, + cursorPosition: CursorPosition? + ) { + guard let cursorPosition, let item = item as? AutoCompleteItem else { return } + + // Make the updates + let replacementRange = currentNode?.range ?? cursorPosition.range + let insertText = LSPCompletionItemsUtil.getInsertText(from: item) + textView.textView.replaceCharacters(in: replacementRange, with: insertText) + + // Set cursor position to end of inserted text + let newCursorRange = NSRange(location: replacementRange.location + insertText.count, length: 0) + textView.setCursorPositions([CursorPosition(range: newCursorRange)]) + } + + func completionWindowDidSelect(item: CodeSuggestionEntry) { } + + func completionWindowDidClose() { + currentNode = nil + currentFilterText = "" + } +} diff --git a/CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteItem.swift b/CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteItem.swift new file mode 100644 index 000000000..0cdab9a9c --- /dev/null +++ b/CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteItem.swift @@ -0,0 +1,73 @@ +// +// AutoCompleteItem.swift +// CodeEdit +// +// Created by Khan Winter on 7/25/25. +// + +import SwiftUI +import CodeEditSourceEditor +import LanguageServerProtocol + +/// A Near 1:1 of `LanguageServerProtocol`'s `CompletionItem`. Wrapped for compatibility with the CESE's +/// `CodeSuggestionEntry` protocol to deal with some optional bools. +struct AutoCompleteItem: Hashable, Sendable, CodeSuggestionEntry, Comparable { + let label: String + let kind: CompletionItemKind? + let detail: String? + var documentation: String? + let deprecated: Bool + let preselect: Bool + let sortText: String? + let filterText: String? + let insertText: String? + let insertTextFormat: InsertTextFormat? + let textEdit: TwoTypeOption? + let additionalTextEdits: [TextEdit]? + let commitCharacters: [String]? + let command: LanguageServerProtocol.Command? + let data: LSPAny? + + // Not used by regular autocomplete items + public var pathComponents: [String]? { nil } + public var targetPosition: CursorPosition? { nil } + + // LSP Spec says the `detail` field holds useful syntax information about the item for completion. + public var sourcePreview: String? { detail } + + public var image: Image { Image(systemName: kind?.symbolName ?? "dot.square.fill") } + public var imageColor: SwiftUI.Color { kind?.swiftUIColor ?? SwiftUI.Color.gray } + + init(_ item: CompletionItem) { + self.label = item.label + self.kind = item.kind + self.detail = item.detail + self.documentation = switch item.documentation { + case .optionA(let string): + string + case .optionB(let markup): + markup.value + case .none: + nil + } + self.deprecated = item.deprecated ?? false + self.preselect = item.preselect ?? false + self.sortText = item.sortText + self.filterText = item.filterText + self.insertText = item.insertText + self.insertTextFormat = item.insertTextFormat + self.textEdit = item.textEdit + self.additionalTextEdits = item.additionalTextEdits + self.commitCharacters = item.commitCharacters + self.command = item.command + self.data = item.data + } + + static func < (lhs: AutoCompleteItem, rhs: AutoCompleteItem) -> Bool { + lhs.sortText ?? lhs.label < rhs.sortText ?? rhs.label + } +} + +extension AutoCompleteItem: FuzzySearchable { + var searchableString: String { filterText ?? label } +} diff --git a/CodeEdit/Features/LSP/LSPUtil.swift b/CodeEdit/Features/LSP/LSPUtil.swift index 740a82104..5a23f99ab 100644 --- a/CodeEdit/Features/LSP/LSPUtil.swift +++ b/CodeEdit/Features/LSP/LSPUtil.swift @@ -38,6 +38,22 @@ enum LSPCompletionItemsUtil { return edits } + static func getInsertText(from completionItem: AutoCompleteItem) -> String { + // According to LSP spec, textEdit takes precedence if present, then insertText, then label + if let textEdit = completionItem.textEdit { + switch textEdit { + case .optionA(let edit): + return edit.newText + case .optionB(let insertReplaceEdit): + return insertReplaceEdit.newText + } + } + if let insertText = completionItem.insertText { + return insertText + } + return completionItem.label + } + private static func editOrReplaceItem(edit: TwoTypeOption, _ edits: inout [TextEdit]) { switch edit { case .optionA(let textEdit): diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift index d1c87f0ee..0db3dfb9c 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift @@ -165,7 +165,7 @@ class LanguageServer { insertTextModeSupport: ValueSet(valueSet: [InsertTextMode.adjustIndentation]), labelDetailsSupport: true ), - completionItemKind: ValueSet(valueSet: [CompletionItemKind.text, CompletionItemKind.method]), + completionItemKind: ValueSet(valueSet: CompletionItemKind.allCases), contextSupport: true, insertTextMode: InsertTextMode.asIs, completionList: CompletionClientCapabilities.CompletionList( diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 559413ec2..f51ba105b 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -35,69 +35,6 @@ import CodeEditLanguages /// ) /// try await lspService.stopServer(for: .python) /// ``` -/// -/// ## Completion Example -/// -/// ```swift -/// func testCompletion() async throws { -/// do { -/// guard var languageClient = self.languageClient(for: .python) else { -/// print("Failed to get client") -/// throw LSPServiceError.languageClientNotFound -/// } -/// -/// let testFilePathStr = "" -/// let testFileURL = URL(fileURLWithPath: testFilePathStr) -/// -/// // Tell server we opened a document -/// _ = await languageClient.addDocument(testFileURL) -/// -/// // Completion example -/// let textPosition = Position(line: 32, character: 18) // Lines and characters start at 0 -/// let completions = try await languageClient.requestCompletion( -/// document: testFileURL.lspURI, -/// position: textPosition -/// ) -/// switch completions { -/// case .optionA(let completionItems): -/// // Handle the case where completions is an array of CompletionItem -/// print("\n*******\nCompletion Items:\n*******\n") -/// for item in completionItems { -/// let textEdits = LSPCompletionItemsUtil.getCompletionItemEdits( -/// startPosition: textPosition, -/// item: item -/// ) -/// for edit in textEdits { -/// print(edit) -/// } -/// } -/// -/// case .optionB(let completionList): -/// // Handle the case where completions is a CompletionList -/// print("\n*******\nCompletion Items:\n*******\n") -/// for item in completionList.items { -/// let textEdits = LSPCompletionItemsUtil.getCompletionItemEdits( -/// startPosition: textPosition, -/// item: item -/// ) -/// for edit in textEdits { -/// print(edit) -/// } -/// } -/// -/// print(completionList.items[0]) -/// -/// case .none: -/// print("No completions found") -/// } -/// -/// // Close the document -/// _ = await languageClient.closeDocument(testFilePathStr) -/// } catch { -/// print(error) -/// } -/// } -/// ``` @MainActor final class LSPService: ObservableObject { typealias LanguageServerType = LanguageServer @@ -192,14 +129,42 @@ final class LSPService: ObservableObject { throw LSPError.binaryNotFound } + let taskUuidString = UUID().uuidString + + // Log start message to the activity viewer + let createInfo: [String: Any] = [ + "id": taskUuidString, + "action": "create", + "title": "Starting \(languageId.rawValue) language server", + "isLoading": true + ] logger.info("Starting \(languageId.rawValue) language server") + + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: createInfo) + let server = try await LanguageServerType.createServer( for: languageId, with: serverBinary, workspacePath: workspacePath ) languageClients[ClientKey(languageId, workspacePath)] = server + + // Log success message update + let updateInfo: [String: Any] = [ + "id": taskUuidString, + "action": "update", + "title": "Successfully started \(languageId.rawValue) language server", + "isLoading": false + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: updateInfo) + + let deleteInfo: [String: Any] = [ + "id": taskUuidString, + "action": "deleteWithDelay", + "delay": 4.0 + ] logger.info("Successfully started \(languageId.rawValue) language server") + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteInfo) self.startListeningToEvents(for: ClientKey(languageId, workspacePath)) return server diff --git a/CodeEdit/Features/LSP/Views/CompletionItemKind.swift b/CodeEdit/Features/LSP/Views/CompletionItemKind.swift new file mode 100644 index 000000000..288def1a8 --- /dev/null +++ b/CodeEdit/Features/LSP/Views/CompletionItemKind.swift @@ -0,0 +1,90 @@ +// +// CompletionItemKind.swift +// CodeEdit +// +// Created by Abe Malla on 10/05/24. +// + +import SwiftUI +import LanguageServerProtocol + +extension CompletionItemKind { + var symbolName: String { + switch self { + case .text: + "t.square.fill" + case .method, .module: + "m.square.fill" + case .function: + "curlybraces.square.fill" + case .constructor, .interface: + "i.square.fill" + case .field, .class, .color: + "c.square.fill" + case .variable: + "v.square.fill" + case .property: + "p.square.fill" + case .unit: + "u.square.fill" + case .value: + "n.square.fill" + case .enum, .enumMember, .event: + "e.square.fill" + case .keyword, .constant: + "k.square.fill" + case .snippet, .struct: + "s.square.fill" + case .file: + "d.square.fill" + case .reference: + "r.square.fill" + case .folder: + "f.square.fill" + case .operator: + "plus.slash.minus" + case .typeParameter: + "t.square.fill" + } + } + + var swiftUIColor: SwiftUI.Color { + switch self { + case .text, + .function, + .interface, + .module, + .unit, + .value, + .color, + .file, + .reference, + .folder, + .enumMember, + .constant, + .struct, + .event, + .operator, + .typeParameter: + Color.blue + case .variable: + Color.green + case .method: + Color.cyan + case .constructor: + Color.teal + case .field: + Color.indigo + case .class: + Color.pink + case .property: + Color.purple + case .enum: + Color.mint + case .keyword: + Color.pink + case .snippet: + Color.purple + } + } +} diff --git a/CodeEdit/Features/Search/FuzzySearch/Collection+FuzzySearch.swift b/CodeEdit/Features/Search/FuzzySearch/Collection+FuzzySearch.swift index 828866e39..13b37c283 100644 --- a/CodeEdit/Features/Search/FuzzySearch/Collection+FuzzySearch.swift +++ b/CodeEdit/Features/Search/FuzzySearch/Collection+FuzzySearch.swift @@ -26,4 +26,22 @@ extension Collection where Iterator.Element: FuzzySearchable { $0.result.weight > $1.result.weight } } + + /// Synchronously performs a fuzzy search on a collection of elements conforming to FuzzySearchable. + /// + /// - Parameter query: The query string to match against the elements. + /// + /// - Returns: An array of tuples containing FuzzySearchMatchResult and the corresponding element. + /// + /// - Note: Because this is an extension on Collection and not only array, + /// you can also use this on sets. + func fuzzySearchSync(query: String) -> [(result: FuzzySearchMatchResult, item: Iterator.Element)] { + return map { + (result: $0.fuzzyMatch(query: query), item: $0) + }.filter { + $0.result.weight > 0 + }.sorted { + $0.result.weight > $1.result.weight + } + } }