Skip to content

Commit 179aea2

Browse files
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
1 parent 6d593c3 commit 179aea2

File tree

9 files changed

+609
-13
lines changed

9 files changed

+609
-13
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
5+
6+
import com.intellij.util.io.createDirectories
7+
import com.intellij.util.text.SemVer
8+
import software.aws.toolkits.core.utils.deleteIfExists
9+
import software.aws.toolkits.core.utils.error
10+
import software.aws.toolkits.core.utils.exists
11+
import software.aws.toolkits.core.utils.getLogger
12+
import software.aws.toolkits.core.utils.info
13+
import software.aws.toolkits.core.utils.warn
14+
import software.aws.toolkits.jetbrains.core.saveFileFromUrl
15+
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
16+
import java.nio.file.Path
17+
import java.util.concurrent.atomic.AtomicInteger
18+
19+
class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH, private val maxDownloadAttempts: Int = MAX_DOWNLOAD_ATTEMPTS) {
20+
21+
companion object {
22+
private val DEFAULT_ARTIFACT_PATH = getToolkitsCommonCacheRoot().resolve("aws").resolve("toolkits").resolve("language-servers")
23+
private val logger = getLogger<ArtifactHelper>()
24+
private const val MAX_DOWNLOAD_ATTEMPTS = 3
25+
}
26+
private val currentAttempt = AtomicInteger(0)
27+
28+
fun removeDelistedVersions(delistedVersions: List<ManifestManager.Version>) {
29+
val localFolders = getSubFolders(lspArtifactsPath)
30+
31+
delistedVersions.forEach { delistedVersion ->
32+
val versionToDelete = delistedVersion.serverVersion ?: return@forEach
33+
34+
localFolders
35+
.filter { folder -> folder.fileName.toString() == versionToDelete }
36+
.forEach { folder ->
37+
try {
38+
folder.toFile().deleteRecursively()
39+
logger.info { "Successfully deleted deListed version: ${folder.fileName}" }
40+
} catch (e: Exception) {
41+
logger.error(e) { "Failed to delete deListed version ${folder.fileName}: ${e.message}" }
42+
}
43+
}
44+
}
45+
}
46+
47+
fun deleteOlderLspArtifacts(manifestVersionRanges: ArtifactManager.SupportedManifestVersionRange) {
48+
val localFolders = getSubFolders(lspArtifactsPath)
49+
50+
val validVersions = localFolders
51+
.mapNotNull { localFolder ->
52+
SemVer.parseFromText(localFolder.fileName.toString())?.let { semVer ->
53+
if (semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion) {
54+
localFolder to semVer
55+
} else {
56+
null
57+
}
58+
}
59+
}
60+
.sortedByDescending { (_, semVer) -> semVer }
61+
62+
// Keep the latest 2 versions, delete others
63+
validVersions.drop(2).forEach { (folder, _) ->
64+
try {
65+
folder.toFile().deleteRecursively()
66+
logger.info { "Deleted older LSP artifact: ${folder.fileName}" }
67+
} catch (e: Exception) {
68+
logger.error(e) { "Failed to delete older LSP artifact: ${folder.fileName}" }
69+
}
70+
}
71+
}
72+
73+
fun getExistingLspArtifacts(versions: List<ManifestManager.Version>, target: ManifestManager.VersionTarget?): Boolean {
74+
if (versions.isEmpty() || target?.contents == null) return false
75+
76+
val localLSPPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString())
77+
if (!localLSPPath.exists()) return false
78+
79+
val hasInvalidFiles = target.contents.any { content ->
80+
content.filename?.let { filename ->
81+
val filePath = localLSPPath.resolve(filename)
82+
!filePath.exists() || !validateFileHash(filePath, content.hashes?.firstOrNull())
83+
} ?: false
84+
}
85+
86+
if (hasInvalidFiles) {
87+
try {
88+
localLSPPath.toFile().deleteRecursively()
89+
logger.info { "Deleted mismatched LSP artifacts at: $localLSPPath" }
90+
} catch (e: Exception) {
91+
logger.error(e) { "Failed to delete mismatched LSP artifacts at: $localLSPPath" }
92+
}
93+
}
94+
return !hasInvalidFiles
95+
}
96+
97+
fun tryDownloadLspArtifacts(versions: List<ManifestManager.Version>, target: ManifestManager.VersionTarget?) {
98+
val temporaryDownloadPath = lspArtifactsPath.resolve("temp")
99+
val downloadPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString())
100+
101+
while (currentAttempt.get() < maxDownloadAttempts) {
102+
currentAttempt.incrementAndGet()
103+
logger.info { "Attempt ${currentAttempt.get()} of $maxDownloadAttempts to download LSP artifacts" }
104+
105+
try {
106+
if (downloadLspArtifacts(temporaryDownloadPath, target)) {
107+
moveFilesFromSourceToDestination(temporaryDownloadPath, downloadPath)
108+
logger.info { "Successfully downloaded and moved LSP artifacts to $downloadPath" }
109+
return
110+
}
111+
} catch (e: Exception) {
112+
logger.error(e) { "Failed to download/move LSP artifacts on attempt ${currentAttempt.get()}" }
113+
temporaryDownloadPath.toFile().deleteRecursively()
114+
115+
if (currentAttempt.get() >= maxDownloadAttempts) {
116+
throw LspException("Failed to download LSP artifacts after $maxDownloadAttempts attempts", LspException.ErrorCode.DOWNLOAD_FAILED)
117+
}
118+
}
119+
}
120+
}
121+
122+
private fun downloadLspArtifacts(downloadPath: Path, target: ManifestManager.VersionTarget?): Boolean {
123+
if (target == null || target.contents.isNullOrEmpty()) {
124+
logger.warn { "No target contents available for download" }
125+
return false
126+
}
127+
try {
128+
downloadPath.createDirectories()
129+
target.contents.forEach { content ->
130+
if (content.url == null || content.filename == null) {
131+
logger.warn { "Missing URL or filename in content" }
132+
return@forEach
133+
}
134+
val filePath = downloadPath.resolve(content.filename)
135+
val contentHash = content.hashes?.firstOrNull() ?: run {
136+
logger.warn { "No hash available for ${content.filename}" }
137+
return@forEach
138+
}
139+
downloadAndValidateFile(content.url, filePath, contentHash)
140+
}
141+
validateDownloadedFiles(downloadPath, target.contents)
142+
} catch (e: Exception) {
143+
logger.error(e) { "Failed to download LSP artifacts: ${e.message}" }
144+
downloadPath.toFile().deleteRecursively()
145+
return false
146+
}
147+
return true
148+
}
149+
150+
private fun downloadAndValidateFile(url: String, filePath: Path, expectedHash: String) {
151+
try {
152+
if (!filePath.exists()) {
153+
logger.info { "Downloading file: ${filePath.fileName}" }
154+
saveFileFromUrl(url, filePath)
155+
}
156+
if (!validateFileHash(filePath, expectedHash)) {
157+
logger.warn { "Hash mismatch for ${filePath.fileName}, re-downloading" }
158+
filePath.deleteIfExists()
159+
saveFileFromUrl(url, filePath)
160+
if (!validateFileHash(filePath, expectedHash)) {
161+
throw LspException("Hash mismatch after re-download for ${filePath.fileName}", LspException.ErrorCode.HASH_MISMATCH)
162+
}
163+
}
164+
} catch (e: Exception) {
165+
throw IllegalStateException("Failed to download/validate file: ${filePath.fileName}", e)
166+
}
167+
}
168+
169+
private fun validateFileHash(filePath: Path, expectedHash: String?): Boolean {
170+
if (expectedHash == null) return false
171+
val contentHash = generateSHA384Hash(filePath)
172+
return "sha384:$contentHash" == expectedHash
173+
}
174+
175+
private fun validateDownloadedFiles(downloadPath: Path, contents: List<ManifestManager.TargetContent>) {
176+
val missingFiles = contents
177+
.mapNotNull { it.filename }
178+
.filter { filename ->
179+
!downloadPath.resolve(filename).exists()
180+
}
181+
if (missingFiles.isNotEmpty()) {
182+
val errorMessage = "Missing required files: ${missingFiles.joinToString(", ")}"
183+
logger.error { errorMessage }
184+
throw LspException(errorMessage, LspException.ErrorCode.DOWNLOAD_FAILED)
185+
}
186+
}
187+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
5+
6+
import com.intellij.util.text.SemVer
7+
import org.assertj.core.util.VisibleForTesting
8+
import software.aws.toolkits.core.utils.error
9+
import software.aws.toolkits.core.utils.getLogger
10+
import software.aws.toolkits.core.utils.info
11+
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
12+
13+
class ArtifactManager {
14+
15+
data class SupportedManifestVersionRange(
16+
val startVersion: SemVer,
17+
val endVersion: SemVer,
18+
)
19+
data class LSPVersions(
20+
val deListedVersions: List<ManifestManager.Version>,
21+
val inRangeVersions: List<ManifestManager.Version>,
22+
)
23+
24+
private val manifestFetcher: ManifestFetcher
25+
private val artifactHelper: ArtifactHelper
26+
private val manifestVersionRanges: SupportedManifestVersionRange
27+
28+
// Primary constructor with config
29+
constructor(
30+
manifestFetcher: ManifestFetcher = ManifestFetcher(),
31+
artifactFetcher: ArtifactHelper = ArtifactHelper(),
32+
manifestRange: SupportedManifestVersionRange?,
33+
) {
34+
manifestVersionRanges = manifestRange ?: DEFAULT_VERSION_RANGE
35+
this.manifestFetcher = manifestFetcher
36+
this.artifactHelper = artifactFetcher
37+
}
38+
39+
// Secondary constructor with no parameters
40+
constructor() : this(ManifestFetcher(), ArtifactHelper(), null)
41+
42+
companion object {
43+
private val DEFAULT_VERSION_RANGE = SupportedManifestVersionRange(
44+
startVersion = SemVer("3.0.0", 3, 0, 0),
45+
endVersion = SemVer("4.0.0", 4, 0, 0)
46+
)
47+
private val logger = getLogger<ArtifactManager>()
48+
}
49+
50+
fun fetchArtifact() {
51+
val manifest = manifestFetcher.fetch() ?: throw LspException(
52+
"Language Support is not available, as manifest is missing.",
53+
LspException.ErrorCode.MANIFEST_FETCH_FAILED
54+
)
55+
val lspVersions = getLSPVersionsFromManifestWithSpecifiedRange(manifest)
56+
57+
this.artifactHelper.removeDelistedVersions(lspVersions.deListedVersions)
58+
59+
if (lspVersions.inRangeVersions.isEmpty()) {
60+
// No versions are found which are in the given range.
61+
throw LspException("Language server versions not found in manifest.", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION)
62+
}
63+
64+
// If there is an LSP Manifest with the same version
65+
val target = getTargetFromLspManifest(lspVersions.inRangeVersions)
66+
67+
// Get Local LSP files and check if we can re-use existing LSP Artifacts
68+
if (!this.artifactHelper.getExistingLspArtifacts(lspVersions.inRangeVersions, target)) {
69+
this.artifactHelper.tryDownloadLspArtifacts(lspVersions.inRangeVersions, target)
70+
}
71+
72+
this.artifactHelper.deleteOlderLspArtifacts(manifestVersionRanges)
73+
}
74+
75+
@VisibleForTesting
76+
internal fun getLSPVersionsFromManifestWithSpecifiedRange(manifest: ManifestManager.Manifest): LSPVersions {
77+
if (manifest.versions.isNullOrEmpty()) return LSPVersions(emptyList(), emptyList())
78+
79+
val (deListed, inRange) = manifest.versions.mapNotNull { version ->
80+
version.serverVersion?.let { serverVersion ->
81+
SemVer.parseFromText(serverVersion)?.let { semVer ->
82+
when {
83+
version.isDelisted != false -> Pair(version, true) // Is deListed
84+
semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion -> Pair(version, false) // Is in range
85+
else -> null
86+
}
87+
}
88+
}
89+
}.partition { it.second }
90+
91+
return LSPVersions(
92+
deListedVersions = deListed.map { it.first },
93+
inRangeVersions = inRange.map { it.first }.sortedByDescending { (_, semVer) -> semVer }
94+
)
95+
}
96+
97+
private fun getTargetFromLspManifest(versions: List<ManifestManager.Version>): ManifestManager.VersionTarget {
98+
val currentOS = getCurrentOS()
99+
val currentArchitecture = getCurrentArchitecture()
100+
101+
val currentTarget = versions.first().targets?.find { target ->
102+
target.platform == currentOS && target.arch == currentArchitecture
103+
}
104+
if (currentTarget == null) {
105+
logger.error { "Failed to obtain target for $currentOS and $currentArchitecture" }
106+
throw LspException("Target not found in the current Version: ${versions.first().serverVersion}", LspException.ErrorCode.TARGET_NOT_FOUND)
107+
}
108+
logger.info { "Target found in the current Version: ${versions.first().serverVersion}" }
109+
return currentTarget
110+
}
111+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
5+
6+
class LspException(message: String, private val errorCode: ErrorCode, cause: Throwable? = null) : Exception(message, cause) {
7+
8+
enum class ErrorCode {
9+
MANIFEST_FETCH_FAILED,
10+
DOWNLOAD_FAILED,
11+
HASH_MISMATCH,
12+
TARGET_NOT_FOUND,
13+
NO_COMPATIBLE_LSP_VERSION,
14+
}
15+
16+
override fun toString(): String = buildString {
17+
append("LSP Error [$errorCode]: $message")
18+
cause?.let { append(", Cause: ${it.message}") }
19+
}
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
5+
6+
import com.intellij.openapi.util.SystemInfo
7+
import com.intellij.openapi.util.text.StringUtil
8+
import com.intellij.util.io.DigestUtil
9+
import com.intellij.util.system.CpuArch
10+
import java.nio.file.Files
11+
import java.nio.file.Path
12+
import java.nio.file.Paths
13+
import java.nio.file.StandardCopyOption
14+
import java.security.MessageDigest
15+
import kotlin.io.path.isDirectory
16+
import kotlin.io.path.listDirectoryEntries
17+
18+
fun getToolkitsCommonCacheRoot(): Path = when {
19+
SystemInfo.isWindows -> {
20+
Paths.get(System.getenv("LOCALAPPDATA"))
21+
}
22+
SystemInfo.isMac -> {
23+
Paths.get(System.getProperty("user.home"), "Library", "Caches")
24+
}
25+
else -> {
26+
Paths.get(System.getProperty("user.home"), ".cache")
27+
}
28+
}
29+
30+
fun getCurrentOS(): String = when {
31+
SystemInfo.isWindows -> "windows"
32+
SystemInfo.isMac -> "darwin"
33+
else -> "linux"
34+
}
35+
36+
fun getCurrentArchitecture() = when (CpuArch.CURRENT) {
37+
CpuArch.X86_64 -> "x64"
38+
CpuArch.ARM64 -> "arm64"
39+
else -> "unknown"
40+
}
41+
42+
fun generateMD5Hash(filePath: Path): String {
43+
val messageDigest = DigestUtil.md5()
44+
DigestUtil.updateContentHash(messageDigest, filePath)
45+
return StringUtil.toHexString(messageDigest.digest())
46+
}
47+
48+
fun generateSHA384Hash(filePath: Path): String {
49+
val messageDigest = MessageDigest.getInstance("SHA-384")
50+
DigestUtil.updateContentHash(messageDigest, filePath)
51+
return StringUtil.toHexString(messageDigest.digest())
52+
}
53+
54+
fun getSubFolders(basePath: Path): List<Path> = try {
55+
basePath.listDirectoryEntries()
56+
.filter { it.isDirectory() }
57+
} catch (e: Exception) {
58+
emptyList()
59+
}
60+
61+
fun moveFilesFromSourceToDestination(sourceDir: Path, targetDir: Path) {
62+
try {
63+
Files.createDirectories(targetDir.parent)
64+
Files.move(sourceDir, targetDir, StandardCopyOption.REPLACE_EXISTING)
65+
} catch (e: Exception) {
66+
throw IllegalStateException("Failed to move files from $sourceDir to $targetDir", e)
67+
}
68+
}

0 commit comments

Comments
 (0)