@hydranium/core 1.0.0-next.334 → 1.0.0-next.338
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.
- package/README.md +1 -1
- package/lib/langium/document-builder/document-builder.js +1 -1
- package/lib/langium/trivia/comment-preserver.d.ts +68 -6
- package/lib/langium/trivia/comment-preserver.d.ts.map +1 -1
- package/lib/langium/trivia/comment-preserver.js +146 -28
- package/lib/langium/trivia/comment-preserver.js.map +1 -1
- package/lib/langium/validation/document-validator.js +1 -1
- package/lib/langium/validation/document-validator.js.map +1 -1
- package/lib/lsp/edit-requests-guard.d.ts +31 -0
- package/lib/lsp/edit-requests-guard.d.ts.map +1 -0
- package/lib/lsp/edit-requests-guard.js +125 -0
- package/lib/lsp/edit-requests-guard.js.map +1 -0
- package/lib/lsp/index.d.ts +2 -1
- package/lib/lsp/index.d.ts.map +1 -1
- package/lib/lsp/index.js +2 -1
- package/lib/lsp/index.js.map +1 -1
- package/lib/lsp/start-language-server.d.ts +9 -8
- package/lib/lsp/start-language-server.d.ts.map +1 -1
- package/lib/lsp/start-language-server.js +11 -8
- package/lib/lsp/start-language-server.js.map +1 -1
- package/lib/messages/carriers.d.ts +1 -1
- package/lib/messages/carriers.js +1 -1
- package/package.json +7 -7
- package/src/langium/document-builder/document-builder.ts +1 -1
- package/src/langium/trivia/comment-preserver.ts +178 -30
- package/src/langium/validation/document-validator.ts +1 -1
- package/src/lsp/edit-requests-guard.ts +152 -0
- package/src/lsp/index.ts +2 -1
- package/src/lsp/start-language-server.ts +11 -8
- package/src/messages/carriers.ts +1 -1
|
@@ -143,6 +143,36 @@ export interface IndentationTokenNames {
|
|
|
143
143
|
readonly dedent: string;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* A container a run of comment lines can be placed in: the run's own, or one
|
|
148
|
+
* it is lifted into when a block's DEDENT follows the run. `last` is the index
|
|
149
|
+
* in its content the run is placed after, `closed` the block the run is lifted
|
|
150
|
+
* out of, and `liftBelow` the column a comment line has to start left of to be
|
|
151
|
+
* lifted this far.
|
|
152
|
+
*/
|
|
153
|
+
export interface CommentLiftTarget {
|
|
154
|
+
readonly container: CompositeCstNode;
|
|
155
|
+
readonly last: number;
|
|
156
|
+
readonly closed?: CompositeCstNode;
|
|
157
|
+
readonly liftBelow?: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A run of hidden nodes in `content`, from `start` to `end`, with the syntax
|
|
162
|
+
* before it and the containers its comments can be placed in, innermost first.
|
|
163
|
+
*/
|
|
164
|
+
export interface CommentRun {
|
|
165
|
+
readonly content: readonly CstNode[];
|
|
166
|
+
readonly start: number;
|
|
167
|
+
readonly end: number;
|
|
168
|
+
readonly previous?: { readonly end: number };
|
|
169
|
+
readonly targets: readonly CommentLiftTarget[];
|
|
170
|
+
/** The nodes {@link CommentPreserver.neighbours} finds before the run, in order. */
|
|
171
|
+
readonly before: readonly CstNode[];
|
|
172
|
+
/** Per target, the nodes {@link CommentPreserver.neighbours} finds after its `last`. */
|
|
173
|
+
readonly after: readonly (readonly CstNode[])[];
|
|
174
|
+
}
|
|
175
|
+
|
|
146
176
|
/**
|
|
147
177
|
* One splice, with what it takes to check the risky kind afterwards.
|
|
148
178
|
*
|
|
@@ -192,6 +222,18 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
|
|
|
192
222
|
protected readonly maxIsolatedEdits: number;
|
|
193
223
|
protected commentTokenNames?: readonly string[];
|
|
194
224
|
protected indentationTokenNames?: IndentationTokenNames | null;
|
|
225
|
+
/**
|
|
226
|
+
* Set for the duration of one {@link extract}: the run
|
|
227
|
+
* {@link resolveCommentRun} resolved last, so the run's next comment reuses
|
|
228
|
+
* it.
|
|
229
|
+
*/
|
|
230
|
+
protected commentRun?: CommentRun;
|
|
231
|
+
/**
|
|
232
|
+
* Set for the duration of one {@link extract}: {@link syntaxSpan} per node,
|
|
233
|
+
* as a block a run is lifted out of would otherwise be measured once per
|
|
234
|
+
* comment.
|
|
235
|
+
*/
|
|
236
|
+
protected spanMemo?: Map<CstNode, { readonly offset: number; readonly end: number } | undefined>;
|
|
195
237
|
/** Set for the duration of one {@link apply}; see {@link cachedAnchorKey}. */
|
|
196
238
|
protected keyMemo?: Map<AstNode, string | undefined>;
|
|
197
239
|
/** Set for the duration of one {@link apply}; see {@link ambiguousRetainedKey}. */
|
|
@@ -259,9 +301,14 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
|
|
|
259
301
|
if (this.getIndentationTokenNames() === undefined) {
|
|
260
302
|
return node;
|
|
261
303
|
}
|
|
304
|
+
if (this.spanMemo?.has(node)) {
|
|
305
|
+
return this.spanMemo.get(node);
|
|
306
|
+
}
|
|
262
307
|
const first = this.edgeToken(node, 1);
|
|
263
308
|
const last = this.edgeToken(node, -1);
|
|
264
|
-
|
|
309
|
+
const span = first && last && { offset: first.offset, end: last.end };
|
|
310
|
+
this.spanMemo?.set(node, span);
|
|
311
|
+
return span;
|
|
265
312
|
}
|
|
266
313
|
|
|
267
314
|
/** The first token of `node` for a `step` of 1, the last for -1, that is neither hidden nor indentation. */
|
|
@@ -355,7 +402,14 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
|
|
|
355
402
|
}
|
|
356
403
|
const source = document.textDocument.getText();
|
|
357
404
|
const comments: DocumentComment[] = [];
|
|
358
|
-
this.
|
|
405
|
+
this.commentRun = undefined;
|
|
406
|
+
this.spanMemo = new Map();
|
|
407
|
+
try {
|
|
408
|
+
this.captureFrom(root, source, commentTokens, comments);
|
|
409
|
+
} finally {
|
|
410
|
+
this.commentRun = undefined;
|
|
411
|
+
this.spanMemo = undefined;
|
|
412
|
+
}
|
|
359
413
|
return { comments, sourceRoot: document.parseResult.value };
|
|
360
414
|
}
|
|
361
415
|
|
|
@@ -409,7 +463,14 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
|
|
|
409
463
|
const following = content[at + 1];
|
|
410
464
|
const blankLinesAfter = following === undefined ? 0 : this.countBlankLines(source.slice(comment.end, offsetOf(following)));
|
|
411
465
|
const preceding = at > 0 ? content[at - 1] : undefined;
|
|
412
|
-
|
|
466
|
+
// From the last text before the comment: a block it is lifted out of can
|
|
467
|
+
// end with comment lines of its own, after the block's syntax.
|
|
468
|
+
const precedingEnd = preceding === undefined ? comment.offset : endOf(preceding);
|
|
469
|
+
let gapStart = comment.offset;
|
|
470
|
+
while (gapStart > precedingEnd && /\s/.test(source[gapStart - 1])) {
|
|
471
|
+
gapStart--;
|
|
472
|
+
}
|
|
473
|
+
const blankLinesBefore = preceding === undefined ? 0 : this.countBlankLines(source.slice(gapStart, comment.offset));
|
|
413
474
|
|
|
414
475
|
// Only a comment that OWNS its line can be reindented — one sharing a line
|
|
415
476
|
// with code keeps whatever spacing that line gives it.
|
|
@@ -418,7 +479,7 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
|
|
|
418
479
|
const sourceIndent = beforeOnLine.trim().length === 0 ? beforeOnLine : undefined;
|
|
419
480
|
|
|
420
481
|
const text = this.normalizeLineEndings(comment.text);
|
|
421
|
-
const sharesLineWithPrevious = previous !== undefined && !
|
|
482
|
+
const sharesLineWithPrevious = previous !== undefined && !this.breaksLine(source, endOf(previous), comment.offset);
|
|
422
483
|
|
|
423
484
|
if (sharesLineWithPrevious) {
|
|
424
485
|
// On the same line as whatever precedes it, so it annotates that line.
|
|
@@ -451,8 +512,10 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
|
|
|
451
512
|
}
|
|
452
513
|
|
|
453
514
|
/**
|
|
454
|
-
* Resolves the container {@link classify} anchors `comment` in and
|
|
455
|
-
* siblings there
|
|
515
|
+
* Resolves the container {@link classify} anchors `comment` in, and the
|
|
516
|
+
* comment's adjacent siblings there and the nearest syntax on each side,
|
|
517
|
+
* without INDENT and DEDENT tokens. The content is that window, not all of
|
|
518
|
+
* the container's siblings.
|
|
456
519
|
*
|
|
457
520
|
* An indentation-aware lexer can emit a block's DEDENT after the comment
|
|
458
521
|
* lines that follow the block, so the parser nests them in it. An own-line
|
|
@@ -466,10 +529,35 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
|
|
|
466
529
|
container: CompositeCstNode,
|
|
467
530
|
source: string
|
|
468
531
|
): { readonly container: CompositeCstNode; readonly content: readonly CstNode[]; readonly index: number } {
|
|
469
|
-
|
|
470
|
-
|
|
532
|
+
const content = container.content;
|
|
533
|
+
const run = this.resolveCommentRun(index, container, source);
|
|
534
|
+
let placed = 0;
|
|
535
|
+
if (run.targets.length > 1 && (run.previous === undefined || this.breaksLine(source, run.previous.end, comment.offset))) {
|
|
536
|
+
const column = this.columnAt(source, comment.offset);
|
|
537
|
+
while (placed + 1 < run.targets.length && column < (run.targets[placed + 1].liftBelow ?? 0)) {
|
|
538
|
+
placed++;
|
|
539
|
+
}
|
|
471
540
|
}
|
|
541
|
+
const { container: parent, closed } = run.targets[placed];
|
|
542
|
+
const before = closed ? [closed] : run.before;
|
|
543
|
+
const around = content.slice(Math.max(index - 1, run.start), Math.min(index + 1, run.end) + 1);
|
|
544
|
+
const view = [...before, ...around, ...run.after[placed]];
|
|
545
|
+
return { container: parent, content: view, index: before.length + (index > run.start ? 1 : 0) };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The run of hidden nodes around `container.content[index]`, and the blocks
|
|
550
|
+
* it can be lifted out of: each that ends right after the run and whose
|
|
551
|
+
* INDENT opened a column, up to the first that does not.
|
|
552
|
+
*/
|
|
553
|
+
protected resolveCommentRun(index: number, container: CompositeCstNode, source: string): CommentRun {
|
|
472
554
|
const content = container.content;
|
|
555
|
+
const cached = this.commentRun;
|
|
556
|
+
// Resolved once per run, not per comment: a run of k comment lines would
|
|
557
|
+
// otherwise cost k scans of the run.
|
|
558
|
+
if (cached?.content === content && index >= cached.start && index <= cached.end) {
|
|
559
|
+
return cached;
|
|
560
|
+
}
|
|
473
561
|
let start = index;
|
|
474
562
|
while (start > 0 && content[start - 1].hidden) {
|
|
475
563
|
start--;
|
|
@@ -478,37 +566,62 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
|
|
|
478
566
|
while (end + 1 < content.length && content[end + 1].hidden) {
|
|
479
567
|
end++;
|
|
480
568
|
}
|
|
481
|
-
const run = content.slice(start, end + 1);
|
|
482
569
|
let previous: { readonly end: number } | undefined;
|
|
483
|
-
|
|
484
|
-
|
|
570
|
+
const targets: CommentLiftTarget[] = [{ container, last: end }];
|
|
571
|
+
if (this.getIndentationTokenNames() !== undefined) {
|
|
572
|
+
for (let scan = start - 1; scan >= 0 && previous === undefined; scan--) {
|
|
573
|
+
previous = content[scan].hidden ? undefined : this.syntaxSpan(content[scan]);
|
|
574
|
+
}
|
|
575
|
+
let target = targets[0];
|
|
576
|
+
while (target.container.container && this.closesAfter(target.container.content, target.last)) {
|
|
577
|
+
const opened = this.openedColumn(target.container.content, target.closed ? target.last : start, source) ?? 0;
|
|
578
|
+
if (opened === 0) {
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
const parent = target.container.container;
|
|
582
|
+
target = {
|
|
583
|
+
container: parent,
|
|
584
|
+
last: this.indexIn(parent.content, target.container),
|
|
585
|
+
closed: target.container,
|
|
586
|
+
liftBelow: opened
|
|
587
|
+
};
|
|
588
|
+
targets.push(target);
|
|
589
|
+
}
|
|
485
590
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
591
|
+
const before = this.neighbours(content, start - 1, -1).reverse();
|
|
592
|
+
const after = targets.map(target => this.neighbours(target.container.content, target.last + 1, 1));
|
|
593
|
+
return (this.commentRun = { content, start, end, previous, targets, before, after });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** The nodes of `content` from `from` in `step` direction up to the first that is syntax, without indentation tokens. */
|
|
597
|
+
protected neighbours(content: readonly CstNode[], from: number, step: 1 | -1): CstNode[] {
|
|
598
|
+
const found: CstNode[] = [];
|
|
599
|
+
for (let scan = from; scan >= 0 && scan < content.length; scan += step) {
|
|
600
|
+
const node = content[scan];
|
|
601
|
+
if (!this.isIndentation(node)) {
|
|
602
|
+
found.push(node);
|
|
603
|
+
if (!node.hidden) {
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
497
606
|
}
|
|
498
607
|
}
|
|
499
|
-
|
|
500
|
-
return { container: parent, content: view, index: view.indexOf(comment) };
|
|
608
|
+
return found;
|
|
501
609
|
}
|
|
502
610
|
|
|
503
|
-
/** Whether only hidden and indentation tokens follow `
|
|
504
|
-
protected closesAfter(content: readonly CstNode[],
|
|
505
|
-
|
|
611
|
+
/** Whether only hidden and indentation tokens follow `index` in `content`. */
|
|
612
|
+
protected closesAfter(content: readonly CstNode[], index: number): boolean {
|
|
613
|
+
for (let scan = index + 1; scan < content.length; scan++) {
|
|
614
|
+
if (!content[scan].hidden && !this.isIndentation(content[scan])) {
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return true;
|
|
506
619
|
}
|
|
507
620
|
|
|
508
|
-
/** The column the last INDENT before `
|
|
509
|
-
protected openedColumn(content: readonly CstNode[],
|
|
621
|
+
/** The column the last INDENT before `content[index]` opens, `undefined` without one. */
|
|
622
|
+
protected openedColumn(content: readonly CstNode[], index: number, source: string): number | undefined {
|
|
510
623
|
const indent = this.getIndentationTokenNames()?.indent;
|
|
511
|
-
for (let scan =
|
|
624
|
+
for (let scan = index - 1; scan >= 0; scan--) {
|
|
512
625
|
if (tokenNameOf(content[scan]) === indent) {
|
|
513
626
|
return this.columnAt(source, content[scan].end);
|
|
514
627
|
}
|
|
@@ -516,6 +629,41 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
|
|
|
516
629
|
return undefined;
|
|
517
630
|
}
|
|
518
631
|
|
|
632
|
+
/**
|
|
633
|
+
* The index of `node` in `content`, found by offset: `indexOf` per lifted
|
|
634
|
+
* block makes extraction quadratic in a block's siblings.
|
|
635
|
+
*/
|
|
636
|
+
protected indexIn(content: readonly CstNode[], node: CstNode): number {
|
|
637
|
+
let low = 0;
|
|
638
|
+
let high = content.length;
|
|
639
|
+
while (low < high) {
|
|
640
|
+
const middle = (low + high) >> 1;
|
|
641
|
+
if (content[middle].offset < node.offset) {
|
|
642
|
+
low = middle + 1;
|
|
643
|
+
} else {
|
|
644
|
+
high = middle;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// Zero-width tokens can share the node's offset.
|
|
648
|
+
for (let index = low; index < content.length && content[index].offset === node.offset; index++) {
|
|
649
|
+
if (content[index] === node) {
|
|
650
|
+
return index;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return content.indexOf(node);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Whether `text` has a line break from `from` up to `to`, without copying the span. */
|
|
657
|
+
protected breaksLine(text: string, from: number, to: number): boolean {
|
|
658
|
+
// Not indexOf, which scans on to the next line break past `to`.
|
|
659
|
+
for (let offset = from; offset < to; offset++) {
|
|
660
|
+
if (text.charCodeAt(offset) === 10) {
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
|
|
519
667
|
/** The column of `offset` in `text`. */
|
|
520
668
|
protected columnAt(text: string, offset: number): number {
|
|
521
669
|
return offset - (text.lastIndexOf('\n', offset - 1) + 1);
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
} from '@hydranium/langium';
|
|
39
39
|
import type { CancellationToken } from 'vscode-languageserver-protocol';
|
|
40
40
|
// A VALUE import, for `Diagnostic.getMessageString`: `message` is
|
|
41
|
-
// `string | MarkupContent`
|
|
41
|
+
// `string | MarkupContent` since LSP 3.18, so upstream's own reader is what
|
|
42
42
|
// narrows it rather than a hand-rolled union check here.
|
|
43
43
|
import { Diagnostic } from 'vscode-languageserver-types';
|
|
44
44
|
import { type LogNameOptions } from '../diagnostics/logger.js';
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
import { type URI } from '@hydranium/langium';
|
|
11
|
+
import { type LangiumSharedServices } from '@hydranium/langium/lsp';
|
|
12
|
+
import {
|
|
13
|
+
type Connection,
|
|
14
|
+
type Disposable,
|
|
15
|
+
type HandlerResult,
|
|
16
|
+
LSPErrorCodes,
|
|
17
|
+
ResponseError,
|
|
18
|
+
type WorkspaceEdit
|
|
19
|
+
} from 'vscode-languageserver';
|
|
20
|
+
import { LANGUAGE_CLIENT_ID } from '../documents/client-ids.js';
|
|
21
|
+
import { HydraniumTextDocuments } from '../documents/hydranium-text-documents.js';
|
|
22
|
+
import { type ServerSharedServicesMinimal } from '../langium/shared-services.js';
|
|
23
|
+
|
|
24
|
+
const wrappedConnections = new WeakSet<object>();
|
|
25
|
+
|
|
26
|
+
type Register<P, A extends unknown[], R, E> = (handler: (params: P, ...rest: A) => HandlerResult<R, E>) => Disposable;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Refuse with `ContentModified` a request whose answer edits documents, when
|
|
30
|
+
* the editor of the request's document is behind the store as it arrives, or
|
|
31
|
+
* when a document the answer edits changed while it was computed, by another
|
|
32
|
+
* client, by a rebuild of a closed document or by its release, or its editor
|
|
33
|
+
* is then behind.
|
|
34
|
+
*
|
|
35
|
+
* The editor applies the edits by position to the text it holds, and sent the
|
|
36
|
+
* request's position in it, so an answer computed on other text rewrites the
|
|
37
|
+
* wrong characters. The check runs on the answer, since the documents a rename
|
|
38
|
+
* or code action edits are only known there, and on the connection, so an
|
|
39
|
+
* adopter's provider for these requests is covered. Edits that arrive by a
|
|
40
|
+
* resolve request, a file operation, `willSaveWaitUntil`, an inlay hint or a
|
|
41
|
+
* command's `workspace/applyEdit` are not.
|
|
42
|
+
*
|
|
43
|
+
* Read-only answers are not refused, since a refusal would blank them. An
|
|
44
|
+
* editor ahead of the store does not exist, as its changes reach the store
|
|
45
|
+
* before any request it sends after them.
|
|
46
|
+
*/
|
|
47
|
+
export function guardEditRequests(connection: Connection, services: LangiumSharedServices): void {
|
|
48
|
+
const store = services.workspace.TextDocuments;
|
|
49
|
+
if (wrappedConnections.has(connection) || !(store instanceof HydraniumTextDocuments)) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
wrappedConnections.add(connection);
|
|
53
|
+
// A Hydranium store implies the Hydranium shared services.
|
|
54
|
+
const keyOf = (uri: URI | string): string =>
|
|
55
|
+
(services as LangiumSharedServices & ServerSharedServicesMinimal).workspace.DocumentUriPolicy.canonicalUri(uri);
|
|
56
|
+
const versions = (): ReadonlyMap<string, number> => new Map(store.all().map(document => [document.uri, document.version]));
|
|
57
|
+
// A closed document records no author per version, so a rebuild of it
|
|
58
|
+
// while an answer is computed is what refuses the answer. A release clears
|
|
59
|
+
// the authors, so a reopen would attribute another client's change to the
|
|
60
|
+
// editor, and refuses it as well.
|
|
61
|
+
const watching = new Set<{ readonly rebuilt: Set<string>; readonly released: Set<string> }>();
|
|
62
|
+
services.workspace.DocumentBuilder.onUpdate((changed, deleted) => {
|
|
63
|
+
if (watching.size === 0) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const keys = [...changed, ...deleted].map(keyOf);
|
|
67
|
+
for (const watched of watching) {
|
|
68
|
+
keys.forEach(key => watched.rebuilt.add(key));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
store.onDidReleaseDocument(event => {
|
|
72
|
+
for (const watched of watching) {
|
|
73
|
+
watched.released.add(event.uri);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
// The editor's own changes are not counted: it discards or re-filters an
|
|
77
|
+
// answer that predates them, and counting them would blank completion
|
|
78
|
+
// while the user types.
|
|
79
|
+
const changedByAnother = (uri: string, from: number | undefined, to: number): boolean => {
|
|
80
|
+
if (from === undefined) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
for (let version = from + 1; version <= to; version++) {
|
|
84
|
+
if (store.getAuthor(uri, version) !== LANGUAGE_CLIENT_ID) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
};
|
|
90
|
+
const refused = <E>(): ResponseError<E> =>
|
|
91
|
+
new ResponseError<E>(LSPErrorCodes.ContentModified, 'The editor does not hold the text the answer was computed on');
|
|
92
|
+
// The request's position is in the editor's text, which an answer
|
|
93
|
+
// computed on the store's would be applied past.
|
|
94
|
+
const behind = (uri: string): boolean => store.isLanguageClientBehind(uri, store.version(uri));
|
|
95
|
+
/** Watches the documents an answer may edit while it is computed; dispose it after. */
|
|
96
|
+
const watch = (): { changed(uris: Iterable<string>): boolean; dispose(): void } => {
|
|
97
|
+
const computedOn = versions();
|
|
98
|
+
const watched = { rebuilt: new Set<string>(), released: new Set<string>() };
|
|
99
|
+
watching.add(watched);
|
|
100
|
+
return {
|
|
101
|
+
changed: uris =>
|
|
102
|
+
[...new Set(uris)].some(uri => {
|
|
103
|
+
const document = store.get(uri);
|
|
104
|
+
const key = keyOf(uri);
|
|
105
|
+
const changed =
|
|
106
|
+
watched.released.has(key) ||
|
|
107
|
+
(document === undefined
|
|
108
|
+
? watched.rebuilt.has(key)
|
|
109
|
+
: changedByAnother(uri, computedOn.get(document.uri), document.version));
|
|
110
|
+
return changed || behind(uri);
|
|
111
|
+
}),
|
|
112
|
+
dispose: () => watching.delete(watched)
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
const guard =
|
|
116
|
+
<P extends { readonly textDocument: { readonly uri: string } }, A extends unknown[], R, E>(
|
|
117
|
+
register: Register<P, A, R, E>,
|
|
118
|
+
edited: (result: R | null | undefined | void) => Iterable<string> = () => []
|
|
119
|
+
): Register<P, A, R, E> =>
|
|
120
|
+
handler =>
|
|
121
|
+
register(async (params, ...rest) => {
|
|
122
|
+
const uri = params.textDocument.uri;
|
|
123
|
+
if (behind(uri)) {
|
|
124
|
+
return refused<E>();
|
|
125
|
+
}
|
|
126
|
+
const answer = watch();
|
|
127
|
+
try {
|
|
128
|
+
const result = await handler(params, ...rest);
|
|
129
|
+
return result instanceof ResponseError || !answer.changed([uri, ...edited(result)]) ? result : refused<E>();
|
|
130
|
+
} finally {
|
|
131
|
+
answer.dispose();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
connection.onCompletion = guard(connection.onCompletion.bind(connection));
|
|
136
|
+
connection.onDocumentFormatting = guard(connection.onDocumentFormatting.bind(connection));
|
|
137
|
+
connection.onDocumentRangeFormatting = guard(connection.onDocumentRangeFormatting.bind(connection));
|
|
138
|
+
connection.onDocumentOnTypeFormatting = guard(connection.onDocumentOnTypeFormatting.bind(connection));
|
|
139
|
+
connection.onRenameRequest = guard(connection.onRenameRequest.bind(connection), editedUris);
|
|
140
|
+
connection.onCodeAction = guard(connection.onCodeAction.bind(connection), actions =>
|
|
141
|
+
(actions ?? []).flatMap(action => ('edit' in action && action.edit ? [...editedUris(action.edit)] : []))
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function* editedUris(edit: WorkspaceEdit | null | undefined | void): Iterable<string> {
|
|
146
|
+
yield* Object.keys(edit?.changes ?? {});
|
|
147
|
+
for (const change of edit?.documentChanges ?? []) {
|
|
148
|
+
if ('textDocument' in change) {
|
|
149
|
+
yield change.textDocument.uri;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/lsp/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export * from './completion/hydranium-completion-provider.js';
|
|
|
11
11
|
export * from './lsp-latency.js';
|
|
12
12
|
export * from './connection-features.js';
|
|
13
13
|
export * from './diagnostics-connection.js';
|
|
14
|
+
export * from './edit-requests-guard.js';
|
|
14
15
|
export * from './semantic-token-provider.js';
|
|
15
16
|
export * from './language-module.js';
|
|
16
17
|
export * from './shared-module.js';
|
|
@@ -22,6 +23,6 @@ export * from './save-text-document.js';
|
|
|
22
23
|
* head: framework overrides + the start function live behind one
|
|
23
24
|
* `@hydranium/core/lsp` subpath, mirroring Langium's own `langium/lsp`
|
|
24
25
|
* convention. Signature-compatible with Langium's `startLanguageServer`, plus
|
|
25
|
-
*
|
|
26
|
+
* the composition check and the connection guards listed in its doc.
|
|
26
27
|
*/
|
|
27
28
|
export * from './start-language-server.js';
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from '@hydranium/langium/lsp';
|
|
15
15
|
import { HydraniumDocumentUpdateHandler } from './hydranium-document-update-handler.js';
|
|
16
16
|
import { guardDiagnosticsConnection } from './diagnostics-connection.js';
|
|
17
|
+
import { guardEditRequests } from './edit-requests-guard.js';
|
|
17
18
|
import { installConsoleTee } from '../langium/diagnostics/console-tee.js';
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -59,20 +60,22 @@ export function assertLspHeadComposed(services: LangiumSharedServices): void {
|
|
|
59
60
|
|
|
60
61
|
/**
|
|
61
62
|
* The framework's LSP server entry point: {@link assertLspHeadComposed}, then
|
|
62
|
-
* {@link guardDiagnosticsConnection}
|
|
63
|
-
* console's wrapping for the log file, then
|
|
64
|
-
* Signature-compatible with Langium's, so it
|
|
65
|
-
* importing from `@hydranium/core/lsp`. Calling
|
|
66
|
-
* composition check, the guard without which a
|
|
67
|
-
* the build that validated the document,
|
|
68
|
-
*
|
|
69
|
-
*
|
|
63
|
+
* {@link guardDiagnosticsConnection} and {@link guardEditRequests} on the
|
|
64
|
+
* tree's connection, then the console's wrapping for the log file, then
|
|
65
|
+
* Langium's `startLanguageServer`. Signature-compatible with Langium's, so it
|
|
66
|
+
* is a drop-in for adopters importing from `@hydranium/core/lsp`. Calling
|
|
67
|
+
* Langium's directly skips the composition check, the guard without which a
|
|
68
|
+
* publish racing teardown fails the build that validated the document, the
|
|
69
|
+
* guard without which an edit lands on text the editor does not hold, and the
|
|
70
|
+
* wrapping, which a stdio connection discards where a logger was built before
|
|
71
|
+
* it (see `installConsoleTee`).
|
|
70
72
|
*/
|
|
71
73
|
export function startLanguageServer(services: LangiumSharedServices, serviceRequirements: Partial<ServiceRequirements> = {}): void {
|
|
72
74
|
assertLspHeadComposed(services);
|
|
73
75
|
const connection = services.lsp.Connection;
|
|
74
76
|
if (connection) {
|
|
75
77
|
guardDiagnosticsConnection(connection);
|
|
78
|
+
guardEditRequests(connection, services);
|
|
76
79
|
}
|
|
77
80
|
installConsoleTee();
|
|
78
81
|
startLangiumLanguageServer(services, serviceRequirements);
|
package/src/messages/carriers.ts
CHANGED
|
@@ -63,7 +63,7 @@ export function acceptMessage<S extends string, N extends AstNode, P extends Pro
|
|
|
63
63
|
* Recover the identity from a published diagnostic, for a surface that
|
|
64
64
|
* identifies or renders diagnostics itself.
|
|
65
65
|
*
|
|
66
|
-
* `Diagnostic.message` is `string | MarkupContent`
|
|
66
|
+
* `Diagnostic.message` is `string | MarkupContent` since LSP 3.18, so the text
|
|
67
67
|
* comes from upstream's own `Diagnostic.getMessageString` rather than a
|
|
68
68
|
* hand-rolled narrowing — consuming the library in its style, and one fewer
|
|
69
69
|
* place restating the union.
|