diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 084ccf831ee..a941648631a 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -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" @@ -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) { diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 9d7d30632ab..69925b21333 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -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" }) @@ -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) { @@ -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 @@ -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 diff --git a/packages/opencode/src/lsp/language.ts b/packages/opencode/src/lsp/language.ts index 58f4c8488ba..9605dd311bd 100644 --- a/packages/opencode/src/lsp/language.ts +++ b/packages/opencode/src/lsp/language.ts @@ -118,3 +118,64 @@ export const LANGUAGE_EXTENSIONS: Record = { ".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: /(? { + 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() + } +} diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index afd297a5ed6..dbfc2d9257d 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -55,6 +55,7 @@ export namespace LSPServer { export interface Info { id: string extensions: string[] + shebangs?: RegExp[] global?: boolean root: RootFunction spawn(root: string): Promise @@ -62,6 +63,7 @@ export namespace LSPServer { export const Deno: Info = { id: "deno", + shebangs: [/\bdeno\b/], root: async (file) => { const files = Filesystem.up({ targets: ["deno.json", "deno.jsonc"], @@ -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"], @@ -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) { @@ -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", @@ -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) { @@ -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) { @@ -1606,6 +1613,7 @@ export namespace LSPServer { } export const BashLS: Info = { id: "bash", + shebangs: [/\b(bash|zsh|dash|fish)\b/, /(? Instance.directory, async spawn(root) { diff --git a/packages/opencode/test/lsp/language.test.ts b/packages/opencode/test/lsp/language.test.ts new file mode 100644 index 00000000000..41b89c3fd1f --- /dev/null +++ b/packages/opencode/test/lsp/language.test.ts @@ -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") + }) +}) diff --git a/packages/web/src/content/docs/de/lsp.mdx b/packages/web/src/content/docs/de/lsp.mdx index 60f8316c6b6..1ceb30ccada 100644 --- a/packages/web/src/content/docs/de/lsp.mdx +++ b/packages/web/src/content/docs/de/lsp.mdx @@ -59,7 +59,31 @@ Sie können LSP-Server-Downloads automatisch deaktivieren, indem Sie die Umgebun Wenn OpenCode eine Datei öffnet, geschieht Folgendes: 1. Überprüft die Dateierweiterung anhand aller aktivierten LSP-Server. -2. Startet den entsprechenden LSP-Server, falls er noch nicht ausgeführt wird. +2. Wenn keine Erweiterung übereinstimmt, wird die Shebang-Zeile auf unterstützte Interpreter überprüft. +3. Startet den entsprechenden LSP-Server, falls er noch nicht ausgeführt wird. + +--- + +## Shebang-Erkennung + +OpenCode kann die Sprache von Skripten ohne Dateierweiterung durch Parsen der Shebang-Zeile erkennen. Dies ist nützlich für Skripte, die PEP 723 Inline-Metadaten verwenden. + +Unterstützte Shebang-Muster: + +| Muster | Sprache | +| ------------ | ---------- | +| `uv run` | Python | +| `python` | Python | +| `node` | JavaScript | +| `bun` | JavaScript | +| `deno` | TypeScript | +| `bash`, `sh` | Shell | +| `ruby` | Ruby | +| `perl` | Perl | +| `php` | PHP | +| `elixir` | Elixir | + +Ein Skript, das mit `#!/usr/bin/env -S uv run --script` beginnt, wird beispielsweise als Python erkannt und verwendet den pyright LSP-Server. --- diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/lsp.mdx index f242f4c5e4d..0946663f10e 100644 --- a/packages/web/src/content/docs/lsp.mdx +++ b/packages/web/src/content/docs/lsp.mdx @@ -60,7 +60,31 @@ You can disable automatic LSP server downloads by setting the `OPENCODE_DISABLE_ When opencode opens a file, it: 1. Checks the file extension against all enabled LSP servers. -2. Starts the appropriate LSP server if not already running. +2. If no extension matches, checks the shebang line for supported interpreters. +3. Starts the appropriate LSP server if not already running. + +--- + +## Shebang Detection + +OpenCode can detect the language of extensionless scripts by parsing their shebang line. This is useful for scripts like those using PEP 723 inline metadata. + +Supported shebang patterns: + +| Pattern | Language | +| ------------ | ---------- | +| `uv run` | Python | +| `python` | Python | +| `node` | JavaScript | +| `bun` | JavaScript | +| `deno` | TypeScript | +| `bash`, `sh` | Shell | +| `ruby` | Ruby | +| `perl` | Perl | +| `php` | PHP | +| `elixir` | Elixir | + +For example, a script starting with `#!/usr/bin/env -S uv run --script` will be detected as Python and use the pyright LSP server. --- diff --git a/packages/web/src/content/docs/zh-cn/lsp.mdx b/packages/web/src/content/docs/zh-cn/lsp.mdx index 59dd7082a1e..6ff7e8653dc 100644 --- a/packages/web/src/content/docs/zh-cn/lsp.mdx +++ b/packages/web/src/content/docs/zh-cn/lsp.mdx @@ -60,7 +60,31 @@ OpenCode 内置了多种适用于主流语言的 LSP 服务器: 当 opencode 打开一个文件时,它会: 1. 将文件扩展名与所有已启用的 LSP 服务器进行匹配。 -2. 如果对应的 LSP 服务器尚未运行,则自动启动它。 +2. 如果没有匹配的扩展名,则检查 shebang 行以识别支持的解释器。 +3. 如果对应的 LSP 服务器尚未运行,则自动启动它。 + +--- + +## Shebang 检测 + +OpenCode 可以通过解析 shebang 行来识别无扩展名脚本的语言。这对于使用 PEP 723 内联元数据的脚本非常有用。 + +支持的 shebang 模式: + +| 模式 | 语言 | +| ------------ | ---------- | +| `uv run` | Python | +| `python` | Python | +| `node` | JavaScript | +| `bun` | JavaScript | +| `deno` | TypeScript | +| `bash`, `sh` | Shell | +| `ruby` | Ruby | +| `perl` | Perl | +| `php` | PHP | +| `elixir` | Elixir | + +例如,以 `#!/usr/bin/env -S uv run --script` 开头的脚本将被识别为 Python 并使用 pyright LSP 服务器。 --- diff --git a/packages/web/src/content/docs/zh-tw/lsp.mdx b/packages/web/src/content/docs/zh-tw/lsp.mdx index ae419261fff..f98adadfdb3 100644 --- a/packages/web/src/content/docs/zh-tw/lsp.mdx +++ b/packages/web/src/content/docs/zh-tw/lsp.mdx @@ -59,7 +59,31 @@ OpenCode 內建了多種適用於主流語言的 LSP 伺服器: 當 OpenCode 開啟一個檔案時,它會: 1. 將檔案副檔名與所有已啟用的 LSP 伺服器進行比對。 -2. 如果對應的 LSP 伺服器尚未執行,則自動啟動它。 +2. 如果沒有匹配的副檔名,則檢查 shebang 行以識別支援的直譯器。 +3. 如果對應的 LSP 伺服器尚未執行,則自動啟動它。 + +--- + +## Shebang 偵測 + +OpenCode 可以透過解析 shebang 行來識別無副檔名指令碼的語言。這對於使用 PEP 723 內嵌中繼資料的指令碼非常有用。 + +支援的 shebang 模式: + +| 模式 | 語言 | +| ------------ | ---------- | +| `uv run` | Python | +| `python` | Python | +| `node` | JavaScript | +| `bun` | JavaScript | +| `deno` | TypeScript | +| `bash`, `sh` | Shell | +| `ruby` | Ruby | +| `perl` | Perl | +| `php` | PHP | +| `elixir` | Elixir | + +例如,以 `#!/usr/bin/env -S uv run --script` 開頭的指令碼將被識別為 Python 並使用 pyright LSP 伺服器。 ---