/ concept-collection / stan-web-ide
Sign in
concept-collection / stan-web-ide
stan-web-ide / src / stan / lsp.ts
352 lines · 11.8 KBBlameHistoryRaw
1import { monaco } from 'minwebide';
3// Stan editor smarts: connects the stan-language-server worker (diagnostics
4// from stanc3, hover docs, completions, auto-format) to monaco. VS Code's
5// monaco build has no built-in LSP client, so this is a small purpose-built
6// one: JSON-RPC over worker postMessage (the wire format of
7// vscode-languageserver's browser transport).
9const MARKER_OWNER = 'stan-language-server';
10const DEBOUNCE_MS = 300;
12interface JsonRpcMessage {
13 jsonrpc: '2.0';
14 id?: number;
15 method?: string;
16 params?: unknown;
17 result?: unknown;
18 error?: { code: number; message: string };
21class LspClient {
22 private nextId = 1;
23 private readonly pending = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();
24 private readonly notificationHandlers = new Map<string, (params: any) => void>();
25 private readonly requestHandlers = new Map<string, (params: any) => unknown>();
27 constructor(private readonly worker: Worker) {
28 worker.onmessage = (event: MessageEvent<JsonRpcMessage>) => this.dispatch(event.data);
29 }
31 request<T = unknown>(method: string, params: unknown): Promise<T> {
32 const id = this.nextId++;
33 this.worker.postMessage({ jsonrpc: '2.0', id, method, params });
34 return new Promise<T>((resolve, reject) => {
35 this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
36 });
37 }
39 notify(method: string, params: unknown): void {
40 this.worker.postMessage({ jsonrpc: '2.0', method, params });
41 }
43 onNotification(method: string, handler: (params: any) => void): void {
44 this.notificationHandlers.set(method, handler);
45 }
47 onRequest(method: string, handler: (params: any) => unknown): void {
48 this.requestHandlers.set(method, handler);
49 }
51 private dispatch(message: JsonRpcMessage): void {
52 if (message.method !== undefined && message.id !== undefined) {
53 // server → client request
54 const handler = this.requestHandlers.get(message.method);
55 if (handler) {
56 Promise.resolve(handler(message.params)).then(
57 (result) => this.worker.postMessage({ jsonrpc: '2.0', id: message.id, result }),
58 (error) => this.worker.postMessage({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: String(error) } }),
59 );
60 } else {
61 this.worker.postMessage({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `unhandled method ${message.method}` } });
62 }
63 } else if (message.method !== undefined) {
64 this.notificationHandlers.get(message.method)?.(message.params);
65 } else if (message.id !== undefined) {
66 const pending = this.pending.get(message.id);
67 this.pending.delete(message.id);
68 if (pending) {
69 if (message.error) {
70 pending.reject(new Error(message.error.message));
71 } else {
72 pending.resolve(message.result);
73 }
74 }
75 }
76 }
79/**
80 * Starts the Stan language server and wires it to every 'stan' monaco model
81 * (current and future). Call once at startup; returns a disposable.
82 */
83export function registerStanLsp(): { dispose(): void } {
84 const worker = new Worker(new URL('./lspWorker.ts', import.meta.url), { type: 'module' });
85 const client = new LspClient(worker);
86 const disposables: { dispose(): void }[] = [];
87 const timers = new Map<string, ReturnType<typeof setTimeout>>();
89 client.onRequest('workspace/configuration', (params: { items: unknown[] }) =>
90 params.items.map(() => ({ warnPedantic: false })));
91 client.onNotification('window/logMessage', () => { /* quiet */ });
93 interface LspDiagnostic {
94 range: LspRange;
95 message: string;
96 severity?: number;
97 source?: string;
98 code?: string | number;
99 }
101 const applyDiagnostics = (model: monaco.editor.ITextModel, diagnostics: LspDiagnostic[]): void => {
102 monaco.editor.setModelMarkers(model, MARKER_OWNER, diagnostics.map((diagnostic) => ({
103 ...toMonacoRange(diagnostic.range),
104 message: diagnostic.message,
105 severity: toMarkerSeverity(diagnostic.severity),
106 source: diagnostic.source ?? MARKER_OWNER,
107 code: diagnostic.code === undefined ? undefined : String(diagnostic.code),
108 })));
109 };
111 // the server implements LSP 3.17 pull diagnostics (textDocument/diagnostic),
112 // so the client asks after every (debounced) change
113 const diagnosticGeneration = new Map<string, number>();
114 const pullDiagnostics = async (model: monaco.editor.ITextModel): Promise<void> => {
115 const uri = model.uri.toString();
116 const generation = (diagnosticGeneration.get(uri) ?? 0) + 1;
117 diagnosticGeneration.set(uri, generation);
118 const result = await client.request<{ kind: string; items?: LspDiagnostic[] } | null>('textDocument/diagnostic', {
119 textDocument: { uri },
120 }).catch(() => null);
121 if (result?.items && !model.isDisposed() && diagnosticGeneration.get(uri) === generation) {
122 applyDiagnostics(model, result.items);
123 }
124 };
126 // push diagnostics too, in case a future server version publishes them
127 client.onNotification('textDocument/publishDiagnostics', (params: { uri: string; diagnostics: LspDiagnostic[] }) => {
128 const model = findModel(params.uri);
129 if (model) {
130 applyDiagnostics(model, params.diagnostics);
131 }
132 });
134 const initialized = client.request('initialize', {
135 processId: null,
136 rootUri: null,
137 workspaceFolders: null,
138 capabilities: {
139 textDocument: {
140 publishDiagnostics: {},
141 hover: { contentFormat: ['markdown', 'plaintext'] },
142 completion: { completionItem: { documentationFormat: ['markdown', 'plaintext'] } },
143 formatting: {},
144 },
145 workspace: {
146 configuration: true,
147 didChangeConfiguration: {},
148 },
149 },
150 }).then(() => {
151 client.notify('initialized', {});
152 }).catch((error) => {
153 console.warn('stan language server failed to initialize', error);
154 });
156 // --- document sync ----------------------------------------------------
158 const opened = new Set<string>();
160 const openModel = (model: monaco.editor.ITextModel): void => {
161 if (model.getLanguageId() !== 'stan' || opened.has(model.uri.toString())) {
162 return;
163 }
164 const uri = model.uri.toString();
165 opened.add(uri);
166 void initialized.then(() => {
167 client.notify('textDocument/didOpen', {
168 textDocument: { uri, languageId: 'stan', version: model.getVersionId(), text: model.getValue() },
169 });
170 void pullDiagnostics(model);
171 });
172 const changeListener = model.onDidChangeContent(() => {
173 clearTimeout(timers.get(uri));
174 timers.set(uri, setTimeout(() => {
175 client.notify('textDocument/didChange', {
176 textDocument: { uri, version: model.getVersionId() },
177 contentChanges: [{ text: model.getValue() }],
178 });
179 void pullDiagnostics(model);
180 }, DEBOUNCE_MS));
181 });
182 const disposeListener = model.onWillDispose(() => {
183 changeListener.dispose();
184 disposeListener.dispose();
185 clearTimeout(timers.get(uri));
186 timers.delete(uri);
187 opened.delete(uri);
188 client.notify('textDocument/didClose', { textDocument: { uri } });
189 });
190 };
192 for (const model of monaco.editor.getModels()) {
193 openModel(model);
194 }
195 disposables.push(monaco.editor.onDidCreateModel(openModel));
196 disposables.push(monaco.editor.onDidChangeModelLanguage(({ model }) => openModel(model)));
198 // --- providers ----------------------------------------------------------
200 disposables.push(monaco.languages.registerHoverProvider('stan', {
201 async provideHover(model, position) {
202 const result = await client.request<{ contents: unknown; range?: LspRange } | null>('textDocument/hover', {
203 textDocument: { uri: model.uri.toString() },
204 position: toLspPosition(position),
205 }).catch(() => null);
206 if (!result) {
207 return null;
208 }
209 return {
210 contents: toMarkdownStrings(result.contents),
211 range: result.range ? toMonacoRange(result.range) : undefined,
212 };
213 },
214 }));
216 disposables.push(monaco.languages.registerCompletionItemProvider('stan', {
217 triggerCharacters: ['~', '.'],
218 async provideCompletionItems(model, position) {
219 const result = await client.request<unknown>('textDocument/completion', {
220 textDocument: { uri: model.uri.toString() },
221 position: toLspPosition(position),
222 }).catch(() => null);
223 const items = Array.isArray(result) ? result : (result as { items?: unknown[] } | null)?.items ?? [];
224 const word = model.getWordUntilPosition(position);
225 const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn);
226 return {
227 suggestions: (items as {
228 label: string;
229 kind?: number;
230 detail?: string;
231 documentation?: unknown;
232 insertText?: string;
233 sortText?: string;
234 }[]).map((item) => ({
235 label: item.label,
236 kind: toMonacoCompletionKind(item.kind),
237 detail: item.detail,
238 documentation: toDocumentation(item.documentation),
239 insertText: item.insertText ?? item.label,
240 sortText: item.sortText,
241 range,
242 })),
243 };
244 },
245 }));
247 disposables.push(monaco.languages.registerDocumentFormattingEditProvider('stan', {
248 async provideDocumentFormattingEdits(model) {
249 const edits = await client.request<{ range: LspRange; newText: string }[] | null>('textDocument/formatting', {
250 textDocument: { uri: model.uri.toString() },
251 options: { tabSize: 2, insertSpaces: true },
252 }).catch(() => null);
253 return (edits ?? []).map((edit) => ({
254 range: toMonacoRange(edit.range),
255 text: edit.newText,
256 }));
257 },
258 }));
260 return {
261 dispose(): void {
262 for (const disposable of disposables) {
263 disposable.dispose();
264 }
265 for (const timer of timers.values()) {
266 clearTimeout(timer);
267 }
268 worker.terminate();
269 },
270 };
273// --- LSP ↔ monaco conversions ---------------------------------------------
275interface LspRange {
276 start: { line: number; character: number };
277 end: { line: number; character: number };
280function toLspPosition(position: monaco.IPosition): { line: number; character: number } {
281 return { line: position.lineNumber - 1, character: position.column - 1 };
284function toMonacoRange(range: LspRange): monaco.IRange {
285 return {
286 startLineNumber: range.start.line + 1,
287 startColumn: range.start.character + 1,
288 endLineNumber: range.end.line + 1,
289 endColumn: range.end.character + 1,
290 };
293function toMarkerSeverity(severity: number | undefined): monaco.editor.IMarkerData['severity'] {
294 switch (severity) {
295 case 1: return monaco.MarkerSeverity.Error;
296 case 2: return monaco.MarkerSeverity.Warning;
297 case 3: return monaco.MarkerSeverity.Info;
298 case 4: return monaco.MarkerSeverity.Hint;
299 default: return monaco.MarkerSeverity.Error;
300 }
303function toMarkdownStrings(contents: unknown): { value: string }[] {
304 const toValue = (entry: unknown): string => {
305 if (typeof entry === 'string') {
306 return entry;
307 }
308 if (entry && typeof entry === 'object' && 'value' in entry) {
309 return String((entry as { value: unknown }).value);
310 }
311 return '';
312 };
313 const list = Array.isArray(contents) ? contents : [contents];
314 return list.map(toValue).filter(Boolean).map(value => ({ value }));
317function toDocumentation(documentation: unknown): string | { value: string } | undefined {
318 if (documentation === undefined || documentation === null) {
319 return undefined;
320 }
321 if (typeof documentation === 'string') {
322 return documentation;
323 }
324 return { value: String((documentation as { value?: unknown }).value ?? '') };
327function toMonacoCompletionKind(kind: number | undefined): monaco.languages.CompletionItemKind {
328 const kinds = monaco.languages.CompletionItemKind;
329 // LSP CompletionItemKind → monaco's (different numberings)
330 switch (kind) {
331 case 2: return kinds.Method;
332 case 3: return kinds.Function;
333 case 4: return kinds.Constructor;
334 case 5: return kinds.Field;
335 case 6: return kinds.Variable;
336 case 7: return kinds.Class;
337 case 8: return kinds.Interface;
338 case 9: return kinds.Module;
339 case 10: return kinds.Property;
340 case 12: return kinds.Value;
341 case 13: return kinds.Enum;
342 case 14: return kinds.Keyword;
343 case 15: return kinds.Snippet;
344 case 21: return kinds.Constant;
345 case 22: return kinds.Struct;
346 default: return kinds.Text;
347 }
350function findModel(uri: string): monaco.editor.ITextModel | undefined {
351 return monaco.editor.getModels().find(model => model.uri.toString() === uri);
moveopenescclose