@hydranium/core 1.0.0-next.114 → 1.0.0-next.117
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 +3 -1
- package/lib/documents/ast-document-manager.d.ts +52 -2
- package/lib/documents/ast-document-manager.d.ts.map +1 -1
- package/lib/documents/ast-document-manager.js +67 -5
- package/lib/documents/ast-document-manager.js.map +1 -1
- package/lib/documents/client-session-errors.d.ts +44 -0
- package/lib/documents/client-session-errors.d.ts.map +1 -0
- package/lib/documents/client-session-errors.js +57 -0
- package/lib/documents/client-session-errors.js.map +1 -0
- package/lib/documents/client-session-registry.d.ts +118 -0
- package/lib/documents/client-session-registry.d.ts.map +1 -0
- package/lib/documents/client-session-registry.js +171 -0
- package/lib/documents/client-session-registry.js.map +1 -0
- package/lib/documents/hydranium-text-documents.d.ts +77 -25
- package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
- package/lib/documents/hydranium-text-documents.js +143 -52
- package/lib/documents/hydranium-text-documents.js.map +1 -1
- package/lib/documents/index.d.ts +2 -0
- package/lib/documents/index.d.ts.map +1 -1
- package/lib/documents/index.js +2 -0
- package/lib/documents/index.js.map +1 -1
- package/lib/langium/integrity/integrity-rule.d.ts +13 -5
- package/lib/langium/integrity/integrity-rule.d.ts.map +1 -1
- package/lib/langium/integrity/integrity-rule.js.map +1 -1
- package/lib/langium/integrity/integrity-service.d.ts +37 -6
- package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
- package/lib/langium/integrity/integrity-service.js +81 -17
- package/lib/langium/integrity/integrity-service.js.map +1 -1
- package/lib/langium/model-service/client-session.d.ts +120 -0
- package/lib/langium/model-service/client-session.d.ts.map +1 -0
- package/lib/langium/model-service/client-session.js +175 -0
- package/lib/langium/model-service/client-session.js.map +1 -0
- package/lib/langium/model-service/index.d.ts +1 -0
- package/lib/langium/model-service/index.d.ts.map +1 -1
- package/lib/langium/model-service/index.js +1 -0
- package/lib/langium/model-service/index.js.map +1 -1
- package/lib/langium/model-service/model-service.d.ts +76 -8
- package/lib/langium/model-service/model-service.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.js +148 -21
- package/lib/langium/model-service/model-service.js.map +1 -1
- package/lib/lsp/hydranium-document-update-handler.d.ts +6 -0
- package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
- package/lib/lsp/hydranium-document-update-handler.js +20 -3
- package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
- package/lib/testing/stub-ast-document-manager.d.ts +4 -3
- package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
- package/lib/testing/stub-ast-document-manager.js +3 -0
- package/lib/testing/stub-ast-document-manager.js.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.d.ts +13 -5
- package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.js +45 -14
- package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
- package/package.json +5 -5
- package/src/documents/ast-document-manager.ts +107 -6
- package/src/documents/client-session-errors.ts +54 -0
- package/src/documents/client-session-registry.ts +211 -0
- package/src/documents/hydranium-text-documents.ts +158 -55
- package/src/documents/index.ts +2 -0
- package/src/langium/integrity/integrity-rule.ts +13 -5
- package/src/langium/integrity/integrity-service.ts +84 -17
- package/src/langium/model-service/client-session.ts +257 -0
- package/src/langium/model-service/index.ts +1 -0
- package/src/langium/model-service/model-service.ts +176 -21
- package/src/lsp/hydranium-document-update-handler.ts +22 -4
- package/src/testing/stub-ast-document-manager.ts +7 -2
- package/src/testing/stub-hydranium-text-documents.ts +62 -18
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, 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 CanonicalUri, FRAMEWORK_CLIENT_IDS } from '@hydranium/protocol';
|
|
11
|
+
import { Emitter, type Event } from 'vscode-languageserver';
|
|
12
|
+
import { INTEGRITY_CLIENT_ID } from '../langium/integrity/integrity-rule.js';
|
|
13
|
+
import { DuplicateClientIdError, SessionClosedError } from './client-session-errors.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What a client session states about one of its opens, kept per
|
|
17
|
+
* `(session, uri)` for as long as that open lasts.
|
|
18
|
+
*
|
|
19
|
+
* Any object type: an adopter declares the fields its own open path reads and
|
|
20
|
+
* passes that type as a session's `TOpenOptions`. Stored per open rather than
|
|
21
|
+
* per document, so two sessions opening one document with different options
|
|
22
|
+
* each keep their own.
|
|
23
|
+
*/
|
|
24
|
+
export type OpenOptions = object;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `'closing'` covers the span in which a session's opens are being closed. The
|
|
28
|
+
* id is still taken then, and the session can open nothing more, so an open
|
|
29
|
+
* issued from a close listener cannot outlive the session that made it.
|
|
30
|
+
*/
|
|
31
|
+
export type ClientSessionState = 'live' | 'closing';
|
|
32
|
+
|
|
33
|
+
/** Delivered by {@link ClientSessionRegistry.onDidCloseSession}. */
|
|
34
|
+
export interface ClientSessionClosedEvent {
|
|
35
|
+
readonly clientId: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The ids no client session may be started under: the wire's well-known ids and
|
|
40
|
+
* the integrity author. Each names a framework participant rather than a
|
|
41
|
+
* client, and a session holding one would have its writes read as that
|
|
42
|
+
* participant's.
|
|
43
|
+
*/
|
|
44
|
+
export const RESERVED_CLIENT_IDS: readonly string[] = [...FRAMEWORK_CLIENT_IDS, INTEGRITY_CLIENT_ID];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Which client has which document open, and which client ids are registered
|
|
48
|
+
* sessions — the open facts every head shares, composed by
|
|
49
|
+
* `HydraniumTextDocuments`.
|
|
50
|
+
*
|
|
51
|
+
* Opens are recorded for every client id, registered or not. An id that was
|
|
52
|
+
* never registered is a client that predates sessions and writes through the
|
|
53
|
+
* flat `clientId` calls; its opens are tracked exactly as a session's are, and
|
|
54
|
+
* only the session table ignores it. Requiring registration before an open
|
|
55
|
+
* would refuse every such client.
|
|
56
|
+
*
|
|
57
|
+
* One open per `(client, uri)`, with no reference count: a repeat open changes
|
|
58
|
+
* nothing, and one close ends it.
|
|
59
|
+
*
|
|
60
|
+
* Pure bookkeeping. Closing a document is the store's job, because a close has
|
|
61
|
+
* to reach the text store and its listeners; the store asks this class what to
|
|
62
|
+
* close and records each close here.
|
|
63
|
+
*/
|
|
64
|
+
export class ClientSessionRegistry {
|
|
65
|
+
protected readonly sessions = new Map<string, ClientSessionState>();
|
|
66
|
+
protected readonly clientsByUri = new Map<CanonicalUri, Set<string>>();
|
|
67
|
+
/** Each client's opens, with the options it opened them with. */
|
|
68
|
+
protected readonly opensByClient = new Map<string, Map<CanonicalUri, OpenOptions | undefined>>();
|
|
69
|
+
protected readonly reservedIds: ReadonlySet<string> = new Set(RESERVED_CLIENT_IDS);
|
|
70
|
+
protected readonly sessionClosedEmitter = new Emitter<ClientSessionClosedEvent>();
|
|
71
|
+
|
|
72
|
+
/** Fires once a session has been removed from the table, after all its opens closed. */
|
|
73
|
+
get onDidCloseSession(): Event<ClientSessionClosedEvent> {
|
|
74
|
+
return this.sessionClosedEmitter.event;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Enter `clientId` in the session table.
|
|
79
|
+
*
|
|
80
|
+
* Throws {@link DuplicateClientIdError} when the id is live anywhere in the
|
|
81
|
+
* process: reserved, registered (including by a session that is still
|
|
82
|
+
* closing), or holding opens as a client that is not a session. The last
|
|
83
|
+
* matters because the id is the author label and the echo key, and a
|
|
84
|
+
* session sharing one with another client would take that client's writes
|
|
85
|
+
* for its own.
|
|
86
|
+
*/
|
|
87
|
+
register(clientId: string): void {
|
|
88
|
+
if (this.reservedIds.has(clientId) || this.sessions.has(clientId) || this.opensByClient.has(clientId)) {
|
|
89
|
+
throw new DuplicateClientIdError(clientId);
|
|
90
|
+
}
|
|
91
|
+
this.sessions.set(clientId, 'live');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether `clientId` is in the session table, live or closing. */
|
|
95
|
+
isRegistered(clientId: string): boolean {
|
|
96
|
+
return this.sessions.has(clientId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Mark `clientId` as closing and return every URI it has open, as a copy the
|
|
101
|
+
* caller can close while this registry changes underneath it.
|
|
102
|
+
*/
|
|
103
|
+
beginClose(clientId: string): CanonicalUri[] {
|
|
104
|
+
if (this.sessions.has(clientId)) {
|
|
105
|
+
this.sessions.set(clientId, 'closing');
|
|
106
|
+
}
|
|
107
|
+
return this.opensOf(clientId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Remove `clientId` from the session table and announce it. A no-op for an id that is not registered. */
|
|
111
|
+
unregister(clientId: string): void {
|
|
112
|
+
if (!this.sessions.delete(clientId)) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
this.sessionClosedEmitter.fire(Object.freeze({ clientId }));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Record that `clientId` opened `uri`, with no options. Returns `false` when
|
|
120
|
+
* it already had it open, in which case nothing changes.
|
|
121
|
+
*
|
|
122
|
+
* Throws {@link SessionClosedError} for a session that is closing.
|
|
123
|
+
*/
|
|
124
|
+
addOpen(uri: CanonicalUri, clientId: string): boolean {
|
|
125
|
+
if (this.sessions.get(clientId) === 'closing') {
|
|
126
|
+
throw new SessionClosedError(clientId);
|
|
127
|
+
}
|
|
128
|
+
let opens = this.opensByClient.get(clientId);
|
|
129
|
+
if (opens?.has(uri)) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (!opens) {
|
|
133
|
+
opens = new Map();
|
|
134
|
+
this.opensByClient.set(clientId, opens);
|
|
135
|
+
}
|
|
136
|
+
opens.set(uri, undefined);
|
|
137
|
+
let clients = this.clientsByUri.get(uri);
|
|
138
|
+
if (!clients) {
|
|
139
|
+
clients = new Set();
|
|
140
|
+
this.clientsByUri.set(uri, clients);
|
|
141
|
+
}
|
|
142
|
+
clients.add(clientId);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Record that `clientId` closed `uri`. Returns `false` when it did not have it open. */
|
|
147
|
+
removeOpen(uri: CanonicalUri, clientId: string): boolean {
|
|
148
|
+
const opens = this.opensByClient.get(clientId);
|
|
149
|
+
if (!opens?.delete(uri)) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
if (opens.size === 0) {
|
|
153
|
+
this.opensByClient.delete(clientId);
|
|
154
|
+
}
|
|
155
|
+
const clients = this.clientsByUri.get(uri);
|
|
156
|
+
clients?.delete(clientId);
|
|
157
|
+
if (!clients?.size) {
|
|
158
|
+
this.clientsByUri.delete(uri);
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Whether `clientId` has `uri` open. */
|
|
164
|
+
isOpenIn(uri: CanonicalUri, clientId: string): boolean {
|
|
165
|
+
return !!this.clientsByUri.get(uri)?.has(clientId);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Whether any client has `uri` open. */
|
|
169
|
+
isOpen(uri: CanonicalUri): boolean {
|
|
170
|
+
return this.clientsByUri.has(uri);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The clients that have `uri` open, in the order they opened it. */
|
|
174
|
+
clientsOf(uri: CanonicalUri): string[] {
|
|
175
|
+
return [...(this.clientsByUri.get(uri) ?? [])];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The URIs `clientId` has open, in the order it opened them. */
|
|
179
|
+
opensOf(clientId: string): CanonicalUri[] {
|
|
180
|
+
return [...(this.opensByClient.get(clientId)?.keys() ?? [])];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Every document open in at least one client, with the clients that have it open. */
|
|
184
|
+
openDocuments(): Array<{ readonly uri: CanonicalUri; readonly clients: string[] }> {
|
|
185
|
+
return [...this.clientsByUri].map(([uri, clients]) => ({ uri, clients: [...clients] }));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The options `clientId` opened `uri` with, or `undefined` when it gave none or does not have it open. */
|
|
189
|
+
openOptions(uri: CanonicalUri, clientId: string): OpenOptions | undefined {
|
|
190
|
+
return this.opensByClient.get(clientId)?.get(uri);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Replace the options of an existing open. Does nothing when `clientId` does not have `uri` open. */
|
|
194
|
+
setOpenOptions(uri: CanonicalUri, clientId: string, options: OpenOptions | undefined): void {
|
|
195
|
+
const opens = this.opensByClient.get(clientId);
|
|
196
|
+
if (opens?.has(uri)) {
|
|
197
|
+
opens.set(uri, options);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Forget every session and open without announcing anything. Subscriptions
|
|
203
|
+
* stay, so a listener registered before the clear hears the sessions that
|
|
204
|
+
* end after it.
|
|
205
|
+
*/
|
|
206
|
+
clear(): void {
|
|
207
|
+
this.sessions.clear();
|
|
208
|
+
this.clientsByUri.clear();
|
|
209
|
+
this.opensByClient.clear();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// on lsp-server, contradicting the peer architecture.
|
|
21
21
|
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
|
22
22
|
import { NormalizedTextDocuments } from '@hydranium/langium/lsp';
|
|
23
|
-
import { UriUtils } from '@hydranium/langium';
|
|
23
|
+
import { type URI, UriUtils } from '@hydranium/langium';
|
|
24
24
|
import { type ServerSharedServices } from '../langium/module.js';
|
|
25
25
|
import {
|
|
26
26
|
type ApplyWorkspaceEditResult,
|
|
@@ -49,6 +49,8 @@ import { type DocumentUri, TextDocument, type TextDocumentContentChangeEvent } f
|
|
|
49
49
|
import { type CanonicalUri, type LanguageClientUri, asLanguageClientUri, DisposableCollection, type Tracer } from '@hydranium/protocol';
|
|
50
50
|
import { type LogNameOptions } from '../langium/diagnostics/logger.js';
|
|
51
51
|
import { LANGUAGE_CLIENT_ID } from './client-ids.js';
|
|
52
|
+
import { INTEGRITY_CLIENT_ID } from '../langium/integrity/integrity-rule.js';
|
|
53
|
+
import { type ClientSessionClosedEvent, ClientSessionRegistry, type OpenOptions } from './client-session-registry.js';
|
|
52
54
|
import { isFullReplace, LanguageClientTextShadow } from './language-client-text-shadow.js';
|
|
53
55
|
|
|
54
56
|
/**
|
|
@@ -97,8 +99,6 @@ export interface HydraniumTextDocumentsOptions<T extends TextDocument = TextDocu
|
|
|
97
99
|
* change that caused it.
|
|
98
100
|
*/
|
|
99
101
|
export interface DocumentTrackingRecord {
|
|
100
|
-
/** Client ids currently holding this document open (multi-client membership). */
|
|
101
|
-
readonly clients: Set<string>;
|
|
102
102
|
/** Author of each version, sparse-indexed by the SHARED (server-assigned) version number. */
|
|
103
103
|
readonly versionAuthors: string[];
|
|
104
104
|
/**
|
|
@@ -252,7 +252,8 @@ function contentHash(text: string): string {
|
|
|
252
252
|
* Multi-client text-document tracking on top of Langium's `NormalizedTextDocuments`.
|
|
253
253
|
*
|
|
254
254
|
* Adds the framework features used by the integrity, model-server, and GLSP layers:
|
|
255
|
-
* - Per-document client membership (multiple clients can attach to the same
|
|
255
|
+
* - Per-document client membership (multiple clients can attach to the same
|
|
256
|
+
* URI) and the client-session table, both kept by {@link __sessions}.
|
|
256
257
|
* - A SERVER-OWNED shared version sequence: per-URI, monotonic across
|
|
257
258
|
* close/reopen cycles, advancing exactly when the synced content changes.
|
|
258
259
|
* Client-declared version ids (Monaco's buffer numbering) feed only a
|
|
@@ -270,15 +271,20 @@ function contentHash(text: string): string {
|
|
|
270
271
|
*/
|
|
271
272
|
export class HydraniumTextDocuments<T extends TextDocument = TextDocument> extends NormalizedTextDocuments<T> {
|
|
272
273
|
/**
|
|
273
|
-
* Per-URI client-facing tracking ({@link DocumentTrackingRecord})
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
* {@link __shadow} (its own abstraction); the parsed-document store stays in
|
|
278
|
-
* the inherited `__syncedDocuments`.
|
|
274
|
+
* Per-URI client-facing tracking ({@link DocumentTrackingRecord}), keyed by
|
|
275
|
+
* canonical URI. One record per URI, so dropping it clears every axis at
|
|
276
|
+
* once. Which client has the document open is kept apart, in
|
|
277
|
+
* {@link __sessions}.
|
|
279
278
|
*/
|
|
280
279
|
protected __documents = new Map<CanonicalUri, DocumentTrackingRecord>();
|
|
281
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Which client has which document open, and which client ids are registered
|
|
283
|
+
* sessions. Every open-state predicate on this class reads it, so an open
|
|
284
|
+
* recorded anywhere else is invisible to the last-close transition.
|
|
285
|
+
*/
|
|
286
|
+
protected readonly __sessions = new ClientSessionRegistry();
|
|
287
|
+
|
|
282
288
|
/**
|
|
283
289
|
* Per-URI shared-version continuity across close/reopen cycles
|
|
284
290
|
* ({@link VersionSequence}), written at last-client close and consulted by
|
|
@@ -496,7 +502,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
496
502
|
// the update) from "incoming version older than ours" (stale race).
|
|
497
503
|
const reason =
|
|
498
504
|
lastSeen === td.version ? `already at version ${lastSeen}` : `incoming version ${td.version} older than current ${lastSeen}`;
|
|
499
|
-
this.logUri(uri, `Ignore update by ${clientId}: ${reason}`, 'debug');
|
|
505
|
+
this.logUri(uri, `Ignore update by ${this.formatClientId(clientId)}: ${reason}`, 'debug');
|
|
500
506
|
return;
|
|
501
507
|
}
|
|
502
508
|
record.clientVersions.set(clientId, td.version);
|
|
@@ -562,7 +568,10 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
562
568
|
return;
|
|
563
569
|
}
|
|
564
570
|
}
|
|
565
|
-
this.log(
|
|
571
|
+
this.log(
|
|
572
|
+
document.uri,
|
|
573
|
+
`Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`
|
|
574
|
+
);
|
|
566
575
|
this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
|
|
567
576
|
}
|
|
568
577
|
}
|
|
@@ -596,22 +605,23 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
596
605
|
this.__syncedDocuments.set(key, document);
|
|
597
606
|
this.setAuthor(key, document.version, clientId);
|
|
598
607
|
}
|
|
599
|
-
this.log(
|
|
608
|
+
this.log(
|
|
609
|
+
document.uri,
|
|
610
|
+
`Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`
|
|
611
|
+
);
|
|
600
612
|
this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
|
|
601
613
|
return document.version;
|
|
602
614
|
}
|
|
603
615
|
|
|
604
616
|
public notifyDidCloseTextDocument(event: DidCloseTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
|
|
605
617
|
const uri = this.documentKey(event.textDocument.uri);
|
|
606
|
-
if (!this.
|
|
618
|
+
if (!this.__sessions.removeOpen(uri, clientId)) {
|
|
607
619
|
return;
|
|
608
620
|
}
|
|
609
|
-
|
|
610
|
-
closingRecord?.clients.delete(clientId);
|
|
611
|
-
closingRecord?.clientVersions.delete(clientId);
|
|
621
|
+
this.__documents.get(uri)?.clientVersions.delete(clientId);
|
|
612
622
|
const syncedDocument = this.__syncedDocuments.get(uri);
|
|
613
623
|
if (syncedDocument !== undefined) {
|
|
614
|
-
this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${clientId}`);
|
|
624
|
+
this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
|
|
615
625
|
this.__onDidClose.fire(Object.freeze({ document: syncedDocument, clientId }));
|
|
616
626
|
|
|
617
627
|
if (clientId === LANGUAGE_CLIENT_ID) {
|
|
@@ -623,7 +633,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
623
633
|
this.__shadow.invalidate(droppedUri);
|
|
624
634
|
this.__pendingPushes.delete(droppedUri);
|
|
625
635
|
}
|
|
626
|
-
if (!this.
|
|
636
|
+
if (!this.__sessions.isOpen(uri)) {
|
|
627
637
|
// Last client closed the document; delete sync state. The downstream
|
|
628
638
|
// "rebuild from disk for file URIs / drop from index for ephemeral
|
|
629
639
|
// URIs" decision lives in `HydraniumDocumentUpdateHandler.didCloseDocument`,
|
|
@@ -668,7 +678,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
668
678
|
public notifyDidSaveTextDocument(event: DidSaveTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
|
|
669
679
|
const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
|
|
670
680
|
if (syncedDocument !== undefined) {
|
|
671
|
-
this.log(syncedDocument.uri, `Saved synced document: ${syncedDocument.version} by ${clientId}`);
|
|
681
|
+
this.log(syncedDocument.uri, `Saved synced document: ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
|
|
672
682
|
this.__onDidSave.fire(Object.freeze({ document: syncedDocument, clientId }));
|
|
673
683
|
}
|
|
674
684
|
}
|
|
@@ -698,9 +708,9 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
698
708
|
return;
|
|
699
709
|
}
|
|
700
710
|
let document = this.__syncedDocuments.get(uri);
|
|
711
|
+
const existingClients = this.__sessions.clientsOf(uri);
|
|
712
|
+
this.__sessions.addOpen(uri, clientId);
|
|
701
713
|
const record = this.trackingFor(uri);
|
|
702
|
-
const existingClients = [...record.clients];
|
|
703
|
-
record.clients.add(clientId);
|
|
704
714
|
// Baseline the per-client staleness guard at the version id the client
|
|
705
715
|
// declared for its own buffer (client-owned per LSP).
|
|
706
716
|
record.clientVersions.set(clientId, td.version);
|
|
@@ -723,7 +733,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
723
733
|
const sequence = this.__versionSequences.get(uri);
|
|
724
734
|
const version =
|
|
725
735
|
sequence === undefined ? td.version : sequence.contentHash === contentHash(text) ? sequence.version : sequence.version + 1;
|
|
726
|
-
this.log(uri, `Open document: Version ${version} by ${clientId} [first client${source}]`);
|
|
736
|
+
this.log(uri, `Open document: Version ${version} by ${this.formatClientId(clientId)} [first client${source}]`);
|
|
727
737
|
document = this.configuration.create(uri, td.languageId, version, text);
|
|
728
738
|
this.__syncedDocuments.set(uri, document);
|
|
729
739
|
this.setAuthor(uri, version, clientId);
|
|
@@ -786,9 +796,9 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
786
796
|
if (!document || this.isOpenInClient(key, clientId)) {
|
|
787
797
|
return false;
|
|
788
798
|
}
|
|
799
|
+
const existingClients = this.__sessions.clientsOf(key);
|
|
800
|
+
this.__sessions.addOpen(key, clientId);
|
|
789
801
|
const record = this.trackingFor(key);
|
|
790
|
-
const existingClients = [...record.clients];
|
|
791
|
-
record.clients.add(clientId);
|
|
792
802
|
record.clientVersions.set(clientId, document.version);
|
|
793
803
|
this.logClientJoined(key, clientId, document.version, existingClients);
|
|
794
804
|
return true;
|
|
@@ -797,8 +807,8 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
797
807
|
protected logClientJoined(uri: DocumentUri, clientId: string, version: number, existingClients: readonly string[]): void {
|
|
798
808
|
this.log(
|
|
799
809
|
uri,
|
|
800
|
-
`Attach client: ${clientId} joined existing document (version ${version}, ` +
|
|
801
|
-
`now open in: ${[...existingClients, clientId].join(', ')})`
|
|
810
|
+
`Attach client: ${this.formatClientId(clientId)} joined existing document (version ${version}, ` +
|
|
811
|
+
`now open in: ${[...existingClients, clientId].map(id => this.formatClientId(id)).join(', ')})`
|
|
802
812
|
);
|
|
803
813
|
}
|
|
804
814
|
|
|
@@ -808,7 +818,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
808
818
|
// Trigger a (re-)build by firing a change event.
|
|
809
819
|
const timer = this.startTimerForUri(
|
|
810
820
|
syncedDocument.uri,
|
|
811
|
-
`Refresh synced document: Version ${syncedDocument.version} by ${clientId}`
|
|
821
|
+
`Refresh synced document: Version ${syncedDocument.version} by ${this.formatClientId(clientId)}`
|
|
812
822
|
);
|
|
813
823
|
this.__onDidChangeContent.fire(Object.freeze({ document: syncedDocument, clientId }));
|
|
814
824
|
timer.dispose();
|
|
@@ -846,7 +856,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
846
856
|
protected trackingFor(uri: CanonicalUri): DocumentTrackingRecord {
|
|
847
857
|
let record = this.__documents.get(uri);
|
|
848
858
|
if (!record) {
|
|
849
|
-
record = {
|
|
859
|
+
record = { versionAuthors: [], clientVersions: new Map() };
|
|
850
860
|
this.__documents.set(uri, record);
|
|
851
861
|
}
|
|
852
862
|
return record;
|
|
@@ -946,10 +956,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
946
956
|
* its own numbering, so requiring the two to agree would refuse every commit
|
|
947
957
|
* on the path this exists for. Content is the thing both sides can be held to.
|
|
948
958
|
*
|
|
949
|
-
*
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
*
|
|
959
|
+
* A changed text is a new version authored by {@link INTEGRITY_CLIENT_ID}.
|
|
960
|
+
* Kept at the old version, the repair is invisible to every reader keyed on
|
|
961
|
+
* versions: a write based on the unrepaired version passes the based-on gate
|
|
962
|
+
* and replaces the repair, and an echo filter credits the repair to the
|
|
963
|
+
* client whose edit it corrected. No change event is fired: the repair
|
|
964
|
+
* rides the build already under way, and an event would re-enter it.
|
|
953
965
|
*/
|
|
954
966
|
commitRepair(uri: DocumentUri, parsedFrom: string, repaired: string): RepairCommit<T> {
|
|
955
967
|
const key = this.documentKey(uri);
|
|
@@ -961,11 +973,16 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
961
973
|
this.logUri(key, 'Refuse repair commit: the open document moved on from the text the AST was parsed from', 'debug');
|
|
962
974
|
return { status: 'stale' };
|
|
963
975
|
}
|
|
976
|
+
if (repaired === parsedFrom) {
|
|
977
|
+
return { status: 'committed', document };
|
|
978
|
+
}
|
|
964
979
|
// Reassigned rather than mutated in place: the default configuration
|
|
965
980
|
// updates and returns the SAME instance, but an adopter-supplied one may
|
|
966
981
|
// return a new object, and the store must end up holding whichever it is.
|
|
967
|
-
const updated = this.configuration.update(document, [{ text: repaired }], document.version);
|
|
982
|
+
const updated = this.configuration.update(document, [{ text: repaired }], document.version + 1);
|
|
968
983
|
this.__syncedDocuments.set(key, updated);
|
|
984
|
+
this.setAuthor(key, updated.version, INTEGRITY_CLIENT_ID);
|
|
985
|
+
this.log(updated.uri, `Update to version ${updated.version} by ${this.formatClientId(INTEGRITY_CLIENT_ID)} (repair)`);
|
|
969
986
|
return { status: 'committed', document: updated };
|
|
970
987
|
}
|
|
971
988
|
|
|
@@ -989,17 +1006,17 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
989
1006
|
* True iff any client still holds `uri` open. Distinct from {@link isOpen},
|
|
990
1007
|
* which reads `__syncedDocuments` — that map is cleared only AFTER the
|
|
991
1008
|
* `onDidClose` event fires for the last client, so `isOpen` returns `true`
|
|
992
|
-
* during the close event itself. `isOpenInAnyClient` reads the
|
|
993
|
-
*
|
|
1009
|
+
* during the close event itself. `isOpenInAnyClient` reads the open table
|
|
1010
|
+
* ({@link __sessions}), which is updated BEFORE the fire, so an `onDidClose`
|
|
994
1011
|
* subscriber that finds this `false` knows the last client just closed and a
|
|
995
1012
|
* disk re-read / rebuild can proceed.
|
|
996
1013
|
*/
|
|
997
1014
|
isOpenInAnyClient(uri: DocumentUri): boolean {
|
|
998
|
-
return
|
|
1015
|
+
return this.__sessions.isOpen(this.documentKey(uri));
|
|
999
1016
|
}
|
|
1000
1017
|
|
|
1001
1018
|
isOpenInClient(uri: DocumentUri, client: string): boolean {
|
|
1002
|
-
return
|
|
1019
|
+
return this.__sessions.isOpenIn(this.documentKey(uri), client);
|
|
1003
1020
|
}
|
|
1004
1021
|
|
|
1005
1022
|
isOpenInLanguageClient(uri: DocumentUri): boolean {
|
|
@@ -1010,14 +1027,14 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1010
1027
|
}
|
|
1011
1028
|
|
|
1012
1029
|
isOnlyOpenInClient(uri: DocumentUri, client: string): boolean {
|
|
1013
|
-
const
|
|
1014
|
-
return
|
|
1030
|
+
const clients = this.__sessions.clientsOf(this.documentKey(uri));
|
|
1031
|
+
return clients.length === 1 && clients[0] === client;
|
|
1015
1032
|
}
|
|
1016
1033
|
|
|
1017
1034
|
/**
|
|
1018
1035
|
* Every document currently held open by at least one client, with the client
|
|
1019
|
-
* ids holding it. Reads the same
|
|
1020
|
-
*
|
|
1036
|
+
* ids holding it. Reads the same open table as {@link isOpenInAnyClient}, so
|
|
1037
|
+
* it reflects a last-client close immediately.
|
|
1021
1038
|
*
|
|
1022
1039
|
* Diagnostics-oriented — the server-state snapshot lists these so an operator
|
|
1023
1040
|
* can see WHY a document is pinned: a document that lingers here after its
|
|
@@ -1025,30 +1042,116 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1025
1042
|
* under a shedding policy, explains why its CST is never shed).
|
|
1026
1043
|
*/
|
|
1027
1044
|
openDocuments(): OpenDocument[] {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1045
|
+
return this.__sessions.openDocuments();
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** Fires once a client session has ended, after every document it had open was closed. */
|
|
1049
|
+
get onDidCloseSession(): Event<ClientSessionClosedEvent> {
|
|
1050
|
+
return this.__sessions.onDidCloseSession;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Start a client session under `clientId`. Throws `DuplicateClientIdError`
|
|
1055
|
+
* where {@link ClientSessionRegistry.register} refuses the id.
|
|
1056
|
+
*/
|
|
1057
|
+
registerSession(clientId: string): void {
|
|
1058
|
+
this.__sessions.register(clientId);
|
|
1059
|
+
this.tracer.info(`Session started: ${this.formatClientId(clientId)}`);
|
|
1060
|
+
this.tracer.trace(`Session started: ${clientId}`);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* End the client session `clientId`: close every document it has open, then
|
|
1065
|
+
* free the id. Immediate — each close runs the ordinary close path before
|
|
1066
|
+
* this returns. A no-op for an id that is not a registered session.
|
|
1067
|
+
*/
|
|
1068
|
+
closeSession(clientId: string): void {
|
|
1069
|
+
if (!this.__sessions.isRegistered(clientId)) {
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
try {
|
|
1073
|
+
for (const uri of this.__sessions.beginClose(clientId)) {
|
|
1074
|
+
this.notifyDidCloseTextDocument({ textDocument: { uri } }, clientId);
|
|
1075
|
+
}
|
|
1076
|
+
} finally {
|
|
1077
|
+
this.__sessions.unregister(clientId);
|
|
1078
|
+
this.tracer.info(`Session closed: ${this.formatClientId(clientId)}`);
|
|
1079
|
+
this.tracer.trace(`Session closed: ${clientId}`);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** The options `clientId` opened `uri` with, or `undefined` when it gave none or does not have it open. */
|
|
1084
|
+
openOptions(uri: DocumentUri, clientId: string): OpenOptions | undefined {
|
|
1085
|
+
return this.__sessions.openOptions(this.documentKey(uri), clientId);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/** Replace the options of an existing open. Does nothing when `clientId` does not have `uri` open. */
|
|
1089
|
+
setOpenOptions(uri: DocumentUri, clientId: string, options: OpenOptions | undefined): void {
|
|
1090
|
+
this.__sessions.setOpenOptions(this.documentKey(uri), clientId, options);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* The file behind `uri` was deleted: close every open of it except the
|
|
1095
|
+
* language client's.
|
|
1096
|
+
*
|
|
1097
|
+
* The editor's open is left alone because the editor owns it: it keeps the
|
|
1098
|
+
* buffer of a deleted file and goes on sending changes for it, and a store
|
|
1099
|
+
* that had closed the document would drop every one of them until the editor
|
|
1100
|
+
* reopened.
|
|
1101
|
+
*/
|
|
1102
|
+
notifyDocumentDeleted(uri: DocumentUri): void {
|
|
1103
|
+
const key = this.documentKey(uri);
|
|
1104
|
+
for (const clientId of this.__sessions.clientsOf(key)) {
|
|
1105
|
+
if (clientId !== LANGUAGE_CLIENT_ID) {
|
|
1106
|
+
this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
|
|
1032
1107
|
}
|
|
1033
1108
|
}
|
|
1034
|
-
return result;
|
|
1035
1109
|
}
|
|
1036
1110
|
|
|
1037
1111
|
/**
|
|
1038
|
-
*
|
|
1112
|
+
* Remove the document for `uri`, closing every open of it first — the
|
|
1113
|
+
* editor's included, since the document it would keep editing is gone.
|
|
1114
|
+
*
|
|
1115
|
+
* The base removes the synced document without consulting the open table,
|
|
1116
|
+
* which leaves every open recorded against a document that no longer exists:
|
|
1117
|
+
* the store then answers that the URI is open, and the next open attaches to
|
|
1118
|
+
* nothing.
|
|
1119
|
+
*/
|
|
1120
|
+
override delete(uri: string | URI | T): void {
|
|
1121
|
+
const key = this.documentKey((typeof uri === 'object' && 'uri' in uri ? uri.uri : uri).toString());
|
|
1122
|
+
for (const clientId of this.__sessions.clientsOf(key)) {
|
|
1123
|
+
this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
|
|
1124
|
+
}
|
|
1125
|
+
super.delete(key);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* A client id as log lines print it. An id containing `#` is cut eight
|
|
1130
|
+
* characters after its last `#`, which leaves a minted `label#uuid` as the
|
|
1131
|
+
* label and enough of the UUID to tell sessions apart; an id without `#`
|
|
1132
|
+
* prints whole. The full id goes out at trace level wherever a session
|
|
1133
|
+
* starts or ends.
|
|
1134
|
+
*/
|
|
1135
|
+
protected formatClientId(clientId: string): string {
|
|
1136
|
+
const separator = clientId.lastIndexOf('#');
|
|
1137
|
+
return separator < 0 ? clientId : clientId.slice(0, separator + 9);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Stages integrity-updated content for a document no client holds.
|
|
1039
1142
|
*
|
|
1040
1143
|
* When `workspace/applyEdit` targets a closed file, the client opens it from
|
|
1041
1144
|
* disk (stale) and sends `didOpen` before applying the edit. This staged
|
|
1042
1145
|
* content is consumed by {@link notifyDidOpenTextDocument} to replace the
|
|
1043
1146
|
* stale disk text, preventing a brief revert of the integrity update.
|
|
1044
1147
|
*
|
|
1045
|
-
* Only a FIRST open consumes it
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
* tracking record.
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1148
|
+
* Only a FIRST open consumes it, so stage only for a URI no client holds
|
|
1149
|
+
* ({@link isOpenInAnyClient} is `false`). A URI held only through another
|
|
1150
|
+
* head is not closed: an editor attaching to it joins the existing entry and
|
|
1151
|
+
* never reads the stage, and the last close discards the stage with the
|
|
1152
|
+
* tracking record. An entry lingers only if `workspace/applyEdit` fails and
|
|
1153
|
+
* the file is never opened — the memory cost is one serialised string per
|
|
1154
|
+
* URI.
|
|
1052
1155
|
*/
|
|
1053
1156
|
stagePendingContent(uri: DocumentUri, text: string): void {
|
|
1054
1157
|
this.trackingFor(this.documentKey(uri)).pendingContent = text;
|
package/src/documents/index.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
********************************************************************************/
|
|
9
9
|
|
|
10
10
|
export * from './client-ids.js';
|
|
11
|
+
export * from './client-session-errors.js';
|
|
12
|
+
export * from './client-session-registry.js';
|
|
11
13
|
export * from './language-client-text-shadow.js';
|
|
12
14
|
export * from './hydranium-text-documents.js';
|
|
13
15
|
export * from './ast-document-manager.js';
|
|
@@ -49,14 +49,22 @@ export namespace IntegrityPhase {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
* Controls how integrity corrections are persisted for
|
|
53
|
-
* mode does not reach
|
|
54
|
-
*
|
|
52
|
+
* Controls how integrity corrections are persisted for files no editor holds.
|
|
53
|
+
* The mode does not reach a file open in the language client: the correction
|
|
54
|
+
* is delivered to the editor, which shows it as an unsaved change.
|
|
55
55
|
*
|
|
56
|
-
* - `silent`: write to disk immediately; no editor involvement.
|
|
56
|
+
* - `silent`: write to disk immediately; no editor involvement. For a file
|
|
57
|
+
* held only through the data or GLSP head, only while disk still holds the
|
|
58
|
+
* text the correction was computed from and the text store still holds the
|
|
59
|
+
* correction. Otherwise — the holder has unsaved edits, disk changed behind
|
|
60
|
+
* the server, or disk cannot be read — the correction waits in the text
|
|
61
|
+
* store for the next save.
|
|
57
62
|
* - `editor`: open the file dirty via `applyEdit`. If the user closes without
|
|
58
63
|
* saving, the correction is suppressed until the file changes or the
|
|
59
|
-
* workspace reloads.
|
|
64
|
+
* workspace reloads. A file held only through the data or GLSP head gets
|
|
65
|
+
* neither: the correction stays in the text store, and since those heads
|
|
66
|
+
* mark only their own edits unsaved, nothing shows that store and disk
|
|
67
|
+
* differ until a save or the last close.
|
|
60
68
|
*/
|
|
61
69
|
export type IntegritySyncMode = 'silent' | 'editor';
|
|
62
70
|
|