@hydranium/core 1.0.0-next.5 → 1.0.0-next.51

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.
Files changed (236) hide show
  1. package/lib/documents/ast-document-manager.d.ts +42 -5
  2. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  3. package/lib/documents/ast-document-manager.js +9 -12
  4. package/lib/documents/ast-document-manager.js.map +1 -1
  5. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  6. package/lib/documents/hydranium-text-documents.js +41 -9
  7. package/lib/documents/hydranium-text-documents.js.map +1 -1
  8. package/lib/documents/language-client-text-shadow.d.ts +24 -1
  9. package/lib/documents/language-client-text-shadow.d.ts.map +1 -1
  10. package/lib/documents/language-client-text-shadow.js +36 -2
  11. package/lib/documents/language-client-text-shadow.js.map +1 -1
  12. package/lib/documents/self-save-registry.d.ts +37 -6
  13. package/lib/documents/self-save-registry.d.ts.map +1 -1
  14. package/lib/documents/self-save-registry.js +28 -15
  15. package/lib/documents/self-save-registry.js.map +1 -1
  16. package/lib/index.d.ts +2 -0
  17. package/lib/index.d.ts.map +1 -1
  18. package/lib/index.js +5 -0
  19. package/lib/index.js.map +1 -1
  20. package/lib/langium/ast-extension/ast-node-builder.d.ts +32 -9
  21. package/lib/langium/ast-extension/ast-node-builder.d.ts.map +1 -1
  22. package/lib/langium/ast-extension/ast-node-builder.js +60 -22
  23. package/lib/langium/ast-extension/ast-node-builder.js.map +1 -1
  24. package/lib/langium/bootstrap.d.ts.map +1 -1
  25. package/lib/langium/bootstrap.js +16 -9
  26. package/lib/langium/bootstrap.js.map +1 -1
  27. package/lib/langium/document-builder/build-pipeline-integration.d.ts +13 -1
  28. package/lib/langium/document-builder/build-pipeline-integration.d.ts.map +1 -1
  29. package/lib/langium/document-builder/build-pipeline-integration.js +1 -1
  30. package/lib/langium/document-builder/build-pipeline-integration.js.map +1 -1
  31. package/lib/langium/document-builder/build-session.d.ts +93 -0
  32. package/lib/langium/document-builder/build-session.d.ts.map +1 -0
  33. package/lib/langium/document-builder/build-session.js +72 -0
  34. package/lib/langium/document-builder/build-session.js.map +1 -0
  35. package/lib/langium/document-builder/document-builder.d.ts +150 -7
  36. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  37. package/lib/langium/document-builder/document-builder.js +298 -11
  38. package/lib/langium/document-builder/document-builder.js.map +1 -1
  39. package/lib/langium/document-builder/index.d.ts +1 -0
  40. package/lib/langium/document-builder/index.d.ts.map +1 -1
  41. package/lib/langium/document-builder/index.js +1 -0
  42. package/lib/langium/document-builder/index.js.map +1 -1
  43. package/lib/langium/integration-services.d.ts +45 -1
  44. package/lib/langium/integration-services.d.ts.map +1 -1
  45. package/lib/langium/integration-services.js +10 -5
  46. package/lib/langium/integration-services.js.map +1 -1
  47. package/lib/langium/integrity/integrity-service.d.ts +5 -0
  48. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  49. package/lib/langium/integrity/integrity-service.js +34 -1
  50. package/lib/langium/integrity/integrity-service.js.map +1 -1
  51. package/lib/langium/keys/containment.d.ts +64 -0
  52. package/lib/langium/keys/containment.d.ts.map +1 -0
  53. package/lib/langium/keys/containment.js +53 -0
  54. package/lib/langium/keys/containment.js.map +1 -0
  55. package/lib/langium/keys/index.d.ts +1 -0
  56. package/lib/langium/keys/index.d.ts.map +1 -1
  57. package/lib/langium/keys/index.js +1 -0
  58. package/lib/langium/keys/index.js.map +1 -1
  59. package/lib/langium/keys/name-based-key-provider.d.ts +4 -0
  60. package/lib/langium/keys/name-based-key-provider.d.ts.map +1 -1
  61. package/lib/langium/keys/name-based-key-provider.js +4 -0
  62. package/lib/langium/keys/name-based-key-provider.js.map +1 -1
  63. package/lib/langium/model-service/model-service.d.ts +103 -7
  64. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  65. package/lib/langium/model-service/model-service.js +70 -89
  66. package/lib/langium/model-service/model-service.js.map +1 -1
  67. package/lib/langium/module.d.ts +82 -13
  68. package/lib/langium/module.d.ts.map +1 -1
  69. package/lib/langium/module.js +21 -12
  70. package/lib/langium/module.js.map +1 -1
  71. package/lib/langium/naming/name-provider.d.ts +10 -0
  72. package/lib/langium/naming/name-provider.d.ts.map +1 -1
  73. package/lib/langium/naming/name-provider.js.map +1 -1
  74. package/lib/langium/naming/name-separator-validation.d.ts +23 -0
  75. package/lib/langium/naming/name-separator-validation.d.ts.map +1 -1
  76. package/lib/langium/naming/name-separator-validation.js +30 -1
  77. package/lib/langium/naming/name-separator-validation.js.map +1 -1
  78. package/lib/langium/residency/cst-residency-service.d.ts +31 -9
  79. package/lib/langium/residency/cst-residency-service.d.ts.map +1 -1
  80. package/lib/langium/residency/cst-residency-service.js +13 -56
  81. package/lib/langium/residency/cst-residency-service.js.map +1 -1
  82. package/lib/langium/scope/hydranium-scope-provider.d.ts +32 -13
  83. package/lib/langium/scope/hydranium-scope-provider.d.ts.map +1 -1
  84. package/lib/langium/scope/hydranium-scope-provider.js +37 -20
  85. package/lib/langium/scope/hydranium-scope-provider.js.map +1 -1
  86. package/lib/langium/shared-services.d.ts +15 -2
  87. package/lib/langium/shared-services.d.ts.map +1 -1
  88. package/lib/langium/shared-services.js.map +1 -1
  89. package/lib/langium/transfer/transfer-encoder.d.ts +33 -2
  90. package/lib/langium/transfer/transfer-encoder.d.ts.map +1 -1
  91. package/lib/langium/transfer/transfer-encoder.js +14 -3
  92. package/lib/langium/transfer/transfer-encoder.js.map +1 -1
  93. package/lib/langium/validation/document-validator.d.ts +145 -6
  94. package/lib/langium/validation/document-validator.d.ts.map +1 -1
  95. package/lib/langium/validation/document-validator.js +211 -4
  96. package/lib/langium/validation/document-validator.js.map +1 -1
  97. package/lib/langium/workspace/document-uri-policy.d.ts +3 -4
  98. package/lib/langium/workspace/document-uri-policy.d.ts.map +1 -1
  99. package/lib/langium/workspace/document-uri-policy.js +3 -4
  100. package/lib/langium/workspace/document-uri-policy.js.map +1 -1
  101. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts +25 -0
  102. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts.map +1 -1
  103. package/lib/langium/workspace/hydranium-langium-document-factory.js +25 -0
  104. package/lib/langium/workspace/hydranium-langium-document-factory.js.map +1 -1
  105. package/lib/langium/workspace/hydranium-workspace-manager.d.ts +21 -1
  106. package/lib/langium/workspace/hydranium-workspace-manager.d.ts.map +1 -1
  107. package/lib/langium/workspace/hydranium-workspace-manager.js +28 -0
  108. package/lib/langium/workspace/hydranium-workspace-manager.js.map +1 -1
  109. package/lib/langium/workspace/in-memory-file-system-provider.d.ts +8 -0
  110. package/lib/langium/workspace/in-memory-file-system-provider.d.ts.map +1 -1
  111. package/lib/langium/workspace/in-memory-file-system-provider.js +11 -2
  112. package/lib/langium/workspace/in-memory-file-system-provider.js.map +1 -1
  113. package/lib/langium/workspace/initialize-workspace.d.ts +22 -2
  114. package/lib/langium/workspace/initialize-workspace.d.ts.map +1 -1
  115. package/lib/langium/workspace/initialize-workspace.js +13 -5
  116. package/lib/langium/workspace/initialize-workspace.js.map +1 -1
  117. package/lib/langium/workspace/langium-documents.d.ts +79 -17
  118. package/lib/langium/workspace/langium-documents.d.ts.map +1 -1
  119. package/lib/langium/workspace/langium-documents.js +83 -28
  120. package/lib/langium/workspace/langium-documents.js.map +1 -1
  121. package/lib/locale/index.d.ts +10 -0
  122. package/lib/locale/index.d.ts.map +1 -0
  123. package/lib/locale/index.js +10 -0
  124. package/lib/locale/index.js.map +1 -0
  125. package/lib/locale/server-locale.d.ts +73 -0
  126. package/lib/locale/server-locale.d.ts.map +1 -0
  127. package/lib/locale/server-locale.js +61 -0
  128. package/lib/locale/server-locale.js.map +1 -0
  129. package/lib/lsp/hydranium-document-update-handler.js +1 -1
  130. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  131. package/lib/messages/carriers.d.ts +54 -0
  132. package/lib/messages/carriers.d.ts.map +1 -0
  133. package/lib/messages/carriers.js +62 -0
  134. package/lib/messages/carriers.js.map +1 -0
  135. package/lib/messages/index.d.ts +25 -0
  136. package/lib/messages/index.d.ts.map +1 -0
  137. package/lib/messages/index.js +25 -0
  138. package/lib/messages/index.js.map +1 -0
  139. package/lib/messages/renderer.d.ts +136 -0
  140. package/lib/messages/renderer.d.ts.map +1 -0
  141. package/lib/messages/renderer.js +159 -0
  142. package/lib/messages/renderer.js.map +1 -0
  143. package/lib/node/node-file-system-provider.d.ts.map +1 -1
  144. package/lib/node/node-file-system-provider.js +1 -40
  145. package/lib/node/node-file-system-provider.js.map +1 -1
  146. package/lib/node/profiling-run.js +1 -1
  147. package/lib/node/profiling-run.js.map +1 -1
  148. package/lib/node/rename-over-open-readers.d.ts +52 -0
  149. package/lib/node/rename-over-open-readers.d.ts.map +1 -0
  150. package/lib/node/rename-over-open-readers.js +66 -0
  151. package/lib/node/rename-over-open-readers.js.map +1 -0
  152. package/lib/testing/fake-document.d.ts +14 -3
  153. package/lib/testing/fake-document.d.ts.map +1 -1
  154. package/lib/testing/fake-document.js +18 -3
  155. package/lib/testing/fake-document.js.map +1 -1
  156. package/lib/testing/make-noop-shared-services.d.ts +14 -0
  157. package/lib/testing/make-noop-shared-services.d.ts.map +1 -1
  158. package/lib/testing/make-noop-shared-services.js +28 -2
  159. package/lib/testing/make-noop-shared-services.js.map +1 -1
  160. package/lib/testing/make-test-services.d.ts +31 -4
  161. package/lib/testing/make-test-services.d.ts.map +1 -1
  162. package/lib/testing/make-test-services.js +19 -3
  163. package/lib/testing/make-test-services.js.map +1 -1
  164. package/lib/testing/node/spawned-server.d.ts +14 -0
  165. package/lib/testing/node/spawned-server.d.ts.map +1 -1
  166. package/lib/testing/node/spawned-server.js +33 -1
  167. package/lib/testing/node/spawned-server.js.map +1 -1
  168. package/lib/testing/playwright/server-log-capture.d.ts +14 -4
  169. package/lib/testing/playwright/server-log-capture.d.ts.map +1 -1
  170. package/lib/testing/playwright/server-log-capture.js +21 -4
  171. package/lib/testing/playwright/server-log-capture.js.map +1 -1
  172. package/lib/testing/playwright/server-log-rename-reporter.d.ts.map +1 -1
  173. package/lib/testing/playwright/server-log-rename-reporter.js +10 -1
  174. package/lib/testing/playwright/server-log-rename-reporter.js.map +1 -1
  175. package/lib/testing/stub-ast-document-manager.d.ts +3 -4
  176. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  177. package/lib/testing/stub-ast-document-manager.js +2 -5
  178. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  179. package/lib/testing/stub-document-builder.d.ts +8 -0
  180. package/lib/testing/stub-document-builder.d.ts.map +1 -1
  181. package/lib/testing/stub-document-builder.js +22 -3
  182. package/lib/testing/stub-document-builder.js.map +1 -1
  183. package/lib/testing/stub-model-service.d.ts +1 -1
  184. package/lib/testing/stub-model-service.d.ts.map +1 -1
  185. package/lib/testing/stub-model-service.js +2 -2
  186. package/lib/testing/stub-model-service.js.map +1 -1
  187. package/package.json +17 -8
  188. package/src/documents/ast-document-manager.ts +55 -14
  189. package/src/documents/hydranium-text-documents.ts +41 -9
  190. package/src/documents/language-client-text-shadow.ts +38 -2
  191. package/src/documents/self-save-registry.ts +52 -13
  192. package/src/index.ts +5 -0
  193. package/src/langium/ast-extension/ast-node-builder.ts +66 -22
  194. package/src/langium/bootstrap.ts +16 -9
  195. package/src/langium/document-builder/build-pipeline-integration.ts +14 -1
  196. package/src/langium/document-builder/build-session.ts +87 -0
  197. package/src/langium/document-builder/document-builder.ts +339 -11
  198. package/src/langium/document-builder/index.ts +1 -0
  199. package/src/langium/integration-services.ts +67 -6
  200. package/src/langium/integrity/integrity-service.ts +35 -1
  201. package/src/langium/keys/containment.ts +87 -0
  202. package/src/langium/keys/index.ts +1 -0
  203. package/src/langium/keys/name-based-key-provider.ts +4 -0
  204. package/src/langium/model-service/model-service.ts +145 -22
  205. package/src/langium/module.ts +99 -19
  206. package/src/langium/naming/name-provider.ts +10 -0
  207. package/src/langium/naming/name-separator-validation.ts +34 -5
  208. package/src/langium/residency/cst-residency-service.ts +36 -12
  209. package/src/langium/scope/hydranium-scope-provider.ts +40 -22
  210. package/src/langium/shared-services.ts +19 -2
  211. package/src/langium/transfer/transfer-encoder.ts +45 -3
  212. package/src/langium/validation/document-validator.ts +255 -8
  213. package/src/langium/workspace/document-uri-policy.ts +3 -4
  214. package/src/langium/workspace/hydranium-langium-document-factory.ts +34 -0
  215. package/src/langium/workspace/hydranium-workspace-manager.ts +30 -1
  216. package/src/langium/workspace/in-memory-file-system-provider.ts +13 -3
  217. package/src/langium/workspace/initialize-workspace.ts +34 -5
  218. package/src/langium/workspace/langium-documents.ts +106 -33
  219. package/src/locale/index.ts +10 -0
  220. package/src/locale/server-locale.ts +86 -0
  221. package/src/lsp/hydranium-document-update-handler.ts +1 -1
  222. package/src/messages/carriers.ts +78 -0
  223. package/src/messages/index.ts +27 -0
  224. package/src/messages/renderer.ts +204 -0
  225. package/src/node/node-file-system-provider.ts +1 -41
  226. package/src/node/profiling-run.ts +1 -1
  227. package/src/node/rename-over-open-readers.ts +78 -0
  228. package/src/testing/fake-document.ts +22 -4
  229. package/src/testing/make-noop-shared-services.ts +50 -2
  230. package/src/testing/make-test-services.ts +49 -7
  231. package/src/testing/node/spawned-server.ts +35 -1
  232. package/src/testing/playwright/server-log-capture.ts +24 -5
  233. package/src/testing/playwright/server-log-rename-reporter.ts +12 -1
  234. package/src/testing/stub-ast-document-manager.ts +5 -10
  235. package/src/testing/stub-document-builder.ts +32 -3
  236. package/src/testing/stub-model-service.ts +2 -2
@@ -644,18 +644,20 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
644
644
  if (this.isOpenInClient(uri, clientId)) {
645
645
  // Already open for this client under this canonical identity. If this is a
646
646
  // NEW client-facing URI for the same file (a second tab reached via a
647
- // divergent path, e.g. a symlink and its real path), record it and baseline
648
- // its shadow so outbound edits reach this tab too — but do NOT re-fire
649
- // open/rebuild; the document is already live.
647
+ // divergent path, e.g. a symlink and its real path), record it so outbound
648
+ // edits reach this tab too — but do NOT re-fire open/rebuild; the document
649
+ // is already live.
650
650
  if (clientId === LANGUAGE_CLIENT_ID) {
651
651
  const clientFacing = this.toLanguageClientUri(td.uri);
652
652
  const record = this.__documents.get(uri);
653
653
  if (record && !record.languageClientUris?.has(clientFacing)) {
654
654
  (record.languageClientUris ??= new Set<LanguageClientUri>()).add(clientFacing);
655
- const open = this.__syncedDocuments.get(uri);
656
- if (open) {
657
- this.__shadow.set(clientFacing, open.getText());
658
- }
655
+ // This tab's own buffer, NOT the synced text: a second tab is a second
656
+ // client model, read from disk, so a server-authored write already
657
+ // applied to the first tab leaves it BEHIND the synced document. Keying
658
+ // a diff to the synced text here addresses lines this tab does not have
659
+ // and drops content it does.
660
+ this.__shadow.setOpenedText(clientFacing, td.text);
659
661
  }
660
662
  }
661
663
  return;
@@ -694,7 +696,18 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
694
696
  // Baseline the shadow to what Monaco just opened so the next outbound
695
697
  // applyEditToLanguageClient diffs against the right starting point. Keyed by the
696
698
  // language-client URI (what Monaco holds), not the canonical document key.
697
- this.__shadow.set(this.toLanguageClientUri(td.uri), document.getText());
699
+ //
700
+ // The CLIENT's declared text, never the synced document: staged content
701
+ // consumed above leaves the two different, and a baseline asserting the
702
+ // client already holds the staged text suppresses the one sync that would
703
+ // deliver it. Skipped entirely when a push is already outstanding for this
704
+ // URI — an `applyEdit` to a closed file has the client open from disk and
705
+ // apply afterwards, so that shadow records what it is about to hold and
706
+ // disk text would key the next diff to a buffer nobody has.
707
+ const clientFacing = this.toLanguageClientUri(td.uri);
708
+ if (this.__shadow.get(clientFacing) === undefined) {
709
+ this.__shadow.set(clientFacing, td.text);
710
+ }
698
711
  }
699
712
  const toFire = Object.freeze({ document, clientId });
700
713
  this.__onDidOpen.fire(toFire);
@@ -706,6 +719,14 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
706
719
  `Attach client: ${clientId} joined existing document (version ${document.version}, ` +
707
720
  `now open in: ${[...existingClients, clientId].join(', ')})`
708
721
  );
722
+ if (clientId === LANGUAGE_CLIENT_ID) {
723
+ // Monaco's own buffer, which on this path is NOT the synced text — another
724
+ // client opened the document and may already have changed it. Recorded as
725
+ // an equality-only baseline (never a diff one, see `setOpenedText`) so the
726
+ // refresh below does not push a full replace of content Monaco already
727
+ // holds, which would dirty the file on open.
728
+ this.__shadow.setOpenedText(this.toLanguageClientUri(td.uri), td.text);
729
+ }
709
730
  this.refreshContent(uri, clientId);
710
731
  }
711
732
  }
@@ -985,9 +1006,20 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
985
1006
  // it is exactly what the caller retries with after a rejection, so
986
1007
  // gating it there would refuse the recovery too.
987
1008
  const version = isFullReplace(edits) ? UNKNOWN_CLIENT_VERSION : this.languageClientVersion(uri);
1009
+ // A full `ApplyWorkspaceEditParams`, `edit` and all — NOT a bare
1010
+ // `WorkspaceEdit` with a `label` beside it. `applyEdit` takes
1011
+ // `ApplyWorkspaceEditParams | WorkspaceEdit` and discriminates on
1012
+ // `!!value.edit`, so `{ label, documentChanges }` is wrapped as
1013
+ // `{ edit: { label, documentChanges } }` — putting the label inside
1014
+ // the edit, where LSP defines no such field and no client reads it.
1015
+ // The union is also what hides it at compile time: excess-property
1016
+ // checking admits a property present in EITHER member, so an object
1017
+ // matching neither type-checks against the union.
988
1018
  const result = await connection.workspace.applyEdit({
989
1019
  label: options?.label,
990
- documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1020
+ edit: {
1021
+ documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1022
+ }
991
1023
  });
992
1024
  if (result && result.applied === false) {
993
1025
  this.__shadow.invalidate(targetUri);
@@ -57,12 +57,23 @@ export function isFullReplace(edits: readonly TextEdit[]): boolean {
57
57
  * - {@link computeEdits} after each outgoing server→client sync (updates the shadow optimistically).
58
58
  * - {@link set} when the server receives a `didChange` from the language client (the client now
59
59
  * holds that text; we shouldn't resend it).
60
+ * - {@link setOpenedText} when the client opens a document the server did not baseline for it —
61
+ * an equality-only baseline, since that text is a snapshot rather than a tracked one.
60
62
  * - {@link invalidate} when the client rejects an edit (`applied=false`) or a doc is closed —
61
63
  * the shadow is now stale and the next sync must fall back to a full replace.
62
64
  */
63
65
  export class LanguageClientTextShadow {
64
66
  protected shadow = new Map<string, string>();
65
67
 
68
+ /**
69
+ * Per URI, the text the client declared it opened the document with, held
70
+ * only until a real baseline exists. Consulted by {@link computeEdits} when
71
+ * there is no shadow, and dropped on {@link invalidate}: a snapshot that
72
+ * outlived what the client holds would let the equality check skip an edit
73
+ * the client still needs.
74
+ */
75
+ protected opened = new Map<string, string>();
76
+
66
77
  /**
67
78
  * @param onFallback invoked when {@link computeEdits} falls back to a full-range replace due to
68
79
  * apply-verify mismatch. Useful for logging regressions.
@@ -84,6 +95,20 @@ export class LanguageClientTextShadow {
84
95
  this.shadow.set(uri, text);
85
96
  }
86
97
 
98
+ /**
99
+ * Record the buffer the language client declared at `didOpen`, for a URI
100
+ * with no baseline yet.
101
+ *
102
+ * Deliberately NOT a {@link set}: this text is only ever compared for
103
+ * EQUALITY, never diffed against. A line-keyed diff is position-dependent,
104
+ * so keying one to a `didOpen` snapshot the client may have moved past
105
+ * splices its buffer — the failure {@link computeEdits}'s full-replace
106
+ * fallback exists to avoid.
107
+ */
108
+ setOpenedText(uri: string, text: string): void {
109
+ this.opened.set(uri, text);
110
+ }
111
+
87
112
  /**
88
113
  * The text the language client is believed to hold, or `undefined` when
89
114
  * there is no baseline (first sync, or after an {@link invalidate}).
@@ -102,6 +127,11 @@ export class LanguageClientTextShadow {
102
127
  /** Forget the shadow for a URI; next {@link computeEdits} falls back to a full-document replace. */
103
128
  invalidate(uri: string): void {
104
129
  this.shadow.delete(uri);
130
+ // Drops the opened snapshot too, and this is the only place that has to:
131
+ // `computeEdits` clears it whenever it consults it, so the sole way it can
132
+ // outlive what the client holds is surviving under a live shadow until that
133
+ // shadow is dropped here.
134
+ this.opened.delete(uri);
105
135
  }
106
136
 
107
137
  /**
@@ -109,7 +139,9 @@ export class LanguageClientTextShadow {
109
139
  * the tracked shadow to `newText`. Updates the shadow to `newText` so subsequent calls diff
110
140
  * against it.
111
141
  *
112
- * - Empty array when the shadow already equals `newText` (caller should skip the RPC).
142
+ * - Empty array when the shadow already equals `newText` (caller should skip the RPC),
143
+ * or when there is no shadow and the client opened the document with exactly `newText`
144
+ * (see {@link setOpenedText}) — pushing it anyway dirties the client's buffer on open.
113
145
  * - Full-range replace when there is no shadow (first sync or post-{@link invalidate}).
114
146
  * - Line-level diff otherwise. Before returning, we locally reconstruct `newText` by applying
115
147
  * the diff to `old`; if that doesn't match, we fall back to a full-range replace and notify
@@ -124,7 +156,11 @@ export class LanguageClientTextShadow {
124
156
  this.shadow.set(uri, newText);
125
157
  const fullReplace: TextEdit = { range: FULL_RANGE, newText };
126
158
  if (old === undefined) {
127
- return [fullReplace];
159
+ // The opened snapshot has served its purpose either way: the shadow above
160
+ // is now the baseline, so leaving it would only risk a later stale match.
161
+ const openedText = this.opened.get(uri);
162
+ this.opened.delete(uri);
163
+ return openedText === newText ? [] : [fullReplace];
128
164
  }
129
165
  const edits = diffToEdits(old, newText);
130
166
  const verifyDoc = this.documents.create(uri, 'plaintext', 0, old);
@@ -22,10 +22,32 @@ export interface SelfSaveRegistryOptions {
22
22
  /**
23
23
  * Tracks `mtime` of files written by the server itself so the echo
24
24
  * `didChangeWatchedFiles` event from the OS file watcher can be suppressed,
25
- * avoiding redundant rebuilds after each save. Entries are matched by mtime
26
- * (not by a time window) and expire after a TTL.
25
+ * avoiding redundant rebuilds after each save.
27
26
  */
28
- export class SelfSaveRegistry {
27
+ export interface SelfSaveRegistry {
28
+ /**
29
+ * Record a self-write, and drop any registration already past its TTL.
30
+ *
31
+ * Keyed by `fsPath` rather than URI string, because the two sides of a
32
+ * suppression see different URI forms for one file.
33
+ */
34
+ register(fsPath: string, mtimeMs: number): void;
35
+
36
+ /**
37
+ * Whether `fsPath` carries a live registration at exactly `mtimeMs`.
38
+ *
39
+ * A registration expires after the TTL, so a write recorded longer ago than
40
+ * that answers `false` — the caller's decision holds only while the echo it
41
+ * suppresses could still be in flight. A query never mutates the registry.
42
+ */
43
+ isRegistered(fsPath: string, mtimeMs: number): boolean;
44
+ }
45
+
46
+ /**
47
+ * Compares entries by mtime rather than by a time window, so the echo of a
48
+ * save whose content is unchanged is still recognised.
49
+ */
50
+ export class DefaultSelfSaveRegistry implements SelfSaveRegistry {
29
51
  protected readonly entries = new Map<string, { mtimeMs: number; registeredAt: number }>();
30
52
  /**
31
53
  * Read time through the shared {@link Clock} rather than `Date.now()`: the
@@ -59,23 +81,40 @@ export class SelfSaveRegistry {
59
81
  return isCaseInsensitiveFileSystem() ? fsPath.toLowerCase() : fsPath;
60
82
  }
61
83
 
62
- /** Record a self-write. Key by `fsPath` (not URI string) to avoid URI-form mismatches. */
63
84
  register(fsPath: string, mtimeMs: number): void {
85
+ this.evictExpired();
64
86
  this.entries.set(this.key(fsPath), { mtimeMs, registeredAt: this.clock.now() });
65
87
  }
66
88
 
67
- /** True if `mtimeMs` matches a recent self-write. Evicts stale entries. Not consumed on match. */
68
- matches(fsPath: string, mtimeMs: number): boolean {
69
- const key = this.key(fsPath);
70
- const entry = this.entries.get(key);
71
- if (!entry) {
72
- return false;
73
- }
74
- if (this.clock.now() - entry.registeredAt > this.ttlMs) {
75
- this.entries.delete(key);
89
+ isRegistered(fsPath: string, mtimeMs: number): boolean {
90
+ const entry = this.entries.get(this.key(fsPath));
91
+ if (!entry || this.isExpired(entry.registeredAt)) {
76
92
  return false;
77
93
  }
78
94
  // Accept mtime equality (some filesystems have 1-second precision).
79
95
  return entry.mtimeMs === mtimeMs;
80
96
  }
97
+
98
+ /**
99
+ * Drop every expired registration.
100
+ *
101
+ * On the WRITE path, so the query stays free of side effects. Evicting from
102
+ * the query instead reaches only the keys something happens to ask about,
103
+ * and a path written once and never queried again is then retained for the
104
+ * life of the process. Bounded by the paths self-written inside one TTL
105
+ * window, so the sweep is over a handful of entries and sits behind a file
106
+ * write either way.
107
+ */
108
+ protected evictExpired(): void {
109
+ for (const [key, entry] of this.entries) {
110
+ if (this.isExpired(entry.registeredAt)) {
111
+ this.entries.delete(key);
112
+ }
113
+ }
114
+ }
115
+
116
+ /** Strictly greater, so an entry aged exactly to the TTL is still eligible. */
117
+ protected isExpired(registeredAt: number): boolean {
118
+ return this.clock.now() - registeredAt > this.ttlMs;
119
+ }
81
120
  }
package/src/index.ts CHANGED
@@ -35,5 +35,10 @@ export * from './langium/workspace/index.js';
35
35
  export * from './langium/service-registry.js';
36
36
  export * from './langium/language-types.js';
37
37
  export * from './launcher/index.js';
38
+ export * from './locale/index.js';
39
+ // The renderer only. The `./messages` subpath additionally enumerates this
40
+ // package's own declarations, which the root barrel already re-exports through
41
+ // the modules that raise them.
42
+ export * from './messages/renderer.js';
38
43
  export * from './documents/index.js';
39
44
  export * from './util/index.js';
@@ -78,15 +78,13 @@ export type AstNodeBuilder<TMap> = <TType extends string & keyof TMap>(
78
78
  * AST-interface mapping, so call sites get full inference from the type
79
79
  * constant alone — no explicit generic parameter at every call site.
80
80
  *
81
- * Runtime behaviour: reads `reflection.getTypeMetaData(type).properties` and,
82
- * for every property whose metadata carries a `defaultValue`, pre-fills the
83
- * result before merging the caller's `init`. Array defaults are materialised
84
- * as a fresh `[]` per call (never shared with the metadata's array instance,
85
- * which downstream callers mutate); scalar defaults (`false`, `0`, `''`, …)
86
- * are copied as-is since they're immutable. Grammar-generated containment
87
- * lists and `?=`-style boolean flags therefore default automatically;
88
- * TS-augmented fields (declared via `declare module './generated/ast.js'`)
89
- * are unaffected by the runtime defaulting but remain typed at call sites.
81
+ * Runtime behaviour: pre-fills every grammar-declared default (see
82
+ * {@link withTypeDefaults}) before merging the caller's `init`, so containment
83
+ * lists and `?=`-style boolean flags default automatically. TS-augmented fields
84
+ * (declared via `declare module './generated/ast.js'`) are unaffected by the
85
+ * runtime defaulting but remain typed at call sites.
86
+ *
87
+ * Use {@link buildAstNode} instead when the type name is only known at runtime.
90
88
  *
91
89
  * The init shape only relaxes array-typed fields to optional (via
92
90
  * {@link ArrayKeys}) — there is no equivalent structural signal for "this
@@ -101,19 +99,7 @@ export function makeAstNodeBuilder<TMap>(reflection: AstReflection): AstNodeBuil
101
99
  init: AstNodeInit<Extract<TMap[TType], AstNode>>,
102
100
  extras?: Record<string, unknown>
103
101
  ): Extract<TMap[TType], AstNode> {
104
- const meta = reflection.getTypeMetaData(typeConstant.$type);
105
- const result: Record<string, unknown> = { $type: typeConstant.$type };
106
- if (meta?.properties) {
107
- for (const [propName, propInfo] of Object.entries(meta.properties)) {
108
- const defaultValue = propInfo.defaultValue;
109
- if (defaultValue === undefined) {
110
- continue;
111
- }
112
- // Arrays are duplicated so each node carries its own mutable instance;
113
- // scalars are immutable and safe to copy by reference.
114
- result[propName] = Array.isArray(defaultValue) ? [...defaultValue] : defaultValue;
115
- }
116
- }
102
+ const result = withTypeDefaults(reflection, typeConstant.$type);
117
103
  Object.assign(result, init);
118
104
  if (extras) {
119
105
  // Extras win over init: this is the explicit escape hatch, so when a
@@ -123,3 +109,61 @@ export function makeAstNodeBuilder<TMap>(reflection: AstReflection): AstNodeBuil
123
109
  return result as unknown as Extract<TMap[TType], AstNode>;
124
110
  };
125
111
  }
112
+
113
+ /**
114
+ * Build a node whose type is only known as a RUNTIME string.
115
+ *
116
+ * The typed {@link makeAstNodeBuilder} cannot express this: its `TMap` generic
117
+ * resolves the AST interface from a `$type` literal, and a type name that
118
+ * arrives over the wire — a protocol-layer reference request naming a node that
119
+ * does not exist yet — has no literal to resolve from. Callers that DO know the
120
+ * type at compile time want the typed builder, which checks their mandatory
121
+ * fields; this one checks nothing beyond the reflection lookup.
122
+ *
123
+ * Applies the same reflection-driven defaulting, so a node built here carries
124
+ * grammar-declared containment arrays rather than leaving them `undefined`. An
125
+ * unknown type name yields a node with `$type` and whatever `init` supplied,
126
+ * because reflection answers no metadata for it and refusing would turn a
127
+ * permissive lookup into a throw at a call site that can only guess.
128
+ *
129
+ * **The `type` argument is authoritative: `init` cannot rebind `$type`.** The
130
+ * typed builder gets this for free — `AstNodeInit` omits `$type`, so a call site
131
+ * cannot reach it — but here `init` is loosely typed, and the case that needs
132
+ * the guarantee is RETYPING: a caller spreading an existing node to produce one
133
+ * of a different type (`{ ...source }`) carries the SOURCE's `$type` in, which
134
+ * would silently win over the type actually requested and hand back a node of
135
+ * the wrong type with no error anywhere.
136
+ */
137
+ export function buildAstNode<TAst extends AstNode = AstNode>(
138
+ reflection: AstReflection,
139
+ type: string,
140
+ init?: Partial<AstNode> & Record<string, unknown>
141
+ ): TAst {
142
+ const result = withTypeDefaults(reflection, type);
143
+ if (init) {
144
+ Object.assign(result, init);
145
+ result.$type = type;
146
+ }
147
+ return result as unknown as TAst;
148
+ }
149
+
150
+ /**
151
+ * Seed a node with `$type` plus every property the grammar declares a default
152
+ * for. Array defaults are materialised as a fresh `[]` per call — never shared
153
+ * with the metadata's own array instance, which downstream callers mutate —
154
+ * while scalar defaults are copied as-is since they are immutable.
155
+ */
156
+ function withTypeDefaults(reflection: AstReflection, type: string): Record<string, unknown> {
157
+ const result: Record<string, unknown> = { $type: type };
158
+ const meta = reflection.getTypeMetaData(type);
159
+ if (meta?.properties) {
160
+ for (const [propName, propInfo] of Object.entries(meta.properties)) {
161
+ const defaultValue = propInfo.defaultValue;
162
+ if (defaultValue === undefined) {
163
+ continue;
164
+ }
165
+ result[propName] = Array.isArray(defaultValue) ? [...defaultValue] : defaultValue;
166
+ }
167
+ }
168
+ return result;
169
+ }
@@ -12,15 +12,15 @@ import { type LangiumSharedCoreServices } from '@hydranium/langium';
12
12
  import { type LangiumServices, type LangiumSharedServices } from '@hydranium/langium/lsp';
13
13
  import type { ServerSharedServicesMinimal } from './shared-services.js';
14
14
  import { collectProducibleTypes } from './language-types.js';
15
- import { AstDocumentManager } from '../documents/ast-document-manager.js';
15
+ import { DefaultAstDocumentManager } from '../documents/ast-document-manager.js';
16
16
  import { HydraniumTextDocuments } from '../documents/hydranium-text-documents.js';
17
- import { BuildPipelineIntegration } from './document-builder/build-pipeline-integration.js';
17
+ import { DefaultBuildPipelineIntegration } from './document-builder/build-pipeline-integration.js';
18
18
  import { HydraniumDocumentBuilder } from './document-builder/document-builder.js';
19
- import { ModelService } from './model-service/model-service.js';
19
+ import { DefaultModelService } from './model-service/model-service.js';
20
20
  import { HydraniumScopeComputation } from './scope/hydranium-scope-computation.js';
21
21
  import { HydraniumScopeProvider } from './scope/hydranium-scope-provider.js';
22
22
  import { processEnv } from '../util/environment.js';
23
- import { TransferEncoder } from './transfer/transfer-encoder.js';
23
+ import { DefaultTransferEncoder } from './transfer/transfer-encoder.js';
24
24
  import { HydraniumWorkspaceManager } from './workspace/hydranium-workspace-manager.js';
25
25
 
26
26
  /**
@@ -73,7 +73,14 @@ const EXPECTED_SHARED_SLOTS: readonly ExpectedSlot[] = [
73
73
  side: 'shared',
74
74
  path: 'workspace.BuildPipelineIntegration',
75
75
  get: services => nested(services, 'workspace', 'BuildPipelineIntegration')
76
- }
76
+ },
77
+ // Unbound, this fails where the cause is least visible: the render pass runs
78
+ // inside the `Validated` phase, so a `TypeError` there propagates out of
79
+ // `notifyDocumentPhase` and the document reaches `Validated` with Langium's
80
+ // publisher never invoked — the client receives no diagnostics for the file
81
+ // and the stack names the document builder.
82
+ { side: 'shared', path: 'MessageRenderer', get: services => nested(services, 'MessageRenderer') },
83
+ { side: 'shared', path: 'ServerLocale', get: services => nested(services, 'ServerLocale') }
77
84
  ];
78
85
 
79
86
  /**
@@ -199,17 +206,17 @@ const STRICT_SHARED_SLOTS: readonly StrictSlot[] = [
199
206
  {
200
207
  side: 'shared',
201
208
  path: 'workspace.AstDocumentManager',
202
- base: AstDocumentManager,
209
+ base: DefaultAstDocumentManager,
203
210
  get: s => nested(s, 'workspace', 'AstDocumentManager')
204
211
  },
205
212
  {
206
213
  side: 'shared',
207
214
  path: 'workspace.BuildPipelineIntegration',
208
- base: BuildPipelineIntegration,
215
+ base: DefaultBuildPipelineIntegration,
209
216
  get: s => nested(s, 'workspace', 'BuildPipelineIntegration')
210
217
  },
211
- { side: 'shared', path: 'model.TransferEncoder', base: TransferEncoder, get: s => nested(s, 'model', 'TransferEncoder') },
212
- { side: 'shared', path: 'model.ModelService', base: ModelService, get: s => nested(s, 'model', 'ModelService') }
218
+ { side: 'shared', path: 'model.TransferEncoder', base: DefaultTransferEncoder, get: s => nested(s, 'model', 'TransferEncoder') },
219
+ { side: 'shared', path: 'model.ModelService', base: DefaultModelService, get: s => nested(s, 'model', 'ModelService') }
213
220
  ];
214
221
 
215
222
  const STRICT_LANGUAGE_SLOTS: readonly StrictSlot[] = [
@@ -65,6 +65,19 @@ export interface BuildPipelineIntegrationOptions {
65
65
  readonly integrityPriority?: number;
66
66
  }
67
67
 
68
+ /**
69
+ * The build-pipeline slot. Empty because nothing calls this service — it wires
70
+ * itself in its constructor and every seam it offers is `protected`.
71
+ *
72
+ * Typing the slot by the class instead would stop an adopter's subclass
73
+ * satisfying it, since a class carries its `protected` members into the check
74
+ * and they compare by declaration rather than by shape. The cost of the empty
75
+ * shape is that any non-null value satisfies it, so a mis-bound slot compiles
76
+ * and the framework's phase listeners are then never attached.
77
+ */
78
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- the slot has no public member to name
79
+ export interface BuildPipelineIntegration {}
80
+
68
81
  /**
69
82
  * Wires the framework's build-time features (integrity rules, AST
70
83
  * enrichment) into Langium's document-build pipeline. The single shared
@@ -90,7 +103,7 @@ export interface BuildPipelineIntegrationOptions {
90
103
  * Eagerly constructed (via `DEFAULT_EAGER_SERVICES`) so the listeners are
91
104
  * attached before the first build cycle.
92
105
  */
93
- export class BuildPipelineIntegration {
106
+ export class DefaultBuildPipelineIntegration implements BuildPipelineIntegration {
94
107
  constructor(
95
108
  protected readonly services: ServerSharedServices,
96
109
  options: BuildPipelineIntegrationOptions = {}
@@ -0,0 +1,87 @@
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 URI } from '@hydranium/langium';
11
+
12
+ /** What a build is about, handed to `HydraniumDocumentBuilder.createBuildSession`. */
13
+ export interface BuildSessionContext {
14
+ /**
15
+ * Which entry point opened the build: `'build'` is the workspace-wide pass
16
+ * (initialization), `'update'` an incremental rebuild. A subclass deriving
17
+ * cascade state skips the work for `'build'`, where every document is in the
18
+ * set already and `shouldRelink` is never consulted.
19
+ */
20
+ readonly kind: 'build' | 'update';
21
+ /** Formatted description of what caused the build — a URI, a count summary. */
22
+ readonly trigger: string;
23
+ /** True when {@link trigger} already states the document count. */
24
+ readonly triggerCountsDocs: boolean;
25
+ /** URIs the build was asked to rebuild, already flattened and canonicalized. */
26
+ readonly changed: readonly URI[];
27
+ /** URIs the build was asked to delete, already expanded by `collectDeletedURIs`. Empty for a `build` call. */
28
+ readonly deleted: readonly URI[];
29
+ }
30
+
31
+ /**
32
+ * One rebuild, treated as a correlated unit.
33
+ *
34
+ * Everything a build phase needs to know about the build it belongs to — what
35
+ * triggered it, when it started, whether it was cancelled, and the log lines it
36
+ * has produced so far. A `HydraniumDocumentBuilder` opens one per `update` /
37
+ * `build` call and holds it for the duration, so a per-phase hook can reach
38
+ * build-wide facts that no per-line formatting hook could.
39
+ *
40
+ * **Identity is the object, not {@link traceId}.** `Tracer.time` short-circuits
41
+ * when its log level is suppressed and never calls `captureId`, so `traceId` is
42
+ * `undefined` on every build in a production configuration that logs above
43
+ * `debug`. A teardown guard keyed on the id would then compare `undefined`
44
+ * against `undefined` and let a preempted build clear its successor's state.
45
+ * Reference equality against `HydraniumDocumentBuilder.activeSession` holds in
46
+ * both configurations, which is why nothing here is keyed on the id.
47
+ *
48
+ * Adopters carrying build-scoped state of their own subclass this and return it
49
+ * from `HydraniumDocumentBuilder.createBuildSession`, narrowing with an
50
+ * `instanceof` where they read it back. That buys them the framework's
51
+ * preemption-correct teardown rather than a second copy of it.
52
+ */
53
+ export class BuildSession {
54
+ /**
55
+ * `Tracer.time` correlation id, so a phase line and the build line share a
56
+ * `#N`. `undefined` until the timer starts, and permanently `undefined` when
57
+ * the timing line is suppressed.
58
+ */
59
+ traceId?: number;
60
+ /** Set when the build ended in `OperationCancelled`, so the successor can tag itself "cancels #N". */
61
+ cancelled = false;
62
+ /** Phase-reached lines produced so far; the first one measures from {@link startMs} rather than from the previous phase. */
63
+ phasesLogged = 0;
64
+ /** Lines held back by {@link detailThresholdMs}; flushed or dropped when the build ends. */
65
+ readonly bufferedLines: string[] = [];
66
+
67
+ constructor(
68
+ /** Millisecond timestamp the build started at, on the same clock as `performance.now()`. */
69
+ readonly startMs: number,
70
+ /** What caused this build — a formatted URI, a count summary, or an adopter label. */
71
+ readonly trigger: string,
72
+ /** True when {@link trigger} already states the document count, so a phase line must not repeat it. */
73
+ readonly triggerCountsDocs: boolean,
74
+ /**
75
+ * Build duration at or above which the buffered lines are emitted; `0`
76
+ * disables buffering entirely. Captured per session so the flush decision
77
+ * uses the same value the buffering decision did, even if the underlying
78
+ * observable changed mid-build.
79
+ */
80
+ readonly detailThresholdMs: number
81
+ ) {}
82
+
83
+ /** True while lines are being held rather than emitted as they are produced. */
84
+ get buffers(): boolean {
85
+ return this.detailThresholdMs > 0;
86
+ }
87
+ }