packages/core/src/amazonq/lsp/lspClient.ts (315 lines of code) (raw):
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*/
import * as vscode from 'vscode'
import { oneMB } from '../../shared/utilities/processUtils'
import * as path from 'path'
import * as nls from 'vscode-nls'
import * as crypto from 'crypto'
import * as jose from 'jose'
import { Disposable, ExtensionContext } from 'vscode'
import { LanguageClient, LanguageClientOptions } from 'vscode-languageclient'
import {
BuildIndexRequestPayload,
BuildIndexRequestType,
GetUsageRequestType,
IndexConfig,
QueryInlineProjectContextRequestType,
QueryVectorIndexRequestType,
UpdateIndexV2RequestPayload,
UpdateIndexV2RequestType,
QueryRepomapIndexRequestType,
GetRepomapIndexJSONRequestType,
Usage,
GetContextCommandItemsRequestType,
ContextCommandItem,
GetIndexSequenceNumberRequestType,
GetContextCommandPromptRequestType,
AdditionalContextPrompt,
} from './types'
import { CodeWhispererSettings } from '../../codewhisperer/util/codewhispererSettings'
import { fs } from '../../shared/fs/fs'
import { getLogger } from '../../shared/logger/logger'
import globals from '../../shared/extensionGlobals'
import { ResourcePaths } from '../../shared/lsp/types'
import { createServerOptions, validateNodeExe } from '../../shared/lsp/utils/platform'
import { waitUntil } from '../../shared/utilities/timeoutUtils'
const localize = nls.loadMessageBundle()
const key = crypto.randomBytes(32)
const logger = getLogger('amazonqWorkspaceLsp')
/**
* LspClient manages the API call between VS Code extension and LSP server
* It encryptes the payload of API call.
*/
export class LspClient {
static #instance: LspClient
client: LanguageClient | undefined
public static get instance() {
return (this.#instance ??= new this())
}
constructor() {
this.client = undefined
}
async encrypt(payload: string) {
return await new jose.CompactEncrypt(new TextEncoder().encode(payload))
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.encrypt(key)
}
async buildIndex(paths: string[], rootPath: string, config: IndexConfig) {
const payload: BuildIndexRequestPayload = {
filePaths: paths,
projectRoot: rootPath,
config: config,
language: '',
}
try {
const encryptedRequest = await this.encrypt(JSON.stringify(payload))
const resp = await this.client?.sendRequest(BuildIndexRequestType, encryptedRequest)
return resp
} catch (e) {
logger.error(`buildIndex error: ${e}`)
return undefined
}
}
async queryVectorIndex(request: string) {
try {
const encryptedRequest = await this.encrypt(
JSON.stringify({
query: request,
})
)
const resp = await this.client?.sendRequest(QueryVectorIndexRequestType, encryptedRequest)
return resp
} catch (e) {
logger.error(`queryVectorIndex error: ${e}`)
return []
}
}
async queryInlineProjectContext(query: string, path: string, target: 'default' | 'codemap' | 'bm25') {
try {
const request = JSON.stringify({
query: query,
filePath: path,
target,
})
const encrypted = await this.encrypt(request)
const resp: any = await this.client?.sendRequest(QueryInlineProjectContextRequestType, encrypted)
return resp
} catch (e) {
logger.error(`queryInlineProjectContext error: ${e}`)
throw e
}
}
async getLspServerUsage(): Promise<Usage | undefined> {
if (this.client) {
return (await this.client.sendRequest(GetUsageRequestType, '')) as Usage
}
}
async updateIndex(filePath: string[], mode: 'update' | 'remove' | 'add' | 'context_command_symbol_update') {
const payload: UpdateIndexV2RequestPayload = {
filePaths: filePath,
updateMode: mode,
}
try {
const encryptedRequest = await this.encrypt(JSON.stringify(payload))
const resp = await this.client?.sendRequest(UpdateIndexV2RequestType, encryptedRequest)
return resp
} catch (e) {
logger.error(`updateIndex error: ${e}`)
return undefined
}
}
async queryRepomapIndex(filePaths: string[]) {
try {
const request = JSON.stringify({
filePaths: filePaths,
})
const resp: any = await this.client?.sendRequest(QueryRepomapIndexRequestType, await this.encrypt(request))
return resp
} catch (e) {
logger.error(`QueryRepomapIndex error: ${e}`)
throw e
}
}
async getRepoMapJSON() {
try {
const request = JSON.stringify({})
const resp: any = await this.client?.sendRequest(
GetRepomapIndexJSONRequestType,
await this.encrypt(request)
)
return resp
} catch (e) {
logger.error(`queryInlineProjectContext error: ${e}`)
throw e
}
}
async getContextCommandItems(): Promise<ContextCommandItem[]> {
try {
const workspaceFolders = vscode.workspace.workspaceFolders || []
const request = JSON.stringify({
workspaceFolders: workspaceFolders.map((it) => it.uri.fsPath),
})
const resp: any = await this.client?.sendRequest(
GetContextCommandItemsRequestType,
await this.encrypt(request)
)
return resp
} catch (e) {
logger.error(`getContextCommandItems error: ${e}`)
throw e
}
}
async getContextCommandPrompt(contextCommandItems: ContextCommandItem[]): Promise<AdditionalContextPrompt[]> {
try {
const request = JSON.stringify({
contextCommands: contextCommandItems,
})
const resp: any = await this.client?.sendRequest(
GetContextCommandPromptRequestType,
await this.encrypt(request)
)
return resp || []
} catch (e) {
logger.error(`getContextCommandPrompt error: ${e}`)
throw e
}
}
async getIndexSequenceNumber(): Promise<number> {
try {
const request = JSON.stringify({})
const resp: any = await this.client?.sendRequest(
GetIndexSequenceNumberRequestType,
await this.encrypt(request)
)
return resp
} catch (e) {
logger.error(`getIndexSequenceNumber error: ${e}`)
throw e
}
}
async waitUntilReady() {
return waitUntil(
async () => {
if (this.client === undefined) {
return false
}
await this.client.onReady()
return true
},
{ interval: 500, timeout: 60_000 * 3, truthy: true }
)
}
}
/**
* Activates the language server (assumes the LSP server has already been downloaded):
* 1. start LSP server running over IPC protocol.
* 2. create a output channel named Amazon Q Language Server.
*/
export async function activate(extensionContext: ExtensionContext, resourcePaths: ResourcePaths) {
LspClient.instance // Tickle the singleton... :/
const toDispose = extensionContext.subscriptions
let rangeFormatting: Disposable | undefined
// The debug options for the server
// --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging
const debugOptions = { execArgv: ['--nolazy', '--preserve-symlinks', '--stdio'] }
const workerThreads = CodeWhispererSettings.instance.getIndexWorkerThreads()
const gpu = CodeWhispererSettings.instance.isLocalIndexGPUEnabled()
if (gpu) {
process.env.Q_ENABLE_GPU = 'true'
} else {
delete process.env.Q_ENABLE_GPU
}
if (workerThreads > 0 && workerThreads < 100) {
process.env.Q_WORKER_THREADS = workerThreads.toString()
} else {
delete process.env.Q_WORKER_THREADS
}
const serverModule = resourcePaths.lsp
const memoryWarnThreshold = 800 * oneMB
const serverOptions = createServerOptions({
encryptionKey: key,
executable: [resourcePaths.node],
serverModule,
// TODO(jmkeyes): we always use the debug options...?
execArgv: debugOptions.execArgv,
warnThresholds: { memory: memoryWarnThreshold },
})
const documentSelector = [{ scheme: 'file', language: '*' }]
await validateNodeExe([resourcePaths.node], resourcePaths.lsp, debugOptions.execArgv, logger)
// Options to control the language client
const clientOptions: LanguageClientOptions = {
// Register the server for json documents
documentSelector,
initializationOptions: {
handledSchemaProtocols: ['file', 'untitled'], // language server only loads file-URI. Fetching schemas with other protocols ('http'...) are made on the client.
provideFormatter: false, // tell the server to not provide formatting capability and ignore the `aws.stepfunctions.asl.format.enable` setting.
// this is used by LSP to determine index cache path, move to this folder so that when extension updates index is not deleted.
extensionPath: path.join(fs.getUserHomeDir(), '.aws', 'amazonq', 'cache'),
},
// Log to the Amazon Q Logs so everything is in a single channel
// TODO: Add prefix to the language server logs so it is easier to search
outputChannel: globals.logOutputChannel,
}
// Create the language client and start the client.
LspClient.instance.client = new LanguageClient(
'amazonq',
localize('amazonq.server.name', 'Amazon Q Language Server'),
serverOptions,
clientOptions
)
LspClient.instance.client.registerProposedFeatures()
const disposable = LspClient.instance.client.start()
toDispose.push(disposable)
let savedDocument: vscode.Uri | undefined = undefined
const onAdd = async (filePaths: string[]) => {
const indexSeqNum = await LspClient.instance.getIndexSequenceNumber()
await LspClient.instance.updateIndex(filePaths, 'add')
await waitUntil(
async () => {
const newIndexSeqNum = await LspClient.instance.getIndexSequenceNumber()
if (newIndexSeqNum > indexSeqNum) {
await vscode.commands.executeCommand(`aws.amazonq.updateContextCommandItems`)
return true
}
return false
},
{ interval: 500, timeout: 5_000, truthy: true }
)
}
const onRemove = async (filePaths: string[]) => {
const indexSeqNum = await LspClient.instance.getIndexSequenceNumber()
await LspClient.instance.updateIndex(filePaths, 'remove')
await waitUntil(
async () => {
const newIndexSeqNum = await LspClient.instance.getIndexSequenceNumber()
if (newIndexSeqNum > indexSeqNum) {
await vscode.commands.executeCommand(`aws.amazonq.updateContextCommandItems`)
return true
}
return false
},
{ interval: 500, timeout: 5_000, truthy: true }
)
}
toDispose.push(
vscode.workspace.onDidSaveTextDocument((document) => {
if (document.uri.scheme !== 'file') {
return
}
savedDocument = document.uri
}),
vscode.window.onDidChangeActiveTextEditor((editor) => {
if (savedDocument && editor && editor.document.uri.fsPath !== savedDocument.fsPath) {
void LspClient.instance.updateIndex([savedDocument.fsPath], 'update')
}
// user created a new empty file using File -> New File
// these events will not be captured by vscode.workspace.onDidCreateFiles
// because it was created by File Explorer(Win) or Finder(MacOS)
// TODO: consider using a high performance fs watcher
if (editor?.document.getText().length === 0) {
void onAdd([editor.document.uri.fsPath])
}
}),
vscode.workspace.onDidCreateFiles(async (e) => {
await onAdd(e.files.map((f) => f.fsPath))
}),
vscode.workspace.onDidDeleteFiles(async (e) => {
await onRemove(e.files.map((f) => f.fsPath))
}),
vscode.workspace.onDidRenameFiles(async (e) => {
await onRemove(e.files.map((f) => f.oldUri.fsPath))
await onAdd(e.files.map((f) => f.newUri.fsPath))
})
)
return LspClient.instance.client.onReady().then(
() => {
const disposableFunc = { dispose: () => rangeFormatting?.dispose() as void }
toDispose.push(disposableFunc)
},
(reason) => {
logger.error('client.onReady() failed: %O', reason)
}
)
}
export async function deactivate(): Promise<any> {
if (!LspClient.instance.client) {
return undefined
}
return LspClient.instance.client.stop()
}