Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 51 additions & 9 deletions packages/core/src/amazonq/lsp/workspaceInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,48 @@
*/

import path from 'path'
import { LspResolution, LspResolver } from '../../shared/lsp/types'
import { LspResolution, LspResolver, LspVersion } from '../../shared/lsp/types'
import { ManifestResolver } from '../../shared/lsp/manifestResolver'
import { LanguageServerResolver } from '../../shared/lsp/lspResolver'
import { Range } from 'semver'
import { Range, sort } from 'semver'
import { getNodeExecutableName } from '../../shared/lsp/utils/platform'
import { fs } from '../../shared/fs/fs'
import { partition } from '../../shared/utilities/tsUtils'

const manifestUrl = 'https://aws-toolkit-language-servers.amazonaws.com/q-context/manifest.json'
export const lspManifestUrl = '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.32'
export const supportedLspServerVersions = '0.1.32'
export const lspWorkspaceName = 'AmazonQ-Workspace'

export class WorkspaceLSPResolver implements LspResolver {
private readonly versionRange: Range
private readonly shouldCleanUp: boolean
public constructor(
options?: Partial<{
versionRange: Range
cleanUp: boolean
}>
) {
this.versionRange = options?.versionRange ?? new Range(supportedLspServerVersions)
this.shouldCleanUp = options?.cleanUp ?? true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there any case where we would make this false?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was using this to get greater control in the integ tests, but considering those tests aren't even running, it likely makes sense to remove this bloat for now.

}

async resolve(): Promise<LspResolution> {
const name = 'AmazonQ-Workspace'
const manifest = await new ManifestResolver(manifestUrl, name).resolve()
const manifest = await new ManifestResolver(lspManifestUrl, lspWorkspaceName).resolve()
const installationResult = await new LanguageServerResolver(
manifest,
name,
new Range(supportedLspServerVersions)
lspWorkspaceName,
this.versionRange
).resolve()

const nodeName =
process.platform === 'win32' ? getNodeExecutableName() : `node-${process.platform}-${process.arch}`
const nodePath = path.join(installationResult.assetDirectory, nodeName)
await fs.chmod(nodePath, 0o755)

// TODO Cleanup old versions of language servers
if (this.shouldCleanUp) {
await this.cleanUp(manifest.versions, path.dirname(installationResult.assetDirectory))
}
return {
...installationResult,
resourcePaths: {
Expand All @@ -42,4 +57,31 @@ export class WorkspaceLSPResolver implements LspResolver {
},
}
}

private async getDownloadedVersions(downloadDirectory: string): Promise<string[]> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you pull this into a common location so the other language server can use it: https://github.com/aws/aws-toolkit-vscode/blob/feature/amazonqLSP/packages/amazonq/src/lsp/lspInstaller.ts

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved it to its own util.ts file to avoid circular dependency. Lmk if there is a better place.

return (await fs.readdir(downloadDirectory)).map(([f, _], __) => f)
}

private isDelisted(manifestVersions: LspVersion[], targetVersion: string): boolean {
Copy link
Contributor

@jpinkney-aws jpinkney-aws Jan 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these are still missing from this file as well: https://github.com/aws/aws-toolkit-vscode/blob/feature/amazonqLSP/packages/amazonq/src/lsp/lspInstaller.ts#L21. Since this only does the cleanup for the workspace context language server and not the codewhisperer one

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it to a general location in core to avoid duplicating.

return manifestVersions.find((v) => v.serverVersion === targetVersion)?.isDelisted ?? false
}

/**
* Delete all delisted versions and keep the two newest versions that remain
* @param manifest
* @param downloadDirectory
*/
async cleanUp(manifestVersions: LspVersion[], downloadDirectory: string): Promise<void> {
const downloadedVersions = await this.getDownloadedVersions(downloadDirectory)
const [delistedVersions, remainingVersions] = partition(downloadedVersions, (v: string) =>
this.isDelisted(manifestVersions, v)
)
for (const v of delistedVersions) {
await fs.delete(path.join(downloadDirectory, v), { force: true, recursive: true })
}

for (const v of sort(remainingVersions).slice(0, -2)) {
await fs.delete(path.join(downloadDirectory, v), { force: true, recursive: true })
}
}
}
8 changes: 6 additions & 2 deletions packages/core/src/shared/lsp/lspResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,9 +339,13 @@ export class LanguageServerResolver {
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(getApplicationSupportFolder(), `aws/toolkits/language-servers`)
}

defaultDownloadFolder() {
const applicationSupportFolder = getApplicationSupportFolder()
return path.join(applicationSupportFolder, `aws/toolkits/language-servers/${this.lsName}`)
return path.join(LanguageServerResolver.defaultDir, `${this.lsName}`)
}

private getDownloadDirectory(version: string) {
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/shared/utilities/tsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ export function createFactoryFunction<T extends new (...args: any[]) => any>(cto
return (...args) => new ctor(...args)
}

export function partition<T>(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<T> = { [Property in keyof T]: Property extends symbol ? never : Property }[keyof T]
export type InterfaceNoSymbol<T> = Pick<T, NoSymbols<T>>
/**
Expand Down
58 changes: 58 additions & 0 deletions packages/core/src/test/amazonq/lsp/workspaceinstaller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import { Uri } from 'vscode'
import { fs } from '../../../shared'
import { createTestWorkspaceFolder } from '../../testUtil'
import path from 'path'
import { WorkspaceLSPResolver } from '../../../amazonq/lsp/workspaceInstaller'
import assert from 'assert'

async function fakeInstallVersion(version: string, installationDir: string): Promise<void> {
const versionDir = path.join(installationDir, version)
await fs.mkdir(versionDir)
await fs.writeFile(path.join(versionDir, 'file.txt'), 'content')
}

describe('workspaceInstaller', function () {
describe('cleanUp', function () {
let installationDir: Uri
let versions: string[]

before(async function () {
installationDir = (await createTestWorkspaceFolder()).uri
versions = ['1.0.0', '1.0.1', '1.1.1', '2.1.1']
})

beforeEach(async function () {
for (const v of versions) {
await fakeInstallVersion(v, installationDir.fsPath)
}
})

after(async function () {
await fs.delete(installationDir, { force: true, recursive: true })
})
it('keeps two newest versions', async function () {
const wsr = new WorkspaceLSPResolver()
await wsr.cleanUp([], 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'))
})

it('deletes delisted versions', async function () {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a couple more tests to make sure if we have < 2 versions that everything behaves correctly? I think in that case we would want to keep the latest 2 just in case one gets delisted and we can fall back to the other

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added 3 new cases.

  • if there are less than 2 remaining versions (after filtering for delisted), we should keep all of them.
  • if there are less than 2 versions overall, we should keep all of the non-delisted ones.
  • if all versions present are delisted, we still shouldn't download it.

Do you think that covers the edge cases with < 2 versions?

const wsr = new WorkspaceLSPResolver()
await wsr.cleanUp([{ 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'))
})
})
})
16 changes: 16 additions & 0 deletions packages/core/src/test/shared/utilities/tsUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import { partition } from '../../../shared/utilities/tsUtils'
import assert from 'assert'

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])
})
})
78 changes: 78 additions & 0 deletions packages/core/src/testInteg/amazonq/lsp/workspaceInstaller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import { Range, sort } from 'semver'
import assert from 'assert'
import { lspWorkspaceName, lspManifestUrl, WorkspaceLSPResolver } from '../../../amazonq/lsp/workspaceInstaller'
import { fs } from '../../../shared/fs/fs'
import path from 'path'
import * as sinon from 'sinon'
import { LanguageServerResolver, ManifestResolver } from '../../../shared'

async function installVersion(version: string, cleanUp: boolean = false) {
const resolver = new WorkspaceLSPResolver({ versionRange: new Range(version), cleanUp: cleanUp })
return await resolver.resolve()
}

/**
* Installs all versions, only running 'cleanUp' on the last install.
* @param versions
* @returns
*/
async function testInstallVersions(versions: string[]) {
await Promise.all(versions.slice(0, -1).map(async (version) => await installVersion(version)))
const finalVersionResult = await installVersion(versions[versions.length - 1], true)
const allVersions = path.dirname(finalVersionResult.assetDirectory)
const versionsDownloaded = (await fs.readdir(allVersions)).map(([f, _], __) => f)
return versionsDownloaded
}

describe('workspaceInstaller', function () {
let testVersions: string[]
let onMac: boolean
before(async function () {
// TODO: remove this when non-mac support is added.
onMac = process.platform === 'darwin'
if (!onMac) {
this.skip()
}
await fs.delete(LanguageServerResolver.defaultDir, { force: true, recursive: true })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a way we can mock this path somehow we aren't nuking our language servers when we run this test

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to run them as e2e as possible, but I think for these integ tests the setup will take more work to get right. Right now, I deleted them, and we can re-add in a followup if it seems important.

const manifest = await new ManifestResolver(lspManifestUrl, lspWorkspaceName).resolve()
testVersions = sort(
manifest.versions
.filter((v) => !v.isDelisted)
.slice(0, 4)
.map((v) => v.serverVersion)
)
})

it('removes all but the latest two versions', async function () {
if (!onMac) {
this.skip()
}
const versionsDownloaded = await testInstallVersions(testVersions)

assert.strictEqual(versionsDownloaded.length, 2)
assert.ok(versionsDownloaded.includes(testVersions[testVersions.length - 1]))
assert.ok(versionsDownloaded.includes(testVersions[testVersions.length - 2]))
})

it('removes delisted versions then keeps 2 remaining most recent', async function () {
if (!onMac) {
this.skip()
}
const isDelisted = sinon.stub(WorkspaceLSPResolver.prototype, 'isDelisted' as any)
isDelisted.callsFake((_manifestVersions, version) => {
return version === testVersions[testVersions.length - 2]
})

const versionsDownloaded = await testInstallVersions(testVersions)

assert.strictEqual(versionsDownloaded.length, 2)
assert.ok(versionsDownloaded.includes(testVersions[testVersions.length - 1]))
assert.ok(versionsDownloaded.includes(testVersions[testVersions.length - 3]))
isDelisted.restore()
})
})
Loading