feat: 品牌替换 + 启动优化 + AGENTS.md 模板定制
- 品牌替换:OpenCode/opencode → AirCoding/aircoding(16+ 文件) - Logo ASCII art:修复 left/right 行数不匹配导致的启动崩溃 - 启动诊断:添加 OPENCODE_PRINT_TIMING 计时探针 - dev 模式默认 --pure 跳过外部插件加载 - AGENTS.md 模板:追加 AirCoding 多 Agent 专项段落 - architect prompt + plugin:强化 AGENTS.md 产出验证
This commit is contained in:
488
packages/opencode/test/lsp/client.test.ts
Normal file
488
packages/opencode/test/lsp/client.test.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { tmpdir, withTestInstance } from "../fixture/fixture"
|
||||
import { LSPClient } from "@/lsp/client"
|
||||
import * as LSPServer from "@/lsp/server"
|
||||
|
||||
function spawnFakeServer() {
|
||||
const { spawn } = require("child_process")
|
||||
const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
|
||||
return {
|
||||
process: spawn(process.execPath, [serverPath], {
|
||||
stdio: "pipe",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe("LSPClient interop", () => {
|
||||
test("handles workspace/workspaceFolders request", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
}),
|
||||
})
|
||||
|
||||
await client.connection.sendNotification("test/trigger", {
|
||||
method: "workspace/workspaceFolders",
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(client.connection).toBeDefined()
|
||||
await client.shutdown()
|
||||
})
|
||||
|
||||
test("handles client/registerCapability request", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
}),
|
||||
})
|
||||
|
||||
await client.connection.sendNotification("test/trigger", {
|
||||
method: "client/registerCapability",
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(client.connection).toBeDefined()
|
||||
await client.shutdown()
|
||||
})
|
||||
|
||||
test("handles client/unregisterCapability request", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
}),
|
||||
})
|
||||
|
||||
await client.connection.sendNotification("test/trigger", {
|
||||
method: "client/unregisterCapability",
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(client.connection).toBeDefined()
|
||||
await client.shutdown()
|
||||
})
|
||||
|
||||
test("initialize does not overclaim unsupported diagnostics capabilities", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
}),
|
||||
})
|
||||
|
||||
const params = await client.connection.sendRequest<any>("test/get-initialize-params", {})
|
||||
expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false)
|
||||
expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false)
|
||||
|
||||
await client.shutdown()
|
||||
})
|
||||
|
||||
test("workspace/configuration returns one result per requested item", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
const initialization = {
|
||||
alpha: {
|
||||
beta: 1,
|
||||
},
|
||||
gamma: true,
|
||||
}
|
||||
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: {
|
||||
...(handle as unknown as LSPServer.Handle),
|
||||
initialization,
|
||||
},
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await client.connection.sendRequest<any[]>("test/request-configuration", {
|
||||
items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}],
|
||||
})
|
||||
|
||||
expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
|
||||
|
||||
await client.shutdown()
|
||||
})
|
||||
|
||||
test("sends ranged didChange for incremental sync servers", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.ts")
|
||||
await Bun.write(file, "first\n")
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.notify.open({ path: file })
|
||||
await Bun.write(file, "second\nthird\n")
|
||||
await client.notify.open({ path: file })
|
||||
|
||||
const change = await client.connection.sendRequest<{
|
||||
textDocument: { version: number }
|
||||
contentChanges: {
|
||||
range?: { start: { line: number; character: number }; end: { line: number; character: number } }
|
||||
text: string
|
||||
}[]
|
||||
}>("test/get-last-change", {})
|
||||
expect(change.textDocument.version).toBe(1)
|
||||
expect(change.contentChanges).toEqual([
|
||||
{
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 1, character: 0 },
|
||||
},
|
||||
text: "second\nthird\n",
|
||||
},
|
||||
])
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("document mode falls back to push diagnostics", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.ts")
|
||||
await Bun.write(file, "const x = 1\n")
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
const version = await client.notify.open({ path: file })
|
||||
const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
|
||||
await client.connection.sendNotification("test/publish-diagnostics", {
|
||||
uri: pathToFileURL(file).href,
|
||||
version,
|
||||
diagnostics: [
|
||||
{
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 5 },
|
||||
},
|
||||
message: "push diagnostic",
|
||||
severity: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
await wait
|
||||
|
||||
const diagnostics = client.diagnostics.get(file) ?? []
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics[0]?.message).toBe("push diagnostic")
|
||||
|
||||
const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
|
||||
expect(count).toBe(0)
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("document mode accepts matching push diagnostics published before waiting", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.ts")
|
||||
await Bun.write(file, "const x = 1\n")
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
const version = await client.notify.open({ path: file })
|
||||
await client.connection.sendNotification("test/publish-diagnostics", {
|
||||
uri: pathToFileURL(file).href,
|
||||
version,
|
||||
diagnostics: [
|
||||
{
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 5 },
|
||||
},
|
||||
message: "push diagnostic",
|
||||
severity: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
|
||||
expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic")
|
||||
|
||||
const started = Date.now()
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "document" })
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("document mode waits for pull diagnostics", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.cs")
|
||||
await Bun.write(file, "class C {}\n")
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
registerOn: "didOpen",
|
||||
registrations: [{ identifier: "DocumentCompilerSemantic" }],
|
||||
documentDiagnosticsByIdentifier: {
|
||||
DocumentCompilerSemantic: [
|
||||
{
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 5 },
|
||||
},
|
||||
message: "pull diagnostic",
|
||||
severity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const version = await client.notify.open({ path: file })
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "document" })
|
||||
|
||||
const diagnostics = client.diagnostics.get(file) ?? []
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics[0]?.message).toBe("pull diagnostic")
|
||||
|
||||
const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
|
||||
expect(count).toBeGreaterThan(0)
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.cs")
|
||||
await Bun.write(file, "class C {}\n")
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
registrations: [{ identifier: "fast" }, { identifier: "slow" }],
|
||||
documentDiagnosticsByIdentifier: {
|
||||
fast: [
|
||||
{
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 5 },
|
||||
},
|
||||
message: "fast diagnostic",
|
||||
severity: 1,
|
||||
},
|
||||
],
|
||||
slow: [],
|
||||
},
|
||||
documentDelayMsByIdentifier: {
|
||||
slow: 2_500,
|
||||
},
|
||||
})
|
||||
|
||||
const version = await client.notify.open({ path: file })
|
||||
await client.connection.sendRequest("test/register-configured-pull-diagnostics", {})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
const started = Date.now()
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "document" })
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic")
|
||||
expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1)
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("full mode includes workspace pull diagnostics", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.cs")
|
||||
const related = path.join(tmp.path, "other.cs")
|
||||
await Bun.write(file, "class C {}\n")
|
||||
await Bun.write(related, "class D {}\n")
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
registerOn: "didOpen",
|
||||
registrations: [
|
||||
{ identifier: "DocumentCompilerSemantic" },
|
||||
{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true },
|
||||
],
|
||||
documentDiagnosticsByIdentifier: {
|
||||
DocumentCompilerSemantic: [
|
||||
{
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 5 },
|
||||
},
|
||||
message: "current file",
|
||||
severity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
workspaceDiagnosticsByIdentifier: {
|
||||
WorkspaceDocumentsAndProject: [
|
||||
{
|
||||
uri: pathToFileURL(related).href,
|
||||
items: [
|
||||
{
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 5 },
|
||||
},
|
||||
message: "workspace file",
|
||||
severity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const version = await client.notify.open({ path: file })
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "full" })
|
||||
|
||||
expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file")
|
||||
expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file")
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("full mode treats an empty workspace pull response as handled", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.cs")
|
||||
await Bun.write(file, "class C {}\n")
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
registerOn: "didOpen",
|
||||
registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }],
|
||||
workspaceDiagnosticsByIdentifier: {
|
||||
WorkspaceDocumentsAndProject: [],
|
||||
},
|
||||
})
|
||||
|
||||
const version = await client.notify.open({ path: file })
|
||||
const started = Date.now()
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "full" })
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
232
packages/opencode/test/lsp/index.test.ts
Normal file
232
packages/opencode/test/lsp/index.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, spyOn } from "bun:test"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Config } from "@/config/config"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import * as LSPServer from "@/lsp/server"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
|
||||
const lspLayer = (flags: Parameters<typeof RuntimeFlags.layer>[0] = {}) =>
|
||||
LSP.layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer(flags)),
|
||||
Layer.provideMerge(EventV2Bridge.defaultLayer),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(lspLayer(), CrossSpawnSpawner.defaultLayer))
|
||||
const experimentalTyIt = testEffect(
|
||||
Layer.mergeAll(lspLayer({ experimentalLspTy: true }), CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
const fakeServerPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
|
||||
const disabledDownloadIt = testEffect(
|
||||
Layer.mergeAll(lspLayer({ disableLspDownload: true }), CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
describe("lsp.spawn", () => {
|
||||
it.instance(
|
||||
"does not spawn builtin LSP for files outside instance",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.touchFile(path.join(dir, "..", "outside.ts"))
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "..", "hover.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(spy).toHaveBeenCalledTimes(0)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
)
|
||||
|
||||
it.instance("does not spawn builtin LSP for files inside instance when LSP is unset", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "src", "inside.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(spy).toHaveBeenCalledTimes(0)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"would spawn builtin LSP for files inside instance when lsp is true",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "src", "inside.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"publishes lsp.updated after custom LSP initialization",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const lsp = yield* LSP.Service
|
||||
const updated = yield* Deferred.make<void>()
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const unsubscribe = yield* events.listen((event) => {
|
||||
if (event.type === LSP.Event.Updated.type) Deferred.doneUnsafe(updated, Effect.void)
|
||||
return Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
const file = path.join(dir, "sample.repro")
|
||||
yield* Effect.promise(() => Bun.write(file, "sample\n"))
|
||||
yield* lsp.touchFile(file)
|
||||
yield* awaitWithTimeout(Deferred.await(updated), "lsp.updated event was not published")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
lsp: {
|
||||
fake: {
|
||||
command: [process.execPath, fakeServerPath],
|
||||
extensions: [".repro"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"would spawn builtin LSP for files inside instance when config object is provided",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "src", "inside.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
lsp: {
|
||||
eslint: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"uses pyright instead of ty by default",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const ty = spyOn(LSPServer.Ty, "spawn").mockResolvedValue(undefined)
|
||||
const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "src", "inside.py"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(ty).toHaveBeenCalledTimes(0)
|
||||
expect(pyright).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
ty.mockRestore()
|
||||
pyright.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
)
|
||||
|
||||
experimentalTyIt.instance(
|
||||
"uses ty instead of pyright when experimentalLspTy is enabled",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const ty = spyOn(LSPServer.Ty, "spawn").mockResolvedValue(undefined)
|
||||
const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "src", "inside.py"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(ty).toHaveBeenCalledTimes(1)
|
||||
expect(pyright).toHaveBeenCalledTimes(0)
|
||||
} finally {
|
||||
ty.mockRestore()
|
||||
pyright.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
)
|
||||
|
||||
disabledDownloadIt.instance(
|
||||
"passes disableLspDownload to builtin LSP spawn",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "src", "inside.py"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(pyright).toHaveBeenCalledTimes(1)
|
||||
expect(pyright.mock.calls[0]?.[2]).toMatchObject({ disableLspDownload: true })
|
||||
} finally {
|
||||
pyright.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
)
|
||||
})
|
||||
459
packages/opencode/test/lsp/jdtls-root.test.ts
Normal file
459
packages/opencode/test/lsp/jdtls-root.test.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { describe, test, expect, afterAll } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import * as LSPServer from "@/lsp/server"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tmpBase = path.join(os.tmpdir(), "opencode-jdtls-test")
|
||||
|
||||
function makeCtx(directory: string): InstanceContext {
|
||||
return { directory, worktree: "/", project: {} as any }
|
||||
}
|
||||
|
||||
async function mkdirp(p: string) {
|
||||
await fs.mkdir(p, { recursive: true })
|
||||
}
|
||||
|
||||
async function touch(p: string) {
|
||||
await mkdirp(path.dirname(p))
|
||||
await fs.writeFile(p, "", "utf-8")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(tmpBase, { recursive: true, force: true }).catch(() => {})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("JDTLS.root", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// Maven
|
||||
// -------------------------------------------------------------------------
|
||||
describe("Maven", () => {
|
||||
test("single-module Maven project returns pom.xml directory", async () => {
|
||||
const root = path.join(tmpBase, "single-maven")
|
||||
await mkdirp(root)
|
||||
await touch(path.join(root, "pom.xml"))
|
||||
const srcDir = path.join(root, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(root))
|
||||
expect(result).toBe(root)
|
||||
})
|
||||
|
||||
test("multi-module Maven project follows <module> chain to top-level pom.xml", async () => {
|
||||
const root = path.join(tmpBase, "multi-maven")
|
||||
await mkdirp(root)
|
||||
// Parent pom with <module>module-a</module>
|
||||
await Bun.write(path.join(root, "pom.xml"), "<project><modules><module>module-a</module></modules></project>")
|
||||
// Child module with its own pom.xml
|
||||
const childDir = path.join(root, "module-a")
|
||||
await mkdirp(childDir)
|
||||
await touch(path.join(childDir, "pom.xml"))
|
||||
const srcDir = path.join(childDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(root))
|
||||
// Parent declares module-a as module → root is parent directory
|
||||
expect(result).toBe(root)
|
||||
})
|
||||
|
||||
test("Maven project inside a nested directory (ctx.directory is workspace root)", async () => {
|
||||
// Workspace root = ctx.directory, Maven project in a subdirectory
|
||||
const workspace = path.join(tmpBase, "maven-workspace")
|
||||
await mkdirp(workspace)
|
||||
const projectDir = path.join(workspace, "my-maven-app")
|
||||
await touch(path.join(projectDir, "pom.xml"))
|
||||
const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
|
||||
// findUp finds projectDir's pom.xml (only one), returns projectDir
|
||||
expect(result).toBe(projectDir)
|
||||
})
|
||||
|
||||
test("nested independent Maven project stops at its own pom.xml", async () => {
|
||||
const workspace = path.join(tmpBase, "nested-independent")
|
||||
await mkdirp(workspace)
|
||||
// Parent pom WITHOUT <module>tools/sample</module>
|
||||
await Bun.write(
|
||||
path.join(workspace, "pom.xml"),
|
||||
"<project><modules><module>module-a</module></modules></project>",
|
||||
)
|
||||
// Independent project nested inside
|
||||
const projectDir = path.join(workspace, "tools", "sample")
|
||||
await mkdirp(projectDir)
|
||||
await touch(path.join(projectDir, "pom.xml"))
|
||||
const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
|
||||
// workspace/pom.xml does NOT declare tools/sample as module → stop at tools/sample
|
||||
expect(result).toBe(projectDir)
|
||||
})
|
||||
|
||||
test("three-level Maven module chain resolves to top-level", async () => {
|
||||
const root = path.join(tmpBase, "three-level")
|
||||
await mkdirp(root)
|
||||
await Bun.write(path.join(root, "pom.xml"), "<project><modules><module>apps</module></modules></project>")
|
||||
const appsDir = path.join(root, "apps")
|
||||
await mkdirp(appsDir)
|
||||
await Bun.write(path.join(appsDir, "pom.xml"), "<project><modules><module>my-app</module></modules></project>")
|
||||
const appDir = path.join(appsDir, "my-app")
|
||||
await mkdirp(appDir)
|
||||
await touch(path.join(appDir, "pom.xml"))
|
||||
const srcDir = path.join(appDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(root))
|
||||
expect(result).toBe(root)
|
||||
})
|
||||
|
||||
test("three-level Maven chain stops when <module> link is broken", async () => {
|
||||
const root = path.join(tmpBase, "broken-chain")
|
||||
await mkdirp(root)
|
||||
await Bun.write(path.join(root, "pom.xml"), "<project><modules><module>apps</module></modules></project>")
|
||||
const appsDir = path.join(root, "apps")
|
||||
await mkdirp(appsDir)
|
||||
await touch(path.join(appsDir, "pom.xml")) // Empty pom, no <module> declaration
|
||||
const appDir = path.join(appsDir, "my-app")
|
||||
await mkdirp(appDir)
|
||||
await touch(path.join(appDir, "pom.xml"))
|
||||
const srcDir = path.join(appDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(root))
|
||||
// apps/pom.xml has no <module>my-app</module> → stop at my-app
|
||||
expect(result).toBe(appDir)
|
||||
})
|
||||
|
||||
test("<module> with ./ prefix is normalized correctly", async () => {
|
||||
const root = path.join(tmpBase, "dot-slash-module")
|
||||
await mkdirp(root)
|
||||
await Bun.write(path.join(root, "pom.xml"), "<project><modules><module>./module-a</module></modules></project>")
|
||||
const childDir = path.join(root, "module-a")
|
||||
await mkdirp(childDir)
|
||||
await touch(path.join(childDir, "pom.xml"))
|
||||
const srcDir = path.join(childDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(root))
|
||||
expect(result).toBe(root)
|
||||
})
|
||||
|
||||
test("<module> with trailing slash is normalized correctly", async () => {
|
||||
const root = path.join(tmpBase, "trailing-slash-module")
|
||||
await mkdirp(root)
|
||||
await Bun.write(path.join(root, "pom.xml"), "<project><modules><module>module-a/</module></modules></project>")
|
||||
const childDir = path.join(root, "module-a")
|
||||
await mkdirp(childDir)
|
||||
await touch(path.join(childDir, "pom.xml"))
|
||||
const srcDir = path.join(childDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(root))
|
||||
expect(result).toBe(root)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Gradle
|
||||
// -------------------------------------------------------------------------
|
||||
describe("Gradle", () => {
|
||||
test("Gradle project with settings.gradle in a subdirectory of ctx.directory", async () => {
|
||||
// Workspace root = ctx.directory, Gradle project in a subdirectory
|
||||
const workspace = path.join(tmpBase, "gradle-sub")
|
||||
await mkdirp(workspace)
|
||||
const projectDir = path.join(workspace, "gradle-app")
|
||||
await touch(path.join(projectDir, "settings.gradle"))
|
||||
await touch(path.join(projectDir, "build.gradle"))
|
||||
const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
|
||||
expect(result).toBe(projectDir)
|
||||
})
|
||||
|
||||
test("Gradle project with only build.gradle in a subdirectory", async () => {
|
||||
const workspace = path.join(tmpBase, "gradle-build-sub")
|
||||
await mkdirp(workspace)
|
||||
const projectDir = path.join(workspace, "gradle-app")
|
||||
await touch(path.join(projectDir, "build.gradle"))
|
||||
const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
|
||||
expect(result).toBe(projectDir)
|
||||
})
|
||||
|
||||
test("Gradle monorepo with settings.gradle takes precedence over nested pom.xml", async () => {
|
||||
const workspace = path.join(tmpBase, "gradle-monorepo")
|
||||
await mkdirp(workspace)
|
||||
const gradleRoot = path.join(workspace, "gradle-project")
|
||||
await touch(path.join(gradleRoot, "settings.gradle"))
|
||||
await touch(path.join(gradleRoot, "gradlew"))
|
||||
// Submodule has pom.xml too
|
||||
const subDir = path.join(gradleRoot, "module-a")
|
||||
await mkdirp(subDir)
|
||||
await touch(path.join(subDir, "pom.xml"))
|
||||
const srcDir = path.join(subDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
|
||||
// Gradle markers found at gradleRoot level
|
||||
expect(result).toBe(gradleRoot)
|
||||
})
|
||||
|
||||
test("settings.gradle.kts (Kotlin DSL) is recognized", async () => {
|
||||
const workspace = path.join(tmpBase, "gradle-kts-settings")
|
||||
await mkdirp(workspace)
|
||||
const projectDir = path.join(workspace, "gradle-app")
|
||||
await touch(path.join(projectDir, "settings.gradle.kts"))
|
||||
const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
|
||||
expect(result).toBe(projectDir)
|
||||
})
|
||||
|
||||
test("build.gradle.kts (Kotlin DSL) is recognized", async () => {
|
||||
const workspace = path.join(tmpBase, "gradle-kts-build")
|
||||
await mkdirp(workspace)
|
||||
const projectDir = path.join(workspace, "gradle-app")
|
||||
await touch(path.join(projectDir, "build.gradle.kts"))
|
||||
const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
|
||||
expect(result).toBe(projectDir)
|
||||
})
|
||||
|
||||
test("gradlew (without settings.gradle) in a subdirectory is recognized", async () => {
|
||||
const workspace = path.join(tmpBase, "gradlew-sub")
|
||||
await mkdirp(workspace)
|
||||
const projectDir = path.join(workspace, "gradle-app")
|
||||
await touch(path.join(projectDir, "gradlew"))
|
||||
const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
|
||||
expect(result).toBe(projectDir)
|
||||
})
|
||||
|
||||
test("pom.xml is excluded when gradlew is present at same level", async () => {
|
||||
const workspace = path.join(tmpBase, "gradle-excludes-maven")
|
||||
await mkdirp(workspace)
|
||||
const projectDir = path.join(workspace, "mixed-project")
|
||||
// Both pom.xml and gradlew exist
|
||||
await touch(path.join(projectDir, "pom.xml"))
|
||||
await touch(path.join(projectDir, "gradlew"))
|
||||
const srcDir = path.join(projectDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
|
||||
// Gradle wrapper takes precedence
|
||||
expect(result).toBe(projectDir)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Eclipse
|
||||
// -------------------------------------------------------------------------
|
||||
describe("Eclipse", () => {
|
||||
test("Eclipse project with .project in a subdirectory", async () => {
|
||||
const workspace = path.join(tmpBase, "eclipse-sub")
|
||||
await mkdirp(workspace)
|
||||
const projectDir = path.join(workspace, "eclipse-app")
|
||||
await touch(path.join(projectDir, ".project"))
|
||||
await touch(path.join(projectDir, ".classpath"))
|
||||
const srcDir = path.join(projectDir, "src", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(workspace))
|
||||
expect(result).toBe(projectDir)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// No markers
|
||||
// -------------------------------------------------------------------------
|
||||
describe("No build markers", () => {
|
||||
test("Java file with no build markers returns undefined", async () => {
|
||||
const root = path.join(tmpBase, "no-build")
|
||||
await mkdirp(root)
|
||||
const srcDir = path.join(root, "src")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(root))
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Additional validation scenarios
|
||||
// -------------------------------------------------------------------------
|
||||
describe("Additional Maven module-chain validation", () => {
|
||||
// Scenario 1: <module> with multi-segment path (e.g. <module>tools/sample</module>)
|
||||
test("<module> multi-segment path matches nested directory", async () => {
|
||||
const root = path.join(tmpBase, "multi-seg-module")
|
||||
await mkdirp(root)
|
||||
await Bun.write(path.join(root, "pom.xml"), "<project><modules><module>tools/sample</module></modules></project>")
|
||||
const childDir = path.join(root, "tools", "sample")
|
||||
await mkdirp(childDir)
|
||||
await touch(path.join(childDir, "pom.xml"))
|
||||
const srcDir = path.join(childDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(root))
|
||||
expect(result).toBe(root)
|
||||
})
|
||||
|
||||
// Scenario 2: <module> declaration does not match actual directory name
|
||||
test("<module> declaration mismatch does not falsely match", async () => {
|
||||
const root = path.join(tmpBase, "module-mismatch")
|
||||
await mkdirp(root)
|
||||
// Parent declares module-a, but actual directory is module-b
|
||||
await Bun.write(path.join(root, "pom.xml"), "<project><modules><module>module-a</module></modules></project>")
|
||||
const childDir = path.join(root, "module-b")
|
||||
await mkdirp(childDir)
|
||||
await touch(path.join(childDir, "pom.xml"))
|
||||
const srcDir = path.join(childDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(root))
|
||||
// module-b is not declared as a module → stop at module-b
|
||||
expect(result).toBe(childDir)
|
||||
})
|
||||
|
||||
// Scenario 3: Multiple <module> declarations
|
||||
test("multiple <module> declarations allow second module to traverse up", async () => {
|
||||
const root = path.join(tmpBase, "multi-modules")
|
||||
await mkdirp(root)
|
||||
await Bun.write(
|
||||
path.join(root, "pom.xml"),
|
||||
"<project><modules><module>module-a</module><module>module-b</module></modules></project>",
|
||||
)
|
||||
const childA = path.join(root, "module-a")
|
||||
await mkdirp(childA)
|
||||
await touch(path.join(childA, "pom.xml"))
|
||||
const childB = path.join(root, "module-b")
|
||||
await mkdirp(childB)
|
||||
await touch(path.join(childB, "pom.xml"))
|
||||
const srcDir = path.join(childB, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(root))
|
||||
expect(result).toBe(root)
|
||||
})
|
||||
|
||||
// Scenario 4: XML comments should not be matched as module declarations
|
||||
test("XML-commented <module> is not matched", async () => {
|
||||
const root = path.join(tmpBase, "commented-module")
|
||||
await mkdirp(root)
|
||||
await Bun.write(
|
||||
path.join(root, "pom.xml"),
|
||||
"<project><modules><!-- <module>module-a</module> --></modules></project>",
|
||||
)
|
||||
const childDir = path.join(root, "module-a")
|
||||
await mkdirp(childDir)
|
||||
await touch(path.join(childDir, "pom.xml"))
|
||||
const srcDir = path.join(childDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(root))
|
||||
// Commented-out module should not be matched → stop at module-a
|
||||
expect(result).toBe(childDir)
|
||||
})
|
||||
|
||||
// Scenario 5: pom.xml at ctx.directory itself
|
||||
test("pom.xml at ctx.directory itself is found correctly", async () => {
|
||||
const root = path.join(tmpBase, "pom-at-ctx")
|
||||
await mkdirp(root)
|
||||
await touch(path.join(root, "pom.xml"))
|
||||
const srcDir = path.join(root, "src", "main", "java", "com", "example")
|
||||
await mkdirp(srcDir)
|
||||
await touch(path.join(srcDir, "App.java"))
|
||||
|
||||
const file = path.join(srcDir, "App.java")
|
||||
const result = await LSPServer.JDTLS.root(file, makeCtx(root))
|
||||
expect(result).toBe(root)
|
||||
})
|
||||
|
||||
// Scenario 6: Mixed Gradle + Maven sibling projects don't interfere
|
||||
test("Maven and Gradle sibling projects don't interfere", async () => {
|
||||
const workspace = path.join(tmpBase, "mixed-siblings")
|
||||
await mkdirp(workspace)
|
||||
// Gradle project
|
||||
const gradleDir = path.join(workspace, "gradle-project")
|
||||
await touch(path.join(gradleDir, "settings.gradle"))
|
||||
const gradleSrc = path.join(gradleDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(gradleSrc)
|
||||
await touch(path.join(gradleSrc, "GradleApp.java"))
|
||||
// Maven project
|
||||
const mavenDir = path.join(workspace, "maven-project")
|
||||
await touch(path.join(mavenDir, "pom.xml"))
|
||||
const mavenSrc = path.join(mavenDir, "src", "main", "java", "com", "example")
|
||||
await mkdirp(mavenSrc)
|
||||
await touch(path.join(mavenSrc, "MavenApp.java"))
|
||||
|
||||
const gradleResult = await LSPServer.JDTLS.root(path.join(gradleSrc, "GradleApp.java"), makeCtx(workspace))
|
||||
expect(gradleResult).toBe(gradleDir)
|
||||
|
||||
const mavenResult = await LSPServer.JDTLS.root(path.join(mavenSrc, "MavenApp.java"), makeCtx(workspace))
|
||||
expect(mavenResult).toBe(mavenDir)
|
||||
})
|
||||
})
|
||||
})
|
||||
22
packages/opencode/test/lsp/launch.test.ts
Normal file
22
packages/opencode/test/lsp/launch.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { spawn } from "../../src/lsp/launch"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("lsp.launch", () => {
|
||||
test("spawns cmd scripts with spaces on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
await using tmp = await tmpdir()
|
||||
const dir = path.join(tmp.path, "with space")
|
||||
const file = path.join(dir, "echo cmd.cmd")
|
||||
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n")
|
||||
|
||||
const proc = spawn(file, ["--stdio"])
|
||||
|
||||
expect(await proc.exited).toBe(0)
|
||||
})
|
||||
})
|
||||
160
packages/opencode/test/lsp/lifecycle.test.ts
Normal file
160
packages/opencode/test/lsp/lifecycle.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import * as LSPServer from "@/lsp/server"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
describe("LSP service lifecycle", () => {
|
||||
let spawnSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
spawnSpy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
spawnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it.instance("init() completes without error", () => LSP.Service.use((lsp) => lsp.init()))
|
||||
|
||||
it.instance("status() returns empty array initially", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.status()
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.length).toBe(0)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("diagnostics() returns empty object initially", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.diagnostics()
|
||||
expect(typeof result).toBe("object")
|
||||
expect(Object.keys(result).length).toBe(0)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("hasClients() returns false for .ts files in instance when LSP is unset", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts"))
|
||||
expect(result).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"hasClients() returns true for .ts files in instance when lsp is true",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts"))
|
||||
expect(result).toBe(true)
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"hasClients() keeps built-in LSPs when config object is provided",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts"))
|
||||
expect(result).toBe(true)
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: { eslint: { disabled: true } } } },
|
||||
)
|
||||
|
||||
it.instance("hasClients() returns false for files outside instance", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "..", "outside.ts"))
|
||||
expect(typeof result).toBe("boolean")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("workspaceSymbol() returns empty array with no clients", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.workspaceSymbol("test")
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.length).toBe(0)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("definition() returns empty array for unknown file", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.definition({
|
||||
file: path.join((yield* TestInstance).directory, "nonexistent.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("references() returns empty array for unknown file", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.references({
|
||||
file: path.join((yield* TestInstance).directory, "nonexistent.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("multiple init() calls are idempotent", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.init()
|
||||
yield* lsp.init()
|
||||
yield* lsp.init()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
describe("LSP.Diagnostic", () => {
|
||||
test("pretty() formats error diagnostic", () => {
|
||||
const result = LSP.Diagnostic.pretty({
|
||||
range: { start: { line: 9, character: 4 }, end: { line: 9, character: 10 } },
|
||||
message: "Type 'string' is not assignable to type 'number'",
|
||||
severity: 1,
|
||||
} as any)
|
||||
expect(result).toBe("ERROR [10:5] Type 'string' is not assignable to type 'number'")
|
||||
})
|
||||
|
||||
test("pretty() formats warning diagnostic", () => {
|
||||
const result = LSP.Diagnostic.pretty({
|
||||
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
|
||||
message: "Unused variable",
|
||||
severity: 2,
|
||||
} as any)
|
||||
expect(result).toBe("WARN [1:1] Unused variable")
|
||||
})
|
||||
|
||||
test("pretty() defaults to ERROR when no severity", () => {
|
||||
const result = LSP.Diagnostic.pretty({
|
||||
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
|
||||
message: "Something wrong",
|
||||
} as any)
|
||||
expect(result).toBe("ERROR [1:1] Something wrong")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user