-
Notifications
You must be signed in to change notification settings - Fork 241
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(amazonq): Added LSP Manifest manager related changes (#5387)
* Added Manifest Fetcher * Addressing code review comments * Added unit test cases * Fixing lint issues * Addressing code review comments * Addressing code review comments * Fixing lint issues * Addressing code review comments * Fixing detektMain lint issues * Added unit test cases * Updating code according to spec. * detekt * Fixing typo * Artifact changes * Fixing validation function * Addressing code review comments * Fixing Detekt
- Loading branch information
1 parent
6d593c3
commit 179aea2
Showing
9 changed files
with
609 additions
and
13 deletions.
There are no files selected for viewing
187 changes: 187 additions & 0 deletions
187
...nity/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,187 @@ | ||
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
// SPDX-License-Identifier: Apache-2.0 | ||
|
||
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts | ||
|
||
import com.intellij.util.io.createDirectories | ||
import com.intellij.util.text.SemVer | ||
import software.aws.toolkits.core.utils.deleteIfExists | ||
import software.aws.toolkits.core.utils.error | ||
import software.aws.toolkits.core.utils.exists | ||
import software.aws.toolkits.core.utils.getLogger | ||
import software.aws.toolkits.core.utils.info | ||
import software.aws.toolkits.core.utils.warn | ||
import software.aws.toolkits.jetbrains.core.saveFileFromUrl | ||
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager | ||
import java.nio.file.Path | ||
import java.util.concurrent.atomic.AtomicInteger | ||
|
||
class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH, private val maxDownloadAttempts: Int = MAX_DOWNLOAD_ATTEMPTS) { | ||
|
||
companion object { | ||
private val DEFAULT_ARTIFACT_PATH = getToolkitsCommonCacheRoot().resolve("aws").resolve("toolkits").resolve("language-servers") | ||
private val logger = getLogger<ArtifactHelper>() | ||
private const val MAX_DOWNLOAD_ATTEMPTS = 3 | ||
} | ||
private val currentAttempt = AtomicInteger(0) | ||
|
||
fun removeDelistedVersions(delistedVersions: List<ManifestManager.Version>) { | ||
val localFolders = getSubFolders(lspArtifactsPath) | ||
|
||
delistedVersions.forEach { delistedVersion -> | ||
val versionToDelete = delistedVersion.serverVersion ?: return@forEach | ||
|
||
localFolders | ||
.filter { folder -> folder.fileName.toString() == versionToDelete } | ||
.forEach { folder -> | ||
try { | ||
folder.toFile().deleteRecursively() | ||
logger.info { "Successfully deleted deListed version: ${folder.fileName}" } | ||
} catch (e: Exception) { | ||
logger.error(e) { "Failed to delete deListed version ${folder.fileName}: ${e.message}" } | ||
} | ||
} | ||
} | ||
} | ||
|
||
fun deleteOlderLspArtifacts(manifestVersionRanges: ArtifactManager.SupportedManifestVersionRange) { | ||
val localFolders = getSubFolders(lspArtifactsPath) | ||
|
||
val validVersions = localFolders | ||
.mapNotNull { localFolder -> | ||
SemVer.parseFromText(localFolder.fileName.toString())?.let { semVer -> | ||
if (semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion) { | ||
localFolder to semVer | ||
} else { | ||
null | ||
} | ||
} | ||
} | ||
.sortedByDescending { (_, semVer) -> semVer } | ||
|
||
// Keep the latest 2 versions, delete others | ||
validVersions.drop(2).forEach { (folder, _) -> | ||
try { | ||
folder.toFile().deleteRecursively() | ||
logger.info { "Deleted older LSP artifact: ${folder.fileName}" } | ||
} catch (e: Exception) { | ||
logger.error(e) { "Failed to delete older LSP artifact: ${folder.fileName}" } | ||
} | ||
} | ||
} | ||
|
||
fun getExistingLspArtifacts(versions: List<ManifestManager.Version>, target: ManifestManager.VersionTarget?): Boolean { | ||
if (versions.isEmpty() || target?.contents == null) return false | ||
|
||
val localLSPPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString()) | ||
if (!localLSPPath.exists()) return false | ||
|
||
val hasInvalidFiles = target.contents.any { content -> | ||
content.filename?.let { filename -> | ||
val filePath = localLSPPath.resolve(filename) | ||
!filePath.exists() || !validateFileHash(filePath, content.hashes?.firstOrNull()) | ||
} ?: false | ||
} | ||
|
||
if (hasInvalidFiles) { | ||
try { | ||
localLSPPath.toFile().deleteRecursively() | ||
logger.info { "Deleted mismatched LSP artifacts at: $localLSPPath" } | ||
} catch (e: Exception) { | ||
logger.error(e) { "Failed to delete mismatched LSP artifacts at: $localLSPPath" } | ||
} | ||
} | ||
return !hasInvalidFiles | ||
} | ||
|
||
fun tryDownloadLspArtifacts(versions: List<ManifestManager.Version>, target: ManifestManager.VersionTarget?) { | ||
val temporaryDownloadPath = lspArtifactsPath.resolve("temp") | ||
val downloadPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString()) | ||
|
||
while (currentAttempt.get() < maxDownloadAttempts) { | ||
currentAttempt.incrementAndGet() | ||
logger.info { "Attempt ${currentAttempt.get()} of $maxDownloadAttempts to download LSP artifacts" } | ||
|
||
try { | ||
if (downloadLspArtifacts(temporaryDownloadPath, target)) { | ||
moveFilesFromSourceToDestination(temporaryDownloadPath, downloadPath) | ||
logger.info { "Successfully downloaded and moved LSP artifacts to $downloadPath" } | ||
return | ||
} | ||
} catch (e: Exception) { | ||
logger.error(e) { "Failed to download/move LSP artifacts on attempt ${currentAttempt.get()}" } | ||
temporaryDownloadPath.toFile().deleteRecursively() | ||
|
||
if (currentAttempt.get() >= maxDownloadAttempts) { | ||
throw LspException("Failed to download LSP artifacts after $maxDownloadAttempts attempts", LspException.ErrorCode.DOWNLOAD_FAILED) | ||
} | ||
} | ||
} | ||
} | ||
|
||
private fun downloadLspArtifacts(downloadPath: Path, target: ManifestManager.VersionTarget?): Boolean { | ||
if (target == null || target.contents.isNullOrEmpty()) { | ||
logger.warn { "No target contents available for download" } | ||
return false | ||
} | ||
try { | ||
downloadPath.createDirectories() | ||
target.contents.forEach { content -> | ||
if (content.url == null || content.filename == null) { | ||
logger.warn { "Missing URL or filename in content" } | ||
return@forEach | ||
} | ||
val filePath = downloadPath.resolve(content.filename) | ||
val contentHash = content.hashes?.firstOrNull() ?: run { | ||
logger.warn { "No hash available for ${content.filename}" } | ||
return@forEach | ||
} | ||
downloadAndValidateFile(content.url, filePath, contentHash) | ||
} | ||
validateDownloadedFiles(downloadPath, target.contents) | ||
} catch (e: Exception) { | ||
logger.error(e) { "Failed to download LSP artifacts: ${e.message}" } | ||
downloadPath.toFile().deleteRecursively() | ||
return false | ||
} | ||
return true | ||
} | ||
|
||
private fun downloadAndValidateFile(url: String, filePath: Path, expectedHash: String) { | ||
try { | ||
if (!filePath.exists()) { | ||
logger.info { "Downloading file: ${filePath.fileName}" } | ||
saveFileFromUrl(url, filePath) | ||
} | ||
if (!validateFileHash(filePath, expectedHash)) { | ||
logger.warn { "Hash mismatch for ${filePath.fileName}, re-downloading" } | ||
filePath.deleteIfExists() | ||
saveFileFromUrl(url, filePath) | ||
if (!validateFileHash(filePath, expectedHash)) { | ||
throw LspException("Hash mismatch after re-download for ${filePath.fileName}", LspException.ErrorCode.HASH_MISMATCH) | ||
} | ||
} | ||
} catch (e: Exception) { | ||
throw IllegalStateException("Failed to download/validate file: ${filePath.fileName}", e) | ||
} | ||
} | ||
|
||
private fun validateFileHash(filePath: Path, expectedHash: String?): Boolean { | ||
if (expectedHash == null) return false | ||
val contentHash = generateSHA384Hash(filePath) | ||
return "sha384:$contentHash" == expectedHash | ||
} | ||
|
||
private fun validateDownloadedFiles(downloadPath: Path, contents: List<ManifestManager.TargetContent>) { | ||
val missingFiles = contents | ||
.mapNotNull { it.filename } | ||
.filter { filename -> | ||
!downloadPath.resolve(filename).exists() | ||
} | ||
if (missingFiles.isNotEmpty()) { | ||
val errorMessage = "Missing required files: ${missingFiles.joinToString(", ")}" | ||
logger.error { errorMessage } | ||
throw LspException(errorMessage, LspException.ErrorCode.DOWNLOAD_FAILED) | ||
} | ||
} | ||
} |
111 changes: 111 additions & 0 deletions
111
...ity/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
// SPDX-License-Identifier: Apache-2.0 | ||
|
||
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts | ||
|
||
import com.intellij.util.text.SemVer | ||
import org.assertj.core.util.VisibleForTesting | ||
import software.aws.toolkits.core.utils.error | ||
import software.aws.toolkits.core.utils.getLogger | ||
import software.aws.toolkits.core.utils.info | ||
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager | ||
|
||
class ArtifactManager { | ||
|
||
data class SupportedManifestVersionRange( | ||
val startVersion: SemVer, | ||
val endVersion: SemVer, | ||
) | ||
data class LSPVersions( | ||
val deListedVersions: List<ManifestManager.Version>, | ||
val inRangeVersions: List<ManifestManager.Version>, | ||
) | ||
|
||
private val manifestFetcher: ManifestFetcher | ||
private val artifactHelper: ArtifactHelper | ||
private val manifestVersionRanges: SupportedManifestVersionRange | ||
|
||
// Primary constructor with config | ||
constructor( | ||
manifestFetcher: ManifestFetcher = ManifestFetcher(), | ||
artifactFetcher: ArtifactHelper = ArtifactHelper(), | ||
manifestRange: SupportedManifestVersionRange?, | ||
) { | ||
manifestVersionRanges = manifestRange ?: DEFAULT_VERSION_RANGE | ||
this.manifestFetcher = manifestFetcher | ||
this.artifactHelper = artifactFetcher | ||
} | ||
|
||
// Secondary constructor with no parameters | ||
constructor() : this(ManifestFetcher(), ArtifactHelper(), null) | ||
|
||
companion object { | ||
private val DEFAULT_VERSION_RANGE = SupportedManifestVersionRange( | ||
startVersion = SemVer("3.0.0", 3, 0, 0), | ||
endVersion = SemVer("4.0.0", 4, 0, 0) | ||
) | ||
private val logger = getLogger<ArtifactManager>() | ||
} | ||
|
||
fun fetchArtifact() { | ||
val manifest = manifestFetcher.fetch() ?: throw LspException( | ||
"Language Support is not available, as manifest is missing.", | ||
LspException.ErrorCode.MANIFEST_FETCH_FAILED | ||
) | ||
val lspVersions = getLSPVersionsFromManifestWithSpecifiedRange(manifest) | ||
|
||
this.artifactHelper.removeDelistedVersions(lspVersions.deListedVersions) | ||
|
||
if (lspVersions.inRangeVersions.isEmpty()) { | ||
// No versions are found which are in the given range. | ||
throw LspException("Language server versions not found in manifest.", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION) | ||
} | ||
|
||
// If there is an LSP Manifest with the same version | ||
val target = getTargetFromLspManifest(lspVersions.inRangeVersions) | ||
|
||
// Get Local LSP files and check if we can re-use existing LSP Artifacts | ||
if (!this.artifactHelper.getExistingLspArtifacts(lspVersions.inRangeVersions, target)) { | ||
this.artifactHelper.tryDownloadLspArtifacts(lspVersions.inRangeVersions, target) | ||
} | ||
|
||
this.artifactHelper.deleteOlderLspArtifacts(manifestVersionRanges) | ||
} | ||
|
||
@VisibleForTesting | ||
internal fun getLSPVersionsFromManifestWithSpecifiedRange(manifest: ManifestManager.Manifest): LSPVersions { | ||
if (manifest.versions.isNullOrEmpty()) return LSPVersions(emptyList(), emptyList()) | ||
|
||
val (deListed, inRange) = manifest.versions.mapNotNull { version -> | ||
version.serverVersion?.let { serverVersion -> | ||
SemVer.parseFromText(serverVersion)?.let { semVer -> | ||
when { | ||
version.isDelisted != false -> Pair(version, true) // Is deListed | ||
semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion -> Pair(version, false) // Is in range | ||
else -> null | ||
} | ||
} | ||
} | ||
}.partition { it.second } | ||
|
||
return LSPVersions( | ||
deListedVersions = deListed.map { it.first }, | ||
inRangeVersions = inRange.map { it.first }.sortedByDescending { (_, semVer) -> semVer } | ||
) | ||
} | ||
|
||
private fun getTargetFromLspManifest(versions: List<ManifestManager.Version>): ManifestManager.VersionTarget { | ||
val currentOS = getCurrentOS() | ||
val currentArchitecture = getCurrentArchitecture() | ||
|
||
val currentTarget = versions.first().targets?.find { target -> | ||
target.platform == currentOS && target.arch == currentArchitecture | ||
} | ||
if (currentTarget == null) { | ||
logger.error { "Failed to obtain target for $currentOS and $currentArchitecture" } | ||
throw LspException("Target not found in the current Version: ${versions.first().serverVersion}", LspException.ErrorCode.TARGET_NOT_FOUND) | ||
} | ||
logger.info { "Target found in the current Version: ${versions.first().serverVersion}" } | ||
return currentTarget | ||
} | ||
} |
20 changes: 20 additions & 0 deletions
20
...munity/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
// SPDX-License-Identifier: Apache-2.0 | ||
|
||
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts | ||
|
||
class LspException(message: String, private val errorCode: ErrorCode, cause: Throwable? = null) : Exception(message, cause) { | ||
|
||
enum class ErrorCode { | ||
MANIFEST_FETCH_FAILED, | ||
DOWNLOAD_FAILED, | ||
HASH_MISMATCH, | ||
TARGET_NOT_FOUND, | ||
NO_COMPATIBLE_LSP_VERSION, | ||
} | ||
|
||
override fun toString(): String = buildString { | ||
append("LSP Error [$errorCode]: $message") | ||
cause?.let { append(", Cause: ${it.message}") } | ||
} | ||
} |
68 changes: 68 additions & 0 deletions
68
...-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
// SPDX-License-Identifier: Apache-2.0 | ||
|
||
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts | ||
|
||
import com.intellij.openapi.util.SystemInfo | ||
import com.intellij.openapi.util.text.StringUtil | ||
import com.intellij.util.io.DigestUtil | ||
import com.intellij.util.system.CpuArch | ||
import java.nio.file.Files | ||
import java.nio.file.Path | ||
import java.nio.file.Paths | ||
import java.nio.file.StandardCopyOption | ||
import java.security.MessageDigest | ||
import kotlin.io.path.isDirectory | ||
import kotlin.io.path.listDirectoryEntries | ||
|
||
fun getToolkitsCommonCacheRoot(): Path = when { | ||
SystemInfo.isWindows -> { | ||
Paths.get(System.getenv("LOCALAPPDATA")) | ||
} | ||
SystemInfo.isMac -> { | ||
Paths.get(System.getProperty("user.home"), "Library", "Caches") | ||
} | ||
else -> { | ||
Paths.get(System.getProperty("user.home"), ".cache") | ||
} | ||
} | ||
|
||
fun getCurrentOS(): String = when { | ||
SystemInfo.isWindows -> "windows" | ||
SystemInfo.isMac -> "darwin" | ||
else -> "linux" | ||
} | ||
|
||
fun getCurrentArchitecture() = when (CpuArch.CURRENT) { | ||
CpuArch.X86_64 -> "x64" | ||
CpuArch.ARM64 -> "arm64" | ||
else -> "unknown" | ||
} | ||
|
||
fun generateMD5Hash(filePath: Path): String { | ||
val messageDigest = DigestUtil.md5() | ||
DigestUtil.updateContentHash(messageDigest, filePath) | ||
return StringUtil.toHexString(messageDigest.digest()) | ||
} | ||
|
||
fun generateSHA384Hash(filePath: Path): String { | ||
val messageDigest = MessageDigest.getInstance("SHA-384") | ||
DigestUtil.updateContentHash(messageDigest, filePath) | ||
return StringUtil.toHexString(messageDigest.digest()) | ||
} | ||
|
||
fun getSubFolders(basePath: Path): List<Path> = try { | ||
basePath.listDirectoryEntries() | ||
.filter { it.isDirectory() } | ||
} catch (e: Exception) { | ||
emptyList() | ||
} | ||
|
||
fun moveFilesFromSourceToDestination(sourceDir: Path, targetDir: Path) { | ||
try { | ||
Files.createDirectories(targetDir.parent) | ||
Files.move(sourceDir, targetDir, StandardCopyOption.REPLACE_EXISTING) | ||
} catch (e: Exception) { | ||
throw IllegalStateException("Failed to move files from $sourceDir to $targetDir", e) | ||
} | ||
} |
Oops, something went wrong.