Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/cli/doctor/checks/lsp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,17 @@ describe("lsp check", () => {
expect(def.critical).toBe(false)
})
})

describe("getLspServersInfo with tsgo", () => {
it("includes tsgo in server list", async () => {
// #given
// #when getting servers info
const servers = await lsp.getLspServersInfo()

// #then should include tsgo server
const tsgoServer = servers.find((s) => s.id === "tsgo")
expect(tsgoServer).toBeDefined()
expect(tsgoServer?.extensions).toContain(".ts")
})
})
})
1 change: 1 addition & 0 deletions src/cli/doctor/checks/lsp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const DEFAULT_LSP_SERVERS: Array<{
extensions: string[]
}> = [
{ id: "typescript-language-server", binary: "typescript-language-server", extensions: [".ts", ".tsx", ".js", ".jsx"] },
{ id: "tsgo", binary: "tsgo", extensions: [".ts", ".tsx", ".js", ".jsx"] },
{ id: "pyright", binary: "pyright-langserver", extensions: [".py"] },
{ id: "rust-analyzer", binary: "rust-analyzer", extensions: [".rs"] },
{ id: "gopls", binary: "gopls", extensions: [".go"] },
Expand Down
147 changes: 136 additions & 11 deletions src/tools/lsp/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { isServerInstalled } from "./config"
import { isServerInstalled, findServerForExtension } from "./config"
import { mkdtempSync, rmSync, writeFileSync } from "fs"
import { join } from "path"
import { tmpdir } from "os"
Expand Down Expand Up @@ -116,15 +116,140 @@ describe("isServerInstalled", () => {

expect(isServerInstalled([binName])).toBe(true)
})
} else {
test("Non-Windows: does not use windows extensions", () => {
const binName = "test-lsp-server-win"
const binPath = join(tempDir, binName + ".cmd")
writeFileSync(binPath, "echo hello")

process.env.PATH = tempDir

expect(isServerInstalled([binName])).toBe(false)
})
} else {
test("Non-Windows: does not use windows extensions", () => {
const binName = "test-lsp-server-win"
const binPath = join(tempDir, binName + ".cmd")
writeFileSync(binPath, "echo hello")

process.env.PATH = tempDir

expect(isServerInstalled([binName])).toBe(false)
})
}
})

describe("findServerForExtension with tsgo", () => {
let tempDir: string
let tempConfigDir: string
let tempDataDir: string
let savedEnv: {
PATH?: string
Path?: string
OPENCODE_CONFIG_DIR?: string
XDG_DATA_HOME?: string
}
let savedCwd: string

beforeEach(() => {
// #given isolated temp environment
tempDir = mkdtempSync(join(tmpdir(), "lsp-tsgo-test-"))
tempConfigDir = mkdtempSync(join(tmpdir(), "lsp-tsgo-config-"))
tempDataDir = mkdtempSync(join(tmpdir(), "lsp-tsgo-data-"))

savedEnv = {
PATH: process.env.PATH,
Path: process.env.Path,
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
XDG_DATA_HOME: process.env.XDG_DATA_HOME
}
savedCwd = process.cwd()

// Isolate from real configs and data dirs
process.env.OPENCODE_CONFIG_DIR = tempConfigDir
process.env.XDG_DATA_HOME = tempDataDir
process.chdir(tempDir)
})

afterEach(() => {
// Restore original environment
process.chdir(savedCwd)

process.env.PATH = savedEnv.PATH
if (process.platform === "win32") {
process.env.Path = savedEnv.Path
}

// Restore or delete env vars
for (const key of ["OPENCODE_CONFIG_DIR", "XDG_DATA_HOME"] as const) {
if (savedEnv[key] !== undefined) {
process.env[key] = savedEnv[key]
} else {
delete process.env[key]
}
}

try {
rmSync(tempDir, { recursive: true, force: true })
rmSync(tempConfigDir, { recursive: true, force: true })
rmSync(tempDataDir, { recursive: true, force: true })
} catch (e) {
console.error(`Cleanup failed: ${e}`)
}
})

// Helper to create fake binary with platform-specific extension
function createFakeBinary(name: string): void {
const ext = process.platform === "win32" ? ".cmd" : ""
const binPath = join(tempDir, name + ext)
writeFileSync(binPath, process.platform === "win32" ? "@echo off" : "#!/bin/sh\nexit 0")
}

// Helper to set PATH on both Windows and Unix deterministically
function setPath(pathValue: string): void {
process.env.PATH = pathValue
if (process.platform === "win32") {
process.env.Path = pathValue
}
}

test("selects tsgo over typescript when both are installed", () => {
// #given BOTH tsgo and typescript-language-server binaries exist in PATH
createFakeBinary("tsgo")
createFakeBinary("typescript-language-server")

const pathSep = process.platform === "win32" ? ";" : ":"
setPath(tempDir + pathSep + (savedEnv.PATH || ""))

// #when findServerForExtension is called for .ts
const result = findServerForExtension(".ts")

// #then tsgo should be selected (PREFER_WHEN_INSTALLED: users who install preview tools want them)
expect(result.status).toBe("found")
if (result.status === "found") {
expect(result.server.id).toBe("tsgo")
}
})

test("falls back to typescript when only typescript-language-server is installed", () => {
// #given ONLY typescript-language-server exists in PATH (no tsgo)
createFakeBinary("typescript-language-server")

const pathSep = process.platform === "win32" ? ";" : ":"
setPath(tempDir + pathSep + (savedEnv.PATH || ""))

// #when findServerForExtension is called for .ts
const result = findServerForExtension(".ts")

// #then typescript should be selected as fallback
expect(result.status).toBe("found")
if (result.status === "found") {
expect(result.server.id).toBe("typescript")
}
})

test("returns not_installed when neither tsgo nor typescript-language-server is installed", () => {
// #given NO TypeScript-related LSP servers in PATH
setPath(tempDir)

// #when findServerForExtension is called for .ts
const result = findServerForExtension(".ts")

// #then status should be not_installed with highest-priority server's details (typescript)
expect(result.status).toBe("not_installed")
if (result.status === "not_installed") {
expect(result.server.id).toBe("typescript")
expect(result.installHint).toContain("typescript-language-server")
}
})
})
4 changes: 3 additions & 1 deletion src/tools/lsp/server-definitions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { LSPServerConfig } from "./types"

export const LSP_INSTALL_HINTS: Record<string, string> = {
tsgo: "npm install -g @typescript/native-preview (Preview: rename/find-references not yet supported)",
typescript: "npm install -g typescript-language-server typescript",
deno: "Install Deno from https://deno.land",
vue: "npm install -g @vue/language-server",
Expand Down Expand Up @@ -46,7 +47,8 @@ export const LSP_INSTALL_HINTS: Record<string, string> = {
// Synced with OpenCode's server.ts
// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/server.ts
export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"] },
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], priority: -50 },
tsgo: { command: ["tsgo", "lsp", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], priority: -100 }, // Preview: deprioritized until rename/find-references are fully supported
deno: { command: ["deno", "lsp"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"] },
vue: { command: ["vue-language-server", "--stdio"], extensions: [".vue"] },
eslint: { command: ["vscode-eslint-language-server", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"] },
Expand Down
19 changes: 19 additions & 0 deletions src/tools/lsp/server-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,28 @@ import { getConfigPaths, getMergedServers, loadAllConfigs } from "./server-confi
import { isServerInstalled } from "./server-installation"
import type { ServerLookupResult } from "./types"

const PREFER_WHEN_INSTALLED = ["tsgo"]

export function findServerForExtension(ext: string): ServerLookupResult {
const servers = getMergedServers()

for (const id of PREFER_WHEN_INSTALLED) {
const server = servers.find((s) => s.id === id && s.extensions.includes(ext))
if (server && isServerInstalled(server.command)) {
return {
status: "found",
server: {
id: server.id,
command: server.command,
extensions: server.extensions,
priority: server.priority,
env: server.env,
initialization: server.initialization,
},
}
}
}

for (const server of servers) {
if (server.extensions.includes(ext) && isServerInstalled(server.command)) {
return {
Expand Down
1 change: 1 addition & 0 deletions src/tools/lsp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface LSPServerConfig {
command: string[]
extensions: string[]
disabled?: boolean
priority?: number
env?: Record<string, string>
initialization?: Record<string, unknown>
}
Expand Down