Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -19,37 +19,74 @@ export type CostTrackingCallback = (
cacheReadTokens: number,
) => void

export interface MatchingSuggestionResult {
text: string
originalSuggestion: FillInAtCursorSuggestion
}

function isAutoClosingChar(char: string): boolean {
return [")", "]", "}", ">", '"', "'", "`"].includes(char)
}

function removePotentialAutoBracket(suffix: string): string {
return suffix.length > 0 && isAutoClosingChar(suffix[0]) ? suffix.substring(1) : suffix
}

function checkPrefixSuffixMatch(
prefix: string,
suffix: string,
expectedPrefix: string,
expectedSuffix: string,
): { matches: boolean; cleanedSuffix: string } {
const cleanedSuffix = removePotentialAutoBracket(suffix)

if (prefix === expectedPrefix && suffix === expectedSuffix) {
return { matches: true, cleanedSuffix: suffix }
}

if (prefix === expectedPrefix && cleanedSuffix === expectedSuffix) {
return { matches: true, cleanedSuffix }
}

return { matches: false, cleanedSuffix }
}

/**
* Find a matching suggestion from the history based on current prefix and suffix
* @param prefix - The text before the cursor position
* @param suffix - The text after the cursor position
* @param suggestionsHistory - Array of previous suggestions (most recent last)
* @returns The matching suggestion text, or null if no match found
* @returns The matching suggestion result with text and original suggestion, or null if no match found
*/
export function findMatchingSuggestion(
prefix: string,
suffix: string,
suggestionsHistory: FillInAtCursorSuggestion[],
): string | null {
): MatchingSuggestionResult | null {
// Search from most recent to least recent
for (let i = suggestionsHistory.length - 1; i >= 0; i--) {
const fillInAtCursor = suggestionsHistory[i]

// First, try exact prefix/suffix match
if (prefix === fillInAtCursor.prefix && suffix === fillInAtCursor.suffix) {
return fillInAtCursor.text
const exactMatch = checkPrefixSuffixMatch(prefix, suffix, fillInAtCursor.prefix, fillInAtCursor.suffix)
if (exactMatch.matches) {
return {
text: fillInAtCursor.text,
originalSuggestion: fillInAtCursor,
}
}

// If no exact match, check for partial typing
// The user may have started typing the suggested text
if (prefix.startsWith(fillInAtCursor.prefix) && suffix === fillInAtCursor.suffix) {
// Extract what the user has typed between the original prefix and current position
const typedContent = prefix.substring(fillInAtCursor.prefix.length)
if (prefix.startsWith(fillInAtCursor.prefix)) {
const partialMatch = checkPrefixSuffixMatch(prefix, suffix, fillInAtCursor.prefix, fillInAtCursor.suffix)

// Check if the typed content matches the beginning of the suggestion
if (fillInAtCursor.text.startsWith(typedContent)) {
// Return the remaining part of the suggestion (with already-typed portion removed)
return fillInAtCursor.text.substring(typedContent.length)
if (partialMatch.cleanedSuffix === fillInAtCursor.suffix) {
const typedContent = prefix.substring(fillInAtCursor.prefix.length)

if (fillInAtCursor.text.startsWith(typedContent)) {
return {
text: fillInAtCursor.text.substring(typedContent.length),
originalSuggestion: fillInAtCursor,
}
}
}
}
}
Expand Down Expand Up @@ -237,12 +274,26 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
): Promise<vscode.InlineCompletionItem[] | vscode.InlineCompletionList> {
const { prefix, suffix } = extractPrefixSuffix(document, position)

const matchingText = findMatchingSuggestion(prefix, suffix, this.suggestionsHistory)
const matchingResult = findMatchingSuggestion(prefix, suffix, this.suggestionsHistory)

if (matchingResult !== null) {
// Check if suffix has a new auto-inserted bracket at the start
// This happens when VS Code's bracket completion runs before our suggestion
const suffixFirstChar = suffix.length > 0 ? suffix[0] : ""
const originalSuffixFirstChar =
matchingResult.originalSuggestion.suffix.length > 0 ? matchingResult.originalSuggestion.suffix[0] : ""

// Detect if a bracket was auto-inserted:
// 1. Current suffix starts with an auto-closing character
// 2. Original suffix didn't start with that character (or was different)
const hasAutoInsertedBracket =
isAutoClosingChar(suffixFirstChar) && suffixFirstChar !== originalSuffixFirstChar

if (matchingText !== null) {
const item: vscode.InlineCompletionItem = {
insertText: matchingText,
range: new vscode.Range(position, position),
insertText: matchingResult.text,
range: hasAutoInsertedBracket
? new vscode.Range(position, new vscode.Position(position.line, position.character + 1)) // Replace the auto-bracket
: new vscode.Range(position, position), // Just insert
}
return [item]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
GhostInlineCompletionProvider,
findMatchingSuggestion,
CostTrackingCallback,
MatchingSuggestionResult,
} from "../GhostInlineCompletionProvider"
import { GhostSuggestionsState, FillInAtCursorSuggestion } from "../GhostSuggestions"
import { MockTextDocument } from "../../../mocking/MockTextDocument"
Expand Down Expand Up @@ -34,7 +35,9 @@ describe("findMatchingSuggestion", () => {
]

const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions)
expect(result).toBe("console.log('Hello, World!');")
expect(result).not.toBeNull()
expect(result?.text).toBe("console.log('Hello, World!');")
expect(result?.originalSuggestion).toEqual(suggestions[0])
})

it("should return null when prefix does not match", () => {
Expand Down Expand Up @@ -81,7 +84,9 @@ describe("findMatchingSuggestion", () => {

// User typed "cons" after the prefix
const result = findMatchingSuggestion("const x = 1cons", "\nconst y = 2", suggestions)
expect(result).toBe("ole.log('Hello, World!');")
expect(result).not.toBeNull()
expect(result?.text).toBe("ole.log('Hello, World!');")
expect(result?.originalSuggestion).toEqual(suggestions[0])
})

it("should return full suggestion when no partial typing", () => {
Expand All @@ -94,7 +99,8 @@ describe("findMatchingSuggestion", () => {
]

const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions)
expect(result).toBe("console.log('test');")
expect(result).not.toBeNull()
expect(result?.text).toBe("console.log('test');")
})

it("should return null when partially typed content does not match suggestion", () => {
Expand All @@ -121,7 +127,8 @@ describe("findMatchingSuggestion", () => {
]

const result = findMatchingSuggestion("const x = 1console.log('test');", "\nconst y = 2", suggestions)
expect(result).toBe("")
expect(result).not.toBeNull()
expect(result?.text).toBe("")
})

it("should return null when suffix has changed during partial typing", () => {
Expand Down Expand Up @@ -149,7 +156,8 @@ describe("findMatchingSuggestion", () => {

// User typed "function te"
const result = findMatchingSuggestion("const x = 1function te", "\nconst y = 2", suggestions)
expect(result).toBe("st() { return 42; }")
expect(result).not.toBeNull()
expect(result?.text).toBe("st() { return 42; }")
})

it("should be case-sensitive in partial matching", () => {
Expand Down Expand Up @@ -183,7 +191,8 @@ describe("findMatchingSuggestion", () => {
]

const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions)
expect(result).toBe("second suggestion")
expect(result).not.toBeNull()
expect(result?.text).toBe("second suggestion")
})

it("should match different suggestions based on context", () => {
Expand All @@ -201,10 +210,12 @@ describe("findMatchingSuggestion", () => {
]

const result1 = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions)
expect(result1).toBe("first suggestion")
expect(result1).not.toBeNull()
expect(result1?.text).toBe("first suggestion")

const result2 = findMatchingSuggestion("const a = 1", "\nconst b = 2", suggestions)
expect(result2).toBe("second suggestion")
expect(result2).not.toBeNull()
expect(result2?.text).toBe("second suggestion")
})

it("should prefer exact match over partial match", () => {
Expand All @@ -223,7 +234,8 @@ describe("findMatchingSuggestion", () => {

// User is at position that matches exact prefix of second suggestion
const result = findMatchingSuggestion("const x = 1cons", "\nconst y = 2", suggestions)
expect(result).toBe("exact match")
expect(result).not.toBeNull()
expect(result?.text).toBe("exact match")
})
})
})
Expand Down Expand Up @@ -808,6 +820,106 @@ describe("GhostInlineCompletionProvider", () => {
expect(result).toEqual([])
})
})

describe("auto-bracket detection", () => {
it("should replace auto-inserted closing bracket when detected", async () => {
// Set up a suggestion with no bracket in suffix
const suggestions = new GhostSuggestionsState()
suggestions.setFillInAtCursor({
text: "useState<boolean>(true);",
prefix: "const x = ",
suffix: "\nconst y = 2",
})
provider.updateSuggestions(suggestions)

// Simulate VS Code auto-inserting a closing bracket after typing "["
// Document now has: "const x = ]\nconst y = 2"
// Position is right after "= " and before the auto-inserted "]"
const documentWithBracket = new MockTextDocument(
vscode.Uri.file("/test.ts"),
"const x = ]\nconst y = 2",
)
const positionBeforeBracket = new vscode.Position(0, 10) // After "= ", before "]"

const result = (await provider.provideInlineCompletionItems(
documentWithBracket,
positionBeforeBracket,
mockContext,
mockToken,
)) as vscode.InlineCompletionItem[]

expect(result).toHaveLength(1)
expect(result[0].insertText).toBe("useState<boolean>(true);")
// Should replace the auto-inserted bracket
expect(result[0].range).toEqual(new vscode.Range(positionBeforeBracket, new vscode.Position(0, 11)))
})

it("should not replace bracket if it was in original suffix", async () => {
// Set up a suggestion where bracket was already in suffix
const suggestions = new GhostSuggestionsState()
suggestions.setFillInAtCursor({
text: "useState<boolean>(true);",
prefix: "const x = ",
suffix: "]\nconst y = 2",
})
provider.updateSuggestions(suggestions)

// Same document state - bracket was already there when suggestion was cached
const documentWithBracket = new MockTextDocument(
vscode.Uri.file("/test.ts"),
"const x = ]\nconst y = 2",
)
const positionBeforeBracket = new vscode.Position(0, 10)

const result = (await provider.provideInlineCompletionItems(
documentWithBracket,
positionBeforeBracket,
mockContext,
mockToken,
)) as vscode.InlineCompletionItem[]

expect(result).toHaveLength(1)
expect(result[0].insertText).toBe("useState<boolean>(true);")
// Should NOT replace the bracket - just insert
expect(result[0].range).toEqual(new vscode.Range(positionBeforeBracket, positionBeforeBracket))
})

it("should handle other auto-closing characters", async () => {
const testCases = [
{ char: ")", desc: "parenthesis" },
{ char: "}", desc: "curly brace" },
{ char: ">", desc: "angle bracket" },
{ char: '"', desc: "double quote" },
{ char: "'", desc: "single quote" },
]

for (const { char, desc } of testCases) {
const suggestions = new GhostSuggestionsState()
suggestions.setFillInAtCursor({
text: "test",
prefix: "const x = ",
suffix: "\nconst y = 2",
})
provider.updateSuggestions(suggestions)

const documentWithChar = new MockTextDocument(
vscode.Uri.file("/test.ts"),
`const x = ${char}\nconst y = 2`,
)
const position = new vscode.Position(0, 10)

const result = (await provider.provideInlineCompletionItems(
documentWithChar,
position,
mockContext,
mockToken,
)) as vscode.InlineCompletionItem[]

expect(result).toHaveLength(1)
expect(result[0].range).toEqual(new vscode.Range(position, new vscode.Position(0, 11)))
}
})
})
})

describe("cachedSuggestionAvailable", () => {
Expand Down