lsp.test.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. import { afterEach, describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import path from "path"
  4. import { Agent } from "../../src/agent/agent"
  5. import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
  6. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  7. import { LSP } from "../../src/lsp"
  8. import { Permission } from "../../src/permission"
  9. import { Instance } from "../../src/project/instance"
  10. import { MessageID, SessionID } from "../../src/session/schema"
  11. import { Tool, Truncate } from "../../src/tool"
  12. import { LspTool } from "../../src/tool/lsp"
  13. import { provideTmpdirInstance } from "../fixture/fixture"
  14. import { testEffect } from "../lib/effect"
  15. afterEach(async () => {
  16. await Instance.disposeAll()
  17. })
  18. const ctx = {
  19. sessionID: SessionID.make("ses_test"),
  20. messageID: MessageID.make(""),
  21. callID: "",
  22. agent: "build",
  23. abort: AbortSignal.any([]),
  24. messages: [],
  25. metadata: () => Effect.void,
  26. ask: () => Effect.void,
  27. }
  28. const lsp = Layer.succeed(
  29. LSP.Service,
  30. LSP.Service.of({
  31. init: () => Effect.void,
  32. status: () => Effect.succeed([]),
  33. hasClients: () => Effect.succeed(true),
  34. touchFile: () => Effect.void,
  35. diagnostics: () => Effect.succeed({}),
  36. hover: () => Effect.succeed([]),
  37. definition: () => Effect.succeed([]),
  38. references: () => Effect.succeed([]),
  39. implementation: () => Effect.succeed([]),
  40. documentSymbol: () => Effect.succeed([]),
  41. workspaceSymbol: () => Effect.succeed([]),
  42. prepareCallHierarchy: () => Effect.succeed([]),
  43. incomingCalls: () => Effect.succeed([]),
  44. outgoingCalls: () => Effect.succeed([]),
  45. }),
  46. )
  47. const it = testEffect(
  48. Layer.mergeAll(
  49. Agent.defaultLayer,
  50. AppFileSystem.defaultLayer,
  51. CrossSpawnSpawner.defaultLayer,
  52. Truncate.defaultLayer,
  53. lsp,
  54. ),
  55. )
  56. const init = Effect.fn("LspToolTest.init")(function* () {
  57. const info = yield* LspTool
  58. return yield* info.init()
  59. })
  60. const run = Effect.fn("LspToolTest.run")(function* (
  61. args: Tool.InferParameters<typeof LspTool>,
  62. next: Tool.Context = ctx,
  63. ) {
  64. const tool = yield* init()
  65. return yield* tool.execute(args, next)
  66. })
  67. const put = Effect.fn("LspToolTest.put")(function* (file: string) {
  68. const fs = yield* AppFileSystem.Service
  69. yield* fs.writeWithDirs(file, "export const x = 1\n")
  70. })
  71. const asks = () => {
  72. const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  73. return {
  74. items,
  75. next: {
  76. ...ctx,
  77. ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
  78. Effect.sync(() => {
  79. items.push(req)
  80. }),
  81. },
  82. }
  83. }
  84. describe("tool.lsp", () => {
  85. describe("permission metadata", () => {
  86. it.live("keeps cursor details for position-based operations", () =>
  87. provideTmpdirInstance(
  88. (dir) =>
  89. Effect.gen(function* () {
  90. const file = path.join(dir, "test.ts")
  91. yield* put(file)
  92. const { items, next } = asks()
  93. const result = yield* run({ operation: "goToDefinition", filePath: file, line: 3, character: 7 }, next)
  94. const req = items.find((item) => item.permission === "lsp")
  95. expect(req).toBeDefined()
  96. expect(req!.metadata).toEqual({
  97. operation: "goToDefinition",
  98. filePath: file,
  99. line: 3,
  100. character: 7,
  101. })
  102. expect(result.title).toBe("goToDefinition test.ts:3:7")
  103. }),
  104. { git: true },
  105. ),
  106. )
  107. it.live("omits cursor details for documentSymbol", () =>
  108. provideTmpdirInstance(
  109. (dir) =>
  110. Effect.gen(function* () {
  111. const file = path.join(dir, "test.ts")
  112. yield* put(file)
  113. const { items, next } = asks()
  114. const result = yield* run({ operation: "documentSymbol", filePath: file, line: 3, character: 7 }, next)
  115. const req = items.find((item) => item.permission === "lsp")
  116. expect(req).toBeDefined()
  117. expect(req!.metadata).toEqual({
  118. operation: "documentSymbol",
  119. filePath: file,
  120. })
  121. expect(result.title).toBe("documentSymbol test.ts")
  122. }),
  123. { git: true },
  124. ),
  125. )
  126. it.live("omits file and cursor details for workspaceSymbol", () =>
  127. provideTmpdirInstance(
  128. (dir) =>
  129. Effect.gen(function* () {
  130. const file = path.join(dir, "test.ts")
  131. yield* put(file)
  132. const { items, next } = asks()
  133. const result = yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7 }, next)
  134. const req = items.find((item) => item.permission === "lsp")
  135. expect(req).toBeDefined()
  136. expect(req!.metadata).toEqual({
  137. operation: "workspaceSymbol",
  138. })
  139. expect(result.title).toBe("workspaceSymbol")
  140. }),
  141. { git: true },
  142. ),
  143. )
  144. })
  145. })