utils.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. // LSP Utilities - Essential formatters and helpers
  2. import { extname, resolve, dirname, join } from "path"
  3. import { fileURLToPath } from "node:url"
  4. import { existsSync, readFileSync, writeFileSync, unlinkSync, statSync } from "fs"
  5. import { lspManager } from "./client"
  6. import type { LSPClient } from "./client"
  7. import { findServerForExtension } from "./config"
  8. import { SEVERITY_MAP } from "./constants"
  9. import { applyTextEditsToFile } from "./text-editor"
  10. import type {
  11. Location,
  12. LocationLink,
  13. Diagnostic,
  14. WorkspaceEdit,
  15. TextEdit,
  16. ServerLookupResult,
  17. } from "./types"
  18. /**
  19. * Finds the workspace root for a given file by looking for common markers like .git or package.json.
  20. * @param filePath - The path to the file.
  21. * @returns The resolved workspace root directory.
  22. */
  23. export function findWorkspaceRoot(filePath: string): string {
  24. let dir = resolve(filePath)
  25. try {
  26. if (!statSync(dir).isDirectory()) {
  27. dir = dirname(dir)
  28. }
  29. } catch {
  30. dir = dirname(dir)
  31. }
  32. const markers = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod"]
  33. let prevDir = ""
  34. while (dir !== prevDir) {
  35. for (const marker of markers) {
  36. if (existsSync(join(dir, marker))) {
  37. return dir
  38. }
  39. }
  40. prevDir = dir
  41. dir = dirname(dir)
  42. }
  43. return dirname(resolve(filePath))
  44. }
  45. /**
  46. * Converts a file URI to a local filesystem path.
  47. * @param uri - The file URI (e.g., 'file:///path/to/file').
  48. * @returns The local filesystem path.
  49. */
  50. export function uriToPath(uri: string): string {
  51. return fileURLToPath(uri)
  52. }
  53. export function formatServerLookupError(result: Exclude<ServerLookupResult, { status: "found" }>): string {
  54. if (result.status === "not_installed") {
  55. return [
  56. `[lsp-utils] findServer: LSP server '${result.server.id}' is NOT INSTALLED.`,
  57. ``,
  58. `Command not found: ${result.server.command[0]}`,
  59. ``,
  60. `To install: ${result.installHint}`,
  61. ].join("\n")
  62. }
  63. return `[lsp-utils] findServer: No LSP server configured for extension: ${result.extension}`
  64. }
  65. /**
  66. * Executes a callback function with an LSP client for the given file.
  67. * Manages client acquisition and release automatically.
  68. * @param filePath - The path to the file to get a client for.
  69. * @param fn - The callback function to execute with the client.
  70. * @returns The result of the callback function.
  71. * @throws Error if no suitable LSP server is found or if the client times out.
  72. */
  73. export async function withLspClient<T>(filePath: string, fn: (client: LSPClient) => Promise<T>): Promise<T> {
  74. const absPath = resolve(filePath)
  75. const ext = extname(absPath)
  76. const result = findServerForExtension(ext)
  77. if (result.status !== "found") {
  78. throw new Error(formatServerLookupError(result))
  79. }
  80. const server = result.server
  81. const root = findWorkspaceRoot(absPath)
  82. const client = await lspManager.getClient(root, server)
  83. try {
  84. return await fn(client)
  85. } catch (e) {
  86. if (e instanceof Error && e.message.includes("timeout")) {
  87. const isInitializing = lspManager.isServerInitializing(root, server.id)
  88. if (isInitializing) {
  89. throw new Error(`[lsp-utils] withLspClient: LSP server is still initializing. Please retry in a few seconds.`)
  90. }
  91. }
  92. throw e
  93. } finally {
  94. lspManager.releaseClient(root, server.id)
  95. }
  96. }
  97. /**
  98. * Formats an LSP location or location link into a human-readable string (path:line:char).
  99. * @param loc - The LSP location or location link.
  100. * @returns A formatted string representation.
  101. */
  102. export function formatLocation(loc: Location | LocationLink): string {
  103. if ("targetUri" in loc) {
  104. const uri = uriToPath(loc.targetUri)
  105. const line = loc.targetRange.start.line + 1
  106. const char = loc.targetRange.start.character
  107. return `${uri}:${line}:${char}`
  108. }
  109. const uri = uriToPath(loc.uri)
  110. const line = loc.range.start.line + 1
  111. const char = loc.range.start.character
  112. return `${uri}:${line}:${char}`
  113. }
  114. export function formatSeverity(severity: number | undefined): string {
  115. if (!severity) return "unknown"
  116. return SEVERITY_MAP[severity] || `unknown(${severity})`
  117. }
  118. /**
  119. * Formats an LSP diagnostic into a human-readable string.
  120. * @param diag - The LSP diagnostic.
  121. * @returns A formatted string representation.
  122. */
  123. export function formatDiagnostic(diag: Diagnostic): string {
  124. const severity = formatSeverity(diag.severity)
  125. const line = diag.range.start.line + 1
  126. const char = diag.range.start.character
  127. const source = diag.source ? `[${diag.source}]` : ""
  128. const code = diag.code ? ` (${diag.code})` : ""
  129. return `${severity}${source}${code} at ${line}:${char}: ${diag.message}`
  130. }
  131. export function filterDiagnosticsBySeverity(
  132. diagnostics: Diagnostic[],
  133. severityFilter?: "error" | "warning" | "information" | "hint" | "all"
  134. ): Diagnostic[] {
  135. if (!severityFilter || severityFilter === "all") {
  136. return diagnostics
  137. }
  138. const severityMap: Record<string, number> = {
  139. error: 1,
  140. warning: 2,
  141. information: 3,
  142. hint: 4,
  143. }
  144. const targetSeverity = severityMap[severityFilter]
  145. return diagnostics.filter((d) => d.severity === targetSeverity)
  146. }
  147. // WorkspaceEdit application
  148. export interface ApplyResult {
  149. success: boolean
  150. filesModified: string[]
  151. totalEdits: number
  152. errors: string[]
  153. }
  154. /**
  155. * Applies an LSP workspace edit to the local filesystem.
  156. * Supports both 'changes' (TextEdits) and 'documentChanges' (Create/Rename/Delete/Edit).
  157. * @param edit - The workspace edit to apply.
  158. * @returns An object containing the success status, modified files, and any errors.
  159. */
  160. export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
  161. if (!edit) {
  162. return { success: false, filesModified: [], totalEdits: 0, errors: ["[lsp-utils] applyWorkspaceEdit: No edit provided"] }
  163. }
  164. const result: ApplyResult = { success: true, filesModified: [], totalEdits: 0, errors: [] }
  165. if (edit.changes) {
  166. for (const [uri, edits] of Object.entries(edit.changes)) {
  167. const filePath = uriToPath(uri)
  168. const applyResult = applyTextEditsToFile(filePath, edits)
  169. if (applyResult.success) {
  170. result.filesModified.push(filePath)
  171. result.totalEdits += applyResult.editCount
  172. } else {
  173. result.success = false
  174. result.errors.push(`[lsp-utils] applyWorkspaceEdit: ${filePath}: ${applyResult.error?.replace("[lsp-utils] applyTextEdits: ", "")}`)
  175. }
  176. }
  177. }
  178. if (edit.documentChanges) {
  179. for (const change of edit.documentChanges) {
  180. if ("kind" in change) {
  181. if (change.kind === "create") {
  182. try {
  183. const filePath = uriToPath(change.uri)
  184. writeFileSync(filePath, "", "utf-8")
  185. result.filesModified.push(filePath)
  186. } catch (err) {
  187. result.success = false
  188. const message = err instanceof Error ? err.message : String(err)
  189. result.errors.push(`[lsp-utils] applyWorkspaceEdit: Create ${change.uri}: ${message}`)
  190. }
  191. } else if (change.kind === "rename") {
  192. try {
  193. const oldPath = uriToPath(change.oldUri)
  194. const newPath = uriToPath(change.newUri)
  195. const content = readFileSync(oldPath, "utf-8")
  196. writeFileSync(newPath, content, "utf-8")
  197. unlinkSync(oldPath)
  198. result.filesModified.push(newPath)
  199. } catch (err) {
  200. result.success = false
  201. const message = err instanceof Error ? err.message : String(err)
  202. result.errors.push(`[lsp-utils] applyWorkspaceEdit: Rename ${change.oldUri}: ${message}`)
  203. }
  204. } else if (change.kind === "delete") {
  205. try {
  206. const filePath = uriToPath(change.uri)
  207. unlinkSync(filePath)
  208. result.filesModified.push(filePath)
  209. } catch (err) {
  210. result.success = false
  211. const message = err instanceof Error ? err.message : String(err)
  212. result.errors.push(`[lsp-utils] applyWorkspaceEdit: Delete ${change.uri}: ${message}`)
  213. }
  214. }
  215. } else {
  216. const filePath = uriToPath(change.textDocument.uri)
  217. const applyResult = applyTextEditsToFile(filePath, change.edits)
  218. if (applyResult.success) {
  219. result.filesModified.push(filePath)
  220. result.totalEdits += applyResult.editCount
  221. } else {
  222. result.success = false
  223. result.errors.push(`${filePath}: ${applyResult.error}`)
  224. }
  225. }
  226. }
  227. }
  228. return result
  229. }
  230. /**
  231. * Formats the result of a workspace edit application into a human-readable summary.
  232. * @param result - The apply result from applyWorkspaceEdit.
  233. * @returns A formatted summary string.
  234. */
  235. export function formatApplyResult(result: ApplyResult): string {
  236. const lines: string[] = []
  237. if (result.success) {
  238. lines.push(`Applied ${result.totalEdits} edit(s) to ${result.filesModified.length} file(s):`)
  239. for (const file of result.filesModified) {
  240. lines.push(` - ${file}`)
  241. }
  242. } else {
  243. lines.push("Failed to apply some changes:")
  244. for (const err of result.errors) {
  245. lines.push(` Error: ${err}`)
  246. }
  247. if (result.filesModified.length > 0) {
  248. lines.push(`Successfully modified: ${result.filesModified.join(", ")}`)
  249. }
  250. }
  251. return lines.join("\n")
  252. }