server.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. import { spawn, type ChildProcessWithoutNullStreams } from "child_process"
  2. import type { App } from "../app/app"
  3. import path from "path"
  4. import { Global } from "../global"
  5. import { Log } from "../util/log"
  6. import { BunProc } from "../bun"
  7. import { $ } from "bun"
  8. import fs from "fs/promises"
  9. import { Filesystem } from "../util/filesystem"
  10. export namespace LSPServer {
  11. const log = Log.create({ service: "lsp.server" })
  12. export interface Handle {
  13. process: ChildProcessWithoutNullStreams
  14. initialization?: Record<string, any>
  15. }
  16. type RootFunction = (file: string, app: App.Info) => Promise<string | undefined>
  17. const NearestRoot = (patterns: string[]): RootFunction => {
  18. return async (file, app) => {
  19. const files = Filesystem.up({
  20. targets: patterns,
  21. start: path.dirname(file),
  22. stop: app.path.root,
  23. })
  24. const first = await files.next()
  25. await files.return()
  26. if (!first.value) return app.path.root
  27. return path.dirname(first.value)
  28. }
  29. }
  30. export interface Info {
  31. id: string
  32. extensions: string[]
  33. global?: boolean
  34. root: RootFunction
  35. spawn(app: App.Info, root: string): Promise<Handle | undefined>
  36. }
  37. export const Typescript: Info = {
  38. id: "typescript",
  39. root: NearestRoot(["tsconfig.json", "package.json", "jsconfig.json"]),
  40. extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
  41. async spawn(app, root) {
  42. const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
  43. if (!tsserver) return
  44. const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
  45. cwd: root,
  46. env: {
  47. ...process.env,
  48. BUN_BE_BUN: "1",
  49. },
  50. })
  51. return {
  52. process: proc,
  53. initialization: {
  54. tsserver: {
  55. path: tsserver,
  56. },
  57. },
  58. }
  59. },
  60. }
  61. export const ESLint: Info = {
  62. id: "eslint",
  63. root: NearestRoot([
  64. "eslint.config.js",
  65. "eslint.config.mjs",
  66. "eslint.config.cjs",
  67. "eslint.config.ts",
  68. "eslint.config.mts",
  69. "eslint.config.cts",
  70. ".eslintrc.js",
  71. ".eslintrc.cjs",
  72. ".eslintrc.yaml",
  73. ".eslintrc.yml",
  74. ".eslintrc.json",
  75. "package.json",
  76. ]),
  77. extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
  78. async spawn(app, root) {
  79. const eslint = await Bun.resolve("eslint", app.path.cwd).catch(() => {})
  80. if (!eslint) return
  81. const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
  82. if (!(await Bun.file(serverPath).exists())) {
  83. log.info("downloading and building VS Code ESLint server")
  84. const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip")
  85. if (!response.ok) return
  86. const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip")
  87. await Bun.file(zipPath).write(response)
  88. await $`unzip -o -q ${zipPath}`.cwd(Global.Path.bin).nothrow()
  89. await fs.rm(zipPath, { force: true })
  90. const extractedPath = path.join(Global.Path.bin, "vscode-eslint-main")
  91. const finalPath = path.join(Global.Path.bin, "vscode-eslint")
  92. if (await Bun.file(finalPath).exists()) {
  93. await fs.rm(finalPath, { force: true, recursive: true })
  94. }
  95. await fs.rename(extractedPath, finalPath)
  96. await $`npm install`.cwd(finalPath).quiet()
  97. await $`npm run compile`.cwd(finalPath).quiet()
  98. log.info("installed VS Code ESLint server", { serverPath })
  99. }
  100. const proc = spawn(BunProc.which(), ["--max-old-space-size=8192", serverPath, "--stdio"], {
  101. cwd: root,
  102. env: {
  103. ...process.env,
  104. BUN_BE_BUN: "1",
  105. },
  106. })
  107. return {
  108. process: proc,
  109. }
  110. },
  111. }
  112. export const Gopls: Info = {
  113. id: "golang",
  114. root: async (file, app) => {
  115. const work = await NearestRoot(["go.work"])(file, app)
  116. if (work) return work
  117. return NearestRoot(["go.mod", "go.sum"])(file, app)
  118. },
  119. extensions: [".go"],
  120. async spawn(_, root) {
  121. let bin = Bun.which("gopls", {
  122. PATH: process.env["PATH"] + ":" + Global.Path.bin,
  123. })
  124. if (!bin) {
  125. if (!Bun.which("go")) return
  126. log.info("installing gopls")
  127. const proc = Bun.spawn({
  128. cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
  129. env: { ...process.env, GOBIN: Global.Path.bin },
  130. stdout: "pipe",
  131. stderr: "pipe",
  132. stdin: "pipe",
  133. })
  134. const exit = await proc.exited
  135. if (exit !== 0) {
  136. log.error("Failed to install gopls")
  137. return
  138. }
  139. bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : ""))
  140. log.info(`installed gopls`, {
  141. bin,
  142. })
  143. }
  144. return {
  145. process: spawn(bin!, {
  146. cwd: root,
  147. }),
  148. }
  149. },
  150. }
  151. export const RubyLsp: Info = {
  152. id: "ruby-lsp",
  153. root: NearestRoot(["Gemfile"]),
  154. extensions: [".rb", ".rake", ".gemspec", ".ru"],
  155. async spawn(_, root) {
  156. let bin = Bun.which("ruby-lsp", {
  157. PATH: process.env["PATH"] + ":" + Global.Path.bin,
  158. })
  159. if (!bin) {
  160. const ruby = Bun.which("ruby")
  161. const gem = Bun.which("gem")
  162. if (!ruby || !gem) {
  163. log.info("Ruby not found, please install Ruby first")
  164. return
  165. }
  166. log.info("installing ruby-lsp")
  167. const proc = Bun.spawn({
  168. cmd: ["gem", "install", "ruby-lsp", "--bindir", Global.Path.bin],
  169. stdout: "pipe",
  170. stderr: "pipe",
  171. stdin: "pipe",
  172. })
  173. const exit = await proc.exited
  174. if (exit !== 0) {
  175. log.error("Failed to install ruby-lsp")
  176. return
  177. }
  178. bin = path.join(Global.Path.bin, "ruby-lsp" + (process.platform === "win32" ? ".exe" : ""))
  179. log.info(`installed ruby-lsp`, {
  180. bin,
  181. })
  182. }
  183. return {
  184. process: spawn(bin!, ["--stdio"], {
  185. cwd: root,
  186. }),
  187. }
  188. },
  189. }
  190. export const Pyright: Info = {
  191. id: "pyright",
  192. extensions: [".py", ".pyi"],
  193. root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
  194. async spawn(_, root) {
  195. const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
  196. cwd: root,
  197. env: {
  198. ...process.env,
  199. BUN_BE_BUN: "1",
  200. },
  201. })
  202. return {
  203. process: proc,
  204. }
  205. },
  206. }
  207. export const ElixirLS: Info = {
  208. id: "elixir-ls",
  209. extensions: [".ex", ".exs"],
  210. root: NearestRoot(["mix.exs", "mix.lock"]),
  211. async spawn(_, root) {
  212. let binary = Bun.which("elixir-ls")
  213. if (!binary) {
  214. const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
  215. binary = path.join(
  216. Global.Path.bin,
  217. "elixir-ls-master",
  218. "release",
  219. process.platform === "win32" ? "language_server.bar" : "language_server.sh",
  220. )
  221. if (!(await Bun.file(binary).exists())) {
  222. const elixir = Bun.which("elixir")
  223. if (!elixir) {
  224. log.error("elixir is required to run elixir-ls")
  225. return
  226. }
  227. log.info("downloading elixir-ls from GitHub releases")
  228. const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
  229. if (!response.ok) return
  230. const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
  231. await Bun.file(zipPath).write(response)
  232. await $`unzip -o -q ${zipPath}`.cwd(Global.Path.bin).nothrow()
  233. await fs.rm(zipPath, {
  234. force: true,
  235. recursive: true,
  236. })
  237. await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release`
  238. .quiet()
  239. .cwd(path.join(Global.Path.bin, "elixir-ls-master"))
  240. .env({ MIX_ENV: "prod", ...process.env })
  241. log.info(`installed elixir-ls`, {
  242. path: elixirLsPath,
  243. })
  244. }
  245. }
  246. return {
  247. process: spawn(binary, {
  248. cwd: root,
  249. }),
  250. }
  251. },
  252. }
  253. export const Zls: Info = {
  254. id: "zls",
  255. extensions: [".zig", ".zon"],
  256. root: NearestRoot(["build.zig"]),
  257. async spawn(_, root) {
  258. let bin = Bun.which("zls", {
  259. PATH: process.env["PATH"] + ":" + Global.Path.bin,
  260. })
  261. if (!bin) {
  262. const zig = Bun.which("zig")
  263. if (!zig) {
  264. log.error("Zig is required to use zls. Please install Zig first.")
  265. return
  266. }
  267. log.info("downloading zls from GitHub releases")
  268. const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest")
  269. if (!releaseResponse.ok) {
  270. log.error("Failed to fetch zls release info")
  271. return
  272. }
  273. const release = await releaseResponse.json()
  274. const platform = process.platform
  275. const arch = process.arch
  276. let assetName = ""
  277. let zlsArch: string = arch
  278. if (arch === "arm64") zlsArch = "aarch64"
  279. else if (arch === "x64") zlsArch = "x86_64"
  280. else if (arch === "ia32") zlsArch = "x86"
  281. let zlsPlatform: string = platform
  282. if (platform === "darwin") zlsPlatform = "macos"
  283. else if (platform === "win32") zlsPlatform = "windows"
  284. const ext = platform === "win32" ? "zip" : "tar.xz"
  285. assetName = `zls-${zlsArch}-${zlsPlatform}.${ext}`
  286. const supportedCombos = [
  287. "zls-x86_64-linux.tar.xz",
  288. "zls-x86_64-macos.tar.xz",
  289. "zls-x86_64-windows.zip",
  290. "zls-aarch64-linux.tar.xz",
  291. "zls-aarch64-macos.tar.xz",
  292. "zls-aarch64-windows.zip",
  293. "zls-x86-linux.tar.xz",
  294. "zls-x86-windows.zip",
  295. ]
  296. if (!supportedCombos.includes(assetName)) {
  297. log.error(`Platform ${platform} and architecture ${arch} is not supported by zls`)
  298. return
  299. }
  300. const asset = release.assets.find((a: any) => a.name === assetName)
  301. if (!asset) {
  302. log.error(`Could not find asset ${assetName} in latest zls release`)
  303. return
  304. }
  305. const downloadUrl = asset.browser_download_url
  306. const downloadResponse = await fetch(downloadUrl)
  307. if (!downloadResponse.ok) {
  308. log.error("Failed to download zls")
  309. return
  310. }
  311. const tempPath = path.join(Global.Path.bin, assetName)
  312. await Bun.file(tempPath).write(downloadResponse)
  313. if (ext === "zip") {
  314. await $`unzip -o -q ${tempPath}`.cwd(Global.Path.bin).nothrow()
  315. } else {
  316. await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).nothrow()
  317. }
  318. await fs.rm(tempPath, { force: true })
  319. bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : ""))
  320. if (!(await Bun.file(bin).exists())) {
  321. log.error("Failed to extract zls binary")
  322. return
  323. }
  324. if (platform !== "win32") {
  325. await $`chmod +x ${bin}`.nothrow()
  326. }
  327. log.info(`installed zls`, { bin })
  328. }
  329. return {
  330. process: spawn(bin, {
  331. cwd: root,
  332. }),
  333. }
  334. },
  335. }
  336. export const CSharp: Info = {
  337. id: "csharp",
  338. root: NearestRoot([".sln", ".csproj", "global.json"]),
  339. extensions: [".cs"],
  340. async spawn(_, root) {
  341. let bin = Bun.which("csharp-ls", {
  342. PATH: process.env["PATH"] + ":" + Global.Path.bin,
  343. })
  344. if (!bin) {
  345. if (!Bun.which("dotnet")) {
  346. log.error(".NET SDK is required to install csharp-ls")
  347. return
  348. }
  349. log.info("installing csharp-ls via dotnet tool")
  350. const proc = Bun.spawn({
  351. cmd: ["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin],
  352. stdout: "pipe",
  353. stderr: "pipe",
  354. stdin: "pipe",
  355. })
  356. const exit = await proc.exited
  357. if (exit !== 0) {
  358. log.error("Failed to install csharp-ls")
  359. return
  360. }
  361. bin = path.join(Global.Path.bin, "csharp-ls" + (process.platform === "win32" ? ".exe" : ""))
  362. log.info(`installed csharp-ls`, { bin })
  363. }
  364. return {
  365. process: spawn(bin, {
  366. cwd: root,
  367. }),
  368. }
  369. },
  370. }
  371. export const Clangd: Info = {
  372. id: "clangd",
  373. root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd", "CMakeLists.txt", "Makefile"]),
  374. extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
  375. async spawn(_, root) {
  376. let bin = Bun.which("clangd", {
  377. PATH: process.env["PATH"] + ":" + Global.Path.bin,
  378. })
  379. if (!bin) {
  380. log.info("downloading clangd from GitHub releases")
  381. const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest")
  382. if (!releaseResponse.ok) {
  383. log.error("Failed to fetch clangd release info")
  384. return
  385. }
  386. const release = await releaseResponse.json()
  387. const platform = process.platform
  388. let assetName = ""
  389. if (platform === "darwin") {
  390. assetName = "clangd-mac-"
  391. } else if (platform === "linux") {
  392. assetName = "clangd-linux-"
  393. } else if (platform === "win32") {
  394. assetName = "clangd-windows-"
  395. } else {
  396. log.error(`Platform ${platform} is not supported by clangd auto-download`)
  397. return
  398. }
  399. assetName += release.tag_name + ".zip"
  400. const asset = release.assets.find((a: any) => a.name === assetName)
  401. if (!asset) {
  402. log.error(`Could not find asset ${assetName} in latest clangd release`)
  403. return
  404. }
  405. const downloadUrl = asset.browser_download_url
  406. const downloadResponse = await fetch(downloadUrl)
  407. if (!downloadResponse.ok) {
  408. log.error("Failed to download clangd")
  409. return
  410. }
  411. const zipPath = path.join(Global.Path.bin, "clangd.zip")
  412. await Bun.file(zipPath).write(downloadResponse)
  413. await $`unzip -o -q ${zipPath}`.cwd(Global.Path.bin).nothrow()
  414. await fs.rm(zipPath, { force: true })
  415. const extractedDir = path.join(Global.Path.bin, assetName.replace(".zip", ""))
  416. bin = path.join(extractedDir, "bin", "clangd" + (platform === "win32" ? ".exe" : ""))
  417. if (!(await Bun.file(bin).exists())) {
  418. log.error("Failed to extract clangd binary")
  419. return
  420. }
  421. if (platform !== "win32") {
  422. await $`chmod +x ${bin}`.nothrow()
  423. }
  424. log.info(`installed clangd`, { bin })
  425. }
  426. return {
  427. process: spawn(bin, ["--background-index", "--clang-tidy"], {
  428. cwd: root,
  429. }),
  430. }
  431. },
  432. }
  433. }