@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
@@ -20,7 +20,7 @@
20
20
  // on lsp-server, contradicting the peer architecture.
21
21
  // eslint-disable-next-line @typescript-eslint/no-restricted-imports
22
22
  import { NormalizedTextDocuments } from '@hydranium/langium/lsp';
23
- import { UriUtils } from '@hydranium/langium';
23
+ import { type URI, UriUtils } from '@hydranium/langium';
24
24
  import { type ServerSharedServices } from '../langium/module.js';
25
25
  import {
26
26
  type ApplyWorkspaceEditResult,
@@ -31,12 +31,13 @@ import {
31
31
  type DidOpenTextDocumentParams,
32
32
  type DidSaveTextDocumentParams,
33
33
  type Disposable,
34
- type Emitter,
34
+ Emitter,
35
35
  type Event,
36
36
  type HandlerResult,
37
37
  OptionalVersionedTextDocumentIdentifier,
38
38
  type RequestHandler,
39
39
  type TextDocumentChangeEvent,
40
+ TextDocumentContentChangeEvent as ContentChange,
40
41
  TextDocumentEdit,
41
42
  TextDocumentSyncKind,
42
43
  type TextDocumentWillSaveEvent,
@@ -45,9 +46,21 @@ import {
45
46
  type WillSaveTextDocumentParams
46
47
  } from 'vscode-languageserver';
47
48
  import { type DocumentUri, TextDocument, type TextDocumentContentChangeEvent } from 'vscode-languageserver-textdocument';
48
- import { type CanonicalUri, type LanguageClientUri, asLanguageClientUri, DisposableCollection, type Tracer } from '@hydranium/protocol';
49
+ import {
50
+ type CanonicalUri,
51
+ type LanguageClientUri,
52
+ asLanguageClientUri,
53
+ DisposableCollection,
54
+ type Stopwatch,
55
+ textHash,
56
+ type Tracer
57
+ } from '@hydranium/protocol';
49
58
  import { type LogNameOptions } from '../langium/diagnostics/logger.js';
59
+ import { HYDRANIUM_BUILD_REASONS } from '../langium/document-builder/document-builder.js';
60
+ import { isConnectionGoneError } from '../util/connection-liveness.js';
50
61
  import { LANGUAGE_CLIENT_ID } from './client-ids.js';
62
+ import { INTEGRITY_CLIENT_ID } from '../langium/integrity/integrity-rule.js';
63
+ import { type ClientSessionClosedEvent, ClientSessionRegistry, type SessionEndCause } from './client-session-registry.js';
51
64
  import { isFullReplace, LanguageClientTextShadow } from './language-client-text-shadow.js';
52
65
 
53
66
  /**
@@ -74,6 +87,46 @@ export interface HydraniumTextDocumentsOptions<T extends TextDocument = TextDocu
74
87
  * Adopters with a custom text-document type (rare) pass their factory.
75
88
  */
76
89
  readonly configuration?: TextDocumentsConfiguration<T>;
90
+ /**
91
+ * How long a document whose last open closed because its client's
92
+ * connection was lost keeps its text before it reverts to disk. An open by
93
+ * a client lost from the document, within this time of its own loss,
94
+ * cancels the revert, so a client that registers again under its id after
95
+ * a dropped connection finds its unsaved edits. Any other open releases the
96
+ * document first and then opens it as a first open does, from the text the
97
+ * opener supplies (an editor's own) or else from the file. Meanwhile the
98
+ * document counts as open for the integrity service, which therefore writes
99
+ * none of its unsaved text to disk. A close the client makes itself, or
100
+ * ending its session, reverts at once whatever this is.
101
+ *
102
+ * Defaults to 10 s. `0` reverts such a document at once as well, released
103
+ * in the close itself rather than on a timer.
104
+ */
105
+ readonly revertGraceMs?: number;
106
+ }
107
+
108
+ /**
109
+ * The default of {@link HydraniumTextDocumentsOptions.revertGraceMs}: long
110
+ * enough for a client whose connection dropped to register again and reopen
111
+ * its documents, which a data client does as soon as it has a connection.
112
+ */
113
+ const DEFAULT_REVERT_GRACE_MS = 10_000;
114
+
115
+ /** Delivered by {@link HydraniumTextDocuments.onDidSaveInLanguageClient}. */
116
+ export interface LanguageClientSavedEvent {
117
+ readonly uri: string;
118
+ }
119
+
120
+ /** Delivered by {@link HydraniumTextDocuments.onDidCloseLastOpen}. */
121
+ export interface LastOpenClosedEvent {
122
+ readonly uri: CanonicalUri;
123
+ }
124
+
125
+ /** Delivered by {@link HydraniumTextDocuments.onDidChangeDirty}. */
126
+ export interface DocumentDirtyChangedEvent {
127
+ readonly uri: CanonicalUri;
128
+ /** The new answer of {@link HydraniumTextDocuments.isDirty}. */
129
+ readonly dirty: boolean;
77
130
  }
78
131
 
79
132
  /**
@@ -89,10 +142,13 @@ export interface HydraniumTextDocumentsOptions<T extends TextDocument = TextDocu
89
142
  * separately-tested diff/apply-verify abstraction that owns its own baseline
90
143
  * text; folding its storage here would couple a clean utility to this record
91
144
  * for no real gain.
145
+ *
146
+ * Returned by the `protected` {@link HydraniumTextDocuments.trackingFor}, so an
147
+ * override has to name it. Restating the shape structurally instead compiles
148
+ * until a field is added here, and then fails at the adopter rather than at the
149
+ * change that caused it.
92
150
  */
93
- interface DocumentTracking {
94
- /** Client ids currently holding this document open (multi-client membership). */
95
- readonly clients: Set<string>;
151
+ export interface DocumentTrackingRecord {
96
152
  /** Author of each version, sparse-indexed by the SHARED (server-assigned) version number. */
97
153
  readonly versionAuthors: string[];
98
154
  /**
@@ -115,6 +171,29 @@ interface DocumentTracking {
115
171
  * record on last close.
116
172
  */
117
173
  languageClientUris?: Set<LanguageClientUri>;
174
+ /**
175
+ * The clients whose close of this document was caused by a lost connection
176
+ * and that have not opened it again, each with a stopwatch started at its
177
+ * loss on the store's `Clock`. While the document waits out the revert grace, only an
178
+ * open by one of them within its own
179
+ * {@link HydraniumTextDocumentsOptions.revertGraceMs} of that loss cancels
180
+ * the revert. Every such client counts, not only the last to close: one
181
+ * connection's sessions all end lost together, and any of them may reopen
182
+ * first. Without the time limit, a client that returns late inherits the
183
+ * unsaved text of a holder lost after it. A stopwatch rather than a `now()`
184
+ * reading, because a wall-clock step would otherwise expire a claim early or
185
+ * revive one past its grace, against a grace timer that the step leaves
186
+ * alone. An entry past its grace counts as any other client's and is pruned
187
+ * at the next open of the document.
188
+ */
189
+ lostClients?: Map<string, Stopwatch>;
190
+ /**
191
+ * The text the server last knew the file to hold, `undefined` for no file.
192
+ * Set by the first open and moved by {@link HydraniumTextDocuments.updateDiskBaseline}.
193
+ */
194
+ diskBaseline?: string;
195
+ /** The last answer {@link HydraniumTextDocuments.onDidChangeDirty} announced. */
196
+ dirty?: boolean;
118
197
  }
119
198
 
120
199
  /**
@@ -128,16 +207,31 @@ export interface OpenDocument {
128
207
  readonly clients: readonly string[];
129
208
  }
130
209
 
210
+ /**
211
+ * What {@link HydraniumTextDocuments.commitRepair} did with an integrity repair.
212
+ *
213
+ * Three outcomes rather than a document-or-nothing, because the two ways of
214
+ * getting nothing call for opposite responses: `not-open` means there is no
215
+ * store document to correct and the caller carries on with the one it holds,
216
+ * while `stale` means there IS one and it has moved past the text the repair
217
+ * was computed against — so carrying on would persist a correction over an edit
218
+ * that superseded it. Collapsing them lets the second read as permission to
219
+ * proceed, which is the failure this shape exists to make unrepresentable.
220
+ */
221
+ export type RepairCommit<T extends TextDocument> =
222
+ { readonly status: 'committed'; readonly document: T } | { readonly status: 'stale' } | { readonly status: 'not-open' };
223
+
131
224
  /**
132
225
  * Where a URI's shared version sequence left off — written once at last-client
133
226
  * close, consulted at the next open so the sequence CONTINUES instead of
134
227
  * restarting at whatever version id the reopening client declares. One entry
135
- * of {@link HydraniumTextDocuments.__versionSequences}.
228
+ * of {@link HydraniumTextDocuments.__versionSequences} — a `protected` field, so
229
+ * a subclass reading the map has to name what it holds.
136
230
  */
137
- interface VersionSequence {
231
+ export interface VersionSequence {
138
232
  /** The shared version at last-client close. */
139
233
  readonly version: number;
140
- /** {@link contentHash} of the synced text at last-client close. */
234
+ /** {@link textHash} of the synced text at last-client close. */
141
235
  readonly contentHash: string;
142
236
  }
143
237
 
@@ -161,76 +255,80 @@ interface ConnectionWithTextDocumentSync {
161
255
  /**
162
256
  * One text pushed to the LSP textual language client by
163
257
  * {@link HydraniumTextDocuments.applyEditToLanguageClient} whose echo has not
164
- * come back yet. One entry of {@link HydraniumTextDocuments.__pendingPushes}.
258
+ * come back yet. One entry of {@link HydraniumTextDocuments.__pendingPushes} —
259
+ * a `protected` field, so a subclass reading the queue has to name what it holds.
165
260
  */
166
- interface PendingPush {
261
+ export interface PendingLanguageClientPush {
167
262
  /**
168
- * The text the client held BEFORE this push — the shadow baseline the
169
- * push's edits were diffed against, and therefore the text the client's
170
- * echo addresses with its ranges.
263
+ * The text the client held BEFORE this push, and therefore the text its
264
+ * echo addresses with its ranges. Wider than the baseline the push's edits
265
+ * were diffed against: a full replace is sent with no diff baseline and
266
+ * still lands on a buffer the echo is keyed to.
171
267
  *
172
- * `undefined` when the push was a full replace sent with no baseline (a
173
- * first sync, or the recovery push after a rejection). There is then no
174
- * knowing what the client held, so the echo is reconstructed against the
175
- * synced text instead — which is sound for a full replace, whose
176
- * application is position-independent.
268
+ * `undefined` only when that buffer is unknown — the client never declared
269
+ * one, or a rejection invalidated what was tracked. The echo is then
270
+ * reconstructed against the synced text, which is sound only for a
271
+ * position-independent (full-text) change.
177
272
  */
178
273
  readonly before: string | undefined;
179
- /** {@link contentHash} of the text this push moves the client to. */
274
+ /** {@link textHash} of the text this push moves the client to. */
180
275
  readonly afterHash: string;
181
276
  }
182
277
 
183
278
  /**
184
279
  * What an incoming language-client change turns out to be once reconstructed
185
- * against the pushes still in flight — the return of
186
- * {@link HydraniumTextDocuments.classifyLanguageClientChange}.
280
+ * against the buffer its ranges address — the return of
281
+ * {@link HydraniumTextDocuments.classifyLanguageClientChange}. That method is
282
+ * `protected`, so an override has to name every arm it can return.
187
283
  */
188
- type LanguageClientChangeOrigin =
284
+ export type LanguageClientChangeOrigin =
189
285
  /** The client is reporting a text we pushed it. The synced document is already there. */
190
286
  | { readonly kind: 'echo' }
191
287
  /**
192
- * The client's buffer has moved somewhere we did not push it — it coalesced
193
- * a genuine keystroke into the echo, or typed before the push arrived. The
194
- * reconstructed text is what it now holds, and is authoritative.
288
+ * The client's buffer holds a text we did not push it — a keystroke that
289
+ * raced a push, or an edit to a buffer the store has already been written
290
+ * past. The reconstructed text is what it now holds, and is authoritative.
195
291
  */
196
- | { readonly kind: 'divergent'; readonly text: string };
292
+ | { readonly kind: 'divergent'; readonly text: string }
293
+ /**
294
+ * The change carries ranges and no known text addresses them, so no
295
+ * reconstruction is offered. Adopting one anyway splices the document and
296
+ * stores an edit nobody made; dropping costs at most the one keystroke the
297
+ * client still holds and the next push contradicts.
298
+ */
299
+ | { readonly kind: 'unreconstructable' };
197
300
 
198
301
  /**
199
- * Cheap, stable, non-cryptographic content hash (cyrb53) for the version
200
- * sequence's "did the content change across close/reopen?" question. Only
201
- * needs to be collision-resistant enough that an accidental match across two
202
- * DIFFERENT revisions of the same file is practically impossible; 53 bits of
203
- * a well-mixed hash over full text + length gives that without pulling in
204
- * `node:crypto` (this module must stay runnable in browser hosts).
302
+ * Whether `err` reports that the file of `target` does not exist, as a Node
303
+ * file system and the framework's providers do: code `ENOENT`, with `target`'s
304
+ * path. A missing file of another document is a different failure.
205
305
  */
206
- function contentHash(text: string): string {
207
- let h1 = 0xdeadbeef;
208
- let h2 = 0x41c6ce57;
209
- for (let index = 0; index < text.length; index++) {
210
- const code = text.charCodeAt(index);
211
- h1 = Math.imul(h1 ^ code, 2654435761);
212
- h2 = Math.imul(h2 ^ code, 1597334677);
213
- }
214
- h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
215
- h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
216
- h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
217
- h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
218
- return `${(4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36)}:${text.length}`;
306
+ function isFileNotFound(err: unknown, target: URI): boolean {
307
+ if (typeof err !== 'object' || err === null || !('code' in err) || err.code !== 'ENOENT') {
308
+ return false;
309
+ }
310
+ return !('path' in err) || err.path === target.fsPath;
219
311
  }
220
312
 
221
313
  /**
222
314
  * Multi-client text-document tracking on top of Langium's `NormalizedTextDocuments`.
223
315
  *
224
316
  * Adds the framework features used by the integrity, model-server, and GLSP layers:
225
- * - Per-document client membership (multiple clients can attach to the same URI).
317
+ * - Per-document client membership (multiple clients can attach to the same
318
+ * URI) and the client-session table, both kept by {@link __sessions}.
226
319
  * - A SERVER-OWNED shared version sequence: per-URI, monotonic across
227
320
  * close/reopen cycles, advancing exactly when the synced content changes.
228
321
  * Client-declared version ids (Monaco's buffer numbering) feed only a
229
322
  * per-client staleness guard and never leak into the shared sequence —
230
323
  * the two are different things (an editor's edit-operation counter vs the
231
324
  * document's content-revision number), and splicing them lets versions drift
232
- * silently past `baseVersion` gate holders.
325
+ * silently past based-on gate holders.
233
326
  * - Version-author history so each edit is attributable to its originating client.
327
+ * - The revert to disk once no client has a document open, for every head
328
+ * ({@link revertToDisk}), deferred by
329
+ * {@link HydraniumTextDocumentsOptions.revertGraceMs} after a lost connection.
330
+ * - A disk baseline per open document, and whether its text differs from it
331
+ * ({@link isDirty}).
234
332
  * - Pending-content staging used by the integrity service to thread corrections
235
333
  * through `workspace/applyEdit` cycles for currently-closed documents.
236
334
  * - `didOpen` notifications arriving over the LSP connection wait on the
@@ -240,23 +338,28 @@ function contentHash(text: string): string {
240
338
  */
241
339
  export class HydraniumTextDocuments<T extends TextDocument = TextDocument> extends NormalizedTextDocuments<T> {
242
340
  /**
243
- * Per-URI client-facing tracking ({@link DocumentTracking}): client
244
- * membership, version-author history, and integrity-staged pending content,
245
- * keyed by normalized URI. One record per URI so the last-client close clears
246
- * every axis atomically. The language-client diff baseline stays in
247
- * {@link __shadow} (its own abstraction); the parsed-document store stays in
248
- * the inherited `__syncedDocuments`.
341
+ * Per-URI client-facing tracking ({@link DocumentTrackingRecord}), keyed by
342
+ * canonical URI. One record per URI, so dropping it clears every axis at
343
+ * once. Which client has the document open is kept apart, in
344
+ * {@link __sessions}.
249
345
  */
250
- protected __documents = new Map<CanonicalUri, DocumentTracking>();
346
+ protected __documents = new Map<CanonicalUri, DocumentTrackingRecord>();
347
+
348
+ /**
349
+ * Which client has which document open, and which client ids are registered
350
+ * sessions. Every open-state predicate on this class reads it, so an open
351
+ * recorded anywhere else is invisible to the last-close transition.
352
+ */
353
+ protected readonly __sessions = new ClientSessionRegistry();
251
354
 
252
355
  /**
253
356
  * Per-URI shared-version continuity across close/reopen cycles
254
357
  * ({@link VersionSequence}), written at last-client close and consulted by
255
- * the next first-client open. DELIBERATELY outside {@link DocumentTracking}:
358
+ * the next first-client open. DELIBERATELY outside {@link DocumentTrackingRecord}:
256
359
  * that record is deleted on last close, while the version sequence must
257
360
  * survive it — the shared version is a server-owned, monotonic,
258
361
  * advances-iff-content-changes counter that never resets while the server
259
- * lives. That invariant is what makes an optimistic `baseVersion` gate
362
+ * lives. That invariant is what makes an optimistic based-on gate
260
363
  * sound: "version unchanged ⇔ content unchanged", with no false conflicts
261
364
  * from close/reopen version resets and no false passes from a reopened
262
365
  * sequence coincidentally landing on a stale writer's number.
@@ -269,7 +372,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
269
372
  /**
270
373
  * Texts pushed to the LSP textual language client via
271
374
  * {@link applyEditToLanguageClient} whose echoes have not come back yet
272
- * ({@link PendingPush}), keyed like the shadow by language-client URI.
375
+ * ({@link PendingLanguageClientPush}), keyed like the shadow by language-client URI.
273
376
  * Outbound pushes and inbound echoes are uncorrelated on the wire; this
274
377
  * FIFO is the explicit correlation, and it is what
275
378
  * {@link classifyLanguageClientChange} reconstructs against.
@@ -291,7 +394,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
291
394
  * a pathological echo that never arrives; the memory cost until then is
292
395
  * one pre-push text per in-flight push, for milliseconds.
293
396
  */
294
- protected readonly __pendingPushes = new Map<LanguageClientUri, PendingPush[]>();
397
+ protected readonly __pendingPushes = new Map<LanguageClientUri, PendingLanguageClientPush[]>();
295
398
 
296
399
  /**
297
400
  * Tracked text content per URI for the LSP textual language client (Monaco / VS Code).
@@ -316,6 +419,11 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
316
419
 
317
420
  protected readonly tracer: Tracer;
318
421
  protected readonly configuration: TextDocumentsConfiguration<T>;
422
+ /** See {@link HydraniumTextDocumentsOptions.revertGraceMs}. */
423
+ protected readonly revertGraceMs: number;
424
+ protected readonly lastOpenClosedEmitter = new Emitter<LastOpenClosedEvent>();
425
+ protected readonly languageClientSavedEmitter = new Emitter<LanguageClientSavedEvent>();
426
+ protected readonly dirtyChangedEmitter = new Emitter<DocumentDirtyChangedEvent>();
319
427
 
320
428
  constructor(
321
429
  protected services: ServerSharedServices,
@@ -329,6 +437,8 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
329
437
  (uri, reason) => this.tracer.with(uri).warn(`Diff apply-verify fallback (${reason}) — using full-document replace`),
330
438
  this
331
439
  );
440
+ this.revertGraceMs = options.revertGraceMs ?? DEFAULT_REVERT_GRACE_MS;
441
+ this.onDidCloseLastOpen(event => void this.revertToDisk(event.uri));
332
442
  }
333
443
 
334
444
  // Re-exposed configuration factories — for framework-internal callers
@@ -429,7 +539,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
429
539
  );
430
540
  disposables.push(
431
541
  connection.onDidSaveTextDocument((event: DidSaveTextDocumentParams) => {
432
- this.notifyDidSaveTextDocument(event);
542
+ void this.notifyLanguageClientSave(event);
433
543
  })
434
544
  );
435
545
  return disposables;
@@ -466,20 +576,21 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
466
576
  // the update) from "incoming version older than ours" (stale race).
467
577
  const reason =
468
578
  lastSeen === td.version ? `already at version ${lastSeen}` : `incoming version ${td.version} older than current ${lastSeen}`;
469
- this.logUri(uri, `Ignore update by ${clientId}: ${reason}`, 'debug');
579
+ this.logUri(uri, `Ignore update by ${this.formatClientId(clientId)}: ${reason}`, 'debug');
470
580
  return;
471
581
  }
472
582
  record.clientVersions.set(clientId, td.version);
473
583
 
474
- // A language-client change arriving while one of our own pushes is in
475
- // flight is keyed to the buffer the client held BEFORE that push, NOT
476
- // to the synced text — the authored write already advanced the latter.
584
+ // A language-client change is keyed to the buffer that client holds,
585
+ // which is the synced text only while the two agree — an authored
586
+ // write advances the synced text without the client knowing, and a
587
+ // push in flight moves the client without the store knowing.
477
588
  // Resolve which it is, and against what, before touching anything.
478
- const inFlight =
589
+ const origin =
479
590
  clientId === LANGUAGE_CLIENT_ID
480
591
  ? this.classifyLanguageClientChange(this.toLanguageClientUri(td.uri), document, changes)
481
592
  : undefined;
482
- if (inFlight?.kind === 'echo') {
593
+ if (origin?.kind === 'echo') {
483
594
  // The client is reporting a text we pushed it, so the synced
484
595
  // document is ALREADY there and the changes must not be applied a
485
596
  // second time. Nothing is minted and no rebuild fires. The
@@ -491,19 +602,23 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
491
602
  this.logUri(uri, `Skip rebuild: echo of a server-authored push (client version ${td.version})`, 'debug');
492
603
  return;
493
604
  }
605
+ if (origin?.kind === 'unreconstructable') {
606
+ this.tracer.with(uri).warn(`Drop change: no known client buffer for its ranges (client version ${td.version})`);
607
+ return;
608
+ }
494
609
 
495
610
  // The SHARED version advances iff the content actually changes — the
496
- // invariant optimistic `baseVersion` gates rely on. The new text is
611
+ // invariant optimistic based-on gates rely on. The new text is
497
612
  // only known after applying the (possibly incremental) changes, so
498
613
  // apply at a tentative +1 and roll the version back on an identical
499
614
  // result (an empty-changes update only re-stamps the version).
500
615
  //
501
- // A divergent in-flight change is applied as its RECONSTRUCTED text
502
- // rather than as its own ranges: the ranges address the pre-push
503
- // baseline, so applying them here would splice the wrong lines.
616
+ // A divergent change is applied as its RECONSTRUCTED text rather than
617
+ // as its own ranges: those ranges address the client's own buffer, so
618
+ // applying them here would splice the wrong lines.
504
619
  const previousText = document.getText();
505
620
  const sharedVersion = document.version;
506
- document = this.configuration.update(document, inFlight === undefined ? changes : [{ text: inFlight.text }], sharedVersion + 1);
621
+ document = this.configuration.update(document, origin === undefined ? changes : [{ text: origin.text }], sharedVersion + 1);
507
622
  const changed = document.getText() !== previousText;
508
623
  if (!changed) {
509
624
  document = this.configuration.update(document, [], sharedVersion);
@@ -511,6 +626,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
511
626
  this.__syncedDocuments.set(uri, document);
512
627
  if (changed) {
513
628
  this.setAuthor(uri, document.version, clientId);
629
+ this.refreshDirty(uri);
514
630
  }
515
631
  if (clientId === LANGUAGE_CLIENT_ID) {
516
632
  // Monaco just told us about its new content; record it so the next outbound
@@ -527,7 +643,10 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
527
643
  return;
528
644
  }
529
645
  }
530
- this.log(document.uri, `Update to version ${document.version} by ${clientId}${changed ? '' : ' (content unchanged)'}`);
646
+ this.log(
647
+ document.uri,
648
+ `Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`
649
+ );
531
650
  this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
532
651
  }
533
652
  }
@@ -544,7 +663,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
544
663
  * A content-identical write still fires the change event (rebuild): the
545
664
  * authored write's server-side rebuild is correctness-bearing, it just
546
665
  * mints no new version — nothing observable changed, so watchers'
547
- * `baseVersion` pointers stay valid.
666
+ * based-on versions stay valid.
548
667
  *
549
668
  * Returns the resulting shared version. Throws when the document is not
550
669
  * open — callers (`AstDocumentManager.update`) open first.
@@ -560,57 +679,201 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
560
679
  document = this.configuration.update(document, [{ text }], document.version + 1);
561
680
  this.__syncedDocuments.set(key, document);
562
681
  this.setAuthor(key, document.version, clientId);
682
+ this.refreshDirty(key);
563
683
  }
564
- this.log(document.uri, `Update to version ${document.version} by ${clientId}${changed ? '' : ' (content unchanged)'}`);
684
+ this.log(
685
+ document.uri,
686
+ `Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`
687
+ );
565
688
  this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
566
689
  return document.version;
567
690
  }
568
691
 
569
- public notifyDidCloseTextDocument(event: DidCloseTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
692
+ /**
693
+ * Close `clientId`'s open of the document. When it was the last open, the
694
+ * document is released and reverts to disk, at once or, for a `'lost'`
695
+ * close, after {@link HydraniumTextDocumentsOptions.revertGraceMs}.
696
+ */
697
+ public notifyDidCloseTextDocument(
698
+ event: DidCloseTextDocumentParams,
699
+ clientId = LANGUAGE_CLIENT_ID,
700
+ cause: SessionEndCause = 'closed'
701
+ ): void {
570
702
  const uri = this.documentKey(event.textDocument.uri);
571
- if (!this.isOpenInClient(uri, clientId)) {
703
+ if (!this.__sessions.removeOpen(uri, clientId)) {
572
704
  return;
573
705
  }
574
- const closingRecord = this.__documents.get(uri);
575
- closingRecord?.clients.delete(clientId);
576
- closingRecord?.clientVersions.delete(clientId);
706
+ const record = this.__documents.get(uri);
707
+ record?.clientVersions.delete(clientId);
708
+ if (record && cause === 'lost') {
709
+ (record.lostClients ??= new Map()).set(clientId, this.services.Clock.stopwatch());
710
+ }
577
711
  const syncedDocument = this.__syncedDocuments.get(uri);
578
712
  if (syncedDocument !== undefined) {
579
- this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${clientId}`);
713
+ this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
580
714
  this.__onDidClose.fire(Object.freeze({ document: syncedDocument, clientId }));
581
715
 
582
716
  if (clientId === LANGUAGE_CLIENT_ID) {
583
717
  // Monaco closed the document; drop the URI it held and the shadow
584
718
  // baselined under it. (If this was the last URI/client the whole record
585
- // is deleted below, clearing the set anyway.)
719
+ // is deleted on release, clearing the set anyway.)
586
720
  const droppedUri = this.toLanguageClientUri(event.textDocument.uri);
587
721
  this.__documents.get(uri)?.languageClientUris?.delete(droppedUri);
588
722
  this.__shadow.invalidate(droppedUri);
589
723
  this.__pendingPushes.delete(droppedUri);
590
724
  }
591
- if (!this.__documents.get(uri)?.clients.size) {
592
- // Last client closed the document; delete sync state. The downstream
593
- // "rebuild from disk for file URIs / drop from index for ephemeral
594
- // URIs" decision lives in `HydraniumDocumentUpdateHandler.didCloseDocument`,
595
- // which subscribes to `onDidClose` and consults `isOpenInAnyClient`
596
- // to detect the last-close transition.
597
- this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`);
598
- // Persist where the shared version sequence left off (version +
599
- // content hash) so the next open CONTINUES the sequence instead of
600
- // restarting at the reopening client's declared id. Hashed once
601
- // here at close, not on every change.
602
- this.__versionSequences.set(uri, {
603
- version: syncedDocument.version,
604
- contentHash: contentHash(syncedDocument.getText())
605
- });
606
- this.__syncedDocuments.delete(uri);
607
- // One delete clears every per-URI axis (version history + any staged
608
- // pending content) so a future open with the same URI starts fresh.
609
- this.__documents.delete(uri);
725
+ if (!this.__sessions.isOpen(uri)) {
726
+ if (cause === 'lost' && this.revertGraceMs > 0) {
727
+ this.deferRelease(uri);
728
+ } else {
729
+ this.releaseDocument(uri);
730
+ }
610
731
  }
611
732
  }
612
733
  }
613
734
 
735
+ /**
736
+ * Keep the document, text and all, for the revert grace, then release it.
737
+ * The document stays in the store meanwhile, so a lost client that opens it
738
+ * again within its own grace attaches to it and finds its unsaved text
739
+ * rather than reading disk; see {@link resolvePendingRevert} for any other
740
+ * open.
741
+ */
742
+ protected deferRelease(uri: CanonicalUri): void {
743
+ this.log(uri, `No client left; revert deferred for ${this.revertGraceMs} ms (connection lost)`);
744
+ const timer = this.services.Clock.setTimer(() => {
745
+ this.__sessions.cancelRevert(uri);
746
+ if (!this.__sessions.isOpen(uri)) {
747
+ this.releaseDocument(uri);
748
+ }
749
+ }, this.revertGraceMs);
750
+ this.__sessions.deferRevert(uri, timer);
751
+ }
752
+
753
+ /**
754
+ * Resolve the pending revert of `uri` for an open by `clientId`. An open by a
755
+ * client lost from the document within its grace cancels the revert, and
756
+ * the client finds its unsaved text. Any other open releases the document
757
+ * first, so it opens as a first open does: cancelling for every open hands
758
+ * a lost client's unsaved text to whoever opens next, a reloaded page or an
759
+ * editor, with nothing marking it unsaved.
760
+ */
761
+ protected resolvePendingRevert(uri: CanonicalUri, clientId: string): void {
762
+ const record = this.__documents.get(uri);
763
+ if (record) {
764
+ this.pruneLostClients(record);
765
+ }
766
+ const returning = record?.lostClients?.delete(clientId) ?? false;
767
+ if (!this.__sessions.isRevertPending(uri)) {
768
+ return;
769
+ }
770
+ this.__sessions.cancelRevert(uri);
771
+ if (!returning) {
772
+ this.releaseDocument(uri);
773
+ }
774
+ }
775
+
776
+ /** Drop the entries of {@link DocumentTrackingRecord.lostClients} whose grace has run out. */
777
+ protected pruneLostClients(record: DocumentTrackingRecord): void {
778
+ for (const [clientId, sinceLoss] of record.lostClients ?? []) {
779
+ if (sinceLoss.elapsedMs >= this.revertGraceMs) {
780
+ record.lostClients?.delete(clientId);
781
+ }
782
+ }
783
+ }
784
+
785
+ /**
786
+ * Drop the document no client has open any more, then announce it on
787
+ * {@link onDidCloseLastOpen}, which is what reverts it to disk.
788
+ */
789
+ protected releaseDocument(uri: CanonicalUri): void {
790
+ const syncedDocument = this.__syncedDocuments.get(uri);
791
+ if (syncedDocument === undefined) {
792
+ return;
793
+ }
794
+ this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`);
795
+ // Persist where the shared version sequence left off (version +
796
+ // content hash) so the next open CONTINUES the sequence instead of
797
+ // restarting at the reopening client's declared id. Hashed once
798
+ // here at release, not on every change.
799
+ this.__versionSequences.set(uri, {
800
+ version: syncedDocument.version,
801
+ contentHash: textHash(syncedDocument.getText())
802
+ });
803
+ const wasDirty = this.__documents.get(uri)?.dirty === true;
804
+ this.__syncedDocuments.delete(uri);
805
+ // One delete clears every per-URI axis (version history + any staged
806
+ // pending content) so a future open with the same URI starts fresh.
807
+ this.__documents.delete(uri);
808
+ if (wasDirty) {
809
+ this.dirtyChangedEmitter.fire(Object.freeze({ uri, dirty: false }));
810
+ }
811
+ this.lastOpenClosedEmitter.fire(Object.freeze({ uri }));
812
+ }
813
+
814
+ /**
815
+ * Rebuild a released document from the file system provider, so the build
816
+ * stops carrying the unsaved text of its last client.
817
+ *
818
+ * The provider decides, for every scheme, by `exists`: a document it can
819
+ * serve is rebuilt from its text, and any other — an editor's `untitled:`
820
+ * buffer, a file never saved, or one deleted meanwhile — is removed
821
+ * from the workspace. A `virtual:` document survives, since the framework's
822
+ * provider for that scheme serves it from the index, whatever provider the
823
+ * host passes as `context.fileSystemProvider`; an edited one therefore keeps
824
+ * its last client's text, and keeping it read-only is the client's job.
825
+ *
826
+ * The answer is read in the document's disk queue, so the rebuild follows
827
+ * any save still queued rather than reverting past it; a file that goes
828
+ * after that read is removed when its rebuild finds none.
829
+ *
830
+ * Whether to revert at all is decided inside the write lock, as its holder:
831
+ * decided before waiting for the lock, a client that opens or re-creates the
832
+ * document meanwhile would have its text rebuilt over, or the document
833
+ * removed. A document some client has open again, or that waits out a new
834
+ * grace, is left to that client.
835
+ */
836
+ protected async revertToDisk(uri: CanonicalUri): Promise<void> {
837
+ const workspace = this.services.workspace;
838
+ const target = UriUtils.toUri(uri);
839
+ const reopened = (): boolean => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri);
840
+ try {
841
+ // Read before the lock: every build and read waits while the lock is
842
+ // held, and this read waits on the file's save I/O.
843
+ const onDisk = await workspace.FileSystemTaskQueue.enqueue(uri, () => workspace.FileSystemProvider.exists(target));
844
+ await workspace.WorkspaceManager?.ready;
845
+ await workspace.WorkspaceLock.write(async token => {
846
+ if (reopened()) {
847
+ return;
848
+ }
849
+ workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
850
+ try {
851
+ await (onDisk
852
+ ? workspace.DocumentBuilder.update([target], [], token)
853
+ : workspace.DocumentBuilder.update([], [target], token));
854
+ } catch (err: unknown) {
855
+ if (!onDisk || !isFileNotFound(err, target)) {
856
+ throw err;
857
+ }
858
+ // The file went after its existence was read.
859
+ this.tracer.with(uri).debug('Revert found no file; removing the document instead');
860
+ workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
861
+ await workspace.DocumentBuilder.update([], [target], token);
862
+ }
863
+ });
864
+ } catch (err: unknown) {
865
+ // A revert that finishes after the LSP peer went away fails its
866
+ // diagnostics publish, and one that runs after its workspace was torn
867
+ // down finds no file: teardown races, not failed reverts.
868
+ if (isConnectionGoneError(err) || isFileNotFound(err, target)) {
869
+ this.tracer.with(uri).debug(`Revert on last close skipped: ${err instanceof Error ? err.message : String(err)}`);
870
+ return;
871
+ }
872
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
873
+ this.tracer.with(uri).error(`Revert on last close dropped. ${detail}`);
874
+ }
875
+ }
876
+
614
877
  public notifyWillSaveTextDocument(event: WillSaveTextDocumentParams): void {
615
878
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
616
879
  if (syncedDocument !== undefined) {
@@ -630,40 +893,103 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
630
893
  }
631
894
  }
632
895
 
896
+ /**
897
+ * Handle a save the language client reports: fire
898
+ * {@link onDidSaveInLanguageClient}, then announce it on {@link onDidSave}
899
+ * only once the file read back from disk holds the store's current text.
900
+ */
901
+ protected async notifyLanguageClientSave(event: DidSaveTextDocumentParams): Promise<void> {
902
+ const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
903
+ if (syncedDocument === undefined) {
904
+ return;
905
+ }
906
+ const uri = syncedDocument.uri;
907
+ this.languageClientSavedEmitter.fire(Object.freeze({ uri }));
908
+ // `onDidSave` tells its listeners that the shared document is on disk.
909
+ // The editor wrote its own buffer, which lags the store while another
910
+ // client's edit is still on its way to it, so only the file says
911
+ // whether that holds. Read it rather than trust the editor: `didSave`
912
+ // carries text only for a client that honours `includeText`, and asking
913
+ // for it means rewriting Langium's initialize answer, which advertises
914
+ // `save` as a Boolean. The read goes through the disk queue and waits on
915
+ // nothing but the provider, so it follows a server save queued
916
+ // meanwhile. A file changed again between the editor's write and this
917
+ // read compares unequal, and the save goes unannounced.
918
+ let onDisk: string | undefined;
919
+ try {
920
+ onDisk = await this.services.workspace.FileSystemTaskQueue.enqueue(uri, () =>
921
+ this.services.workspace.FileSystemProvider.readFile(UriUtils.toUri(uri))
922
+ );
923
+ } catch (err: unknown) {
924
+ this.tracer
925
+ .with(uri)
926
+ .debug(`Editor save not announced: reading the file back failed. ${err instanceof Error ? err.message : String(err)}`);
927
+ return;
928
+ }
929
+ // What the file holds, not what the editor meant to write: an editor
930
+ // that saved older text leaves the document dirty.
931
+ this.updateDiskBaseline(uri, onDisk);
932
+ // An editor that saves and then closes releases the document while the
933
+ // read is under way; its save is still a save of the text it held.
934
+ const document = this.__syncedDocuments.get(this.documentKey(uri)) ?? syncedDocument;
935
+ if (onDisk !== document.getText()) {
936
+ this.tracer.with(uri).debug(`Editor save not announced: the file does not hold the text of version ${document.version}`);
937
+ return;
938
+ }
939
+ this.announceSave(document, LANGUAGE_CLIENT_ID);
940
+ }
941
+
942
+ /**
943
+ * Announce a save the server made. `event.text`, when given, is what the
944
+ * file now holds, written or found there, and becomes the disk baseline
945
+ * before the save is announced. It lags the store when an edit landed
946
+ * after the save took its text, and the document then stays dirty.
947
+ */
633
948
  public notifyDidSaveTextDocument(event: DidSaveTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
634
949
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
635
950
  if (syncedDocument !== undefined) {
636
- this.log(syncedDocument.uri, `Saved synced document: ${syncedDocument.version} by ${clientId}`);
637
- this.__onDidSave.fire(Object.freeze({ document: syncedDocument, clientId }));
951
+ if (event.text !== undefined) {
952
+ this.updateDiskBaseline(syncedDocument.uri, event.text);
953
+ }
954
+ this.announceSave(syncedDocument, clientId);
638
955
  }
639
956
  }
640
957
 
958
+ /** Fire {@link onDidSave} for `document`, which need not be synced any more. */
959
+ protected announceSave(document: T, clientId: string): void {
960
+ this.log(document.uri, `Saved synced document: ${document.version} by ${this.formatClientId(clientId)}`);
961
+ this.__onDidSave.fire(Object.freeze({ document, clientId }));
962
+ }
963
+
641
964
  public notifyDidOpenTextDocument(event: DidOpenTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
642
965
  const td = event.textDocument;
643
966
  const uri = this.documentKey(td.uri);
967
+ this.resolvePendingRevert(uri, clientId);
644
968
  if (this.isOpenInClient(uri, clientId)) {
645
969
  // Already open for this client under this canonical identity. If this is a
646
970
  // NEW client-facing URI for the same file (a second tab reached via a
647
- // divergent path, e.g. a symlink and its real path), record it and baseline
648
- // its shadow so outbound edits reach this tab too — but do NOT re-fire
649
- // open/rebuild; the document is already live.
971
+ // divergent path, e.g. a symlink and its real path), record it so outbound
972
+ // edits reach this tab too — but do NOT re-fire open/rebuild; the document
973
+ // is already live.
650
974
  if (clientId === LANGUAGE_CLIENT_ID) {
651
975
  const clientFacing = this.toLanguageClientUri(td.uri);
652
976
  const record = this.__documents.get(uri);
653
977
  if (record && !record.languageClientUris?.has(clientFacing)) {
654
978
  (record.languageClientUris ??= new Set<LanguageClientUri>()).add(clientFacing);
655
- const open = this.__syncedDocuments.get(uri);
656
- if (open) {
657
- this.__shadow.set(clientFacing, open.getText());
658
- }
979
+ // This tab's own buffer, NOT the synced text: a second tab is a second
980
+ // client model, read from disk, so a server-authored write already
981
+ // applied to the first tab leaves it BEHIND the synced document. Keying
982
+ // a diff to the synced text here addresses lines this tab does not have
983
+ // and drops content it does.
984
+ this.__shadow.setOpenedText(clientFacing, td.text);
659
985
  }
660
986
  }
661
987
  return;
662
988
  }
663
989
  let document = this.__syncedDocuments.get(uri);
990
+ const existingClients = this.__sessions.clientsOf(uri);
991
+ this.__sessions.addOpen(uri, clientId);
664
992
  const record = this.trackingFor(uri);
665
- const existingClients = [...record.clients];
666
- record.clients.add(clientId);
667
993
  // Baseline the per-client staleness guard at the version id the client
668
994
  // declared for its own buffer (client-owned per LSP).
669
995
  record.clientVersions.set(clientId, td.version);
@@ -679,44 +1005,110 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
679
1005
  const source = pendingText ? ', source=pending' : '';
680
1006
  // The SHARED version is server-assigned: continue the persisted
681
1007
  // sequence — same version when the content is unchanged since the
682
- // last close (so watchers' `baseVersion` pointers stay valid), one
1008
+ // last close (so watchers' based-on versions stay valid), one
683
1009
  // step when it changed (so no stale pointer can coincidentally pass
684
1010
  // the optimistic gate). Only a first-ever open adopts the client's
685
1011
  // declared id as the sequence seed.
686
1012
  const sequence = this.__versionSequences.get(uri);
687
1013
  const version =
688
- sequence === undefined ? td.version : sequence.contentHash === contentHash(text) ? sequence.version : sequence.version + 1;
689
- this.log(uri, `Open document: Version ${version} by ${clientId} [first client${source}]`);
1014
+ sequence === undefined ? td.version : sequence.contentHash === textHash(text) ? sequence.version : sequence.version + 1;
1015
+ this.log(uri, `Open document: Version ${version} by ${this.formatClientId(clientId)} [first client${source}]`);
690
1016
  document = this.configuration.create(uri, td.languageId, version, text);
691
1017
  this.__syncedDocuments.set(uri, document);
692
1018
  this.setAuthor(uri, version, clientId);
1019
+ // The opener's text, not the staged content: a session's open read
1020
+ // it from the file, and an editor opened its buffer from there. An
1021
+ // editor that opens a buffer it never saved is taken as clean.
1022
+ record.diskBaseline = td.text;
1023
+ this.refreshDirty(uri);
693
1024
  if (clientId === LANGUAGE_CLIENT_ID) {
694
1025
  // Baseline the shadow to what Monaco just opened so the next outbound
695
1026
  // applyEditToLanguageClient diffs against the right starting point. Keyed by the
696
1027
  // language-client URI (what Monaco holds), not the canonical document key.
697
- this.__shadow.set(this.toLanguageClientUri(td.uri), document.getText());
1028
+ //
1029
+ // The CLIENT's declared text, never the synced document: staged content
1030
+ // consumed above leaves the two different, and a baseline asserting the
1031
+ // client already holds the staged text suppresses the one sync that would
1032
+ // deliver it. Skipped entirely when a push is already outstanding for this
1033
+ // URI — an `applyEdit` to a closed file has the client open from disk and
1034
+ // apply afterwards, so that shadow records what it is about to hold and
1035
+ // disk text would key the next diff to a buffer nobody has.
1036
+ const clientFacing = this.toLanguageClientUri(td.uri);
1037
+ if (!this.__shadow.isTracked(clientFacing)) {
1038
+ this.__shadow.set(clientFacing, td.text);
1039
+ }
698
1040
  }
699
1041
  const toFire = Object.freeze({ document, clientId });
700
1042
  this.__onDidOpen.fire(toFire);
701
1043
  this.__onDidChangeContent.fire(toFire);
702
1044
  } else {
703
1045
  // An additional client attaches to a document already open by another client.
704
- this.log(
705
- uri,
706
- `Attach client: ${clientId} joined existing document (version ${document.version}, ` +
707
- `now open in: ${[...existingClients, clientId].join(', ')})`
708
- );
1046
+ this.logClientJoined(uri, clientId, document.version, existingClients);
1047
+ if (clientId === LANGUAGE_CLIENT_ID) {
1048
+ // Monaco's own buffer, which on this path is NOT the synced text — another
1049
+ // client opened the document and may already have changed it. Recorded as
1050
+ // an equality-only baseline (never a diff one, see `setOpenedText`) so the
1051
+ // refresh below does not push a full replace of content Monaco already
1052
+ // holds, which would dirty the file on open.
1053
+ this.__shadow.setOpenedText(this.toLanguageClientUri(td.uri), td.text);
1054
+ }
709
1055
  this.refreshContent(uri, clientId);
710
1056
  }
711
1057
  }
712
1058
 
1059
+ /**
1060
+ * Record `clientId` as an additional holder of a document another client
1061
+ * already has open.
1062
+ *
1063
+ * Unlike the attach branch of {@link notifyDidOpenTextDocument} this does
1064
+ * not refresh. That branch re-renders an attaching TEXTUAL view; a client
1065
+ * reaching a document through a non-textual route has nothing to re-render,
1066
+ * and refreshing per attach turns each into a Langium rebuild and dependent
1067
+ * relink cascade.
1068
+ *
1069
+ * The staleness guard baselines at the SYNCED version, not a declared buffer
1070
+ * version, because a client arriving this way holds no buffer of its own.
1071
+ * Unifying the two routes therefore rebaselines a textual client's guard to a
1072
+ * version it never declared.
1073
+ *
1074
+ * Returns whether a hold was added — `false` when `uri` is not open,
1075
+ * `clientId` already holds it, or the document was waiting out the revert
1076
+ * grace for other clients and was released instead (see
1077
+ * {@link resolvePendingRevert}); the caller then opens it anew.
1078
+ */
1079
+ attachClient(uri: DocumentUri, clientId: string): boolean {
1080
+ const key = this.documentKey(uri);
1081
+ if (!this.__syncedDocuments.has(key) || this.isOpenInClient(key, clientId)) {
1082
+ return false;
1083
+ }
1084
+ this.resolvePendingRevert(key, clientId);
1085
+ const document = this.__syncedDocuments.get(key);
1086
+ if (!document) {
1087
+ return false;
1088
+ }
1089
+ const existingClients = this.__sessions.clientsOf(key);
1090
+ this.__sessions.addOpen(key, clientId);
1091
+ const record = this.trackingFor(key);
1092
+ record.clientVersions.set(clientId, document.version);
1093
+ this.logClientJoined(key, clientId, document.version, existingClients);
1094
+ return true;
1095
+ }
1096
+
1097
+ protected logClientJoined(uri: DocumentUri, clientId: string, version: number, existingClients: readonly string[]): void {
1098
+ this.log(
1099
+ uri,
1100
+ `Attach client: ${this.formatClientId(clientId)} joined existing document (version ${version}, ` +
1101
+ `now open in: ${[...existingClients, clientId].map(id => this.formatClientId(id)).join(', ')})`
1102
+ );
1103
+ }
1104
+
713
1105
  refreshContent(uri: DocumentUri, clientId: string): void {
714
1106
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(uri));
715
1107
  if (syncedDocument) {
716
1108
  // Trigger a (re-)build by firing a change event.
717
1109
  const timer = this.startTimerForUri(
718
1110
  syncedDocument.uri,
719
- `Refresh synced document: Version ${syncedDocument.version} by ${clientId}`
1111
+ `Refresh synced document: Version ${syncedDocument.version} by ${this.formatClientId(clientId)}`
720
1112
  );
721
1113
  this.__onDidChangeContent.fire(Object.freeze({ document: syncedDocument, clientId }));
722
1114
  timer.dispose();
@@ -730,7 +1122,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
730
1122
  * two URIs for one physical file (a symlink path and its real path) into a
731
1123
  * single registration — dedup at the editor layer, not just in
732
1124
  * `LangiumDocuments`. The URI the client opened under is preserved separately
733
- * for egress addressing (see {@link DocumentTracking.languageClientUris}). The
1125
+ * for egress addressing (see {@link DocumentTrackingRecord.languageClientUris}). The
734
1126
  * policy is always bound (the framework defaults it to
735
1127
  * `DefaultDocumentUriPolicy`, where canonical ≡ syntactic normalize).
736
1128
  */
@@ -751,10 +1143,10 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
751
1143
  }
752
1144
 
753
1145
  /** Get-or-create the per-URI tracking record. `uri` must already be a {@link documentKey}. */
754
- protected trackingFor(uri: CanonicalUri): DocumentTracking {
1146
+ protected trackingFor(uri: CanonicalUri): DocumentTrackingRecord {
755
1147
  let record = this.__documents.get(uri);
756
1148
  if (!record) {
757
- record = { clients: new Set(), versionAuthors: [], clientVersions: new Map() };
1149
+ record = { versionAuthors: [], clientVersions: new Map() };
758
1150
  this.__documents.set(uri, record);
759
1151
  }
760
1152
  return record;
@@ -790,7 +1182,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
790
1182
  * `0` for a URI this store has never seen. The shared sequence is
791
1183
  * server-owned and monotonic across close/reopen cycles, and advances
792
1184
  * exactly when the synced content changes — which is what makes it a sound
793
- * optimistic-concurrency token (`baseVersion` gates): version unchanged ⇔
1185
+ * optimistic-concurrency token (based-on gates): version unchanged ⇔
794
1186
  * content unchanged.
795
1187
  */
796
1188
  version(uri: DocumentUri): number {
@@ -822,7 +1214,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
822
1214
  if (sequence === undefined) {
823
1215
  return undefined;
824
1216
  }
825
- const hash = contentHash(text);
1217
+ const hash = textHash(text);
826
1218
  if (hash === sequence.contentHash) {
827
1219
  return sequence.version;
828
1220
  }
@@ -832,6 +1224,59 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
832
1224
  return stepped.version;
833
1225
  }
834
1226
 
1227
+ /**
1228
+ * Commit an integrity repair into the OPEN document for `uri`, returning the
1229
+ * document that now holds it, or `undefined` when there was nothing here to
1230
+ * commit into.
1231
+ *
1232
+ * The one thing this exists to do is address the store BY URI. A repair
1233
+ * reaches the resync as a text-document object, and that object is the
1234
+ * store's own only on the LSP path; a document built for an already-open URI
1235
+ * through `LangiumDocumentFactory.fromString` carries its own, so writing the
1236
+ * repair into it corrects a copy the store does not know about and the editor
1237
+ * never sees.
1238
+ *
1239
+ * `parsedFrom` is the text the repaired AST was parsed from, and a mismatch
1240
+ * against the current content REFUSES the commit: the store has moved on,
1241
+ * which means an editor change landed after that parse, and committing would
1242
+ * overwrite a newer edit with a repair computed against text the user has
1243
+ * already replaced. The build that change provokes recomputes the repair.
1244
+ *
1245
+ * Deliberately NOT compared by version. A separately created document seeds
1246
+ * its own numbering, so requiring the two to agree would refuse every commit
1247
+ * on the path this exists for. Content is the thing both sides can be held to.
1248
+ *
1249
+ * A changed text is a new version authored by {@link INTEGRITY_CLIENT_ID}.
1250
+ * Kept at the old version, the repair is invisible to every reader keyed on
1251
+ * versions: a write based on the unrepaired version passes the based-on gate
1252
+ * and replaces the repair, and an echo filter credits the repair to the
1253
+ * client whose edit it corrected. No change event is fired: the repair
1254
+ * rides the build already under way, and an event would re-enter it.
1255
+ */
1256
+ commitRepair(uri: DocumentUri, parsedFrom: string, repaired: string): RepairCommit<T> {
1257
+ const key = this.documentKey(uri);
1258
+ const document = this.__syncedDocuments.get(key);
1259
+ if (document === undefined) {
1260
+ return { status: 'not-open' };
1261
+ }
1262
+ if (document.getText() !== parsedFrom) {
1263
+ this.logUri(key, 'Refuse repair commit: the open document moved on from the text the AST was parsed from', 'debug');
1264
+ return { status: 'stale' };
1265
+ }
1266
+ if (repaired === parsedFrom) {
1267
+ return { status: 'committed', document };
1268
+ }
1269
+ // Reassigned rather than mutated in place: the default configuration
1270
+ // updates and returns the SAME instance, but an adopter-supplied one may
1271
+ // return a new object, and the store must end up holding whichever it is.
1272
+ const updated = this.configuration.update(document, [{ text: repaired }], document.version + 1);
1273
+ this.__syncedDocuments.set(key, updated);
1274
+ this.setAuthor(key, updated.version, INTEGRITY_CLIENT_ID);
1275
+ this.refreshDirty(key);
1276
+ this.log(updated.uri, `Update to version ${updated.version} by ${this.formatClientId(INTEGRITY_CLIENT_ID)} (repair)`);
1277
+ return { status: 'committed', document: updated };
1278
+ }
1279
+
835
1280
  getAuthor(uri: DocumentUri, version?: number): string | undefined {
836
1281
  const history = this.__documents.get(this.documentKey(uri))?.versionAuthors;
837
1282
  // Either the requested version, or the latest. `version !== undefined` so we treat 0 correctly.
@@ -852,17 +1297,17 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
852
1297
  * True iff any client still holds `uri` open. Distinct from {@link isOpen},
853
1298
  * which reads `__syncedDocuments` — that map is cleared only AFTER the
854
1299
  * `onDidClose` event fires for the last client, so `isOpen` returns `true`
855
- * during the close event itself. `isOpenInAnyClient` reads the tracking
856
- * record's `clients` set, which is updated BEFORE the fire, so an `onDidClose`
1300
+ * during the close event itself. `isOpenInAnyClient` reads the open table
1301
+ * ({@link __sessions}), which is updated BEFORE the fire, so an `onDidClose`
857
1302
  * subscriber that finds this `false` knows the last client just closed and a
858
1303
  * disk re-read / rebuild can proceed.
859
1304
  */
860
1305
  isOpenInAnyClient(uri: DocumentUri): boolean {
861
- return (this.__documents.get(this.documentKey(uri))?.clients.size ?? 0) > 0;
1306
+ return this.__sessions.isOpen(this.documentKey(uri));
862
1307
  }
863
1308
 
864
1309
  isOpenInClient(uri: DocumentUri, client: string): boolean {
865
- return !!this.__documents.get(this.documentKey(uri))?.clients.has(client);
1310
+ return this.__sessions.isOpenIn(this.documentKey(uri), client);
866
1311
  }
867
1312
 
868
1313
  isOpenInLanguageClient(uri: DocumentUri): boolean {
@@ -873,14 +1318,14 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
873
1318
  }
874
1319
 
875
1320
  isOnlyOpenInClient(uri: DocumentUri, client: string): boolean {
876
- const key = this.documentKey(uri);
877
- return this.__documents.get(key)?.clients.size === 1 && this.isOpenInClient(key, client);
1321
+ const clients = this.__sessions.clientsOf(this.documentKey(uri));
1322
+ return clients.length === 1 && clients[0] === client;
878
1323
  }
879
1324
 
880
1325
  /**
881
1326
  * Every document currently held open by at least one client, with the client
882
- * ids holding it. Reads the same per-URI tracking records as
883
- * {@link isOpenInAnyClient}, so it reflects a last-client close immediately.
1327
+ * ids holding it. Reads the same open table as {@link isOpenInAnyClient}, so
1328
+ * it reflects a last-client close immediately.
884
1329
  *
885
1330
  * Diagnostics-oriented — the server-state snapshot lists these so an operator
886
1331
  * can see WHY a document is pinned: a document that lingers here after its
@@ -888,28 +1333,237 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
888
1333
  * under a shedding policy, explains why its CST is never shed).
889
1334
  */
890
1335
  openDocuments(): OpenDocument[] {
891
- const result: OpenDocument[] = [];
892
- for (const [uri, tracking] of this.__documents) {
893
- if (tracking.clients.size > 0) {
894
- result.push({ uri, clients: [...tracking.clients] });
1336
+ return this.__sessions.openDocuments();
1337
+ }
1338
+
1339
+ /** Fires once a client session has ended, after every document it had open was closed. */
1340
+ get onDidCloseSession(): Event<ClientSessionClosedEvent> {
1341
+ return this.__sessions.onDidCloseSession;
1342
+ }
1343
+
1344
+ /**
1345
+ * Fires once a document no client has open is released: at its last close,
1346
+ * or, for a lost client's last close, when its revert grace runs out,
1347
+ * another client opens it, or it is deleted. The document then reverts to
1348
+ * disk.
1349
+ */
1350
+ get onDidCloseLastOpen(): Event<LastOpenClosedEvent> {
1351
+ return this.lastOpenClosedEmitter.event;
1352
+ }
1353
+
1354
+ /**
1355
+ * Fires for every save the language client reports of a document it has
1356
+ * open: the editor has written the file. {@link onDidSave} follows only
1357
+ * when the file holds the store's text.
1358
+ */
1359
+ get onDidSaveInLanguageClient(): Event<LanguageClientSavedEvent> {
1360
+ return this.languageClientSavedEmitter.event;
1361
+ }
1362
+
1363
+ /**
1364
+ * Whether `uri` is waiting out the revert grace: its last open closed with a
1365
+ * lost connection, and it still holds its unsaved text. Such a document is
1366
+ * open for no client, yet not closed either, so a caller that would persist
1367
+ * a closed document's text to disk treats it as open.
1368
+ */
1369
+ isRevertPending(uri: DocumentUri): boolean {
1370
+ return this.__sessions.isRevertPending(this.documentKey(uri));
1371
+ }
1372
+
1373
+ /**
1374
+ * Whether the store holds `uri` with text that differs from its disk
1375
+ * baseline: what the server last knew the file to hold. `false` for a URI
1376
+ * the store does not hold; a document waiting out the revert grace is still
1377
+ * held.
1378
+ *
1379
+ * The baseline is the text a first open brought, or what the server wrote,
1380
+ * or read back after an editor's save or a watched-file change, so it can
1381
+ * trail a change to the file that none of these has seen yet. A check that
1382
+ * must know the file reads it instead.
1383
+ */
1384
+ isDirty(uri: DocumentUri): boolean {
1385
+ return this.__documents.get(this.documentKey(uri))?.dirty ?? false;
1386
+ }
1387
+
1388
+ /** Fires each time the answer of {@link isDirty} changes, the release of a dirty document included. */
1389
+ get onDidChangeDirty(): Event<DocumentDirtyChangedEvent> {
1390
+ return this.dirtyChangedEmitter.event;
1391
+ }
1392
+
1393
+ /**
1394
+ * Record that the file behind `uri` holds `text`, or no file at all for
1395
+ * `undefined`. A no-op for a URI the store does not hold: the next first
1396
+ * open sets the baseline from its own text.
1397
+ */
1398
+ updateDiskBaseline(uri: DocumentUri, text: string | undefined): void {
1399
+ const key = this.documentKey(uri);
1400
+ const record = this.__documents.get(key);
1401
+ if (!record || !this.__syncedDocuments.has(key)) {
1402
+ return;
1403
+ }
1404
+ record.diskBaseline = text;
1405
+ this.refreshDirty(key);
1406
+ }
1407
+
1408
+ /**
1409
+ * Read the file behind `uri` through its disk queue and take it as the
1410
+ * baseline. A file that cannot be read counts as none, the side that
1411
+ * leaves the document dirty.
1412
+ */
1413
+ async reloadDiskBaseline(uri: DocumentUri): Promise<void> {
1414
+ const key = this.documentKey(uri);
1415
+ if (!this.__syncedDocuments.has(key)) {
1416
+ return;
1417
+ }
1418
+ let onDisk: string | undefined;
1419
+ try {
1420
+ onDisk = await this.services.workspace.FileSystemTaskQueue.enqueue(key, () =>
1421
+ this.services.workspace.FileSystemProvider.readFile(UriUtils.toUri(key))
1422
+ );
1423
+ } catch (err: unknown) {
1424
+ this.tracer.with(key).debug(`Disk baseline: the file cannot be read. ${err instanceof Error ? err.message : String(err)}`);
1425
+ }
1426
+ this.updateDiskBaseline(key, onDisk);
1427
+ }
1428
+
1429
+ /** Compare the held text of `uri` with its baseline, and announce a changed answer. */
1430
+ protected refreshDirty(uri: CanonicalUri): void {
1431
+ const record = this.__documents.get(uri);
1432
+ const document = this.__syncedDocuments.get(uri);
1433
+ if (!record || !document) {
1434
+ return;
1435
+ }
1436
+ const dirty = document.getText() !== record.diskBaseline;
1437
+ if (dirty !== (record.dirty ?? false)) {
1438
+ record.dirty = dirty;
1439
+ this.dirtyChangedEmitter.fire(Object.freeze({ uri, dirty }));
1440
+ }
1441
+ }
1442
+
1443
+ /**
1444
+ * Start a client session under `clientId`. Throws where
1445
+ * {@link ClientSessionRegistry.register} refuses the id.
1446
+ */
1447
+ registerSession(clientId: string): void {
1448
+ this.__sessions.register(clientId);
1449
+ this.tracer.info(`Session started: ${this.formatClientId(clientId)}`);
1450
+ this.tracer.trace(`Session started: ${clientId}`);
1451
+ }
1452
+
1453
+ /**
1454
+ * End the client session `clientId`: close every document it has open, then
1455
+ * free the id. Immediate — each close runs the ordinary close path before
1456
+ * this returns, with `cause` as its cause. A no-op for an id that is not a
1457
+ * registered session.
1458
+ */
1459
+ closeSession(clientId: string, cause: SessionEndCause = 'closed'): void {
1460
+ if (!this.__sessions.isRegistered(clientId)) {
1461
+ return;
1462
+ }
1463
+ try {
1464
+ for (const uri of this.__sessions.beginClose(clientId)) {
1465
+ this.notifyDidCloseTextDocument({ textDocument: { uri } }, clientId, cause);
895
1466
  }
1467
+ } finally {
1468
+ this.__sessions.unregister(clientId);
1469
+ this.tracer.info(`Session closed: ${this.formatClientId(clientId)}`);
1470
+ this.tracer.trace(`Session closed: ${clientId}`);
896
1471
  }
897
- return result;
898
1472
  }
899
1473
 
900
1474
  /**
901
- * Stages integrity-updated content for a closed document.
1475
+ * Close every document the language client has open, as a `didClose` for
1476
+ * each would, so each last close reverts. For a host whose editor connection
1477
+ * can end while the process lives on, such as a worker whose port's peer
1478
+ * closed: the language client is no session, so nothing else closes them.
1479
+ *
1480
+ * It first waits for the workspace initialization the open handler waits
1481
+ * for, so an open that arrived before the close is closed too; closing at
1482
+ * once would leave that open to land afterwards, held by a client that is
1483
+ * gone.
1484
+ */
1485
+ async closeLanguageClientDocuments(): Promise<void> {
1486
+ await this.services.workspace.WorkspaceManager.workspaceInitialized;
1487
+ for (const uri of this.__sessions.opensOf(LANGUAGE_CLIENT_ID)) {
1488
+ // Every spelling the client opened the file under, since the close
1489
+ // below drops only the canonical one's.
1490
+ const clientUris = this.__documents.get(uri)?.languageClientUris;
1491
+ for (const clientUri of clientUris ?? []) {
1492
+ this.invalidateLanguageClientText(clientUri);
1493
+ }
1494
+ clientUris?.clear();
1495
+ this.notifyDidCloseTextDocument({ textDocument: { uri } });
1496
+ }
1497
+ }
1498
+
1499
+ /**
1500
+ * The file behind `uri` was deleted: close every open of it except the
1501
+ * language client's.
1502
+ *
1503
+ * The editor's open is left alone because the editor owns it: it keeps the
1504
+ * buffer of a deleted file and goes on sending changes for it, and a store
1505
+ * that had closed the document would drop every one of them until the editor
1506
+ * reopened.
1507
+ */
1508
+ notifyDocumentDeleted(uri: DocumentUri): void {
1509
+ const key = this.documentKey(uri);
1510
+ for (const clientId of this.__sessions.clientsOf(key)) {
1511
+ if (clientId !== LANGUAGE_CLIENT_ID) {
1512
+ this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
1513
+ }
1514
+ }
1515
+ }
1516
+
1517
+ /**
1518
+ * Remove the document for `uri`, closing every open of it first — the
1519
+ * editor's included, since the document it would keep editing is gone.
1520
+ *
1521
+ * The base removes the synced document without consulting the open table,
1522
+ * which leaves every open recorded against a document that no longer exists:
1523
+ * the store then answers that the URI is open, and the next open attaches to
1524
+ * nothing.
1525
+ */
1526
+ override delete(uri: string | URI | T): void {
1527
+ const key = this.documentKey((typeof uri === 'object' && 'uri' in uri ? uri.uri : uri).toString());
1528
+ for (const clientId of this.__sessions.clientsOf(key)) {
1529
+ this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
1530
+ }
1531
+ // A document waiting out the grace has no client left to close, and is
1532
+ // released now as its last close would have released it.
1533
+ if (this.__sessions.isRevertPending(key)) {
1534
+ this.__sessions.cancelRevert(key);
1535
+ this.releaseDocument(key);
1536
+ }
1537
+ super.delete(key);
1538
+ }
1539
+
1540
+ /**
1541
+ * A client id as log lines print it. An id containing `#` is cut eight
1542
+ * characters after its last `#`, which leaves a minted `label#uuid` as the
1543
+ * label and enough of the UUID to tell sessions apart; an id without `#`
1544
+ * prints whole. The full id goes out at trace level wherever a session
1545
+ * starts or ends.
1546
+ */
1547
+ protected formatClientId(clientId: string): string {
1548
+ const separator = clientId.lastIndexOf('#');
1549
+ return separator < 0 ? clientId : clientId.slice(0, separator + 9);
1550
+ }
1551
+
1552
+ /**
1553
+ * Stages integrity-updated content for a document no client holds.
902
1554
  *
903
1555
  * When `workspace/applyEdit` targets a closed file, the client opens it from
904
1556
  * disk (stale) and sends `didOpen` before applying the edit. This staged
905
1557
  * content is consumed by {@link notifyDidOpenTextDocument} to replace the
906
1558
  * stale disk text, preventing a brief revert of the integrity update.
907
1559
  *
908
- * Not cleared on close: pending content is only staged for already-closed
909
- * documents (the integrity service checks `isOpenInLanguageClient` first), so
910
- * a close → stage → open cycle cannot occur. The only scenario where an
911
- * entry lingers is if `workspace/applyEdit` fails and the file is never
912
- * opened — the memory cost is one serialised string per URI.
1560
+ * Only a FIRST open consumes it, so stage only for a URI no client holds
1561
+ * ({@link isOpenInAnyClient} is `false`). A URI held only through another
1562
+ * head is not closed: an editor attaching to it joins the existing entry and
1563
+ * never reads the stage, and the last close discards the stage with the
1564
+ * tracking record. An entry lingers only if `workspace/applyEdit` fails and
1565
+ * the file is never opened — the memory cost is one serialised string per
1566
+ * URI.
913
1567
  */
914
1568
  stagePendingContent(uri: DocumentUri, text: string): void {
915
1569
  this.trackingFor(this.documentKey(uri)).pendingContent = text;
@@ -967,10 +1621,11 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
967
1621
  const targets: Iterable<LanguageClientUri> = recorded && recorded.size > 0 ? recorded : [this.toLanguageClientUri(uri)];
968
1622
  let lastResult: ApplyWorkspaceEditResult | undefined;
969
1623
  for (const targetUri of targets) {
970
- // Read BEFORE computeEdits, which overwrites it. This is the text the
971
- // edits below are keyed to, and therefore the only text the client's
972
- // echo of them can be reconstructed against.
973
- const before = this.__shadow.get(targetUri);
1624
+ // Read BEFORE computeEdits, which overwrites the baseline and drops
1625
+ // the opened snapshot. This is the text the client holds, and
1626
+ // therefore the only text its echo of this push can be reconstructed
1627
+ // against — whether or not the push below is keyed to it.
1628
+ const before = this.__shadow.clientText(targetUri);
974
1629
  const edits = this.__shadow.computeEdits(targetUri, newText);
975
1630
  if (edits.length === 0) {
976
1631
  continue;
@@ -985,9 +1640,20 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
985
1640
  // it is exactly what the caller retries with after a rejection, so
986
1641
  // gating it there would refuse the recovery too.
987
1642
  const version = isFullReplace(edits) ? UNKNOWN_CLIENT_VERSION : this.languageClientVersion(uri);
1643
+ // A full `ApplyWorkspaceEditParams`, `edit` and all — NOT a bare
1644
+ // `WorkspaceEdit` with a `label` beside it. `applyEdit` takes
1645
+ // `ApplyWorkspaceEditParams | WorkspaceEdit` and discriminates on
1646
+ // `!!value.edit`, so `{ label, documentChanges }` is wrapped as
1647
+ // `{ edit: { label, documentChanges } }` — putting the label inside
1648
+ // the edit, where LSP defines no such field and no client reads it.
1649
+ // The union is also what hides it at compile time: excess-property
1650
+ // checking admits a property present in EITHER member, so an object
1651
+ // matching neither type-checks against the union.
988
1652
  const result = await connection.workspace.applyEdit({
989
1653
  label: options?.label,
990
- documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1654
+ edit: {
1655
+ documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1656
+ }
991
1657
  });
992
1658
  if (result && result.applied === false) {
993
1659
  this.__shadow.invalidate(targetUri);
@@ -1034,7 +1700,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1034
1700
  pending = [];
1035
1701
  this.__pendingPushes.set(targetUri, pending);
1036
1702
  }
1037
- pending.push({ before, afterHash: contentHash(newText) });
1703
+ pending.push({ before, afterHash: textHash(newText) });
1038
1704
  if (pending.length > PENDING_ECHO_CAP) {
1039
1705
  pending.shift();
1040
1706
  this.logUri(targetUri, `Pending-echo queue exceeded ${PENDING_ECHO_CAP} entries; dropped the oldest`, 'debug');
@@ -1043,12 +1709,17 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1043
1709
 
1044
1710
  /**
1045
1711
  * Decide what an incoming language-client change actually is, by
1046
- * reconstructing the client's resulting buffer against the OLDEST push still
1047
- * in flight for `clientFacing`.
1712
+ * reconstructing the client's resulting buffer against the text its ranges
1713
+ * address — the pre-push buffer of the OLDEST push still in flight for
1714
+ * `clientFacing`, else whatever that client is believed to hold.
1048
1715
  *
1049
- * `undefined` when nothing of ours is in flight, which is the ordinary path:
1050
- * the client's ranges then address the synced text and the caller applies
1051
- * them to it directly.
1716
+ * `undefined` when the client's ranges address the synced text itself —
1717
+ * nothing in flight, and no evidence the client holds anything else. That
1718
+ * is the ordinary path, and the caller then applies the ranges directly.
1719
+ * The two texts part company without a push in flight whenever a client
1720
+ * attaches to a document another client has already written: it opened
1721
+ * from disk, so applying its ranges to the synced text splices lines they
1722
+ * never addressed.
1052
1723
  *
1053
1724
  * **Why the oldest, and why reconstruct at all.** The client applies our
1054
1725
  * pushes in order and echoes each against the buffer it held before that
@@ -1079,22 +1750,41 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1079
1750
  document: T,
1080
1751
  changes: TextDocumentContentChangeEvent[]
1081
1752
  ): LanguageClientChangeOrigin | undefined {
1082
- const pending = this.__pendingPushes.get(clientFacing);
1083
- if (pending === undefined || pending.length === 0) {
1753
+ const queued = this.__pendingPushes.get(clientFacing);
1754
+ const pending = queued?.length ? queued : undefined;
1755
+ // The text the client's ranges address: the buffer it held before the
1756
+ // oldest push still in flight, else the buffer it is believed to hold.
1757
+ const clientText = pending?.[0].before ?? this.__shadow.clientText(clientFacing);
1758
+ if (pending === undefined && (clientText === undefined || clientText === document.getText())) {
1084
1759
  return undefined;
1085
1760
  }
1761
+ if (pending !== undefined && pending[0].before === undefined && changes.some(change => ContentChange.isIncremental(change))) {
1762
+ // A push sent to a client whose buffer was unknown — a first sync, or
1763
+ // the recovery push after a rejection. The shadow now holds the text
1764
+ // that push MOVES the client to, which is the one text the echo's
1765
+ // ranges provably do not address, so the fallback above is a baseline
1766
+ // known to be wrong rather than merely unverified. Drop the queue and
1767
+ // the shadow: the next sync is then a full replace, which lands on
1768
+ // whatever the client holds. A full-text change is exempt because it
1769
+ // reconstructs identically against any baseline.
1770
+ pending.length = 0;
1771
+ this.__shadow.invalidate(clientFacing);
1772
+ return { kind: 'unreconstructable' };
1773
+ }
1086
1774
  // Through the configured factories, not `TextDocument` directly, so an
1087
1775
  // adopter's custom text-document type governs how the ranges are applied
1088
1776
  // here exactly as it does on the synced document.
1089
- const probe = this.create(clientFacing, document.languageId, 0, pending[0].before ?? document.getText());
1090
- const clientText = this.update(probe, changes, 0).getText();
1091
- const matchIndex = pending.findIndex(push => push.afterHash === contentHash(clientText));
1092
- if (matchIndex >= 0) {
1093
- pending.splice(0, matchIndex + 1);
1094
- return { kind: 'echo' };
1777
+ const probe = this.create(clientFacing, document.languageId, 0, clientText ?? document.getText());
1778
+ const reconstructed = this.update(probe, changes, 0).getText();
1779
+ if (pending !== undefined) {
1780
+ const matchIndex = pending.findIndex(push => push.afterHash === textHash(reconstructed));
1781
+ if (matchIndex >= 0) {
1782
+ pending.splice(0, matchIndex + 1);
1783
+ return { kind: 'echo' };
1784
+ }
1785
+ pending.length = 0;
1095
1786
  }
1096
- pending.length = 0;
1097
- return { kind: 'divergent', text: clientText };
1787
+ return { kind: 'divergent', text: reconstructed };
1098
1788
  }
1099
1789
 
1100
1790
  /**