Skip to content

Autocomplete Implementation #1949

New issue

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

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

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
23 changes: 10 additions & 13 deletions CodeEdit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,15 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
3048523D2D182DA6000CD5CF /* CodeEditSourceEditor in Frameworks */,
6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */,
6C66C31329D05CDC00DE9ED2 /* GRDB in Frameworks */,
58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */,
6C147C4529A329350089B630 /* OrderedCollections in Frameworks */,
6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */,
6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */,
6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor 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 */,
Expand Down Expand Up @@ -309,11 +309,9 @@
6C0617D52BDB4432008C9C42 /* LogStream */,
6C85BB3F2C2105ED00EB5DEF /* CodeEditKit */,
6C85BB432C210EFD00EB5DEF /* SwiftUIIntrospect */,
6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */,
6C0824A02C5C0C9700A0751E /* SwiftTerm */,
6CE21E862C650D2C0031B056 /* SwiftTerm */,
6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */,
6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */,
6CB94D022CA1205100E8651C /* AsyncAlgorithms */,
6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */,
6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */,
Expand Down Expand Up @@ -1616,6 +1614,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;
Expand Down Expand Up @@ -1851,14 +1856,6 @@
isa = XCSwiftPackageProductDependency;
productName = CodeEditSourceEditor;
};
6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */ = {
isa = XCSwiftPackageProductDependency;
productName = CodeEditSourceEditor;
};
6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */ = {
isa = XCSwiftPackageProductDependency;
productName = CodeEditSourceEditor;
};
6CE21E862C650D2C0031B056 /* SwiftTerm */ = {
isa = XCSwiftPackageProductDependency;
productName = SwiftTerm;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -123,8 +123,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/ChimeHQ/LanguageServerProtocol",
"state" : {
"revision" : "ac76fccf0e981c8e30c5ee4de1b15adc1decd697",
"version" : "0.13.2"
"revision" : "d51412945ae88ffcab65ec339ca89aed9c9f0b8a",
"version" : "0.13.3"
}
},
{
Expand All @@ -150,8 +150,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattmassicotte/Queue",
"state" : {
"revision" : "8d6f936097888f97011610ced40313655dc5948d",
"version" : "0.1.4"
"revision" : "6adf359a705e3252742905b413bb8f56401043ca",
"version" : "0.2.0"
}
},
{
Expand Down Expand Up @@ -195,8 +195,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "9bf03ff58ce34478e66aaee630e491823326fd06",
"version" : "1.1.3"
"revision" : "671108c96644956dddcd89dd59c203dcdb36cec7",
"version" : "1.1.4"
}
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import Foundation
import SwiftUI
import UniformTypeIdentifiers
import CodeEditSourceEditor
import CodeEditTextView
import CodeEditLanguages
import Combine
import OSLog
Expand Down
263 changes: 263 additions & 0 deletions CodeEdit/Features/Editor/AutoCompleteCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
//
// 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<String> = [
"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 = ""
}
}
2 changes: 2 additions & 0 deletions CodeEdit/Features/Editor/Models/EditorInstance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class EditorInstance: Hashable {
// Public TextViewCoordinator APIs

var rangeTranslator: RangeTranslator?
var autoCompleteCoordinator: AutoCompleteCoordinator?

// Internal Combine subjects

Expand All @@ -38,6 +39,7 @@ class EditorInstance: Hashable {
self.file = file
self.cursorSubject.send(cursorPositions)
self.rangeTranslator = RangeTranslator(cursorSubject: cursorSubject)
self.autoCompleteCoordinator = AutoCompleteCoordinator(file)
}

func hash(into hasher: inout Hasher) {
Expand Down
Loading
Loading