lsp-mcp: headless-editor MCP server over a language server
Effect 4 MCP server (stdio/HTTP) bridging one LSP process: on-demand document sync with LRU and disk change detection, push/pull diagnostics reconciliation, capability-gated tools, diagnostics resources, tests against a fixture server.
This commit is contained in:
18 files changed
+3611
-38
No files matched your search
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Diagnostics reconcile LSP's push model with the agent's pull model.
|
||||
*
|
||||
* Published diagnostics are accumulated per URI. Asking for a document's
|
||||
* diagnostics either pulls them (when the server supports
|
||||
* `textDocument/diagnostic`) or waits for the push that follows the document's
|
||||
* latest version to settle, bounded by a timeout.
|
||||
*/
|
||||
import {
|
||||
Context,
|
||||
Effect,
|
||||
HashMap,
|
||||
Layer,
|
||||
Option,
|
||||
PubSub,
|
||||
Ref,
|
||||
Stream,
|
||||
} from "effect";
|
||||
import {
|
||||
type Diagnostic,
|
||||
DocumentDiagnosticReportKind,
|
||||
DocumentDiagnosticRequest,
|
||||
PublishDiagnosticsNotification,
|
||||
type PublishDiagnosticsParams,
|
||||
WorkspaceDiagnosticRequest,
|
||||
} from "vscode-languageserver-protocol";
|
||||
import type { TextDocument } from "vscode-languageserver-textdocument";
|
||||
import { LanguageServer, type LspError } from "./LanguageServer.ts";
|
||||
|
||||
/** Quiet period after a publish before diagnostics count as settled. */
|
||||
const settle = "250 millis";
|
||||
/** How long to wait for a server that never publishes for a change. */
|
||||
const patience = "5 seconds";
|
||||
|
||||
const make = Effect.gen(function* () {
|
||||
const lsp = yield* LanguageServer;
|
||||
const state = yield* Ref.make(
|
||||
HashMap.empty<string, PublishDiagnosticsParams>(),
|
||||
);
|
||||
const updates = yield* PubSub.unbounded<string>();
|
||||
const settled = new Map<string, number>();
|
||||
const provider = lsp.capabilities.diagnosticProvider;
|
||||
|
||||
const publish = (params: PublishDiagnosticsParams) =>
|
||||
Ref.update(state, HashMap.set(params.uri, params)).pipe(
|
||||
Effect.andThen(PubSub.publish(updates, params.uri)),
|
||||
);
|
||||
yield* lsp
|
||||
.notifications(PublishDiagnosticsNotification.type)
|
||||
.pipe(Stream.runForEach(publish), Effect.forkScoped);
|
||||
|
||||
const current = (uri: string) =>
|
||||
Ref.get(state).pipe(
|
||||
Effect.map((map) => Option.getOrUndefined(HashMap.get(map, uri))),
|
||||
);
|
||||
|
||||
const pull = Effect.fn("Diagnostics.pull")(function* (doc: TextDocument) {
|
||||
const report = yield* lsp.request(DocumentDiagnosticRequest.type, {
|
||||
textDocument: { uri: doc.uri },
|
||||
});
|
||||
if (report.kind === DocumentDiagnosticReportKind.Full) {
|
||||
yield* publish({
|
||||
uri: doc.uri,
|
||||
version: doc.version,
|
||||
diagnostics: report.items,
|
||||
});
|
||||
}
|
||||
return (yield* current(doc.uri))?.diagnostics ?? [];
|
||||
});
|
||||
|
||||
const awaitPush = Effect.fn("Diagnostics.awaitPush")(function* (
|
||||
doc: TextDocument,
|
||||
) {
|
||||
if (settled.get(doc.uri) !== doc.version) {
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const subscription = yield* PubSub.subscribe(updates);
|
||||
const fresh = (yield* current(doc.uri))?.version === doc.version;
|
||||
yield* Stream.fromSubscription(subscription).pipe(
|
||||
Stream.filter((uri) => uri === doc.uri),
|
||||
Stream.debounce(settle),
|
||||
Stream.runHead,
|
||||
Effect.timeoutOption(fresh ? settle : patience),
|
||||
);
|
||||
}),
|
||||
);
|
||||
settled.set(doc.uri, doc.version);
|
||||
}
|
||||
return (yield* current(doc.uri))?.diagnostics ?? [];
|
||||
});
|
||||
|
||||
const all = Effect.gen(function* () {
|
||||
if (provider?.workspaceDiagnostics) {
|
||||
const report = yield* lsp.request(WorkspaceDiagnosticRequest.type, {
|
||||
previousResultIds: [],
|
||||
});
|
||||
for (const item of report.items) {
|
||||
if (item.kind === DocumentDiagnosticReportKind.Full) {
|
||||
yield* publish({
|
||||
uri: item.uri,
|
||||
diagnostics: item.items,
|
||||
...(item.version === null ? {} : { version: item.version }),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(HashMap.values(yield* Ref.get(state)));
|
||||
});
|
||||
|
||||
return Diagnostics.of({
|
||||
all,
|
||||
forDocument: provider ? pull : awaitPush,
|
||||
changes: Stream.fromPubSub(updates),
|
||||
});
|
||||
});
|
||||
|
||||
export class Diagnostics extends Context.Service<
|
||||
Diagnostics,
|
||||
{
|
||||
/** Everything the server has reported, per URI. */
|
||||
readonly all: Effect.Effect<
|
||||
ReadonlyArray<PublishDiagnosticsParams>,
|
||||
LspError
|
||||
>;
|
||||
/** Diagnostics matching the document's current version. */
|
||||
readonly forDocument: (
|
||||
doc: TextDocument,
|
||||
) => Effect.Effect<ReadonlyArray<Diagnostic>, LspError>;
|
||||
/** URIs whose diagnostics changed. */
|
||||
readonly changes: Stream.Stream<string>;
|
||||
}
|
||||
>()("lsp-mcp/lsp/Diagnostics") {
|
||||
static readonly layer = Layer.effect(Diagnostics, make);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* The headless editor's document store.
|
||||
*
|
||||
* Documents are opened in the language server on demand and kept in an LRU
|
||||
* set; the least recently used one is closed when the capacity is exceeded.
|
||||
* Every access re-checks the file on disk and, if it changed, pushes the new
|
||||
* content as `didChange` + `didSave`, so edits agents make with other tools
|
||||
* are visible to the server without any explicit synchronization step.
|
||||
*/
|
||||
import {
|
||||
Context,
|
||||
Duration,
|
||||
Effect,
|
||||
Exit,
|
||||
FileSystem,
|
||||
Layer,
|
||||
Option,
|
||||
Path,
|
||||
type PlatformError,
|
||||
Schema,
|
||||
ScopedCache,
|
||||
Semaphore,
|
||||
} from "effect";
|
||||
import {
|
||||
ApplyWorkspaceEditRequest,
|
||||
CreateFile,
|
||||
DeleteFile,
|
||||
DidChangeTextDocumentNotification,
|
||||
DidCloseTextDocumentNotification,
|
||||
DidOpenTextDocumentNotification,
|
||||
DidSaveTextDocumentNotification,
|
||||
RenameFile,
|
||||
type TextDocumentEdit,
|
||||
type TextEdit,
|
||||
type WorkspaceEdit,
|
||||
} from "vscode-languageserver-protocol";
|
||||
import { TextDocument } from "vscode-languageserver-textdocument";
|
||||
import { URI } from "vscode-uri";
|
||||
import { LanguageServer } from "./LanguageServer.ts";
|
||||
|
||||
export interface Options {
|
||||
/** How many documents stay open in the server before the least recently used is closed. */
|
||||
readonly capacity: number;
|
||||
/** Extra file extension (without dot) to LSP `languageId` mappings. */
|
||||
readonly languages: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export class DocumentError extends Schema.TaggedError<DocumentError>()(
|
||||
"DocumentError",
|
||||
{
|
||||
file: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export type Change = TextDocumentEdit | CreateFile | RenameFile | DeleteFile;
|
||||
|
||||
export class Documents extends Context.Service<
|
||||
Documents,
|
||||
{
|
||||
/** URI for a path that is absolute or relative to the workspace root. */
|
||||
readonly uri: (file: string) => string;
|
||||
/** Display path for a URI: relative to the root when inside it. */
|
||||
readonly file: (uri: string) => string;
|
||||
/** Open (or refresh) a document in the server and return its current text. */
|
||||
readonly open: (file: string) => Effect.Effect<TextDocument, DocumentError>;
|
||||
/** Current content of a URI without opening it in the server. */
|
||||
readonly peek: (uri: string) => Effect.Effect<TextDocument, DocumentError>;
|
||||
/** Replace an open document's content on disk and in the server. */
|
||||
readonly write: (
|
||||
uri: string,
|
||||
text: string,
|
||||
) => Effect.Effect<void, DocumentError>;
|
||||
/** Apply a server-provided edit to disk, in order, refusing stale versions. */
|
||||
readonly applyEdit: (
|
||||
edit: WorkspaceEdit,
|
||||
) => Effect.Effect<void, DocumentError>;
|
||||
readonly opened: Effect.Effect<ReadonlyArray<string>>;
|
||||
}
|
||||
>()("lsp-mcp/lsp/Documents") {
|
||||
static readonly layer = (options: Options) =>
|
||||
Layer.effect(Documents, make(options));
|
||||
}
|
||||
|
||||
/** The ordered list of changes described by a `WorkspaceEdit`. */
|
||||
export const changes = (edit: WorkspaceEdit): ReadonlyArray<Change> =>
|
||||
edit.documentChanges ??
|
||||
Object.entries(edit.changes ?? {}).map(([uri, edits]) => ({
|
||||
textDocument: { uri, version: null },
|
||||
edits,
|
||||
}));
|
||||
|
||||
const textEdits = (edit: TextDocumentEdit): Array<TextEdit> =>
|
||||
edit.edits.filter((e): e is TextEdit => "newText" in e);
|
||||
|
||||
interface Entry {
|
||||
readonly doc: TextDocument;
|
||||
stamp: string;
|
||||
}
|
||||
|
||||
const make = Effect.fn("Documents.make")(function* (options: Options) {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const path = yield* Path.Path;
|
||||
const lsp = yield* LanguageServer;
|
||||
const lock = yield* Semaphore.make(1);
|
||||
const sync = lsp.capabilities.textDocumentSync;
|
||||
const save = typeof sync === "object" ? sync.save : undefined;
|
||||
|
||||
const uri = (file: string) =>
|
||||
URI.file(path.resolve(lsp.root, file)).toString();
|
||||
const fsPath = (uri: string) => URI.parse(uri).fsPath;
|
||||
const file = (uri: string) => {
|
||||
const parsed = URI.parse(uri);
|
||||
if (parsed.scheme !== "file") return uri;
|
||||
const relative = path.relative(lsp.root, parsed.fsPath);
|
||||
return relative.startsWith("..") || path.isAbsolute(relative)
|
||||
? parsed.fsPath
|
||||
: relative;
|
||||
};
|
||||
const io = (uri: string) => (error: PlatformError.PlatformError) =>
|
||||
new DocumentError({ file: file(uri), message: error.message });
|
||||
const read = (uri: string) =>
|
||||
fs.readFileString(fsPath(uri)).pipe(Effect.mapError(io(uri)));
|
||||
const stamp = (uri: string) =>
|
||||
fs.stat(fsPath(uri)).pipe(
|
||||
Effect.map(
|
||||
(info) =>
|
||||
`${Option.getOrUndefined(info.mtime)?.getTime()}:${info.size}`,
|
||||
),
|
||||
Effect.mapError(io(uri)),
|
||||
);
|
||||
|
||||
const languageId = (uri: string) => {
|
||||
const name = path.basename(fsPath(uri)).toLowerCase();
|
||||
const ext = path.extname(name).slice(1);
|
||||
return (
|
||||
options.languages[ext] ?? languages[ext] ?? languages[name] ?? "plaintext"
|
||||
);
|
||||
};
|
||||
|
||||
const cache = yield* ScopedCache.makeWith<string, Entry, DocumentError>({
|
||||
capacity: options.capacity,
|
||||
timeToLive: (exit) =>
|
||||
Exit.isFailure(exit) ? Duration.zero : Duration.infinity,
|
||||
lookup: (uri) =>
|
||||
Effect.gen(function* () {
|
||||
const [text, current] = yield* Effect.all([read(uri), stamp(uri)]);
|
||||
const doc = TextDocument.create(uri, languageId(uri), 1, text);
|
||||
yield* Effect.acquireRelease(
|
||||
lsp.notify(DidOpenTextDocumentNotification.type, {
|
||||
textDocument: {
|
||||
uri,
|
||||
languageId: doc.languageId,
|
||||
version: doc.version,
|
||||
text,
|
||||
},
|
||||
}),
|
||||
() =>
|
||||
lsp.notify(DidCloseTextDocumentNotification.type, {
|
||||
textDocument: { uri },
|
||||
}),
|
||||
);
|
||||
return { doc, stamp: current } satisfies Entry;
|
||||
}),
|
||||
});
|
||||
|
||||
const change = Effect.fn("Documents.change")(function* (
|
||||
entry: Entry,
|
||||
text: string,
|
||||
) {
|
||||
const { uri } = entry.doc;
|
||||
const version = entry.doc.version + 1;
|
||||
TextDocument.update(entry.doc, [{ text }], version);
|
||||
yield* lsp.notify(DidChangeTextDocumentNotification.type, {
|
||||
textDocument: { uri, version },
|
||||
contentChanges: [{ text }],
|
||||
});
|
||||
if (save) {
|
||||
yield* lsp.notify(DidSaveTextDocumentNotification.type, {
|
||||
textDocument: { uri },
|
||||
...(typeof save === "object" && save.includeText ? { text } : {}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** Bring an open document in line with the file on disk. */
|
||||
const refresh = Effect.fn("Documents.refresh")(function* (uri: string) {
|
||||
const entry = yield* ScopedCache.get(cache, uri);
|
||||
const current = yield* stamp(uri).pipe(
|
||||
Effect.tapError(() => ScopedCache.invalidate(cache, uri)),
|
||||
);
|
||||
if (current !== entry.stamp) {
|
||||
const text = yield* read(uri);
|
||||
entry.stamp = current;
|
||||
if (text !== entry.doc.getText()) yield* change(entry, text);
|
||||
}
|
||||
return entry.doc;
|
||||
});
|
||||
|
||||
const open = (file: string) => lock.withPermits(1)(refresh(uri(file)));
|
||||
|
||||
const peek = (uri: string) =>
|
||||
Effect.flatMap(ScopedCache.has(cache, uri), (open) =>
|
||||
open
|
||||
? lock.withPermits(1)(refresh(uri))
|
||||
: Effect.map(read(uri), (text) =>
|
||||
TextDocument.create(uri, "", 0, text),
|
||||
),
|
||||
);
|
||||
|
||||
/** Persist new content for an open document and tell the server. */
|
||||
const store = Effect.fn("Documents.store")(function* (
|
||||
uri: string,
|
||||
text: string,
|
||||
) {
|
||||
const entry = yield* ScopedCache.get(cache, uri);
|
||||
yield* fs.writeFileString(fsPath(uri), text).pipe(Effect.mapError(io(uri)));
|
||||
entry.stamp = yield* stamp(uri);
|
||||
if (text !== entry.doc.getText()) yield* change(entry, text);
|
||||
});
|
||||
|
||||
const write = (uri: string, text: string) =>
|
||||
lock.withPermits(1)(store(uri, text));
|
||||
|
||||
const forget = (uri: string) => ScopedCache.invalidate(cache, uri);
|
||||
|
||||
const apply = Effect.fn("Documents.applyChange")(function* (change: Change) {
|
||||
if (CreateFile.is(change)) {
|
||||
const target = fsPath(change.uri);
|
||||
if (
|
||||
change.options?.ignoreIfExists &&
|
||||
(yield* fs.exists(target).pipe(Effect.mapError(io(change.uri))))
|
||||
)
|
||||
return;
|
||||
yield* fs
|
||||
.makeDirectory(path.dirname(target), { recursive: true })
|
||||
.pipe(
|
||||
Effect.andThen(fs.writeFileString(target, "")),
|
||||
Effect.mapError(io(change.uri)),
|
||||
);
|
||||
} else if (RenameFile.is(change)) {
|
||||
yield* Effect.all([forget(change.oldUri), forget(change.newUri)]);
|
||||
yield* fs
|
||||
.rename(fsPath(change.oldUri), fsPath(change.newUri))
|
||||
.pipe(Effect.mapError(io(change.oldUri)));
|
||||
} else if (DeleteFile.is(change)) {
|
||||
yield* forget(change.uri);
|
||||
yield* fs
|
||||
.remove(fsPath(change.uri), {
|
||||
recursive: change.options?.recursive ?? false,
|
||||
})
|
||||
.pipe(Effect.mapError(io(change.uri)));
|
||||
} else {
|
||||
const { uri, version } = change.textDocument;
|
||||
const doc = yield* refresh(uri);
|
||||
if (
|
||||
version !== null &&
|
||||
version !== undefined &&
|
||||
version !== doc.version
|
||||
) {
|
||||
return yield* new DocumentError({
|
||||
file: file(uri),
|
||||
message: "document changed since the edit was computed",
|
||||
});
|
||||
}
|
||||
yield* store(uri, TextDocument.applyEdits(doc, textEdits(change)));
|
||||
}
|
||||
});
|
||||
|
||||
const applyEdit = (edit: WorkspaceEdit) =>
|
||||
lock.withPermits(1)(
|
||||
Effect.forEach(changes(edit), apply, { discard: true }),
|
||||
);
|
||||
|
||||
yield* lsp.onRequest(ApplyWorkspaceEditRequest.type, ({ edit }) =>
|
||||
applyEdit(edit).pipe(
|
||||
Effect.as({ applied: true }),
|
||||
Effect.catch((error) =>
|
||||
Effect.succeed({ applied: false, failureReason: error.message }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Documents.of({
|
||||
uri,
|
||||
file,
|
||||
open,
|
||||
peek,
|
||||
write,
|
||||
applyEdit,
|
||||
opened: ScopedCache.keys(cache),
|
||||
});
|
||||
});
|
||||
|
||||
const languages: Record<string, string> = {
|
||||
ts: "typescript",
|
||||
mts: "typescript",
|
||||
cts: "typescript",
|
||||
tsx: "typescriptreact",
|
||||
js: "javascript",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
jsx: "javascriptreact",
|
||||
json: "json",
|
||||
jsonc: "jsonc",
|
||||
py: "python",
|
||||
pyi: "python",
|
||||
rs: "rust",
|
||||
go: "go",
|
||||
c: "c",
|
||||
h: "c",
|
||||
cc: "cpp",
|
||||
cpp: "cpp",
|
||||
cxx: "cpp",
|
||||
hpp: "cpp",
|
||||
hh: "cpp",
|
||||
java: "java",
|
||||
kt: "kotlin",
|
||||
kts: "kotlin",
|
||||
scala: "scala",
|
||||
cs: "csharp",
|
||||
fs: "fsharp",
|
||||
swift: "swift",
|
||||
rb: "ruby",
|
||||
php: "php",
|
||||
lua: "lua",
|
||||
zig: "zig",
|
||||
hs: "haskell",
|
||||
ml: "ocaml",
|
||||
mli: "ocaml",
|
||||
ex: "elixir",
|
||||
exs: "elixir",
|
||||
erl: "erlang",
|
||||
clj: "clojure",
|
||||
cljs: "clojurescript",
|
||||
dart: "dart",
|
||||
elm: "elm",
|
||||
nix: "nix",
|
||||
r: "r",
|
||||
jl: "julia",
|
||||
vue: "vue",
|
||||
svelte: "svelte",
|
||||
astro: "astro",
|
||||
html: "html",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
less: "less",
|
||||
md: "markdown",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
xml: "xml",
|
||||
sql: "sql",
|
||||
sh: "shellscript",
|
||||
bash: "shellscript",
|
||||
zsh: "shellscript",
|
||||
fish: "fish",
|
||||
tf: "terraform",
|
||||
proto: "proto",
|
||||
graphql: "graphql",
|
||||
gql: "graphql",
|
||||
tex: "latex",
|
||||
vim: "vim",
|
||||
dockerfile: "dockerfile",
|
||||
makefile: "makefile",
|
||||
};
|
||||
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* The operations an agent performs on the headless editor.
|
||||
*
|
||||
* Each function resolves agent-facing input (paths, 1-based positions,
|
||||
* symbols on a line) to LSP requests and presents the results in the
|
||||
* `Model` vocabulary. These functions are the MCP tool handlers.
|
||||
*/
|
||||
import { Effect, Option } from "effect";
|
||||
import {
|
||||
CallHierarchyIncomingCallsRequest,
|
||||
CallHierarchyOutgoingCallsRequest,
|
||||
CallHierarchyPrepareRequest,
|
||||
CodeAction,
|
||||
CodeActionRequest,
|
||||
CodeActionResolveRequest,
|
||||
CodeActionTriggerKind,
|
||||
type Command,
|
||||
CompletionItemKind,
|
||||
CompletionRequest,
|
||||
CompletionTriggerKind,
|
||||
DeclarationRequest,
|
||||
DefinitionRequest,
|
||||
type Diagnostic,
|
||||
DocumentFormattingRequest,
|
||||
type DocumentSymbol,
|
||||
DocumentSymbolRequest,
|
||||
ExecuteCommandRequest,
|
||||
type Hover,
|
||||
HoverRequest,
|
||||
ImplementationRequest,
|
||||
type Location,
|
||||
type LocationLink,
|
||||
type MarkupContent,
|
||||
type Position,
|
||||
type Range,
|
||||
ReferencesRequest,
|
||||
RenameRequest,
|
||||
type ServerCapabilities,
|
||||
SignatureHelpRequest,
|
||||
SymbolKind,
|
||||
TextDocumentEdit,
|
||||
TypeDefinitionRequest,
|
||||
type WorkspaceEdit,
|
||||
WorkspaceSymbolRequest,
|
||||
} from "vscode-languageserver-protocol";
|
||||
import { TextDocument } from "vscode-languageserver-textdocument";
|
||||
import type * as Model from "../Model.ts";
|
||||
import { Diagnostics } from "./Diagnostics.ts";
|
||||
import { changes, Documents } from "./Documents.ts";
|
||||
import { LanguageServer, LspError } from "./LanguageServer.ts";
|
||||
|
||||
// --- Presentation ----------------------------------------------------------
|
||||
|
||||
const range = (r: Range): Model.Range => ({
|
||||
line: r.start.line + 1,
|
||||
column: r.start.character + 1,
|
||||
endLine: r.end.line + 1,
|
||||
endColumn: r.end.character + 1,
|
||||
});
|
||||
|
||||
const lineText = (doc: TextDocument, line: number) =>
|
||||
doc
|
||||
.getText()
|
||||
.slice(
|
||||
doc.offsetAt({ line, character: 0 }),
|
||||
doc.offsetAt({ line: line + 1, character: 0 }),
|
||||
)
|
||||
.replace(/\r?\n$/, "");
|
||||
|
||||
const preview = (doc: TextDocument | undefined, line: number) =>
|
||||
doc === undefined ? "" : lineText(doc, line).trim();
|
||||
|
||||
const markup = (content: Hover["contents"] | MarkupContent | string): string =>
|
||||
typeof content === "string"
|
||||
? content
|
||||
: Array.isArray(content)
|
||||
? content.map(markup).join("\n\n")
|
||||
: "kind" in content
|
||||
? content.value
|
||||
: `\`\`\`${content.language}\n${content.value}\n\`\`\``;
|
||||
|
||||
/** `{ Class: 5 }` → `{ 5: "class" }`, `{ TypeParameter: 26 }` → `{ 26: "typeParameter" }`. */
|
||||
const kindNames = (kinds: Record<string, number>): Record<number, string> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(kinds).map(([name, value]) => [
|
||||
value,
|
||||
name[0]?.toLowerCase() + name.slice(1),
|
||||
]),
|
||||
);
|
||||
const symbolKinds = kindNames(SymbolKind);
|
||||
const completionKinds = kindNames(CompletionItemKind);
|
||||
const severities = [
|
||||
"error",
|
||||
"warning",
|
||||
"information",
|
||||
"hint",
|
||||
] as const satisfies ReadonlyArray<Model.Severity>;
|
||||
|
||||
// --- Resolution ------------------------------------------------------------
|
||||
|
||||
const unsupported = (feature: string) =>
|
||||
new LspError({
|
||||
method: feature,
|
||||
message: `the language server does not support ${feature}`,
|
||||
});
|
||||
|
||||
const ensure = (
|
||||
capabilities: ServerCapabilities,
|
||||
key: keyof ServerCapabilities,
|
||||
) => (capabilities[key] ? Effect.void : Effect.fail(unsupported(key)));
|
||||
|
||||
/** Turn agent-facing `Where` into an LSP text document position. */
|
||||
const locate = Effect.fn("Editor.locate")(function* (where: Model.Where) {
|
||||
const docs = yield* Documents;
|
||||
const doc = yield* docs.open(where.file);
|
||||
const line = where.line - 1;
|
||||
if (line >= doc.lineCount) {
|
||||
return yield* new LspError({
|
||||
method: "locate",
|
||||
message: `${where.file} has only ${doc.lineCount} lines`,
|
||||
});
|
||||
}
|
||||
const text = lineText(doc, line);
|
||||
const character =
|
||||
where.column !== undefined
|
||||
? where.column - 1
|
||||
: where.symbol !== undefined
|
||||
? yield* Effect.orElseSucceed(findSymbol(text, where.symbol), () => -1)
|
||||
: Math.max(0, text.search(/\S/));
|
||||
if (character < 0) {
|
||||
return yield* new LspError({
|
||||
method: "locate",
|
||||
message: `"${where.symbol}" does not occur on line ${where.line}`,
|
||||
});
|
||||
}
|
||||
const position: Position = {
|
||||
line,
|
||||
character: Math.min(character, text.length),
|
||||
};
|
||||
return { doc, position, textDocument: { uri: doc.uri } };
|
||||
});
|
||||
|
||||
const findSymbol = (text: string, symbol: string) => {
|
||||
const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const index = text.search(
|
||||
new RegExp(`(?<![\\p{L}\\p{N}_$])${escaped}(?![\\p{L}\\p{N}_$])`, "u"),
|
||||
);
|
||||
const found = index >= 0 ? index : text.indexOf(symbol);
|
||||
return found >= 0 ? Effect.succeed(found) : Effect.fail(found);
|
||||
};
|
||||
|
||||
const locations = Effect.fn("Editor.locations")(function* (
|
||||
items: ReadonlyArray<Location | LocationLink>,
|
||||
) {
|
||||
const docs = yield* Documents;
|
||||
const targets = items.map((item) =>
|
||||
"targetUri" in item
|
||||
? { uri: item.targetUri, range: item.targetSelectionRange }
|
||||
: item,
|
||||
);
|
||||
const previews = new Map(
|
||||
yield* Effect.forEach([...new Set(targets.map((t) => t.uri))], (uri) =>
|
||||
docs.peek(uri).pipe(
|
||||
Effect.option,
|
||||
Effect.map((doc) => [uri, Option.getOrUndefined(doc)] as const),
|
||||
),
|
||||
),
|
||||
);
|
||||
return targets.map(
|
||||
({ uri, range: r }): Model.Location => ({
|
||||
file: docs.file(uri),
|
||||
...range(r),
|
||||
preview: preview(previews.get(uri), r.start.line),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const workspaceEdit = Effect.fn("Editor.workspaceEdit")(function* (
|
||||
edit: WorkspaceEdit,
|
||||
apply: boolean,
|
||||
) {
|
||||
const docs = yield* Documents;
|
||||
const files: Array<typeof Model.FileEdits.Type> = [];
|
||||
const operations: Array<string> = [];
|
||||
for (const change of changes(edit)) {
|
||||
if (TextDocumentEdit.is(change)) {
|
||||
files.push({
|
||||
file: docs.file(change.textDocument.uri),
|
||||
edits: change.edits.flatMap((e) =>
|
||||
"newText" in e ? [{ ...range(e.range), newText: e.newText }] : [],
|
||||
),
|
||||
});
|
||||
} else if (change.kind === "rename") {
|
||||
operations.push(
|
||||
`rename ${docs.file(change.oldUri)} -> ${docs.file(change.newUri)}`,
|
||||
);
|
||||
} else {
|
||||
operations.push(`${change.kind} ${docs.file(change.uri)}`);
|
||||
}
|
||||
}
|
||||
if (apply) yield* docs.applyEdit(edit);
|
||||
return { applied: apply, files, operations } satisfies Model.WorkspaceEdit;
|
||||
});
|
||||
|
||||
// --- Operations ------------------------------------------------------------
|
||||
|
||||
export const hover = Effect.fn("Editor.hover")(function* (where: Model.Where) {
|
||||
const lsp = yield* LanguageServer;
|
||||
const { textDocument, position } = yield* locate(where);
|
||||
const result = yield* lsp.request(HoverRequest.type, {
|
||||
textDocument,
|
||||
position,
|
||||
});
|
||||
return result === null
|
||||
? null
|
||||
: ({
|
||||
contents: markup(result.contents),
|
||||
range: result.range && range(result.range),
|
||||
} satisfies Model.Hover);
|
||||
});
|
||||
|
||||
const gotoRequests = {
|
||||
definition: DefinitionRequest.type,
|
||||
typeDefinition: TypeDefinitionRequest.type,
|
||||
implementation: ImplementationRequest.type,
|
||||
declaration: DeclarationRequest.type,
|
||||
};
|
||||
|
||||
export const definition = Effect.fn("Editor.definition")(function* ({
|
||||
kind = "definition",
|
||||
...where
|
||||
}: Model.Goto) {
|
||||
const lsp = yield* LanguageServer;
|
||||
yield* ensure(lsp.capabilities, `${kind}Provider`);
|
||||
const { textDocument, position } = yield* locate(where);
|
||||
const result = yield* lsp.request(
|
||||
gotoRequests[kind] as typeof DefinitionRequest.type,
|
||||
{ textDocument, position },
|
||||
);
|
||||
return yield* locations(
|
||||
result === null ? [] : Array.isArray(result) ? result : [result],
|
||||
);
|
||||
});
|
||||
|
||||
export const references = Effect.fn("Editor.references")(function* ({
|
||||
includeDeclaration = true,
|
||||
...where
|
||||
}: Model.References) {
|
||||
const lsp = yield* LanguageServer;
|
||||
const { textDocument, position } = yield* locate(where);
|
||||
const result = yield* lsp.request(ReferencesRequest.type, {
|
||||
textDocument,
|
||||
position,
|
||||
context: { includeDeclaration },
|
||||
});
|
||||
return yield* locations(result ?? []);
|
||||
});
|
||||
|
||||
export const documentSymbols = Effect.fn("Editor.documentSymbols")(function* ({
|
||||
file,
|
||||
}: {
|
||||
readonly file: string;
|
||||
}) {
|
||||
const lsp = yield* LanguageServer;
|
||||
const docs = yield* Documents;
|
||||
const doc = yield* docs.open(file);
|
||||
const result = yield* lsp.request(DocumentSymbolRequest.type, {
|
||||
textDocument: { uri: doc.uri },
|
||||
});
|
||||
const present = (symbol: DocumentSymbol): Model.DocumentSymbol => ({
|
||||
name: symbol.name,
|
||||
kind: symbolKinds[symbol.kind] ?? "unknown",
|
||||
detail: symbol.detail || undefined,
|
||||
...range(symbol.range),
|
||||
children: symbol.children?.length
|
||||
? symbol.children.map(present)
|
||||
: undefined,
|
||||
});
|
||||
return (result ?? []).map(
|
||||
(symbol): Model.DocumentSymbol =>
|
||||
"range" in symbol
|
||||
? present(symbol)
|
||||
: {
|
||||
name: symbol.name,
|
||||
kind: symbolKinds[symbol.kind] ?? "unknown",
|
||||
container: symbol.containerName || undefined,
|
||||
...range(symbol.location.range),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
export const workspaceSymbols = Effect.fn("Editor.workspaceSymbols")(
|
||||
function* ({ query, limit = 50 }: Model.WorkspaceQuery) {
|
||||
const lsp = yield* LanguageServer;
|
||||
const docs = yield* Documents;
|
||||
const result = yield* lsp.request(WorkspaceSymbolRequest.type, { query });
|
||||
return (result ?? []).slice(0, limit).map(
|
||||
(symbol): Model.WorkspaceSymbol => ({
|
||||
name: symbol.name,
|
||||
kind: symbolKinds[symbol.kind] ?? "unknown",
|
||||
container: symbol.containerName || undefined,
|
||||
file: docs.file(symbol.location.uri),
|
||||
...("range" in symbol.location
|
||||
? {
|
||||
line: symbol.location.range.start.line + 1,
|
||||
column: symbol.location.range.start.character + 1,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const diagnostics = Effect.fn("Editor.diagnostics")(function* ({
|
||||
file,
|
||||
severity = "hint",
|
||||
}: Model.DiagnosticsQuery) {
|
||||
const docs = yield* Documents;
|
||||
const diags = yield* Diagnostics;
|
||||
const present =
|
||||
(uri: string) =>
|
||||
(d: Diagnostic): Model.Diagnostic => ({
|
||||
file: docs.file(uri),
|
||||
...range(d.range),
|
||||
severity: severities[(d.severity ?? 1) - 1] ?? "error",
|
||||
code: d.code === undefined ? undefined : String(d.code),
|
||||
source: d.source,
|
||||
message: markup(d.message),
|
||||
related: d.relatedInformation?.map((r) => ({
|
||||
file: docs.file(r.location.uri),
|
||||
line: r.location.range.start.line + 1,
|
||||
column: r.location.range.start.character + 1,
|
||||
message: r.message,
|
||||
})),
|
||||
});
|
||||
const items =
|
||||
file === undefined
|
||||
? (yield* diags.all).flatMap((p) => p.diagnostics.map(present(p.uri)))
|
||||
: yield* docs
|
||||
.open(file)
|
||||
.pipe(
|
||||
Effect.flatMap((doc) =>
|
||||
Effect.map(diags.forDocument(doc), (ds) =>
|
||||
ds.map(present(doc.uri)),
|
||||
),
|
||||
),
|
||||
);
|
||||
const threshold = severities.indexOf(severity);
|
||||
return items
|
||||
.filter((d) => severities.indexOf(d.severity) <= threshold)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column,
|
||||
);
|
||||
});
|
||||
|
||||
export const completions = Effect.fn("Editor.completions")(function* ({
|
||||
prefix,
|
||||
limit = 20,
|
||||
...where
|
||||
}: Model.Completions) {
|
||||
const lsp = yield* LanguageServer;
|
||||
const { textDocument, position } = yield* locate(where);
|
||||
const result = yield* lsp.request(CompletionRequest.type, {
|
||||
textDocument,
|
||||
position,
|
||||
context: { triggerKind: CompletionTriggerKind.Invoked },
|
||||
});
|
||||
const items = Array.isArray(result) ? result : (result?.items ?? []);
|
||||
const lower = prefix?.toLowerCase();
|
||||
return items
|
||||
.filter(
|
||||
(item) =>
|
||||
lower === undefined ||
|
||||
(item.filterText ?? item.label).toLowerCase().startsWith(lower),
|
||||
)
|
||||
.sort((a, b) =>
|
||||
(a.sortText ?? a.label).localeCompare(b.sortText ?? b.label),
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map(
|
||||
(item): Model.Completion => ({
|
||||
label: item.label,
|
||||
kind: completionKinds[item.kind ?? 1] ?? "text",
|
||||
detail: item.detail,
|
||||
documentation:
|
||||
item.documentation === undefined
|
||||
? undefined
|
||||
: markup(item.documentation),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
export const signatureHelp = Effect.fn("Editor.signatureHelp")(function* (
|
||||
where: Model.Where,
|
||||
) {
|
||||
const lsp = yield* LanguageServer;
|
||||
const { textDocument, position } = yield* locate(where);
|
||||
const result = yield* lsp.request(SignatureHelpRequest.type, {
|
||||
textDocument,
|
||||
position,
|
||||
});
|
||||
return {
|
||||
active: result?.activeSignature ?? 0,
|
||||
signatures: (result?.signatures ?? []).map((s) => ({
|
||||
label: s.label,
|
||||
documentation:
|
||||
s.documentation === undefined ? undefined : markup(s.documentation),
|
||||
parameters: (s.parameters ?? []).map((p) =>
|
||||
typeof p.label === "string"
|
||||
? p.label
|
||||
: s.label.slice(p.label[0], p.label[1]),
|
||||
),
|
||||
activeParameter:
|
||||
s.activeParameter ?? result?.activeParameter ?? undefined,
|
||||
})),
|
||||
} satisfies Model.SignatureHelp;
|
||||
});
|
||||
|
||||
export const rename = Effect.fn("Editor.rename")(function* ({
|
||||
newName,
|
||||
apply = false,
|
||||
...where
|
||||
}: Model.Rename) {
|
||||
const lsp = yield* LanguageServer;
|
||||
const { textDocument, position } = yield* locate(where);
|
||||
const edit = yield* lsp.request(RenameRequest.type, {
|
||||
textDocument,
|
||||
position,
|
||||
newName,
|
||||
});
|
||||
if (edit === null)
|
||||
return yield* new LspError({
|
||||
method: "rename",
|
||||
message: "nothing to rename at this position",
|
||||
});
|
||||
return yield* workspaceEdit(edit, apply);
|
||||
});
|
||||
|
||||
const selection = Effect.fn("Editor.selection")(function* ({
|
||||
file,
|
||||
line,
|
||||
column,
|
||||
endLine,
|
||||
endColumn,
|
||||
}: Model.Selection) {
|
||||
const docs = yield* Documents;
|
||||
const doc = yield* docs.open(file);
|
||||
const end = (endLine ?? line) - 1;
|
||||
const range: Range = {
|
||||
start: { line: line - 1, character: column === undefined ? 0 : column - 1 },
|
||||
end: {
|
||||
line: end,
|
||||
character:
|
||||
endColumn === undefined ? lineText(doc, end).length : endColumn - 1,
|
||||
},
|
||||
};
|
||||
return { doc, textDocument: { uri: doc.uri }, range };
|
||||
});
|
||||
|
||||
const overlaps = (a: Range, b: Range) =>
|
||||
(a.start.line < b.end.line ||
|
||||
(a.start.line === b.end.line && a.start.character <= b.end.character)) &&
|
||||
(b.start.line < a.end.line ||
|
||||
(b.start.line === a.end.line && b.start.character <= a.end.character));
|
||||
|
||||
const codeActionsAt = Effect.fn("Editor.codeActionsAt")(function* ({
|
||||
kind,
|
||||
...where
|
||||
}: Model.CodeActions) {
|
||||
const lsp = yield* LanguageServer;
|
||||
const diags = yield* Diagnostics;
|
||||
const { doc, textDocument, range } = yield* selection(where);
|
||||
const diagnostics = (yield* diags.forDocument(doc)).filter((d) =>
|
||||
overlaps(d.range, range),
|
||||
);
|
||||
const actions = yield* lsp.request(CodeActionRequest.type, {
|
||||
textDocument,
|
||||
range,
|
||||
context: {
|
||||
diagnostics,
|
||||
triggerKind: CodeActionTriggerKind.Invoked,
|
||||
...(kind === undefined ? {} : { only: [kind] }),
|
||||
},
|
||||
});
|
||||
return actions ?? [];
|
||||
});
|
||||
|
||||
export const codeActions = Effect.fn("Editor.codeActions")(function* (
|
||||
params: Model.CodeActions,
|
||||
) {
|
||||
return (yield* codeActionsAt(params)).map(
|
||||
(action): Model.CodeAction => ({
|
||||
title: action.title,
|
||||
kind: CodeAction.is(action) ? action.kind : undefined,
|
||||
preferred: CodeAction.is(action) && action.isPreferred === true,
|
||||
fixes: CodeAction.is(action)
|
||||
? (action.diagnostics ?? []).map((d) => markup(d.message))
|
||||
: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
export const applyCodeAction = Effect.fn("Editor.applyCodeAction")(function* ({
|
||||
title,
|
||||
...params
|
||||
}: Model.ApplyCodeAction) {
|
||||
const lsp = yield* LanguageServer;
|
||||
const action = (yield* codeActionsAt(params)).find((a) => a.title === title);
|
||||
if (action === undefined)
|
||||
return yield* new LspError({
|
||||
method: "codeAction",
|
||||
message: `no code action titled "${title}"`,
|
||||
});
|
||||
const execute = (command: Command) =>
|
||||
lsp
|
||||
.request(ExecuteCommandRequest.type, {
|
||||
command: command.command,
|
||||
...(command.arguments && { arguments: command.arguments }),
|
||||
})
|
||||
.pipe(Effect.as(`command ${command.command}`));
|
||||
if (!CodeAction.is(action)) {
|
||||
return {
|
||||
applied: true,
|
||||
files: [],
|
||||
operations: [yield* execute(action)],
|
||||
} satisfies Model.WorkspaceEdit;
|
||||
}
|
||||
const resolved =
|
||||
action.edit === undefined &&
|
||||
action.command === undefined &&
|
||||
lsp.capabilities.codeActionProvider
|
||||
? yield* lsp.request(CodeActionResolveRequest.type, action)
|
||||
: action;
|
||||
const result = yield* workspaceEdit(resolved.edit ?? {}, true);
|
||||
const operations = resolved.command
|
||||
? [...result.operations, yield* execute(resolved.command)]
|
||||
: result.operations;
|
||||
return { ...result, operations };
|
||||
});
|
||||
|
||||
export const format = Effect.fn("Editor.format")(function* ({
|
||||
file,
|
||||
tabSize = 4,
|
||||
insertSpaces = true,
|
||||
apply = false,
|
||||
}: Model.Format) {
|
||||
const lsp = yield* LanguageServer;
|
||||
const docs = yield* Documents;
|
||||
const doc = yield* docs.open(file);
|
||||
const edits =
|
||||
(yield* lsp.request(DocumentFormattingRequest.type, {
|
||||
textDocument: { uri: doc.uri },
|
||||
options: { tabSize, insertSpaces },
|
||||
})) ?? [];
|
||||
if (apply && edits.length > 0)
|
||||
yield* docs.write(doc.uri, TextDocument.applyEdits(doc, edits));
|
||||
return {
|
||||
applied: apply,
|
||||
files: [
|
||||
{
|
||||
file: docs.file(doc.uri),
|
||||
edits: edits.map((e) => ({ ...range(e.range), newText: e.newText })),
|
||||
},
|
||||
],
|
||||
operations: [],
|
||||
} satisfies Model.WorkspaceEdit;
|
||||
});
|
||||
|
||||
export const callHierarchy = Effect.fn("Editor.callHierarchy")(function* ({
|
||||
direction = "incoming",
|
||||
...where
|
||||
}: Model.CallHierarchy) {
|
||||
const lsp = yield* LanguageServer;
|
||||
const docs = yield* Documents;
|
||||
const { textDocument, position } = yield* locate(where);
|
||||
const item = (yield* lsp.request(CallHierarchyPrepareRequest.type, {
|
||||
textDocument,
|
||||
position,
|
||||
}))?.[0];
|
||||
if (item === undefined) return [];
|
||||
const calls =
|
||||
direction === "incoming"
|
||||
? (
|
||||
(yield* lsp.request(CallHierarchyIncomingCallsRequest.type, {
|
||||
item,
|
||||
})) ?? []
|
||||
).map((c) => ({ item: c.from, sites: c.fromRanges }))
|
||||
: (
|
||||
(yield* lsp.request(CallHierarchyOutgoingCallsRequest.type, {
|
||||
item,
|
||||
})) ?? []
|
||||
).map((c) => ({ item: c.to, sites: c.fromRanges }));
|
||||
return calls.map(
|
||||
({ item, sites }): Model.Call => ({
|
||||
name: item.name,
|
||||
kind: symbolKinds[item.kind] ?? "unknown",
|
||||
detail: item.detail,
|
||||
file: docs.file(item.uri),
|
||||
line: item.selectionRange.start.line + 1,
|
||||
column: item.selectionRange.start.character + 1,
|
||||
sites: sites.map((r) => ({
|
||||
line: r.start.line + 1,
|
||||
column: r.start.character + 1,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* A running language server: process, JSON-RPC connection and the LSP
|
||||
* lifecycle, exposed as an Effect service.
|
||||
*
|
||||
* `request` waits for the server to become idle (no `$/progress` work in
|
||||
* flight) and retries transient `ContentModified` / `ServerCancelled`
|
||||
* failures, so callers can treat the server as if it were always ready.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { NodeStream } from "@effect/platform-node";
|
||||
import {
|
||||
Context,
|
||||
Deferred,
|
||||
Effect,
|
||||
FiberSet,
|
||||
HashSet,
|
||||
Layer,
|
||||
PubSub,
|
||||
Schedule,
|
||||
Schema,
|
||||
type Scope,
|
||||
Stream,
|
||||
SubscriptionRef,
|
||||
} from "effect";
|
||||
import {
|
||||
CancellationTokenSource,
|
||||
type ClientCapabilities,
|
||||
ConfigurationRequest,
|
||||
createMessageConnection,
|
||||
DidChangeConfigurationNotification,
|
||||
ErrorCodes,
|
||||
ExitNotification,
|
||||
type HandlerResult,
|
||||
InitializedNotification,
|
||||
InitializeRequest,
|
||||
LogMessageNotification,
|
||||
type LSPAny,
|
||||
LSPErrorCodes,
|
||||
type MessageType,
|
||||
type NotificationType,
|
||||
RegistrationRequest,
|
||||
type RequestType,
|
||||
ResponseError,
|
||||
type ServerCapabilities,
|
||||
ShowMessageNotification,
|
||||
ShutdownRequest,
|
||||
StreamMessageReader,
|
||||
StreamMessageWriter,
|
||||
UnregistrationRequest,
|
||||
WorkDoneProgressCreateRequest,
|
||||
WorkspaceFoldersRequest,
|
||||
} from "vscode-languageserver-protocol/node";
|
||||
import { URI } from "vscode-uri";
|
||||
|
||||
export interface Options {
|
||||
readonly command: string;
|
||||
readonly args: ReadonlyArray<string>;
|
||||
/** Absolute path of the workspace root. */
|
||||
readonly root: string;
|
||||
readonly initializationOptions?: unknown;
|
||||
/** Served as `workspace/configuration` and pushed via `didChangeConfiguration`. */
|
||||
readonly settings?: unknown;
|
||||
}
|
||||
|
||||
export class LspError extends Schema.TaggedError<LspError>()("LspError", {
|
||||
method: Schema.String,
|
||||
message: Schema.String,
|
||||
code: Schema.optional(Schema.Int),
|
||||
}) {
|
||||
get retryable() {
|
||||
return (
|
||||
this.code === LSPErrorCodes.ContentModified ||
|
||||
this.code === LSPErrorCodes.ServerCancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type LogLevel = "error" | "warning" | "info" | "debug";
|
||||
export interface LogMessage {
|
||||
readonly level: LogLevel;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export class LanguageServer extends Context.Service<
|
||||
LanguageServer,
|
||||
{
|
||||
readonly root: string;
|
||||
readonly capabilities: ServerCapabilities;
|
||||
readonly serverInfo:
|
||||
| { readonly name: string; readonly version?: string | undefined }
|
||||
| undefined;
|
||||
readonly request: <P, R>(
|
||||
type: RequestType<P, R, unknown>,
|
||||
params: P,
|
||||
) => Effect.Effect<R, LspError>;
|
||||
readonly notify: <P>(
|
||||
type: NotificationType<P>,
|
||||
params: P,
|
||||
) => Effect.Effect<void>;
|
||||
readonly notifications: <P>(type: NotificationType<P>) => Stream.Stream<P>;
|
||||
/** Handle a request the server sends to us; unhandled methods answer `MethodNotFound`. */
|
||||
readonly onRequest: <P, R>(
|
||||
type: RequestType<P, R, unknown>,
|
||||
handler: (params: P) => Effect.Effect<R, unknown>,
|
||||
) => Effect.Effect<void, never, Scope.Scope>;
|
||||
/** `window/logMessage` and `window/showMessage` traffic. */
|
||||
readonly logs: Stream.Stream<LogMessage>;
|
||||
}
|
||||
>()("lsp-mcp/lsp/LanguageServer") {
|
||||
static readonly layer = (options: Options) =>
|
||||
Layer.effect(LanguageServer, make(options));
|
||||
}
|
||||
|
||||
const make = Effect.fn("LanguageServer.make")(function* (options: Options) {
|
||||
const run = yield* FiberSet.makeRuntime<never>();
|
||||
const runPromise = yield* FiberSet.makeRuntimePromise<never>();
|
||||
const exited = yield* Deferred.make<never, LspError>();
|
||||
const events = yield* PubSub.unbounded<{
|
||||
readonly method: string;
|
||||
readonly params: unknown;
|
||||
}>();
|
||||
const progress = yield* SubscriptionRef.make(
|
||||
HashSet.empty<string | number>(),
|
||||
);
|
||||
const exit = (message: string) =>
|
||||
Deferred.doneUnsafe(
|
||||
exited,
|
||||
Effect.fail(new LspError({ method: "connection", message })),
|
||||
);
|
||||
|
||||
const child = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
spawn(options.command, options.args, {
|
||||
cwd: options.root,
|
||||
stdio: "pipe",
|
||||
}),
|
||||
),
|
||||
(child) =>
|
||||
Effect.sync(() => void (child.exitCode === null && child.kill())),
|
||||
);
|
||||
child.on("error", (error) =>
|
||||
exit(`failed to start ${options.command}: ${error.message}`),
|
||||
);
|
||||
child.on("exit", (code, signal) =>
|
||||
exit(`${options.command} exited (${signal ?? code})`),
|
||||
);
|
||||
yield* NodeStream.fromReadable<Uint8Array, never>({
|
||||
evaluate: () => child.stderr,
|
||||
onError: () => undefined as never,
|
||||
}).pipe(
|
||||
Stream.decodeText(),
|
||||
Stream.splitLines,
|
||||
Stream.runForEach((line) => Effect.logDebug(line)),
|
||||
Effect.annotateLogs("source", `${options.command}:stderr`),
|
||||
Effect.forkScoped,
|
||||
);
|
||||
|
||||
const connection = createMessageConnection(
|
||||
new StreamMessageReader(child.stdout),
|
||||
new StreamMessageWriter(child.stdin),
|
||||
{
|
||||
error: (m) => run(Effect.logError(m)),
|
||||
warn: (m) => run(Effect.logWarning(m)),
|
||||
info: (m) => run(Effect.logInfo(m)),
|
||||
log: (m) => run(Effect.logDebug(m)),
|
||||
},
|
||||
);
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => connection.dispose()));
|
||||
connection.onClose(() => exit(`${options.command} closed the connection`));
|
||||
connection.onNotification((method, params) => {
|
||||
PubSub.publishUnsafe(events, { method, params });
|
||||
});
|
||||
connection.onUnhandledProgress(({ token, value }) =>
|
||||
run(
|
||||
SubscriptionRef.update(progress, (active) =>
|
||||
value.kind === "begin"
|
||||
? HashSet.add(active, token)
|
||||
: value.kind === "end"
|
||||
? HashSet.remove(active, token)
|
||||
: active,
|
||||
),
|
||||
),
|
||||
);
|
||||
connection.onRequest(WorkDoneProgressCreateRequest.type, () => undefined);
|
||||
connection.onRequest(RegistrationRequest.type, () => undefined);
|
||||
connection.onRequest(UnregistrationRequest.type, () => undefined);
|
||||
connection.onRequest(ConfigurationRequest.type, ({ items }) =>
|
||||
items.map((item) => section(options.settings, item.section)),
|
||||
);
|
||||
const rootUri = URI.file(options.root).toString();
|
||||
const workspaceFolders = [
|
||||
{ uri: rootUri, name: options.root.split(/[\\/]/).pop() ?? options.root },
|
||||
];
|
||||
connection.onRequest(WorkspaceFoldersRequest.type, () => workspaceFolders);
|
||||
connection.listen();
|
||||
|
||||
const failure = (method: string, error: unknown) =>
|
||||
error instanceof ResponseError
|
||||
? new LspError({ method, message: error.message, code: error.code })
|
||||
: new LspError({
|
||||
method,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
const send = <P, R>(type: RequestType<P, R, unknown>, params: P) =>
|
||||
Effect.callback<R, LspError>((resume) => {
|
||||
const cancellation = new CancellationTokenSource();
|
||||
try {
|
||||
connection.sendRequest(type, params as never, cancellation.token).then(
|
||||
(result) => resume(Effect.succeed(result)),
|
||||
(error) => resume(Effect.fail(failure(type.method, error))),
|
||||
);
|
||||
} catch (error) {
|
||||
resume(Effect.fail(failure(type.method, error)));
|
||||
}
|
||||
return Effect.ignore(Effect.try(() => cancellation.cancel()));
|
||||
}).pipe(Effect.raceFirst(Deferred.await(exited)));
|
||||
|
||||
const idle = SubscriptionRef.changes(progress).pipe(
|
||||
Stream.filter(HashSet.isEmpty),
|
||||
Stream.runHead,
|
||||
Effect.timeoutOption("15 seconds"),
|
||||
Effect.asVoid,
|
||||
);
|
||||
|
||||
const request = <P, R>(type: RequestType<P, R, unknown>, params: P) =>
|
||||
idle.pipe(
|
||||
Effect.andThen(send(type, params)),
|
||||
Effect.retry({
|
||||
while: (error) => error.retryable,
|
||||
schedule: Schedule.exponential("100 millis").pipe(
|
||||
Schedule.upTo({ times: 5 }),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const notify = <P>(type: NotificationType<P>, params: P) =>
|
||||
Effect.tryPromise({
|
||||
try: () => connection.sendNotification(type, params as never),
|
||||
catch: (error) => failure(type.method, error),
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning(error.message)));
|
||||
|
||||
const notifications = <P>(type: NotificationType<P>): Stream.Stream<P> =>
|
||||
Stream.fromPubSub(events).pipe(
|
||||
Stream.filter((event) => event.method === type.method),
|
||||
Stream.map((event) => event.params as P),
|
||||
);
|
||||
|
||||
const onRequest = <P, R>(
|
||||
type: RequestType<P, R, unknown>,
|
||||
handler: (params: P) => Effect.Effect<R, unknown>,
|
||||
) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
connection.onRequest(
|
||||
type.method,
|
||||
(params: P): HandlerResult<R, unknown> =>
|
||||
runPromise(
|
||||
handler(params).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ResponseError
|
||||
? error
|
||||
: new ResponseError(
|
||||
ErrorCodes.InternalError,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
),
|
||||
),
|
||||
) as Promise<never>,
|
||||
),
|
||||
),
|
||||
(disposable) => Effect.sync(() => disposable.dispose()),
|
||||
).pipe(Effect.asVoid);
|
||||
|
||||
const initialized = yield* send(InitializeRequest.type, {
|
||||
processId: process.pid,
|
||||
clientInfo: { name: "lsp-mcp", version: "0.1.0" },
|
||||
locale: "en",
|
||||
rootPath: options.root,
|
||||
rootUri,
|
||||
workspaceFolders,
|
||||
capabilities: clientCapabilities,
|
||||
initializationOptions: options.initializationOptions as LSPAny,
|
||||
});
|
||||
yield* notify(InitializedNotification.type, {});
|
||||
if (options.settings !== undefined) {
|
||||
yield* notify(DidChangeConfigurationNotification.type, {
|
||||
settings: options.settings as LSPAny,
|
||||
});
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.tryPromise(() => connection.sendRequest(ShutdownRequest.type)).pipe(
|
||||
Effect.timeout("3 seconds"),
|
||||
Effect.andThen(
|
||||
Effect.tryPromise(() =>
|
||||
connection.sendNotification(ExitNotification.type),
|
||||
),
|
||||
),
|
||||
Effect.andThen(Deferred.await(exited).pipe(Effect.timeout("2 seconds"))),
|
||||
Effect.ignore,
|
||||
),
|
||||
);
|
||||
|
||||
const logs = Stream.merge(
|
||||
notifications(LogMessageNotification.type),
|
||||
notifications(ShowMessageNotification.type),
|
||||
).pipe(
|
||||
Stream.map(
|
||||
({ type, message }): LogMessage => ({
|
||||
level: logLevels[type] ?? "info",
|
||||
message,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return LanguageServer.of({
|
||||
root: options.root,
|
||||
capabilities: initialized.capabilities,
|
||||
serverInfo: initialized.serverInfo,
|
||||
request,
|
||||
notify,
|
||||
notifications,
|
||||
onRequest,
|
||||
logs,
|
||||
});
|
||||
});
|
||||
|
||||
const logLevels: Record<MessageType, LogLevel> = {
|
||||
1: "error",
|
||||
2: "warning",
|
||||
3: "info",
|
||||
4: "debug",
|
||||
5: "debug",
|
||||
};
|
||||
|
||||
/** Resolve a dotted `section` path inside the configured settings. */
|
||||
const section = (settings: unknown, path: string | undefined): LSPAny =>
|
||||
(path === undefined
|
||||
? settings
|
||||
: path
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(value, key) => (value as Record<string, unknown> | null)?.[key],
|
||||
settings,
|
||||
)) as LSPAny;
|
||||
|
||||
/**
|
||||
* What we tell the server we can do. Deliberately narrow: no dynamic
|
||||
* registration, no snippets, no watched files (servers fall back to their
|
||||
* own watchers), UTF-16 positions only.
|
||||
*/
|
||||
const clientCapabilities: ClientCapabilities = {
|
||||
general: {
|
||||
positionEncodings: ["utf-16"],
|
||||
markdown: { parser: "marked", version: "1.1.0" },
|
||||
},
|
||||
workspace: {
|
||||
applyEdit: true,
|
||||
workspaceEdit: {
|
||||
documentChanges: true,
|
||||
resourceOperations: ["create", "rename", "delete"],
|
||||
failureHandling: "abort",
|
||||
},
|
||||
configuration: true,
|
||||
workspaceFolders: true,
|
||||
symbol: { symbolKind: { valueSet: allSymbolKinds() } },
|
||||
didChangeConfiguration: { dynamicRegistration: false },
|
||||
didChangeWatchedFiles: { dynamicRegistration: false },
|
||||
},
|
||||
window: {
|
||||
workDoneProgress: true,
|
||||
showMessage: {},
|
||||
showDocument: { support: false },
|
||||
},
|
||||
textDocument: {
|
||||
synchronization: {
|
||||
didSave: true,
|
||||
willSave: false,
|
||||
willSaveWaitUntil: false,
|
||||
},
|
||||
publishDiagnostics: {
|
||||
relatedInformation: true,
|
||||
versionSupport: true,
|
||||
codeDescriptionSupport: true,
|
||||
dataSupport: true,
|
||||
},
|
||||
diagnostic: { dynamicRegistration: false, relatedDocumentSupport: false },
|
||||
hover: { contentFormat: ["markdown", "plaintext"] },
|
||||
completion: {
|
||||
completionItem: {
|
||||
snippetSupport: false,
|
||||
documentationFormat: ["markdown", "plaintext"],
|
||||
deprecatedSupport: true,
|
||||
},
|
||||
completionItemKind: {
|
||||
valueSet: Array.from({ length: 25 }, (_, i) => (i + 1) as never),
|
||||
},
|
||||
contextSupport: true,
|
||||
},
|
||||
signatureHelp: {
|
||||
signatureInformation: {
|
||||
documentationFormat: ["markdown", "plaintext"],
|
||||
parameterInformation: { labelOffsetSupport: true },
|
||||
activeParameterSupport: true,
|
||||
},
|
||||
},
|
||||
definition: { linkSupport: true },
|
||||
typeDefinition: { linkSupport: true },
|
||||
implementation: { linkSupport: true },
|
||||
declaration: { linkSupport: true },
|
||||
references: {},
|
||||
documentSymbol: {
|
||||
hierarchicalDocumentSymbolSupport: true,
|
||||
symbolKind: { valueSet: allSymbolKinds() },
|
||||
},
|
||||
codeAction: {
|
||||
codeActionLiteralSupport: {
|
||||
codeActionKind: {
|
||||
valueSet: [
|
||||
"",
|
||||
"quickfix",
|
||||
"refactor",
|
||||
"refactor.extract",
|
||||
"refactor.inline",
|
||||
"refactor.rewrite",
|
||||
"source",
|
||||
"source.organizeImports",
|
||||
"source.fixAll",
|
||||
],
|
||||
},
|
||||
},
|
||||
isPreferredSupport: true,
|
||||
dataSupport: true,
|
||||
resolveSupport: { properties: ["edit"] },
|
||||
},
|
||||
rename: { prepareSupport: true },
|
||||
formatting: {},
|
||||
callHierarchy: {},
|
||||
},
|
||||
};
|
||||
|
||||
function allSymbolKinds() {
|
||||
return Array.from({ length: 26 }, (_, i) => (i + 1) as never);
|
||||
}
|
||||
Reference in new issue
Block a user