Skip to content
Merged
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
2 changes: 2 additions & 0 deletions Sources/CodeEditorApp/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// A cancelled panel on an untitled document means the work is still there.
// Abandoning the quit is the only way not to throw it away.
if await appState?.saveAllModified() == true {
appState?.disconnectLanguageServers()
sender.terminate(nil)
} else {
isResolvingTermination = false
}
case .discard:
appState?.disconnectLanguageServers()
sender.terminate(nil)
case .cancel:
isResolvingTermination = false
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodeEditorApp/Sources/MainWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ struct MainWindowView: View {
CodeEditorView(
document: document,
theme: appState.theme,
editorState: appState.editorState
editorState: appState.editorState,
appState: appState
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
Expand Down
48 changes: 48 additions & 0 deletions Sources/CodeEditorCore/Sources/Document/Document+Completion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation

extension Document {
/// LSP position (0-indexed line; 0-indexed character in UTF-16 code units) for a UTF-8
/// content offset.
///
/// `lineAndColumn(for:)` works in UTF-8 bytes; LSP wants UTF-16 code units for
/// `character`. They coincide for ASCII source, which covers every identifier the
/// completion popup is offered on. Non-ASCII content (e.g. accented letters in strings)
/// is a known v1 limitation.
public func lspPosition(for offset: Int) -> (line: Int, character: Int)? {
guard let (line, column) = lineAndColumn(for: offset),
lines.indices.contains(line)
else { return nil }
let lineText = lines[line]
let utf16 = lineText.prefix(column).utf16.count
return (line, utf16)
}

/// The contiguous run of identifier characters (letters, digits, `_`) ending at
/// `offset` — the text the user is mid-word typing. Returned as a UTF-16 range (the
/// unit the text view addresses by); `nil` when no identifier character sits
/// immediately before `offset`.
///
/// `offset` is a UTF-8 offset treated as a UTF-16 index here; for ASCII source these
/// are the same value, which is the contract honoured throughout the completion path.
public func completionPrefixRange(at offset: Int) -> NSRange? {
let source = content
guard offset > 0, offset <= source.utf16.count else { return nil }
var start = offset
while start > 0 {
let index = source.index(source.startIndex, offsetBy: start - 1)
let char = source[index]
if char.isASCII, char.isNumber || char.isLetter || String(char) == "_" {
start -= 1
} else {
break
}
}
return start < offset ? NSRange(location: start, length: offset - start) : nil
}

/// The prefix the user is typing at `offset`, for fuzzy filtering of completions.
public func completionPrefix(at offset: Int) -> String {
guard let range = completionPrefixRange(at: offset) else { return "" }
return (content as NSString).substring(with: range)
}
}
50 changes: 50 additions & 0 deletions Sources/CodeEditorLSP/Completion/CompletionMatching.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation

/// Case- and diacritic-insensitive subsequence (fuzzy) match.
///
/// A completion labeled `forEachPrefix(_:)` matches the query `ffp` because its
/// characters appear in order. Used to narrow the server's list as the user types,
/// mirroring the trigger behaviour upstream expects from a `CompletionMode`.
public func fuzzyMatch(_ query: String, _ label: String) -> Bool {
let query = query.lowercased()
let label = label.lowercased()
guard !query.isEmpty else { return true }
var scan = label.startIndex
for char in query {
guard let range = label[scan...].range(of: String(char)) else { return false }
scan = range.upperBound
}
return true
}

extension CompletionItem {
/// The string used to filter and rank an item. Servers may set `filterText` to a
/// substring of the label; absent that we match against the label itself.
public var filterLabel: String { filterText ?? label }

/// The value `sortText` when the server supplied it, otherwise the filter label.
/// sourcekit-lsp emits `sortText`; other servers may not, in which case we fall back
/// to the label so ordering stays deterministic.
public var sortKey: String { sortText ?? filterLabel }
}

/// Order completions the way a stable, predictable popup would: by the server's
/// `sortText` first, then by label.
public func completionOrdered(_ lhs: CompletionItem, _ rhs: CompletionItem) -> Bool {
let l = lhs.sortKey
let r = rhs.sortKey
if l != r { return l < r }
return lhs.filterLabel < rhs.filterLabel
}

/// Filter `items` to those matching `query` (empty query matches all) and rank them.
///
/// - Parameter query: what the user typed at the caret. Computed by the caller from
/// the document so this function stays a pure, side-effect-free ranking step that is
/// cheap enough to run on every cursor move.
/// - Returns: the surviving items in display order.
public func rankedCompletions(_ items: [CompletionItem], query: String) -> [CompletionItem] {
items
.filter { fuzzyMatch(query, $0.filterLabel) }
.sorted(by: completionOrdered)
}
Loading
Loading