diff options
| author | Dax Raad <[email protected]> | 2025-05-20 22:00:00 -0400 |
|---|---|---|
| committer | Dax Raad <[email protected]> | 2025-05-26 12:40:17 -0400 |
| commit | 2860a2bb1a1f227c26b02f1325454ab79d6f6451 (patch) | |
| tree | 2a52a5c159025c6ff6854af2995c24c4d7d46b77 /js/src | |
| parent | 9b564f0b73d099d40c79517213211ba81b3312c6 (diff) | |
| download | opencode-2860a2bb1a1f227c26b02f1325454ab79d6f6451.tar.gz opencode-2860a2bb1a1f227c26b02f1325454ab79d6f6451.zip | |
sync
Diffstat (limited to 'js/src')
| -rw-r--r-- | js/src/index.ts | 7 | ||||
| -rw-r--r-- | js/src/lsp/client.ts | 178 | ||||
| -rw-r--r-- | js/src/lsp/index.ts | 31 | ||||
| -rw-r--r-- | js/src/lsp/language.ts | 83 | ||||
| -rw-r--r-- | js/src/tool/edit.ts | 232 | ||||
| -rw-r--r-- | js/src/tool/tool.ts | 3 | ||||
| -rw-r--r-- | js/src/tool/view.ts | 8 | ||||
| -rw-r--r-- | js/src/util/log.ts | 1 | ||||
| -rw-r--r-- | js/src/util/scrap.ts | 5 |
9 files changed, 375 insertions, 173 deletions
diff --git a/js/src/index.ts b/js/src/index.ts index 609abf14b..7d114c642 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -1,10 +1,11 @@ import { App } from "./app"; import { Server } from "./server/server"; -import { Cli, Command, Option, runExit } from "clipanion"; +import { Cli, Command, Option } from "clipanion"; import fs from "fs/promises"; import path from "path"; import { Bus } from "./bus"; import { Session } from "./session/session"; +import { LSP } from "./lsp"; const cli = new Cli({ binaryLabel: `opencode`, @@ -25,6 +26,7 @@ cli.register( } }, ); + cli.register( class extends Command { static paths = [["generate"]]; @@ -71,6 +73,9 @@ cli.register( "tool:", part.toolInvocation.toolName, part.toolInvocation.args, + part.toolInvocation.state === "result" + ? part.toolInvocation.result + : "", ); } } diff --git a/js/src/lsp/client.ts b/js/src/lsp/client.ts new file mode 100644 index 000000000..e6cfdb2eb --- /dev/null +++ b/js/src/lsp/client.ts @@ -0,0 +1,178 @@ +import { spawn } from "child_process"; +import path from "path"; +import { + createMessageConnection, + Disposable, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-jsonrpc/node"; +import { App } from "../app"; +import { Log } from "../util/log"; +import { LANGUAGE_EXTENSIONS } from "./language"; + +export namespace LSPClient { + const log = Log.create({ service: "lsp.client" }); + + export type Info = Awaited<ReturnType<typeof create>>; + + export async function create(input: { cmd: string[] }) { + log.info("starting client", input); + let version = 0; + + const app = await App.use(); + const [command, ...args] = input.cmd; + const server = spawn(command, args, { + stdio: ["pipe", "pipe", "pipe"], + cwd: app.root, + }); + + const connection = createMessageConnection( + new StreamMessageReader(server.stdout), + new StreamMessageWriter(server.stdin), + ); + + const diagnostics = new Map<string, any>(); + connection.onNotification("textDocument/publishDiagnostics", (params) => { + log.info("textDocument/publishDiagnostics", { + path: new URL(params.uri).pathname, + }); + diagnostics.set(new URL(params.uri).pathname, params.diagnostics); + }); + connection.listen(); + + await connection.sendRequest("initialize", { + processId: server.pid, + initializationOptions: { + workspaceFolders: [ + { + name: "workspace", + uri: "file://" + app.root, + }, + ], + tsserver: { + path: require.resolve("typescript/lib/tsserver.js"), + }, + }, + capabilities: { + workspace: { + configuration: true, + didChangeConfiguration: { + dynamicRegistration: true, + }, + didChangeWatchedFiles: { + dynamicRegistration: true, + relativePatternSupport: true, + }, + }, + textDocument: { + synchronization: { + dynamicRegistration: true, + didSave: true, + }, + completion: { + completionItem: {}, + }, + codeLens: { + dynamicRegistration: true, + }, + documentSymbol: {}, + codeAction: { + codeActionLiteralSupport: { + codeActionKind: { + valueSet: [], + }, + }, + }, + publishDiagnostics: { + versionSupport: true, + }, + semanticTokens: { + requests: { + range: {}, + full: {}, + }, + tokenTypes: [], + tokenModifiers: [], + formats: [], + }, + }, + window: {}, + }, + }); + await connection.sendNotification("initialized", {}); + log.info("initialized"); + + const result = { + get connection() { + return connection; + }, + notify: { + async open(input: { path: string }) { + log.info("textDocument/didOpen", input); + diagnostics.delete(input.path); + const text = await Bun.file(input.path).text(); + const languageId = LANGUAGE_EXTENSIONS[path.extname(input.path)]; + await connection.sendNotification("textDocument/didOpen", { + textDocument: { + uri: `file://` + input.path, + languageId, + version: 1, + text: text, + }, + }); + }, + async change(input: { path: string }) { + log.info("textDocument/didChange", input); + diagnostics.delete(input.path); + const text = await Bun.file(input.path).text(); + version++; + await connection.sendNotification("textDocument/didChange", { + textDocument: { + uri: `file://` + input.path, + version: Date.now(), + }, + contentChanges: [ + { + text, + }, + ], + }); + }, + }, + get diagnostics() { + return diagnostics; + }, + async refreshDiagnostics(input: { path: string }) { + log.info("refreshing diagnostics", input); + let notif: Disposable | undefined; + return await Promise.race([ + new Promise<void>(async (resolve) => { + notif = connection.onNotification( + "textDocument/publishDiagnostics", + (params) => { + log.info("refreshed diagnostics", input); + if (new URL(params.uri).pathname === input.path) { + diagnostics.set( + new URL(params.uri).pathname, + params.diagnostics, + ); + resolve(); + notif?.dispose(); + } + }, + ); + await result.notify.change(input); + }), + new Promise<void>((resolve) => + setTimeout(() => { + notif?.dispose(); + resolve(); + }, 5000), + ), + ]); + }, + }; + + return result; + } +} diff --git a/js/src/lsp/index.ts b/js/src/lsp/index.ts new file mode 100644 index 000000000..ac200fc51 --- /dev/null +++ b/js/src/lsp/index.ts @@ -0,0 +1,31 @@ +import { App } from "../app"; +import { Log } from "../util/log"; +import { LSPClient } from "./client"; + +export namespace LSP { + const log = Log.create({ service: "lsp" }); + + const state = App.state("lsp", async () => { + const clients = new Map<string, LSPClient.Info>(); + + clients.set( + "typescript", + await LSPClient.create({ + cmd: ["bun", "x", "typescript-language-server", "--stdio"], + }), + ); + + return { + clients, + diagnostics: new Map<string, any>(), + }; + }); + + export async function run<T>( + input: (client: LSPClient.Info) => Promise<T>, + ): Promise<T[]> { + const clients = await state().then((x) => [...x.clients.values()]); + const tasks = clients.map((x) => input(x)); + return Promise.all(tasks); + } +} diff --git a/js/src/lsp/language.ts b/js/src/lsp/language.ts new file mode 100644 index 000000000..0a9bc0f7f --- /dev/null +++ b/js/src/lsp/language.ts @@ -0,0 +1,83 @@ +export const LANGUAGE_EXTENSIONS: Record<string, string> = { + ".abap": "abap", + ".bat": "bat", + ".bib": "bibtex", + ".bibtex": "bibtex", + ".clj": "clojure", + ".coffee": "coffeescript", + ".c": "c", + ".cpp": "cpp", + ".cxx": "cpp", + ".cc": "cpp", + ".c++": "cpp", + ".cs": "csharp", + ".css": "css", + ".d": "d", + ".pas": "pascal", + ".pascal": "pascal", + ".diff": "diff", + ".patch": "diff", + ".dart": "dart", + ".dockerfile": "dockerfile", + ".ex": "elixir", + ".exs": "elixir", + ".erl": "erlang", + ".hrl": "erlang", + ".fs": "fsharp", + ".fsi": "fsharp", + ".fsx": "fsharp", + ".fsscript": "fsharp", + ".gitcommit": "git-commit", + ".gitrebase": "git-rebase", + ".go": "go", + ".groovy": "groovy", + ".hbs": "handlebars", + ".handlebars": "handlebars", + ".hs": "haskell", + ".html": "html", + ".htm": "html", + ".ini": "ini", + ".java": "java", + ".js": "javascript", + ".jsx": "javascriptreact", + ".json": "json", + ".tex": "latex", + ".latex": "latex", + ".less": "less", + ".lua": "lua", + ".makefile": "makefile", + makefile: "makefile", + ".md": "markdown", + ".markdown": "markdown", + ".m": "objective-c", + ".mm": "objective-cpp", + ".pl": "perl", + ".pm": "perl6", + ".php": "php", + ".ps1": "powershell", + ".psm1": "powershell", + ".pug": "jade", + ".jade": "jade", + ".py": "python", + ".r": "r", + ".cshtml": "razor", + ".razor": "razor", + ".rb": "ruby", + ".rs": "rust", + ".scss": "scss", + ".sass": "sass", + ".scala": "scala", + ".shader": "shaderlab", + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", + ".ksh": "shellscript", + ".sql": "sql", + ".swift": "swift", + ".ts": "typescript", + ".tsx": "typescriptreact", + ".xml": "xml", + ".xsl": "xsl", + ".yaml": "yaml", + ".yml": "yaml", +}; diff --git a/js/src/tool/edit.ts b/js/src/tool/edit.ts index 24bf29bcb..e83564f0f 100644 --- a/js/src/tool/edit.ts +++ b/js/src/tool/edit.ts @@ -4,6 +4,7 @@ import * as path from "path"; import { Log } from "../util/log"; import { Tool } from "./tool"; import { FileTimes } from "./util/file-times"; +import { LSP } from "../lsp"; const log = Log.create({ service: "tool.edit" }); @@ -78,7 +79,7 @@ Before using this tool: - Use the LS tool to verify the parent directory exists and is the correct location To make a file edit, provide the following: -1. file_path: The absolute path to the file to modify (must be absolute, not relative) +1. file_path: The relative path to the file to modify (must be relative, not absolute) 2. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation) 3. new_string: The edited text to replace the old_string @@ -112,7 +113,7 @@ WARNING: If you do not follow these requirements: When making edits: - Ensure the edit results in idiomatic, correct code - Do not leave the code in a broken state - - Always use absolute file paths (starting with /) + - Always use relative file paths Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`; @@ -134,21 +135,68 @@ export const EditTool = Tool.define({ filePath = path.join(process.cwd(), filePath); } - // Handle different operations based on parameters - if (params.old_string === "") { - return { - output: createNewFile(filePath, params.new_string), - }; - } + await (async () => { + if (params.old_string === "") { + await createNewFile(filePath, params.new_string); + return; + } - if (params.new_string === "") { - return { - output: deleteContent(filePath, params.old_string), - }; + const read = FileTimes.get(filePath); + if (!read) + throw new Error( + `You must read the file ${filePath} before editing it. Use the View tool first`, + ); + const file = Bun.file(filePath); + if (!(await file.exists())) throw new Error(`File ${filePath} not found`); + const stats = await file.stat(); + if (stats.isDirectory()) + throw new Error(`Path is a directory, not a file: ${filePath}`); + if (stats.mtime.getTime() > read.getTime()) + throw new Error( + `File ${filePath} has been modified since it was last read.\nLast modification: ${read.toISOString()}\nLast read: ${stats.mtime.toISOString()}\n\nPlease read the file again before modifying it.`, + ); + + const content = await file.text(); + const index = content.indexOf(params.old_string); + if (index === -1) + throw new Error( + `old_string not found in file. Make sure it matches exactly, including whitespace and line breaks`, + ); + const lastIndex = content.lastIndexOf(params.old_string); + if (index !== lastIndex) + throw new Error( + `old_string appears multiple times in the file. Please provide more context to ensure a unique match`, + ); + + const newContent = + content.substring(0, index) + + params.new_string + + content.substring(index + params.old_string.length); + + console.log(newContent); + await file.write(newContent); + })(); + + FileTimes.write(filePath); + FileTimes.read(filePath); + + let output = ""; + await LSP.run((client) => client.refreshDiagnostics({ path: filePath })); + const diagnostics = await LSP.run(async (client) => client.diagnostics); + for (const diagnostic of diagnostics) { + for (const [file, params] of diagnostic.entries()) { + if (params.length === 0) continue; + if (file === filePath) { + output += `\nThis file has errors, please fix\n<file_diagnostics>\n${JSON.stringify(params)}\n</file_diagnostics>\n`; + continue; + } + output += `\n<project_diagnostics>\n${JSON.stringify(params)}\n</project_diagnostics>\n`; + } } + console.log(output); return { - output: replaceContent(filePath, params.old_string, params.new_string), + output, }; }, }); @@ -186,160 +234,4 @@ async function createNewFile( } } -async function deleteContent( - filePath: string, - oldString: string, -): Promise<string> { - try { - // Check if file exists - let fileStats; - try { - fileStats = fs.statSync(filePath); - if (fileStats.isDirectory()) { - throw new Error(`Path is a directory, not a file: ${filePath}`); - } - } catch (err: any) { - if (err.code === "ENOENT") { - throw new Error(`File not found: ${filePath}`); - } - throw err; - } - - const lastReadTime = FileTimes.get(filePath); - if (!lastReadTime) { - throw new Error( - "You must read the file before editing it. Use the View tool first", - ); - } - - const modTime = fileStats.mtime; - if (modTime > lastReadTime) { - throw new Error( - `File ${filePath} has been modified since it was last read (mod time: ${modTime.toISOString()}, last read: ${lastReadTime.toISOString()})`, - ); - } - - const oldContent = fs.readFileSync(filePath, "utf8"); - const index = oldContent.indexOf(oldString); - if (index === -1) { - throw new Error( - "old_string not found in file. Make sure it matches exactly, including whitespace and line breaks", - ); - } - - const lastIndex = oldContent.lastIndexOf(oldString); - if (index !== lastIndex) { - throw new Error( - "old_string appears multiple times in the file. Please provide more context to ensure a unique match", - ); - } - - const newContent = - oldContent.substring(0, index) + - oldContent.substring(index + oldString.length); - - const { diff, additions, removals } = generateDiff( - oldContent, - newContent, - filePath, - ); - - // Write the file - fs.writeFileSync(filePath, newContent); - - FileTimes.write(filePath); - FileTimes.read(filePath); - - return `Content deleted from file: ${filePath}`; - } catch (err: any) { - throw new Error(`Failed to delete content: ${err.message}`); - } -} - -async function replaceContent( - filePath: string, - oldString: string, - newString: string, -): Promise<string> { - try { - // Check if file exists - let fileStats; - try { - fileStats = fs.statSync(filePath); - if (fileStats.isDirectory()) { - throw new Error(`Path is a directory, not a file: ${filePath}`); - } - } catch (err: any) { - if (err.code === "ENOENT") { - throw new Error(`File not found: ${filePath}`); - } - throw err; - } - - // Check if file has been read before - const lastReadTime = getLastReadTime(filePath); - if (!lastReadTime) { - throw new Error( - "You must read the file before editing it. Use the View tool first", - ); - } - - // Check if file has been modified since last read - const modTime = fileStats.mtime; - if (modTime > lastReadTime) { - throw new Error( - `File ${filePath} has been modified since it was last read (mod time: ${modTime.toISOString()}, last read: ${lastReadTime.toISOString()})`, - ); - } - - // Read the file content - const oldContent = fs.readFileSync(filePath, "utf8"); - - // Find the string to replace - const index = oldContent.indexOf(oldString); - if (index === -1) { - throw new Error( - "old_string not found in file. Make sure it matches exactly, including whitespace and line breaks", - ); - } - - // Check if the string appears multiple times - const lastIndex = oldContent.lastIndexOf(oldString); - if (index !== lastIndex) { - throw new Error( - "old_string appears multiple times in the file. Please provide more context to ensure a unique match", - ); - } - - // Create the new content - const newContent = - oldContent.substring(0, index) + - newString + - oldContent.substring(index + oldString.length); - - // Check if content actually changed - if (oldContent === newContent) { - throw new Error( - "new content is the same as old content. No changes made.", - ); - } - - // Generate diff - const { diff, additions, removals } = generateDiff( - oldContent, - newContent, - filePath, - ); - - // Write the file - fs.writeFileSync(filePath, newContent); - - FileTimes.write(filePath); - FileTimes.read(filePath); - - return `Content replaced in file: ${filePath}`; - } catch (err: any) { - throw new Error(`Failed to replace content: ${err.message}`); - } -} - +function getFile(filePath: string) {} diff --git a/js/src/tool/tool.ts b/js/src/tool/tool.ts index 748025707..47a3918d4 100644 --- a/js/src/tool/tool.ts +++ b/js/src/tool/tool.ts @@ -40,6 +40,9 @@ export namespace Tool { output: result.output, }; } catch (e: any) { + log.error("error", { + msg: e.toString(), + }); return "An error occurred: " + e.toString(); } }, diff --git a/js/src/tool/view.ts b/js/src/tool/view.ts index ba6fabbfa..2da0ab0f3 100644 --- a/js/src/tool/view.ts +++ b/js/src/tool/view.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import * as fs from "fs"; import * as path from "path"; import { Tool } from "./tool"; +import { LSP } from "../lsp"; +import { FileTimes } from "./util/file-times"; const MAX_READ_SIZE = 250 * 1024; const DEFAULT_READ_LIMIT = 2000; @@ -117,8 +119,11 @@ export const ViewTool = Tool.define({ } output += "\n</file>"; + await LSP.run((client) => client.notify.open({ path: filePath })); + FileTimes.read(filePath); + return { - output: output, + output, }; }, }); @@ -143,4 +148,3 @@ function isImageFile(filePath: string): string | false { return false; } } - diff --git a/js/src/util/log.ts b/js/src/util/log.ts index c43514ef6..d05f42dc2 100644 --- a/js/src/util/log.ts +++ b/js/src/util/log.ts @@ -12,6 +12,7 @@ export namespace Log { }; export function file(directory: string) { + return; const out = Bun.file( path.join(AppPath.data(directory), "opencode.out.log"), ).writer(); diff --git a/js/src/util/scrap.ts b/js/src/util/scrap.ts new file mode 100644 index 000000000..16005acdc --- /dev/null +++ b/js/src/util/scrap.ts @@ -0,0 +1,5 @@ +export const foo: string = "42"; + +export function dummyFunction(): void { + console.log("This is a dummy function"); +} |
