@hydranium/client-browser 1.0.0-next.303 → 1.0.0-next.304

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.
@@ -0,0 +1,1462 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ /**
11
+ * The client half of the LSP head for a Monaco editor, over the connection the
12
+ * page already holds for the head.
13
+ *
14
+ * Hand-written rather than `monaco-languageclient`, which drags in the
15
+ * `@codingame/monaco-vscode-*` shim stack: at `platform: 'browser'` a bundler
16
+ * refuses a `node:*` builtin rather than shimming it, and that refusal is what
17
+ * keeps a page bundle a neutrality gate over the head composition it carries.
18
+ *
19
+ * A Theia or VS Code integration hands `vscode-languageclient` a document
20
+ * store, a marker service and a provider registry. Here Monaco is all three,
21
+ * and the three translations between it and LSP are the whole of the work:
22
+ *
23
+ * - **Identity.** A Monaco model URI must be string-identical to the LSP URI, or
24
+ * `publishDiagnostics` addresses a document Monaco does not have and the
25
+ * markers land nowhere, silently.
26
+ * - **Coordinates.** LSP lines and characters are 0-based; Monaco lines and
27
+ * columns are 1-based. Every range crossing this file is converted.
28
+ * - **Versions.** Monaco's `versionId` is monotonic per model, which is exactly
29
+ * what the server's text store wants for its stale-write gate, so it is used
30
+ * directly rather than counted here in parallel.
31
+ */
32
+
33
+ import { type BaseVersion, isResponseError, sendByMethodName, type TextState, textHash } from '@hydranium/protocol';
34
+ import * as monaco from 'monaco-editor-core';
35
+ import { ConnectionErrors, type MessageConnection } from 'vscode-jsonrpc/browser';
36
+ import {
37
+ type AnnotatedTextEdit,
38
+ type ApplyWorkspaceEditParams,
39
+ ApplyWorkspaceEditRequest,
40
+ type ApplyWorkspaceEditResult,
41
+ type ClientCapabilities,
42
+ type CompletionContext,
43
+ type CompletionItem,
44
+ CompletionItemKind,
45
+ CompletionItemTag,
46
+ CompletionRequest,
47
+ CompletionTriggerKind,
48
+ DefinitionRequest,
49
+ DidChangeTextDocumentNotification,
50
+ DidCloseTextDocumentNotification,
51
+ DidOpenTextDocumentNotification,
52
+ DocumentHighlightKind,
53
+ DocumentHighlightRequest,
54
+ ErrorCodes,
55
+ type PublishDiagnosticsParams,
56
+ type Diagnostic,
57
+ DiagnosticSeverity,
58
+ DiagnosticTag,
59
+ type Hover,
60
+ HoverRequest,
61
+ InsertTextFormat,
62
+ InsertTextMode,
63
+ type Location,
64
+ LocationLink,
65
+ LSPErrorCodes,
66
+ type MarkupContent,
67
+ MarkupKind,
68
+ type Position as LspPosition,
69
+ type Range as LspRange,
70
+ SemanticTokenModifiers,
71
+ SemanticTokensRequest,
72
+ SemanticTokenTypes,
73
+ type ServerCapabilities,
74
+ SnippetTextEdit,
75
+ type TextDocumentContentChangeEvent,
76
+ TextDocumentEdit,
77
+ type TextEdit,
78
+ type WorkspaceEdit
79
+ } from 'vscode-languageserver-protocol';
80
+
81
+ export interface MonacoLspClientOptions {
82
+ /**
83
+ * The languages the server speaks, as Monaco registers them. A document is
84
+ * routed to the first entry whose `extensions` its URI ends with, and that
85
+ * id is the `languageId` on `didOpen`, which the server routes the document
86
+ * to a grammar by. No entry means the open throws rather than guessing: a
87
+ * document parsed by the wrong grammar reports a page of syntax errors that
88
+ * read as the file being broken.
89
+ */
90
+ readonly languages: readonly monaco.languages.ILanguageExtensionPoint[];
91
+ /** The owner of the markers this client sets, which a host reads them back by; {@link DEFAULT_MARKER_OWNER} by default. */
92
+ readonly markerOwner?: string;
93
+ }
94
+
95
+ /** The marker owner a client sets diagnostics under when its options name none. */
96
+ export const DEFAULT_MARKER_OWNER = 'lsp';
97
+
98
+ /**
99
+ * The client capabilities for a host whose editor is a {@link MonacoLspClient},
100
+ * to pass as `initializeLanguageClient`'s `capabilities`: the two workspace
101
+ * answers of the default, plus every form the client renders, so a server that
102
+ * gates a form on its capability sends it. Multi-line semantic tokens stay
103
+ * undeclared, since Monaco cannot render one, and Langium then splits them
104
+ * per line. Frozen: a host that declares more spreads it.
105
+ */
106
+ export const MONACO_CLIENT_CAPABILITIES: ClientCapabilities = deepFreeze({
107
+ workspace: { applyEdit: true, configuration: true, workspaceEdit: { documentChanges: true } },
108
+ textDocument: {
109
+ synchronization: { willSave: false, willSaveWaitUntil: false, didSave: false },
110
+ completion: {
111
+ contextSupport: true,
112
+ completionItem: {
113
+ snippetSupport: true,
114
+ commitCharactersSupport: true,
115
+ documentationFormat: [MarkupKind.Markdown, MarkupKind.PlainText],
116
+ deprecatedSupport: true,
117
+ preselectSupport: true,
118
+ tagSupport: { valueSet: [CompletionItemTag.Deprecated] },
119
+ insertReplaceSupport: true,
120
+ insertTextModeSupport: { valueSet: [InsertTextMode.asIs, InsertTextMode.adjustIndentation] },
121
+ labelDetailsSupport: true
122
+ },
123
+ completionItemKind: { valueSet: Object.values(CompletionItemKind) }
124
+ },
125
+ hover: { contentFormat: [MarkupKind.Markdown, MarkupKind.PlainText] },
126
+ definition: { linkSupport: true },
127
+ documentHighlight: {},
128
+ publishDiagnostics: {
129
+ relatedInformation: true,
130
+ tagSupport: { valueSet: [DiagnosticTag.Unnecessary, DiagnosticTag.Deprecated] },
131
+ codeDescriptionSupport: true
132
+ },
133
+ semanticTokens: {
134
+ requests: { full: true },
135
+ tokenTypes: Object.values(SemanticTokenTypes),
136
+ tokenModifiers: Object.values(SemanticTokenModifiers),
137
+ formats: ['relative']
138
+ }
139
+ }
140
+ });
141
+
142
+ /** `value` and everything it holds, frozen, so a shared constant cannot be changed in place. */
143
+ function deepFreeze<T extends object>(value: T): T {
144
+ for (const child of Object.values(value)) {
145
+ if (typeof child === 'object' && child !== null) {
146
+ deepFreeze(child);
147
+ }
148
+ }
149
+ return Object.freeze(value);
150
+ }
151
+
152
+ function toMarkerSeverity(severity: DiagnosticSeverity | undefined): monaco.MarkerSeverity {
153
+ switch (severity) {
154
+ case DiagnosticSeverity.Error:
155
+ return monaco.MarkerSeverity.Error;
156
+ case DiagnosticSeverity.Warning:
157
+ return monaco.MarkerSeverity.Warning;
158
+ case DiagnosticSeverity.Information:
159
+ return monaco.MarkerSeverity.Info;
160
+ case DiagnosticSeverity.Hint:
161
+ return monaco.MarkerSeverity.Hint;
162
+ // An absent severity is an ERROR by the LSP spec's own wording ("the
163
+ // client is free to decide"), and treating it as a hint would hide a
164
+ // deliberate error behind a barely-visible squiggle.
165
+ default:
166
+ return monaco.MarkerSeverity.Error;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * LSP completion kinds to Monaco's.
172
+ *
173
+ * **Written out rather than passed through, because the two enums are different
174
+ * NUMBER SPACES over the same names.** LSP `Class` is 7 and Monaco's 7 is
175
+ * `Interface`; LSP `Text` is 1 and Monaco's 1 is `Function`. A pass-through
176
+ * therefore compiles, runs, and puts the wrong icon on every entry in the list —
177
+ * a defect nobody reports as a bug because the list is otherwise correct.
178
+ *
179
+ * Written by NAME on both sides so the map stays readable and a future enum
180
+ * addition on either side is a compile error rather than a silent gap.
181
+ */
182
+ const COMPLETION_KINDS: Readonly<Record<CompletionItemKind, monaco.languages.CompletionItemKind>> = {
183
+ [CompletionItemKind.Text]: monaco.languages.CompletionItemKind.Text,
184
+ [CompletionItemKind.Method]: monaco.languages.CompletionItemKind.Method,
185
+ [CompletionItemKind.Function]: monaco.languages.CompletionItemKind.Function,
186
+ [CompletionItemKind.Constructor]: monaco.languages.CompletionItemKind.Constructor,
187
+ [CompletionItemKind.Field]: monaco.languages.CompletionItemKind.Field,
188
+ [CompletionItemKind.Variable]: monaco.languages.CompletionItemKind.Variable,
189
+ [CompletionItemKind.Class]: monaco.languages.CompletionItemKind.Class,
190
+ [CompletionItemKind.Interface]: monaco.languages.CompletionItemKind.Interface,
191
+ [CompletionItemKind.Module]: monaco.languages.CompletionItemKind.Module,
192
+ [CompletionItemKind.Property]: monaco.languages.CompletionItemKind.Property,
193
+ [CompletionItemKind.Unit]: monaco.languages.CompletionItemKind.Unit,
194
+ [CompletionItemKind.Value]: monaco.languages.CompletionItemKind.Value,
195
+ [CompletionItemKind.Enum]: monaco.languages.CompletionItemKind.Enum,
196
+ [CompletionItemKind.Keyword]: monaco.languages.CompletionItemKind.Keyword,
197
+ [CompletionItemKind.Snippet]: monaco.languages.CompletionItemKind.Snippet,
198
+ [CompletionItemKind.Color]: monaco.languages.CompletionItemKind.Color,
199
+ [CompletionItemKind.File]: monaco.languages.CompletionItemKind.File,
200
+ [CompletionItemKind.Reference]: monaco.languages.CompletionItemKind.Reference,
201
+ [CompletionItemKind.Folder]: monaco.languages.CompletionItemKind.Folder,
202
+ [CompletionItemKind.EnumMember]: monaco.languages.CompletionItemKind.EnumMember,
203
+ [CompletionItemKind.Constant]: monaco.languages.CompletionItemKind.Constant,
204
+ [CompletionItemKind.Struct]: monaco.languages.CompletionItemKind.Struct,
205
+ [CompletionItemKind.Event]: monaco.languages.CompletionItemKind.Event,
206
+ [CompletionItemKind.Operator]: monaco.languages.CompletionItemKind.Operator,
207
+ [CompletionItemKind.TypeParameter]: monaco.languages.CompletionItemKind.TypeParameter
208
+ };
209
+
210
+ /**
211
+ * LSP document-highlight kinds to Monaco's.
212
+ *
213
+ * **The same three names over two number spaces, offset by one.** LSP counts
214
+ * from 1 and Monaco from 0, so a pass-through renders every `Read` as a `Text`
215
+ * and every `Write` as a `Read` — and `Write`, LSP's 3, is outside Monaco's enum
216
+ * entirely. The decoration differs per kind, so the result is a file highlighted
217
+ * in the wrong colours rather than an error.
218
+ */
219
+ const HIGHLIGHT_KINDS: Readonly<Record<DocumentHighlightKind, monaco.languages.DocumentHighlightKind>> = {
220
+ [DocumentHighlightKind.Text]: monaco.languages.DocumentHighlightKind.Text,
221
+ [DocumentHighlightKind.Read]: monaco.languages.DocumentHighlightKind.Read,
222
+ [DocumentHighlightKind.Write]: monaco.languages.DocumentHighlightKind.Write
223
+ };
224
+
225
+ /** LSP 0-based line/character to Monaco 1-based line/column. */
226
+ function toMonacoRange(range: LspRange): monaco.IRange {
227
+ return {
228
+ startLineNumber: range.start.line + 1,
229
+ startColumn: range.start.character + 1,
230
+ endLineNumber: range.end.line + 1,
231
+ endColumn: range.end.character + 1
232
+ };
233
+ }
234
+
235
+ function toMarker(diagnostic: Diagnostic): monaco.editor.IMarkerData {
236
+ return {
237
+ ...toMonacoRange(diagnostic.range),
238
+ // LSP 3.18 widened `message` to `string | MarkupContent`, and Monaco's
239
+ // marker takes a plain string. Flattening to the markup's `value` rather
240
+ // than stringifying keeps a rich message readable instead of rendering
241
+ // `[object Object]` in the hover.
242
+ message: typeof diagnostic.message === 'string' ? diagnostic.message : diagnostic.message.value,
243
+ severity: toMarkerSeverity(diagnostic.severity),
244
+ source: diagnostic.source,
245
+ // Stringified because LSP allows a numeric code and Monaco's marker does
246
+ // not, and `undefined` is preserved rather than becoming `"undefined"` in
247
+ // the problem list.
248
+ code:
249
+ diagnostic.code === undefined
250
+ ? undefined
251
+ : diagnostic.codeDescription === undefined
252
+ ? String(diagnostic.code)
253
+ : { value: String(diagnostic.code), target: monaco.Uri.parse(diagnostic.codeDescription.href) },
254
+ tags: diagnostic.tags?.flatMap(toMarkerTag),
255
+ relatedInformation: diagnostic.relatedInformation?.map(related => ({
256
+ resource: monaco.Uri.parse(related.location.uri),
257
+ message: related.message,
258
+ ...toMonacoRange(related.location.range)
259
+ }))
260
+ };
261
+ }
262
+
263
+ /** A tag Monaco renders, or none for a tag a later LSP version adds. */
264
+ function toMarkerTag(tag: DiagnosticTag): monaco.MarkerTag[] {
265
+ switch (tag) {
266
+ case DiagnosticTag.Unnecessary:
267
+ return [monaco.MarkerTag.Unnecessary];
268
+ case DiagnosticTag.Deprecated:
269
+ return [monaco.MarkerTag.Deprecated];
270
+ default:
271
+ return [];
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Monaco's content changes as LSP incremental changes, ordered so that applying
277
+ * them in sequence is correct.
278
+ *
279
+ * **Monaco reports every range against the state BEFORE the edit; LSP reads each
280
+ * range against the state after the previous one.** The two agree only if the
281
+ * changes are applied back-to-front, which is why they are sorted here descending
282
+ * by position. Monaco is documented to deliver them in that order already, and
283
+ * relying on it would be free — but the failure of relying on it wrongly is a
284
+ * multi-cursor edit silently corrupting the server's copy of the document while
285
+ * the editor looks correct, which is not a defect anyone finds from the symptom.
286
+ */
287
+ function toContentChanges(changes: readonly monaco.editor.IModelContentChange[]): TextDocumentContentChangeEvent[] {
288
+ return [...changes]
289
+ .sort((left, right) => right.rangeOffset - left.rangeOffset)
290
+ .map(change => ({
291
+ range: {
292
+ start: { line: change.range.startLineNumber - 1, character: change.range.startColumn - 1 },
293
+ end: { line: change.range.endLineNumber - 1, character: change.range.endColumn - 1 }
294
+ },
295
+ rangeLength: change.rangeLength,
296
+ text: change.text
297
+ }));
298
+ }
299
+
300
+ /** Monaco 1-based line/column to an LSP 0-based position. */
301
+ function toLspPosition(position: monaco.IPosition): LspPosition {
302
+ return { line: position.lineNumber - 1, character: position.column - 1 };
303
+ }
304
+
305
+ /**
306
+ * Where an editor-opener request wants the caret.
307
+ *
308
+ * Monaco hands the opener a RANGE where it has one and a bare position where it
309
+ * does not, and the two spell their line under different names — so a reader of
310
+ * `lineNumber` alone gets `undefined` for every navigation that carried a range,
311
+ * which is all of the ones a definition result produces: Monaco collapses a
312
+ * target to its start before it reaches here.
313
+ *
314
+ * Line 1, column 1 for a request naming no position at all, which a request to
315
+ * open a document merely by URI is. The top of the file is that request's own
316
+ * answer rather than a fallback.
317
+ */
318
+ function toEditorPosition(selectionOrPosition: monaco.IRange | monaco.IPosition | undefined): monaco.IPosition {
319
+ if (selectionOrPosition === undefined) {
320
+ return { lineNumber: 1, column: 1 };
321
+ }
322
+ return 'startLineNumber' in selectionOrPosition
323
+ ? { lineNumber: selectionOrPosition.startLineNumber, column: selectionOrPosition.startColumn }
324
+ : { lineNumber: selectionOrPosition.lineNumber, column: selectionOrPosition.column };
325
+ }
326
+
327
+ /**
328
+ * Whether `error` says the connection is closed or disposed, by its code
329
+ * rather than by `instanceof`: a connection the host hands in may come from
330
+ * another `vscode-jsonrpc` copy, whose `ConnectionError` is a different class.
331
+ */
332
+ function isConnectionClosedError(error: unknown): boolean {
333
+ return error instanceof Error && 'code' in error && (error.code === ConnectionErrors.Closed || error.code === ConnectionErrors.Disposed);
334
+ }
335
+
336
+ /**
337
+ * Send a notification that a closed connection does not fail: the server's
338
+ * side of the sync ended with the connection. A write that fails later is
339
+ * logged by the connection itself.
340
+ */
341
+ function notifyUnlessClosed(send: () => Promise<void>): void {
342
+ try {
343
+ void send().catch(() => undefined);
344
+ } catch (error: unknown) {
345
+ if (!isConnectionClosedError(error)) {
346
+ throw error;
347
+ }
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Whether a request failed because nobody wants its answer any more: the
353
+ * server gave it up (`RequestCancelled`, `ServerCancelled`, or
354
+ * `ContentModified` for a document changed under it), or the connection
355
+ * closed before or while it ran.
356
+ */
357
+ function isAbandonedRequest(error: unknown): boolean {
358
+ if (isConnectionClosedError(error)) {
359
+ return true;
360
+ }
361
+ if (!isResponseError(error)) {
362
+ return false;
363
+ }
364
+ return (
365
+ error.code === LSPErrorCodes.RequestCancelled ||
366
+ error.code === LSPErrorCodes.ServerCancelled ||
367
+ error.code === LSPErrorCodes.ContentModified ||
368
+ error.code === ErrorCodes.PendingResponseRejected ||
369
+ error.code === ErrorCodes.ConnectionInactive
370
+ );
371
+ }
372
+
373
+ /**
374
+ * Monaco's cancellation, which it recognises by name and message: Monaco's API
375
+ * does not export the class, and drops this error silently where it reports
376
+ * any other from a timer as an uncaught page error.
377
+ */
378
+ function monacoCancellation(): Error {
379
+ const error = new Error('Canceled');
380
+ error.name = 'Canceled';
381
+ return error;
382
+ }
383
+
384
+ function isMarkupContent(value: unknown): value is MarkupContent {
385
+ return typeof value === 'object' && value !== null && 'kind' in value && 'value' in value;
386
+ }
387
+
388
+ /**
389
+ * A `textDocument/definition` result as Monaco location links.
390
+ *
391
+ * **Both wire shapes are live, and reading only one of them fails quietly.** The
392
+ * response is a `Location`, an array of them, or an array of `LocationLink`s,
393
+ * and the framework's head answers with the last — to a client that declared no
394
+ * capabilities at all, so `linkSupport` cannot be relied on to narrow it. The
395
+ * two spell the target under different keys, so a client that reads a link's
396
+ * `uri` parses a URI out of nothing, which addresses no model and navigates
397
+ * nowhere without raising anything.
398
+ *
399
+ * The link form is the richer one and is kept whole rather than flattened to a
400
+ * bare location. `targetRange` is the declaration's full extent and
401
+ * `targetSelectionRange` the name inside it, which is what puts the caret on the
402
+ * name rather than on the keyword in front of it. `originSelectionRange` is the
403
+ * span underlined under the Ctrl key, and dropping it leaves Monaco underlining
404
+ * its own word-at-position instead — a guess about where the reference the
405
+ * server resolved actually begins.
406
+ */
407
+ function toDefinitionLinks(result: Location | Location[] | LocationLink[]): monaco.languages.LocationLink[] {
408
+ const entries: readonly (Location | LocationLink)[] = Array.isArray(result) ? result : [result];
409
+ return entries.map(entry =>
410
+ LocationLink.is(entry)
411
+ ? {
412
+ uri: monaco.Uri.parse(entry.targetUri),
413
+ range: toMonacoRange(entry.targetRange),
414
+ targetSelectionRange: toMonacoRange(entry.targetSelectionRange),
415
+ originSelectionRange: entry.originSelectionRange === undefined ? undefined : toMonacoRange(entry.originSelectionRange)
416
+ }
417
+ : { uri: monaco.Uri.parse(entry.uri), range: toMonacoRange(entry.range) }
418
+ );
419
+ }
420
+
421
+ /**
422
+ * `Hover.contents` as Monaco markdown blocks.
423
+ *
424
+ * The LSP type is a union of four shapes — a `MarkupContent`, a plain string, a
425
+ * `{ language, value }` code block, and an array of the latter two — and all
426
+ * four are still live in 3.18 despite the last two being deprecated. Monaco
427
+ * takes markdown, so the code-block form is fenced with its language rather than
428
+ * dropped: rendering it as prose would silently lose the formatting that was the
429
+ * point of sending it that way.
430
+ */
431
+ function toMarkdown(contents: Hover['contents']): monaco.IMarkdownString[] {
432
+ if (isMarkupContent(contents)) {
433
+ return [{ value: contents.kind === 'plaintext' ? escapePlaintext(contents.value) : contents.value }];
434
+ }
435
+ const blocks = Array.isArray(contents) ? contents : [contents];
436
+ return blocks.map(block =>
437
+ typeof block === 'string' ? { value: block } : { value: `\`\`\`${block.language}\n${block.value}\n\`\`\`` }
438
+ );
439
+ }
440
+
441
+ /**
442
+ * Plaintext as markdown that renders it verbatim, escaped as Monaco's own
443
+ * `MarkdownString.appendText` does, which Monaco does not export.
444
+ */
445
+ function escapePlaintext(text: string): string {
446
+ return text
447
+ .replace(/[\\`*_{}[\]()#+!~]/g, '\\$&')
448
+ .replace(/^([ \t]*)-/gm, '$1\\-')
449
+ .replace(/[ \t]+/g, whitespace => '&nbsp;'.repeat(whitespace.length))
450
+ .replace(/>/g, '\\>')
451
+ .replace(/\n/g, '\n\n');
452
+ }
453
+
454
+ /**
455
+ * The range a completion item applies to.
456
+ *
457
+ * **Monaco REQUIRES a range and LSP does not**, which is the one real asymmetry
458
+ * in this conversion. An LSP item may carry a `textEdit` (either a plain
459
+ * `TextEdit` or an `InsertReplaceEdit`), or nothing at all — in which case the
460
+ * client is expected to replace the word being typed. Getting the fallback wrong
461
+ * does not fail: accepting an item inserts alongside the prefix instead of over
462
+ * it, so `Order.stastatus` appears and reads as a server sending a bad label.
463
+ *
464
+ * **An `InsertReplaceEdit` is forwarded as BOTH ranges, not collapsed to one.**
465
+ * Monaco's `range` accepts the `{ insert, replace }` pair and then honours
466
+ * `suggest.insertMode`, which is the whole point of the server sending two: the
467
+ * server states what a suggestion could stand in for and the USER's setting
468
+ * decides whether accepting it overwrites that or inserts before it. Collapsing
469
+ * to `replace` here would silently overrule that setting, and collapsing to
470
+ * `insert` would throw away the wider range the server went to the trouble of
471
+ * computing.
472
+ */
473
+ function toCompletionRange(
474
+ item: CompletionItem,
475
+ model: monaco.editor.ITextModel,
476
+ position: monaco.IPosition
477
+ ): monaco.IRange | { insert: monaco.IRange; replace: monaco.IRange } {
478
+ const edit = item.textEdit;
479
+ if (edit !== undefined) {
480
+ return 'range' in edit ? toMonacoRange(edit.range) : { insert: toMonacoRange(edit.insert), replace: toMonacoRange(edit.replace) };
481
+ }
482
+ const word = model.getWordUntilPosition(position);
483
+ return {
484
+ startLineNumber: position.lineNumber,
485
+ endLineNumber: position.lineNumber,
486
+ startColumn: word.startColumn,
487
+ endColumn: position.column
488
+ };
489
+ }
490
+
491
+ /** Monaco's completion trigger in LSP's numbering, which starts at one where Monaco's starts at zero. */
492
+ function toCompletionContext(context: monaco.languages.CompletionContext): CompletionContext {
493
+ switch (context.triggerKind) {
494
+ case monaco.languages.CompletionTriggerKind.TriggerCharacter:
495
+ return { triggerKind: CompletionTriggerKind.TriggerCharacter, triggerCharacter: context.triggerCharacter };
496
+ case monaco.languages.CompletionTriggerKind.TriggerForIncompleteCompletions:
497
+ return { triggerKind: CompletionTriggerKind.TriggerForIncompleteCompletions };
498
+ default:
499
+ return { triggerKind: CompletionTriggerKind.Invoked };
500
+ }
501
+ }
502
+
503
+ /** Monaco's insert rules for `item`: a snippet, and whitespace kept as sent for an as-is item. */
504
+ function toInsertTextRules(item: CompletionItem): monaco.languages.CompletionItemInsertTextRule | undefined {
505
+ const rules = monaco.languages.CompletionItemInsertTextRule;
506
+ const snippet = item.insertTextFormat === InsertTextFormat.Snippet ? rules.InsertAsSnippet : rules.None;
507
+ const keepWhitespace = item.insertTextMode === InsertTextMode.asIs ? rules.KeepWhitespace : rules.None;
508
+ const combined = snippet | keepWhitespace;
509
+ return combined === rules.None ? undefined : combined;
510
+ }
511
+
512
+ /** The deprecated tag Monaco strikes through, from the tag or the older `deprecated` flag. */
513
+ function toCompletionTags(item: CompletionItem): monaco.languages.CompletionItemTag[] | undefined {
514
+ return item.tags?.includes(CompletionItemTag.Deprecated) || item.deprecated === true
515
+ ? [monaco.languages.CompletionItemTag.Deprecated]
516
+ : undefined;
517
+ }
518
+
519
+ function toCompletionItem(
520
+ item: CompletionItem,
521
+ model: monaco.editor.ITextModel,
522
+ position: monaco.IPosition
523
+ ): monaco.languages.CompletionItem {
524
+ return {
525
+ label:
526
+ item.labelDetails === undefined
527
+ ? item.label
528
+ : { label: item.label, detail: item.labelDetails.detail, description: item.labelDetails.description },
529
+ // `Text` for an item with no kind, which is Monaco's plainest icon. LSP
530
+ // leaves the kind optional and Monaco does not.
531
+ kind: item.kind === undefined ? monaco.languages.CompletionItemKind.Text : COMPLETION_KINDS[item.kind],
532
+ insertText: item.textEdit?.newText ?? item.insertText ?? item.label,
533
+ insertTextRules: toInsertTextRules(item),
534
+ range: toCompletionRange(item, model, position),
535
+ detail: item.detail,
536
+ // A plain string is Monaco's plaintext.
537
+ documentation: isMarkupContent(item.documentation)
538
+ ? item.documentation.kind === 'plaintext'
539
+ ? item.documentation.value
540
+ : { value: item.documentation.value }
541
+ : item.documentation,
542
+ sortText: item.sortText,
543
+ filterText: item.filterText,
544
+ tags: toCompletionTags(item),
545
+ preselect: item.preselect,
546
+ commitCharacters: item.commitCharacters,
547
+ // Monaco runs it after the item is accepted, if a command of that id is
548
+ // registered: an editor action such as `editor.action.triggerSuggest`.
549
+ command:
550
+ item.command === undefined
551
+ ? undefined
552
+ : { id: item.command.command, title: item.command.title, arguments: item.command.arguments },
553
+ // Forwarded rather than dropped: an item that needs a companion edit (an
554
+ // import, a sibling declaration) is silently half-applied without them,
555
+ // and the half that lands is the one that parses.
556
+ additionalTextEdits: item.additionalTextEdits?.map(edit => ({ range: toMonacoRange(edit.range), text: edit.newText }))
557
+ };
558
+ }
559
+
560
+ /** One document's worth of an inbound workspace edit, ready to push into Monaco. */
561
+ export interface ResolvedDocumentEdit {
562
+ readonly model: monaco.editor.ITextModel;
563
+ readonly operations: readonly monaco.editor.IIdentifiedSingleEditOperation[];
564
+ }
565
+
566
+ /**
567
+ * Whether one document's worth of an inbound workspace edit can be applied, and
568
+ * if not, what to tell the server.
569
+ *
570
+ * A REASON rather than a bare `false`, because the reason travels: it becomes
571
+ * `ApplyWorkspaceEditResult.failureReason`, which is the only thing the server
572
+ * learns about a refusal beyond the fact of it.
573
+ */
574
+ export type EditResolution =
575
+ | { readonly applicable: true; readonly edit: ResolvedDocumentEdit }
576
+ | { readonly applicable: false; readonly reason: string };
577
+
578
+ /**
579
+ * One LSP text edit as a Monaco edit operation.
580
+ *
581
+ * An `AnnotatedTextEdit` is a plain edit plus a change-annotation id — a hint
582
+ * that a client with a confirmation UI could group and prompt on. This client
583
+ * has no such UI, so the annotation is dropped and the text applied, which is
584
+ * the behaviour LSP prescribes for a client that does not implement
585
+ * `changeAnnotationSupport`. Dropping the EDIT instead would silently lose part
586
+ * of a server write.
587
+ */
588
+ function toEditOperation(edit: TextEdit | AnnotatedTextEdit): monaco.editor.IIdentifiedSingleEditOperation {
589
+ return { range: toMonacoRange(edit.range), text: edit.newText };
590
+ }
591
+
592
+ /** One open document's content and the buffer version that content was read at. */
593
+ export interface EditorDocument {
594
+ /**
595
+ * Monaco's form of the URI, as the server knows the document: a host
596
+ * compares it with `Uri.parse(uri).toString()` of its own, since a space or
597
+ * a drive-letter colon is encoded here and not in the string it opened with.
598
+ */
599
+ readonly uri: string;
600
+ readonly text: string;
601
+ readonly version: number;
602
+ /** The model the text was read from: a reopened URI is a new model, and a record of the old one marks nothing. */
603
+ readonly model: monaco.editor.ITextModel;
604
+ }
605
+
606
+ /**
607
+ * The editor settings a server-driven language needs, under whatever the host
608
+ * passes to {@link MonacoLspClient.createEditor}.
609
+ *
610
+ * - `fixedOverflowWidgets`: Monaco sizes the suggest list against the document
611
+ * body rather than the editor, so in a pane smaller than the list the pane's
612
+ * clipping ancestor cuts it off part-way down and the list draws no
613
+ * scrollbar: a nine-item completion reads as a two-item one.
614
+ * - `wordBasedSuggestions` off: Monaco's own word-based suggestions offer every
615
+ * word already in the document, so in a language whose identifiers are scoped
616
+ * cross-references they propose names not in scope at the cursor, mixed
617
+ * indistinguishably into the server's list.
618
+ * - `suggest.insertMode` replace: the client forwards both ranges of an
619
+ * `InsertReplaceEdit`, and this is the setting that chooses between them. An
620
+ * identifier in a modelling language is a whole reference, not a prefix
621
+ * someone is part-way through typing. Monaco's own default is `'insert'`.
622
+ * - `semanticHighlighting.enabled`: the default, `'configuredByTheme'`, leaves
623
+ * highlighting off for the built-in themes, which is indistinguishable from
624
+ * the server never answering. Without a grammar underneath, the server's
625
+ * semantic tokens are the only source of highlighting.
626
+ */
627
+ const LSP_EDITOR_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = {
628
+ fixedOverflowWidgets: true,
629
+ wordBasedSuggestions: 'off',
630
+ suggest: { insertMode: 'replace' },
631
+ 'semanticHighlighting.enabled': true
632
+ };
633
+
634
+ /**
635
+ * Registers the server's languages with Monaco and keeps its models in step
636
+ * with the LSP head on the other end of `connection`.
637
+ *
638
+ * Construction registers the languages, the providers and the inbound
639
+ * `workspace/applyEdit` handler; {@link openDocument} opens one document and
640
+ * {@link closeDocument} closes it; {@link dispose} closes every document and
641
+ * releases every registration. Diagnostics are NOT subscribed here — see
642
+ * {@link applyDiagnostics}.
643
+ */
644
+ export class MonacoLspClient {
645
+ /**
646
+ * The models this client opened, by URI in Monaco's form. Every inbound
647
+ * edit, diagnostic and dirty read goes through this map rather than
648
+ * Monaco's global lookup: a page may hold a second client, and a model
649
+ * that client holds is not this one's to write, mark or save, however
650
+ * its URI reads.
651
+ */
652
+ protected readonly models = new Map<string, monaco.editor.ITextModel>();
653
+ /** Per URI, the buffer version last known to be persisted. */
654
+ protected readonly savedVersions = new Map<string, number>();
655
+ /** Everything construction and {@link registerEditorOpener} registered, released in reverse by {@link dispose}. */
656
+ protected readonly registrations: monaco.IDisposable[] = [];
657
+ protected readonly markerOwner: string;
658
+ /**
659
+ * The host's connection, sending by method name: the LSP message types come
660
+ * from `vscode-languageserver-protocol`'s copy of `vscode-jsonrpc`, which
661
+ * need not be the connection's.
662
+ */
663
+ protected readonly connection: MessageConnection;
664
+
665
+ constructor(
666
+ connection: MessageConnection,
667
+ capabilities: ServerCapabilities,
668
+ protected readonly options: MonacoLspClientOptions
669
+ ) {
670
+ this.connection = sendByMethodName(connection);
671
+ this.markerOwner = options.markerOwner ?? DEFAULT_MARKER_OWNER;
672
+ // First: it is the one registration on the connection, which throws once
673
+ // closed, and a constructor that throws leaves nothing to dispose.
674
+ this.registerApplyEdit();
675
+ for (const language of options.languages) {
676
+ monaco.languages.register(language);
677
+ }
678
+ this.registerSemanticTokens(capabilities);
679
+ this.registerCompletion(capabilities);
680
+ this.registerHover();
681
+ this.registerDefinition();
682
+ this.registerDocumentHighlight();
683
+ }
684
+
685
+ /**
686
+ * The language id for `uri`, by the extensions the options name.
687
+ *
688
+ * Throws rather than falling back to a default id. An unknown extension means
689
+ * the host named a document the server has no grammar for, and the failure of
690
+ * the guessing version is that the document opens, parses as the wrong
691
+ * language and reports a page of syntax errors that look like the file being
692
+ * broken.
693
+ */
694
+ protected languageIdFor(uri: string): string {
695
+ // The path alone: a query or a fragment follows the extension.
696
+ const path = monaco.Uri.parse(uri).path;
697
+ const language = this.options.languages.find(candidate => candidate.extensions?.some(extension => path.endsWith(extension)));
698
+ if (language === undefined) {
699
+ throw new Error(`No language registered for ${uri}`);
700
+ }
701
+ return language.id;
702
+ }
703
+
704
+ /**
705
+ * Apply one `publishDiagnostics` notification to the model it addresses.
706
+ *
707
+ * **Called by the host's single notification handler rather than subscribing
708
+ * here, and that is forced rather than stylistic.**
709
+ * `MessageConnection.onNotification` stores handlers in a map keyed by method,
710
+ * so a second registration for `textDocument/publishDiagnostics` REPLACES the
711
+ * first and returns a disposable as though it had worked. A host with a
712
+ * handler for its own diagnostics view would have it taken over by a client
713
+ * that registered its own, and its view would freeze at whatever it had
714
+ * reached — with nothing on the console to say so.
715
+ *
716
+ * A URI with no model of this client's is not an error: a workspace has more
717
+ * documents than the host opens, so most publishes address a file Monaco
718
+ * has never heard of, and a model another client holds is that client's.
719
+ */
720
+ applyDiagnostics(params: PublishDiagnosticsParams): void {
721
+ const model = this.modelOf(params.uri);
722
+ if (model === undefined) {
723
+ return;
724
+ }
725
+ monaco.editor.setModelMarkers(model, this.markerOwner, params.diagnostics.map(toMarker));
726
+ }
727
+
728
+ /**
729
+ * Answer `workspace/applyEdit` — the server→text direction, and the half of
730
+ * the sync a client that only sends `didOpen` silently loses.
731
+ *
732
+ * A server-side write (a diagram drag, a form edit, an integrity repair) goes
733
+ * through the framework's `ModelService`, which rewrites the in-memory text
734
+ * document and then mirrors the settled text to whichever client holds it
735
+ * open, as a MINIMAL `workspace/applyEdit` computed against a shadow of that
736
+ * client's buffer. So the request goes out on every such write the moment the
737
+ * host opens a document — and a client with no handler makes
738
+ * `vscode-jsonrpc` answer `MethodNotFound`, which the framework logs through
739
+ * its tracer and therefore onto `window/logMessage`. A host that has no
740
+ * handler for that either loses the whole inbound direction with nothing on
741
+ * the console, in the editor or in the server's own report to say so.
742
+ *
743
+ * **Registered here rather than by the host, unlike `publishDiagnostics`.**
744
+ * `onRequest` keys handlers by method exactly as `onNotification` does, so a
745
+ * second registration would silently replace this one — but nothing else
746
+ * answers `workspace/applyEdit`, so there is no fan-out to arbitrate, and the
747
+ * handler needs the model store, which is this class's business.
748
+ *
749
+ * **The framework does not gate this on a client capability.**
750
+ * `HydraniumTextDocuments.applyEditToLanguageClient` checks that an LSP
751
+ * connection is bound and that the client has the document open, never a
752
+ * capability, and `vscode-languageserver`'s own
753
+ * `RemoteWorkspaceImpl.applyEdit` forwards unconditionally — so declaring
754
+ * `workspace.applyEdit` in `initialize` does not turn the request on and
755
+ * omitting it does not turn it off.
756
+ *
757
+ * **The echo does not LOOP, and is deliberately not suppressed here.**
758
+ * Applying the edit makes Monaco fire `onDidChangeContent`, which
759
+ * {@link openDocument} turns into a `didChange`. The server answers that
760
+ * echo with no push of its own, so nothing ping-pongs.
761
+ * The server correlates that `didChange` against the push still in flight
762
+ * and reconstructs it against the buffer this client held BEFORE the push —
763
+ * which is the only text its incremental ranges address — so an echo mints
764
+ * no version and drives no rebuild.
765
+ *
766
+ * Suppressing it would break what that correlation runs on: `didChange` is
767
+ * what keeps the server's per-URI shadow and its per-client version aligned
768
+ * with this buffer, so withholding it makes the next outbound diff wrong and
769
+ * makes every versioned push arrive stale. A conforming client echoes.
770
+ *
771
+ * **This client echoes the ranges it was handed, and a server must not assume
772
+ * that.** A host is free to minimise a coarse edit against its own buffer
773
+ * before applying it — Theia routes every workspace edit through
774
+ * `computeMoreMinimalEdits` — so the echo of a single full-document replace
775
+ * can arrive as a set of small ranges keyed to text the server never sent.
776
+ */
777
+ protected registerApplyEdit(): void {
778
+ this.registrations.push(
779
+ this.connection.onRequest(ApplyWorkspaceEditRequest.type, (params: ApplyWorkspaceEditParams) =>
780
+ this.applyWorkspaceEdit(params.edit)
781
+ )
782
+ );
783
+ }
784
+
785
+ /**
786
+ * Apply `edit` to the models it addresses, or refuse all of it.
787
+ *
788
+ * **Every target is resolved before any is written.** An LSP workspace edit
789
+ * is a unit — `failureHandling` exists precisely because a partially applied
790
+ * one leaves the workspace in a state no participant described — and here the
791
+ * server's text shadow is what would carry the damage: it tracks one text per
792
+ * URI, so a half-applied multi-document edit desynchronises the untouched
793
+ * half's baseline and every later minimal diff for it addresses the wrong
794
+ * lines.
795
+ *
796
+ * The framework's own egress never exercises that: it sends a write that
797
+ * touches two documents as two requests of one `TextDocumentEdit` each,
798
+ * because `applyEditToLanguageClient` is per-URI and coalesced per-URI.
799
+ * The loop is therefore for an adopter's server that batches, and the
800
+ * all-or-nothing shape is what keeps this client honest for one.
801
+ */
802
+ protected applyWorkspaceEdit(edit: WorkspaceEdit): ApplyWorkspaceEditResult {
803
+ const resolved: ResolvedDocumentEdit[] = [];
804
+ // A document named twice is refused whole: each entry is resolved against
805
+ // the buffer as it is now, so a second entry would be refused or applied
806
+ // at positions the first one moved.
807
+ const named = new Set<string>();
808
+ const nameOnce = (uri: string): string | undefined => {
809
+ const key = monaco.Uri.parse(uri).toString();
810
+ if (named.has(key)) {
811
+ return `An edit names ${key} more than once`;
812
+ }
813
+ named.add(key);
814
+ return undefined;
815
+ };
816
+ for (const change of edit.documentChanges ?? []) {
817
+ if (!TextDocumentEdit.is(change)) {
818
+ // A create / rename / delete of a FILE. The framework never sends
819
+ // one, and a page has no filesystem of its own to perform it in —
820
+ // the worker's does, and it is not addressable from here. Refused by
821
+ // name so the reason names the operation rather than the shape.
822
+ return { applied: false, failureReason: `Resource operations are not supported by this client: ${change.kind}` };
823
+ }
824
+ const repeated = nameOnce(change.textDocument.uri);
825
+ if (repeated !== undefined) {
826
+ return { applied: false, failureReason: repeated };
827
+ }
828
+ const resolution = this.resolveDocumentEdit(change.textDocument.uri, change.textDocument.version, change.edits);
829
+ if (!resolution.applicable) {
830
+ return { applied: false, failureReason: resolution.reason };
831
+ }
832
+ resolved.push(resolution.edit);
833
+ }
834
+ // The `changes` map is the pre-3.13 form, carrying no version to check
835
+ // against. Handled rather than refused because it is still live in the
836
+ // spec and an adopter's own server may well send it; the framework's
837
+ // egress does not. Read only without `documentChanges`: the spec makes
838
+ // the two alternatives, and an edit carrying both would apply twice.
839
+ for (const [uri, edits] of Object.entries(edit.documentChanges === undefined ? (edit.changes ?? {}) : {})) {
840
+ const repeated = nameOnce(uri);
841
+ if (repeated !== undefined) {
842
+ return { applied: false, failureReason: repeated };
843
+ }
844
+ const resolution = this.resolveDocumentEdit(uri, null, edits);
845
+ if (!resolution.applicable) {
846
+ return { applied: false, failureReason: resolution.reason };
847
+ }
848
+ resolved.push(resolution.edit);
849
+ }
850
+
851
+ for (const target of resolved) {
852
+ // `pushEditOperations`, not `setValue`: the edit joins Monaco's undo
853
+ // stack, so Ctrl+Z reverses a server write from the editor. `setValue`
854
+ // would reset the model — dropping the undo history and the cursor
855
+ // with it, and re-tokenising the whole document for an edit the server
856
+ // went to the trouble of making minimal.
857
+ //
858
+ // An undo stop on either side, so the write is an entry of its own.
859
+ // A typing run leaves Monaco's last element open and an edit pushed
860
+ // then is appended to it, so without the stop before, one Ctrl+Z
861
+ // reverts the write together with the word typed before it; without
862
+ // the stop after, the next keystroke joins the write.
863
+ //
864
+ // `() => null` for the cursor computer: the edit is the SERVER's, not a
865
+ // user gesture, so it must not move a caret the user placed. Returning
866
+ // no selection leaves the cursor where it was.
867
+ target.model.pushStackElement();
868
+ target.model.pushEditOperations(null, [...target.operations], () => null);
869
+ target.model.pushStackElement();
870
+ }
871
+ return { applied: true };
872
+ }
873
+
874
+ /**
875
+ * Resolve one document's worth of an inbound edit against the model it names.
876
+ *
877
+ * Refuses, rather than dropping quietly, in three cases:
878
+ *
879
+ * - **No model for the URI.** `applied: true` for an edit that went nowhere
880
+ * would leave the server's shadow believing this client holds text it does
881
+ * not, and every later minimal diff for that URI would be computed against
882
+ * the wrong baseline. The framework pushes only to documents the language
883
+ * client has opened, and stages or keeps in the store a write to any
884
+ * other, so this branch should stay unreached — it exists so that if it is
885
+ * ever reached the failure is loud rather than a slow desynchronisation.
886
+ * - **A stale version.** The framework addresses a position-dependent
887
+ * (line-keyed) push at its last known version of this buffer, precisely so
888
+ * the client can refuse a push its buffer has outrun: applying a stale
889
+ * range does not fail, it splices the file at the wrong lines. Refusing is
890
+ * not a dead branch — `ModelService` re-pushes a full-range replace on
891
+ * `applied: false`, which is position-independent and therefore correct
892
+ * against whatever the buffer now holds. That retry arrives with
893
+ * `version: null` and passes this gate by construction.
894
+ * - **A snippet edit.** Monaco applies snippets through a controller rather
895
+ * than through the model, so a `SnippetTextEdit` pushed as text would
896
+ * insert its placeholder syntax literally.
897
+ */
898
+ protected resolveDocumentEdit(
899
+ uri: string,
900
+ version: number | null,
901
+ edits: readonly (TextEdit | AnnotatedTextEdit | SnippetTextEdit)[]
902
+ ): EditResolution {
903
+ const model = this.modelOf(uri);
904
+ if (model === undefined) {
905
+ return { applicable: false, reason: `No editor open for ${uri}` };
906
+ }
907
+ if (version !== null && version !== model.getVersionId()) {
908
+ return {
909
+ applicable: false,
910
+ reason: `Stale edit for ${uri}: addressed at version ${version}, buffer is at ${model.getVersionId()}`
911
+ };
912
+ }
913
+ const operations: monaco.editor.IIdentifiedSingleEditOperation[] = [];
914
+ for (const edit of edits) {
915
+ if (SnippetTextEdit.is(edit)) {
916
+ return { applicable: false, reason: `Snippet edits are not supported by this client: ${uri}` };
917
+ }
918
+ operations.push(toEditOperation(edit));
919
+ }
920
+ return { applicable: true, edit: { model, operations } };
921
+ }
922
+
923
+ /**
924
+ * Create a model for `uri` holding `text`, and tell the server the document is
925
+ * open. Returns the model this client already holds for the URI; a model
926
+ * another client of this page holds is refused, since this client would
927
+ * neither announce it nor send its changes.
928
+ *
929
+ * **`text` must be the bytes the server already has.** A browser host takes
930
+ * it from the worker's own filesystem, so `didOpen` carries what the heads
931
+ * came up on. Inventing placeholder text would not fail — it would
932
+ * OVERWRITE the server's copy through the text-document store and quietly
933
+ * change the diagnostics the server reports.
934
+ *
935
+ * `didOpen` carries the model's text rather than `text` itself: Monaco drops
936
+ * a BOM and rewrites mixed line endings to the majority form when it builds
937
+ * the model, as any editor does, and a server holding the bytes instead
938
+ * would be one character off every position and never match the buffer a
939
+ * save compares it with.
940
+ *
941
+ * Which is also why the second call for a URI is a no-op rather than a
942
+ * refresh: by then the buffer may hold a server write the snapshot never had,
943
+ * and the snapshot is the older claim.
944
+ *
945
+ * The URI is sent in Monaco's form: `Uri.parse(uri).toString()` encodes a
946
+ * space or a drive-letter colon, and the providers name the document by the
947
+ * model's URI, so a `didOpen` with the host's string would open a document
948
+ * no provider request ever addresses.
949
+ */
950
+ openDocument(uri: string, text: string): monaco.editor.ITextModel {
951
+ const key = monaco.Uri.parse(uri).toString();
952
+ // Idempotent, and that is what makes two panes able to swap content: a
953
+ // model is Monaco's per-URI singleton, so a second `createModel` for the
954
+ // same URI THROWS — and re-announcing `didOpen` would push the host's
955
+ // startup snapshot over whatever the server's copy has since become.
956
+ const held = this.models.get(key);
957
+ const existing = monaco.editor.getModel(monaco.Uri.parse(key));
958
+ if (held !== undefined && existing === held) {
959
+ return held;
960
+ }
961
+ if (held !== undefined) {
962
+ // The host disposed this client's model itself. The server still has
963
+ // the document open, so it is closed there before anything else:
964
+ // a second `didOpen` without a `didClose` is a client out of step.
965
+ this.closeDocument(key);
966
+ }
967
+ if (existing !== null) {
968
+ throw new Error(`A model for ${key} exists that this client did not open`);
969
+ }
970
+ const languageId = this.languageIdFor(key);
971
+ const model = monaco.editor.createModel(text, languageId, monaco.Uri.parse(key));
972
+ this.models.set(key, model);
973
+ // The opened content came out of the server's filesystem, so it is by
974
+ // definition already persisted — a document that reported itself dirty on
975
+ // open would make the first save rewrite every file it did not change.
976
+ this.savedVersions.set(key, model.getAlternativeVersionId());
977
+
978
+ notifyUnlessClosed(() =>
979
+ this.connection.sendNotification(DidOpenTextDocumentNotification.type, {
980
+ textDocument: { uri: key, languageId, version: model.getVersionId(), text: model.getValue() }
981
+ })
982
+ );
983
+
984
+ // Subscribed before the editor is created, so an editor contribution that
985
+ // touches the model on attach cannot slip a change past the server.
986
+ // Typing goes on after the worker dies: the buffer is the page's.
987
+ model.onDidChangeContent(event => {
988
+ notifyUnlessClosed(() =>
989
+ this.connection.sendNotification(DidChangeTextDocumentNotification.type, {
990
+ textDocument: { uri: key, version: event.versionId },
991
+ contentChanges: toContentChanges(event.changes)
992
+ })
993
+ );
994
+ });
995
+ return model;
996
+ }
997
+
998
+ /**
999
+ * Dispose the model for `uri` and tell the server the document is closed, so
1000
+ * the server drops this client's shadow of it and stops pushing to it. A URI
1001
+ * this client never opened is left alone. An editor still showing the model
1002
+ * is the host's to re-point first: Monaco detaches a disposed model from it.
1003
+ * A model the host disposed itself is closed on the server all the same,
1004
+ * here or by the next {@link openDocument} of its URI.
1005
+ *
1006
+ * The markers go first: Monaco drops a disposed model's markers only for its
1007
+ * own schemes, so a `file:` document reopened later would start with the
1008
+ * squiggles the server published before the close.
1009
+ *
1010
+ * The model goes before `didClose`, and a connection already closed does not
1011
+ * fail the call: the page's side of the sync is this client's to release
1012
+ * whatever became of the worker, and the server's side ends with its
1013
+ * connection.
1014
+ */
1015
+ closeDocument(uri: string): void {
1016
+ const key = monaco.Uri.parse(uri).toString();
1017
+ const model = this.models.get(key);
1018
+ if (model === undefined) {
1019
+ return;
1020
+ }
1021
+ this.models.delete(key);
1022
+ this.savedVersions.delete(key);
1023
+ // Cleared whether or not the host disposed the model already: the marker
1024
+ // service keys by URI and keeps a disposed `file:` model's markers.
1025
+ monaco.editor.setModelMarkers(model, this.markerOwner, []);
1026
+ if (!model.isDisposed()) {
1027
+ model.dispose();
1028
+ }
1029
+ notifyUnlessClosed(() => this.connection.sendNotification(DidCloseTextDocumentNotification.type, { textDocument: { uri: key } }));
1030
+ }
1031
+
1032
+ /**
1033
+ * Close every open document and release every registration, in reverse
1034
+ * order, the `workspace/applyEdit` handler included: a later request is
1035
+ * answered as an unknown method, as for a client that never registered.
1036
+ * The languages stay registered, since Monaco cannot unregister one.
1037
+ */
1038
+ dispose(): void {
1039
+ try {
1040
+ for (const uri of [...this.models.keys()]) {
1041
+ this.closeDocument(uri);
1042
+ }
1043
+ } finally {
1044
+ for (const registration of this.registrations.splice(0).reverse()) {
1045
+ registration.dispose();
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ /**
1051
+ * Create one editor over `model`, with the settings a server-driven language
1052
+ * needs under `options`: the host's theme, layout and chrome spread over
1053
+ * them, so a host cannot lose one by spreading in the wrong order.
1054
+ *
1055
+ * Separate from {@link openDocument} because a host may hold a FIXED number
1056
+ * of editors and swap their models rather than create an editor per
1057
+ * document. That is what keeps a `didOpen` a statement about the workspace
1058
+ * instead of about which pane happens to be showing what.
1059
+ *
1060
+ * `suggest` is merged one level deep: it is the one nested setting here, and
1061
+ * a host's own suggest options would otherwise replace the insert mode the
1062
+ * forwarded `InsertReplaceEdit` ranges depend on.
1063
+ */
1064
+ createEditor(
1065
+ container: HTMLElement,
1066
+ model: monaco.editor.ITextModel,
1067
+ options: monaco.editor.IStandaloneEditorConstructionOptions = {}
1068
+ ): monaco.editor.IStandaloneCodeEditor {
1069
+ return monaco.editor.create(container, {
1070
+ ...LSP_EDITOR_OPTIONS,
1071
+ ...options,
1072
+ suggest: { ...LSP_EDITOR_OPTIONS.suggest, ...options.suggest },
1073
+ model
1074
+ });
1075
+ }
1076
+
1077
+ /**
1078
+ * The open documents whose buffer has moved since it was opened or last
1079
+ * saved, with the text to persist.
1080
+ *
1081
+ * **The editor's buffer is the right source for a save**, not the host's copy
1082
+ * of the workspace: a server write reaches this buffer through
1083
+ * `workspace/applyEdit`, so Monaco's model is the one place both directions of
1084
+ * the sync are already merged.
1085
+ *
1086
+ * **Dirty rather than every open document, and that is not cosmetic.** A save
1087
+ * of an unmodified file writes its current bytes into the backing store, and
1088
+ * a store that wins over a seed on the next load would then pin a file nobody
1089
+ * changed.
1090
+ *
1091
+ * `getAlternativeVersionId` rather than `getVersionId`, because it is the one
1092
+ * that returns to its old value when an edit is UNDONE — undoing a server
1093
+ * write leaves nothing to save, which `getVersionId` (monotonic) would report
1094
+ * as a change.
1095
+ */
1096
+ dirtyDocuments(): EditorDocument[] {
1097
+ return [...this.models.keys()].flatMap(uri => this.dirtyDocument(uri) ?? []);
1098
+ }
1099
+
1100
+ /**
1101
+ * The document at `uri`, in any form, if it has unsaved changes, read from
1102
+ * its model alone; nothing for a document that is saved or that this client
1103
+ * does not hold.
1104
+ */
1105
+ dirtyDocument(uri: string): EditorDocument | undefined {
1106
+ const key = monaco.Uri.parse(uri).toString();
1107
+ const model = this.modelOf(key);
1108
+ if (model === undefined) {
1109
+ return undefined;
1110
+ }
1111
+ const version = model.getAlternativeVersionId();
1112
+ return version === this.savedVersions.get(key) ? undefined : { uri: key, text: model.getValue(), version, model };
1113
+ }
1114
+
1115
+ /** The model this client holds for a URI in any form, if it still exists. */
1116
+ protected modelOf(uri: string): monaco.editor.ITextModel | undefined {
1117
+ const model = this.models.get(monaco.Uri.parse(uri).toString());
1118
+ return model === undefined || model.isDisposed() ? undefined : model;
1119
+ }
1120
+
1121
+ /**
1122
+ * Run one provider's request. A request nobody wants an answer to any more,
1123
+ * since the server gave it up or the connection closed, rejects with
1124
+ * Monaco's cancellation, which Monaco drops silently. Any other rejection
1125
+ * reaches Monaco, which reports it as an uncaught page error.
1126
+ */
1127
+ protected async request<R>(send: () => Promise<R>): Promise<R> {
1128
+ try {
1129
+ return await send();
1130
+ } catch (error: unknown) {
1131
+ throw isAbandonedRequest(error) ? monacoCancellation() : error;
1132
+ }
1133
+ }
1134
+
1135
+ /**
1136
+ * Whether `model` is one this client opened. Monaco picks a provider by
1137
+ * language id, so a second client of the same language is handed this
1138
+ * client's models and this client the other's; a request for one of those
1139
+ * would name a document this client's server never opened.
1140
+ */
1141
+ protected owns(model: monaco.editor.ITextModel): boolean {
1142
+ return this.models.get(model.uri.toString()) === model;
1143
+ }
1144
+
1145
+ /**
1146
+ * Record that `document`'s content has been persisted.
1147
+ *
1148
+ * Takes the document rather than the URI so the version recorded is the one
1149
+ * the TEXT was read at: a keystroke landing while the save was in flight must
1150
+ * leave the document dirty, and marking it against the buffer's current
1151
+ * version would silently swallow that edit. A record of a model since closed
1152
+ * marks nothing: a reopened URI is a new model whose versions start over, so
1153
+ * the old record's version could name an edit of the new one.
1154
+ */
1155
+ markSaved(document: EditorDocument): void {
1156
+ if (this.models.get(document.uri) === document.model) {
1157
+ this.savedVersions.set(document.uri, document.version);
1158
+ }
1159
+ }
1160
+
1161
+ /**
1162
+ * Register one `DocumentSemanticTokensProvider` per language, reading the
1163
+ * legend off the `initialize` result.
1164
+ *
1165
+ * The legend has to come from the server rather than being restated here: it
1166
+ * is the index space the token stream is encoded in, so a client legend that
1167
+ * merely LOOKS right renders every token as the wrong kind — a colouring bug
1168
+ * with no error anywhere. Langium builds it by merging every registered
1169
+ * language's provider options, which is why one legend serves them all.
1170
+ *
1171
+ * Absent capability means no provider at all rather than an empty legend: an
1172
+ * empty one would decode every token index to nothing and produce a file with
1173
+ * no highlighting, which is what a broken server looks like.
1174
+ */
1175
+ protected registerSemanticTokens(capabilities: ServerCapabilities): void {
1176
+ const legend = capabilities.semanticTokensProvider?.legend;
1177
+ if (legend === undefined) {
1178
+ return;
1179
+ }
1180
+ for (const language of this.options.languages) {
1181
+ this.registrations.push(
1182
+ monaco.languages.registerDocumentSemanticTokensProvider(language.id, {
1183
+ getLegend: () => legend,
1184
+ provideDocumentSemanticTokens: async (model, _lastResultId, token) => {
1185
+ if (!this.owns(model)) {
1186
+ return null;
1187
+ }
1188
+ const tokens = await this.request(() =>
1189
+ this.connection.sendRequest(SemanticTokensRequest.type, { textDocument: { uri: model.uri.toString() } }, token)
1190
+ );
1191
+ // `data` arrives as a plain array over JSON-RPC; Monaco requires
1192
+ // a `Uint32Array` and reads garbage from anything else.
1193
+ return tokens === null ? null : { data: new Uint32Array(tokens.data), resultId: tokens.resultId };
1194
+ },
1195
+ // Nothing to release: no `resultId`-keyed state is kept here, because
1196
+ // this provider does not implement the delta request.
1197
+ releaseDocumentSemanticTokens: () => undefined
1198
+ })
1199
+ );
1200
+ }
1201
+ }
1202
+
1203
+ /**
1204
+ * Register one `CompletionItemProvider` per language.
1205
+ *
1206
+ * `triggerCharacters` is taken from the server rather than guessed. It is what
1207
+ * makes the list appear on `.` without a letter after it; a client that
1208
+ * invents its own set either misses a trigger the grammar defines or asks on a
1209
+ * character the server has nothing to say about.
1210
+ */
1211
+ protected registerCompletion(capabilities: ServerCapabilities): void {
1212
+ for (const language of this.options.languages) {
1213
+ this.registrations.push(
1214
+ monaco.languages.registerCompletionItemProvider(language.id, {
1215
+ triggerCharacters: capabilities.completionProvider?.triggerCharacters,
1216
+ provideCompletionItems: async (model, position, context, token) => {
1217
+ if (!this.owns(model)) {
1218
+ return { suggestions: [] };
1219
+ }
1220
+ const result = await this.request(() =>
1221
+ this.connection.sendRequest(
1222
+ CompletionRequest.type,
1223
+ {
1224
+ textDocument: { uri: model.uri.toString() },
1225
+ position: toLspPosition(position),
1226
+ context: toCompletionContext(context)
1227
+ },
1228
+ token
1229
+ )
1230
+ );
1231
+ if (result === null) {
1232
+ return { suggestions: [] };
1233
+ }
1234
+ // `CompletionList | CompletionItem[]`, and both forms are live —
1235
+ // Langium answers with a list, but an adopter's own provider is
1236
+ // free to return the array.
1237
+ const items = Array.isArray(result) ? result : result.items;
1238
+ return {
1239
+ suggestions: items.map(item => toCompletionItem(item, model, position)),
1240
+ incomplete: Array.isArray(result) ? undefined : result.isIncomplete
1241
+ };
1242
+ }
1243
+ })
1244
+ );
1245
+ }
1246
+ }
1247
+
1248
+ /**
1249
+ * Register one `HoverProvider` per language.
1250
+ *
1251
+ * Registered unconditionally rather than behind `capabilities.hoverProvider`,
1252
+ * unlike the semantic-token provider above, because the two failure modes are
1253
+ * not alike: a hover request a server does not implement answers `null` and
1254
+ * Monaco shows nothing, whereas a semantic-token provider without the
1255
+ * server's legend would decode the token stream against the wrong index space
1256
+ * and mis-colour the file.
1257
+ */
1258
+ protected registerHover(): void {
1259
+ for (const language of this.options.languages) {
1260
+ this.registrations.push(
1261
+ monaco.languages.registerHoverProvider(language.id, {
1262
+ provideHover: async (model, position, token) => {
1263
+ if (!this.owns(model)) {
1264
+ return null;
1265
+ }
1266
+ const hover = await this.request(() =>
1267
+ this.connection.sendRequest(
1268
+ HoverRequest.type,
1269
+ { textDocument: { uri: model.uri.toString() }, position: toLspPosition(position) },
1270
+ token
1271
+ )
1272
+ );
1273
+ if (hover === null) {
1274
+ return null;
1275
+ }
1276
+ return {
1277
+ contents: toMarkdown(hover.contents),
1278
+ range: hover.range === undefined ? undefined : toMonacoRange(hover.range)
1279
+ };
1280
+ }
1281
+ })
1282
+ );
1283
+ }
1284
+ }
1285
+
1286
+ /**
1287
+ * Route a definition target in ANOTHER document to `open`, which answers
1288
+ * whether it could show it.
1289
+ *
1290
+ * **Monaco's standalone editor service can only navigate within the editor
1291
+ * that was clicked in**, and it declines the rest by doing nothing: it looks
1292
+ * the target resource up on the source editor alone, finds a different model,
1293
+ * and returns — no message, no marker, no console line. That is the same
1294
+ * observable as a server that resolved no reference, which is what makes the
1295
+ * seam worth stating rather than discovering. A host with a workbench behind
1296
+ * it supplies this step; a plain page supplies it by hand.
1297
+ *
1298
+ * `false` from `open` leaves the reader where they are, and that is the
1299
+ * honest answer for a target the host holds no text for rather than a
1300
+ * failure: a `virtual:` in-code contribution is a document to the server and
1301
+ * no file to anyone.
1302
+ *
1303
+ * **A source editor on a model this client does not own is left to the next
1304
+ * opener.** Monaco calls every opener for every editor, so on a page with
1305
+ * two clients this one would otherwise claim a jump the other client's
1306
+ * server resolved, and hand its host a document that host never opened.
1307
+ *
1308
+ * **A target in the SOURCE editor's own model is deliberately NOT routed
1309
+ * here.** Monaco calls the last-registered opener first and falls through to
1310
+ * its own on a `false`, and its own is better for that case: it selects the
1311
+ * declaration's whole range where this seam could only place a caret, and it
1312
+ * is the path the editor's navigation history is recorded on.
1313
+ *
1314
+ * Released by {@link dispose} as well as by the returned disposable.
1315
+ */
1316
+ registerEditorOpener(open: (uri: string, position: monaco.IPosition) => boolean): monaco.IDisposable {
1317
+ const registration = monaco.editor.registerEditorOpener({
1318
+ openCodeEditor: (source, resource, selectionOrPosition) => {
1319
+ const model = source.getModel();
1320
+ if (model === null || !this.owns(model) || model.uri.toString() === resource.toString()) {
1321
+ return false;
1322
+ }
1323
+ return open(resource.toString(), toEditorPosition(selectionOrPosition));
1324
+ }
1325
+ });
1326
+ this.registrations.push(registration);
1327
+ return registration;
1328
+ }
1329
+
1330
+ /**
1331
+ * Register one `DefinitionProvider` per language.
1332
+ *
1333
+ * **This is half of go-to-definition, and on its own it moves nothing for a
1334
+ * target in another document.** A provider answers where the declaration is;
1335
+ * going there is the host's job, and {@link registerEditorOpener} is the
1336
+ * other half. Registered unconditionally, for the reason {@link registerHover}
1337
+ * states.
1338
+ */
1339
+ protected registerDefinition(): void {
1340
+ for (const language of this.options.languages) {
1341
+ this.registrations.push(
1342
+ monaco.languages.registerDefinitionProvider(language.id, {
1343
+ provideDefinition: async (model, position, token) => {
1344
+ if (!this.owns(model)) {
1345
+ return null;
1346
+ }
1347
+ const result = await this.request(() =>
1348
+ this.connection.sendRequest(
1349
+ DefinitionRequest.type,
1350
+ { textDocument: { uri: model.uri.toString() }, position: toLspPosition(position) },
1351
+ token
1352
+ )
1353
+ );
1354
+ return result === null ? null : toDefinitionLinks(result);
1355
+ }
1356
+ })
1357
+ );
1358
+ }
1359
+ }
1360
+
1361
+ /**
1362
+ * Register one `DocumentHighlightProvider` per language.
1363
+ *
1364
+ * **Document-scoped by the protocol, unlike every other reference-following
1365
+ * request here**, so it answers what a name does in the file being read
1366
+ * rather than where it comes from — the declaration and its uses in one view,
1367
+ * with no jump and nothing opened. Registered unconditionally, for the reason
1368
+ * {@link registerHover} states.
1369
+ */
1370
+ protected registerDocumentHighlight(): void {
1371
+ for (const language of this.options.languages) {
1372
+ this.registrations.push(
1373
+ monaco.languages.registerDocumentHighlightProvider(language.id, {
1374
+ provideDocumentHighlights: async (model, position, token) => {
1375
+ if (!this.owns(model)) {
1376
+ return null;
1377
+ }
1378
+ const highlights = await this.request(() =>
1379
+ this.connection.sendRequest(
1380
+ DocumentHighlightRequest.type,
1381
+ { textDocument: { uri: model.uri.toString() }, position: toLspPosition(position) },
1382
+ token
1383
+ )
1384
+ );
1385
+ if (highlights === null) {
1386
+ return null;
1387
+ }
1388
+ // An absent kind is `Text` by the protocol, which is also what
1389
+ // Monaco assumes for an absent one — stated rather than left off,
1390
+ // so the default is this client's and not a coincidence.
1391
+ return highlights.map(highlight => ({
1392
+ range: toMonacoRange(highlight.range),
1393
+ kind: HIGHLIGHT_KINDS[highlight.kind ?? DocumentHighlightKind.Text]
1394
+ }));
1395
+ }
1396
+ })
1397
+ );
1398
+ }
1399
+ }
1400
+ }
1401
+
1402
+ /** The two calls of a data session {@link saveDirtyDocuments} makes, which `DataSession` satisfies. */
1403
+ export interface DocumentPersister {
1404
+ /** Open `args.uri`, run `fn` with the opened document, whose `text` is the server's state of it, and close it after. */
1405
+ withOpenDocument<T>(args: { readonly uri: string }, fn: (document: { readonly text?: TextState }) => Promise<T>): Promise<T>;
1406
+ persistDocument(args: { readonly uri: string; readonly baseVersion: BaseVersion }): Promise<unknown>;
1407
+ }
1408
+
1409
+ /**
1410
+ * Persist the dirty documents of `client`, or those of them `uris` names, in
1411
+ * the host's form or Monaco's, through `session`, one after another, and
1412
+ * return what was saved.
1413
+ *
1414
+ * **A save persists; it does not author.** Every keystroke already reached the
1415
+ * server as a `didChange`, and every server write reached the buffer as a
1416
+ * `workspace/applyEdit`, so the server's text is the buffer and disk is the
1417
+ * only thing behind. `persistDocument` at `'any'` writes that text without a
1418
+ * model: a write of the buffer's text would be authored against no read, so
1419
+ * no `ModelVersion` could gate it, and `'any'` on such a write could put an
1420
+ * older buffer over a server write still in flight.
1421
+ *
1422
+ * **The open's text hash is the gate.** A `didChange` travels the LSP port and
1423
+ * the persist the data port, and nothing orders one port's messages before
1424
+ * the other's, so the open can answer with text a keystroke has not reached.
1425
+ * The open answer's hash is `textHash` of the server's text; when it is not
1426
+ * the buffer's, the save throws, naming the document, and the document stays
1427
+ * dirty for the next save. A keystroke landing after the open persists as
1428
+ * newer text, which leaves the buffer dirty the same way.
1429
+ *
1430
+ * Each document's text is read just before its own open, not once for the
1431
+ * set: an earlier document's persist takes time, and a keystroke in a later
1432
+ * one during it would otherwise fail that one's hash check for nothing. Each
1433
+ * is marked saved at the version its text was read at, one at a time, so a
1434
+ * failure part-way through leaves the documents it never reached dirty and a
1435
+ * second call retries exactly those. Sequential, not concurrent: each persist
1436
+ * runs over the server's shared store.
1437
+ */
1438
+ export async function saveDirtyDocuments(
1439
+ client: MonacoLspClient,
1440
+ session: DocumentPersister,
1441
+ uris?: readonly string[]
1442
+ ): Promise<EditorDocument[]> {
1443
+ const wanted = uris?.map(uri => monaco.Uri.parse(uri).toString());
1444
+ const dirty = client.dirtyDocuments().filter(document => wanted === undefined || wanted.includes(document.uri));
1445
+ const saved: EditorDocument[] = [];
1446
+ for (const { uri } of dirty) {
1447
+ // Read again now: an undo during an earlier save may have cleaned it.
1448
+ const document = client.dirtyDocument(uri);
1449
+ if (document === undefined) {
1450
+ continue;
1451
+ }
1452
+ await session.withOpenDocument({ uri }, opened => {
1453
+ if (opened.text?.hash !== textHash(document.text)) {
1454
+ throw new Error(`The server's text for ${uri} differs from the buffer this save read; the document stays unsaved`);
1455
+ }
1456
+ return session.persistDocument({ uri, baseVersion: 'any' });
1457
+ });
1458
+ client.markSaved(document);
1459
+ saved.push(document);
1460
+ }
1461
+ return saved;
1462
+ }