Dax Raad 9 months ago
parent
commit
de9f144858

+ 1 - 0
js/example/broken.ts

@@ -0,0 +1 @@
+export const x: number = "asd";

+ 19 - 25
js/example/cli.ts

@@ -1,27 +1,21 @@
-import { hc } from "hono/client";
-import type { Server } from "../src/server/server";
+import { App } from "../src/app";
+import path from "path";
+import { edit } from "../src/tool";
+import { FileTimes } from "../src/tool/util/file-times";
 
-const message = process.argv.slice(2).join(" ");
-console.log(message);
-
-const client = hc<Server.App>(`http://localhost:16713`);
-const session = await client.session_create.$post().then((res) => res.json());
-const result = await client.session_chat
-  .$post({
-    json: {
-      sessionID: session.id,
-      parts: [
-        {
-          type: "text",
-          text: message,
-        },
-      ],
+await App.provide({ directory: process.cwd() }, async () => {
+  const file = path.join(process.cwd(), "example/broken.ts");
+  FileTimes.read(file);
+  const tool = await edit.execute(
+    {
+      file_path: file,
+      old_string: "x:",
+      new_string: "x:",
     },
-  })
-  .then((res) => res.json());
-
-for (const part of result.parts) {
-  if (part.type === "text") {
-    console.log(part.text);
-  }
-}
+    {
+      toolCallId: "test",
+      messages: [],
+    },
+  );
+  console.log(tool.output);
+});

+ 37 - 21
js/src/lsp/client.ts

@@ -5,6 +5,7 @@ import {
   StreamMessageReader,
   StreamMessageWriter,
 } from "vscode-jsonrpc/node";
+import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types";
 import { App } from "../app";
 import { Log } from "../util/log";
 import { LANGUAGE_EXTENSIONS } from "./language";
@@ -16,16 +17,19 @@ export namespace LSPClient {
 
   export type Info = Awaited<ReturnType<typeof create>>;
 
+  export type Diagnostic = VSCodeDiagnostic;
+
   export const Event = {
     Diagnostics: Bus.event(
       "lsp.client.diagnostics",
       z.object({
+        serverID: z.string(),
         path: z.string(),
       }),
     ),
   };
 
-  export async function create(input: { cmd: string[] }) {
+  export async function create(input: { cmd: string[]; serverID: string }) {
     log.info("starting client", input);
     let version = 0;
 
@@ -41,14 +45,14 @@ export namespace LSPClient {
       new StreamMessageWriter(server.stdin),
     );
 
-    const diagnostics = new Map<string, any>();
+    const diagnostics = new Map<string, Diagnostic[]>();
     connection.onNotification("textDocument/publishDiagnostics", (params) => {
       const path = new URL(params.uri).pathname;
       log.info("textDocument/publishDiagnostics", {
         path,
       });
       diagnostics.set(path, params.diagnostics);
-      Bus.publish(Event.Diagnostics, { path });
+      Bus.publish(Event.Diagnostics, { path, serverID: input.serverID });
     });
     connection.listen();
 
@@ -114,34 +118,43 @@ export namespace LSPClient {
     await connection.sendNotification("initialized", {});
     log.info("initialized");
 
+    const files = new Set<string>();
+
     const result = {
+      get clientID() {
+        return input.serverID;
+      },
       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 }) {
+          const opened = files.has(input.path);
+          if (!opened) {
+            log.info("textDocument/didOpen", input);
+            diagnostics.delete(input.path);
+            const extension = path.extname(input.path);
+            const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext";
+            await connection.sendNotification("textDocument/didOpen", {
+              textDocument: {
+                uri: `file://` + input.path,
+                languageId,
+                version: 1,
+                text: text,
+              },
+            });
+            files.add(input.path);
+            return;
+          }
+
           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(),
+              version,
             },
             contentChanges: [
               {
@@ -154,21 +167,24 @@ export namespace LSPClient {
       get diagnostics() {
         return diagnostics;
       },
-      async refreshDiagnostics(input: { path: string }) {
+      async waitForDiagnostics(input: { path: string }) {
         log.info("refreshing diagnostics", input);
         let unsub: () => void;
         let timeout: NodeJS.Timeout;
         return await Promise.race([
           new Promise<void>(async (resolve) => {
             unsub = Bus.subscribe(Event.Diagnostics, (event) => {
-              if (event.properties.path === input.path) {
+              if (
+                event.properties.path === input.path &&
+                event.properties.serverID === result.clientID
+              ) {
                 log.info("refreshed diagnostics", input);
                 clearTimeout(timeout);
                 unsub?.();
                 resolve();
               }
             });
-            await result.notify.change(input);
+            await result.notify.open(input);
           }),
           new Promise<void>((resolve) => {
             timeout = setTimeout(() => {

+ 65 - 21
js/src/lsp/index.ts

@@ -1,6 +1,7 @@
 import { App } from "../app";
 import { Log } from "../util/log";
 import { LSPClient } from "./client";
+import path from "path";
 
 export namespace LSP {
   const log = Log.create({ service: "lsp" });
@@ -10,17 +11,8 @@ export namespace LSP {
     async () => {
       const clients = new Map<string, LSPClient.Info>();
 
-      // QUESTION: how lazy should lsp auto discovery be? should it not initialize until a file is opened?
-      clients.set(
-        "typescript",
-        await LSPClient.create({
-          cmd: ["bun", "x", "typescript-language-server", "--stdio"],
-        }),
-      );
-
       return {
         clients,
-        diagnostics: new Map<string, any>(),
       };
     },
     async (state) => {
@@ -30,7 +22,39 @@ export namespace LSP {
     },
   );
 
-  export async function run<T>(
+  export async function file(input: string) {
+    const extension = path.parse(input).ext;
+    const s = await state();
+    const matches = AUTO.filter((x) => x.extensions.includes(extension));
+    for (const match of matches) {
+      const existing = s.clients.get(match.id);
+      if (existing) continue;
+      const client = await LSPClient.create({
+        cmd: match.command,
+        serverID: match.id,
+      });
+      s.clients.set(match.id, client);
+    }
+    await run(async (client) => {
+      const wait = client.waitForDiagnostics({ path: input });
+      await client.notify.open({ path: input });
+      return wait;
+    });
+  }
+
+  export async function diagnostics() {
+    const results: Record<string, LSPClient.Diagnostic[]> = {};
+    for (const result of await run(async (client) => client.diagnostics)) {
+      for (const [path, diagnostics] of result.entries()) {
+        const arr = results[path] || [];
+        arr.push(...diagnostics);
+        results[path] = arr;
+      }
+    }
+    return results;
+  }
+
+  async function run<T>(
     input: (client: LSPClient.Info) => Promise<T>,
   ): Promise<T[]> {
     const clients = await state().then((x) => [...x.clients.values()]);
@@ -39,28 +63,48 @@ export namespace LSP {
   }
 
   const AUTO: {
+    id: string;
     command: string[];
     extensions: string[];
     install?: () => Promise<void>;
   }[] = [
     {
+      id: "typescript",
       command: ["bun", "x", "typescript-language-server", "--stdio"],
       extensions: [
-        "ts",
-        "tsx",
-        "js",
-        "jsx",
-        "mjs",
-        "cjs",
-        "mts",
-        "cts",
-        "mtsx",
-        "ctsx",
+        ".ts",
+        ".tsx",
+        ".js",
+        ".jsx",
+        ".mjs",
+        ".cjs",
+        ".mts",
+        ".cts",
+        ".mtsx",
+        ".ctsx",
       ],
     },
     {
+      id: "golang",
       command: ["gopls"],
-      extensions: ["go"],
+      extensions: [".go"],
     },
   ];
+
+  export namespace Diagnostic {
+    export function pretty(diagnostic: LSPClient.Diagnostic) {
+      const severityMap = {
+        1: "ERROR",
+        2: "WARN",
+        3: "INFO",
+        4: "HINT",
+      };
+
+      const severity = severityMap[diagnostic.severity || 1];
+      const line = diagnostic.range.start.line + 1;
+      const col = diagnostic.range.start.character + 1;
+
+      return `${severity} [${line}:${col}] ${diagnostic.message}`;
+    }
+  }
 }

+ 6 - 0
js/src/lsp/language.ts

@@ -76,8 +76,14 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
   ".swift": "swift",
   ".ts": "typescript",
   ".tsx": "typescriptreact",
+  ".mts": "typescript",
+  ".cts": "typescript",
+  ".mtsx": "typescriptreact",
+  ".ctsx": "typescriptreact",
   ".xml": "xml",
   ".xsl": "xsl",
   ".yaml": "yaml",
   ".yml": "yaml",
+  ".mjs": "javascript",
+  ".cjs": "javascript",
 } as const;

+ 56 - 0
js/src/tool/diagnostics.ts

@@ -0,0 +1,56 @@
+import { z } from "zod";
+import { Tool } from "./tool";
+import path from "path";
+import { LSP } from "../lsp";
+import { App } from "../app";
+
+export const DiagnosticsTool = Tool.define({
+  name: "diagnostics",
+  description: `Get diagnostics for a file and/or project.
+
+WHEN TO USE THIS TOOL:
+- Use when you need to check for errors or warnings in your code
+- Helpful for debugging and ensuring code quality
+- Good for getting a quick overview of issues in a file or project
+
+HOW TO USE:
+- Provide a path to a file to get diagnostics for that file
+- Results are displayed in a structured format with severity levels
+
+FEATURES:
+- Displays errors, warnings, and hints
+- Groups diagnostics by severity
+- Provides detailed information about each diagnostic
+
+LIMITATIONS:
+- Results are limited to the diagnostics provided by the LSP clients
+- May not cover all possible issues in the code
+- Does not provide suggestions for fixing issues
+
+TIPS:
+- Use in conjunction with other tools for a comprehensive code review
+- Combine with the LSP client for real-time diagnostics`,
+  parameters: z.object({
+    path: z.string().describe("The path to the file to get diagnostics."),
+  }),
+  execute: async (args) => {
+    const app = await App.use();
+    const normalized = path.isAbsolute(args.path)
+      ? args.path
+      : path.join(app.root, args.path);
+    await LSP.file(normalized);
+    const diagnostics = await LSP.diagnostics();
+    const file = diagnostics[normalized];
+    return {
+      metadata: {
+        diagnostics,
+      },
+      output: file?.length
+        ? file.map(LSP.Diagnostic.pretty).join("\n")
+        : "No errors found",
+    };
+  },
+});
+
+const x: number = "asd";
+

+ 8 - 10
js/src/tool/edit.ts

@@ -118,17 +118,15 @@ export const edit = Tool.define({
     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`;
+    await LSP.file(filePath);
+    const diagnostics = await LSP.diagnostics();
+    for (const [file, issues] of Object.entries(diagnostics)) {
+      if (issues.length === 0) continue;
+      if (file === filePath) {
+        output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`;
+        continue;
       }
+      output += `\n<project_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</project_diagnostics>\n`;
     }
 
     return {

+ 1 - 0
js/src/tool/index.ts

@@ -5,3 +5,4 @@ export * from "./glob";
 export * from "./grep";
 export * from "./view";
 export * from "./ls";
+export * from "./diagnostics";

+ 2 - 1
js/src/tool/view.ts

@@ -115,7 +115,8 @@ export const view = Tool.define({
     }
     output += "\n</file>";
 
-    await LSP.run((client) => client.notify.open({ path: filePath }));
+    // just warms the lsp client
+    LSP.file(filePath);
     FileTimes.read(filePath);
 
     return {