From 9a898e6842c602ec68870068d0077b7a46da2be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93lafur=20P=C3=A1ll=20Geirsson?= Date: Sat, 12 Aug 2023 13:18:11 +0200 Subject: [PATCH] Agent: add support for autocomplete (#624) This PR adds a new `autocomplete/execute` endpoint to the agent protocol. To implement this endpoint, the agent now directly depend on the VS Code extension by providing a shim for all imports against the `'vscode'` module. By using a shim, the agent can launch the VS Code extension in a headless environment and capture the instance o the completion provider when it's registered. This PR doesn't implement all of VS Code APIs yet. It only implements that necessary parts to get autocomplete working. ## Test plan See new `index.test.ts` --------- Signed-off-by: Stephen Gutekanst Co-authored-by: Chris Warwick Co-authored-by: Stephen Gutekanst --- agent/package.json | 11 +- agent/src/AgentTabGroups.ts | 13 + agent/src/AgentTextDocument.test.ts | 74 ++++ agent/src/AgentTextDocument.ts | 88 +++++ agent/src/AgentTextEditor.ts | 42 ++ agent/src/AgentWorkspaceDocuments.ts | 73 ++++ agent/src/agent.ts | 148 ++++++- agent/src/editor.ts | 6 +- agent/src/esbuild.mjs | 23 ++ agent/src/index.test.ts | 28 +- agent/src/offsets.ts | 62 ++- agent/src/protocol.ts | 20 + agent/src/vscode-shim.ts | 284 ++++++++++++++ agent/tsconfig.json | 7 +- agent/vitest.config.ts | 23 ++ pnpm-lock.yaml | 265 ++++++++++++- .../vscodeInlineCompletionItemProvider.ts | 2 +- vscode/src/testutils/mocks.ts | 365 +++++++++++++++++- 18 files changed, 1465 insertions(+), 69 deletions(-) create mode 100644 agent/src/AgentTabGroups.ts create mode 100644 agent/src/AgentTextDocument.test.ts create mode 100644 agent/src/AgentTextDocument.ts create mode 100644 agent/src/AgentTextEditor.ts create mode 100644 agent/src/AgentWorkspaceDocuments.ts create mode 100644 agent/src/esbuild.mjs create mode 100644 agent/src/vscode-shim.ts diff --git a/agent/package.json b/agent/package.json index af9fff5174a4..951d3e48c0a0 100644 --- a/agent/package.json +++ b/agent/package.json @@ -12,10 +12,10 @@ "main": "src/index.ts", "sideEffects": false, "scripts": { - "build": "esbuild ./src/index.ts --bundle --outfile=dist/agent.js --format=cjs --platform=node", - "build-minify": "pnpm build --minify", + "build": "node ./src/esbuild.mjs", + "agent": "pnpm run build && node dist/index.js", "build-ts": "tsc --build", - "build-agent-binaries": "pnpm run build && pkg -t node16-linux-arm64,node16-linux-x64,node16-macos-arm64,node16-macos-x64,node16-win-x64 dist/agent.js --out-path ${AGENT_EXECUTABLE_TARGET_DIRECTORY:-dist}", + "build-agent-binaries": "pnpm run build && cp dist/index.js dist/agent.js && pkg -t node16-linux-arm64,node16-linux-x64,node16-macos-arm64,node16-macos-x64,node16-win-x64 dist/agent.js --out-path ${AGENT_EXECUTABLE_TARGET_DIRECTORY:-dist}", "lint": "pnpm run lint:js", "lint:js": "eslint --cache '**/*.[tj]s?(x)'", "test": "pnpm run build && vitest" @@ -25,6 +25,9 @@ "vscode-uri": "^3.0.7" }, "devDependencies": { - "pkg": "^5.8.1" + "pkg": "^5.8.1", + "@types/vscode": "^1.80.0", + "esbuild-plugin-alias-path": "^2.0.2", + "esbuild": "^0.18.19" } } diff --git a/agent/src/AgentTabGroups.ts b/agent/src/AgentTabGroups.ts new file mode 100644 index 000000000000..eebbc38d46df --- /dev/null +++ b/agent/src/AgentTabGroups.ts @@ -0,0 +1,13 @@ +import type * as vscode from 'vscode' + +import { emptyEvent } from '../../vscode/src/testutils/mocks' + +export class AgentTabGroups implements vscode.TabGroups { + public all: vscode.TabGroup[] = [] + public activeTabGroup: vscode.TabGroup = { activeTab: undefined, isActive: true, tabs: [], viewColumn: 1 } + public onDidChangeTabGroups: vscode.Event = emptyEvent() + public onDidChangeTabs: vscode.Event = emptyEvent() + public close(): Thenable { + throw new Error('Method not implemented.') + } +} diff --git a/agent/src/AgentTextDocument.test.ts b/agent/src/AgentTextDocument.test.ts new file mode 100644 index 000000000000..f6159ae9b80a --- /dev/null +++ b/agent/src/AgentTextDocument.test.ts @@ -0,0 +1,74 @@ +import assert from 'assert' + +import { describe, it } from 'vitest' +import * as vscode from 'vscode' + +import { AgentTextDocument } from './AgentTextDocument' + +describe('AgentTextDocument', () => { + const basic = new AgentTextDocument({ filePath: 'foo', content: 'a\nb\n' }) + const basicCrlf = new AgentTextDocument({ filePath: 'foo', content: 'a\r\nb\r\n' }) + const emptyLine = new AgentTextDocument({ filePath: 'foo', content: 'a\n\n' }) + const noEndOfFileNewline = new AgentTextDocument({ filePath: 'foo', content: 'a\nb' }) + const emptyFirstLine = new AgentTextDocument({ filePath: 'foo', content: '\nb' }) + const emptyFirstLineCrlf = new AgentTextDocument({ filePath: 'foo', content: '\r\nb' }) + const noIndentation = new AgentTextDocument({ filePath: 'foo', content: 'sss\n' }) + const indentation = new AgentTextDocument({ filePath: 'foo', content: ' a\n' }) + const indentationTab = new AgentTextDocument({ filePath: 'foo', content: '\t\tab\n' }) + + it('getText(Range)', () => { + assert.equal(basic.getText(new vscode.Range(0, 0, 0, 1)), 'a') + assert.equal(basic.getText(new vscode.Range(0, 0, 1, 1)), 'a\nb') + assert.equal(basic.getText(new vscode.Range(2, 0, 2, 10)), '') + assert.equal(basic.getText(new vscode.Range(0, 0, 2, 3)), 'a\nb\n') + }) + + it('lineCount()', () => { + assert.equal(basic.lineCount, 2) + assert.equal(basicCrlf.lineCount, 2) + assert.equal(emptyFirstLine.lineCount, 2) + assert.equal(noEndOfFileNewline.lineCount, 2) + assert.equal(emptyFirstLine.lineCount, 2) + assert.equal(emptyFirstLineCrlf.lineCount, 2) + }) + + it('positionAt()', () => { + assert.deepEqual(basic.positionAt(0), new vscode.Position(0, 0)) + }) + + it('lineAt()', () => { + assert.equal(basic.getText(basic.lineAt(1).range), 'b') + assert.equal(basic.getText(basic.lineAt(1).rangeIncludingLineBreak), 'b\n') + assert.equal(basic.getText(basic.lineAt(2).range), '') + assert.equal(basic.getText(basic.lineAt(2).rangeIncludingLineBreak), '') + + assert.equal(basicCrlf.getText(basic.lineAt(1).range), 'b') + assert.equal(basicCrlf.getText(basicCrlf.lineAt(1).rangeIncludingLineBreak), 'b\r\n') + assert.equal(basicCrlf.getText(basic.lineAt(2).range), '') + assert.equal(basicCrlf.getText(basic.lineAt(2).rangeIncludingLineBreak), '') + + assert.equal(emptyLine.getText(emptyLine.lineAt(0).range), 'a') + assert.equal(emptyLine.getText(emptyLine.lineAt(0).rangeIncludingLineBreak), 'a\n') + assert.equal(emptyLine.getText(emptyLine.lineAt(1).range), '') + assert.equal(emptyLine.getText(emptyLine.lineAt(1).rangeIncludingLineBreak), '\n') + + assert.equal(noEndOfFileNewline.getText(noEndOfFileNewline.lineAt(1).range), 'b') + assert.equal(noEndOfFileNewline.getText(noEndOfFileNewline.lineAt(1).rangeIncludingLineBreak), 'b') + + assert.equal(emptyFirstLine.getText(emptyFirstLine.lineAt(0).range), '') + assert.equal(emptyFirstLine.getText(emptyFirstLine.lineAt(0).rangeIncludingLineBreak), '\n') + assert.equal(emptyFirstLine.getText(emptyFirstLine.lineAt(1).range), 'b') + assert.equal(emptyFirstLine.getText(emptyFirstLine.lineAt(1).rangeIncludingLineBreak), 'b') + + assert.equal(emptyFirstLineCrlf.getText(emptyFirstLineCrlf.lineAt(0).range), '') + assert.equal(emptyFirstLineCrlf.getText(emptyFirstLineCrlf.lineAt(0).rangeIncludingLineBreak), '\r\n') + assert.equal(emptyFirstLineCrlf.getText(emptyFirstLineCrlf.lineAt(1).range), 'b') + assert.equal(emptyFirstLineCrlf.getText(emptyFirstLineCrlf.lineAt(1).rangeIncludingLineBreak), 'b') + }) + + it('lineAt().firstNonWhitespaceCharacterIndex', () => { + assert.equal(noIndentation.lineAt(0).firstNonWhitespaceCharacterIndex, 0) + assert.equal(indentation.lineAt(0).firstNonWhitespaceCharacterIndex, 2) + assert.equal(indentationTab.lineAt(0).firstNonWhitespaceCharacterIndex, 2) + }) +}) diff --git a/agent/src/AgentTextDocument.ts b/agent/src/AgentTextDocument.ts new file mode 100644 index 000000000000..abb742e59331 --- /dev/null +++ b/agent/src/AgentTextDocument.ts @@ -0,0 +1,88 @@ +import * as vscode from 'vscode' + +import { DocumentOffsets } from './offsets' +import { TextDocument } from './protocol' +import * as vscode_shim from './vscode-shim' + +// TODO: implement with vscode-languageserver-textdocument The reason we don't +// use vscode-languageserver-textdocument is because it doesn't implement all +// the properties/functions that vscode.TextDocument has. For example, lineAt is +// missing in vscode-languageserver-textdocument +export class AgentTextDocument implements vscode.TextDocument { + constructor(public readonly textDocument: TextDocument) { + this.content = textDocument.content ?? '' + this.uri = vscode_shim.Uri.from({ scheme: 'file', path: textDocument.filePath }) + this.fileName = textDocument.filePath + this.isUntitled = false + this.languageId = this.fileName.split('.').splice(-1)[0] + this.offsets = new DocumentOffsets(textDocument) + this.lineCount = this.offsets.lineCount() + } + private readonly content: string + private readonly offsets: DocumentOffsets + public readonly uri: vscode.Uri + public readonly fileName: string + public readonly lineCount: number + public readonly isUntitled: boolean + public readonly languageId: string + + public readonly version: number = 0 + public readonly isDirty: boolean = false + public readonly isClosed: boolean = false + public save(): Thenable { + throw new Error('Method not implemented.') + } + public readonly eol: vscode.EndOfLine = vscode_shim.EndOfLine.LF + public lineAt(position: vscode.Position | number): vscode.TextLine { + const line = typeof position === 'number' ? position : position.line + const text = this.getText( + new vscode_shim.Range( + new vscode_shim.Position(line, 0), + new vscode_shim.Position(line, this.offsets.lineLengthExcludingNewline(line)) + ) + ) + let firstNonWhitespaceCharacterIndex = 0 + while (firstNonWhitespaceCharacterIndex < text.length && /\s/.test(text[firstNonWhitespaceCharacterIndex])) { + firstNonWhitespaceCharacterIndex++ + } + return { + lineNumber: line, + firstNonWhitespaceCharacterIndex, + isEmptyOrWhitespace: firstNonWhitespaceCharacterIndex === text.length, + range: new vscode_shim.Range( + new vscode_shim.Position(line, 0), + new vscode_shim.Position(line, text.length) + ), + rangeIncludingLineBreak: new vscode_shim.Range( + new vscode_shim.Position(line, 0), + new vscode_shim.Position(line, text.length + this.offsets.newlineLength(line)) + ), + text, + } + } + public offsetAt(position: vscode.Position): number { + return this.offsets.offset(position) + } + public positionAt(offset: number): vscode.Position { + const { line, character } = this.offsets.position(offset) + return new vscode_shim.Position(line, character) + } + public getText(range?: vscode.Range | undefined): string { + if (range === undefined) { + return this.content + } + const start = this.offsets.offset(range.start) + const end = this.offsets.offset(range.end) + const text = this.content.slice(start, end) + return text + } + public getWordRangeAtPosition(position: vscode.Position, regex?: RegExp | undefined): vscode.Range | undefined { + throw new Error('Method not implemented.') + } + public validateRange(range: vscode.Range): vscode.Range { + throw new Error('Method not implemented.') + } + public validatePosition(position: vscode.Position): vscode.Position { + throw new Error('Method not implemented.') + } +} diff --git a/agent/src/AgentTextEditor.ts b/agent/src/AgentTextEditor.ts new file mode 100644 index 000000000000..6de5b1211829 --- /dev/null +++ b/agent/src/AgentTextEditor.ts @@ -0,0 +1,42 @@ +import * as vscode from 'vscode' + +import { AgentTextDocument } from './AgentTextDocument' + +export function newTextEditor(document: AgentTextDocument): vscode.TextEditor { + const selection: vscode.Selection = document.textDocument.selection + ? new vscode.Selection( + new vscode.Position( + document.textDocument.selection.start.line, + document.textDocument.selection.start.character + ), + new vscode.Position( + document.textDocument.selection.end.line, + document.textDocument.selection.end.character + ) + ) + : new vscode.Selection(new vscode.Position(0, 0), new vscode.Position(0, 0)) + + return { + // Looking at the implementation of the extension, we only need + // to provide `document` but we do a best effort to shim the + // rest of the `TextEditor` properties. + document, + selection, + selections: [selection], + edit: () => Promise.resolve(true), + insertSnippet: () => Promise.resolve(true), + revealRange: () => {}, + options: { + cursorStyle: undefined, + insertSpaces: undefined, + lineNumbers: undefined, + // TODO: fix tabSize + tabSize: 2, + }, + setDecorations: () => {}, + viewColumn: vscode.ViewColumn.Active, + visibleRanges: [selection], + show: () => {}, + hide: () => {}, + } +} diff --git a/agent/src/AgentWorkspaceDocuments.ts b/agent/src/AgentWorkspaceDocuments.ts new file mode 100644 index 000000000000..9ec4e57609bb --- /dev/null +++ b/agent/src/AgentWorkspaceDocuments.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type * as vscode from 'vscode' +import { URI } from 'vscode-uri' + +import { AgentTextDocument } from './AgentTextDocument' +import { newTextEditor } from './AgentTextEditor' +import { TextDocument } from './protocol' +import * as vscode_shim from './vscode-shim' + +export class AgentWorkspaceDocuments implements vscode_shim.WorkspaceDocuments { + private readonly documents: Map = new Map() + public workspaceRootUri: URI | null = null + public activeDocumentFilePath: string | null = null + public loadedDocument(document: TextDocument): TextDocument { + const fromCache = this.documents.get(document.filePath) + if (document.content === undefined) { + document.content = fromCache?.content + } + if (document.selection === undefined) { + document.selection = fromCache?.selection + } + return document + } + public agentTextDocument(document: TextDocument): AgentTextDocument { + return new AgentTextDocument(this.loadedDocument(document)) + } + + public allFilePaths(): string[] { + return [...this.documents.keys()] + } + public allDocuments(): TextDocument[] { + return [...this.documents.values()] + } + public getDocument(filePath: string): TextDocument | undefined { + return this.documents.get(filePath) + } + public setDocument(document: TextDocument): void { + this.documents.set(document.filePath, this.loadedDocument(document)) + const tabs: readonly vscode.Tab[] = this.allFilePaths().map(filePath => this.vscodeTab(filePath)) + vscode_shim.tabGroups.all = [ + { + tabs, + isActive: true, + activeTab: this.vscodeTab(this.activeDocumentFilePath ?? ''), + viewColumn: vscode_shim.ViewColumn.Active, + }, + ] + + while (vscode_shim.visibleTextEditors.length > 0) { + vscode_shim.visibleTextEditors.pop() + } + for (const document of this.allDocuments()) { + vscode_shim.visibleTextEditors.push(newTextEditor(this.agentTextDocument(document))) + } + } + public deleteDocument(filePath: string): void { + this.documents.delete(filePath) + } + private vscodeTab(filePath: string): vscode.Tab { + return { + input: { + uri: filePath, + }, + label: 'label', + group: { activeTab: undefined, isActive: false, tabs: [], viewColumn: -1 }, + } as any + } + + public openTextDocument(filePath: string): Promise { + return Promise.resolve(this.agentTextDocument({ filePath })) + } +} diff --git a/agent/src/agent.ts b/agent/src/agent.ts index a2416e1f99a3..84ed9c44b753 100644 --- a/agent/src/agent.ts +++ b/agent/src/agent.ts @@ -1,33 +1,85 @@ +import path from 'path' + +import * as vscode from 'vscode' import { URI } from 'vscode-uri' import { Client, createClient } from '@sourcegraph/cody-shared/src/chat/client' import { registeredRecipes } from '@sourcegraph/cody-shared/src/chat/recipes/agent-recipes' import { SourcegraphNodeCompletionsClient } from '@sourcegraph/cody-shared/src/sourcegraph-api/completions/nodeClient' +import { activate } from '../../vscode/src/extension.node' + +import { AgentTextDocument } from './AgentTextDocument' +import { newTextEditor } from './AgentTextEditor' +import { AgentWorkspaceDocuments } from './AgentWorkspaceDocuments' import { AgentEditor } from './editor' import { MessageHandler } from './jsonrpc' -import { ConnectionConfiguration, TextDocument } from './protocol' +import { AutocompleteItem, ConnectionConfiguration } from './protocol' +import * as vscode_shim from './vscode-shim' + +const secretStorage = new Map() + +function initializeVscodeExtension(): void { + activate({ + asAbsolutePath(relativePath) { + return path.resolve(process.cwd(), relativePath) + }, + environmentVariableCollection: {} as any, + extension: {} as any, + extensionMode: {} as any, + extensionPath: {} as any, + extensionUri: {} as any, + globalState: { + keys: () => [], + get: () => undefined, + update: (key, value) => Promise.resolve(), + setKeysForSync: keys => {}, + }, + logUri: {} as any, + logPath: {} as any, + secrets: { + onDidChange: vscode_shim.emptyEvent(), + get(key) { + if (key === 'cody.access-token' && vscode_shim.connectionConfig) { + // console.log('MATCH') + return Promise.resolve(vscode_shim.connectionConfig.accessToken) + } + // console.log({ key }) + + return Promise.resolve(secretStorage.get(key)) + }, + store(key, value) { + // console.log({ key, value }) + secretStorage.set(key, value) + return Promise.resolve() + }, + delete(key) { + return Promise.resolve() + }, + }, + storageUri: {} as any, + subscriptions: [], + workspaceState: {} as any, + globalStorageUri: {} as any, + storagePath: {} as any, + globalStoragePath: {} as any, + }) +} export class Agent extends MessageHandler { private client: Promise = Promise.resolve(null) - public workspaceRootUri: URI | null = null - public activeDocumentFilePath: string | null = null - public documents: Map = new Map() + public workspace = new AgentWorkspaceDocuments() constructor() { super() - - this.setClient({ - customHeaders: {}, - accessToken: process.env.SRC_ACCESS_TOKEN || '', - serverEndpoint: process.env.SRC_ENDPOINT || 'https://sourcegraph.com', - }) + vscode_shim.setWorkspaceDocuments(this.workspace) this.registerRequest('initialize', async client => { process.stderr.write( `Cody Agent: handshake with client '${client.name}' (version '${client.version}') at workspace root path '${client.workspaceRootUri}'\n` ) - this.workspaceRootUri = URI.parse(client.workspaceRootUri || `file://${client.workspaceRootPath}`) + initializeVscodeExtension() + this.workspace.workspaceRootUri = URI.parse(client.workspaceRootUri || `file://${client.workspaceRootPath}`) if (client.connectionConfiguration) { this.setClient(client.connectionConfiguration) } @@ -60,21 +112,31 @@ export class Agent extends MessageHandler { }) this.registerNotification('textDocument/didFocus', document => { - this.activeDocumentFilePath = document.filePath + this.workspace.activeDocumentFilePath = document.filePath + vscode_shim.onDidChangeActiveTextEditor.fire(newTextEditor(this.workspace.agentTextDocument(document))) }) this.registerNotification('textDocument/didOpen', document => { - this.documents.set(document.filePath, document) - this.activeDocumentFilePath = document.filePath + this.workspace.setDocument(document) + this.workspace.activeDocumentFilePath = document.filePath + const textDocument = this.workspace.agentTextDocument(document) + vscode_shim.onDidOpenTextDocument.fire(textDocument) + vscode_shim.onDidChangeActiveTextEditor.fire(newTextEditor(textDocument)) }) this.registerNotification('textDocument/didChange', document => { - if (document.content === undefined) { - document.content = this.documents.get(document.filePath)?.content - } - this.documents.set(document.filePath, document) - this.activeDocumentFilePath = document.filePath + const textDocument = this.workspace.agentTextDocument(document) + this.workspace.setDocument(document) + this.workspace.activeDocumentFilePath = document.filePath + + vscode_shim.onDidChangeActiveTextEditor.fire(newTextEditor(textDocument)) + vscode_shim.onDidChangeTextDocument.fire({ + document: textDocument, + contentChanges: [], // TODO: implement this. It's only used by recipes, not autocomplete. + reason: undefined, + }) }) this.registerNotification('textDocument/didClose', document => { - this.documents.delete(document.filePath) + this.workspace.deleteDocument(document.filePath) + vscode_shim.onDidCloseTextDocument.fire(this.workspace.agentTextDocument(document)) }) this.registerNotification('connectionConfiguration/didChange', config => { @@ -95,15 +157,61 @@ export class Agent extends MessageHandler { if (!client) { return null } + await client.executeRecipe(data.id, { humanChatInput: data.humanChatInput, data: data.data, }) return null }) + this.registerRequest('autocomplete/execute', async params => { + const provider = await vscode_shim.completionProvider + if (!provider) { + console.log('Completion provider is not initialized') + return { items: [] } + } + const token = new vscode.CancellationTokenSource().token + const document = this.workspace.getDocument(params.filePath) + if (!document) { + console.log('No document found for file path', params.filePath, [...this.workspace.allFilePaths()]) + return { items: [] } + } + + const textDocument = new AgentTextDocument(document) + + try { + const result = await provider.provideInlineCompletionItems( + textDocument as any, + new vscode.Position(params.position.line, params.position.character), + { triggerKind: vscode.InlineCompletionTriggerKind.Automatic, selectedCompletionInfo: undefined }, + token + ) + const items: AutocompleteItem[] = + result === null + ? [] + : result.items.flatMap(({ insertText, range }) => + typeof insertText === 'string' && range !== undefined ? [{ insertText, range }] : [] + ) + return { items } + } catch (error) { + console.log('autocomplete failed', error) + return { items: [] } + } + }) } private setClient(config: ConnectionConfiguration): void { + vscode_shim.setConnectionConfig(config) + vscode_shim.onDidChangeConfiguration.fire({ + affectsConfiguration: () => + // assuming the return value below only impacts performance (not + // functionality), we return true to always triggger the callback. + true, + }) + vscode_shim.commands.executeCommand('cody.auth.sync').then( + () => {}, + () => {} + ) this.client = createClient({ editor: new AgentEditor(this), config: { ...config, useContext: 'none' }, diff --git a/agent/src/editor.ts b/agent/src/editor.ts index 266e46e5d872..c4c5af3a81b8 100644 --- a/agent/src/editor.ts +++ b/agent/src/editor.ts @@ -29,14 +29,14 @@ export class AgentEditor implements Editor { } public getWorkspaceRootUri(): URI | null { - return this.agent.workspaceRootUri + return this.agent.workspace.workspaceRootUri } private activeDocument(): TextDocument | undefined { - if (this.agent.activeDocumentFilePath === null) { + if (this.agent.workspace.activeDocumentFilePath === null) { return undefined } - return this.agent.documents.get(this.agent.activeDocumentFilePath) + return this.agent.workspace.getDocument(this.agent.workspace.activeDocumentFilePath) } public getActiveTextEditor(): ActiveTextEditor | null { diff --git a/agent/src/esbuild.mjs b/agent/src/esbuild.mjs new file mode 100644 index 000000000000..7b22913dd620 --- /dev/null +++ b/agent/src/esbuild.mjs @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import path from 'path' +import process from 'process' + +import { build } from 'esbuild' +import { aliasPath } from 'esbuild-plugin-alias-path' + +;(async () => { + /** @type {import('esbuild').BuildOptions} */ + const esbuildOptions = { + entryPoints: ['./src/index.ts'], + bundle: true, + outfile: './dist/index.js', + platform: 'node', + format: 'cjs', + plugins: [ + aliasPath({ + alias: { vscode: path.resolve(process.cwd(), './src/vscode-shim.ts') }, + }), + ], + } + const res = await build(esbuildOptions) +})() diff --git a/agent/src/index.test.ts b/agent/src/index.test.ts index 6971e0da6046..d3daf118e395 100644 --- a/agent/src/index.test.ts +++ b/agent/src/index.test.ts @@ -1,5 +1,5 @@ import assert from 'assert' -import { spawn } from 'child_process' +import { execSync, spawn } from 'child_process' import path from 'path' import { afterAll, describe, it } from 'vitest' @@ -15,6 +15,15 @@ export class TestClient extends MessageHandler { version: 'v1', workspaceRootUri: 'file:///path/to/foo', workspaceRootPath: '/path/to/foo', + connectionConfiguration: { + accessToken: process.env.SRC_ACCESS_TOKEN ?? 'invalid', + serverEndpoint: process.env.SRC_ENDPOINT ?? 'invalid', + customHeaders: {}, + autocompleteAdvancedProvider: '', + autocompleteAdvancedAccessToken: '', + autocompleteAdvancedServerEndpoint: '', + autocompleteAdvancedEmbeddings: true, + }, }) this.notify('initialized', null) return info @@ -43,7 +52,11 @@ describe('StandardAgent', () => { return } const client = new TestClient() - const agentProcess = spawn('node', [path.join(__dirname, '../dist/agent.js')], { + + // Bundle the agent. When running `pnpm run test`, vitest doesn't re-run this step. + execSync('pnpm run build') + + const agentProcess = spawn('node', ['--inspect', path.join(__dirname, '../dist/index.js'), '--inspect'], { stdio: 'pipe', }) @@ -65,6 +78,17 @@ describe('StandardAgent', () => { assert(recipes.length === 8) }) + it('returns non-empty autocomplete', async () => { + const filePath = '/path/to/foo/file.js' + const content = 'function sum(a, b) {\n \n}' + client.notify('textDocument/didOpen', { filePath, content }) + const completions = await client.request('autocomplete/execute', { + filePath, + position: { line: 1, character: 4 }, + }) + assert(completions.items.length > 0) + }) + const streamingChatMessages = new Promise(resolve => { client.registerNotification('chat/updateMessageInProgress', msg => { if (msg === null) { diff --git a/agent/src/offsets.ts b/agent/src/offsets.ts index df7234158446..f2e532e26c1c 100644 --- a/agent/src/offsets.ts +++ b/agent/src/offsets.ts @@ -5,23 +5,59 @@ import { Position, TextDocument } from './protocol' */ export class DocumentOffsets { private lines: number[] = [] + private content: string constructor(public readonly document: TextDocument) { - if (document.content) { - this.lines.push(0) - let index = 1 - while (index < document.content.length) { - if (document.content[index] === '\n') { - this.lines.push(index + 1) - } - index++ - } - if (document.content.length !== this.lines[this.lines.length - 1]) { - this.lines.push(document.content.length) // sentinel value + this.content = document?.content || '' + this.lines.push(0) + let index = 0 + while (index < this.content.length) { + if (this.content[index] === '\n') { + this.lines.push(index + 1) } + index++ + } + if (this.content.length !== this.lines[this.lines.length - 1]) { + this.lines.push(this.content.length) // sentinel value + } + } + public lineCount(): number { + return this.lines.length - 1 + } + public lineStartOffset(line: number): number { + return this.lines[line] + } + public lineEndOffset(line: number): number { + const nextLine = line + 1 + return nextLine < this.lines.length ? this.lines[nextLine] : this.document.content?.length ?? 0 + } + public newlineLength(line: number): number { + const endOffset = this.lineEndOffset(line) + const isEndOfFile = endOffset === this.content.length + const hasNewlineAtEndOfFile = this.content.endsWith('\n') + if (isEndOfFile && !hasNewlineAtEndOfFile) { + return 0 } + const isCarriageReturn = endOffset > 1 && this.content[endOffset - 2] === '\r' + return isCarriageReturn ? 2 : 1 + } + public lineLengthIncludingNewline(line: number): number { + return this.lineEndOffset(line) - this.lineStartOffset(line) + } + public lineLengthExcludingNewline(line: number): number { + return this.lineLengthIncludingNewline(line) - this.newlineLength(line) } public offset(position: Position): number { - const lineStartOffset = this.lines[position.line] - return lineStartOffset + position.character + return this.lines[position.line] + Math.min(position.character, this.lineLengthIncludingNewline(position.line)) + } + public position(offset: number): { line: number; character: number } { + let line = 0 + // TODO: use binary search to optimize this part. + while (line < this.lines.length - 1 && offset >= this.lines[line + 1]) { + line++ + } + return { + line, + character: offset - this.lines[line], + } } } diff --git a/agent/src/protocol.ts b/agent/src/protocol.ts index ba16d741a088..1ab7907766f2 100644 --- a/agent/src/protocol.ts +++ b/agent/src/protocol.ts @@ -32,6 +32,8 @@ export type Requests = { // client <-- chat/updateMessageInProgress --- server 'recipes/execute': [ExecuteRecipeParams, null] + 'autocomplete/execute': [AutocompleteParams, AutocompleteResult] + // ================ // Server -> Client // ================ @@ -79,6 +81,20 @@ export type Notifications = { 'chat/updateMessageInProgress': [ChatMessage | null] } +export interface AutocompleteParams { + filePath: string + position: Position +} + +export interface AutocompleteResult { + items: AutocompleteItem[] +} + +export interface AutocompleteItem { + insertText: string + range: Range +} + export interface ClientInfo { name: string version: string @@ -110,6 +126,10 @@ export interface ConnectionConfiguration { serverEndpoint: string accessToken: string customHeaders: Record + autocompleteAdvancedProvider: string + autocompleteAdvancedServerEndpoint: string | null + autocompleteAdvancedAccessToken: string | null + autocompleteAdvancedEmbeddings: boolean } export interface Position { diff --git a/agent/src/vscode-shim.ts b/agent/src/vscode-shim.ts new file mode 100644 index 000000000000..8d8de57c7da9 --- /dev/null +++ b/agent/src/vscode-shim.ts @@ -0,0 +1,284 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import type * as vscode from 'vscode' + +// +// This file must not import any module that transitively imports from 'vscode'. +// It's only OK to `import type` from vscode. We can't depend on any vscode APIs +// to implement this this file because this file is responsible for implementing +// VS Code APIs resulting in cyclic dependencies. If we make a mistake and +// transitively import vscode then you are most likely to hit an error like this: +// +// /pkg/prelude/bootstrap.js:1926 +// return wrapper.apply(this.exports, args); +// ^ +// TypeError: Cannot read properties of undefined (reading 'getConfiguration') +// at Object. (/snapshot/dist/agent.js) +// at Module._compile (pkg/prelude/bootstrap.js:1926:22) +// +import type { InlineCompletionItemProvider } from '../../vscode/src/completions/vscodeInlineCompletionItemProvider' +import { + // It's OK to import the VS Code mocks because they don't depend on the 'vscode' module. + Disposable, + emptyDisposable, + emptyEvent, + EventEmitter, + UIKind, + Uri, +} from '../../vscode/src/testutils/mocks' + +import { AgentTabGroups } from './AgentTabGroups' +import type { ConnectionConfiguration } from './protocol' + +export { + emptyEvent, + emptyDisposable, + Range, + Selection, + Position, + Disposable, + CancellationTokenSource, + EndOfLine, + EventEmitter, + InlineCompletionItem, + InlineCompletionTriggerKind, + WorkspaceEdit, + QuickPickItemKind, + ConfigurationTarget, + StatusBarAlignment, + RelativePattern, + MarkdownString, + CommentMode, + CommentThreadCollapsibleState, + OverviewRulerLane, + CodeLens, + CodeAction, + CodeActionKind, + FileType, + ThemeColor, + ThemeIcon, + TreeItemCollapsibleState, + TreeItem, + ExtensionMode, + DiagnosticSeverity, + SymbolKind, + ViewColumn, + QuickInputButtons, + Uri, + UIKind, +} from '../../vscode/src/testutils/mocks' + +const emptyFileWatcher: vscode.FileSystemWatcher = { + onDidChange: emptyEvent(), + onDidCreate: emptyEvent(), + onDidDelete: emptyEvent(), + ignoreChangeEvents: true, + ignoreCreateEvents: true, + ignoreDeleteEvents: true, + dispose(): void {}, +} + +export let connectionConfig: ConnectionConfiguration | undefined +export function setConnectionConfig(newConfig: ConnectionConfiguration): void { + connectionConfig = newConfig +} + +const configuration: vscode.WorkspaceConfiguration = { + has(section) { + return true + }, + get: (section, defaultValue?: any) => { + switch (section) { + case 'cody.serverEndpoint': + return connectionConfig?.serverEndpoint + case 'cody.customHeaders': + return connectionConfig?.customHeaders + case 'cody.autocomplete.enabled': + return true + case 'cody.autocomplete.advanced.provider': + return connectionConfig?.autocompleteAdvancedProvider + case 'cody.autocomplete.advanced.serverEndpoint': + return connectionConfig?.autocompleteAdvancedServerEndpoint + case 'cody.autocomplete.advanced.accessToken': + return connectionConfig?.autocompleteAdvancedAccessToken + case 'cody.autocomplete.advanced.embeddings': + return connectionConfig?.autocompleteAdvancedEmbeddings + default: + // console.log({ section }) + return defaultValue + } + }, + update(section, value, configurationTarget, overrideInLanguage) { + return Promise.resolve() + }, + inspect(section) { + return undefined + }, +} + +export const onDidChangeActiveTextEditor = new EventEmitter() +export const onDidChangeConfiguration = new EventEmitter() +export const onDidOpenTextDocument = new EventEmitter() +export const onDidChangeTextDocument = new EventEmitter() +export const onDidCloseTextDocument = new EventEmitter() +export const onDidRenameFiles = new EventEmitter() +export const onDidDeleteFiles = new EventEmitter() + +export interface WorkspaceDocuments { + openTextDocument: (filePath: string) => Promise +} +let workspaceDocuments: WorkspaceDocuments | undefined +export function setWorkspaceDocuments(newWorkspaceDocuments: WorkspaceDocuments): void { + workspaceDocuments = newWorkspaceDocuments +} + +// vscode.workspace.onDidChangeConfiguration +const _workspace: Partial = { + openTextDocument: uri => { + // We currently treat filePath the same as uri for now, but will need to + // properly pass around URIs once the agent protocol supports URIs + const filePath = uri instanceof Uri ? uri.path : uri?.toString() ?? '' + return workspaceDocuments ? workspaceDocuments.openTextDocument(filePath) : ('missingWorkspaceDocuments' as any) + }, + onDidChangeWorkspaceFolders: (() => ({})) as any, + onDidOpenTextDocument: onDidOpenTextDocument.event, + onDidChangeConfiguration: onDidChangeConfiguration.event, + onDidChangeTextDocument: onDidChangeTextDocument.event, + onDidCloseTextDocument: onDidCloseTextDocument.event, + onDidRenameFiles: onDidRenameFiles.event, + onDidDeleteFiles: onDidDeleteFiles.event, + registerTextDocumentContentProvider: () => emptyDisposable, + asRelativePath: (pathOrUri: string | vscode.Uri, includeWorkspaceFolder?: boolean): string => pathOrUri.toString(), + createFileSystemWatcher: () => emptyFileWatcher, + getConfiguration: (() => configuration) as any, +} +export const workspace = _workspace as typeof vscode.workspace + +const statusBarItem: Partial = { + show: () => {}, +} + +export const visibleTextEditors: vscode.TextEditor[] = [] + +export const tabGroups = new AgentTabGroups() + +const _window: Partial = { + tabGroups, + registerCustomEditorProvider: () => emptyDisposable, + registerFileDecorationProvider: () => emptyDisposable, + registerTerminalLinkProvider: () => emptyDisposable, + registerTerminalProfileProvider: () => emptyDisposable, + registerTreeDataProvider: () => emptyDisposable, + registerWebviewPanelSerializer: () => emptyDisposable, + onDidChangeTextEditorVisibleRanges: emptyEvent(), + onDidChangeActiveColorTheme: emptyEvent(), + onDidChangeActiveNotebookEditor: emptyEvent(), + onDidChangeActiveTerminal: emptyEvent(), + onDidChangeNotebookEditorSelection: emptyEvent(), + onDidChangeNotebookEditorVisibleRanges: emptyEvent(), + onDidChangeTerminalState: emptyEvent(), + onDidChangeTextEditorOptions: emptyEvent(), + onDidChangeTextEditorViewColumn: emptyEvent(), + onDidChangeVisibleNotebookEditors: emptyEvent(), + onDidChangeWindowState: emptyEvent(), + onDidCloseTerminal: emptyEvent(), + onDidOpenTerminal: emptyEvent(), + registerUriHandler: () => emptyDisposable, + registerWebviewViewProvider: () => emptyDisposable, + createStatusBarItem: (() => statusBarItem) as any, + visibleTextEditors, + onDidChangeActiveTextEditor: onDidChangeActiveTextEditor.event, + onDidChangeVisibleTextEditors: (() => ({})) as any, + onDidChangeTextEditorSelection: (() => ({})) as any, + showErrorMessage: ((message: string, ...items: string[]) => {}) as any, + showWarningMessage: ((message: string, ...items: string[]) => {}) as any, + showInformationMessage: ((message: string, ...items: string[]) => {}) as any, + createOutputChannel: ((name: string) => + ({ + name, + append: () => {}, + appendLine: () => {}, + replace: () => {}, + clear: () => {}, + show: () => {}, + hide: () => {}, + dispose: () => {}, + }) as vscode.OutputChannel) as any, + createTextEditorDecorationType: () => ({ key: 'foo', dispose: () => {} }), +} + +export const window = _window as typeof vscode.window + +const _extensions: Partial = { + getExtension: (extensionId: string) => undefined, +} +export const extensions = _extensions as typeof vscode.extensions + +interface RegisteredCommand { + command: string + callback: (...args: any[]) => any + thisArg?: any +} +const registeredCommands = new Map() + +const _commands: Partial = { + registerCommand: (command: string, callback: (...args: any[]) => any, thisArg?: any) => { + const value: RegisteredCommand = { command, callback, thisArg } + registeredCommands.set(command, value) + return new Disposable(() => { + const registered = registeredCommands.get(command) + if (registered === value) { + registeredCommands.delete(command) + } + }) + }, + executeCommand: (command, args) => { + const registered = registeredCommands.get(command) + if (registered) { + try { + if (args) { + return registered.callback(...args) + } + return registered.callback() + } catch (error) { + console.error(error) + } + } + }, +} +export const commands = _commands as typeof vscode.commands + +const _env: Partial = { + uriScheme: 'file', + appRoot: process.cwd(), + uiKind: UIKind.Web, +} +export const env = _env as typeof vscode.env + +let resolveCompletionProvider: (provider: InlineCompletionItemProvider) => void = () => {} +export let completionProvider: Promise = new Promise(resolve => { + resolveCompletionProvider = resolve +}) + +const _languages: Partial = { + registerCodeActionsProvider: () => emptyDisposable, + registerCodeLensProvider: () => emptyDisposable, + registerInlineCompletionItemProvider: (_selector, provider) => { + resolveCompletionProvider(provider as any) + completionProvider = Promise.resolve(provider as any) + return emptyDisposable + }, +} +export const languages = _languages as typeof vscode.languages + +const commentController: vscode.CommentController = { + createCommentThread(uri, range, comments) { + return 'createCommentThread' as any + }, + id: 'commentController.id', + label: 'commentController.label', + dispose: () => {}, +} +const _comments: Partial = { + createCommentController: () => commentController, +} +export const comments = _comments as typeof vscode.comments diff --git a/agent/tsconfig.json b/agent/tsconfig.json index 8b1d0832754d..297b10d19353 100644 --- a/agent/tsconfig.json +++ b/agent/tsconfig.json @@ -4,8 +4,13 @@ "module": "commonjs", "rootDir": ".", "outDir": "dist", + "target": "es2019", + "allowJs": true, + // "paths": { + // "vscode": ["./src/vscode-shim.ts"], + // }, }, "include": ["**/*", ".*", "package.json"], "exclude": ["dist"], - "references": [{ "path": "../lib/shared" }], + "references": [{ "path": "../lib/shared" }, { "path": "../vscode" }], } diff --git a/agent/vitest.config.ts b/agent/vitest.config.ts index e87135b6b9cf..4259798f0929 100644 --- a/agent/vitest.config.ts +++ b/agent/vitest.config.ts @@ -1,8 +1,31 @@ /// +import { statSync } from 'fs' +import path from 'path' + import { defineConfig } from 'vite' +const shimFromAgentDirectory = path.resolve(process.cwd(), 'src', 'vscode-shim') +const shimFromRootDirectory = path.resolve(process.cwd(), 'agent', 'src', 'vscode-shim') + +// Returns the absolute path to the vscode-shim.ts file depending on whether +// we're running tests from the root directory of the cody repo or from the +// agent/ subdirectory. +function shimDirectory(): string { + console.log({ shimFromRootDirectory }) + try { + if (statSync(shimFromRootDirectory + '.ts').isFile()) { + return shimFromRootDirectory + } + // eslint-disable-next-line no-empty + } catch {} + return shimFromAgentDirectory +} + export default defineConfig({ logLevel: 'warn', test: {}, + resolve: { + alias: { vscode: shimDirectory() }, + }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 079ad18d5878..c79d98b1e941 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,15 @@ importers: specifier: ^3.0.7 version: 3.0.7 devDependencies: + '@types/vscode': + specifier: ^1.80.0 + version: 1.80.0 + esbuild: + specifier: ^0.18.19 + version: 0.18.19 + esbuild-plugin-alias-path: + specifier: ^2.0.2 + version: 2.0.2(esbuild@0.18.19) pkg: specifier: ^5.8.1 version: 5.8.1 @@ -2928,6 +2937,15 @@ packages: cpu: [arm64] os: [android] requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.18.19: + resolution: {integrity: sha512-4+jkUFQxZkQfQOOxfGVZB38YUWHMJX2ihZwF+2nh8m7bHdWXpixiurgGRN3c/KMSwlltbYI0/i929jwBRMFzbA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true optional: true /@esbuild/android-arm@0.17.14: @@ -2953,6 +2971,15 @@ packages: cpu: [arm] os: [android] requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.18.19: + resolution: {integrity: sha512-1uOoDurJYh5MNqPqpj3l/TQCI1V25BXgChEldCB7D6iryBYqYKrbZIhYO5AI9fulf66sM8UJpc3UcCly2Tv28w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true optional: true /@esbuild/android-x64@0.17.14: @@ -2978,6 +3005,15 @@ packages: cpu: [x64] os: [android] requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.18.19: + resolution: {integrity: sha512-ae5sHYiP/Ogj2YNrLZbWkBmyHIDOhPgpkGvFnke7XFGQldBDWvc/AyYwSLpNuKw9UNkgnLlB/jPpnBmlF3G9Bg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true optional: true /@esbuild/darwin-arm64@0.17.14: @@ -3003,6 +3039,15 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.18.19: + resolution: {integrity: sha512-HIpQvNQWFYROmWDANMRL+jZvvTQGOiTuwWBIuAsMaQrnStedM+nEKJBzKQ6bfT9RFKH2wZ+ej+DY7+9xHBTFPg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true optional: true /@esbuild/darwin-x64@0.17.14: @@ -3028,6 +3073,15 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.18.19: + resolution: {integrity: sha512-m6JdvXJQt0thNLIcWOeG079h2ivhYH4B5sVCgqb/B29zTcFd7EE8/J1nIUHhdtwGeItdUeqKaqqb4towwxvglQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true optional: true /@esbuild/freebsd-arm64@0.17.14: @@ -3053,6 +3107,15 @@ packages: cpu: [arm64] os: [freebsd] requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.18.19: + resolution: {integrity: sha512-G0p4EFMPZhGn/xVNspUyMQbORH3nlKTV0bFNHPIwLraBuAkTeMyxNviTe0ZXUbIXQrR1lrwniFjNFU4s+x7veQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true optional: true /@esbuild/freebsd-x64@0.17.14: @@ -3078,6 +3141,15 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.18.19: + resolution: {integrity: sha512-hBxgRlG42+W+j/1/cvlnSa+3+OBKeDCyO7OG2ICya1YJaSCYfSpuG30KfOnQHI7Ytgu4bRqCgrYXxQEzy0zM5Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true optional: true /@esbuild/linux-arm64@0.17.14: @@ -3103,6 +3175,15 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.18.19: + resolution: {integrity: sha512-X8g33tczY0GsJq3lhyBrjnFtaKjWVpp1gMq5IlF9BQJ3TUfSK74nQnz9mRIEejmcV+OIYn6bkOJeUaU1Knrljg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-arm@0.17.14: @@ -3128,6 +3209,15 @@ packages: cpu: [arm] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.18.19: + resolution: {integrity: sha512-qtWyoQskfJlb9MD45mvzCEKeO4uCnDZ7lPFeNqbfaaJHqBiH9qA5Vu2EuckqYZuFMJWy1l4dxTf9NOulCVfUjg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-ia32@0.17.14: @@ -3153,6 +3243,15 @@ packages: cpu: [ia32] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.18.19: + resolution: {integrity: sha512-SAkRWJgb+KN+gOhmbiE6/wu23D6HRcGQi15cB13IVtBZZgXxygTV5GJlUAKLQ5Gcx0gtlmt+XIxEmSqA6sZTOw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-loong64@0.17.14: @@ -3178,6 +3277,15 @@ packages: cpu: [loong64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.18.19: + resolution: {integrity: sha512-YLAslaO8NsB9UOxBchos82AOMRDbIAWChwDKfjlGrHSzS3v1kxce7dGlSTsrb0PJwo1KYccypN3VNjQVLtz7LA==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-mips64el@0.17.14: @@ -3203,6 +3311,15 @@ packages: cpu: [mips64el] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.18.19: + resolution: {integrity: sha512-vSYFtlYds/oTI8aflEP65xo3MXChMwBOG1eWPGGKs/ev9zkTeXVvciU+nifq8J1JYMz+eQ4J9JDN0O2RKF8+1Q==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-ppc64@0.17.14: @@ -3228,6 +3345,15 @@ packages: cpu: [ppc64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.18.19: + resolution: {integrity: sha512-tgG41lRVwlzqO9tv9l7aXYVw35BxKXLtPam1qALScwSqPivI8hjkZLNH0deaaSCYCFT9cBIdB+hUjWFlFFLL9A==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-riscv64@0.17.14: @@ -3253,6 +3379,15 @@ packages: cpu: [riscv64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.18.19: + resolution: {integrity: sha512-EgBZFLoN1S5RuB4cCJI31pBPsjE1nZ+3+fHRjguq9Ibrzo29bOLSBcH1KZJvRNh5qtd+fcYIGiIUia8Jw5r1lQ==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-s390x@0.17.14: @@ -3278,6 +3413,15 @@ packages: cpu: [s390x] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.18.19: + resolution: {integrity: sha512-q1V1rtHRojAzjSigZEqrcLkpfh5K09ShCoIsdTakozVBnM5rgV58PLFticqDp5UJ9uE0HScov9QNbbl8HBo6QQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-x64@0.17.14: @@ -3303,6 +3447,15 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.18.19: + resolution: {integrity: sha512-D0IiYjpZRXxGZLQfsydeAD7ZWqdGyFLBj5f2UshJpy09WPs3qizDCsEr8zyzcym6Woj/UI9ZzMIXwvoXVtyt0A==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true optional: true /@esbuild/netbsd-x64@0.17.14: @@ -3328,6 +3481,15 @@ packages: cpu: [x64] os: [netbsd] requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.18.19: + resolution: {integrity: sha512-3tt3SOS8L3D54R8oER41UdDshlBIAjYhdWRPiZCTZ1E41+shIZBpTjaW5UaN/jD1ENE/Ok5lkeqhoNMbxstyxw==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true optional: true /@esbuild/openbsd-x64@0.17.14: @@ -3353,6 +3515,15 @@ packages: cpu: [x64] os: [openbsd] requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.18.19: + resolution: {integrity: sha512-MxbhcuAYQPlfln1EMc4T26OUoeg/YQc6wNoEV8xvktDKZhLtBxjkoeESSo9BbPaGKhAPzusXYj5n8n5A8iZSrA==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true optional: true /@esbuild/sunos-x64@0.17.14: @@ -3378,6 +3549,15 @@ packages: cpu: [x64] os: [sunos] requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.18.19: + resolution: {integrity: sha512-m0/UOq1wj25JpWqOJxoWBRM9VWc3c32xiNzd+ERlYstUZ6uwx5SZsQUtkiFHaYmcaoj+f6+Tfcl7atuAz3idwQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true optional: true /@esbuild/win32-arm64@0.17.14: @@ -3403,6 +3583,15 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.18.19: + resolution: {integrity: sha512-L4vb6pcoB1cEcXUHU6EPnUhUc4+/tcz4OqlXTWPcSQWxegfmcOprhmIleKKwmMNQVc4wrx/+jB7tGkjjDmiupg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true optional: true /@esbuild/win32-ia32@0.17.14: @@ -3428,6 +3617,15 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.18.19: + resolution: {integrity: sha512-rQng7LXSKdrDlNDb7/v0fujob6X0GAazoK/IPd9C3oShr642ri8uIBkgM37/l8B3Rd5sBQcqUXoDdEy75XC/jg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true optional: true /@esbuild/win32-x64@0.17.14: @@ -3453,6 +3651,15 @@ packages: cpu: [x64] os: [win32] requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.18.19: + resolution: {integrity: sha512-z69jhyG20Gq4QL5JKPLqUT+eREuqnDAFItLbza4JCmpvUnIlY73YNjd5djlO7kBiiZnvTnJuAbOjIoZIOa1GjA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true optional: true /@eslint/eslintrc@1.4.1: @@ -4523,7 +4730,7 @@ packages: '@storybook/react-dom-shim': 7.0.26(react-dom@18.2.0)(react@18.2.0) '@storybook/theming': 7.0.26(react-dom@18.2.0)(react@18.2.0) '@storybook/types': 7.0.26 - fs-extra: 11.1.0 + fs-extra: 11.1.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) remark-external-links: 8.0.0 @@ -4801,7 +5008,7 @@ packages: browser-assert: 1.2.1 es-module-lexer: 0.9.3 express: 4.18.2 - fs-extra: 11.1.0 + fs-extra: 11.1.1 glob: 8.1.0 glob-promise: 6.0.3(glob@8.1.0) magic-string: 0.27.0 @@ -4952,7 +5159,7 @@ packages: esbuild-register: 3.4.2(esbuild@0.17.14) file-system-cache: 2.3.0 find-up: 5.0.0 - fs-extra: 11.1.0 + fs-extra: 11.1.1 glob: 8.1.0 glob-promise: 6.0.3(glob@8.1.0) handlebars: 4.7.7 @@ -8401,6 +8608,17 @@ packages: resolution: {integrity: sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw==} dev: true + /esbuild-plugin-alias-path@2.0.2(esbuild@0.18.19): + resolution: {integrity: sha512-YK8H9bzx6/CG6YBV11XjoNLjRhNZP0Ta4xZ3ATHhPn7pN8ljQGg+zne4d47DpIzF8/sX2qM+xQWev0CvaD2rSQ==} + peerDependencies: + esbuild: '>= 0.14.0' + dependencies: + esbuild: 0.18.19 + find-up: 5.0.0 + fs-extra: 10.1.0 + jsonfile: 6.1.0 + dev: true + /esbuild-plugin-alias@0.2.1: resolution: {integrity: sha512-jyfL/pwPqaFXyKnj8lP8iLk6Z0m099uXR45aSN8Av1XD4vhvQutxxPzgA2bTcAwQpa1zCXDcWOlhFgyP3GKqhQ==} dev: true @@ -8512,6 +8730,36 @@ packages: '@esbuild/win32-arm64': 0.18.11 '@esbuild/win32-ia32': 0.18.11 '@esbuild/win32-x64': 0.18.11 + dev: true + + /esbuild@0.18.19: + resolution: {integrity: sha512-ra3CaIKCzJp5bU5BDfrCc0FRqKj71fQi+gbld0aj6lN0ifuX2fWJYPgLVLGwPfA+ruKna+OWwOvf/yHj6n+i0g==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.18.19 + '@esbuild/android-arm64': 0.18.19 + '@esbuild/android-x64': 0.18.19 + '@esbuild/darwin-arm64': 0.18.19 + '@esbuild/darwin-x64': 0.18.19 + '@esbuild/freebsd-arm64': 0.18.19 + '@esbuild/freebsd-x64': 0.18.19 + '@esbuild/linux-arm': 0.18.19 + '@esbuild/linux-arm64': 0.18.19 + '@esbuild/linux-ia32': 0.18.19 + '@esbuild/linux-loong64': 0.18.19 + '@esbuild/linux-mips64el': 0.18.19 + '@esbuild/linux-ppc64': 0.18.19 + '@esbuild/linux-riscv64': 0.18.19 + '@esbuild/linux-s390x': 0.18.19 + '@esbuild/linux-x64': 0.18.19 + '@esbuild/netbsd-x64': 0.18.19 + '@esbuild/openbsd-x64': 0.18.19 + '@esbuild/sunos-x64': 0.18.19 + '@esbuild/win32-arm64': 0.18.19 + '@esbuild/win32-ia32': 0.18.19 + '@esbuild/win32-x64': 0.18.19 /escalade@3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} @@ -9491,6 +9739,7 @@ packages: graceful-fs: 4.2.11 jsonfile: 6.0.1 universalify: 2.0.0 + dev: false /fs-extra@11.1.1: resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==} @@ -11048,6 +11297,14 @@ packages: optionalDependencies: graceful-fs: 4.2.11 + /jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + dependencies: + universalify: 2.0.0 + optionalDependencies: + graceful-fs: 4.2.11 + dev: true + /jsonpointer@5.0.1: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} @@ -16288,7 +16545,7 @@ packages: optional: true dependencies: '@types/node': 20.4.0 - esbuild: 0.18.11 + esbuild: 0.18.19 postcss: 8.4.25 rollup: 3.26.2 optionalDependencies: diff --git a/vscode/src/completions/vscodeInlineCompletionItemProvider.ts b/vscode/src/completions/vscodeInlineCompletionItemProvider.ts index 1ca3ab06768b..a808937dfa3b 100644 --- a/vscode/src/completions/vscodeInlineCompletionItemProvider.ts +++ b/vscode/src/completions/vscodeInlineCompletionItemProvider.ts @@ -21,7 +21,7 @@ import { ProvideInlineCompletionItemsTracer, ProvideInlineCompletionsItemTraceDa import { InlineCompletionItem } from './types' import { getNextNonEmptyLine } from './utils/text-utils' -interface CodyCompletionItemProviderConfig { +export interface CodyCompletionItemProviderConfig { providerConfig: ProviderConfig history: DocumentHistory statusBar: CodyStatusBar diff --git a/vscode/src/testutils/mocks.ts b/vscode/src/testutils/mocks.ts index ccf6e2006e06..c3584a0d638f 100644 --- a/vscode/src/testutils/mocks.ts +++ b/vscode/src/testutils/mocks.ts @@ -1,10 +1,101 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +/* eslint-disable import/no-duplicates */ /* eslint-disable @typescript-eslint/no-empty-function */ // TODO: use implements vscode.XXX on mocked classes to ensure they match the real vscode API. import type { + Disposable as VSCodeDisposable, InlineCompletionTriggerKind as VSCodeInlineCompletionTriggerKind, Position as VSCodePosition, Range as VSCodeRange, } from 'vscode' +import type * as vscode_types from 'vscode' +import { URI } from 'vscode-uri' + +export class Uri { + public static parse(value: string, strict?: boolean): URI { + return Uri.from(URI.parse(value, strict).toJSON()) + } + public static file(path: string): URI { + return Uri.from(URI.file(path).toJSON()) + } + + public static joinPath(base: Uri, ...pathSegments: string[]): Uri { + return base.with({ path: [base.path, ...pathSegments].join('/') }) + } + + public static from(components: { + readonly scheme: string + readonly authority?: string + readonly path?: string + readonly query?: string + readonly fragment?: string + }): Uri { + const uri = URI.from(components) + return new Uri(uri.scheme, uri.authority, uri.path, uri.query, uri.fragment) + } + + private uri: URI + + private constructor( + public readonly scheme: string, + public readonly authority: string, + public readonly path: string, + public readonly query: string, + public readonly fragment: string + ) { + this.uri = URI.from({ scheme, authority, path, query, fragment }) + this.fsPath = path // TODO + } + + public readonly fsPath: string + + public with(change: { + scheme?: string + authority?: string + path?: string + query?: string + fragment?: string + }): Uri { + return Uri.from({ + scheme: change.scheme || this.scheme, + authority: change.authority || this.authority, + path: change.path || this.path, + query: change.query || this.query, + fragment: change.fragment || this.fragment, + }) + } + + public toString(skipEncoding?: boolean): string { + return this.uri.toString(skipEncoding) + } + + public toJSON(): any { + return { + scheme: this.scheme, + authority: this.authority, + path: this.path, + query: this.query, + fragment: this.fragment, + } + } +} + +export class Disposable implements VSCodeDisposable { + public static from(...disposableLikes: { dispose: () => any }[]): Disposable { + return new Disposable(() => { + for (const disposable of disposableLikes) { + disposable.dispose() + } + }) + } + constructor(private readonly callOnDispose: () => any) {} + public dispose(): void { + this.callOnDispose() + } +} /** * This module defines shared VSCode mocks for use in every Vitest test. @@ -12,12 +103,192 @@ import type { * This is made possible via the `setupFiles` property in the Vitest configuration. */ -enum InlineCompletionTriggerKind { +export enum InlineCompletionTriggerKind { Invoke = 0 satisfies VSCodeInlineCompletionTriggerKind.Invoke, Automatic = 1 satisfies VSCodeInlineCompletionTriggerKind.Automatic, } -class Position implements VSCodePosition { +export enum QuickPickItemKind { + Separator = -1, + Default = 0, +} + +export enum ConfigurationTarget { + Global = 1, + Workspace = 2, + WorkspaceFolder = 3, +} + +export enum StatusBarAlignment { + Left = 1, + Right = 2, +} + +export enum CommentThreadCollapsibleState { + Collapsed = 0, + Expanded = 1, +} + +export enum OverviewRulerLane { + Left = 1, + Center = 2, + Right = 4, + Full = 7, +} + +export class CodeLens { + public readonly isResolved = true + constructor( + public readonly range: Range, + public readonly command?: vscode_types.Command + ) {} +} +export class ThemeColor { + constructor(public readonly id: string) {} +} + +export class ThemeIcon { + static readonly File = new ThemeIcon('file') + static readonly Folder = new ThemeIcon('folder') + constructor( + public readonly id: string, + public readonly color?: ThemeColor + ) {} +} + +export class MarkdownString implements vscode_types.MarkdownString { + constructor(public readonly value: string) {} + isTrusted?: boolean | { readonly enabledCommands: readonly string[] } | undefined + supportThemeIcons?: boolean | undefined + supportHtml?: boolean | undefined + baseUri?: vscode_types.Uri | undefined + appendText(): vscode_types.MarkdownString { + throw new Error('Method not implemented.') + } + appendMarkdown(): vscode_types.MarkdownString { + throw new Error('Method not implemented.') + } + appendCodeblock(): vscode_types.MarkdownString { + throw new Error('Method not implemented.') + } +} + +export enum CommentMode { + Editing = 0, + Preview = 1, +} + +export enum TreeItemCollapsibleState { + None = 0, + Collapsed = 1, + Expanded = 2, +} +export enum ExtensionMode { + Production = 1, + Development = 2, + Test = 3, +} +export enum DiagnosticSeverity { + Error = 0, + Warning = 1, + Information = 2, + Hint = 3, +} +export enum SymbolKind { + File = 0, + Module = 1, + Namespace = 2, + Package = 3, + Class = 4, + Method = 5, + Property = 6, + Field = 7, + Constructor = 8, + Enum = 9, + Interface = 10, + Function = 11, + Variable = 12, + Constant = 13, + String = 14, + Number = 15, + Boolean = 16, + Array = 17, + Object = 18, + Key = 19, + Null = 20, + EnumMember = 21, + Struct = 22, + Event = 23, + Operator = 24, + TypeParameter = 25, +} +export enum ViewColumn { + Active = -1, + Beside = -2, + One = 1, + Two = 2, + Three = 3, + Four = 4, + Five = 5, + Six = 6, + Seven = 7, + Eight = 8, + Nine = 9, +} +export class CodeAction { + edit?: WorkspaceEdit + diagnostics?: vscode_types.Diagnostic[] + command?: vscode_types.Command + isPreferred?: boolean + disabled?: { + readonly reason: string + } + constructor( + public readonly title: string, + public readonly kind?: vscode_types.CodeActionKind + ) {} +} +export class CodeActionKind { + static readonly Empty = new CodeActionKind('Empty') + static readonly QuickFix = new CodeActionKind('') + static readonly Refactor = new CodeActionKind('') + static readonly RefactorExtract = new CodeActionKind('') + static readonly RefactorInline = new CodeActionKind('') + static readonly RefactorMove = new CodeActionKind('') + static readonly RefactorRewrite = new CodeActionKind('') + static readonly Source = new CodeActionKind('') + static readonly SourceOrganizeImports = new CodeActionKind('') + + static readonly SourceFixAll = new CodeActionKind('') + + constructor(public readonly value: string) {} +} + +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class QuickInputButtons { + public static readonly Back: vscode_types.QuickInputButton = { iconPath: Uri.parse('file://foobar') } +} + +export class TreeItem { + constructor( + public readonly resourceUri: Uri, + public readonly collapsibleState?: TreeItemCollapsibleState + ) {} +} + +export class RelativePattern implements vscode_types.RelativePattern { + public baseUri = Uri.parse('file:///foobar') + public base: string + constructor( + _base: vscode_types.WorkspaceFolder | Uri | string, + public readonly pattern: string + ) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + this.base = _base.toString() + } +} + +export class Position implements VSCodePosition { public line: number public character: number @@ -65,7 +336,7 @@ class Position implements VSCodePosition { } } -class Range implements VSCodeRange { +export class Range implements VSCodeRange { public start: Position public end: Position @@ -133,16 +404,31 @@ class Range implements VSCodeRange { } } -class Uri { - public fsPath: string - public path: string - constructor(path: string) { - this.fsPath = path - this.path = path +export class Selection extends Range { + constructor( + public readonly anchor: Position, + public readonly active: Position + ) { + super(anchor, active) } + + /** + * Create a selection from four coordinates. + * + * @param anchorLine A zero-based line value. + * @param anchorCharacter A zero-based character value. + * @param activeLine A zero-based line value. + * @param activeCharacter A zero-based character value. + */ + // constructor(anchorLine: number, anchorCharacter: number, activeLine: number, activeCharacter: number) {} + + /** + * A selection is reversed if its {@link Selection.anchor anchor} is the {@link Selection.end end} position. + */ + isReversed = false } -class InlineCompletionItem { +export class InlineCompletionItem { public insertText: string public range: Range | undefined constructor(content: string, range?: Range) { @@ -152,7 +438,7 @@ class InlineCompletionItem { } // TODO(abeatrix): Implement delete and insert mocks -class WorkspaceEdit { +export class WorkspaceEdit { public delete(uri: Uri, range: Range): Range { return range } @@ -161,26 +447,54 @@ class WorkspaceEdit { } } -class EventEmitter { - public on: () => undefined +interface Callback { + handler: (arg?: any) => any + thisArg?: any +} +function invokeCallback(callback: Callback, arg?: any): any { + return callback.thisArg ? callback.handler.bind(callback.thisArg)(arg) : callback.handler(arg) +} +export const emptyDisposable = new Disposable(() => {}) + +export class EventEmitter implements vscode_types.EventEmitter { + public on = (): undefined => undefined constructor() { this.on = () => undefined } + + private readonly listeners = new Set() + event: vscode_types.Event = (listener, thisArgs) => { + const value: Callback = { handler: listener, thisArg: thisArgs } + this.listeners.add(value) + return new Disposable(() => { + this.listeners.delete(value) + }) + } + + fire(data: T): void { + for (const listener of this.listeners) { + invokeCallback(listener, data) + } + } + dispose(): void { + // throw new Error('Method not implemented.') + } } -enum EndOfLine { - /** - * The line feed `\n` character. - */ +export enum EndOfLine { LF = 1, - /** - * The carriage return line feed `\r\n` sequence. - */ CRLF = 2, } -class CancellationTokenSource { +export enum FileType { + Unknown = 0, + File = 1, + Directory = 2, + SymbolicLink = 64, +} + +export class CancellationTokenSource { public token: unknown constructor() { @@ -248,3 +562,12 @@ export const vsCodeMocks = { }, InlineCompletionTriggerKind, } as const + +export enum UIKind { + Desktop = 1, + Web = 2, +} + +export function emptyEvent(): vscode_types.Event { + return () => emptyDisposable +}