@hydranium/core 1.0.0-next.16 → 1.0.0-next.160

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 (505) hide show
  1. package/README.md +5 -1
  2. package/lib/documents/ast-document-manager.d.ts +190 -29
  3. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  4. package/lib/documents/ast-document-manager.js +235 -38
  5. package/lib/documents/ast-document-manager.js.map +1 -1
  6. package/lib/documents/client-ids.d.ts +5 -19
  7. package/lib/documents/client-ids.d.ts.map +1 -1
  8. package/lib/documents/client-ids.js +5 -19
  9. package/lib/documents/client-ids.js.map +1 -1
  10. package/lib/documents/client-session-errors.d.ts +15 -0
  11. package/lib/documents/client-session-errors.d.ts.map +1 -0
  12. package/lib/documents/client-session-errors.js +15 -0
  13. package/lib/documents/client-session-errors.js.map +1 -0
  14. package/lib/documents/client-session-registry.d.ts +138 -0
  15. package/lib/documents/client-session-registry.d.ts.map +1 -0
  16. package/lib/documents/client-session-registry.js +187 -0
  17. package/lib/documents/client-session-registry.js.map +1 -0
  18. package/lib/documents/file-system-task-queue.d.ts +49 -0
  19. package/lib/documents/file-system-task-queue.d.ts.map +1 -0
  20. package/lib/documents/file-system-task-queue.js +34 -0
  21. package/lib/documents/file-system-task-queue.js.map +1 -0
  22. package/lib/documents/hydranium-text-documents.d.ts +389 -59
  23. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  24. package/lib/documents/hydranium-text-documents.js +699 -136
  25. package/lib/documents/hydranium-text-documents.js.map +1 -1
  26. package/lib/documents/index.d.ts +3 -0
  27. package/lib/documents/index.d.ts.map +1 -1
  28. package/lib/documents/index.js +3 -0
  29. package/lib/documents/index.js.map +1 -1
  30. package/lib/documents/language-client-text-shadow.d.ts +41 -9
  31. package/lib/documents/language-client-text-shadow.d.ts.map +1 -1
  32. package/lib/documents/language-client-text-shadow.js +56 -11
  33. package/lib/documents/language-client-text-shadow.js.map +1 -1
  34. package/lib/documents/self-save-registry.d.ts +37 -6
  35. package/lib/documents/self-save-registry.d.ts.map +1 -1
  36. package/lib/documents/self-save-registry.js +28 -15
  37. package/lib/documents/self-save-registry.js.map +1 -1
  38. package/lib/index.d.ts +3 -0
  39. package/lib/index.d.ts.map +1 -1
  40. package/lib/index.js +6 -0
  41. package/lib/index.js.map +1 -1
  42. package/lib/langium/ast-extension/ast-node-builder.d.ts +32 -9
  43. package/lib/langium/ast-extension/ast-node-builder.d.ts.map +1 -1
  44. package/lib/langium/ast-extension/ast-node-builder.js +60 -22
  45. package/lib/langium/ast-extension/ast-node-builder.js.map +1 -1
  46. package/lib/langium/bootstrap.d.ts.map +1 -1
  47. package/lib/langium/bootstrap.js +16 -9
  48. package/lib/langium/bootstrap.js.map +1 -1
  49. package/lib/langium/config/configuration-provider.d.ts +81 -0
  50. package/lib/langium/config/configuration-provider.d.ts.map +1 -0
  51. package/lib/langium/config/configuration-provider.js +112 -0
  52. package/lib/langium/config/configuration-provider.js.map +1 -0
  53. package/lib/langium/config/index.d.ts +1 -0
  54. package/lib/langium/config/index.d.ts.map +1 -1
  55. package/lib/langium/config/index.js +1 -0
  56. package/lib/langium/config/index.js.map +1 -1
  57. package/lib/langium/diagnostics/lsp-logger.d.ts +17 -0
  58. package/lib/langium/diagnostics/lsp-logger.d.ts.map +1 -1
  59. package/lib/langium/diagnostics/lsp-logger.js +40 -6
  60. package/lib/langium/diagnostics/lsp-logger.js.map +1 -1
  61. package/lib/langium/document-builder/build-pipeline-integration.d.ts +21 -1
  62. package/lib/langium/document-builder/build-pipeline-integration.d.ts.map +1 -1
  63. package/lib/langium/document-builder/build-pipeline-integration.js +32 -5
  64. package/lib/langium/document-builder/build-pipeline-integration.js.map +1 -1
  65. package/lib/langium/document-builder/build-session.d.ts +93 -0
  66. package/lib/langium/document-builder/build-session.d.ts.map +1 -0
  67. package/lib/langium/document-builder/build-session.js +72 -0
  68. package/lib/langium/document-builder/build-session.js.map +1 -0
  69. package/lib/langium/document-builder/document-builder.d.ts +365 -19
  70. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  71. package/lib/langium/document-builder/document-builder.js +637 -44
  72. package/lib/langium/document-builder/document-builder.js.map +1 -1
  73. package/lib/langium/document-builder/index.d.ts +1 -0
  74. package/lib/langium/document-builder/index.d.ts.map +1 -1
  75. package/lib/langium/document-builder/index.js +1 -0
  76. package/lib/langium/document-builder/index.js.map +1 -1
  77. package/lib/langium/integration-services.d.ts +45 -1
  78. package/lib/langium/integration-services.d.ts.map +1 -1
  79. package/lib/langium/integration-services.js +10 -5
  80. package/lib/langium/integration-services.js.map +1 -1
  81. package/lib/langium/integrity/integrity-rule.d.ts +13 -5
  82. package/lib/langium/integrity/integrity-rule.d.ts.map +1 -1
  83. package/lib/langium/integrity/integrity-rule.js.map +1 -1
  84. package/lib/langium/integrity/integrity-service.d.ts +50 -6
  85. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  86. package/lib/langium/integrity/integrity-service.js +193 -17
  87. package/lib/langium/integrity/integrity-service.js.map +1 -1
  88. package/lib/langium/keys/containment.d.ts +64 -0
  89. package/lib/langium/keys/containment.d.ts.map +1 -0
  90. package/lib/langium/keys/containment.js +53 -0
  91. package/lib/langium/keys/containment.js.map +1 -0
  92. package/lib/langium/keys/index.d.ts +1 -0
  93. package/lib/langium/keys/index.d.ts.map +1 -1
  94. package/lib/langium/keys/index.js +1 -0
  95. package/lib/langium/keys/index.js.map +1 -1
  96. package/lib/langium/keys/name-based-key-provider.d.ts +4 -0
  97. package/lib/langium/keys/name-based-key-provider.d.ts.map +1 -1
  98. package/lib/langium/keys/name-based-key-provider.js +4 -0
  99. package/lib/langium/keys/name-based-key-provider.js.map +1 -1
  100. package/lib/langium/language-module.d.ts +27 -2
  101. package/lib/langium/language-module.d.ts.map +1 -1
  102. package/lib/langium/language-module.js +16 -2
  103. package/lib/langium/language-module.js.map +1 -1
  104. package/lib/langium/model-service/client-session.d.ts +307 -0
  105. package/lib/langium/model-service/client-session.d.ts.map +1 -0
  106. package/lib/langium/model-service/client-session.js +395 -0
  107. package/lib/langium/model-service/client-session.js.map +1 -0
  108. package/lib/langium/model-service/index.d.ts +1 -0
  109. package/lib/langium/model-service/index.d.ts.map +1 -1
  110. package/lib/langium/model-service/index.js +1 -0
  111. package/lib/langium/model-service/index.js.map +1 -1
  112. package/lib/langium/model-service/model-service.d.ts +261 -198
  113. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  114. package/lib/langium/model-service/model-service.js +232 -344
  115. package/lib/langium/model-service/model-service.js.map +1 -1
  116. package/lib/langium/module.d.ts +144 -35
  117. package/lib/langium/module.d.ts.map +1 -1
  118. package/lib/langium/module.js +45 -34
  119. package/lib/langium/module.js.map +1 -1
  120. package/lib/langium/naming/name-provider.d.ts +25 -4
  121. package/lib/langium/naming/name-provider.d.ts.map +1 -1
  122. package/lib/langium/naming/name-provider.js +2 -1
  123. package/lib/langium/naming/name-provider.js.map +1 -1
  124. package/lib/langium/naming/name-separator-validation.d.ts +23 -0
  125. package/lib/langium/naming/name-separator-validation.d.ts.map +1 -1
  126. package/lib/langium/naming/name-separator-validation.js +30 -1
  127. package/lib/langium/naming/name-separator-validation.js.map +1 -1
  128. package/lib/langium/project/abstract-project-manager.d.ts.map +1 -1
  129. package/lib/langium/project/abstract-project-manager.js +9 -2
  130. package/lib/langium/project/abstract-project-manager.js.map +1 -1
  131. package/lib/langium/project/project-change-event.d.ts +2 -2
  132. package/lib/langium/residency/cst-residency-service.d.ts +31 -11
  133. package/lib/langium/residency/cst-residency-service.d.ts.map +1 -1
  134. package/lib/langium/residency/cst-residency-service.js +13 -56
  135. package/lib/langium/residency/cst-residency-service.js.map +1 -1
  136. package/lib/langium/scope/hydranium-scope-provider.d.ts +48 -13
  137. package/lib/langium/scope/hydranium-scope-provider.d.ts.map +1 -1
  138. package/lib/langium/scope/hydranium-scope-provider.js +56 -20
  139. package/lib/langium/scope/hydranium-scope-provider.js.map +1 -1
  140. package/lib/langium/serialization/abstract-serializer.d.ts +11 -20
  141. package/lib/langium/serialization/abstract-serializer.d.ts.map +1 -1
  142. package/lib/langium/serialization/abstract-serializer.js +12 -21
  143. package/lib/langium/serialization/abstract-serializer.js.map +1 -1
  144. package/lib/langium/service-registry.d.ts +15 -1
  145. package/lib/langium/service-registry.d.ts.map +1 -1
  146. package/lib/langium/service-registry.js +16 -0
  147. package/lib/langium/service-registry.js.map +1 -1
  148. package/lib/langium/shared-services.d.ts +15 -2
  149. package/lib/langium/shared-services.d.ts.map +1 -1
  150. package/lib/langium/shared-services.js.map +1 -1
  151. package/lib/langium/transfer/transfer-encoder.d.ts +60 -17
  152. package/lib/langium/transfer/transfer-encoder.d.ts.map +1 -1
  153. package/lib/langium/transfer/transfer-encoder.js +38 -16
  154. package/lib/langium/transfer/transfer-encoder.js.map +1 -1
  155. package/lib/langium/trivia/comment-preserver.d.ts +423 -0
  156. package/lib/langium/trivia/comment-preserver.d.ts.map +1 -0
  157. package/lib/langium/trivia/comment-preserver.js +906 -0
  158. package/lib/langium/trivia/comment-preserver.js.map +1 -0
  159. package/lib/langium/trivia/document-ending-preserver.d.ts +43 -0
  160. package/lib/langium/trivia/document-ending-preserver.d.ts.map +1 -0
  161. package/lib/langium/trivia/document-ending-preserver.js +48 -0
  162. package/lib/langium/trivia/document-ending-preserver.js.map +1 -0
  163. package/lib/langium/trivia/index.d.ts +14 -0
  164. package/lib/langium/trivia/index.d.ts.map +1 -0
  165. package/lib/langium/trivia/index.js +14 -0
  166. package/lib/langium/trivia/index.js.map +1 -0
  167. package/lib/langium/trivia/trivia-contribution.d.ts +37 -0
  168. package/lib/langium/trivia/trivia-contribution.d.ts.map +1 -0
  169. package/lib/langium/trivia/trivia-contribution.js +10 -0
  170. package/lib/langium/trivia/trivia-contribution.js.map +1 -0
  171. package/lib/langium/trivia/trivia-preserver.d.ts +50 -0
  172. package/lib/langium/trivia/trivia-preserver.d.ts.map +1 -0
  173. package/lib/langium/trivia/trivia-preserver.js +10 -0
  174. package/lib/langium/trivia/trivia-preserver.js.map +1 -0
  175. package/lib/langium/trivia/trivia-service.d.ts +70 -0
  176. package/lib/langium/trivia/trivia-service.d.ts.map +1 -0
  177. package/lib/langium/trivia/trivia-service.js +56 -0
  178. package/lib/langium/trivia/trivia-service.js.map +1 -0
  179. package/lib/langium/update-rewrite/update-rewrite.d.ts +1 -1
  180. package/lib/langium/validation/document-validator.d.ts +294 -10
  181. package/lib/langium/validation/document-validator.d.ts.map +1 -1
  182. package/lib/langium/validation/document-validator.js +448 -5
  183. package/lib/langium/validation/document-validator.js.map +1 -1
  184. package/lib/langium/workspace/document-uri-policy.d.ts +17 -12
  185. package/lib/langium/workspace/document-uri-policy.d.ts.map +1 -1
  186. package/lib/langium/workspace/document-uri-policy.js +10 -9
  187. package/lib/langium/workspace/document-uri-policy.js.map +1 -1
  188. package/lib/langium/workspace/file-not-found.d.ts +17 -0
  189. package/lib/langium/workspace/file-not-found.d.ts.map +1 -0
  190. package/lib/langium/workspace/file-not-found.js +20 -0
  191. package/lib/langium/workspace/file-not-found.js.map +1 -0
  192. package/lib/langium/workspace/file-system-provider.d.ts +84 -8
  193. package/lib/langium/workspace/file-system-provider.d.ts.map +1 -1
  194. package/lib/langium/workspace/file-system-provider.js +93 -14
  195. package/lib/langium/workspace/file-system-provider.js.map +1 -1
  196. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts +25 -0
  197. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts.map +1 -1
  198. package/lib/langium/workspace/hydranium-langium-document-factory.js +25 -0
  199. package/lib/langium/workspace/hydranium-langium-document-factory.js.map +1 -1
  200. package/lib/langium/workspace/hydranium-workspace-lock.d.ts +13 -0
  201. package/lib/langium/workspace/hydranium-workspace-lock.d.ts.map +1 -1
  202. package/lib/langium/workspace/hydranium-workspace-lock.js +18 -0
  203. package/lib/langium/workspace/hydranium-workspace-lock.js.map +1 -1
  204. package/lib/langium/workspace/hydranium-workspace-manager.d.ts +77 -8
  205. package/lib/langium/workspace/hydranium-workspace-manager.d.ts.map +1 -1
  206. package/lib/langium/workspace/hydranium-workspace-manager.js +115 -9
  207. package/lib/langium/workspace/hydranium-workspace-manager.js.map +1 -1
  208. package/lib/langium/workspace/in-memory-file-system-provider.d.ts +25 -2
  209. package/lib/langium/workspace/in-memory-file-system-provider.d.ts.map +1 -1
  210. package/lib/langium/workspace/in-memory-file-system-provider.js +41 -15
  211. package/lib/langium/workspace/in-memory-file-system-provider.js.map +1 -1
  212. package/lib/langium/workspace/initialize-workspace.d.ts +31 -2
  213. package/lib/langium/workspace/initialize-workspace.d.ts.map +1 -1
  214. package/lib/langium/workspace/initialize-workspace.js +13 -5
  215. package/lib/langium/workspace/initialize-workspace.js.map +1 -1
  216. package/lib/langium/workspace/langium-documents.d.ts +79 -17
  217. package/lib/langium/workspace/langium-documents.d.ts.map +1 -1
  218. package/lib/langium/workspace/langium-documents.js +91 -29
  219. package/lib/langium/workspace/langium-documents.js.map +1 -1
  220. package/lib/langium/workspace/persistent-file-system-provider.d.ts +0 -7
  221. package/lib/langium/workspace/persistent-file-system-provider.d.ts.map +1 -1
  222. package/lib/langium/workspace/persistent-file-system-provider.js +0 -7
  223. package/lib/langium/workspace/persistent-file-system-provider.js.map +1 -1
  224. package/lib/langium/workspace/virtual-document.d.ts +48 -15
  225. package/lib/langium/workspace/virtual-document.d.ts.map +1 -1
  226. package/lib/langium/workspace/virtual-document.js +78 -18
  227. package/lib/langium/workspace/virtual-document.js.map +1 -1
  228. package/lib/langium/workspace/write-lock-scope.d.ts +4 -3
  229. package/lib/langium/workspace/write-lock-scope.d.ts.map +1 -1
  230. package/lib/langium/workspace/write-lock-scope.js +4 -3
  231. package/lib/langium/workspace/write-lock-scope.js.map +1 -1
  232. package/lib/locale/index.d.ts +10 -0
  233. package/lib/locale/index.d.ts.map +1 -0
  234. package/lib/locale/index.js +10 -0
  235. package/lib/locale/index.js.map +1 -0
  236. package/lib/locale/server-locale.d.ts +73 -0
  237. package/lib/locale/server-locale.d.ts.map +1 -0
  238. package/lib/locale/server-locale.js +61 -0
  239. package/lib/locale/server-locale.js.map +1 -0
  240. package/lib/lsp/completion/hydranium-completion-provider.d.ts +44 -2
  241. package/lib/lsp/completion/hydranium-completion-provider.d.ts.map +1 -1
  242. package/lib/lsp/completion/hydranium-completion-provider.js +68 -0
  243. package/lib/lsp/completion/hydranium-completion-provider.js.map +1 -1
  244. package/lib/lsp/connection-features.d.ts +23 -0
  245. package/lib/lsp/connection-features.d.ts.map +1 -0
  246. package/lib/lsp/connection-features.js +60 -0
  247. package/lib/lsp/connection-features.js.map +1 -0
  248. package/lib/lsp/diagnostics-connection.d.ts +27 -0
  249. package/lib/lsp/diagnostics-connection.d.ts.map +1 -0
  250. package/lib/lsp/diagnostics-connection.js +53 -0
  251. package/lib/lsp/diagnostics-connection.js.map +1 -0
  252. package/lib/lsp/hydranium-document-update-handler.d.ts +90 -79
  253. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  254. package/lib/lsp/hydranium-document-update-handler.js +152 -99
  255. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  256. package/lib/lsp/index.d.ts +3 -1
  257. package/lib/lsp/index.d.ts.map +1 -1
  258. package/lib/lsp/index.js +3 -1
  259. package/lib/lsp/index.js.map +1 -1
  260. package/lib/lsp/lsp-latency.d.ts +39 -0
  261. package/lib/lsp/lsp-latency.d.ts.map +1 -0
  262. package/lib/lsp/lsp-latency.js +58 -0
  263. package/lib/lsp/lsp-latency.js.map +1 -0
  264. package/lib/lsp/semantic-token-provider.d.ts +34 -1
  265. package/lib/lsp/semantic-token-provider.d.ts.map +1 -1
  266. package/lib/lsp/semantic-token-provider.js +55 -8
  267. package/lib/lsp/semantic-token-provider.js.map +1 -1
  268. package/lib/lsp/shared-module.d.ts +4 -8
  269. package/lib/lsp/shared-module.d.ts.map +1 -1
  270. package/lib/lsp/shared-module.js.map +1 -1
  271. package/lib/lsp/start-language-server.d.ts +8 -7
  272. package/lib/lsp/start-language-server.d.ts.map +1 -1
  273. package/lib/lsp/start-language-server.js +13 -7
  274. package/lib/lsp/start-language-server.js.map +1 -1
  275. package/lib/messages/carriers.d.ts +54 -0
  276. package/lib/messages/carriers.d.ts.map +1 -0
  277. package/lib/messages/carriers.js +62 -0
  278. package/lib/messages/carriers.js.map +1 -0
  279. package/lib/messages/index.d.ts +25 -0
  280. package/lib/messages/index.d.ts.map +1 -0
  281. package/lib/messages/index.js +25 -0
  282. package/lib/messages/index.js.map +1 -0
  283. package/lib/messages/renderer.d.ts +142 -0
  284. package/lib/messages/renderer.d.ts.map +1 -0
  285. package/lib/messages/renderer.js +165 -0
  286. package/lib/messages/renderer.js.map +1 -0
  287. package/lib/node/heap-ceiling.d.ts +61 -0
  288. package/lib/node/heap-ceiling.d.ts.map +1 -0
  289. package/lib/node/heap-ceiling.js +72 -0
  290. package/lib/node/heap-ceiling.js.map +1 -0
  291. package/lib/node/index.d.ts +1 -0
  292. package/lib/node/index.d.ts.map +1 -1
  293. package/lib/node/index.js +1 -0
  294. package/lib/node/index.js.map +1 -1
  295. package/lib/node/latency-from-env.d.ts +1 -1
  296. package/lib/node/latency-from-env.js +1 -1
  297. package/lib/node/measure-memory.d.ts.map +1 -1
  298. package/lib/node/measure-memory.js +1 -0
  299. package/lib/node/measure-memory.js.map +1 -1
  300. package/lib/node/node-file-system-provider.d.ts +39 -4
  301. package/lib/node/node-file-system-provider.d.ts.map +1 -1
  302. package/lib/node/node-file-system-provider.js +109 -49
  303. package/lib/node/node-file-system-provider.js.map +1 -1
  304. package/lib/node/profile-capture.d.ts +8 -2
  305. package/lib/node/profile-capture.d.ts.map +1 -1
  306. package/lib/node/profile-capture.js.map +1 -1
  307. package/lib/node/profiling-run.d.ts +2 -2
  308. package/lib/node/profiling-run.js +1 -1
  309. package/lib/node/profiling-run.js.map +1 -1
  310. package/lib/node/rename-over-open-readers.d.ts +52 -0
  311. package/lib/node/rename-over-open-readers.d.ts.map +1 -0
  312. package/lib/node/rename-over-open-readers.js +66 -0
  313. package/lib/node/rename-over-open-readers.js.map +1 -0
  314. package/lib/node/socket-launcher.d.ts +16 -1
  315. package/lib/node/socket-launcher.d.ts.map +1 -1
  316. package/lib/node/socket-launcher.js +4 -3
  317. package/lib/node/socket-launcher.js.map +1 -1
  318. package/lib/node/stdio-launcher.d.ts +1 -1
  319. package/lib/node/stdio-launcher.d.ts.map +1 -1
  320. package/lib/node/stdio-launcher.js +1 -1
  321. package/lib/node/stdio-launcher.js.map +1 -1
  322. package/lib/testing/fake-document.d.ts +16 -4
  323. package/lib/testing/fake-document.d.ts.map +1 -1
  324. package/lib/testing/fake-document.js +18 -3
  325. package/lib/testing/fake-document.js.map +1 -1
  326. package/lib/testing/make-noop-shared-services.d.ts +14 -0
  327. package/lib/testing/make-noop-shared-services.d.ts.map +1 -1
  328. package/lib/testing/make-noop-shared-services.js +28 -2
  329. package/lib/testing/make-noop-shared-services.js.map +1 -1
  330. package/lib/testing/make-test-services.d.ts +62 -14
  331. package/lib/testing/make-test-services.d.ts.map +1 -1
  332. package/lib/testing/make-test-services.js +27 -6
  333. package/lib/testing/make-test-services.js.map +1 -1
  334. package/lib/testing/node/index.d.ts +1 -0
  335. package/lib/testing/node/index.d.ts.map +1 -1
  336. package/lib/testing/node/index.js +1 -0
  337. package/lib/testing/node/index.js.map +1 -1
  338. package/lib/testing/node/lsp-harness.d.ts.map +1 -1
  339. package/lib/testing/node/lsp-harness.js +2 -0
  340. package/lib/testing/node/lsp-harness.js.map +1 -1
  341. package/lib/testing/node/lsp-server-connection.d.ts +11 -3
  342. package/lib/testing/node/lsp-server-connection.d.ts.map +1 -1
  343. package/lib/testing/node/lsp-server-connection.js +42 -9
  344. package/lib/testing/node/lsp-server-connection.js.map +1 -1
  345. package/lib/testing/node/scratch-workspace.d.ts.map +1 -1
  346. package/lib/testing/node/scratch-workspace.js +10 -5
  347. package/lib/testing/node/scratch-workspace.js.map +1 -1
  348. package/lib/testing/node/spawned-server.d.ts +14 -0
  349. package/lib/testing/node/spawned-server.d.ts.map +1 -1
  350. package/lib/testing/node/spawned-server.js +33 -1
  351. package/lib/testing/node/spawned-server.js.map +1 -1
  352. package/lib/testing/node/unhandled-rejections.d.ts +20 -0
  353. package/lib/testing/node/unhandled-rejections.d.ts.map +1 -0
  354. package/lib/testing/node/unhandled-rejections.js +32 -0
  355. package/lib/testing/node/unhandled-rejections.js.map +1 -0
  356. package/lib/testing/playwright/flaky-network-proxy.d.ts +76 -0
  357. package/lib/testing/playwright/flaky-network-proxy.d.ts.map +1 -0
  358. package/lib/testing/playwright/flaky-network-proxy.js +164 -0
  359. package/lib/testing/playwright/flaky-network-proxy.js.map +1 -0
  360. package/lib/testing/playwright/index.d.ts +1 -0
  361. package/lib/testing/playwright/index.d.ts.map +1 -1
  362. package/lib/testing/playwright/index.js +1 -0
  363. package/lib/testing/playwright/index.js.map +1 -1
  364. package/lib/testing/playwright/server-log-capture.d.ts +14 -4
  365. package/lib/testing/playwright/server-log-capture.d.ts.map +1 -1
  366. package/lib/testing/playwright/server-log-capture.js +21 -4
  367. package/lib/testing/playwright/server-log-capture.js.map +1 -1
  368. package/lib/testing/playwright/server-log-rename-reporter.d.ts.map +1 -1
  369. package/lib/testing/playwright/server-log-rename-reporter.js +10 -1
  370. package/lib/testing/playwright/server-log-rename-reporter.js.map +1 -1
  371. package/lib/testing/run-update-pipeline.d.ts +4 -4
  372. package/lib/testing/run-update-pipeline.d.ts.map +1 -1
  373. package/lib/testing/stub-ast-document-manager.d.ts +10 -9
  374. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  375. package/lib/testing/stub-ast-document-manager.js +17 -9
  376. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  377. package/lib/testing/stub-document-builder.d.ts +26 -11
  378. package/lib/testing/stub-document-builder.d.ts.map +1 -1
  379. package/lib/testing/stub-document-builder.js +57 -4
  380. package/lib/testing/stub-document-builder.js.map +1 -1
  381. package/lib/testing/stub-hydranium-text-documents.d.ts +22 -7
  382. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  383. package/lib/testing/stub-hydranium-text-documents.js +92 -15
  384. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  385. package/lib/testing/stub-langium-documents.d.ts +3 -2
  386. package/lib/testing/stub-langium-documents.d.ts.map +1 -1
  387. package/lib/testing/stub-langium-documents.js.map +1 -1
  388. package/lib/testing/stub-model-service.d.ts +7 -6
  389. package/lib/testing/stub-model-service.d.ts.map +1 -1
  390. package/lib/testing/stub-model-service.js +4 -4
  391. package/lib/testing/stub-model-service.js.map +1 -1
  392. package/lib/util/connection-liveness.d.ts +7 -6
  393. package/lib/util/connection-liveness.d.ts.map +1 -1
  394. package/lib/util/connection-liveness.js +35 -14
  395. package/lib/util/connection-liveness.js.map +1 -1
  396. package/package.json +17 -8
  397. package/src/documents/ast-document-manager.ts +348 -64
  398. package/src/documents/client-ids.ts +5 -22
  399. package/src/documents/client-session-errors.ts +24 -0
  400. package/src/documents/client-session-registry.ts +240 -0
  401. package/src/documents/file-system-task-queue.ts +68 -0
  402. package/src/documents/hydranium-text-documents.ts +856 -166
  403. package/src/documents/index.ts +3 -0
  404. package/src/documents/language-client-text-shadow.ts +59 -11
  405. package/src/documents/self-save-registry.ts +52 -13
  406. package/src/index.ts +6 -0
  407. package/src/langium/ast-extension/ast-node-builder.ts +66 -22
  408. package/src/langium/bootstrap.ts +16 -9
  409. package/src/langium/config/configuration-provider.ts +119 -0
  410. package/src/langium/config/index.ts +1 -0
  411. package/src/langium/diagnostics/lsp-logger.ts +41 -6
  412. package/src/langium/document-builder/build-pipeline-integration.ts +50 -5
  413. package/src/langium/document-builder/build-session.ts +87 -0
  414. package/src/langium/document-builder/document-builder.ts +720 -43
  415. package/src/langium/document-builder/index.ts +1 -0
  416. package/src/langium/integration-services.ts +67 -6
  417. package/src/langium/integrity/integrity-rule.ts +13 -5
  418. package/src/langium/integrity/integrity-service.ts +202 -17
  419. package/src/langium/keys/containment.ts +87 -0
  420. package/src/langium/keys/index.ts +1 -0
  421. package/src/langium/keys/name-based-key-provider.ts +4 -0
  422. package/src/langium/language-module.ts +42 -4
  423. package/src/langium/model-service/client-session.ts +606 -0
  424. package/src/langium/model-service/index.ts +1 -0
  425. package/src/langium/model-service/model-service.ts +393 -349
  426. package/src/langium/module.ts +192 -63
  427. package/src/langium/naming/name-provider.ts +27 -5
  428. package/src/langium/naming/name-separator-validation.ts +34 -5
  429. package/src/langium/project/abstract-project-manager.ts +9 -1
  430. package/src/langium/project/project-change-event.ts +2 -2
  431. package/src/langium/residency/cst-residency-service.ts +36 -14
  432. package/src/langium/scope/hydranium-scope-provider.ts +60 -22
  433. package/src/langium/serialization/abstract-serializer.ts +13 -32
  434. package/src/langium/service-registry.ts +19 -2
  435. package/src/langium/shared-services.ts +19 -2
  436. package/src/langium/transfer/transfer-encoder.ts +85 -24
  437. package/src/langium/trivia/comment-preserver.ts +1100 -0
  438. package/src/langium/trivia/document-ending-preserver.ts +55 -0
  439. package/src/langium/trivia/index.ts +14 -0
  440. package/src/langium/trivia/trivia-contribution.ts +39 -0
  441. package/src/langium/trivia/trivia-preserver.ts +54 -0
  442. package/src/langium/trivia/trivia-service.ts +99 -0
  443. package/src/langium/update-rewrite/update-rewrite.ts +1 -1
  444. package/src/langium/validation/document-validator.ts +537 -11
  445. package/src/langium/workspace/document-uri-policy.ts +17 -12
  446. package/src/langium/workspace/file-not-found.ts +23 -0
  447. package/src/langium/workspace/file-system-provider.ts +153 -16
  448. package/src/langium/workspace/hydranium-langium-document-factory.ts +34 -0
  449. package/src/langium/workspace/hydranium-workspace-lock.ts +21 -0
  450. package/src/langium/workspace/hydranium-workspace-manager.ts +133 -12
  451. package/src/langium/workspace/in-memory-file-system-provider.ts +47 -16
  452. package/src/langium/workspace/initialize-workspace.ts +43 -5
  453. package/src/langium/workspace/langium-documents.ts +113 -33
  454. package/src/langium/workspace/persistent-file-system-provider.ts +0 -7
  455. package/src/langium/workspace/virtual-document.ts +96 -19
  456. package/src/langium/workspace/write-lock-scope.ts +4 -3
  457. package/src/locale/index.ts +10 -0
  458. package/src/locale/server-locale.ts +86 -0
  459. package/src/lsp/completion/hydranium-completion-provider.ts +82 -2
  460. package/src/lsp/connection-features.ts +67 -0
  461. package/src/lsp/diagnostics-connection.ts +56 -0
  462. package/src/lsp/hydranium-document-update-handler.ts +182 -106
  463. package/src/lsp/index.ts +3 -1
  464. package/src/lsp/lsp-latency.ts +62 -0
  465. package/src/lsp/semantic-token-provider.ts +74 -7
  466. package/src/lsp/shared-module.ts +4 -8
  467. package/src/lsp/start-language-server.ts +13 -7
  468. package/src/messages/carriers.ts +78 -0
  469. package/src/messages/index.ts +36 -0
  470. package/src/messages/renderer.ts +212 -0
  471. package/src/node/heap-ceiling.ts +108 -0
  472. package/src/node/index.ts +1 -0
  473. package/src/node/latency-from-env.ts +1 -1
  474. package/src/node/measure-memory.ts +1 -0
  475. package/src/node/node-file-system-provider.ts +122 -51
  476. package/src/node/profile-capture.ts +8 -2
  477. package/src/node/profiling-run.ts +3 -3
  478. package/src/node/rename-over-open-readers.ts +78 -0
  479. package/src/node/socket-launcher.ts +19 -4
  480. package/src/node/stdio-launcher.ts +1 -2
  481. package/src/testing/fake-document.ts +24 -5
  482. package/src/testing/make-noop-shared-services.ts +50 -2
  483. package/src/testing/make-test-services.ts +103 -27
  484. package/src/testing/node/index.ts +1 -0
  485. package/src/testing/node/lsp-harness.ts +2 -0
  486. package/src/testing/node/lsp-server-connection.ts +61 -11
  487. package/src/testing/node/scratch-workspace.ts +10 -5
  488. package/src/testing/node/spawned-server.ts +35 -1
  489. package/src/testing/node/unhandled-rejections.ts +31 -0
  490. package/src/testing/playwright/flaky-network-proxy.ts +240 -0
  491. package/src/testing/playwright/index.ts +1 -0
  492. package/src/testing/playwright/server-log-capture.ts +24 -5
  493. package/src/testing/playwright/server-log-rename-reporter.ts +12 -1
  494. package/src/testing/run-update-pipeline.ts +4 -4
  495. package/src/testing/stub-ast-document-manager.ts +28 -21
  496. package/src/testing/stub-document-builder.ts +83 -17
  497. package/src/testing/stub-hydranium-text-documents.ts +127 -22
  498. package/src/testing/stub-langium-documents.ts +3 -2
  499. package/src/testing/stub-model-service.ts +9 -8
  500. package/src/util/connection-liveness.ts +37 -14
  501. package/lib/lsp/instrument-connection.d.ts +0 -36
  502. package/lib/lsp/instrument-connection.d.ts.map +0 -1
  503. package/lib/lsp/instrument-connection.js +0 -62
  504. package/lib/lsp/instrument-connection.js.map +0 -1
  505. package/src/lsp/instrument-connection.ts +0 -66
@@ -19,10 +19,14 @@
19
19
  // eslint-disable-next-line @typescript-eslint/no-restricted-imports
20
20
  import { NormalizedTextDocuments } from '@hydranium/langium/lsp';
21
21
  import { UriUtils } from '@hydranium/langium';
22
- import { OptionalVersionedTextDocumentIdentifier, TextDocumentEdit, TextDocumentSyncKind } from 'vscode-languageserver';
22
+ import { Emitter, OptionalVersionedTextDocumentIdentifier, TextDocumentContentChangeEvent as ContentChange, TextDocumentEdit, TextDocumentSyncKind } from 'vscode-languageserver';
23
23
  import { TextDocument } from 'vscode-languageserver-textdocument';
24
- import { asLanguageClientUri, DisposableCollection } from '@hydranium/protocol';
24
+ import { asLanguageClientUri, DisposableCollection, textHash } from '@hydranium/protocol';
25
+ import { HYDRANIUM_BUILD_REASONS } from '../langium/document-builder/document-builder.js';
26
+ import { isConnectionGoneError } from '../util/connection-liveness.js';
25
27
  import { LANGUAGE_CLIENT_ID } from './client-ids.js';
28
+ import { INTEGRITY_CLIENT_ID } from '../langium/integrity/integrity-rule.js';
29
+ import { ClientSessionRegistry } from './client-session-registry.js';
26
30
  import { isFullReplace, LanguageClientTextShadow } from './language-client-text-shadow.js';
27
31
  /**
28
32
  * The LSP spec's "version is intentionally unknown" for an
@@ -32,6 +36,12 @@ import { isFullReplace, LanguageClientTextShadow } from './language-client-text-
32
36
  * cast at each call site.
33
37
  */
34
38
  const UNKNOWN_CLIENT_VERSION = null;
39
+ /**
40
+ * The default of {@link HydraniumTextDocumentsOptions.revertGraceMs}: long
41
+ * enough for a client whose connection dropped to register again and reopen
42
+ * its documents, which a data client does as soon as it has a connection.
43
+ */
44
+ const DEFAULT_REVERT_GRACE_MS = 10_000;
35
45
  /**
36
46
  * Upper bound on {@link HydraniumTextDocuments.__pendingPushes} entries
37
47
  * per URI. Echoes normally return within milliseconds and consume their
@@ -40,40 +50,35 @@ const UNKNOWN_CLIENT_VERSION = null;
40
50
  */
41
51
  const PENDING_ECHO_CAP = 32;
42
52
  /**
43
- * Cheap, stable, non-cryptographic content hash (cyrb53) for the version
44
- * sequence's "did the content change across close/reopen?" question. Only
45
- * needs to be collision-resistant enough that an accidental match across two
46
- * DIFFERENT revisions of the same file is practically impossible; 53 bits of
47
- * a well-mixed hash over full text + length gives that without pulling in
48
- * `node:crypto` (this module must stay runnable in browser hosts).
53
+ * Whether `err` reports that the file of `target` does not exist, as a Node
54
+ * file system and the framework's providers do: code `ENOENT`, with `target`'s
55
+ * path. A missing file of another document is a different failure.
49
56
  */
50
- function contentHash(text) {
51
- let h1 = 0xdeadbeef;
52
- let h2 = 0x41c6ce57;
53
- for (let index = 0; index < text.length; index++) {
54
- const code = text.charCodeAt(index);
55
- h1 = Math.imul(h1 ^ code, 2654435761);
56
- h2 = Math.imul(h2 ^ code, 1597334677);
57
- }
58
- h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
59
- h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
60
- h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
61
- h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
62
- return `${(4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36)}:${text.length}`;
57
+ function isFileNotFound(err, target) {
58
+ if (typeof err !== 'object' || err === null || !('code' in err) || err.code !== 'ENOENT') {
59
+ return false;
60
+ }
61
+ return !('path' in err) || err.path === target.fsPath;
63
62
  }
64
63
  /**
65
64
  * Multi-client text-document tracking on top of Langium's `NormalizedTextDocuments`.
66
65
  *
67
66
  * Adds the framework features used by the integrity, model-server, and GLSP layers:
68
- * - Per-document client membership (multiple clients can attach to the same URI).
67
+ * - Per-document client membership (multiple clients can attach to the same
68
+ * URI) and the client-session table, both kept by {@link __sessions}.
69
69
  * - A SERVER-OWNED shared version sequence: per-URI, monotonic across
70
70
  * close/reopen cycles, advancing exactly when the synced content changes.
71
71
  * Client-declared version ids (Monaco's buffer numbering) feed only a
72
72
  * per-client staleness guard and never leak into the shared sequence —
73
73
  * the two are different things (an editor's edit-operation counter vs the
74
74
  * document's content-revision number), and splicing them lets versions drift
75
- * silently past `baseVersion` gate holders.
75
+ * silently past based-on gate holders.
76
76
  * - Version-author history so each edit is attributable to its originating client.
77
+ * - The revert to disk once no client has a document open, for every head
78
+ * ({@link revertToDisk}), deferred by
79
+ * {@link HydraniumTextDocumentsOptions.revertGraceMs} after a lost connection.
80
+ * - A disk baseline per open document, and whether its text differs from it
81
+ * ({@link isDirty}).
77
82
  * - Pending-content staging used by the integrity service to thread corrections
78
83
  * through `workspace/applyEdit` cycles for currently-closed documents.
79
84
  * - `didOpen` notifications arriving over the LSP connection wait on the
@@ -84,22 +89,26 @@ function contentHash(text) {
84
89
  export class HydraniumTextDocuments extends NormalizedTextDocuments {
85
90
  services;
86
91
  /**
87
- * Per-URI client-facing tracking ({@link DocumentTracking}): client
88
- * membership, version-author history, and integrity-staged pending content,
89
- * keyed by normalized URI. One record per URI so the last-client close clears
90
- * every axis atomically. The language-client diff baseline stays in
91
- * {@link __shadow} (its own abstraction); the parsed-document store stays in
92
- * the inherited `__syncedDocuments`.
92
+ * Per-URI client-facing tracking ({@link DocumentTrackingRecord}), keyed by
93
+ * canonical URI. One record per URI, so dropping it clears every axis at
94
+ * once. Which client has the document open is kept apart, in
95
+ * {@link __sessions}.
93
96
  */
94
97
  __documents = new Map();
98
+ /**
99
+ * Which client has which document open, and which client ids are registered
100
+ * sessions. Every open-state predicate on this class reads it, so an open
101
+ * recorded anywhere else is invisible to the last-close transition.
102
+ */
103
+ __sessions = new ClientSessionRegistry();
95
104
  /**
96
105
  * Per-URI shared-version continuity across close/reopen cycles
97
106
  * ({@link VersionSequence}), written at last-client close and consulted by
98
- * the next first-client open. DELIBERATELY outside {@link DocumentTracking}:
107
+ * the next first-client open. DELIBERATELY outside {@link DocumentTrackingRecord}:
99
108
  * that record is deleted on last close, while the version sequence must
100
109
  * survive it — the shared version is a server-owned, monotonic,
101
110
  * advances-iff-content-changes counter that never resets while the server
102
- * lives. That invariant is what makes an optimistic `baseVersion` gate
111
+ * lives. That invariant is what makes an optimistic based-on gate
103
112
  * sound: "version unchanged ⇔ content unchanged", with no false conflicts
104
113
  * from close/reopen version resets and no false passes from a reopened
105
114
  * sequence coincidentally landing on a stale writer's number.
@@ -111,7 +120,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
111
120
  /**
112
121
  * Texts pushed to the LSP textual language client via
113
122
  * {@link applyEditToLanguageClient} whose echoes have not come back yet
114
- * ({@link PendingPush}), keyed like the shadow by language-client URI.
123
+ * ({@link PendingLanguageClientPush}), keyed like the shadow by language-client URI.
115
124
  * Outbound pushes and inbound echoes are uncorrelated on the wire; this
116
125
  * FIFO is the explicit correlation, and it is what
117
126
  * {@link classifyLanguageClientChange} reconstructs against.
@@ -156,6 +165,11 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
156
165
  __shadow;
157
166
  tracer;
158
167
  configuration;
168
+ /** See {@link HydraniumTextDocumentsOptions.revertGraceMs}. */
169
+ revertGraceMs;
170
+ lastOpenClosedEmitter = new Emitter();
171
+ languageClientSavedEmitter = new Emitter();
172
+ dirtyChangedEmitter = new Emitter();
159
173
  constructor(services, options = {}) {
160
174
  const configuration = options.configuration ?? TextDocument;
161
175
  super(configuration);
@@ -163,6 +177,8 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
163
177
  this.configuration = configuration;
164
178
  this.tracer = services.Tracer.for(options.logName ?? 'TextDocuments').trace('instantiated');
165
179
  this.__shadow = new LanguageClientTextShadow((uri, reason) => this.tracer.with(uri).warn(`Diff apply-verify fallback (${reason}) — using full-document replace`), this);
180
+ this.revertGraceMs = options.revertGraceMs ?? DEFAULT_REVERT_GRACE_MS;
181
+ this.onDidCloseLastOpen(event => void this.revertToDisk(event.uri));
166
182
  }
167
183
  // Re-exposed configuration factories — for framework-internal callers
168
184
  // (LanguageClientTextShadow's apply-verify probe; IntegrityService.resyncDocument)
@@ -235,7 +251,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
235
251
  }));
236
252
  disposables.push(connection.onWillSaveTextDocumentWaitUntil((event, token) => this.notifyWillSaveTextDocumentWaitUntil(event, token)));
237
253
  disposables.push(connection.onDidSaveTextDocument((event) => {
238
- this.notifyDidSaveTextDocument(event);
254
+ void this.notifyLanguageClientSave(event);
239
255
  }));
240
256
  return disposables;
241
257
  }
@@ -267,18 +283,19 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
267
283
  // Distinguish "already at this version" (common: an echo from the client that triggered
268
284
  // the update) from "incoming version older than ours" (stale race).
269
285
  const reason = lastSeen === td.version ? `already at version ${lastSeen}` : `incoming version ${td.version} older than current ${lastSeen}`;
270
- this.logUri(uri, `Ignore update by ${clientId}: ${reason}`, 'debug');
286
+ this.logUri(uri, `Ignore update by ${this.formatClientId(clientId)}: ${reason}`, 'debug');
271
287
  return;
272
288
  }
273
289
  record.clientVersions.set(clientId, td.version);
274
- // A language-client change arriving while one of our own pushes is in
275
- // flight is keyed to the buffer the client held BEFORE that push, NOT
276
- // to the synced text — the authored write already advanced the latter.
290
+ // A language-client change is keyed to the buffer that client holds,
291
+ // which is the synced text only while the two agree — an authored
292
+ // write advances the synced text without the client knowing, and a
293
+ // push in flight moves the client without the store knowing.
277
294
  // Resolve which it is, and against what, before touching anything.
278
- const inFlight = clientId === LANGUAGE_CLIENT_ID
295
+ const origin = clientId === LANGUAGE_CLIENT_ID
279
296
  ? this.classifyLanguageClientChange(this.toLanguageClientUri(td.uri), document, changes)
280
297
  : undefined;
281
- if (inFlight?.kind === 'echo') {
298
+ if (origin?.kind === 'echo') {
282
299
  // The client is reporting a text we pushed it, so the synced
283
300
  // document is ALREADY there and the changes must not be applied a
284
301
  // second time. Nothing is minted and no rebuild fires. The
@@ -290,18 +307,22 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
290
307
  this.logUri(uri, `Skip rebuild: echo of a server-authored push (client version ${td.version})`, 'debug');
291
308
  return;
292
309
  }
310
+ if (origin?.kind === 'unreconstructable') {
311
+ this.tracer.with(uri).warn(`Drop change: no known client buffer for its ranges (client version ${td.version})`);
312
+ return;
313
+ }
293
314
  // The SHARED version advances iff the content actually changes — the
294
- // invariant optimistic `baseVersion` gates rely on. The new text is
315
+ // invariant optimistic based-on gates rely on. The new text is
295
316
  // only known after applying the (possibly incremental) changes, so
296
317
  // apply at a tentative +1 and roll the version back on an identical
297
318
  // result (an empty-changes update only re-stamps the version).
298
319
  //
299
- // A divergent in-flight change is applied as its RECONSTRUCTED text
300
- // rather than as its own ranges: the ranges address the pre-push
301
- // baseline, so applying them here would splice the wrong lines.
320
+ // A divergent change is applied as its RECONSTRUCTED text rather than
321
+ // as its own ranges: those ranges address the client's own buffer, so
322
+ // applying them here would splice the wrong lines.
302
323
  const previousText = document.getText();
303
324
  const sharedVersion = document.version;
304
- document = this.configuration.update(document, inFlight === undefined ? changes : [{ text: inFlight.text }], sharedVersion + 1);
325
+ document = this.configuration.update(document, origin === undefined ? changes : [{ text: origin.text }], sharedVersion + 1);
305
326
  const changed = document.getText() !== previousText;
306
327
  if (!changed) {
307
328
  document = this.configuration.update(document, [], sharedVersion);
@@ -309,6 +330,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
309
330
  this.__syncedDocuments.set(uri, document);
310
331
  if (changed) {
311
332
  this.setAuthor(uri, document.version, clientId);
333
+ this.refreshDirty(uri);
312
334
  }
313
335
  if (clientId === LANGUAGE_CLIENT_ID) {
314
336
  // Monaco just told us about its new content; record it so the next outbound
@@ -325,7 +347,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
325
347
  return;
326
348
  }
327
349
  }
328
- this.log(document.uri, `Update to version ${document.version} by ${clientId}${changed ? '' : ' (content unchanged)'}`);
350
+ this.log(document.uri, `Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`);
329
351
  this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
330
352
  }
331
353
  }
@@ -341,7 +363,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
341
363
  * A content-identical write still fires the change event (rebuild): the
342
364
  * authored write's server-side rebuild is correctness-bearing, it just
343
365
  * mints no new version — nothing observable changed, so watchers'
344
- * `baseVersion` pointers stay valid.
366
+ * based-on versions stay valid.
345
367
  *
346
368
  * Returns the resulting shared version. Throws when the document is not
347
369
  * open — callers (`AstDocumentManager.update`) open first.
@@ -357,52 +379,187 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
357
379
  document = this.configuration.update(document, [{ text }], document.version + 1);
358
380
  this.__syncedDocuments.set(key, document);
359
381
  this.setAuthor(key, document.version, clientId);
382
+ this.refreshDirty(key);
360
383
  }
361
- this.log(document.uri, `Update to version ${document.version} by ${clientId}${changed ? '' : ' (content unchanged)'}`);
384
+ this.log(document.uri, `Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`);
362
385
  this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
363
386
  return document.version;
364
387
  }
365
- notifyDidCloseTextDocument(event, clientId = LANGUAGE_CLIENT_ID) {
388
+ /**
389
+ * Close `clientId`'s open of the document. When it was the last open, the
390
+ * document is released and reverts to disk, at once or, for a `'lost'`
391
+ * close, after {@link HydraniumTextDocumentsOptions.revertGraceMs}.
392
+ */
393
+ notifyDidCloseTextDocument(event, clientId = LANGUAGE_CLIENT_ID, cause = 'closed') {
366
394
  const uri = this.documentKey(event.textDocument.uri);
367
- if (!this.isOpenInClient(uri, clientId)) {
395
+ if (!this.__sessions.removeOpen(uri, clientId)) {
368
396
  return;
369
397
  }
370
- const closingRecord = this.__documents.get(uri);
371
- closingRecord?.clients.delete(clientId);
372
- closingRecord?.clientVersions.delete(clientId);
398
+ const record = this.__documents.get(uri);
399
+ record?.clientVersions.delete(clientId);
400
+ if (record && cause === 'lost') {
401
+ (record.lostClients ??= new Map()).set(clientId, this.services.Clock.stopwatch());
402
+ }
373
403
  const syncedDocument = this.__syncedDocuments.get(uri);
374
404
  if (syncedDocument !== undefined) {
375
- this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${clientId}`);
405
+ this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
376
406
  this.__onDidClose.fire(Object.freeze({ document: syncedDocument, clientId }));
377
407
  if (clientId === LANGUAGE_CLIENT_ID) {
378
408
  // Monaco closed the document; drop the URI it held and the shadow
379
409
  // baselined under it. (If this was the last URI/client the whole record
380
- // is deleted below, clearing the set anyway.)
410
+ // is deleted on release, clearing the set anyway.)
381
411
  const droppedUri = this.toLanguageClientUri(event.textDocument.uri);
382
412
  this.__documents.get(uri)?.languageClientUris?.delete(droppedUri);
383
413
  this.__shadow.invalidate(droppedUri);
384
414
  this.__pendingPushes.delete(droppedUri);
385
415
  }
386
- if (!this.__documents.get(uri)?.clients.size) {
387
- // Last client closed the document; delete sync state. The downstream
388
- // "rebuild from disk for file URIs / drop from index for ephemeral
389
- // URIs" decision lives in `HydraniumDocumentUpdateHandler.didCloseDocument`,
390
- // which subscribes to `onDidClose` and consults `isOpenInAnyClient`
391
- // to detect the last-close transition.
392
- this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`);
393
- // Persist where the shared version sequence left off (version +
394
- // content hash) so the next open CONTINUES the sequence instead of
395
- // restarting at the reopening client's declared id. Hashed once
396
- // here at close, not on every change.
397
- this.__versionSequences.set(uri, {
398
- version: syncedDocument.version,
399
- contentHash: contentHash(syncedDocument.getText())
400
- });
401
- this.__syncedDocuments.delete(uri);
402
- // One delete clears every per-URI axis (version history + any staged
403
- // pending content) so a future open with the same URI starts fresh.
404
- this.__documents.delete(uri);
416
+ if (!this.__sessions.isOpen(uri)) {
417
+ if (cause === 'lost' && this.revertGraceMs > 0) {
418
+ this.deferRelease(uri);
419
+ }
420
+ else {
421
+ this.releaseDocument(uri);
422
+ }
423
+ }
424
+ }
425
+ }
426
+ /**
427
+ * Keep the document, text and all, for the revert grace, then release it.
428
+ * The document stays in the store meanwhile, so a lost client that opens it
429
+ * again within its own grace attaches to it and finds its unsaved text
430
+ * rather than reading disk; see {@link resolvePendingRevert} for any other
431
+ * open.
432
+ */
433
+ deferRelease(uri) {
434
+ this.log(uri, `No client left; revert deferred for ${this.revertGraceMs} ms (connection lost)`);
435
+ const timer = this.services.Clock.setTimer(() => {
436
+ this.__sessions.cancelRevert(uri);
437
+ if (!this.__sessions.isOpen(uri)) {
438
+ this.releaseDocument(uri);
439
+ }
440
+ }, this.revertGraceMs);
441
+ this.__sessions.deferRevert(uri, timer);
442
+ }
443
+ /**
444
+ * Resolve the pending revert of `uri` for an open by `clientId`. An open by a
445
+ * client lost from the document within its grace cancels the revert, and
446
+ * the client finds its unsaved text. Any other open releases the document
447
+ * first, so it opens as a first open does: cancelling for every open hands
448
+ * a lost client's unsaved text to whoever opens next, a reloaded page or an
449
+ * editor, with nothing marking it unsaved.
450
+ */
451
+ resolvePendingRevert(uri, clientId) {
452
+ const record = this.__documents.get(uri);
453
+ if (record) {
454
+ this.pruneLostClients(record);
455
+ }
456
+ const returning = record?.lostClients?.delete(clientId) ?? false;
457
+ if (!this.__sessions.isRevertPending(uri)) {
458
+ return;
459
+ }
460
+ this.__sessions.cancelRevert(uri);
461
+ if (!returning) {
462
+ this.releaseDocument(uri);
463
+ }
464
+ }
465
+ /** Drop the entries of {@link DocumentTrackingRecord.lostClients} whose grace has run out. */
466
+ pruneLostClients(record) {
467
+ for (const [clientId, sinceLoss] of record.lostClients ?? []) {
468
+ if (sinceLoss.elapsedMs >= this.revertGraceMs) {
469
+ record.lostClients?.delete(clientId);
470
+ }
471
+ }
472
+ }
473
+ /**
474
+ * Drop the document no client has open any more, then announce it on
475
+ * {@link onDidCloseLastOpen}, which is what reverts it to disk.
476
+ */
477
+ releaseDocument(uri) {
478
+ const syncedDocument = this.__syncedDocuments.get(uri);
479
+ if (syncedDocument === undefined) {
480
+ return;
481
+ }
482
+ this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`);
483
+ // Persist where the shared version sequence left off (version +
484
+ // content hash) so the next open CONTINUES the sequence instead of
485
+ // restarting at the reopening client's declared id. Hashed once
486
+ // here at release, not on every change.
487
+ this.__versionSequences.set(uri, {
488
+ version: syncedDocument.version,
489
+ contentHash: textHash(syncedDocument.getText())
490
+ });
491
+ const wasDirty = this.__documents.get(uri)?.dirty === true;
492
+ this.__syncedDocuments.delete(uri);
493
+ // One delete clears every per-URI axis (version history + any staged
494
+ // pending content) so a future open with the same URI starts fresh.
495
+ this.__documents.delete(uri);
496
+ if (wasDirty) {
497
+ this.dirtyChangedEmitter.fire(Object.freeze({ uri, dirty: false }));
498
+ }
499
+ this.lastOpenClosedEmitter.fire(Object.freeze({ uri }));
500
+ }
501
+ /**
502
+ * Rebuild a released document from the file system provider, so the build
503
+ * stops carrying the unsaved text of its last client.
504
+ *
505
+ * The provider decides, for every scheme, by `exists`: a document it can
506
+ * serve is rebuilt from its text, and any other — an editor's `untitled:`
507
+ * buffer, a file never saved, or one deleted meanwhile — is removed
508
+ * from the workspace. A `virtual:` document survives, since the framework's
509
+ * provider for that scheme serves it from the index, whatever provider the
510
+ * host passes as `context.fileSystemProvider`; an edited one therefore keeps
511
+ * its last client's text, and keeping it read-only is the client's job.
512
+ *
513
+ * The answer is read in the document's disk queue, so the rebuild follows
514
+ * any save still queued rather than reverting past it; a file that goes
515
+ * after that read is removed when its rebuild finds none.
516
+ *
517
+ * Whether to revert at all is decided inside the write lock, as its holder:
518
+ * decided before waiting for the lock, a client that opens or re-creates the
519
+ * document meanwhile would have its text rebuilt over, or the document
520
+ * removed. A document some client has open again, or that waits out a new
521
+ * grace, is left to that client.
522
+ */
523
+ async revertToDisk(uri) {
524
+ const workspace = this.services.workspace;
525
+ const target = UriUtils.toUri(uri);
526
+ const reopened = () => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri);
527
+ try {
528
+ // Read before the lock: every build and read waits while the lock is
529
+ // held, and this read waits on the file's save I/O.
530
+ const onDisk = await workspace.FileSystemTaskQueue.enqueue(uri, () => workspace.FileSystemProvider.exists(target));
531
+ await workspace.WorkspaceManager?.ready;
532
+ await workspace.WorkspaceLock.write(async (token) => {
533
+ if (reopened()) {
534
+ return;
535
+ }
536
+ workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
537
+ try {
538
+ await (onDisk
539
+ ? workspace.DocumentBuilder.update([target], [], token)
540
+ : workspace.DocumentBuilder.update([], [target], token));
541
+ }
542
+ catch (err) {
543
+ if (!onDisk || !isFileNotFound(err, target)) {
544
+ throw err;
545
+ }
546
+ // The file went after its existence was read.
547
+ this.tracer.with(uri).debug('Revert found no file; removing the document instead');
548
+ workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
549
+ await workspace.DocumentBuilder.update([], [target], token);
550
+ }
551
+ });
552
+ }
553
+ catch (err) {
554
+ // A revert that finishes after the LSP peer went away fails its
555
+ // diagnostics publish, and one that runs after its workspace was torn
556
+ // down finds no file: teardown races, not failed reverts.
557
+ if (isConnectionGoneError(err) || isFileNotFound(err, target)) {
558
+ this.tracer.with(uri).debug(`Revert on last close skipped: ${err instanceof Error ? err.message : String(err)}`);
559
+ return;
405
560
  }
561
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
562
+ this.tracer.with(uri).error(`Revert on last close dropped. ${detail}`);
406
563
  }
407
564
  }
408
565
  notifyWillSaveTextDocument(event) {
@@ -420,39 +577,99 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
420
577
  return [];
421
578
  }
422
579
  }
580
+ /**
581
+ * Handle a save the language client reports: fire
582
+ * {@link onDidSaveInLanguageClient}, then announce it on {@link onDidSave}
583
+ * only once the file read back from disk holds the store's current text.
584
+ */
585
+ async notifyLanguageClientSave(event) {
586
+ const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
587
+ if (syncedDocument === undefined) {
588
+ return;
589
+ }
590
+ const uri = syncedDocument.uri;
591
+ this.languageClientSavedEmitter.fire(Object.freeze({ uri }));
592
+ // `onDidSave` tells its listeners that the shared document is on disk.
593
+ // The editor wrote its own buffer, which lags the store while another
594
+ // client's edit is still on its way to it, so only the file says
595
+ // whether that holds. Read it rather than trust the editor: `didSave`
596
+ // carries text only for a client that honours `includeText`, and asking
597
+ // for it means rewriting Langium's initialize answer, which advertises
598
+ // `save` as a Boolean. The read goes through the disk queue and waits on
599
+ // nothing but the provider, so it follows a server save queued
600
+ // meanwhile. A file changed again between the editor's write and this
601
+ // read compares unequal, and the save goes unannounced.
602
+ let onDisk;
603
+ try {
604
+ onDisk = await this.services.workspace.FileSystemTaskQueue.enqueue(uri, () => this.services.workspace.FileSystemProvider.readFile(UriUtils.toUri(uri)));
605
+ }
606
+ catch (err) {
607
+ this.tracer
608
+ .with(uri)
609
+ .debug(`Editor save not announced: reading the file back failed. ${err instanceof Error ? err.message : String(err)}`);
610
+ return;
611
+ }
612
+ // What the file holds, not what the editor meant to write: an editor
613
+ // that saved older text leaves the document dirty.
614
+ this.updateDiskBaseline(uri, onDisk);
615
+ // An editor that saves and then closes releases the document while the
616
+ // read is under way; its save is still a save of the text it held.
617
+ const document = this.__syncedDocuments.get(this.documentKey(uri)) ?? syncedDocument;
618
+ if (onDisk !== document.getText()) {
619
+ this.tracer.with(uri).debug(`Editor save not announced: the file does not hold the text of version ${document.version}`);
620
+ return;
621
+ }
622
+ this.announceSave(document, LANGUAGE_CLIENT_ID);
623
+ }
624
+ /**
625
+ * Announce a save the server made. `event.text`, when given, is what the
626
+ * file now holds, written or found there, and becomes the disk baseline
627
+ * before the save is announced. It lags the store when an edit landed
628
+ * after the save took its text, and the document then stays dirty.
629
+ */
423
630
  notifyDidSaveTextDocument(event, clientId = LANGUAGE_CLIENT_ID) {
424
631
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
425
632
  if (syncedDocument !== undefined) {
426
- this.log(syncedDocument.uri, `Saved synced document: ${syncedDocument.version} by ${clientId}`);
427
- this.__onDidSave.fire(Object.freeze({ document: syncedDocument, clientId }));
633
+ if (event.text !== undefined) {
634
+ this.updateDiskBaseline(syncedDocument.uri, event.text);
635
+ }
636
+ this.announceSave(syncedDocument, clientId);
428
637
  }
429
638
  }
639
+ /** Fire {@link onDidSave} for `document`, which need not be synced any more. */
640
+ announceSave(document, clientId) {
641
+ this.log(document.uri, `Saved synced document: ${document.version} by ${this.formatClientId(clientId)}`);
642
+ this.__onDidSave.fire(Object.freeze({ document, clientId }));
643
+ }
430
644
  notifyDidOpenTextDocument(event, clientId = LANGUAGE_CLIENT_ID) {
431
645
  const td = event.textDocument;
432
646
  const uri = this.documentKey(td.uri);
647
+ this.resolvePendingRevert(uri, clientId);
433
648
  if (this.isOpenInClient(uri, clientId)) {
434
649
  // Already open for this client under this canonical identity. If this is a
435
650
  // NEW client-facing URI for the same file (a second tab reached via a
436
- // divergent path, e.g. a symlink and its real path), record it and baseline
437
- // its shadow so outbound edits reach this tab too — but do NOT re-fire
438
- // open/rebuild; the document is already live.
651
+ // divergent path, e.g. a symlink and its real path), record it so outbound
652
+ // edits reach this tab too — but do NOT re-fire open/rebuild; the document
653
+ // is already live.
439
654
  if (clientId === LANGUAGE_CLIENT_ID) {
440
655
  const clientFacing = this.toLanguageClientUri(td.uri);
441
656
  const record = this.__documents.get(uri);
442
657
  if (record && !record.languageClientUris?.has(clientFacing)) {
443
658
  (record.languageClientUris ??= new Set()).add(clientFacing);
444
- const open = this.__syncedDocuments.get(uri);
445
- if (open) {
446
- this.__shadow.set(clientFacing, open.getText());
447
- }
659
+ // This tab's own buffer, NOT the synced text: a second tab is a second
660
+ // client model, read from disk, so a server-authored write already
661
+ // applied to the first tab leaves it BEHIND the synced document. Keying
662
+ // a diff to the synced text here addresses lines this tab does not have
663
+ // and drops content it does.
664
+ this.__shadow.setOpenedText(clientFacing, td.text);
448
665
  }
449
666
  }
450
667
  return;
451
668
  }
452
669
  let document = this.__syncedDocuments.get(uri);
670
+ const existingClients = this.__sessions.clientsOf(uri);
671
+ this.__sessions.addOpen(uri, clientId);
453
672
  const record = this.trackingFor(uri);
454
- const existingClients = [...record.clients];
455
- record.clients.add(clientId);
456
673
  // Baseline the per-client staleness guard at the version id the client
457
674
  // declared for its own buffer (client-owned per LSP).
458
675
  record.clientVersions.set(clientId, td.version);
@@ -468,21 +685,37 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
468
685
  const source = pendingText ? ', source=pending' : '';
469
686
  // The SHARED version is server-assigned: continue the persisted
470
687
  // sequence — same version when the content is unchanged since the
471
- // last close (so watchers' `baseVersion` pointers stay valid), one
688
+ // last close (so watchers' based-on versions stay valid), one
472
689
  // step when it changed (so no stale pointer can coincidentally pass
473
690
  // the optimistic gate). Only a first-ever open adopts the client's
474
691
  // declared id as the sequence seed.
475
692
  const sequence = this.__versionSequences.get(uri);
476
- const version = sequence === undefined ? td.version : sequence.contentHash === contentHash(text) ? sequence.version : sequence.version + 1;
477
- this.log(uri, `Open document: Version ${version} by ${clientId} [first client${source}]`);
693
+ const version = sequence === undefined ? td.version : sequence.contentHash === textHash(text) ? sequence.version : sequence.version + 1;
694
+ this.log(uri, `Open document: Version ${version} by ${this.formatClientId(clientId)} [first client${source}]`);
478
695
  document = this.configuration.create(uri, td.languageId, version, text);
479
696
  this.__syncedDocuments.set(uri, document);
480
697
  this.setAuthor(uri, version, clientId);
698
+ // The opener's text, not the staged content: a session's open read
699
+ // it from the file, and an editor opened its buffer from there. An
700
+ // editor that opens a buffer it never saved is taken as clean.
701
+ record.diskBaseline = td.text;
702
+ this.refreshDirty(uri);
481
703
  if (clientId === LANGUAGE_CLIENT_ID) {
482
704
  // Baseline the shadow to what Monaco just opened so the next outbound
483
705
  // applyEditToLanguageClient diffs against the right starting point. Keyed by the
484
706
  // language-client URI (what Monaco holds), not the canonical document key.
485
- this.__shadow.set(this.toLanguageClientUri(td.uri), document.getText());
707
+ //
708
+ // The CLIENT's declared text, never the synced document: staged content
709
+ // consumed above leaves the two different, and a baseline asserting the
710
+ // client already holds the staged text suppresses the one sync that would
711
+ // deliver it. Skipped entirely when a push is already outstanding for this
712
+ // URI — an `applyEdit` to a closed file has the client open from disk and
713
+ // apply afterwards, so that shadow records what it is about to hold and
714
+ // disk text would key the next diff to a buffer nobody has.
715
+ const clientFacing = this.toLanguageClientUri(td.uri);
716
+ if (!this.__shadow.isTracked(clientFacing)) {
717
+ this.__shadow.set(clientFacing, td.text);
718
+ }
486
719
  }
487
720
  const toFire = Object.freeze({ document, clientId });
488
721
  this.__onDidOpen.fire(toFire);
@@ -490,16 +723,64 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
490
723
  }
491
724
  else {
492
725
  // An additional client attaches to a document already open by another client.
493
- this.log(uri, `Attach client: ${clientId} joined existing document (version ${document.version}, ` +
494
- `now open in: ${[...existingClients, clientId].join(', ')})`);
726
+ this.logClientJoined(uri, clientId, document.version, existingClients);
727
+ if (clientId === LANGUAGE_CLIENT_ID) {
728
+ // Monaco's own buffer, which on this path is NOT the synced text — another
729
+ // client opened the document and may already have changed it. Recorded as
730
+ // an equality-only baseline (never a diff one, see `setOpenedText`) so the
731
+ // refresh below does not push a full replace of content Monaco already
732
+ // holds, which would dirty the file on open.
733
+ this.__shadow.setOpenedText(this.toLanguageClientUri(td.uri), td.text);
734
+ }
495
735
  this.refreshContent(uri, clientId);
496
736
  }
497
737
  }
738
+ /**
739
+ * Record `clientId` as an additional holder of a document another client
740
+ * already has open.
741
+ *
742
+ * Unlike the attach branch of {@link notifyDidOpenTextDocument} this does
743
+ * not refresh. That branch re-renders an attaching TEXTUAL view; a client
744
+ * reaching a document through a non-textual route has nothing to re-render,
745
+ * and refreshing per attach turns each into a Langium rebuild and dependent
746
+ * relink cascade.
747
+ *
748
+ * The staleness guard baselines at the SYNCED version, not a declared buffer
749
+ * version, because a client arriving this way holds no buffer of its own.
750
+ * Unifying the two routes therefore rebaselines a textual client's guard to a
751
+ * version it never declared.
752
+ *
753
+ * Returns whether a hold was added — `false` when `uri` is not open,
754
+ * `clientId` already holds it, or the document was waiting out the revert
755
+ * grace for other clients and was released instead (see
756
+ * {@link resolvePendingRevert}); the caller then opens it anew.
757
+ */
758
+ attachClient(uri, clientId) {
759
+ const key = this.documentKey(uri);
760
+ if (!this.__syncedDocuments.has(key) || this.isOpenInClient(key, clientId)) {
761
+ return false;
762
+ }
763
+ this.resolvePendingRevert(key, clientId);
764
+ const document = this.__syncedDocuments.get(key);
765
+ if (!document) {
766
+ return false;
767
+ }
768
+ const existingClients = this.__sessions.clientsOf(key);
769
+ this.__sessions.addOpen(key, clientId);
770
+ const record = this.trackingFor(key);
771
+ record.clientVersions.set(clientId, document.version);
772
+ this.logClientJoined(key, clientId, document.version, existingClients);
773
+ return true;
774
+ }
775
+ logClientJoined(uri, clientId, version, existingClients) {
776
+ this.log(uri, `Attach client: ${this.formatClientId(clientId)} joined existing document (version ${version}, ` +
777
+ `now open in: ${[...existingClients, clientId].map(id => this.formatClientId(id)).join(', ')})`);
778
+ }
498
779
  refreshContent(uri, clientId) {
499
780
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(uri));
500
781
  if (syncedDocument) {
501
782
  // Trigger a (re-)build by firing a change event.
502
- const timer = this.startTimerForUri(syncedDocument.uri, `Refresh synced document: Version ${syncedDocument.version} by ${clientId}`);
783
+ const timer = this.startTimerForUri(syncedDocument.uri, `Refresh synced document: Version ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
503
784
  this.__onDidChangeContent.fire(Object.freeze({ document: syncedDocument, clientId }));
504
785
  timer.dispose();
505
786
  }
@@ -511,7 +792,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
511
792
  * two URIs for one physical file (a symlink path and its real path) into a
512
793
  * single registration — dedup at the editor layer, not just in
513
794
  * `LangiumDocuments`. The URI the client opened under is preserved separately
514
- * for egress addressing (see {@link DocumentTracking.languageClientUris}). The
795
+ * for egress addressing (see {@link DocumentTrackingRecord.languageClientUris}). The
515
796
  * policy is always bound (the framework defaults it to
516
797
  * `DefaultDocumentUriPolicy`, where canonical ≡ syntactic normalize).
517
798
  */
@@ -533,7 +814,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
533
814
  trackingFor(uri) {
534
815
  let record = this.__documents.get(uri);
535
816
  if (!record) {
536
- record = { clients: new Set(), versionAuthors: [], clientVersions: new Map() };
817
+ record = { versionAuthors: [], clientVersions: new Map() };
537
818
  this.__documents.set(uri, record);
538
819
  }
539
820
  return record;
@@ -566,7 +847,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
566
847
  * `0` for a URI this store has never seen. The shared sequence is
567
848
  * server-owned and monotonic across close/reopen cycles, and advances
568
849
  * exactly when the synced content changes — which is what makes it a sound
569
- * optimistic-concurrency token (`baseVersion` gates): version unchanged ⇔
850
+ * optimistic-concurrency token (based-on gates): version unchanged ⇔
570
851
  * content unchanged.
571
852
  */
572
853
  version(uri) {
@@ -597,7 +878,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
597
878
  if (sequence === undefined) {
598
879
  return undefined;
599
880
  }
600
- const hash = contentHash(text);
881
+ const hash = textHash(text);
601
882
  if (hash === sequence.contentHash) {
602
883
  return sequence.version;
603
884
  }
@@ -606,6 +887,58 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
606
887
  this.logUri(key, `External content change while closed: sequence stepped to version ${stepped.version}`, 'debug');
607
888
  return stepped.version;
608
889
  }
890
+ /**
891
+ * Commit an integrity repair into the OPEN document for `uri`, returning the
892
+ * document that now holds it, or `undefined` when there was nothing here to
893
+ * commit into.
894
+ *
895
+ * The one thing this exists to do is address the store BY URI. A repair
896
+ * reaches the resync as a text-document object, and that object is the
897
+ * store's own only on the LSP path; a document built for an already-open URI
898
+ * through `LangiumDocumentFactory.fromString` carries its own, so writing the
899
+ * repair into it corrects a copy the store does not know about and the editor
900
+ * never sees.
901
+ *
902
+ * `parsedFrom` is the text the repaired AST was parsed from, and a mismatch
903
+ * against the current content REFUSES the commit: the store has moved on,
904
+ * which means an editor change landed after that parse, and committing would
905
+ * overwrite a newer edit with a repair computed against text the user has
906
+ * already replaced. The build that change provokes recomputes the repair.
907
+ *
908
+ * Deliberately NOT compared by version. A separately created document seeds
909
+ * its own numbering, so requiring the two to agree would refuse every commit
910
+ * on the path this exists for. Content is the thing both sides can be held to.
911
+ *
912
+ * A changed text is a new version authored by {@link INTEGRITY_CLIENT_ID}.
913
+ * Kept at the old version, the repair is invisible to every reader keyed on
914
+ * versions: a write based on the unrepaired version passes the based-on gate
915
+ * and replaces the repair, and an echo filter credits the repair to the
916
+ * client whose edit it corrected. No change event is fired: the repair
917
+ * rides the build already under way, and an event would re-enter it.
918
+ */
919
+ commitRepair(uri, parsedFrom, repaired) {
920
+ const key = this.documentKey(uri);
921
+ const document = this.__syncedDocuments.get(key);
922
+ if (document === undefined) {
923
+ return { status: 'not-open' };
924
+ }
925
+ if (document.getText() !== parsedFrom) {
926
+ this.logUri(key, 'Refuse repair commit: the open document moved on from the text the AST was parsed from', 'debug');
927
+ return { status: 'stale' };
928
+ }
929
+ if (repaired === parsedFrom) {
930
+ return { status: 'committed', document };
931
+ }
932
+ // Reassigned rather than mutated in place: the default configuration
933
+ // updates and returns the SAME instance, but an adopter-supplied one may
934
+ // return a new object, and the store must end up holding whichever it is.
935
+ const updated = this.configuration.update(document, [{ text: repaired }], document.version + 1);
936
+ this.__syncedDocuments.set(key, updated);
937
+ this.setAuthor(key, updated.version, INTEGRITY_CLIENT_ID);
938
+ this.refreshDirty(key);
939
+ this.log(updated.uri, `Update to version ${updated.version} by ${this.formatClientId(INTEGRITY_CLIENT_ID)} (repair)`);
940
+ return { status: 'committed', document: updated };
941
+ }
609
942
  getAuthor(uri, version) {
610
943
  const history = this.__documents.get(this.documentKey(uri))?.versionAuthors;
611
944
  // Either the requested version, or the latest. `version !== undefined` so we treat 0 correctly.
@@ -624,16 +957,16 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
624
957
  * True iff any client still holds `uri` open. Distinct from {@link isOpen},
625
958
  * which reads `__syncedDocuments` — that map is cleared only AFTER the
626
959
  * `onDidClose` event fires for the last client, so `isOpen` returns `true`
627
- * during the close event itself. `isOpenInAnyClient` reads the tracking
628
- * record's `clients` set, which is updated BEFORE the fire, so an `onDidClose`
960
+ * during the close event itself. `isOpenInAnyClient` reads the open table
961
+ * ({@link __sessions}), which is updated BEFORE the fire, so an `onDidClose`
629
962
  * subscriber that finds this `false` knows the last client just closed and a
630
963
  * disk re-read / rebuild can proceed.
631
964
  */
632
965
  isOpenInAnyClient(uri) {
633
- return (this.__documents.get(this.documentKey(uri))?.clients.size ?? 0) > 0;
966
+ return this.__sessions.isOpen(this.documentKey(uri));
634
967
  }
635
968
  isOpenInClient(uri, client) {
636
- return !!this.__documents.get(this.documentKey(uri))?.clients.has(client);
969
+ return this.__sessions.isOpenIn(this.documentKey(uri), client);
637
970
  }
638
971
  isOpenInLanguageClient(uri) {
639
972
  // Takes any URI form (canonicalizes internally via `documentKey`, like
@@ -642,13 +975,13 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
642
975
  return this.isOpenInClient(uri, LANGUAGE_CLIENT_ID);
643
976
  }
644
977
  isOnlyOpenInClient(uri, client) {
645
- const key = this.documentKey(uri);
646
- return this.__documents.get(key)?.clients.size === 1 && this.isOpenInClient(key, client);
978
+ const clients = this.__sessions.clientsOf(this.documentKey(uri));
979
+ return clients.length === 1 && clients[0] === client;
647
980
  }
648
981
  /**
649
982
  * Every document currently held open by at least one client, with the client
650
- * ids holding it. Reads the same per-URI tracking records as
651
- * {@link isOpenInAnyClient}, so it reflects a last-client close immediately.
983
+ * ids holding it. Reads the same open table as {@link isOpenInAnyClient}, so
984
+ * it reflects a last-client close immediately.
652
985
  *
653
986
  * Diagnostics-oriented — the server-state snapshot lists these so an operator
654
987
  * can see WHY a document is pinned: a document that lingers here after its
@@ -656,27 +989,221 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
656
989
  * under a shedding policy, explains why its CST is never shed).
657
990
  */
658
991
  openDocuments() {
659
- const result = [];
660
- for (const [uri, tracking] of this.__documents) {
661
- if (tracking.clients.size > 0) {
662
- result.push({ uri, clients: [...tracking.clients] });
992
+ return this.__sessions.openDocuments();
993
+ }
994
+ /** Fires once a client session has ended, after every document it had open was closed. */
995
+ get onDidCloseSession() {
996
+ return this.__sessions.onDidCloseSession;
997
+ }
998
+ /**
999
+ * Fires once a document no client has open is released: at its last close,
1000
+ * or, for a lost client's last close, when its revert grace runs out,
1001
+ * another client opens it, or it is deleted. The document then reverts to
1002
+ * disk.
1003
+ */
1004
+ get onDidCloseLastOpen() {
1005
+ return this.lastOpenClosedEmitter.event;
1006
+ }
1007
+ /**
1008
+ * Fires for every save the language client reports of a document it has
1009
+ * open: the editor has written the file. {@link onDidSave} follows only
1010
+ * when the file holds the store's text.
1011
+ */
1012
+ get onDidSaveInLanguageClient() {
1013
+ return this.languageClientSavedEmitter.event;
1014
+ }
1015
+ /**
1016
+ * Whether `uri` is waiting out the revert grace: its last open closed with a
1017
+ * lost connection, and it still holds its unsaved text. Such a document is
1018
+ * open for no client, yet not closed either, so a caller that would persist
1019
+ * a closed document's text to disk treats it as open.
1020
+ */
1021
+ isRevertPending(uri) {
1022
+ return this.__sessions.isRevertPending(this.documentKey(uri));
1023
+ }
1024
+ /**
1025
+ * Whether the store holds `uri` with text that differs from its disk
1026
+ * baseline: what the server last knew the file to hold. `false` for a URI
1027
+ * the store does not hold; a document waiting out the revert grace is still
1028
+ * held.
1029
+ *
1030
+ * The baseline is the text a first open brought, or what the server wrote,
1031
+ * or read back after an editor's save or a watched-file change, so it can
1032
+ * trail a change to the file that none of these has seen yet. A check that
1033
+ * must know the file reads it instead.
1034
+ */
1035
+ isDirty(uri) {
1036
+ return this.__documents.get(this.documentKey(uri))?.dirty ?? false;
1037
+ }
1038
+ /** Fires each time the answer of {@link isDirty} changes, the release of a dirty document included. */
1039
+ get onDidChangeDirty() {
1040
+ return this.dirtyChangedEmitter.event;
1041
+ }
1042
+ /**
1043
+ * Record that the file behind `uri` holds `text`, or no file at all for
1044
+ * `undefined`. A no-op for a URI the store does not hold: the next first
1045
+ * open sets the baseline from its own text.
1046
+ */
1047
+ updateDiskBaseline(uri, text) {
1048
+ const key = this.documentKey(uri);
1049
+ const record = this.__documents.get(key);
1050
+ if (!record || !this.__syncedDocuments.has(key)) {
1051
+ return;
1052
+ }
1053
+ record.diskBaseline = text;
1054
+ this.refreshDirty(key);
1055
+ }
1056
+ /**
1057
+ * Read the file behind `uri` through its disk queue and take it as the
1058
+ * baseline. A file that cannot be read counts as none, the side that
1059
+ * leaves the document dirty.
1060
+ */
1061
+ async reloadDiskBaseline(uri) {
1062
+ const key = this.documentKey(uri);
1063
+ if (!this.__syncedDocuments.has(key)) {
1064
+ return;
1065
+ }
1066
+ let onDisk;
1067
+ try {
1068
+ onDisk = await this.services.workspace.FileSystemTaskQueue.enqueue(key, () => this.services.workspace.FileSystemProvider.readFile(UriUtils.toUri(key)));
1069
+ }
1070
+ catch (err) {
1071
+ this.tracer.with(key).debug(`Disk baseline: the file cannot be read. ${err instanceof Error ? err.message : String(err)}`);
1072
+ }
1073
+ this.updateDiskBaseline(key, onDisk);
1074
+ }
1075
+ /** Compare the held text of `uri` with its baseline, and announce a changed answer. */
1076
+ refreshDirty(uri) {
1077
+ const record = this.__documents.get(uri);
1078
+ const document = this.__syncedDocuments.get(uri);
1079
+ if (!record || !document) {
1080
+ return;
1081
+ }
1082
+ const dirty = document.getText() !== record.diskBaseline;
1083
+ if (dirty !== (record.dirty ?? false)) {
1084
+ record.dirty = dirty;
1085
+ this.dirtyChangedEmitter.fire(Object.freeze({ uri, dirty }));
1086
+ }
1087
+ }
1088
+ /**
1089
+ * Start a client session under `clientId`. Throws where
1090
+ * {@link ClientSessionRegistry.register} refuses the id.
1091
+ */
1092
+ registerSession(clientId) {
1093
+ this.__sessions.register(clientId);
1094
+ this.tracer.info(`Session started: ${this.formatClientId(clientId)}`);
1095
+ this.tracer.trace(`Session started: ${clientId}`);
1096
+ }
1097
+ /**
1098
+ * End the client session `clientId`: close every document it has open, then
1099
+ * free the id. Immediate — each close runs the ordinary close path before
1100
+ * this returns, with `cause` as its cause. A no-op for an id that is not a
1101
+ * registered session.
1102
+ */
1103
+ closeSession(clientId, cause = 'closed') {
1104
+ if (!this.__sessions.isRegistered(clientId)) {
1105
+ return;
1106
+ }
1107
+ try {
1108
+ for (const uri of this.__sessions.beginClose(clientId)) {
1109
+ this.notifyDidCloseTextDocument({ textDocument: { uri } }, clientId, cause);
1110
+ }
1111
+ }
1112
+ finally {
1113
+ this.__sessions.unregister(clientId);
1114
+ this.tracer.info(`Session closed: ${this.formatClientId(clientId)}`);
1115
+ this.tracer.trace(`Session closed: ${clientId}`);
1116
+ }
1117
+ }
1118
+ /**
1119
+ * Close every document the language client has open, as a `didClose` for
1120
+ * each would, so each last close reverts. For a host whose editor connection
1121
+ * can end while the process lives on, such as a worker whose port's peer
1122
+ * closed: the language client is no session, so nothing else closes them.
1123
+ *
1124
+ * It first waits for the workspace initialization the open handler waits
1125
+ * for, so an open that arrived before the close is closed too; closing at
1126
+ * once would leave that open to land afterwards, held by a client that is
1127
+ * gone.
1128
+ */
1129
+ async closeLanguageClientDocuments() {
1130
+ await this.services.workspace.WorkspaceManager.workspaceInitialized;
1131
+ for (const uri of this.__sessions.opensOf(LANGUAGE_CLIENT_ID)) {
1132
+ // Every spelling the client opened the file under, since the close
1133
+ // below drops only the canonical one's.
1134
+ const clientUris = this.__documents.get(uri)?.languageClientUris;
1135
+ for (const clientUri of clientUris ?? []) {
1136
+ this.invalidateLanguageClientText(clientUri);
663
1137
  }
1138
+ clientUris?.clear();
1139
+ this.notifyDidCloseTextDocument({ textDocument: { uri } });
664
1140
  }
665
- return result;
666
1141
  }
667
1142
  /**
668
- * Stages integrity-updated content for a closed document.
1143
+ * The file behind `uri` was deleted: close every open of it except the
1144
+ * language client's.
1145
+ *
1146
+ * The editor's open is left alone because the editor owns it: it keeps the
1147
+ * buffer of a deleted file and goes on sending changes for it, and a store
1148
+ * that had closed the document would drop every one of them until the editor
1149
+ * reopened.
1150
+ */
1151
+ notifyDocumentDeleted(uri) {
1152
+ const key = this.documentKey(uri);
1153
+ for (const clientId of this.__sessions.clientsOf(key)) {
1154
+ if (clientId !== LANGUAGE_CLIENT_ID) {
1155
+ this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
1156
+ }
1157
+ }
1158
+ }
1159
+ /**
1160
+ * Remove the document for `uri`, closing every open of it first — the
1161
+ * editor's included, since the document it would keep editing is gone.
1162
+ *
1163
+ * The base removes the synced document without consulting the open table,
1164
+ * which leaves every open recorded against a document that no longer exists:
1165
+ * the store then answers that the URI is open, and the next open attaches to
1166
+ * nothing.
1167
+ */
1168
+ delete(uri) {
1169
+ const key = this.documentKey((typeof uri === 'object' && 'uri' in uri ? uri.uri : uri).toString());
1170
+ for (const clientId of this.__sessions.clientsOf(key)) {
1171
+ this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
1172
+ }
1173
+ // A document waiting out the grace has no client left to close, and is
1174
+ // released now as its last close would have released it.
1175
+ if (this.__sessions.isRevertPending(key)) {
1176
+ this.__sessions.cancelRevert(key);
1177
+ this.releaseDocument(key);
1178
+ }
1179
+ super.delete(key);
1180
+ }
1181
+ /**
1182
+ * A client id as log lines print it. An id containing `#` is cut eight
1183
+ * characters after its last `#`, which leaves a minted `label#uuid` as the
1184
+ * label and enough of the UUID to tell sessions apart; an id without `#`
1185
+ * prints whole. The full id goes out at trace level wherever a session
1186
+ * starts or ends.
1187
+ */
1188
+ formatClientId(clientId) {
1189
+ const separator = clientId.lastIndexOf('#');
1190
+ return separator < 0 ? clientId : clientId.slice(0, separator + 9);
1191
+ }
1192
+ /**
1193
+ * Stages integrity-updated content for a document no client holds.
669
1194
  *
670
1195
  * When `workspace/applyEdit` targets a closed file, the client opens it from
671
1196
  * disk (stale) and sends `didOpen` before applying the edit. This staged
672
1197
  * content is consumed by {@link notifyDidOpenTextDocument} to replace the
673
1198
  * stale disk text, preventing a brief revert of the integrity update.
674
1199
  *
675
- * Not cleared on close: pending content is only staged for already-closed
676
- * documents (the integrity service checks `isOpenInLanguageClient` first), so
677
- * a close → stage → open cycle cannot occur. The only scenario where an
678
- * entry lingers is if `workspace/applyEdit` fails and the file is never
679
- * opened — the memory cost is one serialised string per URI.
1200
+ * Only a FIRST open consumes it, so stage only for a URI no client holds
1201
+ * ({@link isOpenInAnyClient} is `false`). A URI held only through another
1202
+ * head is not closed: an editor attaching to it joins the existing entry and
1203
+ * never reads the stage, and the last close discards the stage with the
1204
+ * tracking record. An entry lingers only if `workspace/applyEdit` fails and
1205
+ * the file is never opened — the memory cost is one serialised string per
1206
+ * URI.
680
1207
  */
681
1208
  stagePendingContent(uri, text) {
682
1209
  this.trackingFor(this.documentKey(uri)).pendingContent = text;
@@ -729,10 +1256,11 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
729
1256
  const targets = recorded && recorded.size > 0 ? recorded : [this.toLanguageClientUri(uri)];
730
1257
  let lastResult;
731
1258
  for (const targetUri of targets) {
732
- // Read BEFORE computeEdits, which overwrites it. This is the text the
733
- // edits below are keyed to, and therefore the only text the client's
734
- // echo of them can be reconstructed against.
735
- const before = this.__shadow.get(targetUri);
1259
+ // Read BEFORE computeEdits, which overwrites the baseline and drops
1260
+ // the opened snapshot. This is the text the client holds, and
1261
+ // therefore the only text its echo of this push can be reconstructed
1262
+ // against — whether or not the push below is keyed to it.
1263
+ const before = this.__shadow.clientText(targetUri);
736
1264
  const edits = this.__shadow.computeEdits(targetUri, newText);
737
1265
  if (edits.length === 0) {
738
1266
  continue;
@@ -747,9 +1275,20 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
747
1275
  // it is exactly what the caller retries with after a rejection, so
748
1276
  // gating it there would refuse the recovery too.
749
1277
  const version = isFullReplace(edits) ? UNKNOWN_CLIENT_VERSION : this.languageClientVersion(uri);
1278
+ // A full `ApplyWorkspaceEditParams`, `edit` and all — NOT a bare
1279
+ // `WorkspaceEdit` with a `label` beside it. `applyEdit` takes
1280
+ // `ApplyWorkspaceEditParams | WorkspaceEdit` and discriminates on
1281
+ // `!!value.edit`, so `{ label, documentChanges }` is wrapped as
1282
+ // `{ edit: { label, documentChanges } }` — putting the label inside
1283
+ // the edit, where LSP defines no such field and no client reads it.
1284
+ // The union is also what hides it at compile time: excess-property
1285
+ // checking admits a property present in EITHER member, so an object
1286
+ // matching neither type-checks against the union.
750
1287
  const result = await connection.workspace.applyEdit({
751
1288
  label: options?.label,
752
- documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1289
+ edit: {
1290
+ documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1291
+ }
753
1292
  });
754
1293
  if (result && result.applied === false) {
755
1294
  this.__shadow.invalidate(targetUri);
@@ -795,7 +1334,7 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
795
1334
  pending = [];
796
1335
  this.__pendingPushes.set(targetUri, pending);
797
1336
  }
798
- pending.push({ before, afterHash: contentHash(newText) });
1337
+ pending.push({ before, afterHash: textHash(newText) });
799
1338
  if (pending.length > PENDING_ECHO_CAP) {
800
1339
  pending.shift();
801
1340
  this.logUri(targetUri, `Pending-echo queue exceeded ${PENDING_ECHO_CAP} entries; dropped the oldest`, 'debug');
@@ -803,12 +1342,17 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
803
1342
  }
804
1343
  /**
805
1344
  * Decide what an incoming language-client change actually is, by
806
- * reconstructing the client's resulting buffer against the OLDEST push still
807
- * in flight for `clientFacing`.
1345
+ * reconstructing the client's resulting buffer against the text its ranges
1346
+ * address — the pre-push buffer of the OLDEST push still in flight for
1347
+ * `clientFacing`, else whatever that client is believed to hold.
808
1348
  *
809
- * `undefined` when nothing of ours is in flight, which is the ordinary path:
810
- * the client's ranges then address the synced text and the caller applies
811
- * them to it directly.
1349
+ * `undefined` when the client's ranges address the synced text itself —
1350
+ * nothing in flight, and no evidence the client holds anything else. That
1351
+ * is the ordinary path, and the caller then applies the ranges directly.
1352
+ * The two texts part company without a push in flight whenever a client
1353
+ * attaches to a document another client has already written: it opened
1354
+ * from disk, so applying its ranges to the synced text splices lines they
1355
+ * never addressed.
812
1356
  *
813
1357
  * **Why the oldest, and why reconstruct at all.** The client applies our
814
1358
  * pushes in order and echoes each against the buffer it held before that
@@ -835,22 +1379,41 @@ export class HydraniumTextDocuments extends NormalizedTextDocuments {
835
1379
  * queue, so undo revisits PAST states while the queue holds IN-FLIGHT ones.
836
1380
  */
837
1381
  classifyLanguageClientChange(clientFacing, document, changes) {
838
- const pending = this.__pendingPushes.get(clientFacing);
839
- if (pending === undefined || pending.length === 0) {
1382
+ const queued = this.__pendingPushes.get(clientFacing);
1383
+ const pending = queued?.length ? queued : undefined;
1384
+ // The text the client's ranges address: the buffer it held before the
1385
+ // oldest push still in flight, else the buffer it is believed to hold.
1386
+ const clientText = pending?.[0].before ?? this.__shadow.clientText(clientFacing);
1387
+ if (pending === undefined && (clientText === undefined || clientText === document.getText())) {
840
1388
  return undefined;
841
1389
  }
1390
+ if (pending !== undefined && pending[0].before === undefined && changes.some(change => ContentChange.isIncremental(change))) {
1391
+ // A push sent to a client whose buffer was unknown — a first sync, or
1392
+ // the recovery push after a rejection. The shadow now holds the text
1393
+ // that push MOVES the client to, which is the one text the echo's
1394
+ // ranges provably do not address, so the fallback above is a baseline
1395
+ // known to be wrong rather than merely unverified. Drop the queue and
1396
+ // the shadow: the next sync is then a full replace, which lands on
1397
+ // whatever the client holds. A full-text change is exempt because it
1398
+ // reconstructs identically against any baseline.
1399
+ pending.length = 0;
1400
+ this.__shadow.invalidate(clientFacing);
1401
+ return { kind: 'unreconstructable' };
1402
+ }
842
1403
  // Through the configured factories, not `TextDocument` directly, so an
843
1404
  // adopter's custom text-document type governs how the ranges are applied
844
1405
  // here exactly as it does on the synced document.
845
- const probe = this.create(clientFacing, document.languageId, 0, pending[0].before ?? document.getText());
846
- const clientText = this.update(probe, changes, 0).getText();
847
- const matchIndex = pending.findIndex(push => push.afterHash === contentHash(clientText));
848
- if (matchIndex >= 0) {
849
- pending.splice(0, matchIndex + 1);
850
- return { kind: 'echo' };
851
- }
852
- pending.length = 0;
853
- return { kind: 'divergent', text: clientText };
1406
+ const probe = this.create(clientFacing, document.languageId, 0, clientText ?? document.getText());
1407
+ const reconstructed = this.update(probe, changes, 0).getText();
1408
+ if (pending !== undefined) {
1409
+ const matchIndex = pending.findIndex(push => push.afterHash === textHash(reconstructed));
1410
+ if (matchIndex >= 0) {
1411
+ pending.splice(0, matchIndex + 1);
1412
+ return { kind: 'echo' };
1413
+ }
1414
+ pending.length = 0;
1415
+ }
1416
+ return { kind: 'divergent', text: reconstructed };
854
1417
  }
855
1418
  /**
856
1419
  * Explicitly baseline the language-client text shadow for a URI. Useful in