@hydranium/core 1.0.0-next.9 → 1.0.0-next.90

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 (316) hide show
  1. package/lib/documents/ast-document-manager.d.ts +75 -16
  2. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  3. package/lib/documents/ast-document-manager.js +57 -19
  4. package/lib/documents/ast-document-manager.js.map +1 -1
  5. package/lib/documents/client-ids.d.ts +5 -19
  6. package/lib/documents/client-ids.d.ts.map +1 -1
  7. package/lib/documents/client-ids.js +5 -19
  8. package/lib/documents/client-ids.js.map +1 -1
  9. package/lib/documents/hydranium-text-documents.d.ts +55 -21
  10. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  11. package/lib/documents/hydranium-text-documents.js +142 -46
  12. package/lib/documents/hydranium-text-documents.js.map +1 -1
  13. package/lib/documents/language-client-text-shadow.d.ts +41 -9
  14. package/lib/documents/language-client-text-shadow.d.ts.map +1 -1
  15. package/lib/documents/language-client-text-shadow.js +56 -11
  16. package/lib/documents/language-client-text-shadow.js.map +1 -1
  17. package/lib/documents/self-save-registry.d.ts +37 -6
  18. package/lib/documents/self-save-registry.d.ts.map +1 -1
  19. package/lib/documents/self-save-registry.js +28 -15
  20. package/lib/documents/self-save-registry.js.map +1 -1
  21. package/lib/index.d.ts +2 -0
  22. package/lib/index.d.ts.map +1 -1
  23. package/lib/index.js +5 -0
  24. package/lib/index.js.map +1 -1
  25. package/lib/langium/ast-extension/ast-node-builder.d.ts +32 -9
  26. package/lib/langium/ast-extension/ast-node-builder.d.ts.map +1 -1
  27. package/lib/langium/ast-extension/ast-node-builder.js +60 -22
  28. package/lib/langium/ast-extension/ast-node-builder.js.map +1 -1
  29. package/lib/langium/bootstrap.d.ts.map +1 -1
  30. package/lib/langium/bootstrap.js +16 -9
  31. package/lib/langium/bootstrap.js.map +1 -1
  32. package/lib/langium/config/configuration-provider.d.ts +81 -0
  33. package/lib/langium/config/configuration-provider.d.ts.map +1 -0
  34. package/lib/langium/config/configuration-provider.js +112 -0
  35. package/lib/langium/config/configuration-provider.js.map +1 -0
  36. package/lib/langium/config/index.d.ts +1 -0
  37. package/lib/langium/config/index.d.ts.map +1 -1
  38. package/lib/langium/config/index.js +1 -0
  39. package/lib/langium/config/index.js.map +1 -1
  40. package/lib/langium/diagnostics/lsp-logger.d.ts +17 -0
  41. package/lib/langium/diagnostics/lsp-logger.d.ts.map +1 -1
  42. package/lib/langium/diagnostics/lsp-logger.js +40 -6
  43. package/lib/langium/diagnostics/lsp-logger.js.map +1 -1
  44. package/lib/langium/document-builder/build-pipeline-integration.d.ts +13 -1
  45. package/lib/langium/document-builder/build-pipeline-integration.d.ts.map +1 -1
  46. package/lib/langium/document-builder/build-pipeline-integration.js +1 -1
  47. package/lib/langium/document-builder/build-pipeline-integration.js.map +1 -1
  48. package/lib/langium/document-builder/build-session.d.ts +93 -0
  49. package/lib/langium/document-builder/build-session.d.ts.map +1 -0
  50. package/lib/langium/document-builder/build-session.js +72 -0
  51. package/lib/langium/document-builder/build-session.js.map +1 -0
  52. package/lib/langium/document-builder/document-builder.d.ts +150 -7
  53. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  54. package/lib/langium/document-builder/document-builder.js +298 -11
  55. package/lib/langium/document-builder/document-builder.js.map +1 -1
  56. package/lib/langium/document-builder/index.d.ts +1 -0
  57. package/lib/langium/document-builder/index.d.ts.map +1 -1
  58. package/lib/langium/document-builder/index.js +1 -0
  59. package/lib/langium/document-builder/index.js.map +1 -1
  60. package/lib/langium/integration-services.d.ts +45 -1
  61. package/lib/langium/integration-services.d.ts.map +1 -1
  62. package/lib/langium/integration-services.js +10 -5
  63. package/lib/langium/integration-services.js.map +1 -1
  64. package/lib/langium/integrity/integrity-service.d.ts +5 -0
  65. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  66. package/lib/langium/integrity/integrity-service.js +34 -1
  67. package/lib/langium/integrity/integrity-service.js.map +1 -1
  68. package/lib/langium/keys/containment.d.ts +64 -0
  69. package/lib/langium/keys/containment.d.ts.map +1 -0
  70. package/lib/langium/keys/containment.js +53 -0
  71. package/lib/langium/keys/containment.js.map +1 -0
  72. package/lib/langium/keys/index.d.ts +1 -0
  73. package/lib/langium/keys/index.d.ts.map +1 -1
  74. package/lib/langium/keys/index.js +1 -0
  75. package/lib/langium/keys/index.js.map +1 -1
  76. package/lib/langium/keys/name-based-key-provider.d.ts +4 -0
  77. package/lib/langium/keys/name-based-key-provider.d.ts.map +1 -1
  78. package/lib/langium/keys/name-based-key-provider.js +4 -0
  79. package/lib/langium/keys/name-based-key-provider.js.map +1 -1
  80. package/lib/langium/model-service/model-service.d.ts +149 -10
  81. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  82. package/lib/langium/model-service/model-service.js +120 -93
  83. package/lib/langium/model-service/model-service.js.map +1 -1
  84. package/lib/langium/module.d.ts +84 -15
  85. package/lib/langium/module.d.ts.map +1 -1
  86. package/lib/langium/module.js +26 -12
  87. package/lib/langium/module.js.map +1 -1
  88. package/lib/langium/naming/name-provider.d.ts +25 -4
  89. package/lib/langium/naming/name-provider.d.ts.map +1 -1
  90. package/lib/langium/naming/name-provider.js +2 -1
  91. package/lib/langium/naming/name-provider.js.map +1 -1
  92. package/lib/langium/naming/name-separator-validation.d.ts +23 -0
  93. package/lib/langium/naming/name-separator-validation.d.ts.map +1 -1
  94. package/lib/langium/naming/name-separator-validation.js +30 -1
  95. package/lib/langium/naming/name-separator-validation.js.map +1 -1
  96. package/lib/langium/residency/cst-residency-service.d.ts +31 -9
  97. package/lib/langium/residency/cst-residency-service.d.ts.map +1 -1
  98. package/lib/langium/residency/cst-residency-service.js +13 -56
  99. package/lib/langium/residency/cst-residency-service.js.map +1 -1
  100. package/lib/langium/scope/hydranium-scope-provider.d.ts +32 -13
  101. package/lib/langium/scope/hydranium-scope-provider.d.ts.map +1 -1
  102. package/lib/langium/scope/hydranium-scope-provider.js +37 -20
  103. package/lib/langium/scope/hydranium-scope-provider.js.map +1 -1
  104. package/lib/langium/service-registry.d.ts +5 -1
  105. package/lib/langium/service-registry.d.ts.map +1 -1
  106. package/lib/langium/service-registry.js +4 -0
  107. package/lib/langium/service-registry.js.map +1 -1
  108. package/lib/langium/shared-services.d.ts +15 -2
  109. package/lib/langium/shared-services.d.ts.map +1 -1
  110. package/lib/langium/shared-services.js.map +1 -1
  111. package/lib/langium/transfer/transfer-encoder.d.ts +60 -17
  112. package/lib/langium/transfer/transfer-encoder.d.ts.map +1 -1
  113. package/lib/langium/transfer/transfer-encoder.js +38 -16
  114. package/lib/langium/transfer/transfer-encoder.js.map +1 -1
  115. package/lib/langium/validation/document-validator.d.ts +294 -10
  116. package/lib/langium/validation/document-validator.d.ts.map +1 -1
  117. package/lib/langium/validation/document-validator.js +448 -5
  118. package/lib/langium/validation/document-validator.js.map +1 -1
  119. package/lib/langium/workspace/document-uri-policy.d.ts +3 -4
  120. package/lib/langium/workspace/document-uri-policy.d.ts.map +1 -1
  121. package/lib/langium/workspace/document-uri-policy.js +3 -4
  122. package/lib/langium/workspace/document-uri-policy.js.map +1 -1
  123. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts +25 -0
  124. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts.map +1 -1
  125. package/lib/langium/workspace/hydranium-langium-document-factory.js +25 -0
  126. package/lib/langium/workspace/hydranium-langium-document-factory.js.map +1 -1
  127. package/lib/langium/workspace/hydranium-workspace-manager.d.ts +21 -1
  128. package/lib/langium/workspace/hydranium-workspace-manager.d.ts.map +1 -1
  129. package/lib/langium/workspace/hydranium-workspace-manager.js +28 -0
  130. package/lib/langium/workspace/hydranium-workspace-manager.js.map +1 -1
  131. package/lib/langium/workspace/in-memory-file-system-provider.d.ts +8 -0
  132. package/lib/langium/workspace/in-memory-file-system-provider.d.ts.map +1 -1
  133. package/lib/langium/workspace/in-memory-file-system-provider.js +11 -2
  134. package/lib/langium/workspace/in-memory-file-system-provider.js.map +1 -1
  135. package/lib/langium/workspace/initialize-workspace.d.ts +22 -2
  136. package/lib/langium/workspace/initialize-workspace.d.ts.map +1 -1
  137. package/lib/langium/workspace/initialize-workspace.js +13 -5
  138. package/lib/langium/workspace/initialize-workspace.js.map +1 -1
  139. package/lib/langium/workspace/langium-documents.d.ts +79 -17
  140. package/lib/langium/workspace/langium-documents.d.ts.map +1 -1
  141. package/lib/langium/workspace/langium-documents.js +83 -28
  142. package/lib/langium/workspace/langium-documents.js.map +1 -1
  143. package/lib/locale/index.d.ts +10 -0
  144. package/lib/locale/index.d.ts.map +1 -0
  145. package/lib/locale/index.js +10 -0
  146. package/lib/locale/index.js.map +1 -0
  147. package/lib/locale/server-locale.d.ts +73 -0
  148. package/lib/locale/server-locale.d.ts.map +1 -0
  149. package/lib/locale/server-locale.js +61 -0
  150. package/lib/locale/server-locale.js.map +1 -0
  151. package/lib/lsp/hydranium-document-update-handler.js +1 -1
  152. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  153. package/lib/lsp/index.d.ts +1 -1
  154. package/lib/lsp/index.d.ts.map +1 -1
  155. package/lib/lsp/index.js +1 -1
  156. package/lib/lsp/index.js.map +1 -1
  157. package/lib/lsp/lsp-latency.d.ts +39 -0
  158. package/lib/lsp/lsp-latency.d.ts.map +1 -0
  159. package/lib/lsp/lsp-latency.js +58 -0
  160. package/lib/lsp/lsp-latency.js.map +1 -0
  161. package/lib/lsp/semantic-token-provider.d.ts +34 -1
  162. package/lib/lsp/semantic-token-provider.d.ts.map +1 -1
  163. package/lib/lsp/semantic-token-provider.js +55 -8
  164. package/lib/lsp/semantic-token-provider.js.map +1 -1
  165. package/lib/messages/carriers.d.ts +54 -0
  166. package/lib/messages/carriers.d.ts.map +1 -0
  167. package/lib/messages/carriers.js +62 -0
  168. package/lib/messages/carriers.js.map +1 -0
  169. package/lib/messages/index.d.ts +25 -0
  170. package/lib/messages/index.d.ts.map +1 -0
  171. package/lib/messages/index.js +25 -0
  172. package/lib/messages/index.js.map +1 -0
  173. package/lib/messages/renderer.d.ts +136 -0
  174. package/lib/messages/renderer.d.ts.map +1 -0
  175. package/lib/messages/renderer.js +159 -0
  176. package/lib/messages/renderer.js.map +1 -0
  177. package/lib/node/heap-ceiling.d.ts +61 -0
  178. package/lib/node/heap-ceiling.d.ts.map +1 -0
  179. package/lib/node/heap-ceiling.js +72 -0
  180. package/lib/node/heap-ceiling.js.map +1 -0
  181. package/lib/node/index.d.ts +1 -0
  182. package/lib/node/index.d.ts.map +1 -1
  183. package/lib/node/index.js +1 -0
  184. package/lib/node/index.js.map +1 -1
  185. package/lib/node/latency-from-env.d.ts +1 -1
  186. package/lib/node/latency-from-env.js +1 -1
  187. package/lib/node/node-file-system-provider.d.ts.map +1 -1
  188. package/lib/node/node-file-system-provider.js +1 -40
  189. package/lib/node/node-file-system-provider.js.map +1 -1
  190. package/lib/node/profiling-run.d.ts +2 -2
  191. package/lib/node/profiling-run.js +1 -1
  192. package/lib/node/profiling-run.js.map +1 -1
  193. package/lib/node/rename-over-open-readers.d.ts +52 -0
  194. package/lib/node/rename-over-open-readers.d.ts.map +1 -0
  195. package/lib/node/rename-over-open-readers.js +66 -0
  196. package/lib/node/rename-over-open-readers.js.map +1 -0
  197. package/lib/node/socket-launcher.d.ts +15 -0
  198. package/lib/node/socket-launcher.d.ts.map +1 -1
  199. package/lib/node/socket-launcher.js +3 -2
  200. package/lib/node/socket-launcher.js.map +1 -1
  201. package/lib/testing/fake-document.d.ts +16 -4
  202. package/lib/testing/fake-document.d.ts.map +1 -1
  203. package/lib/testing/fake-document.js +18 -3
  204. package/lib/testing/fake-document.js.map +1 -1
  205. package/lib/testing/make-noop-shared-services.d.ts +14 -0
  206. package/lib/testing/make-noop-shared-services.d.ts.map +1 -1
  207. package/lib/testing/make-noop-shared-services.js +28 -2
  208. package/lib/testing/make-noop-shared-services.js.map +1 -1
  209. package/lib/testing/make-test-services.d.ts +44 -8
  210. package/lib/testing/make-test-services.d.ts.map +1 -1
  211. package/lib/testing/make-test-services.js +19 -3
  212. package/lib/testing/make-test-services.js.map +1 -1
  213. package/lib/testing/node/spawned-server.d.ts +14 -0
  214. package/lib/testing/node/spawned-server.d.ts.map +1 -1
  215. package/lib/testing/node/spawned-server.js +33 -1
  216. package/lib/testing/node/spawned-server.js.map +1 -1
  217. package/lib/testing/playwright/flaky-network-proxy.d.ts +76 -0
  218. package/lib/testing/playwright/flaky-network-proxy.d.ts.map +1 -0
  219. package/lib/testing/playwright/flaky-network-proxy.js +164 -0
  220. package/lib/testing/playwright/flaky-network-proxy.js.map +1 -0
  221. package/lib/testing/playwright/index.d.ts +1 -0
  222. package/lib/testing/playwright/index.d.ts.map +1 -1
  223. package/lib/testing/playwright/index.js +1 -0
  224. package/lib/testing/playwright/index.js.map +1 -1
  225. package/lib/testing/playwright/server-log-capture.d.ts +14 -4
  226. package/lib/testing/playwright/server-log-capture.d.ts.map +1 -1
  227. package/lib/testing/playwright/server-log-capture.js +21 -4
  228. package/lib/testing/playwright/server-log-capture.js.map +1 -1
  229. package/lib/testing/playwright/server-log-rename-reporter.d.ts.map +1 -1
  230. package/lib/testing/playwright/server-log-rename-reporter.js +10 -1
  231. package/lib/testing/playwright/server-log-rename-reporter.js.map +1 -1
  232. package/lib/testing/stub-ast-document-manager.d.ts +5 -5
  233. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  234. package/lib/testing/stub-ast-document-manager.js +4 -6
  235. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  236. package/lib/testing/stub-document-builder.d.ts +8 -0
  237. package/lib/testing/stub-document-builder.d.ts.map +1 -1
  238. package/lib/testing/stub-document-builder.js +22 -3
  239. package/lib/testing/stub-document-builder.js.map +1 -1
  240. package/lib/testing/stub-langium-documents.d.ts +3 -2
  241. package/lib/testing/stub-langium-documents.d.ts.map +1 -1
  242. package/lib/testing/stub-langium-documents.js.map +1 -1
  243. package/lib/testing/stub-model-service.d.ts +5 -4
  244. package/lib/testing/stub-model-service.d.ts.map +1 -1
  245. package/lib/testing/stub-model-service.js +2 -2
  246. package/lib/testing/stub-model-service.js.map +1 -1
  247. package/package.json +17 -8
  248. package/src/documents/ast-document-manager.ts +129 -36
  249. package/src/documents/client-ids.ts +5 -22
  250. package/src/documents/hydranium-text-documents.ts +167 -61
  251. package/src/documents/language-client-text-shadow.ts +59 -11
  252. package/src/documents/self-save-registry.ts +52 -13
  253. package/src/index.ts +5 -0
  254. package/src/langium/ast-extension/ast-node-builder.ts +66 -22
  255. package/src/langium/bootstrap.ts +16 -9
  256. package/src/langium/config/configuration-provider.ts +119 -0
  257. package/src/langium/config/index.ts +1 -0
  258. package/src/langium/diagnostics/lsp-logger.ts +41 -6
  259. package/src/langium/document-builder/build-pipeline-integration.ts +14 -1
  260. package/src/langium/document-builder/build-session.ts +87 -0
  261. package/src/langium/document-builder/document-builder.ts +339 -11
  262. package/src/langium/document-builder/index.ts +1 -0
  263. package/src/langium/integration-services.ts +67 -6
  264. package/src/langium/integrity/integrity-service.ts +35 -1
  265. package/src/langium/keys/containment.ts +87 -0
  266. package/src/langium/keys/index.ts +1 -0
  267. package/src/langium/keys/name-based-key-provider.ts +4 -0
  268. package/src/langium/model-service/model-service.ts +211 -30
  269. package/src/langium/module.ts +106 -21
  270. package/src/langium/naming/name-provider.ts +27 -5
  271. package/src/langium/naming/name-separator-validation.ts +34 -5
  272. package/src/langium/residency/cst-residency-service.ts +36 -12
  273. package/src/langium/scope/hydranium-scope-provider.ts +40 -22
  274. package/src/langium/service-registry.ts +6 -2
  275. package/src/langium/shared-services.ts +19 -2
  276. package/src/langium/transfer/transfer-encoder.ts +85 -24
  277. package/src/langium/validation/document-validator.ts +537 -11
  278. package/src/langium/workspace/document-uri-policy.ts +3 -4
  279. package/src/langium/workspace/hydranium-langium-document-factory.ts +34 -0
  280. package/src/langium/workspace/hydranium-workspace-manager.ts +30 -1
  281. package/src/langium/workspace/in-memory-file-system-provider.ts +13 -3
  282. package/src/langium/workspace/initialize-workspace.ts +34 -5
  283. package/src/langium/workspace/langium-documents.ts +106 -33
  284. package/src/locale/index.ts +10 -0
  285. package/src/locale/server-locale.ts +86 -0
  286. package/src/lsp/hydranium-document-update-handler.ts +1 -1
  287. package/src/lsp/index.ts +1 -1
  288. package/src/lsp/lsp-latency.ts +62 -0
  289. package/src/lsp/semantic-token-provider.ts +74 -7
  290. package/src/messages/carriers.ts +78 -0
  291. package/src/messages/index.ts +36 -0
  292. package/src/messages/renderer.ts +204 -0
  293. package/src/node/heap-ceiling.ts +108 -0
  294. package/src/node/index.ts +1 -0
  295. package/src/node/latency-from-env.ts +1 -1
  296. package/src/node/node-file-system-provider.ts +1 -41
  297. package/src/node/profiling-run.ts +3 -3
  298. package/src/node/rename-over-open-readers.ts +78 -0
  299. package/src/node/socket-launcher.ts +18 -2
  300. package/src/testing/fake-document.ts +24 -5
  301. package/src/testing/make-noop-shared-services.ts +50 -2
  302. package/src/testing/make-test-services.ts +73 -18
  303. package/src/testing/node/spawned-server.ts +35 -1
  304. package/src/testing/playwright/flaky-network-proxy.ts +240 -0
  305. package/src/testing/playwright/index.ts +1 -0
  306. package/src/testing/playwright/server-log-capture.ts +24 -5
  307. package/src/testing/playwright/server-log-rename-reporter.ts +12 -1
  308. package/src/testing/stub-ast-document-manager.ts +10 -14
  309. package/src/testing/stub-document-builder.ts +32 -3
  310. package/src/testing/stub-langium-documents.ts +3 -2
  311. package/src/testing/stub-model-service.ts +7 -6
  312. package/lib/lsp/instrument-connection.d.ts +0 -36
  313. package/lib/lsp/instrument-connection.d.ts.map +0 -1
  314. package/lib/lsp/instrument-connection.js +0 -62
  315. package/lib/lsp/instrument-connection.js.map +0 -1
  316. package/src/lsp/instrument-connection.ts +0 -66
@@ -37,6 +37,7 @@ import {
37
37
  OptionalVersionedTextDocumentIdentifier,
38
38
  type RequestHandler,
39
39
  type TextDocumentChangeEvent,
40
+ TextDocumentContentChangeEvent as ContentChange,
40
41
  TextDocumentEdit,
41
42
  TextDocumentSyncKind,
42
43
  type TextDocumentWillSaveEvent,
@@ -165,15 +166,15 @@ interface ConnectionWithTextDocumentSync {
165
166
  */
166
167
  interface PendingPush {
167
168
  /**
168
- * The text the client held BEFORE this push — the shadow baseline the
169
- * push's edits were diffed against, and therefore the text the client's
170
- * echo addresses with its ranges.
169
+ * The text the client held BEFORE this push, and therefore the text its
170
+ * echo addresses with its ranges. Wider than the baseline the push's edits
171
+ * were diffed against: a full replace is sent with no diff baseline and
172
+ * still lands on a buffer the echo is keyed to.
171
173
  *
172
- * `undefined` when the push was a full replace sent with no baseline (a
173
- * first sync, or the recovery push after a rejection). There is then no
174
- * knowing what the client held, so the echo is reconstructed against the
175
- * synced text instead — which is sound for a full replace, whose
176
- * application is position-independent.
174
+ * `undefined` only when that buffer is unknown — the client never declared
175
+ * one, or a rejection invalidated what was tracked. The echo is then
176
+ * reconstructed against the synced text, which is sound only for a
177
+ * position-independent (full-text) change.
177
178
  */
178
179
  readonly before: string | undefined;
179
180
  /** {@link contentHash} of the text this push moves the client to. */
@@ -182,18 +183,25 @@ interface PendingPush {
182
183
 
183
184
  /**
184
185
  * What an incoming language-client change turns out to be once reconstructed
185
- * against the pushes still in flight — the return of
186
+ * against the buffer its ranges address — the return of
186
187
  * {@link HydraniumTextDocuments.classifyLanguageClientChange}.
187
188
  */
188
189
  type LanguageClientChangeOrigin =
189
190
  /** The client is reporting a text we pushed it. The synced document is already there. */
190
191
  | { readonly kind: 'echo' }
191
192
  /**
192
- * The client's buffer has moved somewhere we did not push it — it coalesced
193
- * a genuine keystroke into the echo, or typed before the push arrived. The
194
- * reconstructed text is what it now holds, and is authoritative.
193
+ * The client's buffer holds a text we did not push it — a keystroke that
194
+ * raced a push, or an edit to a buffer the store has already been written
195
+ * past. The reconstructed text is what it now holds, and is authoritative.
195
196
  */
196
- | { readonly kind: 'divergent'; readonly text: string };
197
+ | { readonly kind: 'divergent'; readonly text: string }
198
+ /**
199
+ * The change carries ranges and no known text addresses them, so no
200
+ * reconstruction is offered. Adopting one anyway splices the document and
201
+ * stores an edit nobody made; dropping costs at most the one keystroke the
202
+ * client still holds and the next push contradicts.
203
+ */
204
+ | { readonly kind: 'unreconstructable' };
197
205
 
198
206
  /**
199
207
  * Cheap, stable, non-cryptographic content hash (cyrb53) for the version
@@ -229,7 +237,7 @@ function contentHash(text: string): string {
229
237
  * per-client staleness guard and never leak into the shared sequence —
230
238
  * the two are different things (an editor's edit-operation counter vs the
231
239
  * document's content-revision number), and splicing them lets versions drift
232
- * silently past `baseVersion` gate holders.
240
+ * silently past based-on gate holders.
233
241
  * - Version-author history so each edit is attributable to its originating client.
234
242
  * - Pending-content staging used by the integrity service to thread corrections
235
243
  * through `workspace/applyEdit` cycles for currently-closed documents.
@@ -256,7 +264,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
256
264
  * that record is deleted on last close, while the version sequence must
257
265
  * survive it — the shared version is a server-owned, monotonic,
258
266
  * advances-iff-content-changes counter that never resets while the server
259
- * lives. That invariant is what makes an optimistic `baseVersion` gate
267
+ * lives. That invariant is what makes an optimistic based-on gate
260
268
  * sound: "version unchanged ⇔ content unchanged", with no false conflicts
261
269
  * from close/reopen version resets and no false passes from a reopened
262
270
  * sequence coincidentally landing on a stale writer's number.
@@ -471,15 +479,16 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
471
479
  }
472
480
  record.clientVersions.set(clientId, td.version);
473
481
 
474
- // A language-client change arriving while one of our own pushes is in
475
- // flight is keyed to the buffer the client held BEFORE that push, NOT
476
- // to the synced text — the authored write already advanced the latter.
482
+ // A language-client change is keyed to the buffer that client holds,
483
+ // which is the synced text only while the two agree — an authored
484
+ // write advances the synced text without the client knowing, and a
485
+ // push in flight moves the client without the store knowing.
477
486
  // Resolve which it is, and against what, before touching anything.
478
- const inFlight =
487
+ const origin =
479
488
  clientId === LANGUAGE_CLIENT_ID
480
489
  ? this.classifyLanguageClientChange(this.toLanguageClientUri(td.uri), document, changes)
481
490
  : undefined;
482
- if (inFlight?.kind === 'echo') {
491
+ if (origin?.kind === 'echo') {
483
492
  // The client is reporting a text we pushed it, so the synced
484
493
  // document is ALREADY there and the changes must not be applied a
485
494
  // second time. Nothing is minted and no rebuild fires. The
@@ -491,19 +500,23 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
491
500
  this.logUri(uri, `Skip rebuild: echo of a server-authored push (client version ${td.version})`, 'debug');
492
501
  return;
493
502
  }
503
+ if (origin?.kind === 'unreconstructable') {
504
+ this.tracer.with(uri).warn(`Drop change: no known client buffer for its ranges (client version ${td.version})`);
505
+ return;
506
+ }
494
507
 
495
508
  // The SHARED version advances iff the content actually changes — the
496
- // invariant optimistic `baseVersion` gates rely on. The new text is
509
+ // invariant optimistic based-on gates rely on. The new text is
497
510
  // only known after applying the (possibly incremental) changes, so
498
511
  // apply at a tentative +1 and roll the version back on an identical
499
512
  // result (an empty-changes update only re-stamps the version).
500
513
  //
501
- // A divergent in-flight change is applied as its RECONSTRUCTED text
502
- // rather than as its own ranges: the ranges address the pre-push
503
- // baseline, so applying them here would splice the wrong lines.
514
+ // A divergent change is applied as its RECONSTRUCTED text rather than
515
+ // as its own ranges: those ranges address the client's own buffer, so
516
+ // applying them here would splice the wrong lines.
504
517
  const previousText = document.getText();
505
518
  const sharedVersion = document.version;
506
- document = this.configuration.update(document, inFlight === undefined ? changes : [{ text: inFlight.text }], sharedVersion + 1);
519
+ document = this.configuration.update(document, origin === undefined ? changes : [{ text: origin.text }], sharedVersion + 1);
507
520
  const changed = document.getText() !== previousText;
508
521
  if (!changed) {
509
522
  document = this.configuration.update(document, [], sharedVersion);
@@ -544,7 +557,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
544
557
  * A content-identical write still fires the change event (rebuild): the
545
558
  * authored write's server-side rebuild is correctness-bearing, it just
546
559
  * mints no new version — nothing observable changed, so watchers'
547
- * `baseVersion` pointers stay valid.
560
+ * based-on versions stay valid.
548
561
  *
549
562
  * Returns the resulting shared version. Throws when the document is not
550
563
  * open — callers (`AstDocumentManager.update`) open first.
@@ -644,18 +657,20 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
644
657
  if (this.isOpenInClient(uri, clientId)) {
645
658
  // Already open for this client under this canonical identity. If this is a
646
659
  // 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.
660
+ // divergent path, e.g. a symlink and its real path), record it so outbound
661
+ // edits reach this tab too — but do NOT re-fire open/rebuild; the document
662
+ // is already live.
650
663
  if (clientId === LANGUAGE_CLIENT_ID) {
651
664
  const clientFacing = this.toLanguageClientUri(td.uri);
652
665
  const record = this.__documents.get(uri);
653
666
  if (record && !record.languageClientUris?.has(clientFacing)) {
654
667
  (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
- }
668
+ // This tab's own buffer, NOT the synced text: a second tab is a second
669
+ // client model, read from disk, so a server-authored write already
670
+ // applied to the first tab leaves it BEHIND the synced document. Keying
671
+ // a diff to the synced text here addresses lines this tab does not have
672
+ // and drops content it does.
673
+ this.__shadow.setOpenedText(clientFacing, td.text);
659
674
  }
660
675
  }
661
676
  return;
@@ -679,7 +694,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
679
694
  const source = pendingText ? ', source=pending' : '';
680
695
  // The SHARED version is server-assigned: continue the persisted
681
696
  // sequence — same version when the content is unchanged since the
682
- // last close (so watchers' `baseVersion` pointers stay valid), one
697
+ // last close (so watchers' based-on versions stay valid), one
683
698
  // step when it changed (so no stale pointer can coincidentally pass
684
699
  // the optimistic gate). Only a first-ever open adopts the client's
685
700
  // declared id as the sequence seed.
@@ -694,22 +709,77 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
694
709
  // Baseline the shadow to what Monaco just opened so the next outbound
695
710
  // applyEditToLanguageClient diffs against the right starting point. Keyed by the
696
711
  // language-client URI (what Monaco holds), not the canonical document key.
697
- this.__shadow.set(this.toLanguageClientUri(td.uri), document.getText());
712
+ //
713
+ // The CLIENT's declared text, never the synced document: staged content
714
+ // consumed above leaves the two different, and a baseline asserting the
715
+ // client already holds the staged text suppresses the one sync that would
716
+ // deliver it. Skipped entirely when a push is already outstanding for this
717
+ // URI — an `applyEdit` to a closed file has the client open from disk and
718
+ // apply afterwards, so that shadow records what it is about to hold and
719
+ // disk text would key the next diff to a buffer nobody has.
720
+ const clientFacing = this.toLanguageClientUri(td.uri);
721
+ if (!this.__shadow.isTracked(clientFacing)) {
722
+ this.__shadow.set(clientFacing, td.text);
723
+ }
698
724
  }
699
725
  const toFire = Object.freeze({ document, clientId });
700
726
  this.__onDidOpen.fire(toFire);
701
727
  this.__onDidChangeContent.fire(toFire);
702
728
  } else {
703
729
  // An additional client attaches to a document already open by another client.
704
- this.log(
705
- uri,
706
- `Attach client: ${clientId} joined existing document (version ${document.version}, ` +
707
- `now open in: ${[...existingClients, clientId].join(', ')})`
708
- );
730
+ this.logClientJoined(uri, clientId, document.version, existingClients);
731
+ if (clientId === LANGUAGE_CLIENT_ID) {
732
+ // Monaco's own buffer, which on this path is NOT the synced text — another
733
+ // client opened the document and may already have changed it. Recorded as
734
+ // an equality-only baseline (never a diff one, see `setOpenedText`) so the
735
+ // refresh below does not push a full replace of content Monaco already
736
+ // holds, which would dirty the file on open.
737
+ this.__shadow.setOpenedText(this.toLanguageClientUri(td.uri), td.text);
738
+ }
709
739
  this.refreshContent(uri, clientId);
710
740
  }
711
741
  }
712
742
 
743
+ /**
744
+ * Record `clientId` as an additional holder of a document another client
745
+ * already has open.
746
+ *
747
+ * Unlike the attach branch of {@link notifyDidOpenTextDocument} this does
748
+ * not refresh. That branch re-renders an attaching TEXTUAL view; a client
749
+ * reaching a document through a non-textual route has nothing to re-render,
750
+ * and refreshing per attach turns each into a Langium rebuild and dependent
751
+ * relink cascade.
752
+ *
753
+ * The staleness guard baselines at the SYNCED version, not a declared buffer
754
+ * version, because a client arriving this way holds no buffer of its own.
755
+ * Unifying the two routes therefore rebaselines a textual client's guard to a
756
+ * version it never declared.
757
+ *
758
+ * Returns whether a hold was added — `false` when `uri` is not open, or
759
+ * `clientId` already holds it.
760
+ */
761
+ attachClient(uri: DocumentUri, clientId: string): boolean {
762
+ const key = this.documentKey(uri);
763
+ const document = this.__syncedDocuments.get(key);
764
+ if (!document || this.isOpenInClient(key, clientId)) {
765
+ return false;
766
+ }
767
+ const record = this.trackingFor(key);
768
+ const existingClients = [...record.clients];
769
+ record.clients.add(clientId);
770
+ record.clientVersions.set(clientId, document.version);
771
+ this.logClientJoined(key, clientId, document.version, existingClients);
772
+ return true;
773
+ }
774
+
775
+ protected logClientJoined(uri: DocumentUri, clientId: string, version: number, existingClients: readonly string[]): void {
776
+ this.log(
777
+ uri,
778
+ `Attach client: ${clientId} joined existing document (version ${version}, ` +
779
+ `now open in: ${[...existingClients, clientId].join(', ')})`
780
+ );
781
+ }
782
+
713
783
  refreshContent(uri: DocumentUri, clientId: string): void {
714
784
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(uri));
715
785
  if (syncedDocument) {
@@ -790,7 +860,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
790
860
  * `0` for a URI this store has never seen. The shared sequence is
791
861
  * server-owned and monotonic across close/reopen cycles, and advances
792
862
  * exactly when the synced content changes — which is what makes it a sound
793
- * optimistic-concurrency token (`baseVersion` gates): version unchanged ⇔
863
+ * optimistic-concurrency token (based-on gates): version unchanged ⇔
794
864
  * content unchanged.
795
865
  */
796
866
  version(uri: DocumentUri): number {
@@ -967,10 +1037,11 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
967
1037
  const targets: Iterable<LanguageClientUri> = recorded && recorded.size > 0 ? recorded : [this.toLanguageClientUri(uri)];
968
1038
  let lastResult: ApplyWorkspaceEditResult | undefined;
969
1039
  for (const targetUri of targets) {
970
- // Read BEFORE computeEdits, which overwrites it. This is the text the
971
- // edits below are keyed to, and therefore the only text the client's
972
- // echo of them can be reconstructed against.
973
- const before = this.__shadow.get(targetUri);
1040
+ // Read BEFORE computeEdits, which overwrites the baseline and drops
1041
+ // the opened snapshot. This is the text the client holds, and
1042
+ // therefore the only text its echo of this push can be reconstructed
1043
+ // against — whether or not the push below is keyed to it.
1044
+ const before = this.__shadow.clientText(targetUri);
974
1045
  const edits = this.__shadow.computeEdits(targetUri, newText);
975
1046
  if (edits.length === 0) {
976
1047
  continue;
@@ -985,9 +1056,20 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
985
1056
  // it is exactly what the caller retries with after a rejection, so
986
1057
  // gating it there would refuse the recovery too.
987
1058
  const version = isFullReplace(edits) ? UNKNOWN_CLIENT_VERSION : this.languageClientVersion(uri);
1059
+ // A full `ApplyWorkspaceEditParams`, `edit` and all — NOT a bare
1060
+ // `WorkspaceEdit` with a `label` beside it. `applyEdit` takes
1061
+ // `ApplyWorkspaceEditParams | WorkspaceEdit` and discriminates on
1062
+ // `!!value.edit`, so `{ label, documentChanges }` is wrapped as
1063
+ // `{ edit: { label, documentChanges } }` — putting the label inside
1064
+ // the edit, where LSP defines no such field and no client reads it.
1065
+ // The union is also what hides it at compile time: excess-property
1066
+ // checking admits a property present in EITHER member, so an object
1067
+ // matching neither type-checks against the union.
988
1068
  const result = await connection.workspace.applyEdit({
989
1069
  label: options?.label,
990
- documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1070
+ edit: {
1071
+ documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1072
+ }
991
1073
  });
992
1074
  if (result && result.applied === false) {
993
1075
  this.__shadow.invalidate(targetUri);
@@ -1043,12 +1125,17 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1043
1125
 
1044
1126
  /**
1045
1127
  * Decide what an incoming language-client change actually is, by
1046
- * reconstructing the client's resulting buffer against the OLDEST push still
1047
- * in flight for `clientFacing`.
1128
+ * reconstructing the client's resulting buffer against the text its ranges
1129
+ * address — the pre-push buffer of the OLDEST push still in flight for
1130
+ * `clientFacing`, else whatever that client is believed to hold.
1048
1131
  *
1049
- * `undefined` when nothing of ours is in flight, which is the ordinary path:
1050
- * the client's ranges then address the synced text and the caller applies
1051
- * them to it directly.
1132
+ * `undefined` when the client's ranges address the synced text itself —
1133
+ * nothing in flight, and no evidence the client holds anything else. That
1134
+ * is the ordinary path, and the caller then applies the ranges directly.
1135
+ * The two texts part company without a push in flight whenever a client
1136
+ * attaches to a document another client has already written: it opened
1137
+ * from disk, so applying its ranges to the synced text splices lines they
1138
+ * never addressed.
1052
1139
  *
1053
1140
  * **Why the oldest, and why reconstruct at all.** The client applies our
1054
1141
  * pushes in order and echoes each against the buffer it held before that
@@ -1079,22 +1166,41 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1079
1166
  document: T,
1080
1167
  changes: TextDocumentContentChangeEvent[]
1081
1168
  ): LanguageClientChangeOrigin | undefined {
1082
- const pending = this.__pendingPushes.get(clientFacing);
1083
- if (pending === undefined || pending.length === 0) {
1169
+ const queued = this.__pendingPushes.get(clientFacing);
1170
+ const pending = queued?.length ? queued : undefined;
1171
+ // The text the client's ranges address: the buffer it held before the
1172
+ // oldest push still in flight, else the buffer it is believed to hold.
1173
+ const clientText = pending?.[0].before ?? this.__shadow.clientText(clientFacing);
1174
+ if (pending === undefined && (clientText === undefined || clientText === document.getText())) {
1084
1175
  return undefined;
1085
1176
  }
1177
+ if (pending !== undefined && pending[0].before === undefined && changes.some(change => ContentChange.isIncremental(change))) {
1178
+ // A push sent to a client whose buffer was unknown — a first sync, or
1179
+ // the recovery push after a rejection. The shadow now holds the text
1180
+ // that push MOVES the client to, which is the one text the echo's
1181
+ // ranges provably do not address, so the fallback above is a baseline
1182
+ // known to be wrong rather than merely unverified. Drop the queue and
1183
+ // the shadow: the next sync is then a full replace, which lands on
1184
+ // whatever the client holds. A full-text change is exempt because it
1185
+ // reconstructs identically against any baseline.
1186
+ pending.length = 0;
1187
+ this.__shadow.invalidate(clientFacing);
1188
+ return { kind: 'unreconstructable' };
1189
+ }
1086
1190
  // Through the configured factories, not `TextDocument` directly, so an
1087
1191
  // adopter's custom text-document type governs how the ranges are applied
1088
1192
  // here exactly as it does on the synced document.
1089
- const probe = this.create(clientFacing, document.languageId, 0, pending[0].before ?? document.getText());
1090
- const clientText = this.update(probe, changes, 0).getText();
1091
- const matchIndex = pending.findIndex(push => push.afterHash === contentHash(clientText));
1092
- if (matchIndex >= 0) {
1093
- pending.splice(0, matchIndex + 1);
1094
- return { kind: 'echo' };
1193
+ const probe = this.create(clientFacing, document.languageId, 0, clientText ?? document.getText());
1194
+ const reconstructed = this.update(probe, changes, 0).getText();
1195
+ if (pending !== undefined) {
1196
+ const matchIndex = pending.findIndex(push => push.afterHash === contentHash(reconstructed));
1197
+ if (matchIndex >= 0) {
1198
+ pending.splice(0, matchIndex + 1);
1199
+ return { kind: 'echo' };
1200
+ }
1201
+ pending.length = 0;
1095
1202
  }
1096
- pending.length = 0;
1097
- return { kind: 'divergent', text: clientText };
1203
+ return { kind: 'divergent', text: reconstructed };
1098
1204
  }
1099
1205
 
1100
1206
  /**
@@ -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.
@@ -85,23 +96,54 @@ export class LanguageClientTextShadow {
85
96
  }
86
97
 
87
98
  /**
88
- * The text the language client is believed to hold, or `undefined` when
89
- * there is no baseline (first sync, or after an {@link invalidate}).
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
+
112
+ /**
113
+ * The text the language client is believed to hold: the tracked text, else
114
+ * the buffer it declared at `didOpen`.
90
115
  *
91
116
  * Read at the `workspace/applyEdit` egress BEFORE {@link computeEdits},
92
- * which overwrites it: a push's minimal edits are keyed to this text, so an
93
- * echo of that push carries ranges addressing it and nothing else. Losing
94
- * it is what makes an incremental echo unreconstructable, and applying such
95
- * an echo to the already-advanced synced text duplicates every inserted
96
- * line.
117
+ * which overwrites the tracked text and drops the opened snapshot. A change
118
+ * the client sends carries ranges addressing this text and nothing else, so
119
+ * losing it leaves that change unreconstructable — and applying its ranges
120
+ * to the already-advanced synced text splices lines they never addressed.
121
+ *
122
+ * Deliberately wider than what {@link computeEdits} may diff from: an
123
+ * opened snapshot is a position-dependent key to a buffer the client may
124
+ * have moved past, so keying a diff to it splices that buffer.
97
125
  */
98
- get(uri: string): string | undefined {
99
- return this.shadow.get(uri);
126
+ clientText(uri: string): string | undefined {
127
+ return this.shadow.get(uri) ?? this.opened.get(uri);
128
+ }
129
+
130
+ /**
131
+ * Whether a tracked text exists for `uri`, which is what decides whether a
132
+ * push can be diffed at all. Distinct from {@link clientText}, which also
133
+ * answers from the opened snapshot and so cannot settle this.
134
+ */
135
+ isTracked(uri: string): boolean {
136
+ return this.shadow.has(uri);
100
137
  }
101
138
 
102
139
  /** Forget the shadow for a URI; next {@link computeEdits} falls back to a full-document replace. */
103
140
  invalidate(uri: string): void {
104
141
  this.shadow.delete(uri);
142
+ // Drops the opened snapshot too, and this is the only place that has to:
143
+ // `computeEdits` clears it whenever it consults it, so the sole way it can
144
+ // outlive what the client holds is surviving under a live shadow until that
145
+ // shadow is dropped here.
146
+ this.opened.delete(uri);
105
147
  }
106
148
 
107
149
  /**
@@ -109,7 +151,9 @@ export class LanguageClientTextShadow {
109
151
  * the tracked shadow to `newText`. Updates the shadow to `newText` so subsequent calls diff
110
152
  * against it.
111
153
  *
112
- * - Empty array when the shadow already equals `newText` (caller should skip the RPC).
154
+ * - Empty array when the shadow already equals `newText` (caller should skip the RPC),
155
+ * or when there is no shadow and the client opened the document with exactly `newText`
156
+ * (see {@link setOpenedText}) — pushing it anyway dirties the client's buffer on open.
113
157
  * - Full-range replace when there is no shadow (first sync or post-{@link invalidate}).
114
158
  * - Line-level diff otherwise. Before returning, we locally reconstruct `newText` by applying
115
159
  * the diff to `old`; if that doesn't match, we fall back to a full-range replace and notify
@@ -124,7 +168,11 @@ export class LanguageClientTextShadow {
124
168
  this.shadow.set(uri, newText);
125
169
  const fullReplace: TextEdit = { range: FULL_RANGE, newText };
126
170
  if (old === undefined) {
127
- return [fullReplace];
171
+ // The opened snapshot has served its purpose either way: the shadow above
172
+ // is now the baseline, so leaving it would only risk a later stale match.
173
+ const openedText = this.opened.get(uri);
174
+ this.opened.delete(uri);
175
+ return openedText === newText ? [] : [fullReplace];
128
176
  }
129
177
  const edits = diffToEdits(old, newText);
130
178
  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';