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
7 changes: 5 additions & 2 deletions packages/opencode/src/lsp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { pathToFileURL, fileURLToPath } from "url"
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
import { Log } from "../util/log"
import { LANGUAGE_EXTENSIONS } from "./language"
import { LANGUAGE_EXTENSIONS, getLanguageFromShebang } from "./language"
import z from "zod"
import type { LSPServer } from "./server"
import { NamedError } from "@opencode-ai/util/error"
Expand Down Expand Up @@ -149,7 +149,10 @@ export namespace LSPClient {
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
const text = await Filesystem.readText(input.path)
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
let languageId = LANGUAGE_EXTENSIONS[extension]
if (!languageId || languageId === "plaintext") {
languageId = (await getLanguageFromShebang(input.path)) ?? "plaintext"
}

const version = files[input.path]
if (version !== undefined) {
Expand Down
33 changes: 29 additions & 4 deletions packages/opencode/src/lsp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Config } from "../config/config"
import { spawn } from "child_process"
import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { getLanguageFromShebang } from "./language"

export namespace LSP {
const log = Log.create({ service: "lsp" })
Expand Down Expand Up @@ -176,7 +177,7 @@ export namespace LSP {

async function getClients(file: string) {
const s = await state()
const extension = path.parse(file).ext || file
const extension = path.parse(file).ext
const result: LSPClient.Info[] = []

async function schedule(server: LSPServer.Info, root: string, key: string) {
Expand Down Expand Up @@ -221,8 +222,19 @@ export namespace LSP {
return client
}

let shebangLanguage: string | undefined
const extensionMatches = Object.values(s.servers).some(
(server) => server.extensions.length && server.extensions.includes(extension),
)
if (!extensionMatches) {
shebangLanguage = await getLanguageFromShebang(file)
}

for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const extensionMatch = !server.extensions.length || server.extensions.includes(extension)
const shebangMatch = shebangLanguage && server.shebangs?.some((regex) => regex.test(shebangLanguage!))

if (!extensionMatch && !shebangMatch) continue

const root = await server.root(file)
if (!root) continue
Expand Down Expand Up @@ -263,9 +275,22 @@ export namespace LSP {

export async function hasClients(file: string) {
const s = await state()
const extension = path.parse(file).ext || file
const extension = path.parse(file).ext

let shebangLanguage: string | undefined
const extensionMatches = Object.values(s.servers).some(
(server) => server.extensions.length && server.extensions.includes(extension),
)
if (!extensionMatches) {
shebangLanguage = await getLanguageFromShebang(file)
}

for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const extensionMatch = !server.extensions.length || server.extensions.includes(extension)
const shebangMatch = shebangLanguage && server.shebangs?.some((regex) => regex.test(shebangLanguage!))

if (!extensionMatch && !shebangMatch) continue

const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
Expand Down
61 changes: 61 additions & 0 deletions packages/opencode/src/lsp/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,64 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".typ": "typst",
".typc": "typst",
} as const

export const SHEBANG_PATTERNS: Array<{ pattern: RegExp; language: string }> = [
{ pattern: /\buv\s+run\b/, language: "python" },
{ pattern: /\bpython[23]?\b/, language: "python" },
{ pattern: /\bts-node\b/, language: "typescript" },
{ pattern: /\btsx\b/, language: "typescriptreact" },
{ pattern: /\bdeno\b/, language: "typescript" },
{ pattern: /\bnode\b/, language: "javascript" },
{ pattern: /\bbun\b/, language: "javascript" },
{ pattern: /\bnpx\b/, language: "javascript" },
{ pattern: /\byarn\b/, language: "javascript" },
{ pattern: /\bpnpm\b/, language: "javascript" },
{ pattern: /\bbash\b/, language: "shellscript" },
{ pattern: /\bzsh\b/, language: "shellscript" },
{ pattern: /\bdash\b/, language: "shellscript" },
{ pattern: /\bfish\b/, language: "shellscript" },
{ pattern: /(?<![-\w])sh(?![-\w])/, language: "shellscript" },
{ pattern: /\bruby\b/, language: "ruby" },
{ pattern: /\bperl[56]?\b/, language: "perl" },
{ pattern: /\bphp\b/, language: "php" },
{ pattern: /\blua\b/, language: "lua" },
{ pattern: /\bRscript\b/, language: "r" },
{ pattern: /\bjulia\b/, language: "julia" },
{ pattern: /\belixir\b/, language: "elixir" },
{ pattern: /\biex\b/, language: "elixir" },
]

export async function getLanguageFromShebang(filePath: string): Promise<string | undefined> {
const file = Bun.file(filePath)
if (!(await file.exists())) return undefined

const stream = file.stream()
const reader = stream.getReader()

try {
const { value, done } = await reader.read()
if (done || !value) return undefined

const decoder = new TextDecoder()
let text = decoder.decode(value, { stream: true })

const newlineIndex = text.indexOf("\n")
if (newlineIndex !== -1) {
text = text.slice(0, newlineIndex)
}

const line = text.trim()
if (!line.startsWith("#!")) return undefined

for (const { pattern, language } of SHEBANG_PATTERNS) {
if (pattern.test(line)) {
return language
}
}

return undefined
} finally {
reader.releaseLock()
await stream.cancel()
}
}
8 changes: 8 additions & 0 deletions packages/opencode/src/lsp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,15 @@ export namespace LSPServer {
export interface Info {
id: string
extensions: string[]
shebangs?: RegExp[]
global?: boolean
root: RootFunction
spawn(root: string): Promise<Handle | undefined>
}

export const Deno: Info = {
id: "deno",
shebangs: [/\bdeno\b/],
root: async (file) => {
const files = Filesystem.up({
targets: ["deno.json", "deno.jsonc"],
Expand Down Expand Up @@ -90,6 +92,7 @@ export namespace LSPServer {

export const Typescript: Info = {
id: "typescript",
shebangs: [/\b(node|bun|npx|yarn|pnpm)\b/],
root: NearestRoot(
["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"],
["deno.json", "deno.jsonc"],
Expand Down Expand Up @@ -402,6 +405,7 @@ export namespace LSPServer {

export const Rubocop: Info = {
id: "ruby-lsp",
shebangs: [/\bruby\b/],
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
Expand Down Expand Up @@ -442,6 +446,7 @@ export namespace LSPServer {

export const Ty: Info = {
id: "ty",
shebangs: [/\b(uv\s+run|python[23]?)\b/],
extensions: [".py", ".pyi"],
root: NearestRoot([
"pyproject.toml",
Expand Down Expand Up @@ -506,6 +511,7 @@ export namespace LSPServer {

export const Pyright: Info = {
id: "pyright",
shebangs: [/\b(uv\s+run|python[23]?)\b/],
extensions: [".py", ".pyi"],
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
async spawn(root) {
Expand Down Expand Up @@ -560,6 +566,7 @@ export namespace LSPServer {

export const ElixirLS: Info = {
id: "elixir-ls",
shebangs: [/\b(elixir|iex)\b/],
extensions: [".ex", ".exs"],
root: NearestRoot(["mix.exs", "mix.lock"]),
async spawn(root) {
Expand Down Expand Up @@ -1606,6 +1613,7 @@ export namespace LSPServer {
}
export const BashLS: Info = {
id: "bash",
shebangs: [/\b(bash|zsh|dash|fish)\b/, /(?<![-\w])sh(?![-\w])/],
extensions: [".sh", ".bash", ".zsh", ".ksh"],
root: async () => Instance.directory,
async spawn(root) {
Expand Down
181 changes: 181 additions & 0 deletions packages/opencode/test/lsp/language.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { getLanguageFromShebang, SHEBANG_PATTERNS } from "../../src/lsp/language"
import { tmpdir } from "../fixture/fixture"

describe("SHEBANG_PATTERNS", () => {
test("uv run pattern matches", () => {
const pattern = SHEBANG_PATTERNS.find((p) => p.language === "python" && p.pattern.source.includes("uv"))
expect(pattern).toBeDefined()
expect(pattern!.pattern.test("#!/usr/bin/env -S uv run --script")).toBe(true)
expect(pattern!.pattern.test("#!uv run")).toBe(true)
expect(pattern!.pattern.test("#!/usr/bin/env uv run")).toBe(true)
})

test("python patterns match", () => {
const patterns = SHEBANG_PATTERNS.filter((p) => p.language === "python")
expect(patterns.length).toBeGreaterThan(0)

const pythonPattern = patterns.find((p) => p.pattern.source.includes("python"))
expect(pythonPattern!.pattern.test("#!/usr/bin/python")).toBe(true)
expect(pythonPattern!.pattern.test("#!/usr/bin/python3")).toBe(true)
expect(pythonPattern!.pattern.test("#!/usr/bin/python2")).toBe(true)
expect(pythonPattern!.pattern.test("#!/usr/bin/env python")).toBe(true)
expect(pythonPattern!.pattern.test("#!/usr/bin/env python3")).toBe(true)
})

test("node patterns match", () => {
const pattern = SHEBANG_PATTERNS.find((p) => p.language === "javascript" && p.pattern.source.includes("node"))
expect(pattern).toBeDefined()
expect(pattern!.pattern.test("#!/usr/bin/node")).toBe(true)
expect(pattern!.pattern.test("#!/usr/bin/env node")).toBe(true)
})

test("bash patterns match", () => {
const pattern = SHEBANG_PATTERNS.find((p) => p.language === "shellscript" && p.pattern.source.includes("bash"))
expect(pattern).toBeDefined()
expect(pattern!.pattern.test("#!/bin/bash")).toBe(true)
expect(pattern!.pattern.test("#!/usr/bin/env bash")).toBe(true)
})

test("deno pattern matches typescript", () => {
const pattern = SHEBANG_PATTERNS.find((p) => p.language === "typescript" && p.pattern.source.includes("deno"))
expect(pattern).toBeDefined()
expect(pattern!.pattern.test("#!/usr/bin/env deno")).toBe(true)
expect(pattern!.pattern.test("#!/usr/local/bin/deno")).toBe(true)
})
})

describe("getLanguageFromShebang", () => {
test("returns python for uv run shebang", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "script"),
`#!/usr/bin/env -S uv run --script
print("hello")
`,
)
return path.join(dir, "script")
},
})
const result = await getLanguageFromShebang(tmp.extra)
expect(result).toBe("python")
})

test("returns python for python3 shebang", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "script"),
`#!/usr/bin/env python3
print("hello")
`,
)
return path.join(dir, "script")
},
})
const result = await getLanguageFromShebang(tmp.extra)
expect(result).toBe("python")
})

test("returns javascript for node shebang", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "script"),
`#!/usr/bin/env node
console.log("hello")
`,
)
return path.join(dir, "script")
},
})
const result = await getLanguageFromShebang(tmp.extra)
expect(result).toBe("javascript")
})

test("returns typescript for deno shebang", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "script"),
`#!/usr/bin/env deno
console.log("hello")
`,
)
return path.join(dir, "script")
},
})
const result = await getLanguageFromShebang(tmp.extra)
expect(result).toBe("typescript")
})

test("returns shellscript for bash shebang", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "script"),
`#!/bin/bash
echo "hello"
`,
)
return path.join(dir, "script")
},
})
const result = await getLanguageFromShebang(tmp.extra)
expect(result).toBe("shellscript")
})

test("returns undefined for file without shebang", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "script"),
`echo "hello"
`,
)
return path.join(dir, "script")
},
})
const result = await getLanguageFromShebang(tmp.extra)
expect(result).toBeUndefined()
})

test("returns undefined for non-existent file", async () => {
const result = await getLanguageFromShebang("/nonexistent/path/to/file")
expect(result).toBeUndefined()
})

test("returns ruby for ruby shebang", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "script"),
`#!/usr/bin/env ruby
puts "hello"
`,
)
return path.join(dir, "script")
},
})
const result = await getLanguageFromShebang(tmp.extra)
expect(result).toBe("ruby")
})

test("returns shellscript for sh shebang", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "script"),
`#!/bin/sh
echo "hello"
`,
)
return path.join(dir, "script")
},
})
const result = await getLanguageFromShebang(tmp.extra)
expect(result).toBe("shellscript")
})
})
Loading
Loading