From ab126d5da8b87f1577f639d73d510dc8359e63a7 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 19 Oct 2024 22:31:39 -0700 Subject: [PATCH 01/10] Autocomplete Coordinator --- CodeEdit.xcodeproj/project.pbxproj | 50 ++++---- .../xcshareddata/swiftpm/Package.resolved | 34 ++---- .../CodeFileDocument/CodeFileDocument.swift | 1 - .../Editor/AutoCompleteCoordinator.swift | 114 ++++++++++++++++++ .../Features/Editor/Views/CodeFileView.swift | 6 +- .../LanguageServerFileMap.swift | 4 +- .../Features/LSP/Views/CompletionItem.swift | 30 +++++ .../LSP/Views/CompletionItemKind.swift | 85 +++++++++++++ 8 files changed, 268 insertions(+), 56 deletions(-) create mode 100644 CodeEdit/Features/Editor/AutoCompleteCoordinator.swift create mode 100644 CodeEdit/Features/LSP/Views/CompletionItem.swift create mode 100644 CodeEdit/Features/LSP/Views/CompletionItemKind.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 0d697ab9fe..c916577a08 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 55; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -64,6 +64,7 @@ 3000516A2BBD3A8200A98562 /* ServiceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300051692BBD3A8200A98562 /* ServiceType.swift */; }; 3000516C2BBD3A9500A98562 /* ServiceWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3000516B2BBD3A9500A98562 /* ServiceWrapper.swift */; }; 3026F50F2AC006C80061227E /* InspectorAreaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3026F50E2AC006C80061227E /* InspectorAreaViewModel.swift */; }; + 3046374E2CB15FA900180667 /* AutoCompleteCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3046374D2CB15F9200180667 /* AutoCompleteCoordinator.swift */; }; 30AB4EBB2BF718A100ED4431 /* DeveloperSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EBA2BF718A100ED4431 /* DeveloperSettings.swift */; }; 30AB4EBD2BF71CA800ED4431 /* DeveloperSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EBC2BF71CA800ED4431 /* DeveloperSettingsView.swift */; }; 30AB4EC22BF7253200ED4431 /* KeyValueTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EC12BF7253200ED4431 /* KeyValueTable.swift */; }; @@ -463,10 +464,10 @@ 6CD26C7B2C8EA8A500ADBA38 /* LSPCache+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C792C8EA8A500ADBA38 /* LSPCache+Data.swift */; }; 6CD26C7D2C8EA8F400ADBA38 /* LanguageServer+DocumentSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C7C2C8EA8F400ADBA38 /* LanguageServer+DocumentSync.swift */; }; 6CD26C812C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C802C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift */; }; - 6CD26C852C8F907800ADBA38 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */; }; + 6CD26C852C8F907800ADBA38 /* (null) in Frameworks */ = {isa = PBXBuildFile; }; 6CD26C872C8F90FD00ADBA38 /* LazyServiceWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C862C8F90FD00ADBA38 /* LazyServiceWrapper.swift */; }; 6CD26C8A2C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C892C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift */; }; - 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */; }; + 6CD3CA552C8B508200D83DCD /* (null) in Frameworks */ = {isa = PBXBuildFile; }; 6CDA84AD284C1BA000C1CC3A /* EditorTabBarContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDA84AC284C1BA000C1CC3A /* EditorTabBarContextMenu.swift */; }; 6CE21E812C643D8F0031B056 /* CETerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CE21E802C643D8F0031B056 /* CETerminalView.swift */; }; 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6CE21E862C650D2C0031B056 /* SwiftTerm */; }; @@ -742,6 +743,7 @@ 300051692BBD3A8200A98562 /* ServiceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceType.swift; sourceTree = ""; }; 3000516B2BBD3A9500A98562 /* ServiceWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceWrapper.swift; sourceTree = ""; }; 3026F50E2AC006C80061227E /* InspectorAreaViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorAreaViewModel.swift; sourceTree = ""; }; + 3046374D2CB15F9200180667 /* AutoCompleteCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteCoordinator.swift; sourceTree = ""; }; 30AB4EBA2BF718A100ED4431 /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = ""; }; 30AB4EBC2BF71CA800ED4431 /* DeveloperSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsView.swift; sourceTree = ""; }; 30AB4EC12BF7253200ED4431 /* KeyValueTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyValueTable.swift; sourceTree = ""; }; @@ -1284,6 +1286,10 @@ EC0870F62A455F6400EB8692 /* ProjectNavigatorViewController+NSMenuDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectNavigatorViewController+NSMenuDelegate.swift"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 302EFC1F2CC3C034004A74DF /* Views */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Views; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 2BE487E928245162003F3F64 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -1301,7 +1307,7 @@ 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */, 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */, 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */, - 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */, + 6CD3CA552C8B508200D83DCD /* (null) in Frameworks */, 6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */, 6CC17B4F2C432AE000834E2C /* CodeEditSourceEditor in Frameworks */, 30CB64912C16CA8100CC8A9E /* LanguageServerProtocol in Frameworks */, @@ -1310,7 +1316,7 @@ 6C85BB442C210EFD00EB5DEF /* SwiftUIIntrospect in Frameworks */, 6CB446402B6DFF3A00539ED0 /* CodeEditSourceEditor in Frameworks */, 2816F594280CF50500DD548B /* CodeEditSymbols in Frameworks */, - 6CD26C852C8F907800ADBA38 /* CodeEditSourceEditor in Frameworks */, + 6CD26C852C8F907800ADBA38 /* (null) in Frameworks */, 30CB64942C16CA9100CC8A9E /* LanguageClient in Frameworks */, 6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */, 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */, @@ -1560,6 +1566,7 @@ children = ( 6CD26C732C8EA71F00ADBA38 /* LanguageServer */, 6CD26C742C8EA79100ADBA38 /* Service */, + 302EFC1F2CC3C034004A74DF /* Views */, 30B087FA2C0D53080063A882 /* LSPUtil.swift */, ); path = LSP; @@ -2823,6 +2830,7 @@ 287776EB27E350BA00D46668 /* TabBar */, B67660642AA970ED00CD56B0 /* Models */, B67660632AA970E300CD56B0 /* Views */, + 3046374D2CB15F9200180667 /* AutoCompleteCoordinator.swift */, ); path = Editor; sourceTree = ""; @@ -3675,6 +3683,9 @@ 6C7B1C762A1D57CE005CBBFC /* PBXTargetDependency */, 2BE487F328245162003F3F64 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 302EFC1F2CC3C034004A74DF /* Views */, + ); name = CodeEdit; packageProductDependencies = ( 2816F593280CF50500DD548B /* CodeEditSymbols */, @@ -3692,8 +3703,6 @@ 6C0824A02C5C0C9700A0751E /* SwiftTerm */, 6CE21E862C650D2C0031B056 /* SwiftTerm */, 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */, - 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */, - 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */, 6CB94D022CA1205100E8651C /* AsyncAlgorithms */, ); productName = CodeEdit; @@ -3791,8 +3800,8 @@ 303E88452C276FD100EEA8D9 /* XCRemoteSwiftPackageReference "LanguageClient" */, 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, - 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, + 302EFBFB2CC3284D004A74DF /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */, ); productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; projectDirPath = ""; @@ -4162,6 +4171,7 @@ 58A5DFA229339F6400D1BD5D /* KeybindingManager.swift in Sources */, B62AEDB32A1FD95B009A9F52 /* UtilityAreaTerminalView.swift in Sources */, 661EF7BD2BEE215300C3E577 /* LoadingFileView.swift in Sources */, + 3046374E2CB15FA900180667 /* AutoCompleteCoordinator.swift in Sources */, 58AFAA2E2933C69E00482B53 /* EditorTabRepresentable.swift in Sources */, 6C4104E6297C884F00F472BA /* AboutDetailView.swift in Sources */, 6C6BD6F129CD13FA00235D17 /* ExtensionDiscovery.swift in Sources */, @@ -5558,6 +5568,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 302EFBFB2CC3284D004A74DF /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../CodeEditSourceEditor; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 2816F592280CF50500DD548B /* XCRemoteSwiftPackageReference "CodeEditSymbols" */ = { isa = XCRemoteSwiftPackageReference; @@ -5687,14 +5704,6 @@ version = 1.0.1; }; }; - 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.8.1; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -5789,15 +5798,6 @@ isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; - 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */ = { - isa = XCSwiftPackageProductDependency; - package = 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; - productName = CodeEditSourceEditor; - }; - 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */ = { - isa = XCSwiftPackageProductDependency; - productName = CodeEditSourceEditor; - }; 6CE21E862C650D2C0031B056 /* SwiftTerm */ = { isa = XCSwiftPackageProductDependency; productName = SwiftTerm; diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a723cdba10..06161ebddf 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5c4a5d433333474763817b9804d7f1856ab3b416ed87b190a2bd6e86c0c9834c", + "originHash" : "ebc53976f916f8c118f851ec7fe31170fe35ad33238a18b10b0fb7a4ba86f0b2", "pins" : [ { "identity" : "anycodable", @@ -13,7 +13,7 @@ { "identity" : "codeeditkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditKit.git", + "location" : "https://github.com/CodeEditApp/CodeEditKit", "state" : { "revision" : "ad28213a968586abb0cb21a8a56a3587227895f1", "version" : "0.1.2" @@ -28,15 +28,6 @@ "version" : "0.1.19" } }, - { - "identity" : "codeeditsourceeditor", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", - "state" : { - "revision" : "033b68d3e3e845984fbc3d405720d5cc6ce61f71", - "version" : "0.8.1" - } - }, { "identity" : "codeeditsymbols", "kind" : "remoteSourceControl", @@ -46,15 +37,6 @@ "version" : "0.2.2" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "2619cb945b4d6c2fc13f22ba873ba891f552b0f3", - "version" : "0.7.6" - } - }, { "identity" : "collectionconcurrencykit", "kind" : "remoteSourceControl", @@ -168,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/Semaphore", "state" : { - "revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2", - "version" : "0.1.0" + "revision" : "f1c4a0acabeb591068dea6cffdd39660b86dec28", + "version" : "0.0.8" } }, { @@ -195,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", - "version" : "1.1.3" + "revision" : "ee97538f5b81ae89698fd95938896dec5217b148", + "version" : "1.1.1" } }, { @@ -248,8 +230,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/siteline/SwiftUI-Introspect.git", "state" : { - "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", - "version" : "1.3.0" + "revision" : "668a65735751432b640260c56dfa621cec568368", + "version" : "1.2.0" } }, { diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index e3afda72c1..d7760893a6 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/AutoCompleteCoordinator.swift b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift new file mode 100644 index 0000000000..4ba8901680 --- /dev/null +++ b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift @@ -0,0 +1,114 @@ +// +// AutoCompleteCoordinator.swift +// CodeEdit +// +// Created by Abe Malla on 9/20/24. +// + +import AppKit +import CodeEditTextView +import CodeEditSourceEditor +import LanguageServerProtocol + +class AutoCompleteCoordinator: TextViewCoordinator { + private weak var textViewController: TextViewController? + private var localEventMonitor: Any? + + private let itemBoxController = ItemBoxWindowController() + + func prepareCoordinator(controller: TextViewController) { + itemBoxController.close() + self.textViewController = controller + + localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + // `ctrl + space` keyboard shortcut listener for the item box to show + if event.modifierFlags.contains(.control) && event.charactersIgnoringModifiers == " " { + self.showAutocompleteWindow() + return nil + } + return event + } + } + + func showAutocompleteWindow() { + guard let cursorPos = textViewController?.cursorPositions.last, + let textView = textViewController?.textView, + let window = NSApplication.shared.keyWindow, + !itemBoxController.isVisible + else { + return + } + + itemBoxController.items = [ + CompletionItem(label: "item1", kind: .class), + CompletionItem(label: "item2", kind: .enum), + CompletionItem(label: "item3", kind: .function), + CompletionItem(label: "item4", kind: .color), + CompletionItem(label: "item5", kind: .constant), + CompletionItem(label: "item6", kind: .constructor), + CompletionItem(label: "item7", kind: .enumMember), + CompletionItem(label: "item8", kind: .field), + CompletionItem(label: "item9", kind: .file), + CompletionItem(label: "item10", kind: .folder), + CompletionItem(label: "item11", kind: .snippet), + CompletionItem(label: "item12", kind: .reference), + ] + + // Reset the size of the window + let windowSize = ItemBoxWindowController.DEFAULT_SIZE + itemBoxController.window?.setContentSize(windowSize) + + let cursorRect = textView.firstRect(forCharacterRange: cursorPos.range, actualRange: nil) + let screenFrame = window.screen!.visibleFrame + let padding: CGFloat = 22 + var autocompleteWindowOrigin = NSPoint( + x: cursorRect.origin.x, + y: cursorRect.origin.y + ) + + // Keep the horizontal position within the screen and some padding + let minX = screenFrame.minX + padding + let maxX = screenFrame.maxX - windowSize.width - padding + + if autocompleteWindowOrigin.x < minX { + autocompleteWindowOrigin.x = minX + } else if autocompleteWindowOrigin.x > maxX { + autocompleteWindowOrigin.x = maxX + } + + // Check if the window will go below the screen + // We determine whether the window drops down or upwards by choosing which + // corner of the window we will position: `setFrameOrigin` or `setFrameTopLeftPoint` + if autocompleteWindowOrigin.y - windowSize.height < screenFrame.minY { + // If the cursor itself if below the screen, then position the window + // at the bottom of the screen with some padding + if autocompleteWindowOrigin.y < screenFrame.minY { + autocompleteWindowOrigin.y = screenFrame.minY + padding + } else { + // Place above the cursor + autocompleteWindowOrigin.y += cursorRect.height + } + + itemBoxController.window?.setFrameOrigin(autocompleteWindowOrigin) + } else { + // If the window goes above the screen, position it below the screen with padding + let maxY = screenFrame.maxY - padding + if autocompleteWindowOrigin.y > maxY { + autocompleteWindowOrigin.y = maxY + } + + itemBoxController.window?.setFrameTopLeftPoint(autocompleteWindowOrigin) + } + + itemBoxController.showWindow(attachedTo: window) + } + + deinit { + print("Destroyed AutoCompleteCoordinator") + itemBoxController.close() + if let localEventMonitor = localEventMonitor { + NSEvent.removeMonitor(localEventMonitor) + self.localEventMonitor = nil + } + } +} diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index 1a0ae19c98..698acd736a 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -21,6 +21,8 @@ struct CodeFileView: View { /// Any coordinators passed to the view. private var textViewCoordinators: [TextViewCoordinator] + /// The coordinator that manages the autocomplete window (item box) + private let autocompleteCoordinator = AutoCompleteCoordinator() @AppSettings(\.textEditing.defaultTabWidth) var defaultTabWidth @@ -58,7 +60,8 @@ struct CodeFileView: View { self._codeFile = .init(wrappedValue: codeFile) self.textViewCoordinators = textViewCoordinators + [ codeFile.contentCoordinator, - codeFile.languageServerCoordinator + codeFile.languageServerCoordinator, + autocompleteCoordinator, ] self.isEditable = isEditable @@ -139,7 +142,6 @@ struct CodeFileView: View { undoManager: undoManager, coordinators: textViewCoordinators ) - .id(codeFile.fileURL) .background { if colorScheme == .dark { diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift index 0f3d4469f7..7da7fd434f 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift @@ -47,8 +47,8 @@ class LanguageServerFileMap { } func incrementVersion(for uri: DocumentUri) -> Int { - trackedDocumentVersions[uri] = (trackedDocumentVersions[uri] ?? 0) + 1 - return trackedDocumentVersions[uri] ?? 0 + trackedDocumentVersions[uri, default: 0] += 1 + return trackedDocumentVersions[uri, default: 1] } func documentVersion(for document: CodeFileDocument) -> Int? { diff --git a/CodeEdit/Features/LSP/Views/CompletionItem.swift b/CodeEdit/Features/LSP/Views/CompletionItem.swift new file mode 100644 index 0000000000..65b276f4d7 --- /dev/null +++ b/CodeEdit/Features/LSP/Views/CompletionItem.swift @@ -0,0 +1,30 @@ +// +// CompletionItem.swift +// CodeEdit +// +// Created by Abe Malla on 10/05/24. +// + +import SwiftUI +import CodeEditTextView +import LanguageServerProtocol +import CodeEditSourceEditor + +extension CompletionItem: @retroactive ItemBoxEntry { + public var view: NSView { + NSHostingView(rootView: HStack(spacing: 0) { + Image(systemName: CompletionItemKind.toSymbolName(kind: self.kind)) + .font(.system(size: 16)) + .foregroundStyle(.white, CompletionItemKind.toSymbolColor(kind: self.kind)) + .padding(0) + .padding(.trailing, 2) + + Text(label) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(0) + + Spacer() + }) + } +} diff --git a/CodeEdit/Features/LSP/Views/CompletionItemKind.swift b/CodeEdit/Features/LSP/Views/CompletionItemKind.swift new file mode 100644 index 0000000000..6f2144a2a8 --- /dev/null +++ b/CodeEdit/Features/LSP/Views/CompletionItemKind.swift @@ -0,0 +1,85 @@ +// +// CompletionItemKind.swift +// CodeEdit +// +// Created by Abe Malla on 10/05/24. +// + +import SwiftUI +import LanguageServerProtocol + +extension CompletionItemKind { + static func toSymbolName(kind: CompletionItemKind?) -> String { + let defaultSymbol = "dot.square.fill" + + guard let kind = kind else { + return defaultSymbol + } + + let symbolMap: [CompletionItemKind: String] = [ + .text: "t.square.fill", + .method: "m.square.fill", + .function: "curlybraces.square.fill", + .constructor: "i.square.fill", + .field: "c.square.fill", + .variable: "v.square.fill", + .class: "c.square.fill", + .interface: "i.square.fill", + .module: "m.square.fill", + .property: "p.square.fill", + .unit: "u.square.fill", + .value: "n.square.fill", + .enum: "e.square.fill", + .keyword: "k.square.fill", + .snippet: "s.square.fill", + .color: "c.square.fill", + .file: "d.square.fill", + .reference: "r.square.fill", + .folder: "f.square.fill", + .enumMember: "e.square.fill", + .constant: "k.square.fill", + .struct: "s.square.fill", + .event: "e.square.fill", + .operator: "plus.slash.minus", + .typeParameter: "t.square.fill" + ] + return symbolMap[kind] ?? defaultSymbol + } + + static func toSymbolColor(kind: CompletionItemKind?) -> SwiftUICore.Color { + let defaultColor = Color.gray + + guard let kind = kind else { + return defaultColor + } + + let symbolMap: [CompletionItemKind: SwiftUICore.Color] = [ + .text: Color.blue, + .method: Color.blue, + .function: Color.blue, + .constructor: Color.teal, + .field: Color.blue, + .variable: Color.blue, + .class: Color.pink, + .interface: Color.blue, + .module: Color.blue, + .property: Color.secondary, + .unit: Color.blue, + .value: Color.blue, + .enum: Color.blue, + .keyword: Color.blue, + .snippet: Color.blue, + .color: Color.blue, + .file: Color.blue, + .reference: Color.blue, + .folder: Color.blue, + .enumMember: Color.blue, + .constant: Color.blue, + .struct: Color.blue, + .event: Color.blue, + .operator: Color.blue, + .typeParameter: Color.blue, + ] + return symbolMap[kind] ?? defaultColor + } +} From 02fb3b5bb21ddc7701b5ae4d5bbfc463dbecafe5 Mon Sep 17 00:00:00 2001 From: Abe M Date: Tue, 17 Dec 2024 23:24:28 -0800 Subject: [PATCH 02/10] ItemBox updates --- .../xcshareddata/swiftpm/Package.resolved | 19 ++- .../Editor/AutoCompleteCoordinator.swift | 121 +++++++++--------- .../Editor/Models/EditorInstance.swift | 2 + .../Features/Editor/Views/CodeFileView.swift | 3 - .../Editor/Views/EditorAreaView.swift | 6 +- .../LSP/Service/LSPService+Events.swift | 100 +++++++-------- .../Features/LSP/Service/LSPService.swift | 28 ++++ .../Features/LSP/Views/CompletionItem.swift | 84 ++++++++++-- .../LSP/Views/CompletionItemKind.swift | 12 +- 9 files changed, 237 insertions(+), 138 deletions(-) diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 06161ebddf..0eba906d65 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ebc53976f916f8c118f851ec7fe31170fe35ad33238a18b10b0fb7a4ba86f0b2", + "originHash" : "bb72acfad31b288599b6721256b508d8209ba1bc1d7ab0fff6a358d49a1deae0", "pins" : [ { "identity" : "anycodable", @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditLanguages.git", "state" : { - "revision" : "5b27f139269e1ea49ceae5e56dca44a3ccad50a1", - "version" : "0.1.19" + "revision" : "331d5dbc5fc8513be5848fce8a2a312908f36a11", + "version" : "0.1.20" } }, { @@ -221,8 +221,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/SwiftTreeSitter.git", "state" : { - "revision" : "2599e95310b3159641469d8a21baf2d3d200e61f", - "version" : "0.8.0" + "revision" : "36aa61d1b531f744f35229f010efba9c6d6cbbdd", + "version" : "0.9.0" } }, { @@ -251,6 +251,15 @@ "revision" : "8dc9148b46fcf93b08ea9d4ef9bdb5e4f700e008", "version" : "0.9.0" } + }, + { + "identity" : "tree-sitter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter", + "state" : { + "revision" : "d97db6d63507eb62c536bcb2c4ac7d70c8ec665e", + "version" : "0.23.2" + } } ], "version" : 3 diff --git a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift index 4ba8901680..eacb50a590 100644 --- a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift +++ b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift @@ -14,10 +14,12 @@ class AutoCompleteCoordinator: TextViewCoordinator { private weak var textViewController: TextViewController? private var localEventMonitor: Any? - private let itemBoxController = ItemBoxWindowController() + private var itemBoxController: ItemBoxWindowController? func prepareCoordinator(controller: TextViewController) { - itemBoxController.close() + itemBoxController = ItemBoxWindowController() + itemBoxController?.delegate = self + itemBoxController?.close() self.textViewController = controller localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in @@ -31,84 +33,79 @@ class AutoCompleteCoordinator: TextViewCoordinator { } func showAutocompleteWindow() { - guard let cursorPos = textViewController?.cursorPositions.last, + guard let cursorPos = textViewController?.cursorPositions.first, let textView = textViewController?.textView, let window = NSApplication.shared.keyWindow, + let itemBoxController = itemBoxController, !itemBoxController.isVisible else { return } + @Service var lspService: LSPService + +// lspService. + itemBoxController.items = [ - CompletionItem(label: "item1", kind: .class), - CompletionItem(label: "item2", kind: .enum), - CompletionItem(label: "item3", kind: .function), - CompletionItem(label: "item4", kind: .color), - CompletionItem(label: "item5", kind: .constant), - CompletionItem(label: "item6", kind: .constructor), - CompletionItem(label: "item7", kind: .enumMember), - CompletionItem(label: "item8", kind: .field), - CompletionItem(label: "item9", kind: .file), - CompletionItem(label: "item10", kind: .folder), - CompletionItem(label: "item11", kind: .snippet), - CompletionItem(label: "item12", kind: .reference), + CompletionItem(label: "CETable", kind: .class), + CompletionItem(label: "CETask", kind: .enum), + CompletionItem(label: "CETarget", kind: .function), + CompletionItem(label: "CEItem", kind: .color), + CompletionItem(label: "tableView", kind: .constant), + CompletionItem(label: "itemBoxController", kind: .constructor), + CompletionItem(label: "showAutocompleteWindow", kind: .enumMember), + CompletionItem(label: "NSApplication", kind: .field), + CompletionItem(label: "CECell", kind: .file), + CompletionItem(label: "Item10", kind: .folder), + CompletionItem(label: "Item11", kind: .snippet), + CompletionItem(label: "Item12", kind: .reference), ] - // Reset the size of the window - let windowSize = ItemBoxWindowController.DEFAULT_SIZE - itemBoxController.window?.setContentSize(windowSize) - let cursorRect = textView.firstRect(forCharacterRange: cursorPos.range, actualRange: nil) - let screenFrame = window.screen!.visibleFrame - let padding: CGFloat = 22 - var autocompleteWindowOrigin = NSPoint( - x: cursorRect.origin.x, - y: cursorRect.origin.y - ) - - // Keep the horizontal position within the screen and some padding - let minX = screenFrame.minX + padding - let maxX = screenFrame.maxX - windowSize.width - padding - - if autocompleteWindowOrigin.x < minX { - autocompleteWindowOrigin.x = minX - } else if autocompleteWindowOrigin.x > maxX { - autocompleteWindowOrigin.x = maxX - } - - // Check if the window will go below the screen - // We determine whether the window drops down or upwards by choosing which - // corner of the window we will position: `setFrameOrigin` or `setFrameTopLeftPoint` - if autocompleteWindowOrigin.y - windowSize.height < screenFrame.minY { - // If the cursor itself if below the screen, then position the window - // at the bottom of the screen with some padding - if autocompleteWindowOrigin.y < screenFrame.minY { - autocompleteWindowOrigin.y = screenFrame.minY + padding - } else { - // Place above the cursor - autocompleteWindowOrigin.y += cursorRect.height - } - - itemBoxController.window?.setFrameOrigin(autocompleteWindowOrigin) - } else { - // If the window goes above the screen, position it below the screen with padding - let maxY = screenFrame.maxY - padding - if autocompleteWindowOrigin.y > maxY { - autocompleteWindowOrigin.y = maxY - } - - itemBoxController.window?.setFrameTopLeftPoint(autocompleteWindowOrigin) - } - + itemBoxController.constrainWindowToScreenEdges(cursorRect: cursorRect) itemBoxController.showWindow(attachedTo: window) } deinit { - print("Destroyed AutoCompleteCoordinator") - itemBoxController.close() + itemBoxController?.close() if let localEventMonitor = localEventMonitor { NSEvent.removeMonitor(localEventMonitor) self.localEventMonitor = nil } } } + +extension MarkupContent { + public init(kind: MarkupKind, value: String) { + do { + let dictionary: [String: Any] = ["kind": kind.rawValue, "value": value] + let data = try JSONSerialization.data(withJSONObject: dictionary) + self = try JSONDecoder().decode(MarkupContent.self, from: data) + } catch { + print("Failed to create MarkupContent: \(error)") + // swiftlint:disable:next force_try + self = try! JSONDecoder().decode(MarkupContent.self, from: """ + {"kind": "plaintext", "value": ""} + """.data(using: .utf8)!) + } + } +} + +extension AutoCompleteCoordinator: ItemBoxDelegate { + func applyCompletionItem(_ item: CompletionItem) { + guard let cursorPos = textViewController?.cursorPositions.first else { + return + } + + do { + let token = try textViewController?.treeSitterClient?.nodesAt(range: cursorPos.range) + guard let token = token?.first else { + return + } + print("Token \(token)") + } catch { + print("\(error)") + return + } + } +} diff --git a/CodeEdit/Features/Editor/Models/EditorInstance.swift b/CodeEdit/Features/Editor/Models/EditorInstance.swift index f8aeb8ebc6..eb50baa4b1 100644 --- a/CodeEdit/Features/Editor/Models/EditorInstance.swift +++ b/CodeEdit/Features/Editor/Models/EditorInstance.swift @@ -27,6 +27,7 @@ class EditorInstance: Hashable { // Public TextViewCoordinator APIs var rangeTranslator: RangeTranslator? + var autoCompleteCoordinator: AutoCompleteCoordinator? // Internal Combine subjects @@ -38,6 +39,7 @@ class EditorInstance: Hashable { self.file = file self.cursorSubject.send(cursorPositions) self.rangeTranslator = RangeTranslator(cursorSubject: cursorSubject) + self.autoCompleteCoordinator = AutoCompleteCoordinator() } func hash(into hasher: inout Hasher) { diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index 622d733c9d..696d445411 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -21,8 +21,6 @@ struct CodeFileView: View { /// Any coordinators passed to the view. private var textViewCoordinators: [TextViewCoordinator] - /// The coordinator that manages the autocomplete window (item box) - private let autocompleteCoordinator = AutoCompleteCoordinator() @AppSettings(\.textEditing.defaultTabWidth) var defaultTabWidth @@ -61,7 +59,6 @@ struct CodeFileView: View { self.textViewCoordinators = textViewCoordinators + [ codeFile.contentCoordinator, codeFile.languageServerCoordinator, - autocompleteCoordinator, ] self.isEditable = isEditable diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index 421adf9d69..384a65536f 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaView.swift @@ -7,6 +7,7 @@ import SwiftUI import CodeEditTextView +import CodeEditSourceEditor struct EditorAreaView: View { @AppSettings(\.general.showEditorPathBar) @@ -52,7 +53,10 @@ struct EditorAreaView: View { if let codeFile = codeFile { EditorAreaFileView( codeFile: codeFile, - textViewCoordinators: [selected.rangeTranslator].compactMap({ $0 }) + // Linter keeps complaining about types, which is why there are these weird casts + textViewCoordinators: [ + selected.rangeTranslator as Any, selected.autoCompleteCoordinator as Any + ].compactMap({ $0 as? any TextViewCoordinator }) ) .focusedObject(editor) .transformEnvironment(\.edgeInsets) { insets in diff --git a/CodeEdit/Features/LSP/Service/LSPService+Events.swift b/CodeEdit/Features/LSP/Service/LSPService+Events.swift index b4baa73bb9..1d5bee6fe8 100644 --- a/CodeEdit/Features/LSP/Service/LSPService+Events.swift +++ b/CodeEdit/Features/LSP/Service/LSPService+Events.swift @@ -33,63 +33,63 @@ extension LSPService { private func handleEvent(_ event: ServerEvent, for key: ClientKey) { // TODO: Handle Events -// switch event { -// case let .request(id, request): -// print("Request ID: \(id) for \(key.languageId.rawValue)") -// handleRequest(request) -// case let .notification(notification): -// handleNotification(notification) -// case let .error(error): -// print("Error from EventStream for \(key.languageId.rawValue): \(error)") -// } + switch event { + case let .request(id, request): + print("Request ID: \(id) for \(key.languageId.rawValue)") + handleRequest(request) + case let .notification(notification): + handleNotification(notification) + case let .error(error): + print("Error from EventStream for \(key.languageId.rawValue): \(error)") + } } private func handleRequest(_ request: ServerRequest) { // TODO: Handle Requests -// switch request { -// case let .workspaceConfiguration(params, _): -// print("workspaceConfiguration: \(params)") -// case let .workspaceFolders(handler): -// print("workspaceFolders: \(String(describing: handler))") -// case let .workspaceApplyEdit(params, _): -// print("workspaceApplyEdit: \(params)") -// case let .clientRegisterCapability(params, _): -// print("clientRegisterCapability: \(params)") -// case let .clientUnregisterCapability(params, _): -// print("clientUnregisterCapability: \(params)") -// case let .workspaceCodeLensRefresh(handler): -// print("workspaceCodeLensRefresh: \(String(describing: handler))") -// case let .workspaceSemanticTokenRefresh(handler): -// print("workspaceSemanticTokenRefresh: \(String(describing: handler))") -// case let .windowShowMessageRequest(params, _): -// print("windowShowMessageRequest: \(params)") -// case let .windowShowDocument(params, _): -// print("windowShowDocument: \(params)") -// case let .windowWorkDoneProgressCreate(params, _): -// print("windowWorkDoneProgressCreate: \(params)") -// -// default: -// print() -// } + switch request { + case let .workspaceConfiguration(params, _): + print("workspaceConfiguration: \(params)") + case let .workspaceFolders(handler): + print("workspaceFolders: \(String(describing: handler))") + case let .workspaceApplyEdit(params, _): + print("workspaceApplyEdit: \(params)") + case let .clientRegisterCapability(params, _): + print("clientRegisterCapability: \(params)") + case let .clientUnregisterCapability(params, _): + print("clientUnregisterCapability: \(params)") + case let .workspaceCodeLensRefresh(handler): + print("workspaceCodeLensRefresh: \(String(describing: handler))") + case let .workspaceSemanticTokenRefresh(handler): + print("workspaceSemanticTokenRefresh: \(String(describing: handler))") + case let .windowShowMessageRequest(params, _): + print("windowShowMessageRequest: \(params)") + case let .windowShowDocument(params, _): + print("windowShowDocument: \(params)") + case let .windowWorkDoneProgressCreate(params, _): + print("windowWorkDoneProgressCreate: \(params)") + + default: + print() + } } private func handleNotification(_ notification: ServerNotification) { // TODO: Handle Notifications -// switch notification { -// case let .windowLogMessage(params): -// print("windowLogMessage \(params.type)\n```\n\(params.message)\n```\n") -// case let .windowShowMessage(params): -// print("windowShowMessage \(params.type)\n```\n\(params.message)\n```\n") -// case let .textDocumentPublishDiagnostics(params): -// print("textDocumentPublishDiagnostics: \(params)") -// case let .telemetryEvent(params): -// print("telemetryEvent: \(params)") -// case let .protocolCancelRequest(params): -// print("protocolCancelRequest: \(params)") -// case let .protocolProgress(params): -// print("protocolProgress: \(params)") -// case let .protocolLogTrace(params): -// print("protocolLogTrace: \(params)") -// } + switch notification { + case let .windowLogMessage(params): + print("windowLogMessage \(params.type)\n```\n\(params.message)\n```\n") + case let .windowShowMessage(params): + print("windowShowMessage \(params.type)\n```\n\(params.message)\n```\n") + case let .textDocumentPublishDiagnostics(params): + print("textDocumentPublishDiagnostics: \(params)") + case let .telemetryEvent(params): + print("telemetryEvent: \(params)") + case let .protocolCancelRequest(params): + print("protocolCancelRequest: \(params)") + case let .protocolProgress(params): + print("protocolProgress: \(params)") + case let .protocolLogTrace(params): + print("protocolLogTrace: \(params)") + } } } diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 0c4b5a812c..7ddfcb1d49 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -158,14 +158,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) + + // Attempt to start the language server let server = try await LanguageServer.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/CompletionItem.swift b/CodeEdit/Features/LSP/Views/CompletionItem.swift index 65b276f4d7..8d7bd3bf40 100644 --- a/CodeEdit/Features/LSP/Views/CompletionItem.swift +++ b/CodeEdit/Features/LSP/Views/CompletionItem.swift @@ -10,21 +10,83 @@ import CodeEditTextView import LanguageServerProtocol import CodeEditSourceEditor +// TODO: REMOVE Y OFFSET ON 16 PX? + +// TODO: IMPORT FONT SIZE +let FONT_SIZE: CGFloat = 12 +let fontSizeToImageSize: [CGFloat: CGFloat] = [ + 12: 16.5, + 13: 17.75, // Not sure + 14: 19, // checking this + 16: 22, + 18: 24, +] +let fontSizeToRowHeight: [CGFloat: CGFloat] = [ + 12: 21, + 13: 22, + 14: 23, + 15: 0, // TODO + 16: 26, + 17: 0, // TODO + 18: 28, +] +let fontSizeToRightPadding: [CGFloat: CGFloat] = [ + 12: 13, + 13: 13, + 14: 13, // TODO + 15: 12.5, + 16: 12.5, + 17: 12.5, + 18: 12.5, +] + extension CompletionItem: @retroactive ItemBoxEntry { public var view: NSView { - NSHostingView(rootView: HStack(spacing: 0) { - Image(systemName: CompletionItemKind.toSymbolName(kind: self.kind)) - .font(.system(size: 16)) - .foregroundStyle(.white, CompletionItemKind.toSymbolColor(kind: self.kind)) - .padding(0) - .padding(.trailing, 2) + NSHostingView( + rootView: HStack(spacing: 0) { + Image(systemName: CompletionItemKind.toSymbolName(kind: self.kind)) + .font(.system(size: fontSizeToImageSize[FONT_SIZE]!)) + .foregroundStyle( + .white, + deprecated == true ? .gray : CompletionItemKind.toSymbolColor(kind: self.kind) + ) + .padding(0) + .padding(.trailing, 2) - Text(label) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(.secondary) + // Main label + HStack(spacing: 0) { + Text(label) + .font(.system(size: FONT_SIZE, design: .monospaced)) + .foregroundStyle(deprecated == true ? .secondary : .primary) + + if let detail = detail { + Text(detail) + .font(.system(size: FONT_SIZE, design: .monospaced)) + .foregroundStyle(.secondary) + } + } .padding(0) + .offset(y: -1) + + Spacer() - Spacer() - }) + // Right side indicators + HStack(spacing: 6.5) { + if deprecated == true { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: FONT_SIZE + 2)) + .foregroundStyle(.primary, .secondary) + } + if documentation != nil { + Image(systemName: "chevron.right") + .font(.system(size: FONT_SIZE - 2.5)) + .fontWeight(.semibold) + } + } + .padding(.leading, 4) + .padding(.trailing, 6.5) + } + .padding(.horizontal, fontSizeToRightPadding[FONT_SIZE]) + ) } } diff --git a/CodeEdit/Features/LSP/Views/CompletionItemKind.swift b/CodeEdit/Features/LSP/Views/CompletionItemKind.swift index 6f2144a2a8..dbe5515058 100644 --- a/CodeEdit/Features/LSP/Views/CompletionItemKind.swift +++ b/CodeEdit/Features/LSP/Views/CompletionItemKind.swift @@ -55,20 +55,20 @@ extension CompletionItemKind { let symbolMap: [CompletionItemKind: SwiftUICore.Color] = [ .text: Color.blue, - .method: Color.blue, + .method: Color.cyan, .function: Color.blue, .constructor: Color.teal, - .field: Color.blue, + .field: Color.indigo, .variable: Color.blue, .class: Color.pink, .interface: Color.blue, .module: Color.blue, - .property: Color.secondary, + .property: Color.purple, .unit: Color.blue, .value: Color.blue, - .enum: Color.blue, - .keyword: Color.blue, - .snippet: Color.blue, + .enum: Color.mint, + .keyword: Color.pink, + .snippet: Color.purple, .color: Color.blue, .file: Color.blue, .reference: Color.blue, From a2de09056087ecacfabfd01847b05fab0da8345b Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 22 Dec 2024 03:47:09 -0800 Subject: [PATCH 03/10] Autocomplete updates --- .../xcshareddata/swiftpm/Package.resolved | 20 +-- .../Editor/AutoCompleteCoordinator.swift | 120 +++++++++++------- .../Editor/Models/EditorInstance.swift | 2 +- 3 files changed, 78 insertions(+), 64 deletions(-) diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 96f4d65746..0eba906d65 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ac57a6899925c3e4ac6d43aed791c845c6fc24a4441b6a10297a207d951b7836", + "originHash" : "bb72acfad31b288599b6721256b508d8209ba1bc1d7ab0fff6a358d49a1deae0", "pins" : [ { "identity" : "anycodable", @@ -28,15 +28,6 @@ "version" : "0.1.20" } }, - { - "identity" : "codeeditsourceeditor", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", - "state" : { - "revision" : "b0688fa59fb8060840fb013afb4d6e6a96000f14", - "version" : "0.9.1" - } - }, { "identity" : "codeeditsymbols", "kind" : "remoteSourceControl", @@ -46,15 +37,6 @@ "version" : "0.2.2" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "509d7b2e86460e8ec15b0dd5410cbc8e8c05940f", - "version" : "0.7.7" - } - }, { "identity" : "collectionconcurrencykit", "kind" : "remoteSourceControl", diff --git a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift index eacb50a590..bea4651442 100644 --- a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift +++ b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift @@ -12,10 +12,15 @@ import LanguageServerProtocol class AutoCompleteCoordinator: TextViewCoordinator { private weak var textViewController: TextViewController? + private unowned var file: CEWorkspaceFile private var localEventMonitor: Any? private var itemBoxController: ItemBoxWindowController? + init(_ file: CEWorkspaceFile) { + self.file = file + } + func prepareCoordinator(controller: TextViewController) { itemBoxController = ItemBoxWindowController() itemBoxController?.delegate = self @@ -25,45 +30,65 @@ class AutoCompleteCoordinator: TextViewCoordinator { localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in // `ctrl + space` keyboard shortcut listener for the item box to show if event.modifierFlags.contains(.control) && event.charactersIgnoringModifiers == " " { - self.showAutocompleteWindow() + Task { + await self.showAutocompleteWindow() + } return nil } return event } } + @MainActor func showAutocompleteWindow() { guard let cursorPos = textViewController?.cursorPositions.first, let textView = textViewController?.textView, let window = NSApplication.shared.keyWindow, - let itemBoxController = itemBoxController, - !itemBoxController.isVisible + let itemBoxController = itemBoxController else { return } + Task { + let textPosition = Position(line: cursorPos.line - 1, character: cursorPos.column - 1) + let completionItems = await fetchCompletions(position: textPosition) + itemBoxController.items = completionItems + + let cursorRect = textView.firstRect(forCharacterRange: cursorPos.range, actualRange: nil) + itemBoxController.constrainWindowToScreenEdges(cursorRect: cursorRect) + itemBoxController.showWindow(attachedTo: window) + } + } + + private func fetchCompletions(position: Position) async -> [CompletionItem] { + let workspace = await file.fileDocument?.findWorkspace() + guard let workspacePath = workspace?.fileURL?.absoluteURL.path() else { return [] } + guard 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 [] + } -// lspService. - - itemBoxController.items = [ - CompletionItem(label: "CETable", kind: .class), - CompletionItem(label: "CETask", kind: .enum), - CompletionItem(label: "CETarget", kind: .function), - CompletionItem(label: "CEItem", kind: .color), - CompletionItem(label: "tableView", kind: .constant), - CompletionItem(label: "itemBoxController", kind: .constructor), - CompletionItem(label: "showAutocompleteWindow", kind: .enumMember), - CompletionItem(label: "NSApplication", kind: .field), - CompletionItem(label: "CECell", kind: .file), - CompletionItem(label: "Item10", kind: .folder), - CompletionItem(label: "Item11", kind: .snippet), - CompletionItem(label: "Item12", kind: .reference), - ] - - let cursorRect = textView.firstRect(forCharacterRange: cursorPos.range, actualRange: nil) - itemBoxController.constrainWindowToScreenEdges(cursorRect: cursorRect) - itemBoxController.showWindow(attachedTo: window) + do { + let completions = try await client.requestCompletion( + for: file.url.absoluteURL.path(), position: position + ) + + // Extract the completion items list + switch completions { + case .optionA(let completionItems): + return completionItems + case .optionB(let completionList): + return completionList.items + case .none: + return [] + } + } catch { + return [] + } } deinit { @@ -75,34 +100,41 @@ class AutoCompleteCoordinator: TextViewCoordinator { } } -extension MarkupContent { - public init(kind: MarkupKind, value: String) { - do { - let dictionary: [String: Any] = ["kind": kind.rawValue, "value": value] - let data = try JSONSerialization.data(withJSONObject: dictionary) - self = try JSONDecoder().decode(MarkupContent.self, from: data) - } catch { - print("Failed to create MarkupContent: \(error)") - // swiftlint:disable:next force_try - self = try! JSONDecoder().decode(MarkupContent.self, from: """ - {"kind": "plaintext", "value": ""} - """.data(using: .utf8)!) - } - } -} - extension AutoCompleteCoordinator: ItemBoxDelegate { func applyCompletionItem(_ item: CompletionItem) { - guard let cursorPos = textViewController?.cursorPositions.first else { + guard let cursorPos = textViewController?.cursorPositions.first, + let textView = textViewController?.textView else { return } do { - let token = try textViewController?.treeSitterClient?.nodesAt(range: cursorPos.range) - guard let token = token?.first else { - return + let textPosition = Position( + line: cursorPos.line - 1, + character: cursorPos.column - 1 + ) + var textEdits = LSPCompletionItemsUtil.getCompletionItemEdits( + startPosition: textPosition, + item: item + ) + // Appropriately order the text edits + textEdits = TextEdit.makeApplicable(textEdits) + + // Make the updates + textView.undoManager?.beginUndoGrouping() + for textEdit in textEdits { + textView.replaceString( + in: NSRange(location: 0, length: 0), + with: textEdit.newText + ) } - print("Token \(token)") + textView.undoManager?.endUndoGrouping() + +// textViewController?.textView.applyMutations(<#T##mutations: [TextMutation]##[TextMutation]#>) +// let token = try textViewController?.treeSitterClient?.nodesAt(range: cursorPos.range) +// guard let token = token?.first else { +// return +// } +// print("Token \(token)") } catch { print("\(error)") return diff --git a/CodeEdit/Features/Editor/Models/EditorInstance.swift b/CodeEdit/Features/Editor/Models/EditorInstance.swift index eb50baa4b1..2029bd45c0 100644 --- a/CodeEdit/Features/Editor/Models/EditorInstance.swift +++ b/CodeEdit/Features/Editor/Models/EditorInstance.swift @@ -39,7 +39,7 @@ class EditorInstance: Hashable { self.file = file self.cursorSubject.send(cursorPositions) self.rangeTranslator = RangeTranslator(cursorSubject: cursorSubject) - self.autoCompleteCoordinator = AutoCompleteCoordinator() + self.autoCompleteCoordinator = AutoCompleteCoordinator(file) } func hash(into hasher: inout Hasher) { From a4326881729c47ccc00c98cedbf2206cf1ea72fd Mon Sep 17 00:00:00 2001 From: Abe M Date: Mon, 23 Dec 2024 04:25:57 -0800 Subject: [PATCH 04/10] Update cursor positioning --- .../Editor/AutoCompleteCoordinator.swift | 69 +++++++++++-------- CodeEdit/Features/LSP/LSPUtil.swift | 16 +++++ 2 files changed, 57 insertions(+), 28 deletions(-) diff --git a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift index bea4651442..6954697a91 100644 --- a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift +++ b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift @@ -11,10 +11,13 @@ import CodeEditSourceEditor import LanguageServerProtocol class AutoCompleteCoordinator: TextViewCoordinator { + /// A reference to the `TextViewController`, to be able to make edits private weak var textViewController: TextViewController? + /// A reference to the file we are working with, to be able to query file information private unowned var file: CEWorkspaceFile + /// The event monitor that looks for the keyboard shortcut to bring up the autocomplete menu private var localEventMonitor: Any? - + /// The `ItemBoxWindowController` lets us display the autocomplete items private var itemBoxController: ItemBoxWindowController? init(_ file: CEWorkspaceFile) { @@ -39,6 +42,7 @@ class AutoCompleteCoordinator: TextViewCoordinator { } } + /// Will query the language server for autocomplete suggestions and then display the window. @MainActor func showAutocompleteWindow() { guard let cursorPos = textViewController?.cursorPositions.first, @@ -67,14 +71,16 @@ class AutoCompleteCoordinator: TextViewCoordinator { @Service var lspService: LSPService guard let client = await lspService.languageClient( - for: language, workspacePath: workspacePath + for: language, + workspacePath: workspacePath ) else { return [] } do { let completions = try await client.requestCompletion( - for: file.url.absoluteURL.path(), position: position + for: file.url.absoluteURL.path(), + position: position ) // Extract the completion items list @@ -101,43 +107,50 @@ class AutoCompleteCoordinator: TextViewCoordinator { } extension AutoCompleteCoordinator: ItemBoxDelegate { + /// Takes a `CompletionItem` and modifies the text view with the new string func applyCompletionItem(_ item: CompletionItem) { guard let cursorPos = textViewController?.cursorPositions.first, let textView = textViewController?.textView else { return } - do { - let textPosition = Position( - line: cursorPos.line - 1, - character: cursorPos.column - 1 - ) - var textEdits = LSPCompletionItemsUtil.getCompletionItemEdits( - startPosition: textPosition, - item: item + let textPosition = Position( + line: cursorPos.line - 1, + character: cursorPos.column - 1 + ) + var textEdits = LSPCompletionItemsUtil.getCompletionItemEdits( + startPosition: textPosition, + item: item + ) + // Appropriately order the text edits + textEdits = TextEdit.makeApplicable(textEdits) + + // Make the updates + textView.undoManager?.beginUndoGrouping() + for textEdit in textEdits { + textView.replaceString( + in: cursorPos.range, + with: textEdit.newText ) - // Appropriately order the text edits - textEdits = TextEdit.makeApplicable(textEdits) - - // Make the updates - textView.undoManager?.beginUndoGrouping() - for textEdit in textEdits { - textView.replaceString( - in: NSRange(location: 0, length: 0), - with: textEdit.newText - ) - } - textView.undoManager?.endUndoGrouping() + } + textView.undoManager?.endUndoGrouping() -// textViewController?.textView.applyMutations(<#T##mutations: [TextMutation]##[TextMutation]#>) + // Set the cursor to the end of the completion + let insertText = LSPCompletionItemsUtil.getInsertText(from: item) + guard let newCursorPos = cursorPos.range.shifted(by: insertText.count) else { + return + } + textViewController?.setCursorPositions([CursorPosition(range: newCursorPos)]) + +// do { // let token = try textViewController?.treeSitterClient?.nodesAt(range: cursorPos.range) // guard let token = token?.first else { // return // } // print("Token \(token)") - } catch { - print("\(error)") - return - } +// } catch { +// print("\(error)") +// return +// } } } diff --git a/CodeEdit/Features/LSP/LSPUtil.swift b/CodeEdit/Features/LSP/LSPUtil.swift index 740a821041..b71b945532 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: CompletionItem) -> 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): From e81ac208eb6b8ffbfd928fec07453e249d4145dd Mon Sep 17 00:00:00 2001 From: Abe M Date: Thu, 26 Dec 2024 18:00:01 -0800 Subject: [PATCH 05/10] UX updates --- CodeEdit.xcodeproj/project.pbxproj | 9 +- .../xcshareddata/swiftpm/Package.resolved | 9 + .../Editor/AutoCompleteCoordinator.swift | 170 +++++++++++++----- .../Features/LSP/Views/CompletionItem.swift | 5 +- 4 files changed, 143 insertions(+), 50 deletions(-) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 84e52222b4..14af561e48 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -65,6 +65,7 @@ 3000516C2BBD3A9500A98562 /* ServiceWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3000516B2BBD3A9500A98562 /* ServiceWrapper.swift */; }; 3026F50F2AC006C80061227E /* InspectorAreaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3026F50E2AC006C80061227E /* InspectorAreaViewModel.swift */; }; 3046374E2CB15FA900180667 /* AutoCompleteCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3046374D2CB15F9200180667 /* AutoCompleteCoordinator.swift */; }; + 3048523D2D182DA6000CD5CF /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */; }; 30AB4EBB2BF718A100ED4431 /* DeveloperSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EBA2BF718A100ED4431 /* DeveloperSettings.swift */; }; 30AB4EBD2BF71CA800ED4431 /* DeveloperSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EBC2BF71CA800ED4431 /* DeveloperSettingsView.swift */; }; 30AB4EC22BF7253200ED4431 /* KeyValueTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EC12BF7253200ED4431 /* KeyValueTable.swift */; }; @@ -458,8 +459,7 @@ 6CBA0D512A1BF524002C6FAA /* SegmentedControlImproved.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBA0D502A1BF524002C6FAA /* SegmentedControlImproved.swift */; }; 6CBD1BC62978DE53006639D5 /* Font+Caption3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBD1BC52978DE53006639D5 /* Font+Caption3.swift */; }; 6CBE1CFB2B71DAA6003AC32E /* Loopable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBE1CFA2B71DAA6003AC32E /* Loopable.swift */; }; - 6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */; }; - 6CC17B4F2C432AE000834E2C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */; }; + 6CC17B4F2C432AE000834E2C /* (null) in Frameworks */ = {isa = PBXBuildFile; }; 6CC17B512C43311900834E2C /* ProjectNavigatorViewController+NSOutlineViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC17B502C43311900834E2C /* ProjectNavigatorViewController+NSOutlineViewDataSource.swift */; }; 6CC17B532C43314000834E2C /* ProjectNavigatorViewController+NSOutlineViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC17B522C43314000834E2C /* ProjectNavigatorViewController+NSOutlineViewDelegate.swift */; }; 6CC17B5B2C44258700834E2C /* WindowControllerPropertyWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC17B5A2C44258700834E2C /* WindowControllerPropertyWrapper.swift */; }; @@ -1339,6 +1339,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3048523D2D182DA6000CD5CF /* CodeEditSourceEditor in Frameworks */, 6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */, 6C66C31329D05CDC00DE9ED2 /* GRDB in Frameworks */, 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */, @@ -1346,7 +1347,7 @@ 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */, 6CD3CA552C8B508200D83DCD /* (null) in Frameworks */, 6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */, - 6CC17B4F2C432AE000834E2C /* CodeEditSourceEditor in Frameworks */, + 6CC17B4F2C432AE000834E2C /* (null) in Frameworks */, 30CB64912C16CA8100CC8A9E /* LanguageServerProtocol in Frameworks */, 6C4E37FC2C73E00700AEE7B5 /* SwiftTerm in Frameworks */, 6C6BD6F429CD142C00235D17 /* CollectionConcurrencyKit in Frameworks */, @@ -1354,7 +1355,6 @@ 6CB446402B6DFF3A00539ED0 /* CodeEditSourceEditor in Frameworks */, 6C05CF9E2CDE8699006AAECD /* CodeEditSourceEditor in Frameworks */, 2816F594280CF50500DD548B /* CodeEditSymbols in Frameworks */, - 6CD26C852C8F907800ADBA38 /* (null) in Frameworks */, 30CB64942C16CA9100CC8A9E /* LanguageClient in Frameworks */, 6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */, 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */, @@ -3771,7 +3771,6 @@ 6C0617D52BDB4432008C9C42 /* LogStream */, 6C85BB3F2C2105ED00EB5DEF /* CodeEditKit */, 6C85BB432C210EFD00EB5DEF /* SwiftUIIntrospect */, - 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */, 6C0824A02C5C0C9700A0751E /* SwiftTerm */, 6CE21E862C650D2C0031B056 /* SwiftTerm */, 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */, diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0eba906d65..5a7e2ce180 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -37,6 +37,15 @@ "version" : "0.2.2" } }, + { + "identity" : "codeedittextview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", + "state" : { + "revision" : "509d7b2e86460e8ec15b0dd5410cbc8e8c05940f", + "version" : "0.7.7" + } + }, { "identity" : "collectionconcurrencykit", "kind" : "remoteSourceControl", diff --git a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift index 6954697a91..945fdd52c4 100644 --- a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift +++ b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift @@ -6,6 +6,7 @@ // import AppKit +import SwiftTreeSitter import CodeEditTextView import CodeEditSourceEditor import LanguageServerProtocol @@ -17,17 +18,19 @@ class AutoCompleteCoordinator: TextViewCoordinator { private unowned var file: CEWorkspaceFile /// The event monitor that looks for the keyboard shortcut to bring up the autocomplete menu private var localEventMonitor: Any? - /// The `ItemBoxWindowController` lets us display the autocomplete items - private var itemBoxController: ItemBoxWindowController? + /// The `SuggestionController` lets us display the autocomplete items + private var suggestionController: SuggestionController? + /// The current TreeSitter node that the main cursor is at + private var currentNode: SwiftTreeSitter.Node? init(_ file: CEWorkspaceFile) { self.file = file } func prepareCoordinator(controller: TextViewController) { - itemBoxController = ItemBoxWindowController() - itemBoxController?.delegate = self - itemBoxController?.close() + suggestionController = SuggestionController() + suggestionController?.delegate = self + suggestionController?.close() self.textViewController = controller localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in @@ -48,19 +51,48 @@ class AutoCompleteCoordinator: TextViewCoordinator { guard let cursorPos = textViewController?.cursorPositions.first, let textView = textViewController?.textView, let window = NSApplication.shared.keyWindow, - let itemBoxController = itemBoxController + let suggestionController = suggestionController else { return } + do { + if let token = try textViewController?.treeSitterClient?.nodesAt(range: cursorPos.range).first { + if tokenIsActionable(token.node) { + currentNode = token.node + } + + // Get the string from the start of the token to the location of the cursor + if cursorPos.range.location > token.node.range.location { + let selectedRange = NSRange( + location: token.node.range.location, + length: cursorPos.range.location - token.node.range.location + ) + let tokenSubstring = textView.textStorage?.substring(from: selectedRange) +// print("Token word: \(String(describing: tokenSubstring))") + } + } + } catch { + print("Error getting TreeSitter node: \(error)") + } + Task { let textPosition = Position(line: cursorPos.line - 1, character: cursorPos.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 +// if let currentNode = currentNode, tokenIsActionable(currentNode) { +// if let newPos = textView.lspRangeFrom(nsRange: currentNode.range) { +// _currentNode +// } +// } + print("Getting completion items at token position: \(textPosition)") + let completionItems = await fetchCompletions(position: textPosition) - itemBoxController.items = completionItems + suggestionController.items = completionItems let cursorRect = textView.firstRect(forCharacterRange: cursorPos.range, actualRange: nil) - itemBoxController.constrainWindowToScreenEdges(cursorRect: cursorRect) - itemBoxController.showWindow(attachedTo: window) + suggestionController.constrainWindowToScreenEdges(cursorRect: cursorRect) + suggestionController.showWindow(attachedTo: window) } } @@ -97,8 +129,24 @@ class AutoCompleteCoordinator: TextViewCoordinator { } } + /// Determines if a TreeSitter node is a type where we can build featues off of. This helps filter out + /// nodes that represent blank spaces or other information that is not useful. + private func tokenIsActionable(_ node: SwiftTreeSitter.Node) -> Bool { + // List of node types that should have their text be replaced + let replaceableTypes: Set = [ + "identifier", + "property_identifier", + "field_identifier", + "variable_name", + "method_name", + "function_name", + "type_identifier" + ] + return replaceableTypes.contains(node.nodeType ?? "") + } + deinit { - itemBoxController?.close() + suggestionController?.close() if let localEventMonitor = localEventMonitor { NSEvent.removeMonitor(localEventMonitor) self.localEventMonitor = nil @@ -106,51 +154,89 @@ class AutoCompleteCoordinator: TextViewCoordinator { } } -extension AutoCompleteCoordinator: ItemBoxDelegate { +extension AutoCompleteCoordinator: SuggestionControllerDelegate { /// Takes a `CompletionItem` and modifies the text view with the new string - func applyCompletionItem(_ item: CompletionItem) { + func applyCompletionItem(item: CompletionItem) { guard let cursorPos = textViewController?.cursorPositions.first, let textView = textViewController?.textView else { return } - let textPosition = Position( - line: cursorPos.line - 1, - character: cursorPos.column - 1 - ) - var textEdits = LSPCompletionItemsUtil.getCompletionItemEdits( - startPosition: textPosition, - item: item - ) - // Appropriately order the text edits - textEdits = TextEdit.makeApplicable(textEdits) + // Get the token the cursor is currently on. Here we will check if we want to + // replace the current token we are on or just add text onto it. + var replacementRange = cursorPos.range + do { + if let token = try textViewController?.treeSitterClient?.nodesAt(range: cursorPos.range).first { + if tokenIsActionable(token.node) { + replacementRange = token.node.range + } + } + } catch { + print("Error getting TreeSitter node: \(error)") + } // Make the updates + let insertText = LSPCompletionItemsUtil.getInsertText(from: item) textView.undoManager?.beginUndoGrouping() - for textEdit in textEdits { - textView.replaceString( - in: cursorPos.range, - with: textEdit.newText - ) - } + textView.replaceString(in: replacementRange, with: insertText) textView.undoManager?.endUndoGrouping() - // Set the cursor to the end of the completion - let insertText = LSPCompletionItemsUtil.getInsertText(from: item) - guard let newCursorPos = cursorPos.range.shifted(by: insertText.count) else { + // Set cursor position to end of inserted text + let newCursorRange = NSRange(location: replacementRange.location + insertText.count, length: 0) + textViewController?.setCursorPositions([CursorPosition(range: newCursorRange)]) + + self.onCompletion() + } + + func onCompletion() { + + } + + func onCursorMove() { + guard let cursorPos = textViewController?.cursorPositions.first, + let suggestionController = suggestionController, + let textView = self.textViewController?.textView, + suggestionController.isVisible + else { + return + } + guard let currentNode = currentNode, + !suggestionController.items.isEmpty else { + self.suggestionController?.close() return } - textViewController?.setCursorPositions([CursorPosition(range: newCursorPos)]) -// do { -// let token = try textViewController?.treeSitterClient?.nodesAt(range: cursorPos.range) -// guard let token = token?.first else { -// return -// } -// print("Token \(token)") -// } catch { -// print("\(error)") -// return -// } + do { + if let token = try textViewController?.treeSitterClient?.nodesAt(range: cursorPos.range).first { + // 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. + let adjustedRange = currentNode.range.shifted(endBy: 1) + if let adjustedRange = adjustedRange, + !adjustedRange.contains(cursorPos.range.location) { + suggestionController.close() + return + } + + // 1. Print cursor position and token range + print("Current node: \(String(describing: currentNode))") + print("Cursor pos: \(cursorPos.range.location) : Line: \(cursorPos.line) Col: \(cursorPos.column)") + + // Get the token string from the start of the token to the location of the cursor +// print("Token contains cursor position: \(String(describing: currentNode.range.contains(cursorPos.range.location)))") +// print("Token info: \(String(describing: tokenSubstring)) Range: \(String(describing: adjustedRange))") +// print("Current cursor position: \(cursorPos.range)") + } + } catch { + print("Error getting TreeSitter node: \(error)") + } + } + + func onItemSelect(item: LanguageServerProtocol.CompletionItem) { + + } + + func onClose() { + currentNode = nil } } diff --git a/CodeEdit/Features/LSP/Views/CompletionItem.swift b/CodeEdit/Features/LSP/Views/CompletionItem.swift index 8d7bd3bf40..4e1c938f1b 100644 --- a/CodeEdit/Features/LSP/Views/CompletionItem.swift +++ b/CodeEdit/Features/LSP/Views/CompletionItem.swift @@ -6,9 +6,8 @@ // import SwiftUI -import CodeEditTextView -import LanguageServerProtocol import CodeEditSourceEditor +import LanguageServerProtocol // TODO: REMOVE Y OFFSET ON 16 PX? @@ -40,7 +39,7 @@ let fontSizeToRightPadding: [CGFloat: CGFloat] = [ 18: 12.5, ] -extension CompletionItem: @retroactive ItemBoxEntry { +extension CompletionItem: @retroactive CodeSuggestionEntry { public var view: NSView { NSHostingView( rootView: HStack(spacing: 0) { From 8e29a0cf7f34c63dfdc89a95f62790f09d49da7b Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 29 Dec 2024 05:11:40 -0800 Subject: [PATCH 06/10] Added item filtering based on cursor position --- .../Editor/AutoCompleteCoordinator.swift | 138 ++++++++++-------- .../LSP/Service/LSPService+Events.swift | 82 +++++------ .../Features/LSP/Service/LSPService.swift | 2 +- 3 files changed, 121 insertions(+), 101 deletions(-) diff --git a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift index 945fdd52c4..e5aa0bb663 100644 --- a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift +++ b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift @@ -22,6 +22,10 @@ class AutoCompleteCoordinator: TextViewCoordinator { private var suggestionController: SuggestionController? /// 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: [CompletionItem] = [] init(_ file: CEWorkspaceFile) { self.file = file @@ -56,20 +60,24 @@ class AutoCompleteCoordinator: TextViewCoordinator { return } + var tokenSubstringCount = 0 + currentFilterText = "" do { if let token = try textViewController?.treeSitterClient?.nodesAt(range: cursorPos.range).first { if tokenIsActionable(token.node) { currentNode = token.node - } - // Get the string from the start of the token to the location of the cursor - if cursorPos.range.location > token.node.range.location { - let selectedRange = NSRange( - location: token.node.range.location, - length: cursorPos.range.location - token.node.range.location - ) - let tokenSubstring = textView.textStorage?.substring(from: selectedRange) -// print("Token word: \(String(describing: tokenSubstring))") + // Get the string from the start of the token to the location of the cursor + if cursorPos.range.location > token.node.range.location { + let selectedRange = NSRange( + location: token.node.range.location, + length: cursorPos.range.location - token.node.range.location + ) + if let tokenSubstring = textView.textStorage?.substring(from: selectedRange) { + currentFilterText = tokenSubstring + tokenSubstringCount = tokenSubstring.count + } + } } } } catch { @@ -77,21 +85,24 @@ class AutoCompleteCoordinator: TextViewCoordinator { } Task { - let textPosition = Position(line: cursorPos.line - 1, character: cursorPos.column - 1) + var textPosition = Position(line: cursorPos.line - 1, character: cursorPos.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 -// if let currentNode = currentNode, tokenIsActionable(currentNode) { -// if let newPos = textView.lspRangeFrom(nsRange: currentNode.range) { -// _currentNode -// } -// } - print("Getting completion items at token position: \(textPosition)") - - let completionItems = await fetchCompletions(position: textPosition) - suggestionController.items = completionItems + if currentNode != nil { + textPosition = Position( + line: cursorPos.line - 1, + character: cursorPos.column - tokenSubstringCount - 1 + ) + } + completionItems = await fetchCompletions(position: textPosition) + suggestionController.items = filterCompletionItems(completionItems) let cursorRect = textView.firstRect(forCharacterRange: cursorPos.range, actualRange: nil) - suggestionController.constrainWindowToScreenEdges(cursorRect: cursorRect) + suggestionController.constrainWindowToScreenEdges( + cursorRect: cursorRect, + // TODO: CALCULATE PADDING BASED ON FONT SIZE, THIS IS JUST TEMP + horizontalOffset: 13 + 16.5 + CGFloat(tokenSubstringCount) * 7.4 + ) suggestionController.showWindow(attachedTo: window) } } @@ -129,6 +140,26 @@ class AutoCompleteCoordinator: TextViewCoordinator { } } + /// Filters completion items based on the current partial token input + private func filterCompletionItems(_ items: [CompletionItem]) -> [CompletionItem] { + guard !currentFilterText.isEmpty else { + return items + } + + return items.filter { item in + let insertText = LSPCompletionItemsUtil.getInsertText(from: item) + let label = item.label.lowercased() + let filterText = currentFilterText.lowercased() + if insertText.lowercased().hasPrefix(filterText) { + return true + } + if label.hasPrefix(filterText) { + return true + } + return false + } + } + /// Determines if a TreeSitter node is a type where we can build featues off of. This helps filter out /// nodes that represent blank spaces or other information that is not useful. private func tokenIsActionable(_ node: SwiftTreeSitter.Node) -> Bool { @@ -162,20 +193,8 @@ extension AutoCompleteCoordinator: SuggestionControllerDelegate { return } - // Get the token the cursor is currently on. Here we will check if we want to - // replace the current token we are on or just add text onto it. - var replacementRange = cursorPos.range - do { - if let token = try textViewController?.treeSitterClient?.nodesAt(range: cursorPos.range).first { - if tokenIsActionable(token.node) { - replacementRange = token.node.range - } - } - } catch { - print("Error getting TreeSitter node: \(error)") - } - // Make the updates + let replacementRange = currentNode?.range ?? cursorPos.range let insertText = LSPCompletionItemsUtil.getInsertText(from: item) textView.undoManager?.beginUndoGrouping() textView.replaceString(in: replacementRange, with: insertText) @@ -188,9 +207,7 @@ extension AutoCompleteCoordinator: SuggestionControllerDelegate { self.onCompletion() } - func onCompletion() { - - } + func onCompletion() { } func onCursorMove() { guard let cursorPos = textViewController?.cursorPositions.first, @@ -206,37 +223,40 @@ extension AutoCompleteCoordinator: SuggestionControllerDelegate { return } - do { - if let token = try textViewController?.treeSitterClient?.nodesAt(range: cursorPos.range).first { - // 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. - let adjustedRange = currentNode.range.shifted(endBy: 1) - if let adjustedRange = adjustedRange, - !adjustedRange.contains(cursorPos.range.location) { - suggestionController.close() - return - } + // 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. + let adjustedRange = currentNode.range.shifted(endBy: 1) + if let adjustedRange = adjustedRange, + !adjustedRange.contains(cursorPos.range.location) { + suggestionController.close() + return + } - // 1. Print cursor position and token range - print("Current node: \(String(describing: currentNode))") - print("Cursor pos: \(cursorPos.range.location) : Line: \(cursorPos.line) Col: \(cursorPos.column)") + // Check if cursor is at the start of the token + if cursorPos.range.location == currentNode.range.location { + currentFilterText = "" + suggestionController.items = completionItems + return + } - // Get the token string from the start of the token to the location of the cursor -// print("Token contains cursor position: \(String(describing: currentNode.range.contains(cursorPos.range.location)))") -// print("Token info: \(String(describing: tokenSubstring)) Range: \(String(describing: adjustedRange))") -// print("Current cursor position: \(cursorPos.range)") + // Filter through the completion items based on how far the cursor is in the token + if cursorPos.range.location > currentNode.range.location { + let selectedRange = NSRange( + location: currentNode.range.location, + length: cursorPos.range.location - currentNode.range.location + ) + if let tokenSubstring = textView.textStorage?.substring(from: selectedRange) { + currentFilterText = tokenSubstring + suggestionController.items = filterCompletionItems(completionItems) } - } catch { - print("Error getting TreeSitter node: \(error)") } } - func onItemSelect(item: LanguageServerProtocol.CompletionItem) { - - } + func onItemSelect(item: LanguageServerProtocol.CompletionItem) { } func onClose() { currentNode = nil + currentFilterText = "" } } diff --git a/CodeEdit/Features/LSP/Service/LSPService+Events.swift b/CodeEdit/Features/LSP/Service/LSPService+Events.swift index 1d5bee6fe8..c6443ddec1 100644 --- a/CodeEdit/Features/LSP/Service/LSPService+Events.swift +++ b/CodeEdit/Features/LSP/Service/LSPService+Events.swift @@ -46,50 +46,50 @@ extension LSPService { private func handleRequest(_ request: ServerRequest) { // TODO: Handle Requests - switch request { - case let .workspaceConfiguration(params, _): - print("workspaceConfiguration: \(params)") - case let .workspaceFolders(handler): - print("workspaceFolders: \(String(describing: handler))") - case let .workspaceApplyEdit(params, _): - print("workspaceApplyEdit: \(params)") - case let .clientRegisterCapability(params, _): - print("clientRegisterCapability: \(params)") - case let .clientUnregisterCapability(params, _): - print("clientUnregisterCapability: \(params)") - case let .workspaceCodeLensRefresh(handler): - print("workspaceCodeLensRefresh: \(String(describing: handler))") - case let .workspaceSemanticTokenRefresh(handler): - print("workspaceSemanticTokenRefresh: \(String(describing: handler))") - case let .windowShowMessageRequest(params, _): - print("windowShowMessageRequest: \(params)") - case let .windowShowDocument(params, _): - print("windowShowDocument: \(params)") - case let .windowWorkDoneProgressCreate(params, _): - print("windowWorkDoneProgressCreate: \(params)") - - default: - print() - } +// switch request { +// case let .workspaceConfiguration(params, _): +// print("workspaceConfiguration: \(params)") +// case let .workspaceFolders(handler): +// print("workspaceFolders: \(String(describing: handler))") +// case let .workspaceApplyEdit(params, _): +// print("workspaceApplyEdit: \(params)") +// case let .clientRegisterCapability(params, _): +// print("clientRegisterCapability: \(params)") +// case let .clientUnregisterCapability(params, _): +// print("clientUnregisterCapability: \(params)") +// case let .workspaceCodeLensRefresh(handler): +// print("workspaceCodeLensRefresh: \(String(describing: handler))") +// case let .workspaceSemanticTokenRefresh(handler): +// print("workspaceSemanticTokenRefresh: \(String(describing: handler))") +// case let .windowShowMessageRequest(params, _): +// print("windowShowMessageRequest: \(params)") +// case let .windowShowDocument(params, _): +// print("windowShowDocument: \(params)") +// case let .windowWorkDoneProgressCreate(params, _): +// print("windowWorkDoneProgressCreate: \(params)") +// +// default: +// print() +// } } private func handleNotification(_ notification: ServerNotification) { // TODO: Handle Notifications - switch notification { - case let .windowLogMessage(params): - print("windowLogMessage \(params.type)\n```\n\(params.message)\n```\n") - case let .windowShowMessage(params): - print("windowShowMessage \(params.type)\n```\n\(params.message)\n```\n") - case let .textDocumentPublishDiagnostics(params): - print("textDocumentPublishDiagnostics: \(params)") - case let .telemetryEvent(params): - print("telemetryEvent: \(params)") - case let .protocolCancelRequest(params): - print("protocolCancelRequest: \(params)") - case let .protocolProgress(params): - print("protocolProgress: \(params)") - case let .protocolLogTrace(params): - print("protocolLogTrace: \(params)") - } +// switch notification { +// case let .windowLogMessage(params): +// print("windowLogMessage \(params.type)\n```\n\(params.message)\n```\n") +// case let .windowShowMessage(params): +// print("windowShowMessage \(params.type)\n```\n\(params.message)\n```\n") +// case let .textDocumentPublishDiagnostics(params): +// print("textDocumentPublishDiagnostics: \(params)") +// case let .telemetryEvent(params): +// print("telemetryEvent: \(params)") +// case let .protocolCancelRequest(params): +// print("protocolCancelRequest: \(params)") +// case let .protocolProgress(params): +// print("protocolProgress: \(params)") +// case let .protocolLogTrace(params): +// print("protocolLogTrace: \(params)") +// } } } diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 9f0cc7c80f..01b3dd0ade 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -246,7 +246,7 @@ final class LSPService: ObservableObject { do { try await languageServer.openDocument(document) } catch { - let uri = await document.languageServerURI + let uri = document.languageServerURI // swiftlint:disable:next line_length self.logger.error("Failed to close document: \(uri ?? "", privacy: .private), language: \(lspLanguage.rawValue). Error \(error)") } From 336cab08e973f60df07aed508e12112bd9c5783d Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 29 Dec 2024 17:12:37 -0800 Subject: [PATCH 07/10] Added CodeSuggestionEntry types to CESE --- .../xcshareddata/swiftpm/Package.resolved | 24 +++++++++---------- .../Editor/AutoCompleteCoordinator.swift | 5 ++-- .../Settings/Views/ExternalLink.swift | 2 +- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5a7e2ce180..3a0778c342 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/LanguageServerProtocol", "state" : { - "revision" : "ac76fccf0e981c8e30c5ee4de1b15adc1decd697", - "version" : "0.13.2" + "revision" : "d51412945ae88ffcab65ec339ca89aed9c9f0b8a", + "version" : "0.13.3" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mattmassicotte/Queue", "state" : { - "revision" : "8d6f936097888f97011610ced40313655dc5948d", - "version" : "0.1.4" + "revision" : "6adf359a705e3252742905b413bb8f56401043ca", + "version" : "0.2.0" } }, { @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/Semaphore", "state" : { - "revision" : "f1c4a0acabeb591068dea6cffdd39660b86dec28", - "version" : "0.0.8" + "revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2", + "version" : "0.1.0" } }, { @@ -186,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "ee97538f5b81ae89698fd95938896dec5217b148", - "version" : "1.1.1" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -213,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/lukepistrol/SwiftLintPlugin", "state" : { - "revision" : "5a65f4074975f811da666dfe31a19850950b1ea4", - "version" : "0.56.2" + "revision" : "87454f5c9ff4d644086aec2a0df1ffba678e7f3c", + "version" : "0.57.1" } }, { @@ -239,8 +239,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/siteline/SwiftUI-Introspect.git", "state" : { - "revision" : "668a65735751432b640260c56dfa621cec568368", - "version" : "1.2.0" + "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", + "version" : "1.3.0" } }, { diff --git a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift index e5aa0bb663..8d1a3a43fd 100644 --- a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift +++ b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift @@ -187,8 +187,9 @@ class AutoCompleteCoordinator: TextViewCoordinator { extension AutoCompleteCoordinator: SuggestionControllerDelegate { /// Takes a `CompletionItem` and modifies the text view with the new string - func applyCompletionItem(item: CompletionItem) { + func applyCompletionItem(item: CodeSuggestionEntry) { guard let cursorPos = textViewController?.cursorPositions.first, + let item = item as? CompletionItem, let textView = textViewController?.textView else { return } @@ -253,7 +254,7 @@ extension AutoCompleteCoordinator: SuggestionControllerDelegate { } } - func onItemSelect(item: LanguageServerProtocol.CompletionItem) { } + func onItemSelect(item: CodeSuggestionEntry) { } func onClose() { currentNode = nil diff --git a/CodeEdit/Features/Settings/Views/ExternalLink.swift b/CodeEdit/Features/Settings/Views/ExternalLink.swift index 9995df5537..4a64531847 100644 --- a/CodeEdit/Features/Settings/Views/ExternalLink.swift +++ b/CodeEdit/Features/Settings/Views/ExternalLink.swift @@ -55,7 +55,7 @@ struct ExternalLink: View { @ViewBuilder content: @escaping () -> Content, title: String? = nil, subtitle: String? = nil, - @ViewBuilder icon: @escaping() -> Icon = { EmptyView() } + @ViewBuilder icon: @escaping () -> Icon = { EmptyView() } ) { self.showInFinder = showInFinder self.title = title From 48a7fd64405a598684a27b11d989b7c813343545 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 29 Dec 2024 17:27:07 -0800 Subject: [PATCH 08/10] Remove completion example --- .../Features/LSP/Service/LSPService.swift | 63 ------------------- 1 file changed, 63 deletions(-) diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 01b3dd0ade..9d07ab18bb 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -34,69 +34,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 ServerManagerError.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.absoluteString, -/// 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 { let logger: Logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LSPService") From 5b8e3b2116662029e3dd9a17b688b179370b2fa2 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:40:15 -0500 Subject: [PATCH 09/10] Convert To CodeSuggestionDelegate Protocol --- CodeEdit.xcodeproj/project.pbxproj | 27 +- .../xcshareddata/swiftpm/Package.resolved | 37 +-- .../Editor/AutoCompleteCoordinator.swift | 263 ------------------ .../Features/Editor/Views/CodeFileView.swift | 3 +- .../AutoCompleteCoordinator.swift | 206 ++++++++++++++ .../LSP/LanguageServer/LanguageServer.swift | 2 +- 6 files changed, 232 insertions(+), 306 deletions(-) delete mode 100644 CodeEdit/Features/Editor/AutoCompleteCoordinator.swift create mode 100644 CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteCoordinator.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index a7979e8c9c..dff3a34650 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -28,10 +28,10 @@ 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 */; }; 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 +176,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,6 +191,7 @@ 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 */, @@ -333,9 +333,9 @@ 5EACE6212DF4BF08005E08B8 /* WelcomeWindow */, 5E4485602DF600D9008BBE69 /* AboutWindow */, 6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */, - 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */, 6CCF6DD22E26D48F00B94F75 /* SwiftTerm */, 6CCF73CF2E26DE3200B94F75 /* SwiftTerm */, + 6C8B56482E2FE62E00DC3F29 /* CodeEditSourceEditor */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -440,8 +440,8 @@ 30ED7B722DD299E600ACC922 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 5EACE6202DF4BF08005E08B8 /* XCRemoteSwiftPackageReference "WelcomeWindow" */, 5E44855F2DF600D9008BBE69 /* XCRemoteSwiftPackageReference "AboutWindow" */, - 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, 6CCF73CE2E26DE3200B94F75 /* XCRemoteSwiftPackageReference "SwiftTerm" */, + 6C8B56472E2FE62E00DC3F29 /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */, ); preferredProjectObjectVersion = 55; productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; @@ -1649,7 +1649,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 302EFBFB2CC3284D004A74DF /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */ = { + 6C8B56472E2FE62E00DC3F29 /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */ = { isa = XCLocalSwiftPackageReference; relativePath = ../CodeEditSourceEditor; }; @@ -1784,14 +1784,6 @@ minimumVersion = 0.2.0; }; }; - 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; - requirement = { - kind = exactVersion; - version = 0.14.1; - }; - }; 6C85BB3E2C2105ED00EB5DEF /* XCRemoteSwiftPackageReference "CodeEditKit" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/CodeEditApp/CodeEditKit"; @@ -1911,11 +1903,6 @@ isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; - 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */ = { - isa = XCSwiftPackageProductDependency; - package = 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; - productName = CodeEditSourceEditor; - }; 6C7B1C752A1D57CE005CBBFC /* SwiftLint */ = { isa = XCSwiftPackageProductDependency; package = 287136B1292A407E00E9F5F4 /* XCRemoteSwiftPackageReference "SwiftLintPlugin" */; @@ -1936,6 +1923,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 077fbaf1e5..d8574b9695 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ed7a5ce46da2e530a4b8ff23058a67ef7423fa2378b9588d66359f210b6365cb", + "originHash" : "a68a63b56c75b058610abbc7c9375c6cd3ccbe25fe06bf0f336610e78dab424c", "pins" : [ { "identity" : "aboutwindow", @@ -22,7 +22,7 @@ { "identity" : "codeeditkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditKit", + "location" : "https://github.com/CodeEditApp/CodeEditKit.git", "state" : { "revision" : "ad28213a968586abb0cb21a8a56a3587227895f1", "version" : "0.1.2" @@ -37,15 +37,6 @@ "version" : "0.1.20" } }, - { - "identity" : "codeeditsourceeditor", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor.git", - "state" : { - "revision" : "afc57523b05c209496a221655c2171c0624b51d3", - "version" : "0.14.1" - } - }, { "identity" : "codeeditsymbols", "kind" : "remoteSourceControl", @@ -60,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "d65c2a4b23a52f69d0b3a113124d7434c7af07fa", - "version" : "0.11.6" + "revision" : "fbb038caa8a2779153a94f6e01caa5016ffb973d", + "version" : "0.11.7" } }, { @@ -285,26 +276,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tree-sitter/tree-sitter", "state" : { - "revision" : "bf655c0beaf4943573543fa77c58e8006ff34971", - "version" : "0.25.6" + "revision" : "f2f197b6b27ce75c280c20f131d4f71e906b86f7", + "version" : "0.25.8" } }, { - "identity" : "zipfoundation", + "identity" : "welcomewindow", "kind" : "remoteSourceControl", - "location" : "https://github.com/weichsel/ZIPFoundation", + "location" : "https://github.com/CodeEditApp/WelcomeWindow", "state" : { - "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", - "version" : "0.9.19" + "revision" : "5168cf1ce9579b35ad00706fafef441418d8011f", + "version" : "1.0.0" } }, { - "identity" : "welcomewindow", + "identity" : "zipfoundation", "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/WelcomeWindow", + "location" : "https://github.com/weichsel/ZIPFoundation", "state" : { - "revision" : "5168cf1ce9579b35ad00706fafef441418d8011f", - "version" : "1.0.0" + "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", + "version" : "0.9.19" } } ], diff --git a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift deleted file mode 100644 index 8d1a3a43fd..0000000000 --- a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift +++ /dev/null @@ -1,263 +0,0 @@ -// -// AutoCompleteCoordinator.swift -// CodeEdit -// -// Created by Abe Malla on 9/20/24. -// - -import AppKit -import SwiftTreeSitter -import CodeEditTextView -import CodeEditSourceEditor -import LanguageServerProtocol - -class AutoCompleteCoordinator: TextViewCoordinator { - /// A reference to the `TextViewController`, to be able to make edits - private weak var textViewController: TextViewController? - /// A reference to the file we are working with, to be able to query file information - private unowned var file: CEWorkspaceFile - /// The event monitor that looks for the keyboard shortcut to bring up the autocomplete menu - private var localEventMonitor: Any? - /// The `SuggestionController` lets us display the autocomplete items - private var suggestionController: SuggestionController? - /// 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: [CompletionItem] = [] - - init(_ file: CEWorkspaceFile) { - self.file = file - } - - func prepareCoordinator(controller: TextViewController) { - suggestionController = SuggestionController() - suggestionController?.delegate = self - suggestionController?.close() - self.textViewController = controller - - localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - // `ctrl + space` keyboard shortcut listener for the item box to show - if event.modifierFlags.contains(.control) && event.charactersIgnoringModifiers == " " { - Task { - await self.showAutocompleteWindow() - } - return nil - } - return event - } - } - - /// Will query the language server for autocomplete suggestions and then display the window. - @MainActor - func showAutocompleteWindow() { - guard let cursorPos = textViewController?.cursorPositions.first, - let textView = textViewController?.textView, - let window = NSApplication.shared.keyWindow, - let suggestionController = suggestionController - else { - return - } - - var tokenSubstringCount = 0 - currentFilterText = "" - do { - if let token = try textViewController?.treeSitterClient?.nodesAt(range: cursorPos.range).first { - if tokenIsActionable(token.node) { - currentNode = token.node - - // Get the string from the start of the token to the location of the cursor - if cursorPos.range.location > token.node.range.location { - let selectedRange = NSRange( - location: token.node.range.location, - length: cursorPos.range.location - token.node.range.location - ) - if let tokenSubstring = textView.textStorage?.substring(from: selectedRange) { - currentFilterText = tokenSubstring - tokenSubstringCount = tokenSubstring.count - } - } - } - } - } catch { - print("Error getting TreeSitter node: \(error)") - } - - Task { - var textPosition = Position(line: cursorPos.line - 1, character: cursorPos.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 - if currentNode != nil { - textPosition = Position( - line: cursorPos.line - 1, - character: cursorPos.column - tokenSubstringCount - 1 - ) - } - completionItems = await fetchCompletions(position: textPosition) - suggestionController.items = filterCompletionItems(completionItems) - - let cursorRect = textView.firstRect(forCharacterRange: cursorPos.range, actualRange: nil) - suggestionController.constrainWindowToScreenEdges( - cursorRect: cursorRect, - // TODO: CALCULATE PADDING BASED ON FONT SIZE, THIS IS JUST TEMP - horizontalOffset: 13 + 16.5 + CGFloat(tokenSubstringCount) * 7.4 - ) - suggestionController.showWindow(attachedTo: window) - } - } - - private func fetchCompletions(position: Position) async -> [CompletionItem] { - let workspace = await file.fileDocument?.findWorkspace() - guard let workspacePath = workspace?.fileURL?.absoluteURL.path() else { return [] } - guard 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.absoluteURL.path(), - position: position - ) - - // Extract the completion items list - switch completions { - case .optionA(let completionItems): - return completionItems - case .optionB(let completionList): - return completionList.items - case .none: - return [] - } - } catch { - return [] - } - } - - /// Filters completion items based on the current partial token input - private func filterCompletionItems(_ items: [CompletionItem]) -> [CompletionItem] { - guard !currentFilterText.isEmpty else { - return items - } - - return items.filter { item in - let insertText = LSPCompletionItemsUtil.getInsertText(from: item) - let label = item.label.lowercased() - let filterText = currentFilterText.lowercased() - if insertText.lowercased().hasPrefix(filterText) { - return true - } - if label.hasPrefix(filterText) { - return true - } - return false - } - } - - /// Determines if a TreeSitter node is a type where we can build featues off of. This helps filter out - /// nodes that represent blank spaces or other information that is not useful. - private func tokenIsActionable(_ node: SwiftTreeSitter.Node) -> Bool { - // List of node types that should have their text be replaced - let replaceableTypes: Set = [ - "identifier", - "property_identifier", - "field_identifier", - "variable_name", - "method_name", - "function_name", - "type_identifier" - ] - return replaceableTypes.contains(node.nodeType ?? "") - } - - deinit { - suggestionController?.close() - if let localEventMonitor = localEventMonitor { - NSEvent.removeMonitor(localEventMonitor) - self.localEventMonitor = nil - } - } -} - -extension AutoCompleteCoordinator: SuggestionControllerDelegate { - /// Takes a `CompletionItem` and modifies the text view with the new string - func applyCompletionItem(item: CodeSuggestionEntry) { - guard let cursorPos = textViewController?.cursorPositions.first, - let item = item as? CompletionItem, - let textView = textViewController?.textView else { - return - } - - // Make the updates - let replacementRange = currentNode?.range ?? cursorPos.range - let insertText = LSPCompletionItemsUtil.getInsertText(from: item) - textView.undoManager?.beginUndoGrouping() - textView.replaceString(in: replacementRange, with: insertText) - textView.undoManager?.endUndoGrouping() - - // Set cursor position to end of inserted text - let newCursorRange = NSRange(location: replacementRange.location + insertText.count, length: 0) - textViewController?.setCursorPositions([CursorPosition(range: newCursorRange)]) - - self.onCompletion() - } - - func onCompletion() { } - - func onCursorMove() { - guard let cursorPos = textViewController?.cursorPositions.first, - let suggestionController = suggestionController, - let textView = self.textViewController?.textView, - suggestionController.isVisible - else { - return - } - guard let currentNode = currentNode, - !suggestionController.items.isEmpty else { - self.suggestionController?.close() - return - } - - // 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. - let adjustedRange = currentNode.range.shifted(endBy: 1) - if let adjustedRange = adjustedRange, - !adjustedRange.contains(cursorPos.range.location) { - suggestionController.close() - return - } - - // Check if cursor is at the start of the token - if cursorPos.range.location == currentNode.range.location { - currentFilterText = "" - suggestionController.items = completionItems - return - } - - // Filter through the completion items based on how far the cursor is in the token - if cursorPos.range.location > currentNode.range.location { - let selectedRange = NSRange( - location: currentNode.range.location, - length: cursorPos.range.location - currentNode.range.location - ) - if let tokenSubstring = textView.textStorage?.substring(from: selectedRange) { - currentFilterText = tokenSubstring - suggestionController.items = filterCompletionItems(completionItems) - } - } - } - - func onItemSelect(item: CodeSuggestionEntry) { } - - func onClose() { - currentNode = nil - currentFilterText = "" - } -} diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index 6320a61272..ac93640d80 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -169,7 +169,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 0000000000..565e262fb7 --- /dev/null +++ b/CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteCoordinator.swift @@ -0,0 +1,206 @@ +// +// 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: [CompletionItem] = [] + + init(_ file: CEWorkspaceFile) { + self.file = file + } + + private func fetchCompletions(position: Position) async -> [CompletionItem] { + 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 + case .optionB(let completionList): + return completionList.items + case .none: + return [] + } + } catch { + return [] + } + } + + /// Filters completion items based on the current partial token input + private func filterCompletionItems(_ items: [CompletionItem]) -> [CompletionItem] { + guard !currentFilterText.isEmpty else { + return items + } + + let items = items.filter { item in + let insertText = LSPCompletionItemsUtil.getInsertText(from: item) + let label = item.label.lowercased() + let filterText = currentFilterText.lowercased() + if insertText.lowercased().hasPrefix(filterText) { + return true + } + if label.hasPrefix(filterText) { + return true + } + return false + } + + return items + } +} + +extension AutoCompleteCoordinator: CodeSuggestionDelegate { + @MainActor + func completionSuggestionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition + ) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? { + let tokenSubstringCount = findTreeSitterNodeAtPosition(textView: textView, cursorPosition: cursorPosition) + currentFilterText = "" + + var textPosition = Position(line: cursorPosition.line - 1, character: cursorPosition.column - 1) + var cursorPosition = cursorPosition + // 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 + if currentNode != nil { + textPosition = Position( + line: cursorPosition.line - 1, + character: cursorPosition.column - tokenSubstringCount - 1 + ) + cursorPosition = CursorPosition(line: textPosition.line + 1, column: textPosition.character + 1) + } + completionItems = await fetchCompletions(position: textPosition) + return (cursorPosition, 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 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 item = item as? CompletionItem 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/LanguageServer/LanguageServer.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift index 266cbbf6a0..43a3732601 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift @@ -157,7 +157,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( From f5af711d0c80ce87f6b47011b7fa6acb2d1b3c95 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:12:19 -0500 Subject: [PATCH 10/10] Update CESE Protocol Conformances --- .../xcshareddata/swiftpm/Package.resolved | 6 +- .../AutoCompleteCoordinator.swift | 14 +- .../AutoComplete/AutoCompleteItem.swift | 58 +++++++ CodeEdit/Features/LSP/LSPUtil.swift | 2 +- .../Features/LSP/Views/CompletionItem.swift | 91 ----------- .../LSP/Views/CompletionItemKind.swift | 141 +++++++++--------- 6 files changed, 142 insertions(+), 170 deletions(-) create mode 100644 CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteItem.swift delete mode 100644 CodeEdit/Features/LSP/Views/CompletionItem.swift diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d8574b9695..5264fab7b5 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a68a63b56c75b058610abbc7c9375c6cd3ccbe25fe06bf0f336610e78dab424c", + "originHash" : "b620be67193e2620d55aeb3152a8e45d7c8b355ec3cfeec961129b411d6de1d7", "pins" : [ { "identity" : "aboutwindow", @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "fbb038caa8a2779153a94f6e01caa5016ffb973d", - "version" : "0.11.7" + "revision" : "e7f1580a8075af84c349fb8c66fbd2776ff5cb1d", + "version" : "0.12.0" } }, { diff --git a/CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteCoordinator.swift b/CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteCoordinator.swift index 565e262fb7..30fd3f714c 100644 --- a/CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteCoordinator.swift +++ b/CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteCoordinator.swift @@ -20,13 +20,13 @@ class AutoCompleteCoordinator { /// The current filter text based on partial token input private var currentFilterText: String = "" /// Stores the unfiltered completion items - private var completionItems: [CompletionItem] = [] + private var completionItems: [AutoCompleteItem] = [] init(_ file: CEWorkspaceFile) { self.file = file } - private func fetchCompletions(position: Position) async -> [CompletionItem] { + private func fetchCompletions(position: Position) async -> [AutoCompleteItem] { let workspace = await file?.fileDocument?.findWorkspace() guard let file, let workspacePath = workspace?.fileURL?.absoluteURL.path(), @@ -51,9 +51,9 @@ class AutoCompleteCoordinator { // Extract the completion items list switch completions { case .optionA(let completionItems): - return completionItems + return completionItems.map { AutoCompleteItem($0) } case .optionB(let completionList): - return completionList.items + return completionList.items.map { AutoCompleteItem($0) } case .none: return [] } @@ -63,7 +63,7 @@ class AutoCompleteCoordinator { } /// Filters completion items based on the current partial token input - private func filterCompletionItems(_ items: [CompletionItem]) -> [CompletionItem] { + private func filterCompletionItems(_ items: [AutoCompleteItem]) -> [AutoCompleteItem] { guard !currentFilterText.isEmpty else { return items } @@ -183,9 +183,9 @@ extension AutoCompleteCoordinator: CodeSuggestionDelegate { func completionWindowApplyCompletion( item: CodeSuggestionEntry, textView: TextViewController, - cursorPosition: CursorPosition + cursorPosition: CursorPosition? ) { - guard let item = item as? CompletionItem else { return } + guard let cursorPosition, let item = item as? AutoCompleteItem else { return } // Make the updates let replacementRange = currentNode?.range ?? cursorPosition.range diff --git a/CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteItem.swift b/CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteItem.swift new file mode 100644 index 0000000000..c415f74c38 --- /dev/null +++ b/CodeEdit/Features/LSP/Features/AutoComplete/AutoCompleteItem.swift @@ -0,0 +1,58 @@ +// +// 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 { + let label: String + let kind: CompletionItemKind? + let detail: String? + let documentation: TwoTypeOption? + 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 = item.documentation + 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 + } +} diff --git a/CodeEdit/Features/LSP/LSPUtil.swift b/CodeEdit/Features/LSP/LSPUtil.swift index b71b945532..5a23f99aba 100644 --- a/CodeEdit/Features/LSP/LSPUtil.swift +++ b/CodeEdit/Features/LSP/LSPUtil.swift @@ -38,7 +38,7 @@ enum LSPCompletionItemsUtil { return edits } - static func getInsertText(from completionItem: CompletionItem) -> String { + 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 { diff --git a/CodeEdit/Features/LSP/Views/CompletionItem.swift b/CodeEdit/Features/LSP/Views/CompletionItem.swift deleted file mode 100644 index 4e1c938f1b..0000000000 --- a/CodeEdit/Features/LSP/Views/CompletionItem.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// CompletionItem.swift -// CodeEdit -// -// Created by Abe Malla on 10/05/24. -// - -import SwiftUI -import CodeEditSourceEditor -import LanguageServerProtocol - -// TODO: REMOVE Y OFFSET ON 16 PX? - -// TODO: IMPORT FONT SIZE -let FONT_SIZE: CGFloat = 12 -let fontSizeToImageSize: [CGFloat: CGFloat] = [ - 12: 16.5, - 13: 17.75, // Not sure - 14: 19, // checking this - 16: 22, - 18: 24, -] -let fontSizeToRowHeight: [CGFloat: CGFloat] = [ - 12: 21, - 13: 22, - 14: 23, - 15: 0, // TODO - 16: 26, - 17: 0, // TODO - 18: 28, -] -let fontSizeToRightPadding: [CGFloat: CGFloat] = [ - 12: 13, - 13: 13, - 14: 13, // TODO - 15: 12.5, - 16: 12.5, - 17: 12.5, - 18: 12.5, -] - -extension CompletionItem: @retroactive CodeSuggestionEntry { - public var view: NSView { - NSHostingView( - rootView: HStack(spacing: 0) { - Image(systemName: CompletionItemKind.toSymbolName(kind: self.kind)) - .font(.system(size: fontSizeToImageSize[FONT_SIZE]!)) - .foregroundStyle( - .white, - deprecated == true ? .gray : CompletionItemKind.toSymbolColor(kind: self.kind) - ) - .padding(0) - .padding(.trailing, 2) - - // Main label - HStack(spacing: 0) { - Text(label) - .font(.system(size: FONT_SIZE, design: .monospaced)) - .foregroundStyle(deprecated == true ? .secondary : .primary) - - if let detail = detail { - Text(detail) - .font(.system(size: FONT_SIZE, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - .padding(0) - .offset(y: -1) - - Spacer() - - // Right side indicators - HStack(spacing: 6.5) { - if deprecated == true { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: FONT_SIZE + 2)) - .foregroundStyle(.primary, .secondary) - } - if documentation != nil { - Image(systemName: "chevron.right") - .font(.system(size: FONT_SIZE - 2.5)) - .fontWeight(.semibold) - } - } - .padding(.leading, 4) - .padding(.trailing, 6.5) - } - .padding(.horizontal, fontSizeToRightPadding[FONT_SIZE]) - ) - } -} diff --git a/CodeEdit/Features/LSP/Views/CompletionItemKind.swift b/CodeEdit/Features/LSP/Views/CompletionItemKind.swift index dbe5515058..288def1a8d 100644 --- a/CodeEdit/Features/LSP/Views/CompletionItemKind.swift +++ b/CodeEdit/Features/LSP/Views/CompletionItemKind.swift @@ -9,77 +9,82 @@ import SwiftUI import LanguageServerProtocol extension CompletionItemKind { - static func toSymbolName(kind: CompletionItemKind?) -> String { - let defaultSymbol = "dot.square.fill" - - guard let kind = kind else { - return defaultSymbol + 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" } - - let symbolMap: [CompletionItemKind: String] = [ - .text: "t.square.fill", - .method: "m.square.fill", - .function: "curlybraces.square.fill", - .constructor: "i.square.fill", - .field: "c.square.fill", - .variable: "v.square.fill", - .class: "c.square.fill", - .interface: "i.square.fill", - .module: "m.square.fill", - .property: "p.square.fill", - .unit: "u.square.fill", - .value: "n.square.fill", - .enum: "e.square.fill", - .keyword: "k.square.fill", - .snippet: "s.square.fill", - .color: "c.square.fill", - .file: "d.square.fill", - .reference: "r.square.fill", - .folder: "f.square.fill", - .enumMember: "e.square.fill", - .constant: "k.square.fill", - .struct: "s.square.fill", - .event: "e.square.fill", - .operator: "plus.slash.minus", - .typeParameter: "t.square.fill" - ] - return symbolMap[kind] ?? defaultSymbol } - static func toSymbolColor(kind: CompletionItemKind?) -> SwiftUICore.Color { - let defaultColor = Color.gray - - guard let kind = kind else { - return defaultColor + 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 } - - let symbolMap: [CompletionItemKind: SwiftUICore.Color] = [ - .text: Color.blue, - .method: Color.cyan, - .function: Color.blue, - .constructor: Color.teal, - .field: Color.indigo, - .variable: Color.blue, - .class: Color.pink, - .interface: Color.blue, - .module: Color.blue, - .property: Color.purple, - .unit: Color.blue, - .value: Color.blue, - .enum: Color.mint, - .keyword: Color.pink, - .snippet: Color.purple, - .color: Color.blue, - .file: Color.blue, - .reference: Color.blue, - .folder: Color.blue, - .enumMember: Color.blue, - .constant: Color.blue, - .struct: Color.blue, - .event: Color.blue, - .operator: Color.blue, - .typeParameter: Color.blue, - ] - return symbolMap[kind] ?? defaultColor } }