nou-cli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.env.example +24 -0
  2. package/README.md +163 -2
  3. package/dist/grammars/tree-sitter-bash.wasm +0 -0
  4. package/dist/grammars/tree-sitter-c.wasm +0 -0
  5. package/dist/grammars/tree-sitter-css.wasm +0 -0
  6. package/dist/grammars/tree-sitter-go.wasm +0 -0
  7. package/dist/grammars/tree-sitter-html.wasm +0 -0
  8. package/dist/grammars/tree-sitter-java.wasm +0 -0
  9. package/dist/grammars/tree-sitter-javascript.wasm +0 -0
  10. package/dist/grammars/tree-sitter-json.wasm +0 -0
  11. package/dist/grammars/tree-sitter-python.wasm +0 -0
  12. package/dist/grammars/tree-sitter-rust.wasm +0 -0
  13. package/dist/grammars/tree-sitter-toml.wasm +0 -0
  14. package/dist/grammars/tree-sitter-tsx.wasm +0 -0
  15. package/dist/grammars/tree-sitter-typescript.wasm +0 -0
  16. package/dist/grammars/tree-sitter-yaml.wasm +0 -0
  17. package/dist/index.js +84 -0
  18. package/dist/src/agent/index.js +246 -0
  19. package/dist/src/commands/index.js +125 -0
  20. package/dist/src/config/index.js +39 -0
  21. package/dist/src/context/index.js +53 -0
  22. package/dist/src/diagnostics/index.js +235 -0
  23. package/dist/src/diagnostics/process.js +64 -0
  24. package/dist/src/diagnostics/providers.js +103 -0
  25. package/dist/src/diagnostics/types.js +1 -0
  26. package/dist/src/env.js +12 -0
  27. package/dist/src/git/index.js +94 -0
  28. package/dist/src/git/tools.js +52 -0
  29. package/dist/src/history/index.js +113 -0
  30. package/dist/src/lsp/client.js +169 -0
  31. package/dist/src/lsp/index.js +169 -0
  32. package/dist/src/plugins/index.js +134 -0
  33. package/dist/src/providers/anthropic.js +109 -0
  34. package/dist/src/providers/ids.js +3 -0
  35. package/dist/src/providers/index.js +46 -0
  36. package/dist/src/providers/ollama.js +58 -0
  37. package/dist/src/providers/openai.js +107 -0
  38. package/dist/src/providers/types.js +2 -0
  39. package/dist/src/syntax/index.js +240 -0
  40. package/dist/src/tools/index.js +100 -0
  41. package/dist/src/ui/App.js +671 -0
  42. package/dist/src/ui/Chat.js +97 -0
  43. package/dist/src/ui/ChatInput.js +128 -0
  44. package/dist/src/ui/CodeView.js +100 -0
  45. package/dist/src/ui/Editor.js +234 -0
  46. package/dist/src/ui/FileTree.js +143 -0
  47. package/dist/src/ui/Header.js +9 -0
  48. package/dist/src/ui/Pane.js +8 -0
  49. package/dist/src/ui/ProblemsView.js +71 -0
  50. package/dist/src/ui/QuickOpen.js +91 -0
  51. package/dist/src/ui/Settings.js +165 -0
  52. package/dist/src/ui/text.js +100 -0
  53. package/dist/src/ui/theme.js +41 -0
  54. package/dist/src/update/index.js +102 -0
  55. package/dist/src/util/index.js +9 -0
  56. package/package.json +69 -4
@@ -0,0 +1,94 @@
1
+ import { execFileSync } from "child_process";
2
+ import path from "path";
3
+ function git(args) {
4
+ return execFileSync("git", args, { cwd: process.cwd(), encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 20 * 1024 * 1024 });
5
+ }
6
+ function tryGit(args) {
7
+ try {
8
+ return git(args);
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
14
+ let rootCache;
15
+ export function repoRoot() {
16
+ if (rootCache === undefined)
17
+ rootCache = tryGit(["rev-parse", "--show-toplevel"])?.trim() || null;
18
+ return rootCache;
19
+ }
20
+ export const isRepo = () => repoRoot() !== null;
21
+ /** Repo-root-relative path (what git prints) -> cwd-relative path (what the UI uses). */
22
+ function fromRoot(p) {
23
+ return path.relative(process.cwd(), path.join(repoRoot(), p)).replace(/\\/g, "/");
24
+ }
25
+ function toRoot(p) {
26
+ return path.relative(repoRoot(), path.resolve(process.cwd(), p)).replace(/\\/g, "/");
27
+ }
28
+ export function status() {
29
+ const result = new Map();
30
+ if (!isRepo())
31
+ return result;
32
+ const out = tryGit(["status", "--porcelain=v1", "-z", "-uall"]);
33
+ if (!out)
34
+ return result;
35
+ const parts = out.split("\0");
36
+ for (let i = 0; i < parts.length; i++) {
37
+ const entry = parts[i];
38
+ if (entry.length < 4)
39
+ continue;
40
+ const xy = entry.slice(0, 2);
41
+ const file = fromRoot(entry.slice(3));
42
+ if (xy[0] === "R" || xy[0] === "C")
43
+ i++; // the next part is the old name
44
+ const code = xy === "??" ? "?"
45
+ : xy.includes("U") ? "U"
46
+ : xy.includes("D") ? "D"
47
+ : xy.includes("A") ? "A"
48
+ : xy[0] === "R" ? "R"
49
+ : "M";
50
+ if (!file.startsWith(".."))
51
+ result.set(file, code);
52
+ }
53
+ return result;
54
+ }
55
+ /** The file as of the last commit, or null if it isn't tracked. */
56
+ export function headContent(file) {
57
+ if (!isRepo())
58
+ return null;
59
+ return tryGit(["show", `HEAD:${toRoot(file)}`]);
60
+ }
61
+ /** Working copy vs HEAD as a FileChange, for the side-by-side diff view. */
62
+ export function diffAgainstHead(file, current) {
63
+ const before = headContent(file);
64
+ if (before === current.replace(/\r\n/g, "\n") || before === current)
65
+ return null;
66
+ return { path: file, before: before ?? "", after: current, isNew: before === null };
67
+ }
68
+ export function branch() {
69
+ return tryGit(["branch", "--show-current"])?.trim() || "(detached)";
70
+ }
71
+ export function log(count = 10) {
72
+ return tryGit(["log", `-${Math.min(Math.max(count, 1), 50)}`, "--pretty=format:%h %ad %an: %s", "--date=short"])?.trim() || "(no commits)";
73
+ }
74
+ export function diffText(file, staged = false) {
75
+ const args = ["diff", ...(staged ? ["--staged"] : ["HEAD"]), "--stat", "--patch"];
76
+ if (file)
77
+ args.push("--", toRoot(file));
78
+ const out = tryGit(args) ?? tryGit(["diff", ...(file ? ["--", toRoot(file)] : [])]) ?? "";
79
+ const untracked = file && status().get(file) === "?" ? `${file} is untracked (new file, not in git yet).` : "";
80
+ const text = (out.trim() || untracked || "No changes.");
81
+ return text.length > 60_000 ? `${text.slice(0, 60_000)}\n... (diff truncated)` : text;
82
+ }
83
+ /** A compact snapshot for the system prompt. */
84
+ export function summary() {
85
+ if (!isRepo())
86
+ return null;
87
+ const changes = [...status()].map(([file, code]) => ` ${code} ${file}`);
88
+ return `Git: use the git_status, git_diff and git_log tools (they need no approval) rather than running git with run_command.
89
+ Branch: ${branch()}
90
+ Uncommitted changes (at session start):
91
+ ${changes.length ? changes.slice(0, 40).join("\n") + (changes.length > 40 ? `\n ... ${changes.length - 40} more` : "") : " (clean)"}
92
+ Recent commits:
93
+ ${log(5).split("\n").map((l) => ` ${l}`).join("\n")}`;
94
+ }
@@ -0,0 +1,52 @@
1
+ import { branch, diffText, isRepo, log, status } from "./index.js";
2
+ /** Read-only git tools for the model. */
3
+ export function gitTools() {
4
+ if (!isRepo())
5
+ return [];
6
+ return [
7
+ {
8
+ source: "git",
9
+ readOnly: true,
10
+ definition: {
11
+ name: "git_status",
12
+ description: "Show the current git branch and uncommitted changes (M modified, A added, D deleted, ? untracked).",
13
+ parameters: { type: "object", properties: {}, required: [] },
14
+ },
15
+ run: () => {
16
+ const changes = [...status()].map(([file, code]) => `${code} ${file}`);
17
+ return `Branch: ${branch()}\n${changes.join("\n") || "Working tree clean."}`;
18
+ },
19
+ },
20
+ {
21
+ source: "git",
22
+ readOnly: true,
23
+ definition: {
24
+ name: "git_diff",
25
+ description: "Show uncommitted changes as a unified diff against HEAD, for one file or the whole repo.",
26
+ parameters: {
27
+ type: "object",
28
+ properties: {
29
+ path: { type: "string", description: "Optional file to diff. Omit for all changes." },
30
+ staged: { type: "boolean", description: "Only show staged changes." },
31
+ },
32
+ required: [],
33
+ },
34
+ },
35
+ run: (args) => diffText(typeof args.path === "string" ? args.path : undefined, args.staged === true),
36
+ },
37
+ {
38
+ source: "git",
39
+ readOnly: true,
40
+ definition: {
41
+ name: "git_log",
42
+ description: "Show recent commits (hash, date, author, message).",
43
+ parameters: {
44
+ type: "object",
45
+ properties: { count: { type: "number", description: "How many commits, default 10, max 50." } },
46
+ required: [],
47
+ },
48
+ },
49
+ run: (args) => log(typeof args.count === "number" ? args.count : 10),
50
+ },
51
+ ];
52
+ }
@@ -0,0 +1,113 @@
1
+ import { createHash } from "crypto";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
3
+ import path from "path";
4
+ import { applyPatch, structuredPatch } from "diff";
5
+ import { NOU_HOME } from "../config/index.js";
6
+ const MAX_CHECKPOINTS = 300;
7
+ const MAX_FILE_BYTES = 1_000_000;
8
+ function readOrNull(file) {
9
+ try {
10
+ return readFileSync(path.resolve(process.cwd(), file), "utf-8");
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ /** Every change made during and across sessions, so edits can be undone safely. */
17
+ export class Timeline {
18
+ onUpdate;
19
+ items = [];
20
+ nextId = 1;
21
+ file;
22
+ constructor(onUpdate = () => { }) {
23
+ this.onUpdate = onUpdate;
24
+ const key = createHash("sha1").update(process.cwd()).digest("hex").slice(0, 16);
25
+ this.file = path.join(NOU_HOME, "history", `${key}.json`);
26
+ try {
27
+ const saved = JSON.parse(readFileSync(this.file, "utf-8"));
28
+ this.items = Array.isArray(saved.items) ? saved.items : [];
29
+ this.nextId = (this.items.at(-1)?.id ?? 0) + 1;
30
+ }
31
+ catch {
32
+ // first run in this project
33
+ }
34
+ }
35
+ get all() {
36
+ return this.items;
37
+ }
38
+ record(entry) {
39
+ if (entry.before === entry.after)
40
+ return null;
41
+ if ((entry.before?.length ?? 0) > MAX_FILE_BYTES || (entry.after?.length ?? 0) > MAX_FILE_BYTES)
42
+ return null;
43
+ const checkpoint = { ...entry, id: this.nextId++, time: Date.now() };
44
+ this.items.push(checkpoint);
45
+ if (this.items.length > MAX_CHECKPOINTS)
46
+ this.items.splice(0, this.items.length - MAX_CHECKPOINTS);
47
+ this.save();
48
+ return checkpoint;
49
+ }
50
+ /** Nou's most recent change that hasn't been undone. */
51
+ lastNouChange() {
52
+ return [...this.items].reverse().find((c) => c.source === "nou" && !c.undone);
53
+ }
54
+ /** All of Nou's live changes from its most recent turn, newest first. */
55
+ lastTurn() {
56
+ const last = this.lastNouChange();
57
+ if (!last)
58
+ return [];
59
+ return this.items.filter((c) => c.source === "nou" && !c.undone && c.turn === last.turn).reverse();
60
+ }
61
+ /**
62
+ * Reverts one checkpoint. If the file changed since, only Nou's part is reversed (patch merge);
63
+ * if that conflicts, nothing is written.
64
+ */
65
+ undo(checkpoint) {
66
+ const full = path.resolve(process.cwd(), checkpoint.path);
67
+ const current = readOrNull(checkpoint.path);
68
+ let target;
69
+ let merged = false;
70
+ if (current === checkpoint.after) {
71
+ target = checkpoint.before;
72
+ }
73
+ else if (current !== null && checkpoint.after !== null && checkpoint.before !== null) {
74
+ const eol = current.includes("\r\n") ? "\r\n" : "\n";
75
+ const reverse = structuredPatch("f", "f", checkpoint.after.replace(/\r\n/g, "\n"), checkpoint.before.replace(/\r\n/g, "\n"));
76
+ const result = applyPatch(current.replace(/\r\n/g, "\n"), reverse, { fuzzFactor: 2 });
77
+ if (result === false) {
78
+ return { ok: false, path: checkpoint.path, reason: "the file changed since in ways that overlap Nou's edit" };
79
+ }
80
+ target = result.replace(/\n/g, eol);
81
+ merged = true;
82
+ }
83
+ else {
84
+ return { ok: false, path: checkpoint.path, reason: "the file was created, removed or replaced since" };
85
+ }
86
+ try {
87
+ if (target === null)
88
+ rmSync(full, { force: true });
89
+ else {
90
+ mkdirSync(path.dirname(full), { recursive: true });
91
+ writeFileSync(full, target, "utf-8");
92
+ }
93
+ }
94
+ catch (error) {
95
+ return { ok: false, path: checkpoint.path, reason: error.message };
96
+ }
97
+ checkpoint.undone = true;
98
+ this.record({ path: checkpoint.path, before: current, after: target, source: "you", turn: checkpoint.turn });
99
+ this.save();
100
+ return { ok: true, path: checkpoint.path, merged };
101
+ }
102
+ save() {
103
+ try {
104
+ if (!existsSync(path.dirname(this.file)))
105
+ mkdirSync(path.dirname(this.file), { recursive: true });
106
+ writeFileSync(this.file, JSON.stringify({ cwd: process.cwd(), items: this.items }));
107
+ }
108
+ catch {
109
+ // history is best effort
110
+ }
111
+ this.onUpdate();
112
+ }
113
+ }
@@ -0,0 +1,169 @@
1
+ import { spawn } from "child_process";
2
+ import path from "path";
3
+ import { fileURLToPath, pathToFileURL } from "url";
4
+ export const toUri = (file) => pathToFileURL(path.resolve(process.cwd(), file)).href;
5
+ /** file:// URI -> path relative to the project, with forward slashes. */
6
+ export function fromUri(uri) {
7
+ try {
8
+ return path.relative(process.cwd(), fileURLToPath(uri)).replace(/\\/g, "/");
9
+ }
10
+ catch {
11
+ return uri;
12
+ }
13
+ }
14
+ /** A minimal Language Server Protocol client over stdio (JSON-RPC with Content-Length framing). */
15
+ export class LspClient {
16
+ name;
17
+ child;
18
+ buffer = Buffer.alloc(0);
19
+ nextId = 1;
20
+ pending = new Map();
21
+ handlers = new Map();
22
+ versions = new Map();
23
+ texts = new Map();
24
+ ready;
25
+ dead = false;
26
+ /** Why the server stopped, if it did (shown in settings). */
27
+ error = null;
28
+ constructor(name, command, args, languageIds) {
29
+ this.name = name;
30
+ // npm-installed servers are .cmd shims on Windows, which need a shell. The command line is ours, not user text.
31
+ const shell = process.platform === "win32" && !/\.exe$/i.test(command);
32
+ // With a shell, quote the path so folders with spaces (C:\Program Files\...) work.
33
+ this.child = spawn(shell ? `"${command}"` : command, args, { cwd: process.cwd(), shell, windowsHide: true, stdio: ["pipe", "pipe", "ignore"] });
34
+ this.child.on("exit", () => this.fail(new Error(`${name} exited`)));
35
+ this.child.on("error", (error) => this.fail(error));
36
+ this.child.stdout.on("data", (chunk) => this.onData(chunk));
37
+ this.languageIds = languageIds;
38
+ this.ready = this.request("initialize", {
39
+ processId: process.pid,
40
+ rootUri: pathToFileURL(process.cwd()).href,
41
+ workspaceFolders: [{ uri: pathToFileURL(process.cwd()).href, name: path.basename(process.cwd()) }],
42
+ capabilities: {
43
+ textDocument: {
44
+ synchronization: { didSave: true },
45
+ publishDiagnostics: { relatedInformation: false },
46
+ hover: { contentFormat: ["plaintext", "markdown"] },
47
+ definition: { linkSupport: false },
48
+ },
49
+ workspace: { workspaceFolders: true, configuration: true },
50
+ },
51
+ }, 30_000).then(() => this.notify("initialized", {}));
52
+ // A server that can't start shouldn't take Nou down; mark it dead and stop it.
53
+ this.ready.catch((error) => {
54
+ this.error = error.message;
55
+ this.fail(error);
56
+ this.child.kill();
57
+ });
58
+ // Servers ask for settings; answering with nulls means "use your defaults".
59
+ this.onRequest("workspace/configuration", (params) => (params?.items ?? []).map(() => null));
60
+ this.onRequest("client/registerCapability", () => null);
61
+ this.onRequest("window/workDoneProgress/create", () => null);
62
+ }
63
+ languageIds;
64
+ requestHandlers = new Map();
65
+ onRequest(method, handler) {
66
+ this.requestHandlers.set(method, handler);
67
+ }
68
+ on(method, handler) {
69
+ this.handlers.set(method, handler);
70
+ }
71
+ fail(error) {
72
+ if (this.dead)
73
+ return;
74
+ this.dead = true;
75
+ this.error ??= error.message;
76
+ for (const p of this.pending.values()) {
77
+ clearTimeout(p.timer);
78
+ p.reject(error);
79
+ }
80
+ this.pending.clear();
81
+ }
82
+ send(message) {
83
+ if (this.dead)
84
+ return;
85
+ const body = Buffer.from(JSON.stringify({ jsonrpc: "2.0", ...message }), "utf-8");
86
+ this.child.stdin.write(`Content-Length: ${body.length}\r\n\r\n`);
87
+ this.child.stdin.write(body);
88
+ }
89
+ onData(chunk) {
90
+ this.buffer = Buffer.concat([this.buffer, chunk]);
91
+ for (;;) {
92
+ const headerEnd = this.buffer.indexOf("\r\n\r\n");
93
+ if (headerEnd < 0)
94
+ return;
95
+ const length = Number(/Content-Length: (\d+)/i.exec(this.buffer.subarray(0, headerEnd).toString())?.[1]);
96
+ if (!length || this.buffer.length < headerEnd + 4 + length)
97
+ return;
98
+ const body = this.buffer.subarray(headerEnd + 4, headerEnd + 4 + length).toString("utf-8");
99
+ this.buffer = this.buffer.subarray(headerEnd + 4 + length);
100
+ try {
101
+ this.dispatch(JSON.parse(body));
102
+ }
103
+ catch {
104
+ // ignore malformed messages
105
+ }
106
+ }
107
+ }
108
+ dispatch(message) {
109
+ if (message.id !== undefined && message.method) {
110
+ // A request from the server.
111
+ const handler = this.requestHandlers.get(message.method);
112
+ this.send({ id: message.id, result: handler ? handler(message.params) : null });
113
+ }
114
+ else if (message.id !== undefined) {
115
+ const pending = this.pending.get(message.id);
116
+ if (!pending)
117
+ return;
118
+ this.pending.delete(message.id);
119
+ clearTimeout(pending.timer);
120
+ if (message.error)
121
+ pending.reject(new Error(message.error.message));
122
+ else
123
+ pending.resolve(message.result);
124
+ }
125
+ else if (message.method) {
126
+ this.handlers.get(message.method)?.(message.params);
127
+ }
128
+ }
129
+ request(method, params, timeout = 15_000) {
130
+ if (this.dead)
131
+ return Promise.reject(new Error(`${this.name} is not running`));
132
+ const id = this.nextId++;
133
+ return new Promise((resolve, reject) => {
134
+ const timer = setTimeout(() => {
135
+ this.pending.delete(id);
136
+ reject(new Error(`${this.name}: ${method} timed out`));
137
+ }, timeout);
138
+ this.pending.set(id, { resolve, reject, timer });
139
+ this.send({ id, method, params });
140
+ });
141
+ }
142
+ notify(method, params) {
143
+ this.send({ method, params });
144
+ }
145
+ /** Opens or updates a document with the given (possibly unsaved) text. Returns false if it was already current. */
146
+ async sync(file, text) {
147
+ await this.ready;
148
+ const uri = toUri(file);
149
+ if (this.texts.get(uri) === text)
150
+ return false;
151
+ const version = (this.versions.get(uri) ?? 0) + 1;
152
+ this.versions.set(uri, version);
153
+ if (!this.texts.has(uri)) {
154
+ const languageId = this.languageIds[path.extname(file).toLowerCase()] ?? "plaintext";
155
+ this.notify("textDocument/didOpen", { textDocument: { uri, languageId, version, text } });
156
+ }
157
+ else {
158
+ this.notify("textDocument/didChange", { textDocument: { uri, version }, contentChanges: [{ text }] });
159
+ }
160
+ this.texts.set(uri, text);
161
+ return true;
162
+ }
163
+ stop() {
164
+ if (this.dead)
165
+ return;
166
+ this.request("shutdown", null, 2000).then(() => this.notify("exit", null)).catch(() => { }).finally(() => this.child.kill());
167
+ this.dead = true;
168
+ }
169
+ }
@@ -0,0 +1,169 @@
1
+ import { existsSync } from "fs";
2
+ import path from "path";
3
+ import { LspClient, fromUri, toUri } from "./client.js";
4
+ /** Servers Nou knows how to talk to. Each is used only if it's installed. Users can add more in settings. */
5
+ export const KNOWN_SERVERS = [
6
+ {
7
+ id: "typescript", name: "TypeScript", command: "typescript-language-server", args: ["--stdio"],
8
+ languages: { ".ts": "typescript", ".mts": "typescript", ".cts": "typescript", ".tsx": "typescriptreact", ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".jsx": "javascriptreact" },
9
+ },
10
+ { id: "pyright", name: "Pyright", command: "pyright-langserver", args: ["--stdio"], languages: { ".py": "python", ".pyi": "python" } },
11
+ { id: "rust-analyzer", name: "rust-analyzer", command: "rust-analyzer", args: [], languages: { ".rs": "rust" } },
12
+ { id: "gopls", name: "gopls", command: "gopls", args: [], languages: { ".go": "go" } },
13
+ { id: "clangd", name: "clangd", command: "clangd", args: [], languages: { ".c": "c", ".h": "c", ".cpp": "cpp", ".cc": "cpp", ".hpp": "cpp" } },
14
+ ];
15
+ /** Finds a program on PATH (or in the project's node_modules/.bin), on Windows, macOS and Linux. */
16
+ export function findExecutable(command) {
17
+ const dirs = [path.join(process.cwd(), "node_modules", ".bin"), ...(process.env.PATH ?? "").split(path.delimiter)].filter(Boolean);
18
+ const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").map((e) => e.toLowerCase()) : [""];
19
+ for (const dir of dirs) {
20
+ for (const ext of exts) {
21
+ const full = path.join(dir, command + ext);
22
+ if (existsSync(full))
23
+ return full;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+ const SEVERITY = { 1: "error", 2: "warning", 3: "info", 4: "info" };
29
+ /** Starts language servers on demand and exposes problems, hover and go-to-definition. */
30
+ export class LspManager {
31
+ servers;
32
+ clients = new Map();
33
+ failures = new Map();
34
+ /** Servers that failed to start, with the reason. */
35
+ broken = new Map();
36
+ /** file (normalized, project-relative) -> latest published problems. Servers format URIs differently (e.g. c%3A), so never key by raw URI. */
37
+ published = new Map();
38
+ waiters = new Map();
39
+ constructor(servers = KNOWN_SERVERS) {
40
+ this.servers = servers;
41
+ }
42
+ serverFor(file) {
43
+ const ext = path.extname(file).toLowerCase();
44
+ return this.servers.find((s) => s.languages[ext] && findExecutable(s.command));
45
+ }
46
+ /** Installed servers, for the settings screen. */
47
+ installed() {
48
+ return this.servers.filter((s) => findExecutable(s.command));
49
+ }
50
+ client(file) {
51
+ const server = this.serverFor(file);
52
+ if (!server)
53
+ return null;
54
+ const existing = this.clients.get(server.id);
55
+ if (existing && !existing.dead)
56
+ return existing;
57
+ if (this.broken.has(server.id) || (this.failures.get(server.id) ?? 0) >= 3)
58
+ return null; // stop retrying
59
+ if (existing?.dead)
60
+ this.failures.set(server.id, (this.failures.get(server.id) ?? 0) + 1);
61
+ const client = new LspClient(server.name, findExecutable(server.command), server.args, server.languages);
62
+ client.on("textDocument/publishDiagnostics", (params) => {
63
+ const file = fromUri(params.uri);
64
+ this.published.set(file.toLowerCase(), (params.diagnostics ?? []).map((d) => ({
65
+ file,
66
+ line: d.range.start.line + 1,
67
+ col: d.range.start.character + 1,
68
+ endLine: d.range.end.line + 1,
69
+ endCol: d.range.end.character + 1,
70
+ severity: SEVERITY[d.severity ?? 1] ?? "error",
71
+ message: String(d.message).split("\n")[0],
72
+ source: server.id,
73
+ ...(d.code !== undefined ? { code: String(d.code) } : {}),
74
+ })));
75
+ for (const wake of this.waiters.get(file.toLowerCase()) ?? [])
76
+ wake();
77
+ this.waiters.delete(file.toLowerCase());
78
+ });
79
+ // If it can't even start (e.g. incompatible TypeScript version), give up on it for this session.
80
+ client.ready.catch((error) => this.broken.set(server.id, error.message.split(". ")[0]));
81
+ this.clients.set(server.id, client);
82
+ return client;
83
+ }
84
+ /** Sends the text and waits for the server's problems (it publishes them asynchronously). */
85
+ async diagnostics(file, text, timeout = 8000) {
86
+ const client = this.client(file);
87
+ if (!client)
88
+ return [];
89
+ const key = fromUri(toUri(file)).toLowerCase();
90
+ let wake;
91
+ const published = new Promise((resolve) => {
92
+ wake = resolve;
93
+ this.waiters.set(key, [...(this.waiters.get(key) ?? []), resolve]);
94
+ });
95
+ const stopWaiting = () => {
96
+ const left = (this.waiters.get(key) ?? []).filter((w) => w !== wake);
97
+ if (left.length)
98
+ this.waiters.set(key, left);
99
+ else
100
+ this.waiters.delete(key);
101
+ };
102
+ let changed;
103
+ try {
104
+ changed = await client.sync(file, text);
105
+ }
106
+ catch {
107
+ stopWaiting();
108
+ return [];
109
+ }
110
+ // Same text as last time: the server won't publish again, so its last results are current.
111
+ if (!changed && this.published.has(key)) {
112
+ stopWaiting();
113
+ return this.published.get(key);
114
+ }
115
+ // Servers often publish twice (syntax, then semantic); wait for the first, then a short quiet period.
116
+ let timer;
117
+ await Promise.race([published, new Promise((r) => (timer = setTimeout(r, timeout)))]);
118
+ clearTimeout(timer);
119
+ stopWaiting();
120
+ await new Promise((r) => setTimeout(r, 400));
121
+ return this.published.get(key) ?? [];
122
+ }
123
+ async hover(file, text, line, col) {
124
+ const client = this.client(file);
125
+ if (!client)
126
+ return null;
127
+ if (!(await client.sync(file, text).then(() => true, () => false)))
128
+ return null;
129
+ const result = await client.request("textDocument/hover", { textDocument: { uri: toUri(file) }, position: { line: line - 1, character: col - 1 } }).catch(() => null);
130
+ const contents = result?.contents;
131
+ if (!contents)
132
+ return null;
133
+ const parts = Array.isArray(contents) ? contents : [contents];
134
+ const textOut = parts.map((p) => (typeof p === "string" ? p : p.value ?? "")).join("\n");
135
+ return textOut.replace(/```\w*\n?/g, "").trim() || null;
136
+ }
137
+ async definition(file, text, line, col) {
138
+ const client = this.client(file);
139
+ if (!client)
140
+ return null;
141
+ if (!(await client.sync(file, text).then(() => true, () => false)))
142
+ return null;
143
+ const result = await client.request("textDocument/definition", { textDocument: { uri: toUri(file) }, position: { line: line - 1, character: col - 1 } }).catch(() => null);
144
+ const first = Array.isArray(result) ? result[0] : result;
145
+ if (!first?.uri && !first?.targetUri)
146
+ return null;
147
+ const uri = first.uri ?? first.targetUri;
148
+ const range = first.range ?? first.targetSelectionRange ?? first.targetRange;
149
+ return { file: fromUri(uri), line: range.start.line + 1, col: range.start.character + 1 };
150
+ }
151
+ stopAll() {
152
+ for (const c of this.clients.values())
153
+ c.stop();
154
+ this.clients.clear();
155
+ }
156
+ /** Problems from language servers, through the same interface as linters. */
157
+ provider() {
158
+ return {
159
+ id: "lsp",
160
+ name: "Language servers",
161
+ checksText: true,
162
+ matches: (file) => this.serverFor(file) !== undefined,
163
+ available: () => this.installed().length > 0,
164
+ check: (file, text) => this.diagnostics(file, text),
165
+ };
166
+ }
167
+ }
168
+ /** One set of language servers for the whole app. */
169
+ export const lsp = new LspManager();