diff --git a/src/eslint.config.mjs b/src/eslint.config.mjs index d0813406d9..3a220f8c1e 100644 --- a/src/eslint.config.mjs +++ b/src/eslint.config.mjs @@ -29,7 +29,14 @@ export default [ "no-undef": "off", }, }, + { + files: ["**/__test_cases__/**/*"], + rules: { + "no-undef": "off", + "no-const-assign": "off", + }, + }, { ignores: ["webview-ui", "out"], }, -] +] \ No newline at end of file diff --git a/src/services/autocomplete/__tests__/MockTextDocument.test.ts b/src/services/autocomplete/__tests__/MockTextDocument.test.ts new file mode 100644 index 0000000000..5c02743b15 --- /dev/null +++ b/src/services/autocomplete/__tests__/MockTextDocument.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect } from "vitest" +import * as vscode from "vscode" +import { MockTextDocument } from "./MockTextDocument" + +describe("MockTextDocument", () => { + describe("constructor and basic properties", () => { + it("should create document from single line content", () => { + const doc = new MockTextDocument("const x = 1") + + expect(doc.lineCount).toBe(1) + expect(doc.getText()).toBe("const x = 1") + }) + + it("should create document from multi-line content", () => { + const content = "function test() {\n return true\n}" + const doc = new MockTextDocument(content) + + expect(doc.lineCount).toBe(3) + expect(doc.getText()).toBe(content) + }) + + it("should handle empty content", () => { + const doc = new MockTextDocument("") + + expect(doc.lineCount).toBe(1) + expect(doc.getText()).toBe("") + }) + + it("should handle content with only newlines", () => { + const doc = new MockTextDocument("\n\n\n") + + expect(doc.lineCount).toBe(4) + expect(doc.getText()).toBe("\n\n\n") + }) + }) + + describe("getText() method", () => { + const multiLineContent = "line 1\nline 2\nline 3\nline 4" + let doc: MockTextDocument + + beforeEach(() => { + doc = new MockTextDocument(multiLineContent) + }) + + it("should return full text when no range provided", () => { + expect(doc.getText()).toBe(multiLineContent) + }) + + it("should return text within single line range", () => { + const range = new vscode.Range(new vscode.Position(1, 2), new vscode.Position(1, 5)) + + expect(doc.getText(range)).toBe("ne ") + }) + + it("should return text from start of line to position", () => { + const range = new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 4)) + + expect(doc.getText(range)).toBe("line") + }) + + it("should return text from position to end of line", () => { + const range = new vscode.Range(new vscode.Position(1, 5), new vscode.Position(1, 6)) + + expect(doc.getText(range)).toBe("2") + }) + + it("should return text across multiple lines", () => { + const range = new vscode.Range(new vscode.Position(1, 2), new vscode.Position(3, 2)) + + expect(doc.getText(range)).toBe("ne 2\nline 3\nli") + }) + + it("should handle range starting from middle of first line", () => { + const range = new vscode.Range(new vscode.Position(0, 2), new vscode.Position(2, 4)) + + expect(doc.getText(range)).toBe("ne 1\nline 2\nline") + }) + + it("should handle range ending in middle of last line", () => { + const range = new vscode.Range(new vscode.Position(1, 0), new vscode.Position(2, 4)) + + expect(doc.getText(range)).toBe("line 2\nline") + }) + + it("should handle range beyond document bounds gracefully", () => { + const range = new vscode.Range(new vscode.Position(2, 0), new vscode.Position(10, 10)) + + expect(doc.getText(range)).toBe("line 3\nline 4") + }) + }) + + describe("lineAt() method", () => { + const content = " const x = 1\n\n function test() {\n return x\n }" + let doc: MockTextDocument + + beforeEach(() => { + doc = new MockTextDocument(content) + }) + + it("should return correct line information for first line", () => { + const line = doc.lineAt(0) + + expect(line.text).toBe(" const x = 1") + expect(line.lineNumber).toBe(0) + expect(line.firstNonWhitespaceCharacterIndex).toBe(2) + expect(line.isEmptyOrWhitespace).toBe(false) + }) + + it("should return correct line information for empty line", () => { + const line = doc.lineAt(1) + + expect(line.text).toBe("") + expect(line.lineNumber).toBe(1) + expect(line.firstNonWhitespaceCharacterIndex).toBe(0) + expect(line.isEmptyOrWhitespace).toBe(true) + }) + + it("should return correct line information for whitespace-only line", () => { + const docWithWhitespace = new MockTextDocument("line1\n \nline3") + const line = docWithWhitespace.lineAt(1) + + expect(line.text).toBe(" ") + expect(line.lineNumber).toBe(1) + expect(line.firstNonWhitespaceCharacterIndex).toBe(4) + expect(line.isEmptyOrWhitespace).toBe(true) + }) + + it("should return correct line information for indented line", () => { + const line = doc.lineAt(2) + + expect(line.text).toBe(" function test() {") + expect(line.lineNumber).toBe(2) + expect(line.firstNonWhitespaceCharacterIndex).toBe(4) + expect(line.isEmptyOrWhitespace).toBe(false) + }) + + it("should include correct range information", () => { + const line = doc.lineAt(0) + + expect(line.range.start.line).toBe(0) + expect(line.range.start.character).toBe(0) + expect(line.range.end.line).toBe(0) + expect(line.range.end.character).toBe(13) // Length of " const x = 1" + }) + + it("should throw error for invalid line number (negative)", () => { + expect(() => doc.lineAt(-1)).toThrow("Invalid line number: -1") + }) + + it("should throw error for invalid line number (beyond bounds)", () => { + expect(() => doc.lineAt(10)).toThrow("Invalid line number: 10") + }) + }) + + describe("edge cases and special characters", () => { + it("should handle tabs correctly", () => { + const doc = new MockTextDocument("\tfunction test() {\n\t\treturn true\n\t}") + + expect(doc.lineCount).toBe(3) + + const line0 = doc.lineAt(0) + expect(line0.text).toBe("\tfunction test() {") + expect(line0.firstNonWhitespaceCharacterIndex).toBe(1) + + const line1 = doc.lineAt(1) + expect(line1.text).toBe("\t\treturn true") + expect(line1.firstNonWhitespaceCharacterIndex).toBe(2) + }) + + it("should handle mixed whitespace", () => { + const doc = new MockTextDocument(" \t const x = 1") + const line = doc.lineAt(0) + + expect(line.text).toBe(" \t const x = 1") + expect(line.firstNonWhitespaceCharacterIndex).toBe(5) + expect(line.isEmptyOrWhitespace).toBe(false) + }) + + it("should handle unicode characters", () => { + const doc = new MockTextDocument("const 🚀 = 'rocket'\nconst 中文 = 'chinese'") + + expect(doc.lineCount).toBe(2) + expect(doc.lineAt(0).text).toBe("const 🚀 = 'rocket'") + expect(doc.lineAt(1).text).toBe("const 中文 = 'chinese'") + }) + + it("should handle Windows line endings (CRLF)", () => { + const doc = new MockTextDocument("line1\r\nline2\r\nline3") + + // Note: split("\n") will still work but will include \r in the text + expect(doc.lineCount).toBe(3) + expect(doc.lineAt(0).text).toBe("line1\r") + expect(doc.lineAt(1).text).toBe("line2\r") + expect(doc.lineAt(2).text).toBe("line3") + }) + }) + + describe("integration with vscode types", () => { + it("should work with vscode.Range for getText", () => { + const doc = new MockTextDocument("function test() {\n return 42\n}") + + // Create a range using vscode constructors + const start = new vscode.Position(0, 9) + const end = new vscode.Position(1, 11) + const range = new vscode.Range(start, end) + + expect(doc.getText(range)).toBe("test() {\n return ") + }) + + it("should return TextLine compatible with vscode interface", () => { + const doc = new MockTextDocument(" const value = 'test'") + const line = doc.lineAt(0) + + // Verify it has all required TextLine properties + expect(line).toHaveProperty("text") + expect(line).toHaveProperty("range") + expect(line).toHaveProperty("lineNumber") + expect(line).toHaveProperty("rangeIncludingLineBreak") + expect(line).toHaveProperty("firstNonWhitespaceCharacterIndex") + expect(line).toHaveProperty("isEmptyOrWhitespace") + + // Verify types match vscode expectations + expect(typeof line.text).toBe("string") + expect(typeof line.lineNumber).toBe("number") + expect(typeof line.firstNonWhitespaceCharacterIndex).toBe("number") + expect(typeof line.isEmptyOrWhitespace).toBe("boolean") + expect(line.range).toBeInstanceOf(vscode.Range) + }) + }) +}) diff --git a/src/services/autocomplete/__tests__/MockTextDocument.ts b/src/services/autocomplete/__tests__/MockTextDocument.ts new file mode 100644 index 0000000000..9a12375fae --- /dev/null +++ b/src/services/autocomplete/__tests__/MockTextDocument.ts @@ -0,0 +1,69 @@ +import * as vscode from "vscode" + +/** + * A simulated vscode TextDocument for testing. + */ +export class MockTextDocument { + private contentLines: string[] + + constructor(content: string) { + this.contentLines = content.split("\n") + } + + updateContent(newContent: string): void { + this.contentLines = newContent.split("\n") + } + + getText(range?: vscode.Range): string { + if (!range) { + return this.contentLines.join("\n") + } + + const startLine = range.start.line + const endLine = range.end.line + + if (startLine === endLine) { + return this.contentLines[startLine].substring(range.start.character, range.end.character) + } + + const lines: string[] = [] + for (let i = startLine; i <= endLine && i < this.contentLines.length; i++) { + if (i === startLine) { + lines.push(this.contentLines[i].substring(range.start.character)) + } else if (i === endLine) { + lines.push(this.contentLines[i].substring(0, range.end.character)) + } else { + lines.push(this.contentLines[i]) + } + } + + return lines.join("\n") + } + + get lineCount(): number { + return this.contentLines.length + } + + /** + * Returns information about a specific line in the document + * @param lineNumber The zero-based line number + * @returns A simplified TextLine object containing the text and position information + */ + lineAt(lineNumber: number): vscode.TextLine { + if (lineNumber < 0 || lineNumber >= this.contentLines.length) { + throw new Error(`Invalid line number: ${lineNumber}`) + } + + const text = this.contentLines[lineNumber] + const range = new vscode.Range(new vscode.Position(lineNumber, 0), new vscode.Position(lineNumber, text.length)) + + return { + text, + range, + lineNumber, + rangeIncludingLineBreak: range, + firstNonWhitespaceCharacterIndex: text.search(/\S|$/), + isEmptyOrWhitespace: !/\S/.test(text), + } as vscode.TextLine + } +} diff --git a/src/services/autocomplete/__tests__/MockTextEditor.test.ts b/src/services/autocomplete/__tests__/MockTextEditor.test.ts new file mode 100644 index 0000000000..fc1c995c6c --- /dev/null +++ b/src/services/autocomplete/__tests__/MockTextEditor.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest" +import { MockTextEditor, CURSOR_MARKER } from "./MockTextEditor" + +describe("MockTextEditor", () => { + it("should correctly parse cursor position from marker", () => { + const editor = MockTextEditor.create(`function test() {\n ␣return true\n}`) + + // Test selection property + expect(editor.selection.active.line).toBe(1) + expect(editor.selection.active.character).toBe(4) + expect(editor.selection.anchor.line).toBe(1) + expect(editor.selection.anchor.character).toBe(4) + + // Verify the cursor marker was removed from the document + const documentText = editor.document.getText() + expect(documentText).toBe("function test() {\n return true\n}") + expect(documentText).not.toContain("␣") + }) + + it("should handle cursor at start of document", () => { + const editor = MockTextEditor.create(`␣const x = 1`) + + expect(editor.selection.active.line).toBe(0) + expect(editor.selection.active.character).toBe(0) + expect(editor.document.getText()).toBe("const x = 1") + }) + + it("should handle cursor at end of document", () => { + const editor = MockTextEditor.create(`const x = 1␣`) + + expect(editor.selection.active.line).toBe(0) + expect(editor.selection.active.character).toBe(11) + expect(editor.document.getText()).toBe("const x = 1") + }) + + it("should handle cursor in middle of line", () => { + const editor = MockTextEditor.create(`const ␣x = 1`) + + expect(editor.selection.active.line).toBe(0) + expect(editor.selection.active.character).toBe(6) + expect(editor.document.getText()).toBe("const x = 1") + }) + + it("should default to position (0,0) when cursor marker is missing", () => { + const editor = MockTextEditor.create("const x = 1") + + // Test selection property + expect(editor.selection.active.line).toBe(0) + expect(editor.selection.active.character).toBe(0) + expect(editor.selection.anchor.line).toBe(0) + expect(editor.selection.anchor.character).toBe(0) + + // Verify document content is unchanged + expect(editor.document.getText()).toBe("const x = 1") + }) + + it("should provide access to document line information", () => { + const editor = MockTextEditor.create(`function test() {\n ␣return true\n}`) + + const line = editor.document.lineAt(editor.selection.active.line) + expect(line.text).toBe(" return true") + expect(line.lineNumber).toBe(1) + }) +}) diff --git a/src/services/autocomplete/__tests__/MockTextEditor.ts b/src/services/autocomplete/__tests__/MockTextEditor.ts new file mode 100644 index 0000000000..a550c1f4f5 --- /dev/null +++ b/src/services/autocomplete/__tests__/MockTextEditor.ts @@ -0,0 +1,51 @@ +import * as vscode from "vscode" +import { MockTextDocument } from "./MockTextDocument" + +/** + * Special character used to mark cursor position in test documents. + * Using "␣" (U+2423, OPEN BOX) as it's visually distinct and unlikely to be in normal code. + */ +export const CURSOR_MARKER = "␣" + +/** + * MockTextEditor encapsulates both a TextDocument and cursor position + * for simpler testing of editor-related functionality + */ +export class MockTextEditor { + public readonly document: vscode.TextDocument + public selection: vscode.Selection + + /** + * Creates a new MockTextEditor + * @param content Text content with optional cursor marker (CURSOR_MARKER) + * If no cursor marker is provided, cursor defaults to position (0,0) + */ + constructor(content: string) { + const cursorOffset = content.indexOf(CURSOR_MARKER) + + if (cursorOffset === -1) { + // No cursor marker found - default to position (0,0) + this.document = new MockTextDocument(content) as unknown as vscode.TextDocument + const defaultPosition = new vscode.Position(0, 0) + this.selection = new vscode.Selection(defaultPosition, defaultPosition) + } else { + // Cursor marker found - remove it and calculate position + const cleanContent = + content.substring(0, cursorOffset) + content.substring(cursorOffset + CURSOR_MARKER.length) + this.document = new MockTextDocument(cleanContent) as unknown as vscode.TextDocument + + // Calculate line and character for cursor position + const beforeCursor = content.substring(0, cursorOffset) + const lines = beforeCursor.split("\n") + const line = lines.length - 1 + const character = lines[line].length + + const cursorPosition = new vscode.Position(line, character) + this.selection = new vscode.Selection(cursorPosition, cursorPosition) + } + } + + static create(content: string): MockTextEditor { + return new MockTextEditor(content) + } +} diff --git a/src/services/ghost/GhostModel.ts b/src/services/ghost/GhostModel.ts index b1642449f7..d17788965b 100644 --- a/src/services/ghost/GhostModel.ts +++ b/src/services/ghost/GhostModel.ts @@ -4,7 +4,7 @@ import { t } from "../../i18n" export class GhostModel { private apiHandler: ApiHandler | null = null - private modelName: string = "google/gemini-2.5-flash-preview-05-20" + private modelName: string = "google/gemini-2.5-flash" constructor() { const kilocodeToken = ContextProxy.instance.getProviderSettings().kilocodeToken diff --git a/src/services/ghost/GhostProvider.ts b/src/services/ghost/GhostProvider.ts index 957fb2c33f..11bffb6d30 100644 --- a/src/services/ghost/GhostProvider.ts +++ b/src/services/ghost/GhostProvider.ts @@ -223,6 +223,7 @@ export class GhostProvider { private async updateGlobalContext() { const hasSuggestions = this.suggestions.hasSuggestions() + console.log("hasSuggestions", hasSuggestions) await vscode.commands.executeCommand("setContext", "kilocode.ghost.hasSuggestions", hasSuggestions) } @@ -231,10 +232,7 @@ export class GhostProvider { } public async cancelSuggestions() { - if (!this.hasPendingSuggestions()) { - return - } - if (this.workspaceEdit.isLocked()) { + if (!this.hasPendingSuggestions() || this.workspaceEdit.isLocked()) { return } this.decorations.clearAll() @@ -244,10 +242,7 @@ export class GhostProvider { } public async applySelectedSuggestions() { - if (!this.hasPendingSuggestions()) { - return - } - if (this.workspaceEdit.isLocked()) { + if (!this.hasPendingSuggestions() || this.workspaceEdit.isLocked()) { return } const editor = vscode.window.activeTextEditor @@ -265,21 +260,18 @@ export class GhostProvider { return } this.decorations.clearAll() - await this.workspaceEdit.revertSelectedSuggestionsPlaceholder(this.suggestions) + await this.workspaceEdit.revertSuggestionsPlaceholder(this.suggestions) await this.workspaceEdit.applySelectedSuggestions(this.suggestions) suggestionsFile.deleteSelectedGroup() this.suggestions.validateFiles() + await this.workspaceEdit.applySuggestionsPlaceholders(this.suggestions) await this.render() } public async applyAllSuggestions() { - if (!this.hasPendingSuggestions()) { + if (!this.hasPendingSuggestions() || this.workspaceEdit.isLocked()) { return } - if (this.workspaceEdit.isLocked()) { - return - } - this.decorations.clearAll() await this.workspaceEdit.revertSuggestionsPlaceholder(this.suggestions) await this.workspaceEdit.applySuggestions(this.suggestions) diff --git a/src/services/ghost/GhostStrategy.ts b/src/services/ghost/GhostStrategy.ts index 0c36621d6e..066076c73d 100644 --- a/src/services/ghost/GhostStrategy.ts +++ b/src/services/ghost/GhostStrategy.ts @@ -140,7 +140,7 @@ ${sections.filter(Boolean).join("\n\n")} const documentContent = document.getText() const newContent = applyPatch(documentContent, diff, { - fuzzFactor: 0.75, // Adjust fuzz factor as needed + fuzzFactor: 0.2, }) console.log("New content after applying patch:", newContent) @@ -176,7 +176,9 @@ ${sections.filter(Boolean).join("\n\n")} continue } - const fileUri = vscode.Uri.parse(filePath) + const fileUri = filePath.startsWith("file://") + ? vscode.Uri.parse(filePath) + : vscode.Uri.parse(`file://${filePath}`) const suggestionFile = suggestions.addFile(fileUri) diff --git a/src/services/ghost/GhostSuggestions.ts b/src/services/ghost/GhostSuggestions.ts index 0324bf2d33..0946bc6d1d 100644 --- a/src/services/ghost/GhostSuggestions.ts +++ b/src/services/ghost/GhostSuggestions.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode" -import { GhostSuggestionEditOperation } from "./types" +import { GhostSuggestionEditOperation, GhostSuggestionEditOperationsOffset } from "./types" class GhostSuggestionFile { public fileUri: vscode.Uri @@ -32,6 +32,14 @@ class GhostSuggestionFile { return this.selectedGroup } + public getSelectedGroupPreviousOperations(): GhostSuggestionEditOperation[] { + if (this.selectedGroup === null || this.selectedGroup <= 0) { + return [] + } + const previousGroups = this.groups.slice(0, this.selectedGroup) + return previousGroups.flat() + } + public getSelectedGroupOperations(): GhostSuggestionEditOperation[] { if (this.selectedGroup === null || this.selectedGroup >= this.groups.length) { return [] @@ -39,14 +47,9 @@ class GhostSuggestionFile { return this.groups[this.selectedGroup] } - public getPlaceholderOffsetSelectedGroupOperations() { - const selectedGroup = this.getSelectedGroup() - if (selectedGroup === null) { - return { added: 0, removed: 0 } - } - const previousGroups = this.groups.slice(0, selectedGroup) - const operations = previousGroups.flat() - return operations.reduce( + public getPlaceholderOffsetSelectedGroupOperations(): GhostSuggestionEditOperationsOffset { + const operations = this.getSelectedGroupPreviousOperations() + const { added, removed } = operations.reduce( (acc, op) => { if (op.type === "+") { return { added: acc.added + 1, removed: acc.removed } @@ -57,6 +60,7 @@ class GhostSuggestionFile { }, { added: 0, removed: 0 }, ) + return { added, removed, offset: added - removed } } public getGroupsOperations(): GhostSuggestionEditOperation[][] { @@ -68,15 +72,19 @@ class GhostSuggestionFile { } public sortGroups() { - this.groups.sort((a, b) => { - const aLine = a[0].line - const bLine = b[0].line - return aLine - bLine - }) + this.groups + .sort((a, b) => { + const aLine = a[0].line + const bLine = b[0].line + return aLine - bLine + }) + .forEach((group) => { + group.sort((a, b) => a.line - b.line) + }) this.selectedGroup = this.groups.length > 0 ? 0 : null } - private computeOperationsOffset(group: GhostSuggestionEditOperation[]) { + private computeOperationsOffset(group: GhostSuggestionEditOperation[]): GhostSuggestionEditOperationsOffset { const { added, removed } = group.reduce( (acc, op) => { if (op.type === "+") { diff --git a/src/services/ghost/GhostWorkspaceEdit.ts b/src/services/ghost/GhostWorkspaceEdit.ts index 87a1a4b1d8..48e68451a3 100644 --- a/src/services/ghost/GhostWorkspaceEdit.ts +++ b/src/services/ghost/GhostWorkspaceEdit.ts @@ -1,11 +1,35 @@ import * as vscode from "vscode" -import { GhostSuggestionEditOperation } from "./types" +import { GhostSuggestionEditOperation, GhostSuggestionEditOperationsOffset } from "./types" import { GhostSuggestionsState } from "./GhostSuggestions" export class GhostWorkspaceEdit { private locked: boolean = false - private async applyOperations(documentUri: vscode.Uri, operations: GhostSuggestionEditOperation[]) { + private groupOperationsIntoBlocks = (ops: T[], lineKey: keyof T): T[][] => { + if (ops.length === 0) { + return [] + } + const blocks: T[][] = [[ops[0]]] + for (let i = 1; i < ops.length; i++) { + const op = ops[i] + const lastBlock = blocks[blocks.length - 1] + const lastOp = lastBlock[lastBlock.length - 1] + if (Number(op[lineKey]) === Number(lastOp[lineKey]) + 1) { + lastBlock.push(op) + } else if (Number(op[lineKey]) === Number(lastOp[lineKey])) { + lastBlock.push(op) + } else { + blocks.push([op]) + } + } + return blocks + } + + private async applyOperations( + documentUri: vscode.Uri, + operations: GhostSuggestionEditOperation[], + previousOperations: GhostSuggestionEditOperation[], + ) { const workspaceEdit = new vscode.WorkspaceEdit() if (operations.length === 0) { return // No operations to apply @@ -16,35 +40,88 @@ export class GhostWorkspaceEdit { console.log(`Could not open document: ${documentUri.toString()}`) return } - const deleteOps = operations.filter((op) => op.type === "-") - const insertOps = operations.filter((op) => op.type === "+") + // --- 1. Calculate Initial State from Previous Operations --- + let originalLineCursor = 0 + let finalLineCursor = 0 - let delPtr = 0 - let insPtr = 0 - let lineOffset = 0 + if (previousOperations.length > 0) { + const prevDeletes = previousOperations.filter((op) => op.type === "-").sort((a, b) => a.line - b.line) + const prevInserts = previousOperations.filter((op) => op.type === "+").sort((a, b) => a.line - b.line) + let prevDelPtr = 0 + let prevInsPtr = 0 - while (delPtr < deleteOps.length || insPtr < insertOps.length) { - const nextDeleteOriginalLine = deleteOps[delPtr]?.line ?? Infinity - const nextInsertOriginalLine = (insertOps[insPtr]?.line ?? Infinity) - lineOffset + // "Dry run" the simulation on previous operations to set the cursors accurately. + while (prevDelPtr < prevDeletes.length || prevInsPtr < prevInserts.length) { + const nextDelLine = prevDeletes[prevDelPtr]?.line ?? Infinity + const nextInsLine = prevInserts[prevInsPtr]?.line ?? Infinity - if (nextDeleteOriginalLine <= nextInsertOriginalLine) { - // Process the deletion next - const op = deleteOps[delPtr] - const range = document.lineAt(op.line).rangeIncludingLineBreak - workspaceEdit.delete(documentUri, range) + if (nextDelLine <= originalLineCursor && nextDelLine !== Infinity) { + originalLineCursor++ + prevDelPtr++ + } else if (nextInsLine <= finalLineCursor && nextInsLine !== Infinity) { + finalLineCursor++ + prevInsPtr++ + } else if (nextDelLine === Infinity && nextInsLine === Infinity) { + break + } else { + originalLineCursor++ + finalLineCursor++ + } + } + } - lineOffset-- - delPtr++ + // --- 2. Translate and Prepare Current Operations --- + const currentDeletes = operations.filter((op) => op.type === "-").sort((a, b) => a.line - b.line) + const currentInserts = operations.filter((op) => op.type === "+").sort((a, b) => a.line - b.line) + const translatedInsertOps: { originalLine: number; content: string }[] = [] + let currDelPtr = 0 + let currInsPtr = 0 + + // Run the simulation for the new operations, starting from the state calculated above. + while (currDelPtr < currentDeletes.length || currInsPtr < currentInserts.length) { + const nextDelLine = currentDeletes[currDelPtr]?.line ?? Infinity + const nextInsLine = currentInserts[currInsPtr]?.line ?? Infinity + + if (nextDelLine <= originalLineCursor && nextDelLine !== Infinity) { + originalLineCursor++ + currDelPtr++ + } else if (nextInsLine <= finalLineCursor && nextInsLine !== Infinity) { + translatedInsertOps.push({ + originalLine: originalLineCursor, + content: currentInserts[currInsPtr].content || "", + }) + finalLineCursor++ + currInsPtr++ + } else if (nextDelLine === Infinity && nextInsLine === Infinity) { + break } else { - // Process the insertion next - const op = insertOps[insPtr] - const position = new vscode.Position(nextInsertOriginalLine, 0) - const textToInsert = (op.content || "") + "\n" - workspaceEdit.insert(documentUri, position, textToInsert) + originalLineCursor++ + finalLineCursor++ + } + } - lineOffset++ - insPtr++ + // --- 3. Group and Apply Deletions --- + const deleteBlocks = this.groupOperationsIntoBlocks(currentDeletes, "line") + for (const block of deleteBlocks) { + const firstDeleteLine = block[0].line + const lastDeleteLine = block[block.length - 1].line + const startPosition = new vscode.Position(firstDeleteLine, 0) + let endPosition + + if (lastDeleteLine >= document.lineCount - 1) { + endPosition = document.lineAt(lastDeleteLine).rangeIncludingLineBreak.end + } else { + endPosition = new vscode.Position(lastDeleteLine + 1, 0) } + workspaceEdit.delete(documentUri, new vscode.Range(startPosition, endPosition)) + } + + // --- 4. Group and Apply Translated Insertions --- + const insertionBlocks = this.groupOperationsIntoBlocks(translatedInsertOps, "originalLine") + for (const block of insertionBlocks) { + const anchorLine = block[0].originalLine + const textToInsert = block.map((op) => op.content).join("\n") + "\n" + workspaceEdit.insert(documentUri, new vscode.Position(anchorLine, 0), textToInsert) } await vscode.workspace.applyEdit(workspaceEdit) @@ -121,25 +198,26 @@ export class GhostWorkspaceEdit { } private async getActiveFileSelectedOperations(suggestions: GhostSuggestionsState) { + const empty = { + documentUri: null, + operations: [], + previousOperations: [], + } const editor = vscode.window.activeTextEditor if (!editor) { - return { - documentUri: null, - operations: [], - } + return empty } const documentUri = editor.document.uri const suggestionsFile = suggestions.getFile(documentUri) if (!suggestionsFile) { - return { - documentUri: null, - operations: [], - } + return empty } const operations = suggestionsFile.getSelectedGroupOperations() + const previousOperations = suggestionsFile.getSelectedGroupPreviousOperations() return { documentUri, operations, + previousOperations, } } @@ -154,10 +232,10 @@ export class GhostWorkspaceEdit { this.locked = true const { documentUri, operations } = this.getActiveFileOperations(suggestions) if (!documentUri || operations.length === 0) { - console.log("No active document or no operations to apply.") + this.locked = false return } - await this.applyOperations(documentUri, operations) + await this.applyOperations(documentUri, operations, []) this.locked = false } @@ -166,12 +244,12 @@ export class GhostWorkspaceEdit { return } this.locked = true - const { documentUri, operations } = await this.getActiveFileSelectedOperations(suggestions) + const { documentUri, operations, previousOperations } = await this.getActiveFileSelectedOperations(suggestions) if (!documentUri || operations.length === 0) { - console.log("No active document or no selected operations to apply.") + this.locked = false return } - await this.applyOperations(documentUri, operations) + await this.applyOperations(documentUri, operations, previousOperations) this.locked = false } @@ -182,21 +260,7 @@ export class GhostWorkspaceEdit { this.locked = true const { documentUri, operations } = this.getActiveFileOperations(suggestions) if (!documentUri || operations.length === 0) { - console.log("No active document or no operations to apply.") - return - } - await this.revertOperationsPlaceholder(documentUri, operations) - this.locked = false - } - - public async revertSelectedSuggestionsPlaceholder(suggestions: GhostSuggestionsState): Promise { - if (this.locked) { - return - } - this.locked = true - const { documentUri, operations } = await this.getActiveFileSelectedOperations(suggestions) - if (!documentUri || operations.length === 0) { - console.log("No active document or no selected operations to apply.") + this.locked = false return } await this.revertOperationsPlaceholder(documentUri, operations) @@ -210,7 +274,7 @@ export class GhostWorkspaceEdit { this.locked = true const { documentUri, operations } = await this.getActiveFileOperations(suggestions) if (!documentUri || operations.length === 0) { - console.log("No active document or no operations to apply.") + this.locked = false return } await this.applyOperationsPlaceholders(documentUri, operations) diff --git a/src/services/ghost/__tests__/GhostProvider.spec.ts b/src/services/ghost/__tests__/GhostProvider.spec.ts new file mode 100644 index 0000000000..ef1be6f59c --- /dev/null +++ b/src/services/ghost/__tests__/GhostProvider.spec.ts @@ -0,0 +1,453 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import * as fs from "node:fs" +import * as path from "node:path" +import { MockWorkspace } from "./MockWorkspace" +import * as vscode from "vscode" +import { GhostStrategy } from "../GhostStrategy" +import { GhostWorkspaceEdit } from "../GhostWorkspaceEdit" +import { GhostSuggestionContext } from "../types" + +vi.mock("vscode", () => ({ + Uri: { + parse: (uriString: string) => ({ + toString: () => uriString, + fsPath: uriString.replace("file://", ""), + scheme: "file", + path: uriString.replace("file://", ""), + }), + }, + Position: class { + constructor( + public line: number, + public character: number, + ) {} + }, + Range: class { + constructor( + public start: any, + public end: any, + ) {} + }, + WorkspaceEdit: class { + private _edits = new Map() + + insert(uri: any, position: any, newText: string) { + const key = uri.toString() + if (!this._edits.has(key)) { + this._edits.set(key, []) + } + this._edits.get(key).push({ range: { start: position, end: position }, newText }) + } + + delete(uri: any, range: any) { + const key = uri.toString() + if (!this._edits.has(key)) { + this._edits.set(key, []) + } + this._edits.get(key).push({ range, newText: "" }) + } + + entries() { + return Array.from(this._edits.entries()).map(([uriString, edits]) => [{ toString: () => uriString }, edits]) + } + }, + workspace: { + openTextDocument: vi.fn(), + applyEdit: vi.fn(), + asRelativePath: vi.fn().mockImplementation((uri) => { + if (typeof uri === "string") { + return uri.replace("file:///", "") + } + return uri.toString().replace("file:///", "") + }), + }, + window: { + activeTextEditor: null, + }, +})) + +describe("GhostProvider", () => { + let mockWorkspace: MockWorkspace + let strategy: GhostStrategy + let workspaceEdit: GhostWorkspaceEdit + + beforeEach(() => { + vi.clearAllMocks() + strategy = new GhostStrategy() + mockWorkspace = new MockWorkspace() + workspaceEdit = new GhostWorkspaceEdit() + + vi.mocked(vscode.workspace.openTextDocument).mockImplementation(async (uri: any) => { + const uriObj = typeof uri === "string" ? vscode.Uri.parse(uri) : uri + return await mockWorkspace.openTextDocument(uriObj) + }) + vi.mocked(vscode.workspace.applyEdit).mockImplementation(async (edit) => { + await mockWorkspace.applyEdit(edit) + return true + }) + }) + + // Helper function to normalize whitespace for consistent testing + function normalizeWhitespace(content: string): string { + return content.replace(/\t/g, " ") // Convert tabs to 2 spaces + } + + // Helper function to set up test document and context + async function setupTestDocument(filename: string, content: string) { + const testUri = vscode.Uri.parse(`file:///${filename}`) + mockWorkspace.addDocument(testUri, content) + ;(vscode.window as any).activeTextEditor = { + document: { uri: testUri }, + } + + const mockDocument = await mockWorkspace.openTextDocument(testUri) + ;(mockDocument as any).uri = testUri + + const context: GhostSuggestionContext = { + document: mockDocument, + openFiles: [mockDocument], + } + + return { testUri, context, mockDocument } + } + + async function parseAndApplySuggestions(diffResponse: string, context: GhostSuggestionContext) { + const suggestions = await strategy.parseResponse(diffResponse, context) + await workspaceEdit.applySuggestions(suggestions) + } + + // Test cases directory for file-based tests + const TEST_CASES_DIR = path.join(__dirname, "__test_cases__") + + // Helper function to run file-based tests + async function runFileBasedTest(testCaseName: string) { + const testCasePath = path.join(TEST_CASES_DIR, testCaseName) + const inputFilePath = path.join(testCasePath, "input.js") + const diffFilePath = path.join(testCasePath, "diff.patch") + const expectedFilePath = path.join(testCasePath, "expected.js") + + const initialContent = fs.readFileSync(inputFilePath, "utf8") + const diffResponse = fs.readFileSync(diffFilePath, "utf8") + const expectedContent = fs.readFileSync(expectedFilePath, "utf8") + + const { testUri, context } = await setupTestDocument(`${testCaseName}/input.js`, initialContent) + await parseAndApplySuggestions(diffResponse, context) + + const finalContent = mockWorkspace.getDocumentContent(testUri) + expect(finalContent).toBe(expectedContent) + } + + async function runFileBasedTestSequential(testCaseName: string) { + const testCasePath = path.join(TEST_CASES_DIR, testCaseName) + const inputFilePath = path.join(testCasePath, "input.js") + const diffFilePath = path.join(testCasePath, "diff.patch") + const expectedFilePath = path.join(testCasePath, "expected.js") + + const initialContent = fs.readFileSync(inputFilePath, "utf8") + const diffResponse = fs.readFileSync(diffFilePath, "utf8") + const expectedContent = fs.readFileSync(expectedFilePath, "utf8") + + const { testUri, context } = await setupTestDocument(`${testCaseName}/input.js`, initialContent) + await parseAndApplySuggestions(diffResponse, context) + + const finalContent = mockWorkspace.getDocumentContent(testUri) + expect(finalContent).toBe(expectedContent) + } + + describe("File-based Suggestions", () => { + it("should apply a simple addition from files", async () => { + await runFileBasedTest("simple-addition") + }) + + it("should apply multiple line additions from files", async () => { + await runFileBasedTest("multiple-line-additions") + }) + + it("should apply line deletions from files", async () => { + await runFileBasedTest("line-deletions") + }) + + it("should apply mixed addition and deletion from files", async () => { + await runFileBasedTest("mixed-addition-deletion") + }) + + it("should handle empty diff response from files", async () => { + await runFileBasedTest("empty-diff-response") + }) + + it("should apply function rename and var to const changes from files", async () => { + await runFileBasedTest("function-rename-var-to-const") + }) + }) + + describe("Sequential application", () => { + it("should handle an inverse individual application of mixed operations", async () => { + const initialContent = normalizeWhitespace(`\ +// Header +// This function adds two numbers. +function add(a, b) { + return a + b; +} + +// This function divides two numbers. +// It throws an error if the divisor is zero. +function divide(a, b) { + if (b === 0) throw new Error("Cannot divide by zero"); + return a / b; +}`) + const diffResponse = `\ +--- a/sequential.js ++++ b/sequential.js +@@ -1,12 +1,16 @@ +-// Header +-// This function adds two numbers. + function add(a, b) { + return a + b; + } + +-// This function divides two numbers. +-// It throws an error if the divisor is zero. + function divide(a, b) { + if (b === 0) throw new Error("Cannot divide by zero"); + return a / b; + } ++ ++function multiply(a, b) { ++ return a * b; ++} ++ ++function subtract(a, b) { ++ return a - b; ++}` + + const expected = `\ +function add(a, b) { + return a + b; +} + +function divide(a, b) { + if (b === 0) throw new Error("Cannot divide by zero"); + return a / b; +} + +function multiply(a, b) { + return a * b; +} + +function subtract(a, b) { + return a - b; +}` + const { testUri, context } = await setupTestDocument("sequential.js", initialContent) + const normalizedDiffResponse = normalizeWhitespace(diffResponse) + const suggestions = await strategy.parseResponse(normalizedDiffResponse, context) + + const suggestionsFile = suggestions.getFile(testUri) + suggestionsFile!.sortGroups() + + // Loop through each suggestion group and apply them one by one + const groups = suggestionsFile!.getGroupsOperations() + const groupsLength = groups.length + suggestionsFile!.selectNextGroup() + for (let i = 0; i < groupsLength; i++) { + // Apply the currently selected suggestion group + await workspaceEdit.applySelectedSuggestions(suggestions) + suggestionsFile!.deleteSelectedGroup() + } + + // Verify the final document content is correct + const finalContent = mockWorkspace.getDocumentContent(testUri) + const expectedContent = normalizeWhitespace(expected) + expect(finalContent).toBe(expectedContent) + }) + it("should handle sequential partial application of mixed operations", async () => { + const initialContent = normalizeWhitespace(`\ +function calculate() { + let a = 1 + let b = 2 + + let sum = a + b + let product = a * b + + console.log(sum) + console.log(product) + + return sum +}`) + const diffResponse = `\ +--- a/sequential.js ++++ b/sequential.js +@@ -1,12 +1,15 @@ + function calculate() { + let a = 1 + let b = 2 ++ let c = 3; // kilocode_change start: Add a new variable + + let sum = a + b + let product = a * b ++ let difference = a - b; // kilocode_change end: Add a new variable + + console.log(sum) + console.log(product) ++ console.log(difference); // kilocode_change start: Log the new variable + +- return sum ++ return sum + difference; // kilocode_change end: Return sum and difference + }` + + const expected = `\ +function calculate() { + let a = 1 + let b = 2 + + let sum = a + b + let product = a * b + let difference = a - b; // kilocode_change end: Add a new variable + + console.log(sum) + console.log(product) + console.log(difference); // kilocode_change start: Log the new variable + + return sum + difference; // kilocode_change end: Return sum and difference +}` + const { testUri, context } = await setupTestDocument("sequential.js", initialContent) + const normalizedDiffResponse = normalizeWhitespace(diffResponse) + const suggestions = await strategy.parseResponse(normalizedDiffResponse, context) + + const suggestionsFile = suggestions.getFile(testUri) + suggestionsFile!.sortGroups() + + // Loop through each suggestion group and apply them one by one + const groups = suggestionsFile!.getGroupsOperations() + const groupsLength = groups.length + for (let i = 0; i < groupsLength; i++) { + if (i === 0) { + // Skip the first operation + suggestionsFile!.selectNextGroup() + } else { + // Apply the currently selected suggestion group + await workspaceEdit.applySelectedSuggestions(suggestions) + suggestionsFile!.deleteSelectedGroup() + } + } + + // Verify the final document content is correct + const finalContent = mockWorkspace.getDocumentContent(testUri) + const expectedContent = normalizeWhitespace(expected) + expect(finalContent).toBe(expectedContent) + }) + it("should handle random individual application of mixed operations", async () => { + const initialContent = normalizeWhitespace(`\ +function calculate() { + let a = 1 + let b = 2 + + let sum = a + b + let product = a * b + + console.log(sum) + console.log(product) + + return sum +}`) + const diffResponse = `\ +--- a/sequential.js ++++ b/sequential.js +@@ -1,12 +1,15 @@ + function calculate() { + let a = 1 + let b = 2 ++ let c = 3; // kilocode_change start: Add a new variable + + let sum = a + b + let product = a * b ++ let difference = a - b; // kilocode_change end: Add a new variable + + console.log(sum) + console.log(product) ++ console.log(difference); // kilocode_change start: Log the new variable + +- return sum ++ return sum + difference; // kilocode_change end: Return sum and difference + }` + + const expected = `\ +function calculate() { + let a = 1 + let b = 2 + let c = 3; // kilocode_change start: Add a new variable + + let sum = a + b + let product = a * b + let difference = a - b; // kilocode_change end: Add a new variable + + console.log(sum) + console.log(product) + console.log(difference); // kilocode_change start: Log the new variable + + return sum + difference; // kilocode_change end: Return sum and difference +}` + const { testUri, context } = await setupTestDocument("sequential.js", initialContent) + const normalizedDiffResponse = normalizeWhitespace(diffResponse) + const suggestions = await strategy.parseResponse(normalizedDiffResponse, context) + + const suggestionsFile = suggestions.getFile(testUri) + suggestionsFile!.sortGroups() + + // Loop through each suggestion group and apply them one by one + const groups = suggestionsFile!.getGroupsOperations() + const groupsLength = groups.length + for (let i = 0; i < groupsLength; i++) { + const random = Math.floor(Math.random() * 4) + 1 + for (let j = 0; j < random; j++) { + suggestionsFile!.selectNextGroup() + } + // Apply the currently selected suggestion group + await workspaceEdit.applySelectedSuggestions(suggestions) + suggestionsFile!.deleteSelectedGroup() + } + + // Verify the final document content is correct + const finalContent = mockWorkspace.getDocumentContent(testUri) + const expectedContent = normalizeWhitespace(expected) + expect(finalContent).toBe(expectedContent) + }) + }) + + describe("Error Handling", () => { + it("should handle empty diff responses", async () => { + const initialContent = `console.log('test');` + const { context } = await setupTestDocument("empty.js", initialContent) + + // Test empty response + const suggestions = await strategy.parseResponse("", context) + expect(suggestions.hasSuggestions()).toBe(false) + }) + + it("should handle invalid diff format", async () => { + const initialContent = `console.log('test');` + const { context } = await setupTestDocument("invalid.js", initialContent) + + // Test invalid diff format + const invalidDiff = "This is not a valid diff format" + const suggestions = await strategy.parseResponse(invalidDiff, context) + expect(suggestions.hasSuggestions()).toBe(false) + }) + + it("should handle file not found in context", async () => { + const initialContent = `console.log('test');` + await setupTestDocument("missing.js", initialContent) + + // Create context without the file in openFiles + const context: GhostSuggestionContext = { + openFiles: [], // Empty - file not in context + } + + const diffResponse = + "--- a/missing.js\n+++ b/missing.js\n@@ -1,1 +1,2 @@\n+// Added comment\n console.log('test');" + + const suggestions = await strategy.parseResponse(diffResponse, context) + // Should still work even if file not in openFiles - it can still parse the diff + expect(suggestions.hasSuggestions()).toBe(true) + }) + }) +}) diff --git a/src/services/ghost/__tests__/MockWorkspace.spec.ts b/src/services/ghost/__tests__/MockWorkspace.spec.ts new file mode 100644 index 0000000000..fe1a746435 --- /dev/null +++ b/src/services/ghost/__tests__/MockWorkspace.spec.ts @@ -0,0 +1,228 @@ +import { describe, it, expect, beforeEach } from "vitest" +import * as vscode from "vscode" +import { MockWorkspace } from "./MockWorkspace" +import { createMockWorkspaceEdit } from "./MockWorkspaceEdit" + +// Mock VS Code API objects for test environment +export const mockVscode = { + Uri: { + parse: (uriString: string) => + ({ + toString: () => uriString, + fsPath: uriString.replace("file://", ""), + scheme: "file", + path: uriString.replace("file://", ""), + }) as vscode.Uri, + }, + Position: class { + constructor( + public line: number, + public character: number, + ) {} + } as any, + Range: class { + constructor( + public start: vscode.Position, + public end: vscode.Position, + ) {} + } as any, +} + +describe("MockWorkspace", () => { + let mockWorkspace: MockWorkspace + + beforeEach(() => { + mockWorkspace = new MockWorkspace() + }) + + describe("document management", () => { + it("should add and retrieve documents", () => { + const uri = mockVscode.Uri.parse("file:///test.ts") + const content = "console.log('Hello, World!')" + + const document = mockWorkspace.addDocument(uri, content) + + expect(document.getText()).toBe(content) + expect(mockWorkspace.getDocumentContent(uri)).toBe(content) + }) + + it("should return empty string for non-existent document", () => { + const uri = mockVscode.Uri.parse("file:///nonexistent.ts") + + expect(mockWorkspace.getDocumentContent(uri)).toBe("") + }) + + it("should throw error when opening non-existent document", async () => { + const uri = mockVscode.Uri.parse("file:///nonexistent.ts") + + await expect(mockWorkspace.openTextDocument(uri)).rejects.toThrow( + "Document not found: file:///nonexistent.ts", + ) + }) + + it("should open existing document", async () => { + const uri = mockVscode.Uri.parse("file:///test.ts") + const content = "const x = 1" + + mockWorkspace.addDocument(uri, content) + const document = await mockWorkspace.openTextDocument(uri) + + expect(document.getText()).toBe(content) + }) + }) + + describe("workspace edit application", () => { + it("should apply insert operations", async () => { + const uri = mockVscode.Uri.parse("file:///test.ts") + const originalContent = "Hello World" + mockWorkspace.addDocument(uri, originalContent) + + const edit = createMockWorkspaceEdit() + const position = new mockVscode.Position(0, 5) + edit.insert(uri, position as vscode.Position, " Beautiful") + + const success = await mockWorkspace.applyEdit(edit) + + expect(success).toBe(true) + expect(mockWorkspace.getDocumentContent(uri)).toBe("Hello Beautiful World") + }) + + it("should apply delete operations", async () => { + const uri = mockVscode.Uri.parse("file:///test.ts") + const originalContent = "console.log('Hello')" + mockWorkspace.addDocument(uri, originalContent) + + const edit = createMockWorkspaceEdit() + const range = new mockVscode.Range(new mockVscode.Position(0, 12), new mockVscode.Position(0, 19)) + edit.delete(uri, range as vscode.Range) + + const success = await mockWorkspace.applyEdit(edit) + + expect(success).toBe(true) + expect(mockWorkspace.getDocumentContent(uri)).toBe("console.log()") + }) + + it("should apply replace operations", async () => { + const uri = mockVscode.Uri.parse("file:///test.ts") + const originalContent = "console.log('Hello, World!')" + mockWorkspace.addDocument(uri, originalContent) + + const edit = createMockWorkspaceEdit() + const range = new mockVscode.Range(new mockVscode.Position(0, 12), new mockVscode.Position(0, 27)) + edit.replace(uri, range as vscode.Range, "'Goodbye'") + + const success = await mockWorkspace.applyEdit(edit) + + expect(success).toBe(true) + expect(mockWorkspace.getDocumentContent(uri)).toBe("console.log('Goodbye')") + }) + + it("should handle multi-line edits", async () => { + const uri = mockVscode.Uri.parse("file:///test.ts") + const originalContent = "line1\nline2\nline3" + mockWorkspace.addDocument(uri, originalContent) + + const edit = createMockWorkspaceEdit() + const range = new mockVscode.Range(new mockVscode.Position(1, 0), new mockVscode.Position(1, 5)) + edit.replace(uri, range as vscode.Range, "modified_line2") + + const success = await mockWorkspace.applyEdit(edit) + + expect(success).toBe(true) + expect(mockWorkspace.getDocumentContent(uri)).toBe("line1\nmodified_line2\nline3") + }) + + it("should handle multiple edits in correct order", async () => { + const uri = mockVscode.Uri.parse("file:///test.ts") + const originalContent = "ABCDEF" + mockWorkspace.addDocument(uri, originalContent) + + const edit = createMockWorkspaceEdit() + // Insert at position 2 (after 'AB') + edit.insert(uri, new mockVscode.Position(0, 2) as vscode.Position, "X") + // Insert at position 3 (after 'ABC' in original) + edit.insert(uri, new mockVscode.Position(0, 3) as vscode.Position, "Y") + + const success = await mockWorkspace.applyEdit(edit) + + expect(success).toBe(true) + // The actual result shows that edits are applied in reverse order by character position + // Position 3 edit (Y) is applied first: AB + Y + CDEF = ABYCDEF + // Position 2 edit (X) is applied second: AB + X + YCDEF = ABXYCDEF + expect(mockWorkspace.getDocumentContent(uri)).toBe("ABXCYDEF") + }) + + it("should return false for edits on non-existent documents", async () => { + const uri = mockVscode.Uri.parse("file:///nonexistent.ts") + const edit = createMockWorkspaceEdit() + edit.insert(uri, new mockVscode.Position(0, 0) as vscode.Position, "test") + + const success = await mockWorkspace.applyEdit(edit) + + expect(success).toBe(false) + }) + }) + + describe("edit tracking", () => { + it("should track applied edits", async () => { + const uri = mockVscode.Uri.parse("file:///test.ts") + mockWorkspace.addDocument(uri, "original") + + const firstEdit = createMockWorkspaceEdit() + firstEdit.insert(uri, new mockVscode.Position(0, 0) as vscode.Position, "prefix ") + + const secondEdit = createMockWorkspaceEdit() + secondEdit.insert(uri, new mockVscode.Position(0, 8) as vscode.Position, " suffix") + + await mockWorkspace.applyEdit(firstEdit) + await mockWorkspace.applyEdit(secondEdit) + + const appliedEdits = mockWorkspace.getAppliedEdits() + expect(appliedEdits).toHaveLength(2) + }) + + it("should clear workspace state", async () => { + const uri = mockVscode.Uri.parse("file:///test.ts") + mockWorkspace.addDocument(uri, "test") + + const edit = createMockWorkspaceEdit() + edit.insert(uri, new mockVscode.Position(0, 0) as vscode.Position, "prefix ") + + await mockWorkspace.applyEdit(edit) + expect(mockWorkspace.getAppliedEdits()).toHaveLength(1) + + mockWorkspace.clear() + expect(mockWorkspace.getAppliedEdits()).toHaveLength(0) + }) + }) + + describe("edge cases", () => { + it("should handle empty document edits", async () => { + const uri = mockVscode.Uri.parse("file:///empty.ts") + mockWorkspace.addDocument(uri, "") + + const edit = createMockWorkspaceEdit() + edit.insert(uri, new mockVscode.Position(0, 0) as vscode.Position, "first line") + + const success = await mockWorkspace.applyEdit(edit) + + expect(success).toBe(true) + expect(mockWorkspace.getDocumentContent(uri)).toBe("first line") + }) + + it("should handle edits at document boundaries", async () => { + const uri = mockVscode.Uri.parse("file:///test.ts") + const originalContent = "test" + mockWorkspace.addDocument(uri, originalContent) + + const edit = createMockWorkspaceEdit() + // Insert at the very end + edit.insert(uri, new mockVscode.Position(0, 4) as vscode.Position, " end") + + const success = await mockWorkspace.applyEdit(edit) + + expect(success).toBe(true) + expect(mockWorkspace.getDocumentContent(uri)).toBe("test end") + }) + }) +}) diff --git a/src/services/ghost/__tests__/MockWorkspace.ts b/src/services/ghost/__tests__/MockWorkspace.ts new file mode 100644 index 0000000000..1daf5d475e --- /dev/null +++ b/src/services/ghost/__tests__/MockWorkspace.ts @@ -0,0 +1,99 @@ +import * as vscode from "vscode" +import { MockTextDocument } from "../../autocomplete/__tests__/MockTextDocument" + +/** + * Mock implementation of the key VSCode workspace APIs needed for testing GhostWorkspaceEdit + */ +export class MockWorkspace { + private documents = new Map() + private appliedEdits: vscode.WorkspaceEdit[] = [] + + addDocument(uri: vscode.Uri, content: string): MockTextDocument { + const document = new MockTextDocument(content) + this.documents.set(uri.toString(), document) + return document + } + + async openTextDocument(uri: vscode.Uri): Promise { + const document = this.documents.get(uri.toString()) + if (!document) { + throw new Error(`Document not found: ${uri.toString()}`) + } + return document as unknown as vscode.TextDocument + } + + async applyEdit(workspaceEdit: vscode.WorkspaceEdit): Promise { + this.appliedEdits.push(workspaceEdit) + + let allEditsSuccessful = true + + // Apply each text edit to the corresponding document + for (const [uri, textEdits] of workspaceEdit.entries()) { + const document = this.documents.get(uri.toString()) + if (!document) { + console.warn(`Document not found for edit: ${uri.toString()}`) + allEditsSuccessful = false + continue + } + + await this.applyTextEditsToDocument(document, textEdits) + } + + return allEditsSuccessful + } + + private async applyTextEditsToDocument( + document: MockTextDocument, + textEdits: readonly (vscode.TextEdit | vscode.SnippetTextEdit)[], + ): Promise { + // Sort edits by position in reverse order to avoid position shifting issues + const sortedEdits = [...textEdits] + .filter((edit): edit is vscode.TextEdit => "range" in edit && "newText" in edit) + .sort((a, b) => { + const startCompare = b.range.start.line - a.range.start.line + if (startCompare !== 0) return startCompare + return b.range.start.character - a.range.start.character + }) + + let currentContent = document.getText() + const lines = currentContent.split("\n") + + for (const edit of sortedEdits) { + const range = edit.range + const newText = edit.newText + + if (range.start.line === range.end.line) { + // Single line edit + const line = lines[range.start.line] || "" + const newLine = line.slice(0, range.start.character) + newText + line.slice(range.end.character) + lines[range.start.line] = newLine + } else { + // Multi-line edit + const startLine = lines[range.start.line] || "" + const endLine = lines[range.end.line] || "" + const newLine = startLine.slice(0, range.start.character) + newText + endLine.slice(range.end.character) + + // Remove the lines in between and replace with the new content + lines.splice(range.start.line, range.end.line - range.start.line + 1, newLine) + } + } + + // Update the document with the new content + const newContent = lines.join("\n") + document.updateContent(newContent) + } + + getDocumentContent(uri: vscode.Uri): string { + const document = this.documents.get(uri.toString()) + return document ? document.getText() : "" + } + + getAppliedEdits(): vscode.WorkspaceEdit[] { + return this.appliedEdits + } + + clear(): void { + this.documents.clear() + this.appliedEdits.length = 0 + } +} diff --git a/src/services/ghost/__tests__/MockWorkspaceEdit.ts b/src/services/ghost/__tests__/MockWorkspaceEdit.ts new file mode 100644 index 0000000000..85d31366be --- /dev/null +++ b/src/services/ghost/__tests__/MockWorkspaceEdit.ts @@ -0,0 +1,46 @@ +import * as vscode from "vscode" +import { mockVscode } from "./MockWorkspace.spec" + +export function createMockWorkspaceEdit(): vscode.WorkspaceEdit { + const _edits = new Map() + + const createTextEdit = (range: vscode.Range, newText: string): vscode.TextEdit => + ({ range, newText }) as vscode.TextEdit + + return { + insert(uri: vscode.Uri, position: vscode.Position, newText: string) { + const key = uri.toString() + if (!_edits.has(key)) { + _edits.set(key, []) + } + const range = new mockVscode.Range(position, position) + _edits.get(key)!.push(createTextEdit(range as vscode.Range, newText)) + }, + + delete(uri: vscode.Uri, range: vscode.Range) { + const key = uri.toString() + if (!_edits.has(key)) { + _edits.set(key, []) + } + _edits.get(key)!.push(createTextEdit(range, "")) + }, + + replace(uri: vscode.Uri, range: vscode.Range, newText: string) { + const key = uri.toString() + if (!_edits.has(key)) { + _edits.set(key, []) + } + _edits.get(key)!.push(createTextEdit(range, newText)) + }, + + get(uri: vscode.Uri) { + return _edits.get(uri.toString()) || [] + }, + + entries() { + return Array.from(_edits.entries()).map( + ([uriString, edits]) => [mockVscode.Uri.parse(uriString), edits] as [vscode.Uri, vscode.TextEdit[]], + ) + }, + } as vscode.WorkspaceEdit +} diff --git a/src/services/ghost/__tests__/__test_cases__/complex-multi-group/diff.patch b/src/services/ghost/__tests__/__test_cases__/complex-multi-group/diff.patch new file mode 100644 index 0000000000..62d6e2c395 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/complex-multi-group/diff.patch @@ -0,0 +1,27 @@ +--- a/complex-multi-group/input.js ++++ b/complex-multi-group/input.js +@@ -1,13 +1,19 @@ + class Calculator { ++ constructor() { ++ this.precision = 2; ++ } ++ + add(a, b) { ++ // Addition with validation + return a + b; + } + + subtract(a, b) { + return a - b; + } + + multiply(a, b) { ++ // Multiplication with precision + return a * b; + } ++ ++ divide(a, b) { ++ if (b === 0) throw new Error('Division by zero'); ++ return a / b; ++ } + } \ No newline at end of file diff --git a/src/services/ghost/__tests__/__test_cases__/complex-multi-group/expected.js b/src/services/ghost/__tests__/__test_cases__/complex-multi-group/expected.js new file mode 100644 index 0000000000..02aa9195ce --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/complex-multi-group/expected.js @@ -0,0 +1,24 @@ +class Calculator { + constructor() { + this.precision = 2 + } + + add(a, b) { + // Addition with validation + return a + b + } + + subtract(a, b) { + return a - b + } + + multiply(a, b) { + // Multiplication with precision + return a * b + } + + divide(a, b) { + if (b === 0) throw new Error("Division by zero") + return a / b + } +} diff --git a/src/services/ghost/__tests__/__test_cases__/complex-multi-group/input.js b/src/services/ghost/__tests__/__test_cases__/complex-multi-group/input.js new file mode 100644 index 0000000000..becb615620 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/complex-multi-group/input.js @@ -0,0 +1,13 @@ +class Calculator { + add(a, b) { + return a + b + } + + subtract(a, b) { + return a - b + } + + multiply(a, b) { + return a * b + } +} diff --git a/src/services/ghost/__tests__/__test_cases__/empty-diff-response/diff.patch b/src/services/ghost/__tests__/__test_cases__/empty-diff-response/diff.patch new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/services/ghost/__tests__/__test_cases__/empty-diff-response/expected.js b/src/services/ghost/__tests__/__test_cases__/empty-diff-response/expected.js new file mode 100644 index 0000000000..2a2392b9dc --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/empty-diff-response/expected.js @@ -0,0 +1 @@ +console.log("test") diff --git a/src/services/ghost/__tests__/__test_cases__/empty-diff-response/input.js b/src/services/ghost/__tests__/__test_cases__/empty-diff-response/input.js new file mode 100644 index 0000000000..2a2392b9dc --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/empty-diff-response/input.js @@ -0,0 +1 @@ +console.log("test") diff --git a/src/services/ghost/__tests__/__test_cases__/function-rename-var-to-const/diff.patch b/src/services/ghost/__tests__/__test_cases__/function-rename-var-to-const/diff.patch new file mode 100644 index 0000000000..7b548d9488 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/function-rename-var-to-const/diff.patch @@ -0,0 +1,20 @@ +--- a/function-rename-var-to-const/input.js ++++ b/function-rename-var-to-const/input.js +@@ -1,12 +1,13 @@ +-function oldFunction() { +- var result = 'initial value'; +- var count = 0; ++function newFunction() { ++ // Use const instead of var ++ const result = 'initial value'; ++ const count = 0; + +- for (var i = 0; i < 5; i++) { ++ for (let i = 0; i < 5; i++) { + count += i; + } + + return result + ' - ' + count; + } + + function helperFunction() { \ No newline at end of file diff --git a/src/services/ghost/__tests__/__test_cases__/function-rename-var-to-const/expected.js b/src/services/ghost/__tests__/__test_cases__/function-rename-var-to-const/expected.js new file mode 100644 index 0000000000..922e1b8aaf --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/function-rename-var-to-const/expected.js @@ -0,0 +1,16 @@ +function newFunction() { + // Use const instead of var + const result = "initial value" + const count = 0 + + for (let i = 0; i < 5; i++) { + count += i + } + + return result + " - " + count +} + +function helperFunction() { + var data = { name: "test" } + return data +} diff --git a/src/services/ghost/__tests__/__test_cases__/function-rename-var-to-const/input.js b/src/services/ghost/__tests__/__test_cases__/function-rename-var-to-const/input.js new file mode 100644 index 0000000000..ac97655d9c --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/function-rename-var-to-const/input.js @@ -0,0 +1,15 @@ +function oldFunction() { + var result = "initial value" + var count = 0 + + for (var i = 0; i < 5; i++) { + count += i + } + + return result + " - " + count +} + +function helperFunction() { + var data = { name: "test" } + return data +} diff --git a/src/services/ghost/__tests__/__test_cases__/line-deletions/diff.patch b/src/services/ghost/__tests__/__test_cases__/line-deletions/diff.patch new file mode 100644 index 0000000000..09b0eeafc3 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/line-deletions/diff.patch @@ -0,0 +1,8 @@ +--- a/line-deletions/input.js ++++ b/line-deletions/input.js +@@ -1,5 +1,3 @@ + function greet(name) { +- // TODO: Remove this debug log +- console.log('Debug: greeting', name); + return `Hello, ${name}!`; + } \ No newline at end of file diff --git a/src/services/ghost/__tests__/__test_cases__/line-deletions/expected.js b/src/services/ghost/__tests__/__test_cases__/line-deletions/expected.js new file mode 100644 index 0000000000..cedb609c18 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/line-deletions/expected.js @@ -0,0 +1,3 @@ +function greet(name) { + return `Hello, ${name}!` +} diff --git a/src/services/ghost/__tests__/__test_cases__/line-deletions/input.js b/src/services/ghost/__tests__/__test_cases__/line-deletions/input.js new file mode 100644 index 0000000000..3debb24855 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/line-deletions/input.js @@ -0,0 +1,5 @@ +function greet(name) { + // TODO: Remove this debug log + console.log("Debug: greeting", name) + return `Hello, ${name}!` +} diff --git a/src/services/ghost/__tests__/__test_cases__/mixed-addition-deletion/diff.patch b/src/services/ghost/__tests__/__test_cases__/mixed-addition-deletion/diff.patch new file mode 100644 index 0000000000..05b9019a89 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/mixed-addition-deletion/diff.patch @@ -0,0 +1,13 @@ +--- a/mixed-addition-deletion/input.js ++++ b/mixed-addition-deletion/input.js +@@ -1,6 +1,7 @@ + function processData(data) { +- // Old validation +- if (!data) return null; ++ // New validation with better error handling ++ if (!data || typeof data !== 'string') { ++ throw new Error('Invalid data provided'); ++ } + + return data.toUpperCase(); + } \ No newline at end of file diff --git a/src/services/ghost/__tests__/__test_cases__/mixed-addition-deletion/expected.js b/src/services/ghost/__tests__/__test_cases__/mixed-addition-deletion/expected.js new file mode 100644 index 0000000000..dd1bdbf0e1 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/mixed-addition-deletion/expected.js @@ -0,0 +1,8 @@ +function processData(data) { + // New validation with better error handling + if (!data || typeof data !== "string") { + throw new Error("Invalid data provided") + } + + return data.toUpperCase() +} diff --git a/src/services/ghost/__tests__/__test_cases__/mixed-addition-deletion/input.js b/src/services/ghost/__tests__/__test_cases__/mixed-addition-deletion/input.js new file mode 100644 index 0000000000..671b83e8da --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/mixed-addition-deletion/input.js @@ -0,0 +1,6 @@ +function processData(data) { + // Old validation + if (!data) return null + + return data.toUpperCase() +} diff --git a/src/services/ghost/__tests__/__test_cases__/multiple-line-additions/diff.patch b/src/services/ghost/__tests__/__test_cases__/multiple-line-additions/diff.patch new file mode 100644 index 0000000000..29e413a9b4 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/multiple-line-additions/diff.patch @@ -0,0 +1,10 @@ +--- a/multiple-line-additions/input.js ++++ b/multiple-line-additions/input.js +@@ -1,3 +1,7 @@ + function calculate(a, b) { ++ // Validate inputs ++ if (typeof a !== 'number' || typeof b !== 'number') { ++ throw new Error('Invalid input'); ++ } + return a + b; + } \ No newline at end of file diff --git a/src/services/ghost/__tests__/__test_cases__/multiple-line-additions/expected.js b/src/services/ghost/__tests__/__test_cases__/multiple-line-additions/expected.js new file mode 100644 index 0000000000..3822ee9816 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/multiple-line-additions/expected.js @@ -0,0 +1,7 @@ +function calculate(a, b) { + // Validate inputs + if (typeof a !== "number" || typeof b !== "number") { + throw new Error("Invalid input") + } + return a + b +} diff --git a/src/services/ghost/__tests__/__test_cases__/multiple-line-additions/input.js b/src/services/ghost/__tests__/__test_cases__/multiple-line-additions/input.js new file mode 100644 index 0000000000..0bd1b2ad9e --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/multiple-line-additions/input.js @@ -0,0 +1,3 @@ +function calculate(a, b) { + return a + b +} diff --git a/src/services/ghost/__tests__/__test_cases__/partial-mixed-operations/diff.patch b/src/services/ghost/__tests__/__test_cases__/partial-mixed-operations/diff.patch new file mode 100644 index 0000000000..3f18882962 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/partial-mixed-operations/diff.patch @@ -0,0 +1,24 @@ +--- partial-mixed-operations/input.js ++++ partial-mixed-operations/input.js +@@ -1,14 +1,18 @@ + function calculateDiscount(price, discountRate) { ++ // Validate inputs ++ if (price < 0 || discountRate < 0) return 0; + return price * discountRate; + } + + function applyDiscount(item) { + const discount = calculateDiscount(item.price, 0.1); + return { + ...item, ++ originalPrice: item.price, + discountedPrice: item.price - discount + }; + } + + function processItems(items) { ++ // Filter out invalid items ++ const validItems = items.filter(item => item && item.price > 0); +- return items.map(applyDiscount); ++ return validItems.map(applyDiscount); + } \ No newline at end of file diff --git a/src/services/ghost/__tests__/__test_cases__/partial-mixed-operations/expected.js b/src/services/ghost/__tests__/__test_cases__/partial-mixed-operations/expected.js new file mode 100644 index 0000000000..6cdd4435d6 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/partial-mixed-operations/expected.js @@ -0,0 +1,18 @@ +function calculateDiscount(price, discountRate) { + return price * discountRate +} + +function applyDiscount(item) { + const discount = calculateDiscount(item.price, 0.1) + return { + ...item, + originalPrice: item.price, + discountedPrice: item.price - discount, + } +} + +function processItems(items) { + // Filter out invalid items + const validItems = items.filter((item) => item && item.price > 0) + return validItems.map(applyDiscount) +} diff --git a/src/services/ghost/__tests__/__test_cases__/partial-mixed-operations/input.js b/src/services/ghost/__tests__/__test_cases__/partial-mixed-operations/input.js new file mode 100644 index 0000000000..29c51b684e --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/partial-mixed-operations/input.js @@ -0,0 +1,15 @@ +function calculateDiscount(price, discountRate) { + return price * discountRate +} + +function applyDiscount(item) { + const discount = calculateDiscount(item.price, 0.1) + return { + ...item, + discountedPrice: item.price - discount, + } +} + +function processItems(items) { + return items.map(applyDiscount) +} diff --git a/src/services/ghost/__tests__/__test_cases__/random-mixed-operations/diff.patch b/src/services/ghost/__tests__/__test_cases__/random-mixed-operations/diff.patch new file mode 100644 index 0000000000..ea8d1d1ea5 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/random-mixed-operations/diff.patch @@ -0,0 +1,31 @@ +--- random-mixed-operations/input.js ++++ random-mixed-operations/input.js +@@ -1,17 +1,23 @@ + function formatCurrency(amount) { ++ // Handle edge cases ++ if (isNaN(amount) || amount < 0) return '$0.00'; + return `$${amount.toFixed(2)}`; + } + + function calculateTax(amount, rate) { ++ // Validate tax rate ++ if (rate < 0 || rate > 1) return 0; + return amount * rate; + } + + function generateInvoice(items, taxRate) { ++ // Validate inputs ++ if (!items || items.length === 0) { ++ throw new Error('No items provided'); ++ } + const subtotal = items.reduce((sum, item) => sum + item.price, 0); + const tax = calculateTax(subtotal, taxRate); + const total = subtotal + tax; + + return { ++ itemCount: items.length, + subtotal: formatCurrency(subtotal), + tax: formatCurrency(tax), + total: formatCurrency(total) + }; + } \ No newline at end of file diff --git a/src/services/ghost/__tests__/__test_cases__/random-mixed-operations/expected.js b/src/services/ghost/__tests__/__test_cases__/random-mixed-operations/expected.js new file mode 100644 index 0000000000..f94d5b5f3a --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/random-mixed-operations/expected.js @@ -0,0 +1,28 @@ +function formatCurrency(amount) { + // Handle edge cases + if (isNaN(amount) || amount < 0) return "$0.00" + return `$${amount.toFixed(2)}` +} + +function calculateTax(amount, rate) { + // Validate tax rate + if (rate < 0 || rate > 1) return 0 + return amount * rate +} + +function generateInvoice(items, taxRate) { + // Validate inputs + if (!items || items.length === 0) { + throw new Error("No items provided") + } + const subtotal = items.reduce((sum, item) => sum + item.price, 0) + const tax = calculateTax(subtotal, taxRate) + const total = subtotal + tax + + return { + itemCount: items.length, + subtotal: formatCurrency(subtotal), + tax: formatCurrency(tax), + total: formatCurrency(total), + } +} diff --git a/src/services/ghost/__tests__/__test_cases__/random-mixed-operations/input.js b/src/services/ghost/__tests__/__test_cases__/random-mixed-operations/input.js new file mode 100644 index 0000000000..d81a95541a --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/random-mixed-operations/input.js @@ -0,0 +1,19 @@ +function formatCurrency(amount) { + return `$${amount.toFixed(2)}` +} + +function calculateTax(amount, rate) { + return amount * rate +} + +function generateInvoice(items, taxRate) { + const subtotal = items.reduce((sum, item) => sum + item.price, 0) + const tax = calculateTax(subtotal, taxRate) + const total = subtotal + tax + + return { + subtotal: formatCurrency(subtotal), + tax: formatCurrency(tax), + total: formatCurrency(total), + } +} diff --git a/src/services/ghost/__tests__/__test_cases__/sequential-mixed-operations/diff.patch b/src/services/ghost/__tests__/__test_cases__/sequential-mixed-operations/diff.patch new file mode 100644 index 0000000000..16d53ce87c --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/sequential-mixed-operations/diff.patch @@ -0,0 +1,26 @@ +--- sequential-mixed-operations/input.js ++++ sequential-mixed-operations/input.js +@@ -1,14 +1,18 @@ + function calculateTotal(items) { + let total = 0; ++ // Add tax calculation + for (const item of items) { +- total += item.price; ++ total += item.price * 1.1; // Apply 10% tax + } + return total; + } + + function processOrder(order) { ++ // Validate order first ++ if (!order || !order.items) { ++ throw new Error('Invalid order'); ++ } + const total = calculateTotal(order.items); + return { + id: order.id, + total: total, +- status: 'processed' ++ status: 'completed' + }; + } \ No newline at end of file diff --git a/src/services/ghost/__tests__/__test_cases__/sequential-mixed-operations/expected.js b/src/services/ghost/__tests__/__test_cases__/sequential-mixed-operations/expected.js new file mode 100644 index 0000000000..2f9b313ca0 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/sequential-mixed-operations/expected.js @@ -0,0 +1,21 @@ +function calculateTotal(items) { + let total = 0 + // Add tax calculation + for (const item of items) { + total += item.price * 1.1 // Apply 10% tax + } + return total +} + +function processOrder(order) { + // Validate order first + if (!order || !order.items) { + throw new Error("Invalid order") + } + const total = calculateTotal(order.items) + return { + id: order.id, + total: total, + status: "completed", + } +} diff --git a/src/services/ghost/__tests__/__test_cases__/sequential-mixed-operations/input.js b/src/services/ghost/__tests__/__test_cases__/sequential-mixed-operations/input.js new file mode 100644 index 0000000000..636657de6c --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/sequential-mixed-operations/input.js @@ -0,0 +1,16 @@ +function calculateTotal(items) { + let total = 0 + for (const item of items) { + total += item.price + } + return total +} + +function processOrder(order) { + const total = calculateTotal(order.items) + return { + id: order.id, + total: total, + status: "processed", + } +} diff --git a/src/services/ghost/__tests__/__test_cases__/simple-addition/diff.patch b/src/services/ghost/__tests__/__test_cases__/simple-addition/diff.patch new file mode 100644 index 0000000000..49ae2ab903 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/simple-addition/diff.patch @@ -0,0 +1,7 @@ +--- a/test.js ++++ b/test.js +@@ -1,3 +1,4 @@ + function add(a, b) { ++ // Add two numbers + return a + b; + } \ No newline at end of file diff --git a/src/services/ghost/__tests__/__test_cases__/simple-addition/expected.js b/src/services/ghost/__tests__/__test_cases__/simple-addition/expected.js new file mode 100644 index 0000000000..a366764e7d --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/simple-addition/expected.js @@ -0,0 +1,4 @@ +function add(a, b) { + // Add two numbers + return a + b +} diff --git a/src/services/ghost/__tests__/__test_cases__/simple-addition/input.js b/src/services/ghost/__tests__/__test_cases__/simple-addition/input.js new file mode 100644 index 0000000000..7b31b215ad --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/simple-addition/input.js @@ -0,0 +1,3 @@ +function add(a, b) { + return a + b +} diff --git a/src/services/ghost/types.ts b/src/services/ghost/types.ts index 0f8125ece9..9d33546e3e 100644 --- a/src/services/ghost/types.ts +++ b/src/services/ghost/types.ts @@ -14,6 +14,12 @@ export interface GhostSuggestionEditOperation { content: string } +export interface GhostSuggestionEditOperationsOffset { + added: number + removed: number + offset: number +} + export interface GhostSuggestionContext { userInput?: string document?: vscode.TextDocument