Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions CodeEdit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@
6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F729CD14D100235D17 /* CodeEditKit */; };
6C6BD6F929CD14D100235D17 /* CodeEditKit in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F729CD14D100235D17 /* CodeEditKit */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
6C73A6D32D4F1E550012D95C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */; };
6C76D6D42E15B91E00EF52C3 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */; };
6C77BFBC2E53B6860076827C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C77BFBB2E53B6860076827C /* CodeEditSourceEditor */; };
6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 6C81916A29B41DD300B75C92 /* DequeModule */; };
6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB3F2C2105ED00EB5DEF /* CodeEditKit */; };
6C85BB442C210EFD00EB5DEF /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB432C210EFD00EB5DEF /* SwiftUIIntrospect */; };
6C8B56492E2FE62E00DC3F29 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C8B56482E2FE62E00DC3F29 /* CodeEditSourceEditor */; };
6C9DB9E42D55656300ACD86E /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C9DB9E32D55656300ACD86E /* CodeEditSourceEditor */; };
6CAAF68A29BC9C2300A1F48A /* (null) in Sources */ = {isa = PBXBuildFile; };
6CAAF69229BCC71C00A1F48A /* (null) in Sources */ = {isa = PBXBuildFile; };
Expand Down Expand Up @@ -176,7 +177,6 @@
58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */,
6C147C4529A329350089B630 /* OrderedCollections in Frameworks */,
6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */,
6C76D6D42E15B91E00EF52C3 /* CodeEditSourceEditor in Frameworks */,
6CCF73D02E26DE3200B94F75 /* SwiftTerm in Frameworks */,
6C315FC82E05E33D0011BFC5 /* CodeEditSourceEditor in Frameworks */,
6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */,
Expand All @@ -192,10 +192,12 @@
6C73A6D32D4F1E550012D95C /* CodeEditSourceEditor in Frameworks */,
2816F594280CF50500DD548B /* CodeEditSymbols in Frameworks */,
30CB64942C16CA9100CC8A9E /* LanguageClient in Frameworks */,
6C8B56492E2FE62E00DC3F29 /* CodeEditSourceEditor in Frameworks */,
5EACE6222DF4BF08005E08B8 /* WelcomeWindow in Frameworks */,
6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */,
6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */,
6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */,
6C77BFBC2E53B6860076827C /* CodeEditSourceEditor in Frameworks */,
6CB94D032CA1205100E8651C /* AsyncAlgorithms in Frameworks */,
6C9DB9E42D55656300ACD86E /* CodeEditSourceEditor in Frameworks */,
);
Expand Down Expand Up @@ -323,7 +325,6 @@
6C0617D52BDB4432008C9C42 /* LogStream */,
6C85BB3F2C2105ED00EB5DEF /* CodeEditKit */,
6C85BB432C210EFD00EB5DEF /* SwiftUIIntrospect */,
6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */,
6C0824A02C5C0C9700A0751E /* SwiftTerm */,
6CE21E862C650D2C0031B056 /* SwiftTerm */,
6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */,
Expand All @@ -334,9 +335,10 @@
5EACE6212DF4BF08005E08B8 /* WelcomeWindow */,
5E4485602DF600D9008BBE69 /* AboutWindow */,
6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */,
6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */,
6CCF6DD22E26D48F00B94F75 /* SwiftTerm */,
6CCF73CF2E26DE3200B94F75 /* SwiftTerm */,
6C8B56482E2FE62E00DC3F29 /* CodeEditSourceEditor */,
6C77BFBB2E53B6860076827C /* CodeEditSourceEditor */,
);
productName = CodeEdit;
productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */;
Expand Down Expand Up @@ -441,8 +443,8 @@
30ED7B722DD299E600ACC922 /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
5EACE6202DF4BF08005E08B8 /* XCRemoteSwiftPackageReference "WelcomeWindow" */,
5E44855F2DF600D9008BBE69 /* XCRemoteSwiftPackageReference "AboutWindow" */,
6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */,
6CCF73CE2E26DE3200B94F75 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
6C77BFBA2E53B6860076827C /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */,
);
preferredProjectObjectVersion = 55;
productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */;
Expand Down Expand Up @@ -1778,7 +1780,7 @@
minimumVersion = 0.2.0;
};
};
6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = {
6C77BFBA2E53B6860076827C /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor";
requirement = {
Expand Down Expand Up @@ -1905,9 +1907,9 @@
isa = XCSwiftPackageProductDependency;
productName = CodeEditSourceEditor;
};
6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */ = {
6C77BFBB2E53B6860076827C /* CodeEditSourceEditor */ = {
isa = XCSwiftPackageProductDependency;
package = 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */;
package = 6C77BFBA2E53B6860076827C /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */;
productName = CodeEditSourceEditor;
};
6C7B1C752A1D57CE005CBBFC /* SwiftLint */ = {
Expand All @@ -1930,6 +1932,10 @@
package = 6C85BB422C210EFD00EB5DEF /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
productName = SwiftUIIntrospect;
};
6C8B56482E2FE62E00DC3F29 /* CodeEditSourceEditor */ = {
isa = XCSwiftPackageProductDependency;
productName = CodeEditSourceEditor;
};
6C9DB9E32D55656300ACD86E /* CodeEditSourceEditor */ = {
isa = XCSwiftPackageProductDependency;
package = 6C9DB9E22D55656300ACD86E /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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
3 changes: 3 additions & 0 deletions CodeEdit/Features/Editor/Models/EditorInstance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class EditorInstance: ObservableObject, Hashable {
@Published var replaceText: String?
var replaceTextSubject: PassthroughSubject<String?, Never>

var autoCompleteCoordinator: AutoCompleteCoordinator?

var rangeTranslator: RangeTranslator = RangeTranslator()

private var cancellables: Set<AnyCancellable> = []
Expand All @@ -37,6 +39,7 @@ class EditorInstance: ObservableObject, Hashable {
self.file = file
let url = file.url
let editorState = EditorStateRestoration.shared?.restorationState(for: url)
self.autoCompleteCoordinator = AutoCompleteCoordinator(file)

findText = workspace?.searchState?.searchQuery
findTextSubject = PassthroughSubject()
Expand Down
3 changes: 2 additions & 1 deletion CodeEdit/Features/Editor/Views/CodeFileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ struct CodeFileView: View {
),
highlightProviders: highlightProviders,
undoManager: undoRegistration.manager(forFile: editorInstance.file),
coordinators: textViewCoordinators
coordinators: textViewCoordinators,
completionDelegate: editorInstance.autoCompleteCoordinator
)
// This view needs to refresh when the codefile changes. The file URL is too stable.
.id(ObjectIdentifier(codeFile))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
//
// AutoCompleteCoordinator.swift
// CodeEdit
//
// Created by Abe Malla on 9/20/24.
//

import AppKit
import SwiftTreeSitter
import CodeEditTextView
import CodeEditSourceEditor
import LanguageServerProtocol

class AutoCompleteCoordinator {
/// A reference to the file we are working with, to be able to query file information
private weak var file: CEWorkspaceFile?

/// The current TreeSitter node that the main cursor is at
private var currentNode: SwiftTreeSitter.Node?
/// The current filter text based on partial token input
private var currentFilterText: String = ""
/// Stores the unfiltered completion items
private var completionItems: [AutoCompleteItem] = []
/// Set to true when the server sends an incomplete list, indicating that we should not filter client-side.
private var receivedIncompleteCompletionItems: Bool = false

init(_ file: CEWorkspaceFile) {
self.file = file
}

private func fetchCompletions(position: Position) async -> [AutoCompleteItem] {
let workspace = await file?.fileDocument?.findWorkspace()
guard let file,
let workspacePath = workspace?.fileURL?.absoluteURL.path(),
let language = await file.fileDocument?.getLanguage().lspLanguage else {
return []
}

@Service var lspService: LSPService
guard let client = await lspService.languageClient(
for: language,
workspacePath: workspacePath
) else {
return []
}

do {
let completions = try await client.requestCompletion(
for: file.url.lspURI,
position: position
)

// Extract the completion items list
switch completions {
case .optionA(let completionItems):
return completionItems.map { AutoCompleteItem($0) }.sorted()
case .optionB(let completionList):
receivedIncompleteCompletionItems = receivedIncompleteCompletionItems || completionList.isIncomplete
return completionList.items.map { AutoCompleteItem($0) }.sorted()
case .none:
return []
}
} catch {
return []
}
}

/// Filters completion items based on the current partial token input
private func filterCompletionItems(_ items: [AutoCompleteItem]) -> [AutoCompleteItem] {
guard !currentFilterText.isEmpty, !receivedIncompleteCompletionItems else {
return items.sorted()
}

let items = items
.map { ($0.fuzzyMatch(query: currentFilterText), $0) }
.compactMap { $0.0.weight > 0 ? $0.1 : nil }

return items.sorted()
}
}

extension AutoCompleteCoordinator: CodeSuggestionDelegate {
@MainActor
func completionSuggestionsRequested(
textView: TextViewController,
cursorPosition: CursorPosition
) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? {
currentFilterText = ""
let tokenSubstringCount = findTreeSitterNodeAtPosition(textView: textView, cursorPosition: cursorPosition)

let textPosition = Position(line: cursorPosition.start.line - 1, character: cursorPosition.start.column - 1)

// If we are asking for completions in the middle of a token, then
// query the language server for completion items at the start of the token
// but *only* if we haven't received an incomplete response.
let queryPosition = if currentNode != nil && !receivedIncompleteCompletionItems {
Position(
line: cursorPosition.start.line - 1,
character: cursorPosition.start.column - tokenSubstringCount - 1
)
} else {
textPosition
}
completionItems = await fetchCompletions(position: queryPosition)

if receivedIncompleteCompletionItems && queryPosition != textPosition {
// We need to re-request this. We've requested the wrong location and since know that the server
// returns incomplete items (meaning we can't filter them ourselves).
return await completionSuggestionsRequested(textView: textView, cursorPosition: cursorPosition)
}

// If we can detect that we're in a node, we still want to adjust the panel to be in the correct position
let cursorPosition: CursorPosition = if currentNode != nil {
CursorPosition(
line: cursorPosition.start.line,
column: cursorPosition.start.column - tokenSubstringCount
)
} else {
CursorPosition(
line: queryPosition.line + 1,
column: queryPosition.character + 1
)
}

return (cursorPosition, filterCompletionItems(completionItems))
}

func findTreeSitterNodeAtPosition(textView: TextViewController, cursorPosition: CursorPosition) -> Int {
var tokenSubstringCount = 0
let prefixRange = NSRange(location: cursorPosition.range.location - 1, length: 1)
guard prefixRange.location >= 0 else { return 0 }
do {
if let token = try textView.treeSitterClient?.nodesAt(range: prefixRange).first,
token.node.isNamed {
currentNode = token.node

// Get the string from the start of the token to the location of the cursor
if cursorPosition.range.location > token.node.range.location {
let selectedRange = NSRange(
location: token.node.range.location,
length: cursorPosition.range.location - token.node.range.location
)
if let tokenSubstring = textView.textView.textStorage?.substring(from: selectedRange) {
currentFilterText = tokenSubstring
tokenSubstringCount = tokenSubstring.count
}
}
}
} catch {
print("Error getting TreeSitter node: \(error)")
}
return tokenSubstringCount
}

func completionOnCursorMove(
textView: TextViewController,
cursorPosition: CursorPosition
) -> [CodeSuggestionEntry]? {
guard var currentNode = currentNode, !completionItems.isEmpty, !receivedIncompleteCompletionItems else {
return nil
}
_ = findTreeSitterNodeAtPosition(textView: textView, cursorPosition: cursorPosition)
guard let refreshedNode = self.currentNode else { return nil }
if refreshedNode.range.intersection(currentNode.range) == nil {
return nil
}
currentNode = refreshedNode

// Moving to a new token requires a new call to the language server
// We extend the range so that the `contains` can include the end value of
// the token, since its check is exclusive.
if !currentNode.range.contains(cursorPosition.range.location)
&& currentNode.range.max != cursorPosition.range.location {
return nil
}

// Check if cursor is at the start of the token
if cursorPosition.range.location == currentNode.range.location {
currentFilterText = ""
return completionItems
}

// Filter through the completion items based on how far the cursor is in the token
if cursorPosition.range.location > currentNode.range.location {
let selectedRange = NSRange(
location: currentNode.range.location,
length: cursorPosition.range.location - currentNode.range.location
)
if let tokenSubstring = textView.textView.textStorage?.substring(from: selectedRange) {
currentFilterText = tokenSubstring
return filterCompletionItems(completionItems)
}
}

return nil
}

/// Takes a `CompletionItem` and modifies the text view with the new string
func completionWindowApplyCompletion(
item: CodeSuggestionEntry,
textView: TextViewController,
cursorPosition: CursorPosition?
) {
guard let cursorPosition, let item = item as? AutoCompleteItem else { return }

// Make the updates
let replacementRange = currentNode?.range ?? cursorPosition.range
let insertText = LSPCompletionItemsUtil.getInsertText(from: item)
textView.textView.replaceCharacters(in: replacementRange, with: insertText)

// Set cursor position to end of inserted text
let newCursorRange = NSRange(location: replacementRange.location + insertText.count, length: 0)
textView.setCursorPositions([CursorPosition(range: newCursorRange)])
}

func completionWindowDidSelect(item: CodeSuggestionEntry) { }

func completionWindowDidClose() {
currentNode = nil
currentFilterText = ""
}
}
Loading
Loading