|
3 | 3 |
|
4 | 4 | package software.aws.toolkits.jetbrains.core.lsp |
5 | 5 |
|
| 6 | +import com.intellij.execution.configurations.GeneralCommandLine |
6 | 7 | import com.intellij.execution.configurations.PathEnvironmentVariableUtil |
7 | 8 | import com.intellij.execution.util.ExecUtil |
8 | 9 | import com.intellij.openapi.util.SystemInfo |
| 10 | +import org.jetbrains.annotations.VisibleForTesting |
9 | 11 | import software.aws.toolkits.core.utils.debug |
10 | 12 | import software.aws.toolkits.core.utils.getLogger |
| 13 | +import java.nio.file.FileSystems |
11 | 14 | import java.nio.file.Files |
12 | 15 | import java.nio.file.Path |
13 | 16 |
|
| 17 | +internal enum class Platform { MAC, LINUX, WINDOWS } |
| 18 | + |
| 19 | +private val BIN_DIR = mapOf(Platform.MAC to "bin/", Platform.LINUX to "bin/", Platform.WINDOWS to "") |
| 20 | +private val EXE_NAME = mapOf(Platform.MAC to "node", Platform.LINUX to "node", Platform.WINDOWS to "node.exe") |
| 21 | + |
| 22 | +@VisibleForTesting |
| 23 | +internal fun buildWellKnownPaths(platform: Platform, home: Path): List<Path> { |
| 24 | + val exeName = EXE_NAME.getValue(platform) |
| 25 | + return buildList { |
| 26 | + if (platform == Platform.MAC) { |
| 27 | + add(Path.of("/opt/homebrew/bin/$exeName")) |
| 28 | + add(Path.of("/usr/local/bin/$exeName")) |
| 29 | + add(home.resolve(".asdf/shims/$exeName")) |
| 30 | + } |
| 31 | + if (platform == Platform.LINUX) { |
| 32 | + add(Path.of("/usr/bin/$exeName")) |
| 33 | + add(Path.of("/usr/local/bin/$exeName")) |
| 34 | + add(Path.of("/snap/bin/$exeName")) |
| 35 | + add(Path.of("/home/linuxbrew/.linuxbrew/bin/$exeName")) |
| 36 | + add(home.resolve(".asdf/shims/$exeName")) |
| 37 | + } |
| 38 | + if (platform == Platform.WINDOWS) { |
| 39 | + add(Path.of("C:/Program Files/nodejs/$exeName")) |
| 40 | + add(Path.of("C:/ProgramData/chocolatey/bin/$exeName")) |
| 41 | + add(home.resolve("scoop/apps/nodejs/current/$exeName")) |
| 42 | + } |
| 43 | + } |
| 44 | +} |
| 45 | + |
| 46 | +@VisibleForTesting |
| 47 | +internal fun buildGlobPatterns(platform: Platform, home: Path, env: (String) -> String?): List<String> { |
| 48 | + val exeName = EXE_NAME.getValue(platform) |
| 49 | + val bin = BIN_DIR.getValue(platform) |
| 50 | + |
| 51 | + return buildList { |
| 52 | + if (platform == Platform.MAC) { |
| 53 | + add("/opt/homebrew/Cellar/node*/*/bin/$exeName") |
| 54 | + add("/usr/local/Cellar/node*/*/bin/$exeName") |
| 55 | + } |
| 56 | + |
| 57 | + // nvm |
| 58 | + val nvmDir = env("NVM_DIR")?.let { Path.of(it) } ?: home.resolve(".nvm") |
| 59 | + if (platform != Platform.WINDOWS) { |
| 60 | + add("$nvmDir/versions/node/v*/bin/$exeName") |
| 61 | + } else { |
| 62 | + val nvmHome = env("NVM_HOME")?.let { Path.of(it) } |
| 63 | + ?: env("APPDATA")?.let { Path.of(it, "nvm") } |
| 64 | + nvmHome?.let { add("$it/v*/$exeName") } |
| 65 | + } |
| 66 | + |
| 67 | + // fnm |
| 68 | + val fnmBase = when (platform) { |
| 69 | + Platform.MAC -> home.resolve("Library/Application Support/fnm") |
| 70 | + Platform.LINUX -> (env("XDG_DATA_HOME")?.let { Path.of(it) } ?: home.resolve(".local/share")).resolve("fnm") |
| 71 | + Platform.WINDOWS -> env("APPDATA")?.let { Path.of(it, "fnm") } |
| 72 | + } |
| 73 | + fnmBase?.let { add("$it/node-versions/v*/installation/${bin}$exeName") } |
| 74 | + |
| 75 | + // volta |
| 76 | + val voltaHome = if (platform == Platform.WINDOWS) { |
| 77 | + env("LOCALAPPDATA")?.let { Path.of(it, "Volta") } |
| 78 | + } else { |
| 79 | + home.resolve(".volta") |
| 80 | + } |
| 81 | + voltaHome?.let { add("$it/tools/image/node/*/${bin}$exeName") } |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +/** |
| 86 | + * Resolves a Node.js executable across system PATH, well-known install locations, |
| 87 | + * and version managers (nvm, fnm, volta). GUI-launched IDEs don't inherit shell |
| 88 | + * PATH modifications, so we search common locations directly. |
| 89 | + */ |
14 | 90 | internal object NodeRuntimeResolver { |
15 | 91 | private val LOG = getLogger<NodeRuntimeResolver>() |
| 92 | + private val home: Path = Path.of(System.getProperty("user.home")) |
| 93 | + |
| 94 | + private val platform: Platform = when { |
| 95 | + SystemInfo.isMac -> Platform.MAC |
| 96 | + SystemInfo.isWindows -> Platform.WINDOWS |
| 97 | + else -> Platform.LINUX |
| 98 | + } |
16 | 99 |
|
17 | | - /** |
18 | | - * Locates a Node.js executable with version >= minVersion. |
19 | | - * Uses IntelliJ's PathEnvironmentVariableUtil to search PATH. |
20 | | - * |
21 | | - * @return Path to valid Node.js executable, or null if not found |
22 | | - */ |
23 | | - fun resolve(minVersion: Int = 18): Path? { |
24 | | - val exeName = if (SystemInfo.isWindows) "node.exe" else "node" |
| 100 | + private val exeName = EXE_NAME.getValue(platform) |
| 101 | + private val wellKnownPaths: List<Path> = buildWellKnownPaths(platform, home) |
| 102 | + private val globPatterns: List<String> by lazy { buildGlobPatterns(platform, home) { System.getenv(it) } } |
25 | 103 |
|
26 | | - return PathEnvironmentVariableUtil.findAllExeFilesInPath(exeName) |
| 104 | + fun resolve(minVersion: Int = 18): Path? = |
| 105 | + resolveFromPath(minVersion) ?: resolveFromWellKnownLocations(minVersion) |
| 106 | + |
| 107 | + private fun resolveFromPath(minVersion: Int): Path? = |
| 108 | + PathEnvironmentVariableUtil.findAllExeFilesInPath(exeName) |
27 | 109 | .asSequence() |
28 | 110 | .map { it.toPath() } |
29 | 111 | .filter { Files.isRegularFile(it) && Files.isExecutable(it) } |
30 | | - .firstNotNullOfOrNull { validateVersion(it, minVersion) } |
31 | | - } |
| 112 | + .firstNotNullOfOrNull { it.takeIfVersionAtLeast(minVersion) } |
32 | 113 |
|
33 | | - private fun validateVersion(path: Path, minVersion: Int): Path? = try { |
34 | | - val output = ExecUtil.execAndGetOutput( |
35 | | - com.intellij.execution.configurations.GeneralCommandLine(path.toString(), "--version"), |
36 | | - 5000 |
37 | | - ) |
38 | | - |
39 | | - if (output.exitCode == 0) { |
40 | | - val version = output.stdout.trim() |
41 | | - val majorVersion = version.removePrefix("v").split(".")[0].toIntOrNull() |
42 | | - |
43 | | - if (majorVersion != null && majorVersion >= minVersion) { |
44 | | - LOG.debug { "Node $version found at: $path" } |
45 | | - path.toAbsolutePath() |
46 | | - } else { |
47 | | - LOG.debug { "Node version < $minVersion at: $path (version: $version)" } |
48 | | - null |
| 114 | + private fun resolveFromWellKnownLocations(minVersion: Int): Path? { |
| 115 | + val fromFixed = wellKnownPaths.asSequence() |
| 116 | + .filter { Files.isRegularFile(it) && Files.isExecutable(it) } |
| 117 | + |
| 118 | + val fromGlobs = globPatterns.asSequence() |
| 119 | + .flatMap { expandGlob(it) } |
| 120 | + |
| 121 | + return (fromFixed + fromGlobs) |
| 122 | + .mapNotNull { path -> |
| 123 | + val version = path.nodeVersion() |
| 124 | + if (version != null && version >= minVersion) version to path.toAbsolutePath() else null |
49 | 125 | } |
| 126 | + .maxByOrNull { it.first } |
| 127 | + ?.second |
| 128 | + } |
| 129 | + |
| 130 | + private fun expandGlob(glob: String): Sequence<Path> { |
| 131 | + val parent = Path.of(glob.substringBefore("*")).parent ?: return emptySequence() |
| 132 | + if (!Files.isDirectory(parent)) return emptySequence() |
| 133 | + |
| 134 | + val matcher = FileSystems.getDefault().getPathMatcher("glob:$glob") |
| 135 | + val depth = glob.removePrefix(parent.toString()).count { it == '/' || it == '\\' } + 1 |
| 136 | + |
| 137 | + return Files.walk(parent, depth).use { stream -> |
| 138 | + stream |
| 139 | + .filter { matcher.matches(it) && Files.isRegularFile(it) && Files.isExecutable(it) } |
| 140 | + .toList() |
| 141 | + }.asSequence() |
| 142 | + } |
| 143 | + |
| 144 | + private fun Path.nodeVersion(): Int? = try { |
| 145 | + val output = ExecUtil.execAndGetOutput(GeneralCommandLine(toString(), "--version"), 5000) |
| 146 | + if (output.exitCode == 0) output.stdout.trim().removePrefix("v").split(".")[0].toIntOrNull() else null |
| 147 | + } catch (e: Exception) { |
| 148 | + LOG.debug(e) { "Failed to get version from node at: $this" } |
| 149 | + null |
| 150 | + } |
| 151 | + |
| 152 | + private fun Path.takeIfVersionAtLeast(minVersion: Int): Path? { |
| 153 | + val version = nodeVersion() ?: return null |
| 154 | + return if (version >= minVersion) { |
| 155 | + LOG.debug { "Node v$version found at: $this" } |
| 156 | + toAbsolutePath() |
50 | 157 | } else { |
51 | | - LOG.debug { "Failed to get version from node at: $path" } |
| 158 | + LOG.debug { "Node v$version < $minVersion at: $this" } |
52 | 159 | null |
53 | 160 | } |
54 | | - } catch (e: Exception) { |
55 | | - LOG.debug(e) { "Failed to check version for node at: $path" } |
56 | | - null |
57 | 161 | } |
58 | 162 | } |
0 commit comments