diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index af930a3a34d..0e23a20d71d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -427,6 +427,32 @@ Example:
}
```
+Overrides specifically for the Amazon Q language server can be set using the `aws.dev.amazonqLsp` setting. This is a JSON object consisting of keys/values required to override language server: `manifestUrl`, `supportedVersions`, `id`, and `path`.
+
+Example:
+
+```json
+"aws.dev.amazonqLsp": {
+ "manifestUrl": "https://custom.url/manifest.json",
+ "supportedVersions": "4.0.0",
+ "id": "AmazonQ",
+ "path": "/custom/path/to/local/lsp/folder",
+}
+```
+
+Overrides specifically for the Amazon Q Workspace Context language server can be set using the `aws.dev.amazonqWorkspaceLsp` setting. This is a JSON object consisting of keys/values required to override language server: `manifestUrl`, `supportedVersions`, `id`, and `path`.
+
+Example:
+
+```json
+"aws.dev.amazonqWorkspaceLsp": {
+ "manifestUrl": "https://custom.url/manifest.json",
+ "supportedVersions": "4.0.0",
+ "id": "AmazonQ",
+ "path": "/custom/path/to/local/lsp/folder",
+}
+```
+
### Environment variables
Environment variables can be used to modify the behaviour of VSCode. The following are environment variables that can be used to configure the extension:
@@ -472,6 +498,14 @@ Unlike the user setting overrides, not all of these environment variables have t
- `__CODEWHISPERER_REGION`: for aws.dev.codewhispererService.region
- `__CODEWHISPERER_ENDPOINT`: for aws.dev.codewhispererService.endpoint
+- `__AMAZONQLSP_MANIFEST_URL`: for aws.dev.amazonqLsp.manifestUrl
+- `__AMAZONQLSP_SUPPORTED_VERSIONS`: for aws.dev.amazonqLsp.supportedVersions
+- `__AMAZONQLSP_ID`: for aws.dev.amazonqLsp.id
+- `__AMAZONQLSP_PATH`: for aws.dev.amazonqWorkspaceLsp.locationOverride
+- `__AMAZONQWORKSPACELSP_MANIFEST_URL`: for aws.dev.amazonqWorkspaceLsp.manifestUrl
+- `__AMAZONQWORKSPACELSP_SUPPORTED_VERSIONS`: for aws.dev.amazonqWorkspaceLsp.supportedVersions
+- `__AMAZONQWORKSPACELSP_ID`: for aws.dev.amazonqWorkspaceLsp.id
+- `__AMAZONQWORKSPACELSP_PATH`: for aws.dev.amazonqWorkspaceLsp.locationOverride
#### Lambda
diff --git a/docs/TEST_E2E.md b/docs/TEST_E2E.md
index 32009d13103..10927c098e3 100644
--- a/docs/TEST_E2E.md
+++ b/docs/TEST_E2E.md
@@ -17,3 +17,27 @@ With this approach, the follow things can be tested:
- Whether or not certain features show/not show depending on the status of the users auth
- Run requests directly against the backend and see if we get results back
- Clicking any follow up buttons (including examples)
+
+## Flare Chat E2E Test flow (Not implemented yet)
+
+This is the new flow that should be introduced when we moved to Flare chat.
+
+```mermaid
+sequenceDiagram
+ participant test as Test
+ participant framework as Test Framework
+ participant ui as Virtual DOM
+ participant lsp as Language Server
+ participant mynah as Mynah UI
+
+ test->>test: starts
+ test->>framework: creates test framework
+ framework->>ui: adds mynah ui to virtual dom
+ test->>lsp: waits for language server activation
+ test->>mynah: triggers action on mynah ui
+ mynah->>framework: sends message
+ framework->>lsp: sends message
+ lsp->>framework: gets response
+ framework->>ui: displays response
+ test->>ui: assert test expectations
+```
diff --git a/docs/lsp.md b/docs/lsp.md
new file mode 100644
index 00000000000..64a3af3e8c0
--- /dev/null
+++ b/docs/lsp.md
@@ -0,0 +1,53 @@
+# Flare Language Server
+
+## Chat Activation flow
+
+```mermaid
+sequenceDiagram
+ participant user as User
+ participant ext as Extension
+ participant webview as Chat Webview
+ participant flare as Amazon Q LSP
+ participant backend as Amazon Q Backend
+
+ user->>ext: opens IDE
+ ext->>ext: activates
+ ext->>webview: loads UI
+ ext->>flare: initialize process
+ flare->>flare: starts and waits
+ user->>webview: interacts
+ webview->>ext: sends message
+ ext->>flare: sends message
+ flare->>backend: call api
+ backend->>flare: returns
+ flare->>ext: display
+ ext->>webview: display
+```
+
+## Language Server Debugging
+
+1. Clone https://github.com/aws/language-servers.git and set it up in the same workspace as this project
+
+ e.g.
+
+ ```
+ /aws-toolkit-vscode
+ /toolkit
+ /core
+ /amazonq
+ /language-servers
+ ```
+
+2. Inside of the language-servers project run:
+ ```
+ npm install
+ npm run compile
+ npm run package
+ ```
+ to get the project setup
+3. Uncomment the `__AMAZONQLSP_PATH` variable in `amazonq/.vscode/launch.json` Extension configuration
+4. Use the `Launch LSP with Debugging` configuration and set breakpoints in VSCode or the language server
+
+## Amazon Q Inline Activation
+
+- In order to get inline completion working you must open a supported file type defined in CodewhispererInlineCompletionLanguages in `packages/amazonq/src/app/inline/completion.ts`
diff --git a/package-lock.json b/package-lock.json
index e8f0e06a793..a23a586adf1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10936,6 +10936,102 @@
"yargs": "^17.0.1"
}
},
+ "node_modules/@aws/chat-client-ui-types": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/@aws/chat-client-ui-types/-/chat-client-ui-types-0.0.8.tgz",
+ "integrity": "sha512-aU8r0FaCKIhMiTWvr/yuWYZmVWPgE2vBAPsVcafhlu7ucubiH/+YodqDw+0Owk0R0kxxZDdjdZghPZSyy0G84A==",
+ "dev": true,
+ "dependencies": {
+ "@aws/language-server-runtimes-types": "^0.0.7"
+ }
+ },
+ "node_modules/@aws/language-server-runtimes": {
+ "version": "0.2.27",
+ "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.27.tgz",
+ "integrity": "sha512-qWog7upRVc09xLcuL0HladoxO3JbkgdtgkI/RUWRDcr6YB8hBvmSCADGWjUGbOyvK4CpaXqHIr883PAqnosoXg==",
+ "dev": true,
+ "dependencies": {
+ "@aws/language-server-runtimes-types": "^0.0.7",
+ "jose": "^5.9.6",
+ "rxjs": "^7.8.1",
+ "vscode-languageserver": "^9.0.1",
+ "vscode-languageserver-protocol": "^3.17.5"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@aws/language-server-runtimes-types": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.0.7.tgz",
+ "integrity": "sha512-P83YkgWITcUGHaZvYFI0N487nWErgRpejALKNm/xs8jEcHooDfjigOpliN8TgzfF9BGvGeQnnAzIG16UBXc9ig==",
+ "dev": true,
+ "dependencies": {
+ "vscode-languageserver-textdocument": "^1.0.12",
+ "vscode-languageserver-types": "^3.17.5"
+ }
+ },
+ "node_modules/@aws/language-server-runtimes-types/node_modules/vscode-languageserver-types": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
+ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
+ "dev": true
+ },
+ "node_modules/@aws/language-server-runtimes/node_modules/jose": {
+ "version": "5.9.6",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz",
+ "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/@aws/language-server-runtimes/node_modules/rxjs": {
+ "version": "7.8.1",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
+ "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/@aws/language-server-runtimes/node_modules/vscode-jsonrpc": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
+ "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws/language-server-runtimes/node_modules/vscode-languageserver": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
+ "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
+ "dev": true,
+ "dependencies": {
+ "vscode-languageserver-protocol": "3.17.5"
+ },
+ "bin": {
+ "installServerIntoExtension": "bin/installServerIntoExtension"
+ }
+ },
+ "node_modules/@aws/language-server-runtimes/node_modules/vscode-languageserver-protocol": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
+ "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
+ "dev": true,
+ "dependencies": {
+ "vscode-jsonrpc": "8.2.0",
+ "vscode-languageserver-types": "3.17.5"
+ }
+ },
+ "node_modules/@aws/language-server-runtimes/node_modules/vscode-languageserver-types": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
+ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
+ "dev": true
+ },
"node_modules/@aws/mynah-ui": {
"version": "4.25.1",
"hasInstallScript": true,
@@ -23582,8 +23678,9 @@
"license": "MIT"
},
"node_modules/vscode-languageserver-textdocument": {
- "version": "1.0.8",
- "license": "MIT"
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
+ "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
@@ -24821,6 +24918,8 @@
},
"devDependencies": {
"@aws-sdk/types": "^3.13.1",
+ "@aws/chat-client-ui-types": "^0.0.8",
+ "@aws/language-server-runtimes": "^0.2.27",
"@cspotcode/source-map-support": "^0.8.1",
"@sinonjs/fake-timers": "^10.0.2",
"@types/adm-zip": "^0.4.34",
diff --git a/packages/amazonq/.changes/next-release/Feature-7a3c930e-cc8e-4323-9ecd-c1bf767fd2ed.json b/packages/amazonq/.changes/next-release/Feature-7a3c930e-cc8e-4323-9ecd-c1bf767fd2ed.json
new file mode 100644
index 00000000000..81a1e026db4
--- /dev/null
+++ b/packages/amazonq/.changes/next-release/Feature-7a3c930e-cc8e-4323-9ecd-c1bf767fd2ed.json
@@ -0,0 +1,4 @@
+{
+ "type": "Feature",
+ "description": "(Experimental) Amazon Q inline code suggestions via Amazon Q Language Server. (enable with `aws.experiments.amazonqLSP: true`)"
+}
diff --git a/packages/amazonq/.vscode/launch.json b/packages/amazonq/.vscode/launch.json
index 84f48b6f785..f4b8642ea4e 100644
--- a/packages/amazonq/.vscode/launch.json
+++ b/packages/amazonq/.vscode/launch.json
@@ -14,6 +14,7 @@
"env": {
"SSMDOCUMENT_LANGUAGESERVER_PORT": "6010",
"WEBPACK_DEVELOPER_SERVER": "http://localhost:8080"
+ // "__AMAZONQLSP_PATH": "${workspaceFolder}/../../../language-servers/app/aws-lsp-codewhisperer-runtimes/out/token-standalone.js",
},
"envFile": "${workspaceFolder}/.local.env",
"outFiles": ["${workspaceFolder}/dist/**/*.js", "${workspaceFolder}/../core/dist/**/*.js"],
@@ -135,6 +136,31 @@
"group": "4_E2ETestCurrentFile",
"order": 2
}
+ },
+ {
+ "name": "Attach to Language Server",
+ "type": "node",
+ "request": "attach",
+ "port": 6080, // Hard defined in core/src/shared/lsp/platform.ts
+ "outFiles": ["${workspaceFolder}/../../../language-servers/**/out/**/*.js"],
+ "skipFiles": [
+ "/**",
+ "${workspaceFolder}/../../../language-servers/**/node_modules/**/*.js"
+ ],
+ "restart": {
+ "maxAttempts": 10,
+ "delay": 1000
+ }
+ }
+ ],
+ "compounds": [
+ {
+ "name": "Launch LSP with Debugging",
+ "configurations": ["Extension", "Attach to Language Server"],
+ "presentation": {
+ "group": "1_Extension",
+ "order": 5
+ }
}
]
}
diff --git a/packages/amazonq/src/app/inline/activation.ts b/packages/amazonq/src/app/inline/activation.ts
new file mode 100644
index 00000000000..d786047b2aa
--- /dev/null
+++ b/packages/amazonq/src/app/inline/activation.ts
@@ -0,0 +1,124 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import vscode from 'vscode'
+import {
+ AuthUtil,
+ CodeSuggestionsState,
+ CodeWhispererCodeCoverageTracker,
+ CodeWhispererConstants,
+ CodeWhispererSettings,
+ ConfigurationEntry,
+ DefaultCodeWhispererClient,
+ invokeRecommendation,
+ isInlineCompletionEnabled,
+ KeyStrokeHandler,
+ RecommendationHandler,
+ runtimeLanguageContext,
+ TelemetryHelper,
+ UserWrittenCodeTracker,
+ vsCodeState,
+} from 'aws-core-vscode/codewhisperer'
+import { Commands, getLogger, globals, sleep } from 'aws-core-vscode/shared'
+
+export async function activate() {
+ const codewhispererSettings = CodeWhispererSettings.instance
+ const client = new DefaultCodeWhispererClient()
+
+ if (isInlineCompletionEnabled()) {
+ await setSubscriptionsforInlineCompletion()
+ await AuthUtil.instance.setVscodeContextProps()
+ }
+
+ function getAutoTriggerStatus(): boolean {
+ return CodeSuggestionsState.instance.isSuggestionsEnabled()
+ }
+
+ async function getConfigEntry(): Promise {
+ const isShowMethodsEnabled: boolean =
+ vscode.workspace.getConfiguration('editor').get('suggest.showMethods') || false
+ const isAutomatedTriggerEnabled: boolean = getAutoTriggerStatus()
+ const isManualTriggerEnabled: boolean = true
+ const isSuggestionsWithCodeReferencesEnabled = codewhispererSettings.isSuggestionsWithCodeReferencesEnabled()
+
+ // TODO:remove isManualTriggerEnabled
+ return {
+ isShowMethodsEnabled,
+ isManualTriggerEnabled,
+ isAutomatedTriggerEnabled,
+ isSuggestionsWithCodeReferencesEnabled,
+ }
+ }
+
+ async function setSubscriptionsforInlineCompletion() {
+ RecommendationHandler.instance.subscribeSuggestionCommands()
+
+ /**
+ * Automated trigger
+ */
+ globals.context.subscriptions.push(
+ vscode.window.onDidChangeActiveTextEditor(async (editor) => {
+ await RecommendationHandler.instance.onEditorChange()
+ }),
+ vscode.window.onDidChangeWindowState(async (e) => {
+ await RecommendationHandler.instance.onFocusChange()
+ }),
+ vscode.window.onDidChangeTextEditorSelection(async (e) => {
+ await RecommendationHandler.instance.onCursorChange(e)
+ }),
+ vscode.workspace.onDidChangeTextDocument(async (e) => {
+ const editor = vscode.window.activeTextEditor
+ if (!editor) {
+ return
+ }
+ if (e.document !== editor.document) {
+ return
+ }
+ if (!runtimeLanguageContext.isLanguageSupported(e.document)) {
+ return
+ }
+
+ CodeWhispererCodeCoverageTracker.getTracker(e.document.languageId)?.countTotalTokens(e)
+ UserWrittenCodeTracker.instance.onTextDocumentChange(e)
+ /**
+ * Handle this keystroke event only when
+ * 1. It is not a backspace
+ * 2. It is not caused by CodeWhisperer editing
+ * 3. It is not from undo/redo.
+ */
+ if (e.contentChanges.length === 0 || vsCodeState.isCodeWhispererEditing) {
+ return
+ }
+
+ if (vsCodeState.lastUserModificationTime) {
+ TelemetryHelper.instance.setTimeSinceLastModification(
+ performance.now() - vsCodeState.lastUserModificationTime
+ )
+ }
+ vsCodeState.lastUserModificationTime = performance.now()
+ /**
+ * Important: Doing this sleep(10) is to make sure
+ * 1. this event is processed by vs code first
+ * 2. editor.selection.active has been successfully updated by VS Code
+ * Then this event can be processed by our code.
+ */
+ await sleep(CodeWhispererConstants.vsCodeCursorUpdateDelay)
+ if (!RecommendationHandler.instance.isSuggestionVisible()) {
+ await KeyStrokeHandler.instance.processKeyStroke(e, editor, client, await getConfigEntry())
+ }
+ }),
+ // manual trigger
+ Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => {
+ invokeRecommendation(
+ vscode.window.activeTextEditor as vscode.TextEditor,
+ client,
+ await getConfigEntry()
+ ).catch((e) => {
+ getLogger().error('invokeRecommendation failed: %s', (e as Error).message)
+ })
+ })
+ )
+ }
+}
diff --git a/packages/amazonq/src/app/inline/completion.ts b/packages/amazonq/src/app/inline/completion.ts
new file mode 100644
index 00000000000..94700768607
--- /dev/null
+++ b/packages/amazonq/src/app/inline/completion.ts
@@ -0,0 +1,93 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ CancellationToken,
+ InlineCompletionContext,
+ InlineCompletionItem,
+ InlineCompletionItemProvider,
+ InlineCompletionList,
+ Position,
+ TextDocument,
+ commands,
+ languages,
+} from 'vscode'
+import { LanguageClient } from 'vscode-languageclient'
+import {
+ InlineCompletionListWithReferences,
+ InlineCompletionWithReferencesParams,
+ inlineCompletionWithReferencesRequestType,
+ logInlineCompletionSessionResultsNotificationType,
+ LogInlineCompletionSessionResultsParams,
+} from '@aws/language-server-runtimes/protocol'
+import { CodeWhispererConstants } from 'aws-core-vscode/codewhisperer'
+
+export function registerInlineCompletion(languageClient: LanguageClient) {
+ const inlineCompletionProvider = new AmazonQInlineCompletionItemProvider(languageClient)
+ languages.registerInlineCompletionItemProvider(CodeWhispererConstants.platformLanguageIds, inlineCompletionProvider)
+
+ const onInlineAcceptance = async (
+ sessionId: string,
+ itemId: string,
+ requestStartTime: number,
+ firstCompletionDisplayLatency?: number
+ ) => {
+ const params: LogInlineCompletionSessionResultsParams = {
+ sessionId: sessionId,
+ completionSessionResult: {
+ [itemId]: {
+ seen: true,
+ accepted: true,
+ discarded: false,
+ },
+ },
+ totalSessionDisplayTime: Date.now() - requestStartTime,
+ firstCompletionDisplayLatency: firstCompletionDisplayLatency,
+ }
+ languageClient.sendNotification(logInlineCompletionSessionResultsNotificationType as any, params)
+ }
+ commands.registerCommand('aws.sample-vscode-ext-amazonq.accept', onInlineAcceptance)
+}
+
+export class AmazonQInlineCompletionItemProvider implements InlineCompletionItemProvider {
+ constructor(private readonly languageClient: LanguageClient) {}
+
+ async provideInlineCompletionItems(
+ document: TextDocument,
+ position: Position,
+ context: InlineCompletionContext,
+ token: CancellationToken
+ ): Promise {
+ const requestStartTime = Date.now()
+ const request: InlineCompletionWithReferencesParams = {
+ textDocument: {
+ uri: document.uri.toString(),
+ },
+ position,
+ context,
+ }
+
+ const response = await this.languageClient.sendRequest(
+ inlineCompletionWithReferencesRequestType as any,
+ request,
+ token
+ )
+
+ const list: InlineCompletionListWithReferences = response as InlineCompletionListWithReferences
+ this.languageClient.info(`Client: Received ${list.items.length} suggestions`)
+ const firstCompletionDisplayLatency = Date.now() - requestStartTime
+
+ // Add completion session tracking and attach onAcceptance command to each item to record used decision
+ for (const item of list.items) {
+ item.command = {
+ command: 'aws.sample-vscode-ext-amazonq.accept',
+ title: 'On acceptance',
+ arguments: [list.sessionId, item.itemId, requestStartTime, firstCompletionDisplayLatency],
+ }
+ }
+
+ return list as InlineCompletionList
+ }
+}
diff --git a/packages/amazonq/src/extension.ts b/packages/amazonq/src/extension.ts
index c351b7ba9eb..ad89a44ed4d 100644
--- a/packages/amazonq/src/extension.ts
+++ b/packages/amazonq/src/extension.ts
@@ -31,6 +31,7 @@ import {
setContext,
setupUninstallHandler,
maybeShowMinVscodeWarning,
+ Experiments,
} from 'aws-core-vscode/shared'
import { ExtStartUpSources } from 'aws-core-vscode/telemetry'
import { VSCODE_EXTENSION_ID } from 'aws-core-vscode/utils'
@@ -39,6 +40,8 @@ import * as semver from 'semver'
import * as vscode from 'vscode'
import { registerCommands } from './commands'
import { focusAmazonQPanel } from 'aws-core-vscode/codewhispererChat'
+import { activate as activateAmazonqLsp } from './lsp/activation'
+import { activate as activateInlineCompletion } from './app/inline/activation'
export const amazonQContextPrefix = 'amazonq'
@@ -113,7 +116,13 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is
const extContext = {
extensionContext: context,
}
+ // This contains every lsp agnostic things (auth, security scan, code scan)
await activateCodeWhisperer(extContext as ExtContext)
+ if (Experiments.instance.get('amazonqLSP', false)) {
+ await activateAmazonqLsp(context)
+ } else {
+ await activateInlineCompletion()
+ }
// Generic extension commands
registerGenericCommands(context, amazonQContextPrefix)
@@ -145,6 +154,23 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is
void focusAmazonQPanel.execute(placeholder, ExtStartUpSources.firstStartUp)
}, 1000)
}
+
+ context.subscriptions.push(
+ Experiments.instance.onDidChange(async (event) => {
+ if (event.key === 'amazonqLSP' || event.key === 'amazonqChatLSP') {
+ await vscode.window
+ .showInformationMessage(
+ 'Amazon Q LSP setting has changed. Reload VS Code for the changes to take effect.',
+ 'Reload Now'
+ )
+ .then(async (selection) => {
+ if (selection === 'Reload Now') {
+ await vscode.commands.executeCommand('workbench.action.reloadWindow')
+ }
+ })
+ }
+ })
+ )
}
export async function deactivateCommon() {
diff --git a/packages/amazonq/src/extensionNode.ts b/packages/amazonq/src/extensionNode.ts
index 04ca8c4023f..d9d36f828eb 100644
--- a/packages/amazonq/src/extensionNode.ts
+++ b/packages/amazonq/src/extensionNode.ts
@@ -7,7 +7,15 @@ import * as vscode from 'vscode'
import { activateAmazonQCommon, amazonQContextPrefix, deactivateCommon } from './extension'
import { DefaultAmazonQAppInitContext } from 'aws-core-vscode/amazonq'
import { activate as activateQGumby } from 'aws-core-vscode/amazonqGumby'
-import { ExtContext, globals, CrashMonitoring, getLogger, isNetworkError, isSageMaker } from 'aws-core-vscode/shared'
+import {
+ ExtContext,
+ globals,
+ CrashMonitoring,
+ getLogger,
+ isNetworkError,
+ isSageMaker,
+ Experiments,
+} from 'aws-core-vscode/shared'
import { filetypes, SchemaService } from 'aws-core-vscode/sharedNode'
import { updateDevMode } from 'aws-core-vscode/dev'
import { CommonAuthViewProvider } from 'aws-core-vscode/login'
@@ -42,8 +50,11 @@ async function activateAmazonQNode(context: vscode.ExtensionContext) {
const extContext = {
extensionContext: context,
}
- await activateCWChat(context)
- await activateQGumby(extContext as ExtContext)
+
+ if (!Experiments.instance.get('amazonqChatLSP', false)) {
+ await activateCWChat(context)
+ await activateQGumby(extContext as ExtContext)
+ }
const authProvider = new CommonAuthViewProvider(
context,
diff --git a/packages/amazonq/src/lsp/activation.ts b/packages/amazonq/src/lsp/activation.ts
new file mode 100644
index 00000000000..4d7918d76b0
--- /dev/null
+++ b/packages/amazonq/src/lsp/activation.ts
@@ -0,0 +1,29 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import vscode from 'vscode'
+import { startLanguageServer } from './client'
+import { AmazonQLspInstaller } from './lspInstaller'
+import { Commands, lspSetupStage, ToolkitError } from 'aws-core-vscode/shared'
+
+export async function activate(ctx: vscode.ExtensionContext): Promise {
+ try {
+ await lspSetupStage('all', async () => {
+ const installResult = await new AmazonQLspInstaller().resolve()
+ await lspSetupStage('launch', async () => await startLanguageServer(ctx, installResult.resourcePaths))
+ })
+ ctx.subscriptions.push(
+ Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => {
+ await vscode.commands.executeCommand('editor.action.inlineSuggest.trigger')
+ }),
+ Commands.declare('aws.amazonq.rejectCodeSuggestion', () => async () => {
+ await vscode.commands.executeCommand('editor.action.inlineSuggest.hide')
+ }).register()
+ )
+ } catch (err) {
+ const e = err as ToolkitError
+ void vscode.window.showInformationMessage(`Unable to launch amazonq language server: ${e.message}`)
+ }
+}
diff --git a/packages/amazonq/src/lsp/auth.ts b/packages/amazonq/src/lsp/auth.ts
new file mode 100644
index 00000000000..70753e75c6b
--- /dev/null
+++ b/packages/amazonq/src/lsp/auth.ts
@@ -0,0 +1,98 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ ConnectionMetadata,
+ NotificationType,
+ RequestType,
+ ResponseMessage,
+} from '@aws/language-server-runtimes/protocol'
+import * as jose from 'jose'
+import * as crypto from 'crypto'
+import { LanguageClient } from 'vscode-languageclient'
+import { AuthUtil } from 'aws-core-vscode/codewhisperer'
+import { Writable } from 'stream'
+
+export const encryptionKey = crypto.randomBytes(32)
+
+/**
+ * Sends a json payload to the language server, who is waiting to know what the encryption key is.
+ * Code reference: https://github.com/aws/language-servers/blob/7da212185a5da75a72ce49a1a7982983f438651a/client/vscode/src/credentialsActivation.ts#L77
+ */
+export function writeEncryptionInit(stream: Writable): void {
+ const request = {
+ version: '1.0',
+ mode: 'JWT',
+ key: encryptionKey.toString('base64'),
+ }
+ stream.write(JSON.stringify(request))
+ stream.write('\n')
+}
+
+/**
+ * Request for custom notifications that Update Credentials and tokens.
+ * See core\aws-lsp-core\src\credentials\updateCredentialsRequest.ts for details
+ */
+export interface UpdateCredentialsRequest {
+ /**
+ * Encrypted token (JWT or PASETO)
+ * The token's contents differ whether IAM or Bearer token is sent
+ */
+ data: string
+ /**
+ * Used by the runtime based language servers.
+ * Signals that this client will encrypt its credentials payloads.
+ */
+ encrypted: boolean
+}
+
+export const notificationTypes = {
+ updateBearerToken: new RequestType(
+ 'aws/credentials/token/update'
+ ),
+ deleteBearerToken: new NotificationType('aws/credentials/token/delete'),
+ getConnectionMetadata: new RequestType(
+ 'aws/credentials/getConnectionMetadata'
+ ),
+}
+
+/**
+ * Facade over our VSCode Auth that does crud operations on the language server auth
+ */
+export class AmazonQLspAuth {
+ constructor(private readonly client: LanguageClient) {}
+
+ async init() {
+ const activeConnection = AuthUtil.instance.auth.activeConnection
+ if (activeConnection?.type === 'sso') {
+ // send the token to the language server
+ const token = await AuthUtil.instance.getBearerToken()
+ await this.updateBearerToken(token)
+ }
+ }
+
+ private async updateBearerToken(token: string) {
+ const request = await this.createUpdateCredentialsRequest({
+ token,
+ })
+
+ await this.client.sendRequest(notificationTypes.updateBearerToken.method, request)
+
+ this.client.info(`UpdateBearerToken: ${JSON.stringify(request)}`)
+ }
+
+ private async createUpdateCredentialsRequest(data: any) {
+ const payload = new TextEncoder().encode(JSON.stringify({ data }))
+
+ const jwt = await new jose.CompactEncrypt(payload)
+ .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
+ .encrypt(encryptionKey)
+
+ return {
+ data: jwt,
+ encrypted: true,
+ }
+ }
+}
diff --git a/packages/amazonq/src/lsp/chat/activation.ts b/packages/amazonq/src/lsp/chat/activation.ts
new file mode 100644
index 00000000000..406b753716f
--- /dev/null
+++ b/packages/amazonq/src/lsp/chat/activation.ts
@@ -0,0 +1,34 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { window } from 'vscode'
+import { LanguageClient } from 'vscode-languageclient'
+import { AmazonQChatViewProvider } from './webviewProvider'
+import { registerCommands } from './commands'
+import { registerLanguageServerEventListener, registerMessageListeners } from './messages'
+import { globals } from 'aws-core-vscode/shared'
+
+export function activate(languageClient: LanguageClient, encryptionKey: Buffer, mynahUIPath: string) {
+ const provider = new AmazonQChatViewProvider(mynahUIPath)
+
+ globals.context.subscriptions.push(
+ window.registerWebviewViewProvider(AmazonQChatViewProvider.viewType, provider, {
+ webviewOptions: {
+ retainContextWhenHidden: true,
+ },
+ })
+ )
+
+ /**
+ * Commands are registered independent of the webview being open because when they're executed
+ * they focus the webview
+ **/
+ registerCommands(provider)
+ registerLanguageServerEventListener(languageClient, provider)
+
+ provider.onDidResolveWebview(() => {
+ registerMessageListeners(languageClient, provider, encryptionKey)
+ })
+}
diff --git a/packages/amazonq/src/lsp/chat/commands.ts b/packages/amazonq/src/lsp/chat/commands.ts
new file mode 100644
index 00000000000..3febc748442
--- /dev/null
+++ b/packages/amazonq/src/lsp/chat/commands.ts
@@ -0,0 +1,79 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as vscode from 'vscode'
+import { Commands, globals } from 'aws-core-vscode/shared'
+import { window } from 'vscode'
+import { AmazonQChatViewProvider } from './webviewProvider'
+
+export function registerCommands(provider: AmazonQChatViewProvider) {
+ globals.context.subscriptions.push(
+ registerGenericCommand('aws.amazonq.explainCode', 'Explain', provider),
+ registerGenericCommand('aws.amazonq.refactorCode', 'Refactor', provider),
+ registerGenericCommand('aws.amazonq.fixCode', 'Fix', provider),
+ registerGenericCommand('aws.amazonq.optimizeCode', 'Optimize', provider),
+ Commands.register('aws.amazonq.sendToPrompt', (data) => {
+ const triggerType = getCommandTriggerType(data)
+ const selection = getSelectedText()
+
+ void focusAmazonQPanel().then(() => {
+ void provider.webview?.postMessage({
+ command: 'sendToPrompt',
+ params: { selection: selection, triggerType },
+ })
+ })
+ }),
+ Commands.register('aws.amazonq.openTab', () => {
+ void focusAmazonQPanel().then(() => {
+ void provider.webview?.postMessage({
+ command: 'aws/chat/openTab',
+ params: {},
+ })
+ })
+ })
+ )
+}
+
+function getSelectedText(): string {
+ const editor = window.activeTextEditor
+ if (editor) {
+ const selection = editor.selection
+ const selectedText = editor.document.getText(selection)
+ return selectedText
+ }
+
+ return ' '
+}
+
+function getCommandTriggerType(data: any): string {
+ // data is undefined when commands triggered from keybinding or command palette. Currently no
+ // way to differentiate keybinding and command palette, so both interactions are recorded as keybinding
+ return data === undefined ? 'hotkeys' : 'contextMenu'
+}
+
+function registerGenericCommand(commandName: string, genericCommand: string, provider: AmazonQChatViewProvider) {
+ return Commands.register(commandName, (data) => {
+ const triggerType = getCommandTriggerType(data)
+ const selection = getSelectedText()
+
+ void focusAmazonQPanel().then(() => {
+ void provider.webview?.postMessage({
+ command: 'genericCommand',
+ params: { genericCommand, selection, triggerType },
+ })
+ })
+ })
+}
+
+/**
+ * Importing focusAmazonQPanel from aws-core-vscode/amazonq leads to several dependencies down the chain not resolving since AmazonQ chat
+ * is currently only activated on node, but the language server is activated on both web and node.
+ *
+ * Instead, we just create our own as a temporary solution
+ */
+async function focusAmazonQPanel() {
+ await vscode.commands.executeCommand('aws.amazonq.AmazonQChatView.focus')
+ await vscode.commands.executeCommand('aws.amazonq.AmazonCommonAuth.focus')
+}
diff --git a/packages/amazonq/src/lsp/chat/messages.ts b/packages/amazonq/src/lsp/chat/messages.ts
new file mode 100644
index 00000000000..4c9d93f7f65
--- /dev/null
+++ b/packages/amazonq/src/lsp/chat/messages.ts
@@ -0,0 +1,225 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ isValidAuthFollowUpType,
+ INSERT_TO_CURSOR_POSITION,
+ AUTH_FOLLOW_UP_CLICKED,
+ CHAT_OPTIONS,
+ COPY_TO_CLIPBOARD,
+} from '@aws/chat-client-ui-types'
+import {
+ ChatResult,
+ chatRequestType,
+ ChatParams,
+ followUpClickNotificationType,
+ quickActionRequestType,
+ QuickActionResult,
+ QuickActionParams,
+ insertToCursorPositionNotificationType,
+} from '@aws/language-server-runtimes/protocol'
+import { v4 as uuidv4 } from 'uuid'
+import { window } from 'vscode'
+import { Disposable, LanguageClient, Position, State, TextDocumentIdentifier } from 'vscode-languageclient'
+import * as jose from 'jose'
+import { AmazonQChatViewProvider } from './webviewProvider'
+
+export function registerLanguageServerEventListener(languageClient: LanguageClient, provider: AmazonQChatViewProvider) {
+ languageClient.onDidChangeState(({ oldState, newState }) => {
+ if (oldState === State.Starting && newState === State.Running) {
+ languageClient.info(
+ 'Language client received initializeResult from server:',
+ JSON.stringify(languageClient.initializeResult)
+ )
+
+ const chatOptions = languageClient.initializeResult?.awsServerCapabilities?.chatOptions
+
+ void provider.webview?.postMessage({
+ command: CHAT_OPTIONS,
+ params: chatOptions,
+ })
+ }
+ })
+
+ languageClient.onTelemetry((e) => {
+ languageClient.info(`[VSCode Client] Received telemetry event from server ${JSON.stringify(e)}`)
+ })
+}
+
+export function registerMessageListeners(
+ languageClient: LanguageClient,
+ provider: AmazonQChatViewProvider,
+ encryptionKey: Buffer
+) {
+ provider.webview?.onDidReceiveMessage(async (message) => {
+ languageClient.info(`[VSCode Client] Received ${JSON.stringify(message)} from chat`)
+
+ switch (message.command) {
+ case COPY_TO_CLIPBOARD:
+ // TODO see what we need to hook this up
+ languageClient.info('[VSCode Client] Copy to clipboard event received')
+ break
+ case INSERT_TO_CURSOR_POSITION: {
+ const editor = window.activeTextEditor
+ let textDocument: TextDocumentIdentifier | undefined = undefined
+ let cursorPosition: Position | undefined = undefined
+ if (editor) {
+ cursorPosition = editor.selection.active
+ textDocument = { uri: editor.document.uri.toString() }
+ }
+
+ languageClient.sendNotification(insertToCursorPositionNotificationType.method, {
+ ...message.params,
+ cursorPosition,
+ textDocument,
+ })
+ break
+ }
+ case AUTH_FOLLOW_UP_CLICKED:
+ // TODO hook this into auth
+ languageClient.info('[VSCode Client] AuthFollowUp clicked')
+ break
+ case chatRequestType.method: {
+ const partialResultToken = uuidv4()
+ const chatDisposable = languageClient.onProgress(chatRequestType, partialResultToken, (partialResult) =>
+ handlePartialResult(partialResult, encryptionKey, provider, message.params.tabId)
+ )
+
+ const editor =
+ window.activeTextEditor ||
+ window.visibleTextEditors.find((editor) => editor.document.languageId !== 'Log')
+ if (editor) {
+ message.params.cursorPosition = [editor.selection.active]
+ message.params.textDocument = { uri: editor.document.uri.toString() }
+ }
+
+ const chatRequest = await encryptRequest(message.params, encryptionKey)
+ const chatResult = (await languageClient.sendRequest(chatRequestType.method, {
+ ...chatRequest,
+ partialResultToken,
+ })) as string | ChatResult
+ void handleCompleteResult(
+ chatResult,
+ encryptionKey,
+ provider,
+ message.params.tabId,
+ chatDisposable
+ )
+ break
+ }
+ case quickActionRequestType.method: {
+ const quickActionPartialResultToken = uuidv4()
+ const quickActionDisposable = languageClient.onProgress(
+ quickActionRequestType,
+ quickActionPartialResultToken,
+ (partialResult) =>
+ handlePartialResult(
+ partialResult,
+ encryptionKey,
+ provider,
+ message.params.tabId
+ )
+ )
+
+ const quickActionRequest = await encryptRequest(message.params, encryptionKey)
+ const quickActionResult = (await languageClient.sendRequest(quickActionRequestType.method, {
+ ...quickActionRequest,
+ partialResultToken: quickActionPartialResultToken,
+ })) as string | ChatResult
+ void handleCompleteResult(
+ quickActionResult,
+ encryptionKey,
+ provider,
+ message.params.tabId,
+ quickActionDisposable
+ )
+ break
+ }
+ case followUpClickNotificationType.method:
+ if (!isValidAuthFollowUpType(message.params.followUp.type)) {
+ languageClient.sendNotification(followUpClickNotificationType.method, message.params)
+ }
+ break
+ default:
+ if (isServerEvent(message.command)) {
+ languageClient.sendNotification(message.command, message.params)
+ }
+ break
+ }
+ }, undefined)
+}
+
+function isServerEvent(command: string) {
+ return command.startsWith('aws/chat/') || command === 'telemetry/event'
+}
+
+async function encryptRequest(params: T, encryptionKey: Buffer): Promise<{ message: string } | T> {
+ const payload = new TextEncoder().encode(JSON.stringify(params))
+
+ const encryptedMessage = await new jose.CompactEncrypt(payload)
+ .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
+ .encrypt(encryptionKey)
+
+ return { message: encryptedMessage }
+}
+
+async function decodeRequest(request: string, key: Buffer): Promise {
+ const result = await jose.jwtDecrypt(request, key, {
+ clockTolerance: 60, // Allow up to 60 seconds to account for clock differences
+ contentEncryptionAlgorithms: ['A256GCM'],
+ keyManagementAlgorithms: ['dir'],
+ })
+
+ if (!result.payload) {
+ throw new Error('JWT payload not found')
+ }
+ return result.payload as T
+}
+
+/**
+ * Decodes partial chat responses from the language server before sending them to mynah UI
+ */
+async function handlePartialResult(
+ partialResult: string | T,
+ encryptionKey: Buffer | undefined,
+ provider: AmazonQChatViewProvider,
+ tabId: string
+) {
+ const decryptedMessage =
+ typeof partialResult === 'string' && encryptionKey
+ ? await decodeRequest(partialResult, encryptionKey)
+ : (partialResult as T)
+
+ if (decryptedMessage.body) {
+ void provider.webview?.postMessage({
+ command: chatRequestType.method,
+ params: decryptedMessage,
+ isPartialResult: true,
+ tabId: tabId,
+ })
+ }
+}
+
+/**
+ * Decodes the final chat responses from the language server before sending it to mynah UI.
+ * Once this is called the answer response is finished
+ */
+async function handleCompleteResult(
+ result: string | T,
+ encryptionKey: Buffer | undefined,
+ provider: AmazonQChatViewProvider,
+ tabId: string,
+ disposable: Disposable
+) {
+ const decryptedMessage =
+ typeof result === 'string' && encryptionKey ? await decodeRequest(result, encryptionKey) : result
+
+ void provider.webview?.postMessage({
+ command: chatRequestType.method,
+ params: decryptedMessage,
+ tabId: tabId,
+ })
+ disposable.dispose()
+}
diff --git a/packages/amazonq/src/lsp/chat/webviewProvider.ts b/packages/amazonq/src/lsp/chat/webviewProvider.ts
new file mode 100644
index 00000000000..7bfab17f3ae
--- /dev/null
+++ b/packages/amazonq/src/lsp/chat/webviewProvider.ts
@@ -0,0 +1,73 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ EventEmitter,
+ CancellationToken,
+ Webview,
+ WebviewView,
+ WebviewViewProvider,
+ WebviewViewResolveContext,
+ Uri,
+} from 'vscode'
+import { LanguageServerResolver } from 'aws-core-vscode/shared'
+
+export class AmazonQChatViewProvider implements WebviewViewProvider {
+ public static readonly viewType = 'aws.amazonq.AmazonQChatView'
+ private readonly onDidResolveWebviewEmitter = new EventEmitter()
+ public readonly onDidResolveWebview = this.onDidResolveWebviewEmitter.event
+
+ webview: Webview | undefined
+
+ constructor(private readonly mynahUIPath: string) {}
+
+ public resolveWebviewView(webviewView: WebviewView, context: WebviewViewResolveContext, _token: CancellationToken) {
+ this.webview = webviewView.webview
+
+ const lspDir = Uri.parse(LanguageServerResolver.defaultDir)
+ webviewView.webview.options = {
+ enableScripts: true,
+ enableCommandUris: true,
+ localResourceRoots: [lspDir],
+ }
+
+ const uiPath = webviewView.webview.asWebviewUri(Uri.parse(this.mynahUIPath)).toString()
+ webviewView.webview.html = getWebviewContent(uiPath)
+
+ this.onDidResolveWebviewEmitter.fire()
+ }
+}
+
+function getWebviewContent(mynahUIPath: string) {
+ return `
+
+
+
+
+
+ Chat
+
+
+
+
+
+
+ `
+}
diff --git a/packages/amazonq/src/lsp/client.ts b/packages/amazonq/src/lsp/client.ts
new file mode 100644
index 00000000000..297ac21c1d6
--- /dev/null
+++ b/packages/amazonq/src/lsp/client.ts
@@ -0,0 +1,130 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import vscode, { env, version } from 'vscode'
+import * as nls from 'vscode-nls'
+import * as crypto from 'crypto'
+import { LanguageClient, LanguageClientOptions } from 'vscode-languageclient'
+import { registerInlineCompletion } from '../app/inline/completion'
+import { AmazonQLspAuth, encryptionKey, notificationTypes } from './auth'
+import { AuthUtil } from 'aws-core-vscode/codewhisperer'
+import { ConnectionMetadata } from '@aws/language-server-runtimes/protocol'
+import { Settings, oidcClientName, createServerOptions, globals, Experiments, getLogger } from 'aws-core-vscode/shared'
+import { activate } from './chat/activation'
+import { AmazonQResourcePaths } from './lspInstaller'
+
+const localize = nls.loadMessageBundle()
+
+export async function startLanguageServer(
+ extensionContext: vscode.ExtensionContext,
+ resourcePaths: AmazonQResourcePaths
+) {
+ const toDispose = extensionContext.subscriptions
+
+ const serverModule = resourcePaths.lsp
+
+ const serverOptions = createServerOptions({
+ encryptionKey,
+ executable: resourcePaths.node,
+ serverModule,
+ execArgv: [
+ '--nolazy',
+ '--preserve-symlinks',
+ '--stdio',
+ '--pre-init-encryption',
+ '--set-credentials-encryption-key',
+ ],
+ })
+
+ const documentSelector = [{ scheme: 'file', language: '*' }]
+
+ const clientId = 'amazonq'
+ const traceServerEnabled = Settings.instance.isSet(`${clientId}.trace.server`)
+
+ // Options to control the language client
+ const clientOptions: LanguageClientOptions = {
+ // Register the server for json documents
+ documentSelector,
+ initializationOptions: {
+ aws: {
+ clientInfo: {
+ name: env.appName,
+ version: version,
+ extension: {
+ name: oidcClientName(),
+ version: '0.0.1',
+ },
+ clientId: crypto.randomUUID(),
+ },
+ awsClientCapabilities: {
+ window: {
+ notifications: true,
+ },
+ },
+ },
+ credentials: {
+ providesBearerToken: true,
+ },
+ },
+ /**
+ * When the trace server is enabled it outputs a ton of log messages so:
+ * When trace server is enabled, logs go to a seperate "Amazon Q Language Server" output.
+ * Otherwise, logs go to the regular "Amazon Q Logs" channel.
+ */
+ ...(traceServerEnabled
+ ? {}
+ : {
+ outputChannel: globals.logOutputChannel,
+ }),
+ }
+
+ const client = new LanguageClient(
+ clientId,
+ localize('amazonq.server.name', 'Amazon Q Language Server'),
+ serverOptions,
+ clientOptions
+ )
+
+ const disposable = client.start()
+ toDispose.push(disposable)
+
+ const auth = new AmazonQLspAuth(client)
+
+ return client.onReady().then(async () => {
+ await auth.init()
+ registerInlineCompletion(client)
+ if (Experiments.instance.get('amazonqChatLSP', false)) {
+ activate(client, encryptionKey, resourcePaths.mynahUI)
+ }
+
+ // Request handler for when the server wants to know about the clients auth connnection
+ client.onRequest(notificationTypes.getConnectionMetadata.method, () => {
+ return {
+ sso: {
+ startUrl: AuthUtil.instance.auth.startUrl,
+ },
+ }
+ })
+
+ // Temporary code for pen test. Will be removed when we switch to the real flare auth
+ const authInterval = setInterval(async () => {
+ try {
+ await auth.init()
+ } catch (e) {
+ getLogger('amazonqLsp').error('Unable to update bearer token: %s', (e as Error).message)
+ clearInterval(authInterval)
+ }
+ }, 300000) // every 5 minutes
+
+ toDispose.push(
+ AuthUtil.instance.auth.onDidChangeActiveConnection(async () => {
+ await auth.init()
+ }),
+ AuthUtil.instance.auth.onDidDeleteConnection(async () => {
+ client.sendNotification(notificationTypes.deleteBearerToken.method)
+ })
+ )
+ })
+}
diff --git a/packages/amazonq/src/lsp/config.ts b/packages/amazonq/src/lsp/config.ts
new file mode 100644
index 00000000000..634cc43aab2
--- /dev/null
+++ b/packages/amazonq/src/lsp/config.ts
@@ -0,0 +1,22 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { DevSettings, getServiceEnvVarConfig } from 'aws-core-vscode/shared'
+import { LspConfig } from 'aws-core-vscode/amazonq'
+
+export const defaultAmazonQLspConfig: LspConfig = {
+ manifestUrl: 'https://aws-toolkit-language-servers.amazonaws.com/codewhisperer/0/manifest.json',
+ supportedVersions: '^3.1.1',
+ id: 'AmazonQ', // used for identification in global storage/local disk location. Do not change.
+ path: undefined,
+}
+
+export function getAmazonQLspConfig(): LspConfig {
+ return {
+ ...defaultAmazonQLspConfig,
+ ...(DevSettings.instance.getServiceConfig('amazonqLsp', {}) as LspConfig),
+ ...getServiceEnvVarConfig('amazonqLsp', Object.keys(defaultAmazonQLspConfig)),
+ }
+}
diff --git a/packages/amazonq/src/lsp/lspInstaller.ts b/packages/amazonq/src/lsp/lspInstaller.ts
new file mode 100644
index 00000000000..31866588e07
--- /dev/null
+++ b/packages/amazonq/src/lsp/lspInstaller.ts
@@ -0,0 +1,41 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { fs, getNodeExecutableName, BaseLspInstaller, ResourcePaths } from 'aws-core-vscode/shared'
+import path from 'path'
+import { getAmazonQLspConfig } from './config'
+import { LspConfig } from 'aws-core-vscode/amazonq'
+
+export interface AmazonQResourcePaths extends ResourcePaths {
+ mynahUI: string
+}
+
+export class AmazonQLspInstaller extends BaseLspInstaller.BaseLspInstaller {
+ constructor(lspConfig: LspConfig = getAmazonQLspConfig()) {
+ super(lspConfig, 'amazonqLsp')
+ }
+
+ protected override async postInstall(assetDirectory: string): Promise {
+ const resourcePaths = this.resourcePaths(assetDirectory)
+ await fs.chmod(resourcePaths.node, 0o755)
+ }
+
+ protected override resourcePaths(assetDirectory?: string): AmazonQResourcePaths {
+ if (!assetDirectory) {
+ return {
+ lsp: this.config.path ?? '',
+ node: getNodeExecutableName(),
+ mynahUI: '', // TODO make mynah UI configurable
+ }
+ }
+
+ const nodePath = path.join(assetDirectory, `servers/${getNodeExecutableName()}`)
+ return {
+ lsp: path.join(assetDirectory, 'servers/aws-lsp-codewhisperer.js'),
+ node: nodePath,
+ mynahUI: path.join(assetDirectory, 'clients/amazonq-ui.js'),
+ }
+ }
+}
diff --git a/packages/amazonq/test/e2e/lsp/amazonqLsp.test.ts b/packages/amazonq/test/e2e/lsp/amazonqLsp.test.ts
new file mode 100644
index 00000000000..d3e90ec4e8e
--- /dev/null
+++ b/packages/amazonq/test/e2e/lsp/amazonqLsp.test.ts
@@ -0,0 +1,31 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { AmazonQLspInstaller } from '../../../src/lsp/lspInstaller'
+import { defaultAmazonQLspConfig } from '../../../src/lsp/config'
+import { createLspInstallerTests } from './lspInstallerUtil'
+import { LspConfig } from 'aws-core-vscode/amazonq'
+
+describe('AmazonQLSP', () => {
+ createLspInstallerTests({
+ suiteName: 'AmazonQLSPInstaller',
+ lspConfig: defaultAmazonQLspConfig,
+ createInstaller: (lspConfig?: LspConfig) => new AmazonQLspInstaller(lspConfig),
+ targetContents: [
+ {
+ bytes: 0,
+ filename: 'servers.zip',
+ hashes: [],
+ url: 'http://fakeurl',
+ },
+ ],
+ setEnv: (path: string) => {
+ process.env.__AMAZONQLSP_PATH = path
+ },
+ resetEnv: () => {
+ delete process.env.__AMAZONQLSP_PATH
+ },
+ })
+})
diff --git a/packages/amazonq/test/e2e/lsp/lspInstallerUtil.ts b/packages/amazonq/test/e2e/lsp/lspInstallerUtil.ts
new file mode 100644
index 00000000000..0031e627d8c
--- /dev/null
+++ b/packages/amazonq/test/e2e/lsp/lspInstallerUtil.ts
@@ -0,0 +1,287 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import assert from 'assert'
+import sinon from 'sinon'
+import {
+ BaseLspInstaller,
+ DevSettings,
+ fs,
+ LanguageServerResolver,
+ makeTemporaryToolkitFolder,
+ ManifestResolver,
+ request,
+ TargetContent,
+} from 'aws-core-vscode/shared'
+import * as semver from 'semver'
+import { assertTelemetry } from 'aws-core-vscode/test'
+import { LspConfig, LspController } from 'aws-core-vscode/amazonq'
+import { LanguageServerSetup } from 'aws-core-vscode/telemetry'
+
+function createVersion(version: string, contents: TargetContent[]) {
+ return {
+ isDelisted: false,
+ serverVersion: version,
+ targets: [
+ {
+ arch: process.arch,
+ platform: process.platform,
+ contents,
+ },
+ ],
+ }
+}
+
+export function createLspInstallerTests({
+ suiteName,
+ lspConfig,
+ createInstaller,
+ targetContents,
+ setEnv,
+ resetEnv,
+}: {
+ suiteName: string
+ lspConfig: LspConfig
+ createInstaller: (lspConfig?: LspConfig) => BaseLspInstaller.BaseLspInstaller
+ targetContents: TargetContent[]
+ setEnv: (path: string) => void
+ resetEnv: () => void
+}) {
+ describe(suiteName, () => {
+ let installer: BaseLspInstaller.BaseLspInstaller
+ let sandbox: sinon.SinonSandbox
+ let tempDir: string
+
+ beforeEach(async () => {
+ sandbox = sinon.createSandbox()
+ installer = createInstaller()
+ tempDir = await makeTemporaryToolkitFolder()
+ sandbox.stub(LanguageServerResolver.prototype, 'defaultDownloadFolder').returns(tempDir)
+ // Called on extension activation and can contaminate telemetry.
+ sandbox.stub(LspController.prototype, 'trySetupLsp')
+ })
+
+ afterEach(async () => {
+ resetEnv()
+ sandbox.restore()
+ await fs.delete(tempDir, {
+ recursive: true,
+ })
+ })
+
+ describe('resolve()', () => {
+ it('uses dev setting override', async () => {
+ const path = '/custom/path/to/lsp'
+ sandbox.stub(DevSettings.instance, 'getServiceConfig').returns({
+ path,
+ })
+ /**
+ * The installer pre-evaluates the config, so if we want to override the config
+ * we need to stub then re-create it
+ */
+ const result = await createInstaller().resolve()
+
+ assert.strictEqual(result.assetDirectory, path)
+ assert.strictEqual(result.location, 'override')
+ assert.strictEqual(result.version, '0.0.0')
+ })
+
+ it('uses environment variable override', async () => {
+ const overridePath = '/custom/path/to/lsp'
+ setEnv(overridePath)
+
+ /**
+ * The installer pre-evaluates the config, so if we want to override the environment variables
+ * we need to override the env then re-create it
+ */
+ const result = await createInstaller().resolve()
+
+ assert.strictEqual(result.assetDirectory, overridePath)
+ assert.strictEqual(result.location, 'override')
+ assert.strictEqual(result.version, '0.0.0')
+ })
+
+ it('resolves', async () => {
+ // First try - should download the file
+ const download = await installer.resolve()
+
+ assert.ok(download.assetDirectory.startsWith(tempDir))
+ assert.deepStrictEqual(download.location, 'remote')
+ assert.ok(semver.satisfies(download.version, lspConfig.supportedVersions))
+
+ // Second try - Should see the contents in the cache
+ const cache = await installer.resolve()
+
+ assert.ok(cache.assetDirectory.startsWith(tempDir))
+ assert.deepStrictEqual(cache.location, 'cache')
+ assert.ok(semver.satisfies(cache.version, lspConfig.supportedVersions))
+
+ /**
+ * Always make sure the latest version is one patch higher. This stops a problem
+ * where the fallback can't be used because the latest compatible version
+ * is equal to the min version, so if the cache isn't valid, then there
+ * would be no fallback location
+ *
+ * Instead, increasing the latest compatible lsp version means we can just
+ * use the one we downloaded earlier in the test as the fallback
+ */
+ const nextVer = semver.inc(cache.version, 'patch', true)
+ if (!nextVer) {
+ throw new Error('Could not increment version')
+ }
+ sandbox.stub(ManifestResolver.prototype, 'resolve').resolves({
+ manifestSchemaVersion: '0.0.0',
+ artifactId: 'foo',
+ artifactDescription: 'foo',
+ isManifestDeprecated: false,
+ versions: [createVersion(nextVer, targetContents), createVersion(cache.version, targetContents)],
+ })
+
+ // fail the next http request for the language server
+ sandbox.stub(request, 'fetch').returns({
+ response: Promise.resolve({
+ ok: false,
+ }),
+ } as any)
+
+ const config = {
+ ...lspConfig,
+ // contains the old version thats actually on disk + the new version
+ supportedVersions: `${cache.version} || ${nextVer}`,
+ }
+
+ // Third try - Cache doesn't exist and we couldn't download from the internet, fallback to a local version
+ const fallback = await createInstaller(config).resolve()
+
+ assert.ok(fallback.assetDirectory.startsWith(tempDir))
+ assert.deepStrictEqual(fallback.location, 'fallback')
+ assert.ok(semver.satisfies(fallback.version, lspConfig.supportedVersions))
+
+ /* First Try Telemetry
+ getManifest: remote succeeds
+ getServer: cache fails then remote succeeds.
+ validate: succeeds.
+ */
+ const firstTryTelemetry: Partial[] = [
+ {
+ id: lspConfig.id,
+ manifestLocation: 'remote',
+ languageServerSetupStage: 'getManifest',
+ result: 'Succeeded',
+ },
+ {
+ id: lspConfig.id,
+ languageServerLocation: 'cache',
+ languageServerSetupStage: 'getServer',
+ result: 'Failed',
+ },
+ {
+ id: lspConfig.id,
+ languageServerLocation: 'remote',
+ languageServerSetupStage: 'validate',
+ result: 'Succeeded',
+ },
+ {
+ id: lspConfig.id,
+ languageServerLocation: 'remote',
+ languageServerSetupStage: 'getServer',
+ result: 'Succeeded',
+ },
+ ]
+
+ /* Second Try Telemetry
+ getManifest: remote fails, then cache succeeds.
+ getServer: cache succeeds
+ validate: doesn't run since its cached.
+ */
+ const secondTryTelemetry: Partial[] = [
+ {
+ id: lspConfig.id,
+ manifestLocation: 'remote',
+ languageServerSetupStage: 'getManifest',
+ result: 'Failed',
+ },
+ {
+ id: lspConfig.id,
+ manifestLocation: 'cache',
+ languageServerSetupStage: 'getManifest',
+ result: 'Succeeded',
+ },
+ {
+ id: lspConfig.id,
+ languageServerLocation: 'cache',
+ languageServerSetupStage: 'getServer',
+ result: 'Succeeded',
+ },
+ ]
+
+ /* Third Try Telemetry
+ getManifest: (stubbed to fail, no telemetry)
+ getServer: remote and cache fail
+ validate: no validation since not remote.
+ */
+ const thirdTryTelemetry: Partial[] = [
+ {
+ id: lspConfig.id,
+ languageServerLocation: 'cache',
+ languageServerSetupStage: 'getServer',
+ result: 'Failed',
+ },
+ {
+ id: lspConfig.id,
+ languageServerLocation: 'remote',
+ languageServerSetupStage: 'getServer',
+ result: 'Failed',
+ },
+ {
+ id: lspConfig.id,
+ languageServerLocation: 'fallback',
+ languageServerSetupStage: 'getServer',
+ result: 'Succeeded',
+ },
+ ]
+
+ const expectedTelemetry = firstTryTelemetry.concat(secondTryTelemetry, thirdTryTelemetry)
+
+ assertTelemetry('languageServer_setup', expectedTelemetry)
+ })
+
+ it('resolves release candidiates', async () => {
+ const original = new ManifestResolver(lspConfig.manifestUrl, lspConfig.id).resolve()
+ sandbox.stub(ManifestResolver.prototype, 'resolve').callsFake(async () => {
+ const originalManifest = await original
+
+ const latestVersion = originalManifest.versions.reduce((latest, current) => {
+ return semver.gt(current.serverVersion, latest.serverVersion) ? current : latest
+ }, originalManifest.versions[0])
+
+ // These convert something like 3.1.1 to 3.1.2-rc.0
+ const incrementedVersion = semver.inc(latestVersion.serverVersion, 'patch')
+ if (!incrementedVersion) {
+ assert.fail('Failed to increment minor version')
+ }
+
+ const prereleaseVersion = semver.inc(incrementedVersion, 'prerelease', 'rc')
+ if (!prereleaseVersion) {
+ assert.fail('Failed to create pre-release version')
+ }
+
+ const newVersion = {
+ ...latestVersion,
+ serverVersion: prereleaseVersion,
+ }
+
+ originalManifest.versions = [newVersion, ...originalManifest.versions]
+ return originalManifest
+ })
+
+ const version = lspConfig.supportedVersions
+ lspConfig.supportedVersions = version.startsWith('^') ? version : `^${version}`
+ const download = await createInstaller(lspConfig).resolve()
+ assert.ok(download.assetDirectory.endsWith('-rc.0'))
+ })
+ })
+ })
+}
diff --git a/packages/amazonq/test/e2e/lsp/workspaceContextLsp.test.ts b/packages/amazonq/test/e2e/lsp/workspaceContextLsp.test.ts
new file mode 100644
index 00000000000..75d57949c0b
--- /dev/null
+++ b/packages/amazonq/test/e2e/lsp/workspaceContextLsp.test.ts
@@ -0,0 +1,42 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as os from 'os'
+import { createLspInstallerTests } from './lspInstallerUtil'
+import { defaultAmazonQWorkspaceLspConfig, LspClient, LspConfig, WorkspaceLspInstaller } from 'aws-core-vscode/amazonq'
+import assert from 'assert'
+
+describe('AmazonQWorkspaceLSP', () => {
+ createLspInstallerTests({
+ suiteName: 'AmazonQWorkspaceLSPInstaller',
+ lspConfig: defaultAmazonQWorkspaceLspConfig,
+ createInstaller: (lspConfig?: LspConfig) => new WorkspaceLspInstaller.WorkspaceLspInstaller(lspConfig),
+ targetContents: [
+ {
+ bytes: 0,
+ filename: `qserver-${os.platform()}-${os.arch()}.zip`,
+ hashes: [],
+ url: 'http://fakeurl',
+ },
+ ],
+ setEnv: (path: string) => {
+ process.env.__AMAZONQWORKSPACELSP_PATH = path
+ },
+ resetEnv: () => {
+ delete process.env.__AMAZONQWORKSPACELSP_PATH
+ },
+ })
+
+ it('activates', async () => {
+ const ok = await LspClient.instance.waitUntilReady()
+ if (!ok) {
+ assert.fail('Workspace context language server failed to become ready')
+ }
+ const serverUsage = await LspClient.instance.getLspServerUsage()
+ if (!serverUsage) {
+ assert.fail('Unable to verify that the workspace context language server has been activated')
+ }
+ })
+})
diff --git a/packages/amazonq/test/unit/amazonq/lsp/config.test.ts b/packages/amazonq/test/unit/amazonq/lsp/config.test.ts
new file mode 100644
index 00000000000..9a9ba1ef348
--- /dev/null
+++ b/packages/amazonq/test/unit/amazonq/lsp/config.test.ts
@@ -0,0 +1,110 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import assert from 'assert'
+import { DevSettings } from 'aws-core-vscode/shared'
+import sinon from 'sinon'
+import { defaultAmazonQLspConfig, getAmazonQLspConfig } from '../../../../src/lsp/config'
+import { LspConfig, getAmazonQWorkspaceLspConfig, defaultAmazonQWorkspaceLspConfig } from 'aws-core-vscode/amazonq'
+
+for (const [name, config, defaultConfig, setEnv, resetEnv] of [
+ [
+ 'getAmazonQLspConfig',
+ getAmazonQLspConfig,
+ defaultAmazonQLspConfig,
+ (envConfig: LspConfig) => {
+ process.env.__AMAZONQLSP_MANIFEST_URL = envConfig.manifestUrl
+ process.env.__AMAZONQLSP_SUPPORTED_VERSIONS = envConfig.supportedVersions
+ process.env.__AMAZONQLSP_ID = envConfig.id
+ process.env.__AMAZONQLSP_PATH = envConfig.path
+ },
+ () => {
+ delete process.env.__AMAZONQLSP_MANIFEST_URL
+ delete process.env.__AMAZONQLSP_SUPPORTED_VERSIONS
+ delete process.env.__AMAZONQLSP_ID
+ delete process.env.__AMAZONQLSP_PATH
+ },
+ ],
+ [
+ 'getAmazonQWorkspaceLspConfig',
+ getAmazonQWorkspaceLspConfig,
+ defaultAmazonQWorkspaceLspConfig,
+ (envConfig: LspConfig) => {
+ process.env.__AMAZONQWORKSPACELSP_MANIFEST_URL = envConfig.manifestUrl
+ process.env.__AMAZONQWORKSPACELSP_SUPPORTED_VERSIONS = envConfig.supportedVersions
+ process.env.__AMAZONQWORKSPACELSP_ID = envConfig.id
+ process.env.__AMAZONQWORKSPACELSP_PATH = envConfig.path
+ },
+ () => {
+ delete process.env.__AMAZONQWORKSPACELSP_MANIFEST_URL
+ delete process.env.__AMAZONQWORKSPACELSP_SUPPORTED_VERSIONS
+ delete process.env.__AMAZONQWORKSPACELSP_ID
+ delete process.env.__AMAZONQWORKSPACELSP_PATH
+ },
+ ],
+] as const) {
+ describe(name, () => {
+ let sandbox: sinon.SinonSandbox
+ let serviceConfigStub: sinon.SinonStub
+ const settingConfig: LspConfig = {
+ manifestUrl: 'https://custom.url/manifest.json',
+ supportedVersions: '4.0.0',
+ id: 'AmazonQSetting',
+ path: '/custom/path',
+ }
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox()
+
+ serviceConfigStub = sandbox.stub()
+ sandbox.stub(DevSettings, 'instance').get(() => ({
+ getServiceConfig: serviceConfigStub,
+ }))
+ })
+
+ afterEach(() => {
+ sandbox.restore()
+ resetEnv()
+ })
+
+ it('uses default config', () => {
+ serviceConfigStub.returns({})
+ assert.deepStrictEqual(config(), defaultConfig)
+ })
+
+ it('overrides location', () => {
+ const path = '/custom/path/to/lsp'
+ serviceConfigStub.returns({ path })
+
+ assert.deepStrictEqual(config(), {
+ ...defaultConfig,
+ path,
+ })
+ })
+
+ it('overrides default settings', () => {
+ serviceConfigStub.returns(settingConfig)
+
+ assert.deepStrictEqual(config(), settingConfig)
+ })
+
+ it('environment variable takes precedence over settings', () => {
+ const envConfig: LspConfig = {
+ manifestUrl: 'https://another-custom.url/manifest.json',
+ supportedVersions: '5.1.1',
+ id: 'AmazonQEnv',
+ path: '/some/new/custom/path',
+ }
+
+ setEnv(envConfig)
+ serviceConfigStub.returns(settingConfig)
+
+ assert.deepStrictEqual(config(), {
+ ...defaultAmazonQLspConfig,
+ ...envConfig,
+ })
+ })
+ })
+}
diff --git a/packages/amazonq/test/unit/amazonq/lsp/lspController.test.ts b/packages/amazonq/test/unit/amazonq/lsp/lspController.test.ts
deleted file mode 100644
index d54551e433f..00000000000
--- a/packages/amazonq/test/unit/amazonq/lsp/lspController.test.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-/*!
- * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
- * SPDX-License-Identifier: Apache-2.0
- */
-import assert from 'assert'
-import sinon from 'sinon'
-import { Content, LspController } from 'aws-core-vscode/amazonq'
-import { createTestFile } from 'aws-core-vscode/test'
-import { fs } from 'aws-core-vscode/shared'
-
-describe('Amazon Q LSP controller', function () {
- it('Download mechanism checks against hash, when hash matches', async function () {
- const content = {
- filename: 'qserver-linux-x64.zip',
- url: 'https://x/0.0.6/qserver-linux-x64.zip',
- hashes: [
- 'sha384:768412320f7b0aa5812fce428dc4706b3cae50e02a64caa16a782249bfe8efc4b7ef1ccb126255d196047dfedf17a0a9',
- ],
- bytes: 512,
- } as Content
- const lspController = new LspController()
- sinon.stub(lspController, '_download')
- const mockFileName = 'test_case_1.zip'
- const mockDownloadFile = await createTestFile(mockFileName)
- await fs.writeFile(mockDownloadFile.fsPath, 'test')
- const result = await lspController.downloadAndCheckHash(mockDownloadFile.fsPath, content)
- assert.strictEqual(result, true)
- })
-
- it('Download mechanism checks against hash, when hash does not match', async function () {
- const content = {
- filename: 'qserver-linux-x64.zip',
- url: 'https://x/0.0.6/qserver-linux-x64.zip',
- hashes: [
- 'sha384:38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b',
- ],
- bytes: 512,
- } as Content
- const lspController = new LspController()
- sinon.stub(lspController, '_download')
- const mockFileName = 'test_case_2.zip'
- const mockDownloadFile = await createTestFile(mockFileName)
- await fs.writeFile(mockDownloadFile.fsPath, 'file_content')
- const result = await lspController.downloadAndCheckHash(mockDownloadFile.fsPath, content)
- assert.strictEqual(result, false)
- })
-
- afterEach(() => {
- sinon.restore()
- })
-})
diff --git a/packages/core/package.json b/packages/core/package.json
index cfd56511826..a4dbf94eaee 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -439,6 +439,8 @@
"serveVue": "Local server for Vue.js code for development purposes. Provides faster iteration when updating Vue files"
},
"devDependencies": {
+ "@aws/language-server-runtimes": "^0.2.27",
+ "@aws/chat-client-ui-types": "^0.0.8",
"@aws-sdk/types": "^3.13.1",
"@cspotcode/source-map-support": "^0.8.1",
"@sinonjs/fake-timers": "^10.0.2",
diff --git a/packages/core/src/amazonq/index.ts b/packages/core/src/amazonq/index.ts
index a16ed4cc438..98ab00087a3 100644
--- a/packages/core/src/amazonq/index.ts
+++ b/packages/core/src/amazonq/index.ts
@@ -15,7 +15,7 @@ export {
walkthroughInlineSuggestionsExample,
walkthroughSecurityScanExample,
} from './onboardingPage/walkthrough'
-export { LspController, Content } from './lsp/lspController'
+export { LspController } from './lsp/lspController'
export { LspClient } from './lsp/lspClient'
export { api } from './extApi'
export { AmazonQChatViewProvider } from './webview/webView'
@@ -44,6 +44,8 @@ export { ExtensionMessage } from '../amazonq/webview/ui/commands'
export { CodeReference } from '../codewhispererChat/view/connector/connector'
export { extractAuthFollowUp } from './util/authUtils'
export { Messenger } from './commons/connector/baseMessenger'
+export * from './lsp/config'
+export * as WorkspaceLspInstaller from './lsp/workspaceInstaller'
import { FeatureContext } from '../shared/featureConfig'
/**
diff --git a/packages/core/src/amazonq/lsp/config.ts b/packages/core/src/amazonq/lsp/config.ts
new file mode 100644
index 00000000000..8557d3cff63
--- /dev/null
+++ b/packages/core/src/amazonq/lsp/config.ts
@@ -0,0 +1,29 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { DevSettings } from '../../shared/settings'
+import { getServiceEnvVarConfig } from '../../shared/vscode/env'
+
+export interface LspConfig {
+ manifestUrl: string
+ supportedVersions: string
+ id: string
+ path?: string
+}
+
+export const defaultAmazonQWorkspaceLspConfig: LspConfig = {
+ manifestUrl: 'https://aws-toolkit-language-servers.amazonaws.com/q-context/manifest.json',
+ supportedVersions: '0.1.42',
+ id: 'AmazonQ-Workspace', // used for identification in global storage/local disk location. Do not change.
+ path: undefined,
+}
+
+export function getAmazonQWorkspaceLspConfig(): LspConfig {
+ return {
+ ...defaultAmazonQWorkspaceLspConfig,
+ ...(DevSettings.instance.getServiceConfig('amazonqWorkspaceLsp', {}) as LspConfig),
+ ...getServiceEnvVarConfig('amazonqWorkspaceLsp', Object.keys(defaultAmazonQWorkspaceLspConfig)),
+ }
+}
diff --git a/packages/core/src/amazonq/lsp/lspClient.ts b/packages/core/src/amazonq/lsp/lspClient.ts
index 5d96650ebf8..e895f0fa8c7 100644
--- a/packages/core/src/amazonq/lsp/lspClient.ts
+++ b/packages/core/src/amazonq/lsp/lspClient.ts
@@ -10,7 +10,6 @@
import * as vscode from 'vscode'
import * as path from 'path'
import * as nls from 'vscode-nls'
-import { spawn } from 'child_process' // eslint-disable-line no-restricted-imports
import * as crypto from 'crypto'
import * as jose from 'jose'
@@ -35,30 +34,18 @@ import {
GetContextCommandPromptRequestType,
AdditionalContextPrompt,
} from './types'
-import { Writable } from 'stream'
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 } from '../../shared/lsp/utils/platform'
import { waitUntil } from '../../shared/utilities/timeoutUtils'
const localize = nls.loadMessageBundle()
const key = crypto.randomBytes(32)
-/**
- * Sends a json payload to the language server, who is waiting to know what the encryption key is.
- * Code reference: https://github.com/aws/language-servers/blob/7da212185a5da75a72ce49a1a7982983f438651a/client/vscode/src/credentialsActivation.ts#L77
- */
-export function writeEncryptionInit(stream: Writable): void {
- const request = {
- version: '1.0',
- mode: 'JWT',
- key: key.toString('base64'),
- }
- stream.write(JSON.stringify(request))
- stream.write('\n')
-}
/**
* LspClient manages the API call between VS Code extension and LSP server
* It encryptes the payload of API call.
@@ -240,13 +227,11 @@ export class LspClient {
* It will create a output channel named Amazon Q Language Server.
* This function assumes the LSP server has already been downloaded.
*/
-export async function activate(extensionContext: ExtensionContext) {
+export async function activate(extensionContext: ExtensionContext, resourcePaths: ResourcePaths) {
LspClient.instance
const toDispose = extensionContext.subscriptions
let rangeFormatting: Disposable | undefined
- // The server is implemented in node
- const serverModule = path.join(extensionContext.extensionPath, 'resources/qserver/lspServer.js')
// 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'] }
@@ -265,15 +250,7 @@ export async function activate(extensionContext: ExtensionContext) {
delete process.env.Q_WORKER_THREADS
}
- const nodename = process.platform === 'win32' ? 'node.exe' : 'node'
-
- const child = spawn(extensionContext.asAbsolutePath(path.join('resources', nodename)), [
- serverModule,
- ...debugOptions.execArgv,
- ])
- // share an encryption key using stdin
- // follow same practice of DEXP LSP server
- writeEncryptionInit(child.stdin)
+ const serverModule = resourcePaths.lsp
// If the extension is launch in debug mode the debug server options are use
// Otherwise the run options are used
@@ -282,7 +259,12 @@ export async function activate(extensionContext: ExtensionContext) {
debug: { module: serverModule, transport: TransportKind.ipc, options: debugOptions },
}
- serverOptions = () => Promise.resolve(child!)
+ serverOptions = createServerOptions({
+ encryptionKey: key,
+ executable: resourcePaths.node,
+ serverModule,
+ execArgv: debugOptions.execArgv,
+ })
const documentSelector = [{ scheme: 'file', language: '*' }]
diff --git a/packages/core/src/amazonq/lsp/lspController.ts b/packages/core/src/amazonq/lsp/lspController.ts
index 5fb4d3668a3..a789d1dd5cb 100644
--- a/packages/core/src/amazonq/lsp/lspController.ts
+++ b/packages/core/src/amazonq/lsp/lspController.ts
@@ -5,24 +5,15 @@
import * as vscode from 'vscode'
import * as path from 'path'
-import * as crypto from 'crypto'
-import { createWriteStream } from 'fs' // eslint-disable-line no-restricted-imports
import { getLogger } from '../../shared/logger/logger'
import { CurrentWsFolders, collectFilesForIndex } from '../../shared/utilities/workspaceUtils'
-import fetch from 'node-fetch'
-import request from '../../shared/request'
-import { LspClient } from './lspClient'
-import AdmZip from 'adm-zip'
-import { makeTemporaryToolkitFolder, tryRemoveFolder } from '../../shared/filesystemUtilities'
-import { activate as activateLsp } from './lspClient'
+import { activate as activateLsp, LspClient } from './lspClient'
import { telemetry } from '../../shared/telemetry/telemetry'
import { isCloud9 } from '../../shared/extensionUtilities'
-import { fs } from '../../shared/fs/fs'
-import globals from '../../shared/extensionGlobals'
-import { ToolkitError } from '../../shared/errors'
-import { isWeb } from '../../shared/extensionGlobals'
-import { getUserAgent } from '../../shared/telemetry/util'
+import globals, { isWeb } from '../../shared/extensionGlobals'
import { isAmazonInternalOs } from '../../shared/vscode/env'
+import { WorkspaceLspInstaller } from './workspaceInstaller'
+import { lspSetupStage } from '../../shared/lsp/utils/setupStage'
import { RelevantTextDocumentAddition } from '../../codewhispererChat/controllers/chat/model'
export interface Chunk {
@@ -34,38 +25,6 @@ export interface Chunk {
readonly startLine?: number
readonly endLine?: number
}
-
-export interface Content {
- filename: string
- url: string
- hashes: string[]
- bytes: number
- serverVersion?: string
-}
-
-export interface Target {
- platform: string
- arch: string
- contents: Content[]
-}
-
-export interface Manifest {
- manifestSchemaVersion: string
- artifactId: string
- artifactDescription: string
- isManifestDeprecated: boolean
- versions: {
- serverVersion: string
- isDelisted: boolean
- targets: Target[]
- }[]
-}
-const manifestUrl = 'https://aws-toolkit-language-servers.amazonaws.com/q-context/manifest.json'
-// this LSP client in Q extension is only going to work with these LSP server versions
-const supportedLspServerVersions = ['0.1.42']
-
-const nodeBinName = process.platform === 'win32' ? 'node.exe' : 'node'
-
export interface BuildIndexConfig {
startUrl?: string
maxIndexSize: number
@@ -73,7 +32,7 @@ export interface BuildIndexConfig {
}
/*
- * LSP Controller manages the status of Amazon Q LSP:
+ * LSP Controller manages the status of Amazon Q Workspace Indexing LSP:
* 1. Downloading, verifying and installing LSP using DEXP LSP manifest and CDN.
* 2. Managing the LSP states. There are a couple of possible LSP states:
* Not installed. Installed. Running. Indexing. Indexing Done.
@@ -86,201 +45,16 @@ export interface BuildIndexConfig {
export class LspController {
static #instance: LspController
private _isIndexingInProgress = false
+ private logger = getLogger('amazonqWorkspaceLsp')
public static get instance() {
return (this.#instance ??= new this())
}
- constructor() {}
isIndexingInProgress() {
return this._isIndexingInProgress
}
- async _download(localFile: string, remoteUrl: string) {
- const res = await fetch(remoteUrl, {
- headers: {
- 'User-Agent': getUserAgent({ includePlatform: true, includeClientId: true }),
- },
- })
- if (!res.ok) {
- throw new ToolkitError(`Failed to download. Error: ${JSON.stringify(res)}`)
- }
- return new Promise((resolve, reject) => {
- const file = createWriteStream(localFile)
- res.body.pipe(file)
- res.body.on('error', (err) => {
- reject(err)
- })
- file.on('finish', () => {
- file.close(resolve)
- })
- })
- }
-
- async fetchManifest() {
- try {
- const resp = await request.fetch('GET', manifestUrl, {
- headers: {
- 'User-Agent': getUserAgent({ includePlatform: true, includeClientId: true }),
- },
- }).response
- if (!resp.ok) {
- throw new ToolkitError(`Failed to fetch manifest. Error: ${resp.statusText}`)
- }
- return resp.json()
- } catch (e: any) {
- throw new ToolkitError(`Failed to fetch manifest. Error: ${JSON.stringify(e)}`)
- }
- }
-
- async getFileSha384(filePath: string): Promise {
- const fileBuffer = await fs.readFileBytes(filePath)
- const hash = crypto.createHash('sha384')
- hash.update(fileBuffer)
- return hash.digest('hex')
- }
-
- async isLspInstalled(context: vscode.ExtensionContext) {
- const localQServer = context.asAbsolutePath(path.join('resources', 'qserver'))
- const localNodeRuntime = context.asAbsolutePath(path.join('resources', nodeBinName))
- return (await fs.exists(localQServer)) && (await fs.exists(localNodeRuntime))
- }
-
- getQserverFromManifest(manifest: Manifest): Content | undefined {
- if (manifest.isManifestDeprecated) {
- return undefined
- }
- for (const version of manifest.versions) {
- if (version.isDelisted) {
- continue
- }
- if (!supportedLspServerVersions.includes(version.serverVersion)) {
- continue
- }
- for (const t of version.targets) {
- if (
- (t.platform === process.platform || (t.platform === 'windows' && process.platform === 'win32')) &&
- t.arch === process.arch
- ) {
- for (const content of t.contents) {
- if (content.filename.startsWith('qserver') && content.hashes.length > 0) {
- content.serverVersion = version.serverVersion
- return content
- }
- }
- }
- }
- }
- return undefined
- }
-
- getNodeRuntimeFromManifest(manifest: Manifest): Content | undefined {
- if (manifest.isManifestDeprecated) {
- return undefined
- }
- for (const version of manifest.versions) {
- if (version.isDelisted) {
- continue
- }
- if (!supportedLspServerVersions.includes(version.serverVersion)) {
- continue
- }
- for (const t of version.targets) {
- if (
- (t.platform === process.platform || (t.platform === 'windows' && process.platform === 'win32')) &&
- t.arch === process.arch
- ) {
- for (const content of t.contents) {
- if (content.filename.startsWith('node') && content.hashes.length > 0) {
- content.serverVersion = version.serverVersion
- return content
- }
- }
- }
- }
- }
- return undefined
- }
-
- private async hashMatch(filePath: string, content: Content) {
- const sha384 = await this.getFileSha384(filePath)
- if ('sha384:' + sha384 !== content.hashes[0]) {
- getLogger().error(
- `LspController: Downloaded file sha ${sha384} does not match manifest ${content.hashes[0]}.`
- )
- await fs.delete(filePath)
- return false
- }
- return true
- }
-
- async downloadAndCheckHash(filePath: string, content: Content) {
- await this._download(filePath, content.url)
- const match = await this.hashMatch(filePath, content)
- if (!match) {
- return false
- }
- return true
- }
-
- async tryInstallLsp(context: vscode.ExtensionContext): Promise {
- let tempFolder = undefined
- try {
- if (await this.isLspInstalled(context)) {
- getLogger().info(`LspController: LSP already installed`)
- return true
- }
- // clean up previous downloaded LSP
- const qserverPath = context.asAbsolutePath(path.join('resources', 'qserver'))
- if (await fs.exists(qserverPath)) {
- await tryRemoveFolder(qserverPath)
- }
- // clean up previous downloaded node runtime
- const nodeRuntimePath = context.asAbsolutePath(path.join('resources', nodeBinName))
- if (await fs.exists(nodeRuntimePath)) {
- await fs.delete(nodeRuntimePath)
- }
- // fetch download url for qserver and node runtime
- const manifest: Manifest = (await this.fetchManifest()) as Manifest
- const qserverContent = this.getQserverFromManifest(manifest)
- const nodeRuntimeContent = this.getNodeRuntimeFromManifest(manifest)
- if (!qserverContent || !nodeRuntimeContent) {
- getLogger().info(`LspController: Did not find LSP URL for ${process.platform} ${process.arch}`)
- return false
- }
-
- tempFolder = await makeTemporaryToolkitFolder()
-
- // download lsp to temp folder
- const qserverZipTempPath = path.join(tempFolder, 'qserver.zip')
- const downloadOk = await this.downloadAndCheckHash(qserverZipTempPath, qserverContent)
- if (!downloadOk) {
- return false
- }
- const zip = new AdmZip(qserverZipTempPath)
- zip.extractAllTo(tempFolder)
- await fs.rename(path.join(tempFolder, 'qserver'), qserverPath)
-
- // download node runtime to temp folder
- const nodeRuntimeTempPath = path.join(tempFolder, nodeBinName)
- const downloadNodeOk = await this.downloadAndCheckHash(nodeRuntimeTempPath, nodeRuntimeContent)
- if (!downloadNodeOk) {
- return false
- }
- await fs.chmod(nodeRuntimeTempPath, 0o755)
- await fs.rename(nodeRuntimeTempPath, nodeRuntimePath)
- return true
- } catch (e) {
- getLogger().error(`LspController: Failed to setup LSP server ${e}`)
- return false
- } finally {
- // clean up temp folder
- if (tempFolder) {
- await tryRemoveFolder(tempFolder)
- }
- }
- }
-
async query(s: string): Promise {
const chunks: Chunk[] | undefined = await LspClient.instance.queryVectorIndex(s)
const resp: RelevantTextDocumentAddition[] = []
@@ -315,18 +89,18 @@ export class LspController {
return await LspClient.instance.queryInlineProjectContext(query, path, target)
} catch (e) {
if (e instanceof Error) {
- getLogger().error(`unexpected error while querying inline project context, e=${e.message}`)
+ this.logger.error(`unexpected error while querying inline project context, e=${e.message}`)
}
return []
}
}
async buildIndex(buildIndexConfig: BuildIndexConfig) {
- getLogger().info(`LspController: Starting to build index of project`)
+ this.logger.info(`LspController: Starting to build index of project`)
const start = performance.now()
const projPaths = (vscode.workspace.workspaceFolders ?? []).map((folder) => folder.uri.fsPath)
if (projPaths.length === 0) {
- getLogger().info(`LspController: Skipping building index. No projects found in workspace`)
+ this.logger.info(`LspController: Skipping building index. No projects found in workspace`)
return
}
projPaths.sort()
@@ -343,12 +117,12 @@ export class LspController {
(accumulator, currentFile) => accumulator + currentFile.fileSizeBytes,
0
)
- getLogger().info(`LspController: Found ${files.length} files in current project ${projPaths}`)
+ this.logger.info(`LspController: Found ${files.length} files in current project ${projPaths}`)
const config = buildIndexConfig.isVectorIndexEnabled ? 'all' : 'default'
const r = files.map((f) => f.fileUri.fsPath)
const resp = await LspClient.instance.buildIndex(r, projRoot, config)
if (resp) {
- getLogger().debug(`LspController: Finish building index of project`)
+ this.logger.debug(`LspController: Finish building index of project`)
const usage = await LspClient.instance.getLspServerUsage()
telemetry.amazonq_indexWorkspace.emit({
duration: performance.now() - start,
@@ -361,7 +135,7 @@ export class LspController {
credentialStartUrl: buildIndexConfig.startUrl,
})
} else {
- getLogger().error(`LspController: Failed to build index of project`)
+ this.logger.error(`LspController: Failed to build index of project`)
telemetry.amazonq_indexWorkspace.emit({
duration: performance.now() - start,
result: 'Failed',
@@ -373,7 +147,7 @@ export class LspController {
}
} catch (error) {
// TODO: use telemetry.run()
- getLogger().error(`LspController: Failed to build index of project`)
+ this.logger.error(`LspController: Failed to build index of project`)
telemetry.amazonq_indexWorkspace.emit({
duration: performance.now() - start,
result: 'Failed',
@@ -390,18 +164,13 @@ export class LspController {
async trySetupLsp(context: vscode.ExtensionContext, buildIndexConfig: BuildIndexConfig) {
if (isCloud9() || isWeb() || isAmazonInternalOs()) {
- getLogger().warn('LspController: Skipping LSP setup. LSP is not compatible with the current environment. ')
+ this.logger.warn('LspController: Skipping LSP setup. LSP is not compatible with the current environment. ')
// do not do anything if in Cloud9 or Web mode or in AL2 (AL2 does not support node v18+)
return
}
setImmediate(async () => {
- const ok = await LspController.instance.tryInstallLsp(context)
- if (!ok) {
- return
- }
try {
- await activateLsp(context)
- getLogger().info('LspController: LSP activated')
+ await this.setupLsp(context)
await vscode.commands.executeCommand(`aws.amazonq.updateContextCommandItems`)
void LspController.instance.buildIndex(buildIndexConfig)
// log the LSP server CPU and Memory usage per 30 minutes.
@@ -409,7 +178,7 @@ export class LspController {
async () => {
const usage = await LspClient.instance.getLspServerUsage()
if (usage) {
- getLogger().info(
+ this.logger.info(
`LspController: LSP server CPU ${usage.cpuUsage}%, LSP server Memory ${
usage.memoryUsage / (1024 * 1024)
}MB `
@@ -419,8 +188,16 @@ export class LspController {
30 * 60 * 1000
)
} catch (e) {
- getLogger().error(`LspController: LSP failed to activate ${e}`)
+ this.logger.error(`LspController: LSP failed to activate ${e}`)
}
})
}
+
+ private async setupLsp(context: vscode.ExtensionContext) {
+ await lspSetupStage('all', async () => {
+ const installResult = await new WorkspaceLspInstaller().resolve()
+ await lspSetupStage('launch', async () => activateLsp(context, installResult.resourcePaths))
+ this.logger.info('LspController: LSP activated')
+ })
+ }
}
diff --git a/packages/core/src/amazonq/lsp/workspaceInstaller.ts b/packages/core/src/amazonq/lsp/workspaceInstaller.ts
new file mode 100644
index 00000000000..99e70f20cbf
--- /dev/null
+++ b/packages/core/src/amazonq/lsp/workspaceInstaller.ts
@@ -0,0 +1,39 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import path from 'path'
+import { ResourcePaths } from '../../shared/lsp/types'
+import { getNodeExecutableName } from '../../shared/lsp/utils/platform'
+import { fs } from '../../shared/fs/fs'
+import { BaseLspInstaller } from '../../shared/lsp/baseLspInstaller'
+import { getAmazonQWorkspaceLspConfig, LspConfig } from './config'
+
+export class WorkspaceLspInstaller extends BaseLspInstaller {
+ constructor(lspConfig: LspConfig = getAmazonQWorkspaceLspConfig()) {
+ super(lspConfig, 'amazonqWorkspaceLsp')
+ }
+
+ protected override async postInstall(assetDirectory: string): Promise {
+ const resourcePaths = this.resourcePaths(assetDirectory)
+ await fs.chmod(resourcePaths.node, 0o755)
+ }
+
+ protected override resourcePaths(assetDirectory?: string): ResourcePaths {
+ // local version
+ if (!assetDirectory) {
+ return {
+ lsp: this.config.path ?? '',
+ node: getNodeExecutableName(),
+ }
+ }
+
+ const lspNodeName =
+ process.platform === 'win32' ? getNodeExecutableName() : `node-${process.platform}-${process.arch}`
+ return {
+ lsp: path.join(assetDirectory, `qserver-${process.platform}-${process.arch}/qserver/lspServer.js`),
+ node: path.join(assetDirectory, lspNodeName),
+ }
+ }
+}
diff --git a/packages/core/src/auth/sso/ssoAccessTokenProvider.ts b/packages/core/src/auth/sso/ssoAccessTokenProvider.ts
index 5ee8513cbb5..e753fb2ef90 100644
--- a/packages/core/src/auth/sso/ssoAccessTokenProvider.ts
+++ b/packages/core/src/auth/sso/ssoAccessTokenProvider.ts
@@ -26,7 +26,7 @@ import { AwsLoginWithBrowser, AwsRefreshCredentials, telemetry } from '../../sha
import { indent, toBase64URL } from '../../shared/utilities/textUtilities'
import { AuthSSOServer } from './server'
import { CancellationError, sleep } from '../../shared/utilities/timeoutUtils'
-import { getIdeProperties, isAmazonQ, isCloud9 } from '../../shared/extensionUtilities'
+import { oidcClientName, isAmazonQ } from '../../shared/extensionUtilities'
import { randomBytes, createHash } from 'crypto'
import { localize } from '../../shared/utilities/vsCodeUtils'
import { randomUUID } from '../../shared/crypto'
@@ -438,10 +438,9 @@ function getSessionDuration(id: string) {
*/
export class DeviceFlowAuthorization extends SsoAccessTokenProvider {
override async registerClient(): Promise {
- const companyName = getIdeProperties().company
return this.oidc.registerClient(
{
- clientName: isCloud9() ? `${companyName} Cloud9` : `${companyName} IDE Extensions for VSCode`,
+ clientName: oidcClientName(),
clientType: clientRegistrationType,
scopes: this.profile.scopes,
},
@@ -543,11 +542,10 @@ export class DeviceFlowAuthorization extends SsoAccessTokenProvider {
*/
class AuthFlowAuthorization extends SsoAccessTokenProvider {
override async registerClient(): Promise {
- const companyName = getIdeProperties().company
return this.oidc.registerClient(
{
// All AWS extensions (Q, Toolkit) for a given IDE use the same client name.
- clientName: isCloud9() ? `${companyName} Cloud9` : `${companyName} IDE Extensions for VSCode`,
+ clientName: oidcClientName(),
clientType: clientRegistrationType,
scopes: this.profile.scopes,
grantTypes: [authorizationGrantType, refreshGrantType],
@@ -653,11 +651,10 @@ class WebAuthorization extends SsoAccessTokenProvider {
private redirectUri = 'http://127.0.0.1:54321/oauth/callback'
override async registerClient(): Promise {
- const companyName = getIdeProperties().company
return this.oidc.registerClient(
{
// All AWS extensions (Q, Toolkit) for a given IDE use the same client name.
- clientName: isCloud9() ? `${companyName} Cloud9` : `${companyName} IDE Extensions for VSCode`,
+ clientName: oidcClientName(),
clientType: clientRegistrationType,
scopes: this.profile.scopes,
grantTypes: [authorizationGrantType, refreshGrantType],
diff --git a/packages/core/src/codewhisperer/activation.ts b/packages/core/src/codewhisperer/activation.ts
index 1ad3d4f0089..73f65ca4ada 100644
--- a/packages/core/src/codewhisperer/activation.ts
+++ b/packages/core/src/codewhisperer/activation.ts
@@ -6,12 +6,9 @@
import * as vscode from 'vscode'
import * as nls from 'vscode-nls'
import { getTabSizeSetting } from '../shared/utilities/editorUtilities'
-import { KeyStrokeHandler } from './service/keyStrokeHandler'
import * as EditorContext from './util/editorContext'
import * as CodeWhispererConstants from './models/constants'
import {
- vsCodeState,
- ConfigurationEntry,
CodeSuggestionsState,
CodeScansState,
SecurityTreeViewFilterState,
@@ -19,13 +16,11 @@ import {
CodeScanIssue,
CodeIssueGroupingStrategyState,
} from './models/model'
-import { invokeRecommendation } from './commands/invokeRecommendation'
import { acceptSuggestion } from './commands/onInlineAcceptance'
import { CodeWhispererSettings } from './util/codewhispererSettings'
import { ExtContext } from '../shared/extensions'
import { CodeWhispererTracker } from './tracker/codewhispererTracker'
import * as codewhispererClient from './client/codewhisperer'
-import { runtimeLanguageContext } from './util/runtimeLanguageContext'
import { getLogger } from '../shared/logger/logger'
import {
enableCodeSuggestions,
@@ -59,7 +54,6 @@ import {
showExploreAgentsView,
showCodeIssueGroupingQuickPick,
} from './commands/basicCommands'
-import { sleep } from '../shared/utilities/timeoutUtils'
import { ReferenceLogViewProvider } from './service/referenceLogViewProvider'
import { ReferenceHoverProvider } from './service/referenceHoverProvider'
import { ReferenceInlineProvider } from './service/referenceInlineProvider'
@@ -73,7 +67,6 @@ import { RecommendationHandler } from './service/recommendationHandler'
import { Commands, registerCommandErrorHandler, registerDeclaredCommands } from '../shared/vscode/commands2'
import { InlineCompletionService, refreshStatusBar } from './service/inlineCompletionService'
import { isInlineCompletionEnabled } from './util/commonUtil'
-import { CodeWhispererCodeCoverageTracker } from './tracker/codewhispererCodeCoverageTracker'
import { AuthUtil } from './util/authUtil'
import { ImportAdderProvider } from './service/importAdderProvider'
import { TelemetryHelper } from './util/telemetryHelper'
@@ -96,13 +89,11 @@ import { SecurityIssueTreeViewProvider } from './service/securityIssueTreeViewPr
import { setContext } from '../shared/vscode/setContext'
import { syncSecurityIssueWebview } from './views/securityIssue/securityIssueWebview'
import { detectCommentAboveLine } from '../shared/utilities/commentUtils'
-import { UserWrittenCodeTracker } from './tracker/userWrittenCodeTracker'
let localize: nls.LocalizeFunc
export async function activate(context: ExtContext): Promise {
localize = nls.loadMessageBundle()
- const codewhispererSettings = CodeWhispererSettings.instance
// Import old CodeWhisperer settings into Amazon Q
await CodeWhispererSettings.instance.importSettings()
@@ -301,16 +292,6 @@ export async function activate(context: ExtContext): Promise {
SecurityIssueProvider.instance.issues.some((group) => group.issues.some((issue) => issue.visible))
void setContext('aws.amazonq.security.noMatches', noMatches)
}),
- // manual trigger
- Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => {
- invokeRecommendation(
- vscode.window.activeTextEditor as vscode.TextEditor,
- client,
- await getConfigEntry()
- ).catch((e) => {
- getLogger().error('invokeRecommendation failed: %s', (e as Error).message)
- })
- }),
// select customization
selectCustomizationPrompt.register(),
// notify new customizations
@@ -478,90 +459,6 @@ export async function activate(context: ExtContext): Promise {
})
}
- function getAutoTriggerStatus(): boolean {
- return CodeSuggestionsState.instance.isSuggestionsEnabled()
- }
-
- async function getConfigEntry(): Promise {
- const isShowMethodsEnabled: boolean =
- vscode.workspace.getConfiguration('editor').get('suggest.showMethods') || false
- const isAutomatedTriggerEnabled: boolean = getAutoTriggerStatus()
- const isManualTriggerEnabled: boolean = true
- const isSuggestionsWithCodeReferencesEnabled = codewhispererSettings.isSuggestionsWithCodeReferencesEnabled()
-
- // TODO:remove isManualTriggerEnabled
- return {
- isShowMethodsEnabled,
- isManualTriggerEnabled,
- isAutomatedTriggerEnabled,
- isSuggestionsWithCodeReferencesEnabled,
- }
- }
-
- if (isInlineCompletionEnabled()) {
- await setSubscriptionsforInlineCompletion()
- await AuthUtil.instance.setVscodeContextProps()
- }
-
- async function setSubscriptionsforInlineCompletion() {
- RecommendationHandler.instance.subscribeSuggestionCommands()
- /**
- * Automated trigger
- */
- context.extensionContext.subscriptions.push(
- vscode.window.onDidChangeActiveTextEditor(async (editor) => {
- await RecommendationHandler.instance.onEditorChange()
- }),
- vscode.window.onDidChangeWindowState(async (e) => {
- await RecommendationHandler.instance.onFocusChange()
- }),
- vscode.window.onDidChangeTextEditorSelection(async (e) => {
- await RecommendationHandler.instance.onCursorChange(e)
- }),
- vscode.workspace.onDidChangeTextDocument(async (e) => {
- const editor = vscode.window.activeTextEditor
- if (!editor) {
- return
- }
- if (e.document !== editor.document) {
- return
- }
- if (!runtimeLanguageContext.isLanguageSupported(e.document)) {
- return
- }
-
- CodeWhispererCodeCoverageTracker.getTracker(e.document.languageId)?.countTotalTokens(e)
- UserWrittenCodeTracker.instance.onTextDocumentChange(e)
- /**
- * Handle this keystroke event only when
- * 1. It is not a backspace
- * 2. It is not caused by CodeWhisperer editing
- * 3. It is not from undo/redo.
- */
- if (e.contentChanges.length === 0 || vsCodeState.isCodeWhispererEditing) {
- return
- }
-
- if (vsCodeState.lastUserModificationTime) {
- TelemetryHelper.instance.setTimeSinceLastModification(
- performance.now() - vsCodeState.lastUserModificationTime
- )
- }
- vsCodeState.lastUserModificationTime = performance.now()
- /**
- * Important: Doing this sleep(10) is to make sure
- * 1. this event is processed by vs code first
- * 2. editor.selection.active has been successfully updated by VS Code
- * Then this event can be processed by our code.
- */
- await sleep(CodeWhispererConstants.vsCodeCursorUpdateDelay)
- if (!RecommendationHandler.instance.isSuggestionVisible()) {
- await KeyStrokeHandler.instance.processKeyStroke(e, editor, client, await getConfigEntry())
- }
- })
- )
- }
-
void FeatureConfigProvider.instance.fetchFeatureConfigs().catch((error) => {
getLogger().error('Failed to fetch feature configs - %s', error)
})
diff --git a/packages/core/src/codewhisperer/service/recommendationHandler.ts b/packages/core/src/codewhisperer/service/recommendationHandler.ts
index 99baf680d0a..00d1f3254a5 100644
--- a/packages/core/src/codewhisperer/service/recommendationHandler.ts
+++ b/packages/core/src/codewhisperer/service/recommendationHandler.ts
@@ -50,23 +50,38 @@ import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker'
* It does not contain UI/UX related logic
*/
-// below commands override VS Code inline completion commands
-const prevCommand = Commands.declare('editor.action.inlineSuggest.showPrevious', () => async () => {
- await RecommendationHandler.instance.showRecommendation(-1)
-})
-const nextCommand = Commands.declare('editor.action.inlineSuggest.showNext', () => async () => {
- await RecommendationHandler.instance.showRecommendation(1)
-})
-
-const rejectCommand = Commands.declare('aws.amazonq.rejectCodeSuggestion', () => async () => {
- telemetry.record({
- traceId: TelemetryHelper.instance.traceId,
+/**
+ * Commands as a level of indirection so that declare doesn't intercept any registrations for the
+ * language server implementation.
+ *
+ * Otherwise you'll get:
+ * "Unable to launch amazonq language server: Command "aws.amazonq.rejectCodeSuggestion" has already been declared by the Toolkit"
+ */
+function createCommands() {
+ // below commands override VS Code inline completion commands
+ const prevCommand = Commands.declare('editor.action.inlineSuggest.showPrevious', () => async () => {
+ await RecommendationHandler.instance.showRecommendation(-1)
+ })
+ const nextCommand = Commands.declare('editor.action.inlineSuggest.showNext', () => async () => {
+ await RecommendationHandler.instance.showRecommendation(1)
})
- await vscode.commands.executeCommand('editor.action.inlineSuggest.hide')
- RecommendationHandler.instance.reportUserDecisions(-1)
- await Commands.tryExecute('aws.amazonq.refreshAnnotation')
-})
+ const rejectCommand = Commands.declare('aws.amazonq.rejectCodeSuggestion', () => async () => {
+ telemetry.record({
+ traceId: TelemetryHelper.instance.traceId,
+ })
+
+ await vscode.commands.executeCommand('editor.action.inlineSuggest.hide')
+ RecommendationHandler.instance.reportUserDecisions(-1)
+ await Commands.tryExecute('aws.amazonq.refreshAnnotation')
+ })
+
+ return {
+ prevCommand,
+ nextCommand,
+ rejectCommand,
+ }
+}
const lock = new AsyncLock({ maxPending: 1 })
@@ -579,6 +594,7 @@ export class RecommendationHandler {
// They are subscribed when suggestion starts and disposed when suggestion is accepted/rejected
// to avoid impacting other plugins or user who uses this API
private registerCommandOverrides() {
+ const { prevCommand, nextCommand, rejectCommand } = createCommands()
this.prev = prevCommand.register()
this.next = nextCommand.register()
this.reject = rejectCommand.register()
diff --git a/packages/core/src/dev/activation.ts b/packages/core/src/dev/activation.ts
index 16b5d7e53ad..8ce0f6aab11 100644
--- a/packages/core/src/dev/activation.ts
+++ b/packages/core/src/dev/activation.ts
@@ -25,6 +25,7 @@ import { NotificationsController } from '../notifications/controller'
import { DevNotificationsState } from '../notifications/types'
import { QuickPickItem } from 'vscode'
import { ChildProcess } from '../shared/utilities/processUtils'
+import { WorkspaceLspInstaller } from '../amazonq/lsp/workspaceInstaller'
interface MenuOption {
readonly label: string
@@ -450,6 +451,12 @@ const resettableFeatures: readonly ResettableFeature[] = [
detail: 'Resets memory/global state for the notifications panel (includes dismissed, onReceive).',
executor: resetNotificationsState,
},
+ {
+ name: 'workspace lsp',
+ label: 'Download Lsp ',
+ detail: 'Resets workspace LSP',
+ executor: resetWorkspaceLspDownload,
+ },
] as const
// TODO this is *somewhat* similar to `openStorageFromInput`. If we need another
@@ -538,6 +545,10 @@ async function resetNotificationsState() {
await targetNotificationsController.reset()
}
+async function resetWorkspaceLspDownload() {
+ await new WorkspaceLspInstaller().resolve()
+}
+
async function editNotifications() {
const storageKey = 'aws.notifications.dev'
const current = globalState.get(storageKey) ?? {}
diff --git a/packages/core/src/shared/crypto.ts b/packages/core/src/shared/crypto.ts
index 808f5730d81..647525098a7 100644
--- a/packages/core/src/shared/crypto.ts
+++ b/packages/core/src/shared/crypto.ts
@@ -24,11 +24,15 @@
import { isWeb } from './extensionGlobals'
export function randomUUID(): `${string}-${string}-${string}-${string}-${string}` {
+ return getCrypto().randomUUID()
+}
+
+function getCrypto() {
if (isWeb()) {
- return globalThis.crypto.randomUUID()
+ return globalThis.crypto
}
- return require('crypto').randomUUID()
+ return require('crypto')
}
/**
@@ -54,3 +58,10 @@ export function truncateUuid(uuid: string) {
const cleanedUUID = uuid.replace(/-/g, '')
return `${cleanedUUID.substring(0, 4)}...${cleanedUUID.substring(cleanedUUID.length - 4)}`
}
+
+export function createHash(algorithm: string, contents: string | Buffer): string {
+ const crypto = getCrypto()
+ const hash = crypto.createHash(algorithm)
+ hash.update(contents)
+ return `${algorithm}:${hash.digest('hex')}`
+}
diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts
index 26fc0490ee8..7cec122acaf 100644
--- a/packages/core/src/shared/errors.ts
+++ b/packages/core/src/shared/errors.ts
@@ -16,6 +16,7 @@ import { CodeWhispererStreamingServiceException } from '@amzn/codewhisperer-stre
import { driveLetterRegex } from './utilities/pathUtils'
import { getLogger } from './logger/logger'
import { crashMonitoringDirName } from './constants'
+import { RequestCancelledError } from './request'
let _username = 'unknown-user'
let _isAutomation = false
@@ -618,7 +619,11 @@ function hasTime(error: Error): error is typeof error & { time: Date } {
}
export function isUserCancelledError(error: unknown): boolean {
- return CancellationError.isUserCancelled(error) || (error instanceof ToolkitError && error.cancelled)
+ return (
+ CancellationError.isUserCancelled(error) ||
+ (error instanceof ToolkitError && error.cancelled) ||
+ error instanceof RequestCancelledError
+ )
}
/**
diff --git a/packages/core/src/shared/extensionUtilities.ts b/packages/core/src/shared/extensionUtilities.ts
index 1e134aaa18f..184ae028cc1 100644
--- a/packages/core/src/shared/extensionUtilities.ts
+++ b/packages/core/src/shared/extensionUtilities.ts
@@ -48,6 +48,13 @@ export function productName() {
return isAmazonQ() ? 'Amazon Q' : `${getIdeProperties().company} Toolkit`
}
+/** Gets the client name stored in oidc */
+export const oidcClientName = once(_oidcClientName)
+function _oidcClientName() {
+ const companyName = getIdeProperties().company
+ return isCloud9() ? `${companyName} Cloud9` : `${companyName} IDE Extensions for VSCode`
+}
+
export const getExtensionId = () => {
return isAmazonQ() ? VSCODE_EXTENSION_ID.amazonq : VSCODE_EXTENSION_ID.awstoolkit
}
diff --git a/packages/core/src/shared/fs/fs.ts b/packages/core/src/shared/fs/fs.ts
index 1c7132648bc..ce317a513df 100644
--- a/packages/core/src/shared/fs/fs.ts
+++ b/packages/core/src/shared/fs/fs.ts
@@ -16,7 +16,7 @@ import {
isPermissionsError,
scrubNames,
} from '../errors'
-import globals from '../extensionGlobals'
+import globals, { isWeb } from '../extensionGlobals'
import { isWin } from '../vscode/env'
import { resolvePath } from '../utilities/pathUtils'
import crypto from 'crypto'
@@ -543,6 +543,43 @@ export class FileSystem {
return this.#homeDir
}
+ /**
+ * Gets the application cache folder for the current platform
+ *
+ * Follows the cache_dir convention outlined in https://crates.io/crates/dirs
+ */
+ getCacheDir(): string {
+ if (isWeb()) {
+ const homeDir = this.#homeDir
+ if (!homeDir) {
+ throw new ToolkitError('Web home directory not found', {
+ code: 'WebHomeDirectoryNotFound',
+ })
+ }
+ return homeDir
+ }
+ switch (process.platform) {
+ case 'darwin': {
+ return _path.join(this.getUserHomeDir(), 'Library/Caches')
+ }
+ case 'win32': {
+ const localAppData = process.env.LOCALAPPDATA
+ if (!localAppData) {
+ throw new ToolkitError('LOCALAPPDATA environment variable not set', {
+ code: 'LocalAppDataNotFound',
+ })
+ }
+ return localAppData
+ }
+ case 'linux': {
+ return _path.join(this.getUserHomeDir(), '.cache')
+ }
+ default: {
+ throw new Error(`Unsupported platform: ${process.platform}. Expected 'darwin', 'win32', 'linux'.`)
+ }
+ }
+ }
+
/**
* Gets the (cached) username for this session, or "webuser" in web-mode, or "unknown-user" if
* a username could not be resolved.
diff --git a/packages/core/src/shared/globalState.ts b/packages/core/src/shared/globalState.ts
index 80f4148d435..26b183de3be 100644
--- a/packages/core/src/shared/globalState.ts
+++ b/packages/core/src/shared/globalState.ts
@@ -45,6 +45,8 @@ export type globalKey =
| 'aws.toolkit.amazonq.dismissed'
| 'aws.toolkit.amazonqInstall.dismissed'
| 'aws.amazonq.workspaceIndexToggleOn'
+ | 'aws.toolkit.lsp.versions'
+ | 'aws.toolkit.lsp.manifest'
| 'aws.amazonq.customization.overrideV2'
// Deprecated/legacy names. New keys should start with "aws.".
| '#sessionCreationDates' // Legacy name from `ssoAccessTokenProvider.ts`.
diff --git a/packages/core/src/shared/index.ts b/packages/core/src/shared/index.ts
index 4e2b770c1c7..c713be1fba8 100644
--- a/packages/core/src/shared/index.ts
+++ b/packages/core/src/shared/index.ts
@@ -13,12 +13,12 @@ export { activate as activateLogger } from './logger/activation'
export { activate as activateTelemetry } from './telemetry/activation'
export { DefaultAwsContext } from './awsContext'
export { DefaultAWSClientBuilder, ServiceOptions } from './awsClientBuilder'
-export { Settings, DevSettings } from './settings'
+export { Settings, Experiments, DevSettings } from './settings'
export * from './extensionUtilities'
export * from './extensionStartup'
export { RegionProvider } from './regions/regionProvider'
export { Commands } from './vscode/commands2'
-export { getMachineId } from './vscode/env'
+export { getMachineId, getServiceEnvVarConfig } from './vscode/env'
export { getLogger } from './logger/logger'
export { activateExtension, openUrl } from './utilities/vsCodeUtils'
export { waitUntil, sleep, Timeout } from './utilities/timeoutUtils'
@@ -62,4 +62,13 @@ export { i18n } from './i18n-helper'
export * from './icons'
export * as textDocumentUtil from './utilities/textDocumentUtilities'
export { TabTypeDataMap } from '../amazonq/webview/ui/tabs/constants'
+export * from './lsp/manifestResolver'
+export * from './lsp/lspResolver'
+export * from './lsp/types'
+export * from './lsp/utils/setupStage'
+export * from './lsp/utils/cleanup'
+export { default as request } from './request'
+export * from './lsp/utils/platform'
+export * as processUtils from './utilities/processUtils'
+export * as BaseLspInstaller from './lsp/baseLspInstaller'
export * as collectionUtil from './utilities/collectionUtils'
diff --git a/packages/core/src/shared/logger/logger.ts b/packages/core/src/shared/logger/logger.ts
index 5dab76ea6e3..3338602685a 100644
--- a/packages/core/src/shared/logger/logger.ts
+++ b/packages/core/src/shared/logger/logger.ts
@@ -11,9 +11,12 @@ export type LogTopic =
| 'notifications'
| 'test'
| 'childProcess'
- | 'unknown'
+ | 'lsp'
+ | 'amazonqWorkspaceLsp'
+ | 'amazonqLsp'
| 'chat'
| 'stepfunctions'
+ | 'unknown'
class ErrorLog {
constructor(
diff --git a/packages/core/src/shared/lsp/baseLspInstaller.ts b/packages/core/src/shared/lsp/baseLspInstaller.ts
new file mode 100644
index 00000000000..4f3bcdf57e7
--- /dev/null
+++ b/packages/core/src/shared/lsp/baseLspInstaller.ts
@@ -0,0 +1,65 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as nodePath from 'path'
+import vscode from 'vscode'
+import { LspConfig } from '../../amazonq/lsp/config'
+import { LanguageServerResolver } from './lspResolver'
+import { ManifestResolver } from './manifestResolver'
+import { LspResolution, ResourcePaths } from './types'
+import { cleanLspDownloads } from './utils/cleanup'
+import { Range } from 'semver'
+import { getLogger } from '../logger/logger'
+import type { Logger, LogTopic } from '../logger/logger'
+
+export abstract class BaseLspInstaller {
+ private logger: Logger
+
+ constructor(
+ protected config: LspConfig,
+ loggerName: Extract
+ ) {
+ this.logger = getLogger(loggerName)
+ }
+
+ async resolve(): Promise> {
+ const { id, manifestUrl, supportedVersions, path } = this.config
+ if (path) {
+ const overrideMsg = `Using language server override location: ${path}`
+ this.logger.info(overrideMsg)
+ void vscode.window.showInformationMessage(overrideMsg)
+ return {
+ assetDirectory: path,
+ location: 'override',
+ version: '0.0.0',
+ resourcePaths: this.resourcePaths(),
+ }
+ }
+
+ const manifest = await new ManifestResolver(manifestUrl, id).resolve()
+ const installationResult = await new LanguageServerResolver(
+ manifest,
+ id,
+ new Range(supportedVersions, {
+ includePrerelease: true,
+ })
+ ).resolve()
+
+ const assetDirectory = installationResult.assetDirectory
+
+ await this.postInstall(assetDirectory)
+
+ const deletedVersions = await cleanLspDownloads(manifest.versions, nodePath.dirname(assetDirectory))
+ this.logger.debug(`cleaning old LSP versions deleted ${deletedVersions.length} versions`)
+
+ return {
+ ...installationResult,
+ resourcePaths: this.resourcePaths(assetDirectory),
+ }
+ }
+
+ protected abstract postInstall(assetDirectory: string): Promise
+ protected abstract resourcePaths(assetDirectory?: string): T
+}
diff --git a/packages/core/src/shared/languageServer/languageModelCache.ts b/packages/core/src/shared/lsp/languageModelCache.ts
similarity index 100%
rename from packages/core/src/shared/languageServer/languageModelCache.ts
rename to packages/core/src/shared/lsp/languageModelCache.ts
diff --git a/packages/core/src/shared/lsp/lspResolver.ts b/packages/core/src/shared/lsp/lspResolver.ts
new file mode 100644
index 00000000000..5d19e35f836
--- /dev/null
+++ b/packages/core/src/shared/lsp/lspResolver.ts
@@ -0,0 +1,433 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import fs from '../fs/fs'
+import { ToolkitError } from '../errors'
+import * as semver from 'semver'
+import * as path from 'path'
+import { FileType } from 'vscode'
+import AdmZip from 'adm-zip'
+import { TargetContent, logger, LspResult, LspVersion, Manifest } from './types'
+import { createHash } from '../crypto'
+import { lspSetupStage, StageResolver, tryStageResolvers } from './utils/setupStage'
+import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher'
+import { showMessageWithCancel } from '../../shared/utilities/messages'
+import { Timeout } from '../utilities/timeoutUtils'
+
+// max timeout for downloading remote LSP assets progress, the lowest possible is 3000, bounded by httpResourceFetcher's waitUntil
+const remoteDownloadTimeout = 5000
+
+export class LanguageServerResolver {
+ constructor(
+ private readonly manifest: Manifest,
+ private readonly lsName: string,
+ private readonly versionRange: semver.Range,
+ private readonly _defaultDownloadFolder?: string
+ ) {}
+
+ /**
+ * Downloads and sets up the Language Server, attempting different locations in order:
+ * 1. Local cache
+ * 2. Remote download
+ * 3. Fallback version
+ * @throws ToolkitError if no compatible version can be found
+ */
+ async resolve() {
+ function getServerVersion(result: LspResult) {
+ return {
+ languageServerVersion: result.version,
+ }
+ }
+ try {
+ const latestVersion = this.latestCompatibleLspVersion()
+ const targetContents = this.getLSPTargetContents(latestVersion)
+ const cacheDirectory = this.getDownloadDirectory(latestVersion.serverVersion)
+
+ const serverResolvers: StageResolver[] = [
+ {
+ resolve: async () => await this.getLocalServer(cacheDirectory, latestVersion, targetContents),
+ telemetryMetadata: { id: this.lsName, languageServerLocation: 'cache' },
+ },
+ {
+ resolve: async () => await this.fetchRemoteServer(cacheDirectory, latestVersion, targetContents),
+ telemetryMetadata: { id: this.lsName, languageServerLocation: 'remote' },
+ },
+ {
+ resolve: async () => await this.getFallbackServer(latestVersion),
+ telemetryMetadata: { id: this.lsName, languageServerLocation: 'fallback' },
+ },
+ ]
+
+ return await tryStageResolvers('getServer', serverResolvers, getServerVersion)
+ } finally {
+ logger.info(`Finished setting up LSP server`)
+ }
+ }
+
+ private async getFallbackServer(latestVersion: LspVersion): Promise {
+ const fallbackDirectory = await this.getFallbackDir(latestVersion.serverVersion)
+ if (!fallbackDirectory) {
+ throw new ToolkitError('Unable to find a compatible version of the Language Server', {
+ code: 'IncompatibleVersion',
+ })
+ }
+
+ const version = path.basename(fallbackDirectory)
+ logger.info(
+ `Unable to install ${this.lsName} language server v${latestVersion.serverVersion}. Launching a previous version from ${fallbackDirectory}`
+ )
+
+ return {
+ location: 'fallback',
+ version: version,
+ assetDirectory: fallbackDirectory,
+ }
+ }
+
+ /**
+ * Show a toast notification with progress bar for lsp remote downlaod
+ * Returns a timeout to be passed down into httpFetcher to handle user cancellation
+ */
+ private async showDownloadProgress() {
+ const timeout = new Timeout(remoteDownloadTimeout)
+ await showMessageWithCancel(`Downloading '${this.lsName}' language server`, timeout)
+ return timeout
+ }
+
+ private async fetchRemoteServer(
+ cacheDirectory: string,
+ latestVersion: LspVersion,
+ targetContents: TargetContent[]
+ ): Promise {
+ const timeout = await this.showDownloadProgress()
+ try {
+ if (await this.downloadRemoteTargetContent(targetContents, latestVersion.serverVersion, timeout)) {
+ return {
+ location: 'remote',
+ version: latestVersion.serverVersion,
+ assetDirectory: cacheDirectory,
+ }
+ } else {
+ throw new ToolkitError('Failed to download server from remote', { code: 'RemoteDownloadFailed' })
+ }
+ } finally {
+ timeout.dispose()
+ }
+ }
+
+ private async getLocalServer(
+ cacheDirectory: string,
+ latestVersion: LspVersion,
+ targetContents: TargetContent[]
+ ): Promise {
+ if (await this.hasValidLocalCache(cacheDirectory, targetContents)) {
+ return {
+ location: 'cache',
+ version: latestVersion.serverVersion,
+ assetDirectory: cacheDirectory,
+ }
+ } else {
+ // Delete the cached directory since it's invalid
+ if (await fs.existsDir(cacheDirectory)) {
+ await fs.delete(cacheDirectory, {
+ recursive: true,
+ })
+ }
+ throw new ToolkitError('Failed to retrieve server from cache', { code: 'InvalidCache' })
+ }
+ }
+
+ /**
+ * Get all of the compatible language server versions from the manifest
+ */
+ private compatibleManifestLspVersion() {
+ return this.manifest.versions.filter((x) => this.isCompatibleVersion(x))
+ }
+
+ /**
+ * Returns the path to the most compatible cached LSP version that can serve as a fallback
+ **/
+ private async getFallbackDir(version: string) {
+ const compatibleLspVersions = this.compatibleManifestLspVersion()
+
+ // determine all folders containing lsp versions in the fallback parent folder
+ const cachedVersions = (await fs.readdir(this.defaultDownloadFolder()))
+ .filter(([_, filetype]) => filetype === FileType.Directory)
+ .map(([pathName, _]) => semver.parse(pathName))
+ .filter((ver): ver is semver.SemVer => ver !== null)
+ .map((x) => x.version)
+
+ const expectedVersion = semver.parse(version)
+ if (!expectedVersion) {
+ return undefined
+ }
+
+ const sortedCachedLspVersions = compatibleLspVersions
+ .filter((v) => this.isValidCachedVersion(v, cachedVersions, expectedVersion))
+ .sort((a, b) => semver.compare(b.serverVersion, a.serverVersion))
+
+ const fallbackDir = (
+ await Promise.all(sortedCachedLspVersions.map((ver) => this.getValidLocalCacheDirectory(ver)))
+ ).filter((v) => v !== undefined)
+ return fallbackDir.length > 0 ? fallbackDir[0] : undefined
+ }
+
+ /**
+ * Validate the local cache directory of the given lsp version (matches expected hash)
+ * If valid return cache directory, else return undefined
+ */
+ private async getValidLocalCacheDirectory(version: LspVersion) {
+ const targetContents = this.getTargetContents(version)
+ if (targetContents === undefined || targetContents.length === 0) {
+ return undefined
+ }
+
+ const cacheDir = this.getDownloadDirectory(version.serverVersion)
+ const hasValidCache = await this.hasValidLocalCache(cacheDir, targetContents)
+
+ return hasValidCache ? cacheDir : undefined
+ }
+
+ /**
+ * Determines if a cached LSP version is valid for use as a fallback.
+ * A version is considered valid if it exists in the cache and is less than
+ * or equal to the expected version.
+ */
+ private isValidCachedVersion(version: LspVersion, cachedVersions: string[], expectedVersion: semver.SemVer) {
+ const serverVersion = semver.parse(version.serverVersion) as semver.SemVer
+ return cachedVersions.includes(serverVersion.version) && semver.lte(serverVersion, expectedVersion)
+ }
+
+ /**
+ * Download and unzip all of the contents into the download directory
+ *
+ * @returns
+ * true, if all of the contents were successfully downloaded and unzipped
+ * false, if any of the contents failed to download or unzip
+ */
+ private async downloadRemoteTargetContent(contents: TargetContent[], version: string, timeout: Timeout) {
+ const downloadDirectory = this.getDownloadDirectory(version)
+
+ if (!(await fs.existsDir(downloadDirectory))) {
+ await fs.mkdir(downloadDirectory)
+ }
+
+ const fetchTasks = contents.map(async (content) => {
+ return {
+ res: await new HttpResourceFetcher(content.url, {
+ showUrl: true,
+ timeout: timeout,
+ throwOnError: true,
+ }).get(),
+ hash: content.hashes[0],
+ filename: content.filename,
+ }
+ })
+ const fetchResults = await Promise.all(fetchTasks)
+ const verifyTasks = fetchResults
+ .filter((fetchResult) => fetchResult.res && fetchResult.res.ok && fetchResult.res.body)
+ .flatMap(async (fetchResult) => {
+ const arrBuffer = await fetchResult.res!.arrayBuffer()
+ const data = Buffer.from(arrBuffer)
+
+ const hash = createHash('sha384', data)
+ if (hash === fetchResult.hash) {
+ return [{ filename: fetchResult.filename, data }]
+ }
+ return []
+ })
+ if (verifyTasks.length !== contents.length) {
+ return false
+ }
+
+ const filesToDownload = await lspSetupStage('validate', async () => (await Promise.all(verifyTasks)).flat())
+
+ for (const file of filesToDownload) {
+ await fs.writeFile(`${downloadDirectory}/${file.filename}`, file.data)
+ }
+
+ return this.extractZipFilesFromRemote(downloadDirectory)
+ }
+
+ private async extractZipFilesFromRemote(downloadDirectory: string) {
+ // Find all the zips
+ const zips = (await fs.readdir(downloadDirectory))
+ .filter(([fileName, _]) => fileName.endsWith('.zip'))
+ .map(([fileName, _]) => `${downloadDirectory}/${fileName}`)
+
+ if (zips.length === 0) {
+ return true
+ }
+
+ return this.copyZipContents(zips)
+ }
+
+ private async hasValidLocalCache(localCacheDirectory: string, targetContents: TargetContent[]) {
+ // check if the zips are still at the present location
+ const results = await Promise.all(
+ targetContents.map((content) => {
+ const path = `${localCacheDirectory}/${content.filename}`
+ return fs.existsFile(path)
+ })
+ )
+
+ const allFilesExist = results.every(Boolean)
+ return allFilesExist && this.ensureUnzippedFoldersMatchZip(localCacheDirectory, targetContents)
+ }
+
+ /**
+ * Ensures zip files in cache have an unzipped folder of the same name
+ * with the same content files (by name)
+ *
+ * @returns
+ * false, if any of the unzipped folder don't match zip contents (by name)
+ */
+ private ensureUnzippedFoldersMatchZip(localCacheDirectory: string, targetContents: TargetContent[]) {
+ const zipPaths = targetContents
+ .filter((x) => x.filename.endsWith('.zip'))
+ .map((y) => `${localCacheDirectory}/${y.filename}`)
+
+ if (zipPaths.length === 0) {
+ return true
+ }
+
+ return this.copyZipContents(zipPaths)
+ }
+
+ /**
+ * Copies all the contents from zip into the directory
+ *
+ * @returns
+ * false, if any of the unzips fails
+ */
+ private copyZipContents(zips: string[]) {
+ const unzips = zips.map((zip) => {
+ try {
+ // attempt to unzip
+ const zipFile = new AdmZip(zip)
+ const extractPath = zip.replace('.zip', '')
+
+ /**
+ * Avoid overwriting existing files during extraction to prevent file corruption.
+ * On Mac ARM64 when a language server is already running in one VS Code window,
+ * attempting to extract and overwrite its files from another window can cause
+ * the newly started language server to crash with 'EXC_CRASH (SIGKILL (Code Signature Invalid))'.
+ */
+ zipFile.extractAllTo(extractPath, false)
+ } catch (e) {
+ return false
+ }
+ return true
+ })
+
+ // make sure every one completed successfully
+ return unzips.every(Boolean)
+ }
+
+ /**
+ * Parses the toolkit lsp version object retrieved from the version manifest to determine
+ * lsp contents
+ */
+ private getLSPTargetContents(version: LspVersion) {
+ const lspTarget = this.getCompatibleLspTarget(version)
+ if (!lspTarget) {
+ throw new ToolkitError("No language server target found matching the system's architecture and platform")
+ }
+
+ const targetContents = lspTarget.contents
+ if (!targetContents) {
+ throw new ToolkitError('No matching target contents found')
+ }
+ return targetContents
+ }
+
+ /**
+ * Get the latest language server version matching the toolkit compatible version range,
+ * not de-listed and contains the required target contents:
+ * architecture, platform and files
+ */
+ private latestCompatibleLspVersion() {
+ if (this.manifest === null) {
+ throw new ToolkitError('No valid manifest')
+ }
+
+ const latestCompatibleVersion =
+ this.manifest.versions
+ .filter((ver) => this.isCompatibleVersion(ver) && this.hasRequiredTargetContent(ver))
+ .sort((a, b) => semver.compare(b.serverVersion, a.serverVersion))[0] ?? undefined
+
+ if (latestCompatibleVersion === undefined) {
+ // TODO fix these error range names
+ throw new ToolkitError(
+ `Unable to find a language server that satifies one or more of these conditions: version in range [${this.versionRange.range}], matching system's architecture and platform`
+ )
+ }
+
+ return latestCompatibleVersion
+ }
+
+ /**
+ * Determine if the given lsp version is toolkit compatible
+ * i.e. in version range and not de-listed
+ */
+ private isCompatibleVersion(version: LspVersion) {
+ // invalid version
+ if (semver.parse(version.serverVersion) === null) {
+ return false
+ }
+
+ return (
+ semver.satisfies(version.serverVersion, this.versionRange, {
+ includePrerelease: true,
+ }) && !version.isDelisted
+ )
+ }
+
+ /**
+ * Validates the lsp version contains the required toolkit compatible contents:
+ * architecture, platform and file
+ */
+ private hasRequiredTargetContent(version: LspVersion) {
+ const targetContents = this.getTargetContents(version)
+ return targetContents !== undefined && targetContents.length > 0
+ }
+
+ /**
+ * Returns the target contents of the lsp version that contains the required
+ * toolkit compatible contents: architecture, platform and file
+ */
+ private getTargetContents(version: LspVersion) {
+ const target = this.getCompatibleLspTarget(version)
+ return target?.contents
+ }
+
+ /**
+ * Retrives the lsp target matching the user's system architecture and platform
+ * from the language server version object
+ */
+ private getCompatibleLspTarget(version: LspVersion) {
+ // TODO make this web friendly
+ // TODO make this fully support windows
+
+ // Workaround: Manifest platform field is `windows`, whereas node returns win32
+ const platform = process.platform === 'win32' ? 'windows' : process.platform
+ const arch = process.arch
+ return version.targets.find((x) => x.arch === arch && x.platform === platform)
+ }
+
+ // lazy calls to `getApplicationSupportFolder()` to avoid failure on windows.
+ public static get defaultDir() {
+ return path.join(fs.getCacheDir(), `aws/toolkits/language-servers`)
+ }
+
+ defaultDownloadFolder() {
+ return path.join(LanguageServerResolver.defaultDir, `${this.lsName}`)
+ }
+
+ private getDownloadDirectory(version: string) {
+ const directory = this._defaultDownloadFolder ?? this.defaultDownloadFolder()
+ return `${directory}/${version}`
+ }
+}
diff --git a/packages/core/src/shared/lsp/manifestResolver.ts b/packages/core/src/shared/lsp/manifestResolver.ts
new file mode 100644
index 00000000000..e2b89a0120b
--- /dev/null
+++ b/packages/core/src/shared/lsp/manifestResolver.ts
@@ -0,0 +1,159 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as vscode from 'vscode'
+import { getLogger } from '../logger/logger'
+import { ToolkitError } from '../errors'
+import { Timeout } from '../utilities/timeoutUtils'
+import globals from '../extensionGlobals'
+import { Manifest } from './types'
+import { StageResolver, tryStageResolvers } from './utils/setupStage'
+import { HttpResourceFetcher } from '../resourcefetcher/httpResourceFetcher'
+import * as localizedText from '../localizedText'
+import { AmazonQPromptSettings, amazonQPrompts } from '../settings'
+
+const logger = getLogger('lsp')
+
+interface StorageManifest {
+ etag: string
+ content: string
+}
+
+type ManifestStorage = Record
+
+export const manifestStorageKey = 'aws.toolkit.lsp.manifest'
+const manifestTimeoutMs = 15000
+
+export class ManifestResolver {
+ constructor(
+ private readonly manifestURL: string,
+ private readonly lsName: string
+ ) {}
+
+ /**
+ * Fetches the latest manifest, falling back to local cache on failure
+ */
+ async resolve(): Promise {
+ const resolvers: StageResolver[] = [
+ {
+ resolve: async () => await this.fetchRemoteManifest(),
+ telemetryMetadata: { id: this.lsName, manifestLocation: 'remote' },
+ },
+ {
+ resolve: async () => await this.getLocalManifest(),
+ telemetryMetadata: { id: this.lsName, manifestLocation: 'cache' },
+ },
+ ]
+
+ return await tryStageResolvers('getManifest', resolvers, extractMetadata)
+
+ function extractMetadata(r: Manifest) {
+ return {
+ manifestSchemaVersion: r.manifestSchemaVersion,
+ }
+ }
+ }
+
+ private async fetchRemoteManifest(): Promise {
+ const resp = await new HttpResourceFetcher(this.manifestURL, {
+ showUrl: true,
+ timeout: new Timeout(manifestTimeoutMs),
+ }).getNewETagContent(this.getEtag())
+
+ if (!resp.content) {
+ throw new ToolkitError(
+ `New content was not downloaded; fallback to the locally stored "${this.lsName}" manifest`
+ )
+ }
+
+ const manifest = this.parseManifest(resp.content)
+ await this.saveManifest(resp.eTag, resp.content)
+ await this.checkDeprecation(manifest)
+ manifest.location = 'remote'
+ return manifest
+ }
+
+ private async getLocalManifest(): Promise {
+ logger.info(`Failed to download latest "${this.lsName}" manifest. Falling back to local manifest.`)
+ const storage = this.getStorage()
+ const manifestData = storage[this.lsName]
+
+ if (!manifestData?.content) {
+ throw new ToolkitError(`Failed to download "${this.lsName}" manifest and no local manifest found.`)
+ }
+
+ const manifest = this.parseManifest(manifestData.content)
+ await this.checkDeprecation(manifest)
+ manifest.location = 'cache'
+ return manifest
+ }
+
+ private parseManifest(content: string): Manifest {
+ try {
+ return JSON.parse(content) as Manifest
+ } catch (error) {
+ throw new ToolkitError(
+ `Failed to parse "${this.lsName}" manifest: ${error instanceof Error ? error.message : 'Unknown error'}`
+ )
+ }
+ }
+
+ /**
+ * Check if the current manifest is deprecated.
+ * If yes and user hasn't muted this notification, shows a toast message with two buttons:
+ * - OK: close and do nothing
+ * - Don't Show Again: Update suppressed prompt setting so the deprecation message is never shown for this manifest.
+ * @param manifest
+ */
+ private async checkDeprecation(manifest: Manifest): Promise {
+ const prompts = AmazonQPromptSettings.instance
+ const lspId = `${this.lsName}LspManifestMessage` as keyof typeof amazonQPrompts
+
+ // Sanity check, if the lsName is changed then we also need to update the prompt keys in settings-amazonq.gen
+ if (!(lspId in amazonQPrompts)) {
+ logger.error(`LSP ID "${lspId}" not found in amazonQPrompts.`)
+ return
+ }
+
+ if (!manifest.isManifestDeprecated) {
+ // In case we got an new url, make sure the prompt is re-enabled for active manifests
+ await prompts.enablePrompt(lspId)
+ return
+ }
+
+ const deprecationMessage = `"${this.lsName}" manifest is deprecated. No future updates will be available.`
+ logger.info(deprecationMessage)
+
+ if (prompts.isPromptEnabled(lspId)) {
+ void vscode.window
+ .showInformationMessage(deprecationMessage, localizedText.ok, localizedText.dontShow)
+ .then(async (button) => {
+ if (button === localizedText.dontShow) {
+ await prompts.disablePrompt(lspId)
+ }
+ })
+ }
+ }
+
+ private async saveManifest(etag: string, content: string): Promise {
+ const storage = this.getStorage()
+
+ globals.globalState.tryUpdate(manifestStorageKey, {
+ ...storage,
+ [this.lsName]: {
+ etag,
+ content,
+ },
+ })
+ }
+
+ private getEtag(): string | undefined {
+ return this.getStorage()[this.lsName]?.etag
+ }
+
+ private getStorage(): ManifestStorage {
+ return globals.globalState.tryGet(manifestStorageKey, Object, {})
+ }
+}
diff --git a/packages/core/src/shared/lsp/types.ts b/packages/core/src/shared/lsp/types.ts
new file mode 100644
index 00000000000..21bd8ff4e77
--- /dev/null
+++ b/packages/core/src/shared/lsp/types.ts
@@ -0,0 +1,58 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { getLogger } from '../logger/logger'
+import { LanguageServerLocation, ManifestLocation } from '../telemetry/telemetry'
+
+export const logger = getLogger('lsp')
+
+export interface LspResult {
+ location: LanguageServerLocation
+ version: string
+ assetDirectory: string
+}
+
+export interface ResourcePaths {
+ lsp: string
+ node: string
+}
+
+export interface LspResolution extends LspResult {
+ resourcePaths: T
+}
+
+export interface TargetContent {
+ filename: string
+ url: string
+ hashes: string[]
+ bytes: number
+ serverVersion?: string
+}
+
+export interface Target {
+ platform: string
+ arch: string
+ contents: TargetContent[]
+}
+
+export interface LspVersion {
+ serverVersion: string
+ isDelisted: boolean
+ targets: Target[]
+}
+
+export interface Manifest {
+ manifestSchemaVersion: string
+ artifactId: string
+ artifactDescription: string
+ isManifestDeprecated: boolean
+ versions: LspVersion[]
+ location?: ManifestLocation
+}
+
+export interface VersionRange {
+ start: number
+ end: number
+}
diff --git a/packages/core/src/shared/lsp/utils/cleanup.ts b/packages/core/src/shared/lsp/utils/cleanup.ts
new file mode 100644
index 00000000000..83b58e2bb8f
--- /dev/null
+++ b/packages/core/src/shared/lsp/utils/cleanup.ts
@@ -0,0 +1,48 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import path from 'path'
+import { LspVersion } from '../types'
+import { fs } from '../../../shared/fs/fs'
+import { partition } from '../../../shared/utilities/tsUtils'
+import { parse, sort } from 'semver'
+
+export async function getDownloadedVersions(installLocation: string) {
+ return (await fs.readdir(installLocation)).filter((x) => parse(x[0]) !== null).map(([f, _], __) => f)
+}
+
+function isDelisted(manifestVersions: LspVersion[], targetVersion: string): boolean {
+ return manifestVersions.find((v) => v.serverVersion === targetVersion)?.isDelisted ?? false
+}
+
+/**
+ * Delete all delisted versions and keep the two newest versions that remain
+ * @param manifestVersions
+ * @param downloadDirectory
+ * @returns array of deleted versions.
+ */
+export async function cleanLspDownloads(manifestVersions: LspVersion[], downloadDirectory: string): Promise {
+ const downloadedVersions = await getDownloadedVersions(downloadDirectory)
+ const [delistedVersions, remainingVersions] = partition(downloadedVersions, (v: string) =>
+ isDelisted(manifestVersions, v)
+ )
+ const deletedVersions: string[] = []
+
+ for (const v of delistedVersions) {
+ await fs.delete(path.join(downloadDirectory, v), { force: true, recursive: true })
+ deletedVersions.push(v)
+ }
+
+ if (remainingVersions.length <= 2) {
+ return deletedVersions
+ }
+
+ for (const v of sort(remainingVersions).slice(0, -2)) {
+ await fs.delete(path.join(downloadDirectory, v), { force: true, recursive: true })
+ deletedVersions.push(v)
+ }
+
+ return deletedVersions
+}
diff --git a/packages/core/src/shared/lsp/utils/platform.ts b/packages/core/src/shared/lsp/utils/platform.ts
new file mode 100644
index 00000000000..4d242c3833e
--- /dev/null
+++ b/packages/core/src/shared/lsp/utils/platform.ts
@@ -0,0 +1,58 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { ToolkitError } from '../../errors'
+import { ChildProcess } from '../../utilities/processUtils'
+import { isDebugInstance } from '../../vscode/env'
+
+export function getNodeExecutableName(): string {
+ return process.platform === 'win32' ? 'node.exe' : 'node'
+}
+
+/**
+ * Get a json payload that will be sent to the language server, who is waiting to know what the encryption key is.
+ * Code reference: https://github.com/aws/language-servers/blob/7da212185a5da75a72ce49a1a7982983f438651a/client/vscode/src/credentialsActivation.ts#L77
+ */
+function getEncryptionInit(key: Buffer): string {
+ const request = {
+ version: '1.0',
+ mode: 'JWT',
+ key: key.toString('base64'),
+ }
+ return JSON.stringify(request) + '\n'
+}
+
+export function createServerOptions({
+ encryptionKey,
+ executable,
+ serverModule,
+ execArgv,
+}: {
+ encryptionKey: Buffer
+ executable: string
+ serverModule: string
+ execArgv: string[]
+}) {
+ return async () => {
+ const args = [serverModule, ...execArgv]
+ if (isDebugInstance()) {
+ args.unshift('--inspect=6080')
+ }
+ const lspProcess = new ChildProcess(executable, args)
+
+ // this is a long running process, awaiting it will never resolve
+ void lspProcess.run()
+
+ // share an encryption key using stdin
+ // follow same practice of DEXP LSP server
+ await lspProcess.send(getEncryptionInit(encryptionKey))
+
+ const proc = lspProcess.proc()
+ if (!proc) {
+ throw new ToolkitError('Language Server process was not started')
+ }
+ return proc
+ }
+}
diff --git a/packages/core/src/shared/languageServer/utils/runner.ts b/packages/core/src/shared/lsp/utils/runner.ts
similarity index 100%
rename from packages/core/src/shared/languageServer/utils/runner.ts
rename to packages/core/src/shared/lsp/utils/runner.ts
diff --git a/packages/core/src/shared/lsp/utils/setupStage.ts b/packages/core/src/shared/lsp/utils/setupStage.ts
new file mode 100644
index 00000000000..cd9dcfa319a
--- /dev/null
+++ b/packages/core/src/shared/lsp/utils/setupStage.ts
@@ -0,0 +1,66 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { LanguageServerSetup, LanguageServerSetupStage, telemetry } from '../../telemetry/telemetry'
+import { tryFunctions } from '../../utilities/tsUtils'
+
+/**
+ * Runs the designated stage within a telemetry span and optionally uses the getMetadata extractor to record metadata from the result of the stage.
+ * @param stageName name of stage for telemetry.
+ * @param runStage stage to be run.
+ * @param getMetadata metadata extractor to be applied to result.
+ * @returns result of stage
+ */
+export async function lspSetupStage(
+ stageName: LanguageServerSetupStage,
+ runStage: () => Promise,
+ getMetadata?: MetadataExtractor
+) {
+ return await telemetry.languageServer_setup.run(async (span) => {
+ span.record({ languageServerSetupStage: stageName })
+ const result = await runStage()
+ if (getMetadata) {
+ span.record(getMetadata(result))
+ }
+ return result
+ })
+}
+/**
+ * Tries to resolve the result of a stage using the resolvers provided in order. The first one to succceed
+ * has its result returned, but all intermediate will emit telemetry.
+ * @param stageName name of stage to resolve.
+ * @param resolvers stage resolvers to try IN ORDER
+ * @param getMetadata function to be applied to result to extract necessary metadata for telemetry.
+ * @returns result of the first succesful resolver.
+ */
+export async function tryStageResolvers(
+ stageName: LanguageServerSetupStage,
+ resolvers: StageResolver[],
+ getMetadata: MetadataExtractor
+) {
+ const fs = resolvers.map((resolver) => async () => {
+ return await lspSetupStage(
+ stageName,
+ async () => {
+ telemetry.record(resolver.telemetryMetadata)
+ const result = await resolver.resolve()
+ return result
+ },
+ getMetadata
+ )
+ })
+
+ return await tryFunctions(fs)
+}
+
+/**
+ * A method that returns the result of a stage along with the default telemetry metadata to attach to the stage metric.
+ */
+export interface StageResolver {
+ resolve: () => Promise
+ telemetryMetadata: Partial
+}
+
+type MetadataExtractor = (r: R) => Partial
diff --git a/packages/core/src/shared/resourcefetcher/httpResourceFetcher.ts b/packages/core/src/shared/resourcefetcher/httpResourceFetcher.ts
index 1a2300cdcb9..18cc696770f 100644
--- a/packages/core/src/shared/resourcefetcher/httpResourceFetcher.ts
+++ b/packages/core/src/shared/resourcefetcher/httpResourceFetcher.ts
@@ -8,6 +8,7 @@ import { getLogger, Logger } from '../logger/logger'
import { ResourceFetcher } from './resourcefetcher'
import { Timeout, CancelEvent, waitUntil } from '../utilities/timeoutUtils'
import request, { RequestError } from '../request'
+import { isUserCancelledError } from '../errors'
type RequestHeaders = { eTag?: string; gZip?: boolean }
@@ -22,6 +23,7 @@ export class HttpResourceFetcher implements ResourceFetcher {
* @param {string} params.friendlyName If URL is not shown, replaces the URL with this text.
* @param {Timeout} params.timeout Timeout token to abort/cancel the request. Similar to `AbortSignal`.
* @param {number} params.retries The number of retries a get request should make if one fails
+ * @param {boolean} params.throwOnError True if we want to throw if there's request error, defaul to false
*/
public constructor(
private readonly url: string,
@@ -29,8 +31,11 @@ export class HttpResourceFetcher implements ResourceFetcher {
showUrl: boolean
friendlyName?: string
timeout?: Timeout
+ throwOnError?: boolean
}
- ) {}
+ ) {
+ this.params.throwOnError = this.params.throwOnError ?? false
+ }
/**
* Returns the response of the resource, or undefined if the response failed could not be retrieved.
@@ -82,6 +87,9 @@ export class HttpResourceFetcher implements ResourceFetcher {
`Error downloading ${this.logText()}: %s`,
error.message ?? error.code ?? error.response.statusText ?? error.response.status
)
+ if (this.params.throwOnError) {
+ throw error
+ }
return undefined
}
}
@@ -112,7 +120,10 @@ export class HttpResourceFetcher implements ResourceFetcher {
timeout: 3000,
interval: 100,
backoff: 2,
- retryOnFail: true,
+ retryOnFail: (error: Error) => {
+ // Retry unless the user intentionally canceled the operation.
+ return !isUserCancelledError(error)
+ },
}
)
}
diff --git a/packages/core/src/shared/settings-amazonq.gen.ts b/packages/core/src/shared/settings-amazonq.gen.ts
index 25d99235c52..9447f43d12b 100644
--- a/packages/core/src/shared/settings-amazonq.gen.ts
+++ b/packages/core/src/shared/settings-amazonq.gen.ts
@@ -18,7 +18,9 @@ export const amazonqSettings = {
"amazonQWelcomePage": {},
"amazonQSessionConfigurationMessage": {},
"minIdeVersion": {},
- "ssoCacheError": {}
+ "ssoCacheError": {},
+ "AmazonQLspManifestMessage": {},
+ "AmazonQ-WorkspaceLspManifestMessage":{}
},
"amazonQ.showCodeWithReferences": {},
"amazonQ.allowFeatureDevelopmentToRunCodeAndTests": {},
diff --git a/packages/core/src/shared/settings-toolkit.gen.ts b/packages/core/src/shared/settings-toolkit.gen.ts
index ea291352701..5cd9854b1fc 100644
--- a/packages/core/src/shared/settings-toolkit.gen.ts
+++ b/packages/core/src/shared/settings-toolkit.gen.ts
@@ -41,7 +41,9 @@ export const toolkitSettings = {
"ssoCacheError": {}
},
"aws.experiments": {
- "jsonResourceModification": {}
+ "jsonResourceModification": {},
+ "amazonqLSP": {},
+ "amazonqChatLSP": {}
},
"aws.resources.enabledResources": {},
"aws.lambda.recentlyUploaded": {},
diff --git a/packages/core/src/shared/settings.ts b/packages/core/src/shared/settings.ts
index a486784fe14..273c61cabe0 100644
--- a/packages/core/src/shared/settings.ts
+++ b/packages/core/src/shared/settings.ts
@@ -671,6 +671,12 @@ export class AmazonQPromptSettings
}
}
+ public async enablePrompt(promptName: amazonQPromptName): Promise {
+ if (!this.isPromptEnabled(promptName)) {
+ await this.update(promptName, false)
+ }
+ }
+
public async disablePrompt(promptName: amazonQPromptName): Promise {
if (this.isPromptEnabled(promptName)) {
await this.update(promptName, true)
@@ -757,6 +763,8 @@ const devSettings = {
endpoints: Record(String, String),
codecatalystService: Record(String, String),
codewhispererService: Record(String, String),
+ amazonqLsp: Record(String, String),
+ amazonqWorkspaceLsp: Record(String, String),
ssoCacheDirectory: String,
autofillStartUrl: String,
webAuth: Boolean,
@@ -769,6 +777,8 @@ type ServiceClients = keyof ServiceTypeMap
interface ServiceTypeMap {
codecatalystService: codecatalyst.CodeCatalystConfig
codewhispererService: codewhisperer.CodeWhispererConfig
+ amazonqLsp: object // type is provided inside of amazon q
+ amazonqWorkspaceLsp: object // type is provided inside of amazon q
}
/**
diff --git a/packages/core/src/shared/utilities/processUtils.ts b/packages/core/src/shared/utilities/processUtils.ts
index e7af78eaaea..c851cefb376 100644
--- a/packages/core/src/shared/utilities/processUtils.ts
+++ b/packages/core/src/shared/utilities/processUtils.ts
@@ -388,6 +388,10 @@ export class ChildProcess {
return this.#processResult
}
+ public proc(): proc.ChildProcess | undefined {
+ return this.#childProcess
+ }
+
public pid(): number {
return this.#childProcess?.pid ?? -1
}
diff --git a/packages/core/src/shared/utilities/timeoutUtils.ts b/packages/core/src/shared/utilities/timeoutUtils.ts
index 7c1a6e521ca..8a8e0226879 100644
--- a/packages/core/src/shared/utilities/timeoutUtils.ts
+++ b/packages/core/src/shared/utilities/timeoutUtils.ts
@@ -223,11 +223,12 @@ interface WaitUntilOptions {
readonly backoff?: number
/**
* Only retries when an error is thrown, otherwise returning the immediate result.
+ * Can also be a callback for conditional retry based on errors
* - 'truthy' arg is ignored
* - If the timeout is reached it throws the last error
* - default: false
*/
- readonly retryOnFail?: boolean
+ readonly retryOnFail?: boolean | ((error: Error) => boolean)
}
export const waitUntilDefaultTimeout = 2000
@@ -247,6 +248,11 @@ export async function waitUntil(
fn: () => Promise,
options: WaitUntilOptions & { retryOnFail: false }
): Promise
+export async function waitUntil(
+ fn: () => Promise,
+ options: WaitUntilOptions & { retryOnFail: (error: Error) => boolean }
+): Promise
+
export async function waitUntil(
fn: () => Promise,
options: Omit
@@ -267,6 +273,17 @@ export async function waitUntil(fn: () => Promise, options: WaitUntilOptio
let elapsed: number = 0
let remaining = opt.timeout
+ // Internal helper to determine if we should retry
+ function shouldRetry(error: Error | undefined): boolean {
+ if (error === undefined) {
+ return typeof opt.retryOnFail === 'boolean' ? opt.retryOnFail : true
+ }
+ if (typeof opt.retryOnFail === 'function') {
+ return opt.retryOnFail(error)
+ }
+ return opt.retryOnFail
+ }
+
for (let i = 0; true; i++) {
const start: number = globals.clock.Date.now()
let result: T
@@ -279,16 +296,16 @@ export async function waitUntil(fn: () => Promise, options: WaitUntilOptio
result = await fn()
}
- if (opt.retryOnFail || (opt.truthy && result) || (!opt.truthy && result !== undefined)) {
+ if (shouldRetry(lastError) || (opt.truthy && result) || (!opt.truthy && result !== undefined)) {
return result
}
} catch (e) {
- if (!opt.retryOnFail) {
+ // Unlikely to hit this, but exists for typing
+ if (!(e instanceof Error)) {
throw e
}
- // Unlikely to hit this, but exists for typing
- if (!(e instanceof Error)) {
+ if (!shouldRetry(e)) {
throw e
}
@@ -300,10 +317,9 @@ export async function waitUntil(fn: () => Promise, options: WaitUntilOptio
// If the sleep will exceed the timeout, abort early
if (elapsed + interval >= remaining) {
- if (!opt.retryOnFail) {
+ if (!shouldRetry(lastError)) {
return undefined
}
-
throw lastError
}
diff --git a/packages/core/src/shared/utilities/tsUtils.ts b/packages/core/src/shared/utilities/tsUtils.ts
index a76ee170c2e..9c89c406a6d 100644
--- a/packages/core/src/shared/utilities/tsUtils.ts
+++ b/packages/core/src/shared/utilities/tsUtils.ts
@@ -94,6 +94,38 @@ export function createFactoryFunction any>(cto
return (...args) => new ctor(...args)
}
+/**
+ * Try functions in the order presented and return the first returned result. If none return, throw the final error.
+ * @param functions non-empty list of functions to try.
+ * @returns
+ */
+export async function tryFunctions(functions: (() => Promise)[]): Promise {
+ let currentError: Error = new Error('No functions provided')
+ for (const func of functions) {
+ try {
+ return await func()
+ } catch (e) {
+ currentError = e as Error
+ }
+ }
+ throw currentError
+}
+
+/**
+ * Split a list into two sublists based on the result of a predicate.
+ * @param lst list to split
+ * @param pred predicate to apply to each element
+ * @returns two nested lists, where for all items x in the left sublist, pred(x) returns true. The remaining elements are in the right sublist.
+ */
+export function partition(lst: T[], pred: (arg: T) => boolean): [T[], T[]] {
+ return lst.reduce(
+ ([leftAcc, rightAcc], item) => {
+ return pred(item) ? [[...leftAcc, item], rightAcc] : [leftAcc, [...rightAcc, item]]
+ },
+ [[], []] as [T[], T[]]
+ )
+}
+
type NoSymbols = { [Property in keyof T]: Property extends symbol ? never : Property }[keyof T]
export type InterfaceNoSymbol = Pick>
/**
diff --git a/packages/core/src/stepFunctions/asl/aslServer.ts b/packages/core/src/stepFunctions/asl/aslServer.ts
index 8da1363969c..2d4c4fadde3 100644
--- a/packages/core/src/stepFunctions/asl/aslServer.ts
+++ b/packages/core/src/stepFunctions/asl/aslServer.ts
@@ -37,8 +37,8 @@ import {
import { posix } from 'path'
import * as URL from 'url'
-import { getLanguageModelCache } from '../../shared/languageServer/languageModelCache'
-import { formatError, runSafe, runSafeAsync } from '../../shared/languageServer/utils/runner'
+import { getLanguageModelCache } from '../../shared/lsp/languageModelCache'
+import { formatError, runSafe, runSafeAsync } from '../../shared/lsp/utils/runner'
import { YAML_ASL, JSON_ASL } from '../constants/aslFormats'
export const ResultLimitReached: NotificationType = new NotificationType('asl/resultLimitReached')
diff --git a/packages/core/src/test/shared/lsp/manifestResolver.test.ts b/packages/core/src/test/shared/lsp/manifestResolver.test.ts
new file mode 100644
index 00000000000..eb5f8e90893
--- /dev/null
+++ b/packages/core/src/test/shared/lsp/manifestResolver.test.ts
@@ -0,0 +1,101 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import assert from 'assert'
+import sinon from 'sinon'
+import { Manifest, ManifestResolver } from '../../../shared'
+import { assertTelemetry } from '../../testUtil'
+import { ManifestLocation } from '../../../shared/telemetry'
+
+const manifestSchemaVersion = '1.0.0'
+const serverName = 'myLS'
+
+/**
+ * Helper function generating valid manifest results for tests.
+ * @param location
+ * @returns
+ */
+function manifestResult(location: ManifestLocation): Manifest {
+ return {
+ location,
+ manifestSchemaVersion,
+ artifactId: 'artifact-id',
+ artifactDescription: 'artifact-description',
+ isManifestDeprecated: false,
+ versions: [],
+ }
+}
+
+describe('manifestResolver', function () {
+ let remoteStub: sinon.SinonStub
+ let localStub: sinon.SinonStub
+
+ before(function () {
+ remoteStub = sinon.stub(ManifestResolver.prototype, 'fetchRemoteManifest' as any)
+ localStub = sinon.stub(ManifestResolver.prototype, 'getLocalManifest' as any)
+ })
+
+ after(function () {
+ sinon.restore()
+ })
+
+ it('attempts to fetch from remote first', async function () {
+ remoteStub.resolves(manifestResult('remote'))
+
+ const r = await new ManifestResolver('remote-manifest.com', serverName).resolve()
+ assert.strictEqual(r.location, 'remote')
+ assertTelemetry('languageServer_setup', {
+ manifestLocation: 'remote',
+ manifestSchemaVersion,
+ languageServerSetupStage: 'getManifest',
+ id: serverName,
+ result: 'Succeeded',
+ })
+ })
+
+ it('uses local cache when remote fails', async function () {
+ remoteStub.rejects(new Error('failed to fetch'))
+ localStub.resolves(manifestResult('cache'))
+
+ const r = await new ManifestResolver('remote-manifest.com', serverName).resolve()
+ assert.strictEqual(r.location, 'cache')
+ assertTelemetry('languageServer_setup', [
+ {
+ manifestLocation: 'remote',
+ languageServerSetupStage: 'getManifest',
+ id: serverName,
+ result: 'Failed',
+ },
+ {
+ manifestLocation: 'cache',
+ manifestSchemaVersion,
+ languageServerSetupStage: 'getManifest',
+ id: serverName,
+ result: 'Succeeded',
+ },
+ ])
+ })
+
+ it('fails if both local and remote fail', async function () {
+ remoteStub.rejects(new Error('failed to fetch'))
+ localStub.rejects(new Error('failed to fetch'))
+
+ await assert.rejects(new ManifestResolver('remote-manifest.com', serverName).resolve(), /failed to fetch/)
+ assertTelemetry('languageServer_setup', [
+ {
+ manifestLocation: 'remote',
+ languageServerSetupStage: 'getManifest',
+ id: serverName,
+ result: 'Failed',
+ },
+ {
+ manifestLocation: 'cache',
+ languageServerSetupStage: 'getManifest',
+ id: serverName,
+ result: 'Failed',
+ },
+ ])
+ })
+})
diff --git a/packages/core/src/test/shared/lsp/utils/cleanup.test.ts b/packages/core/src/test/shared/lsp/utils/cleanup.test.ts
new file mode 100644
index 00000000000..98f37fff28f
--- /dev/null
+++ b/packages/core/src/test/shared/lsp/utils/cleanup.test.ts
@@ -0,0 +1,116 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Uri } from 'vscode'
+import { cleanLspDownloads, fs, getDownloadedVersions } from '../../../../shared'
+import { createTestWorkspaceFolder } from '../../../testUtil'
+import path from 'path'
+import assert from 'assert'
+
+async function fakeInstallVersion(version: string, installationDir: string): Promise {
+ const versionDir = path.join(installationDir, version)
+ await fs.mkdir(versionDir)
+ await fs.writeFile(path.join(versionDir, 'file.txt'), 'content')
+}
+
+async function fakeInstallVersions(versions: string[], installationDir: string): Promise {
+ for (const v of versions) {
+ await fakeInstallVersion(v, installationDir)
+ }
+}
+
+describe('cleanLSPDownloads', function () {
+ let installationDir: Uri
+
+ before(async function () {
+ installationDir = (await createTestWorkspaceFolder()).uri
+ })
+
+ afterEach(async function () {
+ const files = await fs.readdir(installationDir.fsPath)
+ for (const [name, _type] of files) {
+ await fs.delete(path.join(installationDir.fsPath, name), { force: true, recursive: true })
+ }
+ })
+
+ after(async function () {
+ await fs.delete(installationDir, { force: true, recursive: true })
+ })
+
+ it('keeps two newest versions', async function () {
+ await fakeInstallVersions(['1.0.0', '1.0.1', '1.1.1', '2.1.1'], installationDir.fsPath)
+ const deleted = await cleanLspDownloads([], installationDir.fsPath)
+
+ const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename)
+ assert.strictEqual(result.length, 2)
+ assert.ok(result.includes('2.1.1'))
+ assert.ok(result.includes('1.1.1'))
+ assert.strictEqual(deleted.length, 2)
+ })
+
+ it('deletes delisted versions', async function () {
+ await fakeInstallVersions(['1.0.0', '1.0.1', '1.1.1', '2.1.1'], installationDir.fsPath)
+ const deleted = await cleanLspDownloads(
+ [{ serverVersion: '1.1.1', isDelisted: true, targets: [] }],
+ installationDir.fsPath
+ )
+
+ const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename)
+ assert.strictEqual(result.length, 2)
+ assert.ok(result.includes('2.1.1'))
+ assert.ok(result.includes('1.0.1'))
+ assert.strictEqual(deleted.length, 2)
+ })
+
+ it('handles case where less than 2 versions are not delisted', async function () {
+ await fakeInstallVersions(['1.0.0', '1.0.1', '1.1.1', '2.1.1'], installationDir.fsPath)
+ const deleted = await cleanLspDownloads(
+ [
+ { serverVersion: '1.1.1', isDelisted: true, targets: [] },
+ { serverVersion: '2.1.1', isDelisted: true, targets: [] },
+ { serverVersion: '1.0.0', isDelisted: true, targets: [] },
+ ],
+ installationDir.fsPath
+ )
+
+ const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename)
+ assert.strictEqual(result.length, 1)
+ assert.ok(result.includes('1.0.1'))
+ assert.strictEqual(deleted.length, 3)
+ })
+
+ it('handles case where less than 2 versions exist', async function () {
+ await fakeInstallVersions(['1.0.0'], installationDir.fsPath)
+ const deleted = await cleanLspDownloads([], installationDir.fsPath)
+
+ const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename)
+ assert.strictEqual(result.length, 1)
+ assert.strictEqual(deleted.length, 0)
+ })
+
+ it('does not install delisted version when no other option exists', async function () {
+ await fakeInstallVersions(['1.0.0'], installationDir.fsPath)
+ const deleted = await cleanLspDownloads(
+ [{ serverVersion: '1.0.0', isDelisted: true, targets: [] }],
+ installationDir.fsPath
+ )
+
+ const result = (await fs.readdir(installationDir.fsPath)).map(([filename, _filetype], _index) => filename)
+ assert.strictEqual(result.length, 0)
+ assert.strictEqual(deleted.length, 1)
+ })
+
+ it('ignores invalid versions', async function () {
+ await fakeInstallVersions(['1.0.0', '.DS_STORE'], installationDir.fsPath)
+ const deleted = await cleanLspDownloads(
+ [{ serverVersion: '1.0.0', isDelisted: true, targets: [] }],
+ installationDir.fsPath
+ )
+
+ const result = await getDownloadedVersions(installationDir.fsPath)
+ assert.strictEqual(result.length, 0)
+ assert.strictEqual(deleted.length, 1)
+ })
+})
diff --git a/packages/core/src/test/shared/utilities/timeoutUtils.test.ts b/packages/core/src/test/shared/utilities/timeoutUtils.test.ts
index 3ee5968132b..71d854d3cc2 100644
--- a/packages/core/src/test/shared/utilities/timeoutUtils.test.ts
+++ b/packages/core/src/test/shared/utilities/timeoutUtils.test.ts
@@ -394,6 +394,46 @@ export const timeoutUtilsDescribe = describe('timeoutUtils', async function () {
fn = sandbox.stub()
})
+ it('should retry when retryOnFail callback returns true', async function () {
+ fn.onCall(0).throws(new Error('Retry error'))
+ fn.onCall(1).throws(new Error('Retry error'))
+ fn.onCall(2).resolves('success')
+
+ const res = waitUntil(fn, {
+ retryOnFail: (error: Error) => {
+ return error.message === 'Retry error'
+ },
+ })
+
+ await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval)
+ assert.strictEqual(fn.callCount, 2)
+ await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval)
+ assert.strictEqual(fn.callCount, 3)
+ assert.strictEqual(await res, 'success')
+ })
+
+ it('should not retry when retryOnFail callback returns false', async function () {
+ fn.onCall(0).throws(new Error('Retry error'))
+ fn.onCall(1).throws(new Error('Retry error'))
+ fn.onCall(2).throws(new Error('Last error'))
+ fn.onCall(3).resolves('this is not hit')
+
+ const res = assert.rejects(
+ waitUntil(fn, {
+ retryOnFail: (error: Error) => {
+ return error.message === 'Retry error'
+ },
+ }),
+ (e) => e instanceof Error && e.message === 'Last error'
+ )
+
+ await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval)
+ assert.strictEqual(fn.callCount, 2)
+ await clock.tickAsync(timeoutUtils.waitUntilDefaultInterval)
+ assert.strictEqual(fn.callCount, 3)
+ await res
+ })
+
it('retries the function until it succeeds', async function () {
fn.onCall(0).throws()
fn.onCall(1).throws()
diff --git a/packages/core/src/test/shared/utilities/tsUtils.test.ts b/packages/core/src/test/shared/utilities/tsUtils.test.ts
new file mode 100644
index 00000000000..384d47df6ec
--- /dev/null
+++ b/packages/core/src/test/shared/utilities/tsUtils.test.ts
@@ -0,0 +1,37 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import assert from 'assert'
+import { tryFunctions } from '../../../shared/utilities/tsUtils'
+import { partition } from '../../../shared/utilities/tsUtils'
+
+describe('tryFunctions', function () {
+ it('should return the result of the first function that returns', async function () {
+ const f1 = () => Promise.reject('f1')
+ const f2 = () => Promise.resolve('f2')
+ const f3 = () => Promise.reject('f3')
+
+ assert.strictEqual(await tryFunctions([f1, f2, f3]), 'f2')
+ })
+
+ it('if all reject, then should throw final error', async function () {
+ const f1 = () => Promise.reject('f1')
+ const f2 = () => Promise.reject('f2')
+ const f3 = () => Promise.reject('f3')
+
+ await assert.rejects(
+ async () => await tryFunctions([f1, f2, f3]),
+ (e) => e === 'f3'
+ )
+ })
+})
+
+describe('partition', function () {
+ it('should split the list according to predicate', function () {
+ const items = [1, 2, 3, 4, 5, 6, 7, 8]
+ const [even, odd] = partition(items, (i) => i % 2 === 0)
+ assert.deepStrictEqual(even, [2, 4, 6, 8])
+ assert.deepStrictEqual(odd, [1, 3, 5, 7])
+ })
+})
diff --git a/packages/core/src/testInteg/perf/getFileSha384.test.ts b/packages/core/src/testInteg/perf/getFileSha384.test.ts
deleted file mode 100644
index 4f12fbfeeb8..00000000000
--- a/packages/core/src/testInteg/perf/getFileSha384.test.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/*!
- * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
- * SPDX-License-Identifier: Apache-2.0
- */
-import assert from 'assert'
-import path from 'path'
-import sinon from 'sinon'
-import { getTestWorkspaceFolder } from '../integrationTestsUtilities'
-import { fs, getRandomString } from '../../shared'
-import { LspController } from '../../amazonq'
-import { getEqualOSTestOptions, performanceTest } from '../../shared/performance/performance'
-import { FileSystem } from '../../shared/fs/fs'
-import { getFsCallsUpperBound } from './utilities'
-
-interface SetupResult {
- testFile: string
- fsSpy: sinon.SinonSpiedInstance
-}
-
-function performanceTestWrapper(label: string, fileSize: number) {
- return performanceTest(
- getEqualOSTestOptions({
- userCpuUsage: 500,
- systemCpuUsage: 35,
- heapTotal: 4,
- }),
- label,
- function () {
- return {
- setup: async () => {
- const workspace = getTestWorkspaceFolder()
- const fileContent = getRandomString(fileSize)
- const testFile = path.join(workspace, 'test-file')
- await fs.writeFile(testFile, fileContent)
- const fsSpy = sinon.spy(fs)
- return { testFile, fsSpy }
- },
- execute: async (setup: SetupResult) => {
- return await LspController.instance.getFileSha384(setup.testFile)
- },
- verify: async (setup: SetupResult, result: string) => {
- assert.strictEqual(result.length, 96)
- assert.ok(getFsCallsUpperBound(setup.fsSpy) <= 1, 'makes a single call to fs')
- },
- }
- }
- )
-}
-
-describe('getFileSha384', function () {
- describe('performance tests', function () {
- afterEach(function () {
- sinon.restore()
- })
- performanceTestWrapper('1MB', 1000)
- performanceTestWrapper('2MB', 2000)
- performanceTestWrapper('4MB', 4000)
- performanceTestWrapper('8MB', 8000)
- })
-})
diff --git a/packages/core/src/testInteg/perf/tryInstallLsp.test.ts b/packages/core/src/testInteg/perf/tryInstallLsp.test.ts
deleted file mode 100644
index d84da8626d3..00000000000
--- a/packages/core/src/testInteg/perf/tryInstallLsp.test.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-/*!
- * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
- * SPDX-License-Identifier: Apache-2.0
- */
-import assert from 'assert'
-import sinon from 'sinon'
-import { Content } from 'aws-sdk/clients/codecommit'
-import AdmZip from 'adm-zip'
-import path from 'path'
-import { LspController } from '../../amazonq'
-import { fs, getRandomString, globals } from '../../shared'
-import { createTestWorkspace } from '../../test/testUtil'
-import { getEqualOSTestOptions, performanceTest } from '../../shared/performance/performance'
-import { getFsCallsUpperBound } from './utilities'
-import { FileSystem } from '../../shared/fs/fs'
-
-// fakeFileContent is matched to fakeQServerContent based on hash.
-const fakeHash = '4eb2865c8f40a322aa04e17d8d83bdaa605d6f1cb363af615240a5442a010e0aef66e21bcf4c88f20fabff06efe8a214'
-
-const fakeQServerContent = {
- filename: 'qserver-fake.zip',
- url: 'https://aws-language-servers/fake.zip',
- hashes: [`sha384:${fakeHash}`],
- bytes: 93610849,
- serverVersion: '1.1.1',
-}
-
-const fakeNodeContent = {
- filename: 'fake-file',
- url: 'https://aws-language-servers.fake-file',
- hashes: [`sha384:${fakeHash}`],
- bytes: 94144448,
- serverVersion: '1.1.1',
-}
-
-function createStubs(numberOfFiles: number, fileSize: number): sinon.SinonSpiedInstance {
- // Avoid making HTTP request or mocking giant manifest, stub what we need directly from request.
- sinon.stub(LspController.prototype, 'fetchManifest')
- // Directly feed the runtime specifications.
- sinon.stub(LspController.prototype, 'getQserverFromManifest').returns(fakeQServerContent)
- sinon.stub(LspController.prototype, 'getNodeRuntimeFromManifest').returns(fakeNodeContent)
- // avoid fetch call.
- sinon.stub(LspController.prototype, '_download').callsFake(getFakeDownload(numberOfFiles, fileSize))
- // Hard code the hash since we are creating files on the spot, whose hashes can't be predicted.
- sinon.stub(LspController.prototype, 'getFileSha384').resolves(fakeHash)
- const fsSpy = sinon.spy(fs)
- fsSpy.rename.restore()
- // Don't allow tryInstallLsp to move runtimes out of temporary folder
- sinon.stub(fsSpy, 'rename')
- return fsSpy
-}
-
-/**
- * Creates a fake zip with some files in it.
- * @param filepath where to write the zip to.
- * @param _content unused parameter, for compatability with real function.
- */
-const getFakeDownload = function (numberOfFiles: number, fileSize: number) {
- return async function (filepath: string, _content: Content) {
- const dummyFilesPath = (
- await createTestWorkspace(numberOfFiles, {
- fileNamePrefix: 'fakeFile',
- fileContent: getRandomString(fileSize),
- workspaceName: 'workspace',
- })
- ).uri.fsPath
- await fs.writeFile(path.join(dummyFilesPath, 'qserver'), 'this value shouldnt matter')
- const zip = new AdmZip()
- zip.addLocalFolder(dummyFilesPath)
- zip.writeZip(filepath)
- }
-}
-
-function performanceTestWrapper(numFiles: number, fileSize: number, message: string) {
- return performanceTest(
- getEqualOSTestOptions({
- userCpuUsage: 150,
- systemCpuUsage: 35,
- heapTotal: 6,
- duration: 15,
- }),
- message,
- function () {
- return {
- setup: async () => {
- return createStubs(numFiles, fileSize)
- },
- execute: async () => {
- return await LspController.instance.tryInstallLsp(globals.context)
- },
- verify: async (fsSpy: sinon.SinonSpiedInstance, result: boolean) => {
- assert.ok(result)
- assert.ok(getFsCallsUpperBound(fsSpy) <= 6 * numFiles)
- },
- }
- }
- )
-}
-
-describe('tryInstallLsp', function () {
- afterEach(function () {
- sinon.restore()
- })
- describe('performance tests', function () {
- performanceTestWrapper(250, 10, '250x10')
- performanceTestWrapper(10, 1000, '10x1000')
- })
-})
diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json
index daf54fd2771..1be6c628ae6 100644
--- a/packages/toolkit/package.json
+++ b/packages/toolkit/package.json
@@ -246,6 +246,14 @@
"jsonResourceModification": {
"type": "boolean",
"default": false
+ },
+ "amazonqLSP": {
+ "type": "boolean",
+ "default": false
+ },
+ "amazonqChatLSP": {
+ "type": "boolean",
+ "default": false
}
},
"additionalProperties": false
diff --git a/packages/webpack.web.config.js b/packages/webpack.web.config.js
index fc6ca86d1d9..0f82af67389 100644
--- a/packages/webpack.web.config.js
+++ b/packages/webpack.web.config.js
@@ -41,7 +41,7 @@ module.exports = (env, argv) => {
* environments. The following allows compilation to pass in Web mode by never bundling the module in the final output for web mode.
*/
new webpack.IgnorePlugin({
- resourceRegExp: /httpResourceFetcher/, // matches the path in the require() statement
+ resourceRegExp: /node\/httpResourceFetcher/, // matches the path in the require() statement
}),
/**
* HACK: the ps-list module breaks Web mode if imported, BUT we still dynamically import this module for non web mode