@hydranium/core 1.0.0-next.19 → 1.0.0-next.192

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 (588) hide show
  1. package/README.md +6 -2
  2. package/lib/documents/ast-document-manager.d.ts +204 -62
  3. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  4. package/lib/documents/ast-document-manager.js +249 -83
  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 +523 -101
  23. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  24. package/lib/documents/hydranium-text-documents.js +961 -177
  25. package/lib/documents/hydranium-text-documents.js.map +1 -1
  26. package/lib/documents/index.d.ts +5 -0
  27. package/lib/documents/index.d.ts.map +1 -1
  28. package/lib/documents/index.js +5 -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/model-ledger.d.ts +47 -0
  35. package/lib/documents/model-ledger.d.ts.map +1 -0
  36. package/lib/documents/model-ledger.js +30 -0
  37. package/lib/documents/model-ledger.js.map +1 -0
  38. package/lib/documents/self-save-registry.d.ts +37 -6
  39. package/lib/documents/self-save-registry.d.ts.map +1 -1
  40. package/lib/documents/self-save-registry.js +28 -15
  41. package/lib/documents/self-save-registry.js.map +1 -1
  42. package/lib/documents/version-sync-service.d.ts +134 -0
  43. package/lib/documents/version-sync-service.d.ts.map +1 -0
  44. package/lib/documents/version-sync-service.js +171 -0
  45. package/lib/documents/version-sync-service.js.map +1 -0
  46. package/lib/index.d.ts +3 -0
  47. package/lib/index.d.ts.map +1 -1
  48. package/lib/index.js +6 -0
  49. package/lib/index.js.map +1 -1
  50. package/lib/langium/ast-extension/ast-extension-service.d.ts.map +1 -1
  51. package/lib/langium/ast-extension/ast-extension-service.js.map +1 -1
  52. package/lib/langium/ast-extension/ast-node-builder.d.ts +32 -9
  53. package/lib/langium/ast-extension/ast-node-builder.d.ts.map +1 -1
  54. package/lib/langium/ast-extension/ast-node-builder.js +60 -22
  55. package/lib/langium/ast-extension/ast-node-builder.js.map +1 -1
  56. package/lib/langium/bootstrap.d.ts.map +1 -1
  57. package/lib/langium/bootstrap.js +32 -9
  58. package/lib/langium/bootstrap.js.map +1 -1
  59. package/lib/langium/build-phase-pass/build-phase-pass-service.d.ts.map +1 -1
  60. package/lib/langium/build-phase-pass/build-phase-pass-service.js.map +1 -1
  61. package/lib/langium/config/configuration-provider.d.ts +81 -0
  62. package/lib/langium/config/configuration-provider.d.ts.map +1 -0
  63. package/lib/langium/config/configuration-provider.js +112 -0
  64. package/lib/langium/config/configuration-provider.js.map +1 -0
  65. package/lib/langium/config/index.d.ts +1 -0
  66. package/lib/langium/config/index.d.ts.map +1 -1
  67. package/lib/langium/config/index.js +1 -0
  68. package/lib/langium/config/index.js.map +1 -1
  69. package/lib/langium/config/settings.js.map +1 -1
  70. package/lib/langium/diagnostics/hydranium-langium-profiler.d.ts.map +1 -1
  71. package/lib/langium/diagnostics/hydranium-langium-profiler.js.map +1 -1
  72. package/lib/langium/diagnostics/logger.d.ts +10 -3
  73. package/lib/langium/diagnostics/logger.d.ts.map +1 -1
  74. package/lib/langium/diagnostics/logger.js +12 -5
  75. package/lib/langium/diagnostics/logger.js.map +1 -1
  76. package/lib/langium/diagnostics/lsp-logger.d.ts +17 -0
  77. package/lib/langium/diagnostics/lsp-logger.d.ts.map +1 -1
  78. package/lib/langium/diagnostics/lsp-logger.js +56 -13
  79. package/lib/langium/diagnostics/lsp-logger.js.map +1 -1
  80. package/lib/langium/diagnostics/server-tracer.d.ts.map +1 -1
  81. package/lib/langium/diagnostics/server-tracer.js.map +1 -1
  82. package/lib/langium/document-builder/build-pipeline-integration.d.ts +21 -1
  83. package/lib/langium/document-builder/build-pipeline-integration.d.ts.map +1 -1
  84. package/lib/langium/document-builder/build-pipeline-integration.js +32 -5
  85. package/lib/langium/document-builder/build-pipeline-integration.js.map +1 -1
  86. package/lib/langium/document-builder/build-session.d.ts +93 -0
  87. package/lib/langium/document-builder/build-session.d.ts.map +1 -0
  88. package/lib/langium/document-builder/build-session.js +72 -0
  89. package/lib/langium/document-builder/build-session.js.map +1 -0
  90. package/lib/langium/document-builder/document-builder.d.ts +382 -29
  91. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  92. package/lib/langium/document-builder/document-builder.js +659 -61
  93. package/lib/langium/document-builder/document-builder.js.map +1 -1
  94. package/lib/langium/document-builder/index.d.ts +1 -0
  95. package/lib/langium/document-builder/index.d.ts.map +1 -1
  96. package/lib/langium/document-builder/index.js +1 -0
  97. package/lib/langium/document-builder/index.js.map +1 -1
  98. package/lib/langium/documentation/comment-provider.d.ts.map +1 -1
  99. package/lib/langium/documentation/comment-provider.js.map +1 -1
  100. package/lib/langium/integration-services.d.ts +45 -1
  101. package/lib/langium/integration-services.d.ts.map +1 -1
  102. package/lib/langium/integration-services.js +10 -5
  103. package/lib/langium/integration-services.js.map +1 -1
  104. package/lib/langium/integrity/integrity-rule.d.ts +13 -5
  105. package/lib/langium/integrity/integrity-rule.d.ts.map +1 -1
  106. package/lib/langium/integrity/integrity-rule.js.map +1 -1
  107. package/lib/langium/integrity/integrity-service.d.ts +58 -13
  108. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  109. package/lib/langium/integrity/integrity-service.js +204 -29
  110. package/lib/langium/integrity/integrity-service.js.map +1 -1
  111. package/lib/langium/keys/containment.d.ts +64 -0
  112. package/lib/langium/keys/containment.d.ts.map +1 -0
  113. package/lib/langium/keys/containment.js +53 -0
  114. package/lib/langium/keys/containment.js.map +1 -0
  115. package/lib/langium/keys/index.d.ts +1 -0
  116. package/lib/langium/keys/index.d.ts.map +1 -1
  117. package/lib/langium/keys/index.js +1 -0
  118. package/lib/langium/keys/index.js.map +1 -1
  119. package/lib/langium/keys/name-based-key-provider.d.ts +4 -0
  120. package/lib/langium/keys/name-based-key-provider.d.ts.map +1 -1
  121. package/lib/langium/keys/name-based-key-provider.js +4 -0
  122. package/lib/langium/keys/name-based-key-provider.js.map +1 -1
  123. package/lib/langium/keys/positional-key-provider.d.ts.map +1 -1
  124. package/lib/langium/keys/positional-key-provider.js.map +1 -1
  125. package/lib/langium/labeling/label-provider.d.ts.map +1 -1
  126. package/lib/langium/labeling/label-provider.js.map +1 -1
  127. package/lib/langium/language-module.d.ts +27 -2
  128. package/lib/langium/language-module.d.ts.map +1 -1
  129. package/lib/langium/language-module.js +16 -2
  130. package/lib/langium/language-module.js.map +1 -1
  131. package/lib/langium/model-service/client-session.d.ts +311 -0
  132. package/lib/langium/model-service/client-session.d.ts.map +1 -0
  133. package/lib/langium/model-service/client-session.js +398 -0
  134. package/lib/langium/model-service/client-session.js.map +1 -0
  135. package/lib/langium/model-service/index.d.ts +1 -0
  136. package/lib/langium/model-service/index.d.ts.map +1 -1
  137. package/lib/langium/model-service/index.js +1 -0
  138. package/lib/langium/model-service/index.js.map +1 -1
  139. package/lib/langium/model-service/model-service.d.ts +353 -253
  140. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  141. package/lib/langium/model-service/model-service.js +361 -408
  142. package/lib/langium/model-service/model-service.js.map +1 -1
  143. package/lib/langium/module.d.ts +150 -35
  144. package/lib/langium/module.d.ts.map +1 -1
  145. package/lib/langium/module.js +50 -35
  146. package/lib/langium/module.js.map +1 -1
  147. package/lib/langium/naming/name-provider.d.ts +25 -4
  148. package/lib/langium/naming/name-provider.d.ts.map +1 -1
  149. package/lib/langium/naming/name-provider.js +2 -1
  150. package/lib/langium/naming/name-provider.js.map +1 -1
  151. package/lib/langium/naming/name-separator-validation.d.ts +23 -0
  152. package/lib/langium/naming/name-separator-validation.d.ts.map +1 -1
  153. package/lib/langium/naming/name-separator-validation.js +30 -1
  154. package/lib/langium/naming/name-separator-validation.js.map +1 -1
  155. package/lib/langium/project/abstract-project-manager.d.ts.map +1 -1
  156. package/lib/langium/project/abstract-project-manager.js +9 -2
  157. package/lib/langium/project/abstract-project-manager.js.map +1 -1
  158. package/lib/langium/project/project-change-event.d.ts +2 -2
  159. package/lib/langium/project/single-project-manager.d.ts.map +1 -1
  160. package/lib/langium/project/single-project-manager.js.map +1 -1
  161. package/lib/langium/residency/cst-residency-service.d.ts +31 -11
  162. package/lib/langium/residency/cst-residency-service.d.ts.map +1 -1
  163. package/lib/langium/residency/cst-residency-service.js +23 -57
  164. package/lib/langium/residency/cst-residency-service.js.map +1 -1
  165. package/lib/langium/scope/ast-node-description-provider.d.ts.map +1 -1
  166. package/lib/langium/scope/ast-node-description-provider.js.map +1 -1
  167. package/lib/langium/scope/hydranium-scope-computation.d.ts.map +1 -1
  168. package/lib/langium/scope/hydranium-scope-computation.js.map +1 -1
  169. package/lib/langium/scope/hydranium-scope-provider.d.ts +48 -13
  170. package/lib/langium/scope/hydranium-scope-provider.d.ts.map +1 -1
  171. package/lib/langium/scope/hydranium-scope-provider.js +56 -20
  172. package/lib/langium/scope/hydranium-scope-provider.js.map +1 -1
  173. package/lib/langium/scope/reference-builder.d.ts.map +1 -1
  174. package/lib/langium/scope/reference-builder.js.map +1 -1
  175. package/lib/langium/scope/reference-candidate-provider.d.ts.map +1 -1
  176. package/lib/langium/scope/reference-candidate-provider.js.map +1 -1
  177. package/lib/langium/scope/scope-extension-service.d.ts.map +1 -1
  178. package/lib/langium/scope/scope-extension-service.js.map +1 -1
  179. package/lib/langium/serialization/abstract-serializer.d.ts +11 -20
  180. package/lib/langium/serialization/abstract-serializer.d.ts.map +1 -1
  181. package/lib/langium/serialization/abstract-serializer.js +12 -21
  182. package/lib/langium/serialization/abstract-serializer.js.map +1 -1
  183. package/lib/langium/serialization/json-serializer.d.ts +1 -1
  184. package/lib/langium/serialization/json-serializer.d.ts.map +1 -1
  185. package/lib/langium/serialization/json-serializer.js.map +1 -1
  186. package/lib/langium/serialization/yaml-serializer.d.ts.map +1 -1
  187. package/lib/langium/serialization/yaml-serializer.js.map +1 -1
  188. package/lib/langium/service-registry.d.ts +15 -1
  189. package/lib/langium/service-registry.d.ts.map +1 -1
  190. package/lib/langium/service-registry.js +16 -0
  191. package/lib/langium/service-registry.js.map +1 -1
  192. package/lib/langium/shared-services.d.ts +21 -4
  193. package/lib/langium/shared-services.d.ts.map +1 -1
  194. package/lib/langium/shared-services.js.map +1 -1
  195. package/lib/langium/transfer/transfer-encoder.d.ts +75 -23
  196. package/lib/langium/transfer/transfer-encoder.d.ts.map +1 -1
  197. package/lib/langium/transfer/transfer-encoder.js +44 -21
  198. package/lib/langium/transfer/transfer-encoder.js.map +1 -1
  199. package/lib/langium/trivia/comment-preserver.d.ts +423 -0
  200. package/lib/langium/trivia/comment-preserver.d.ts.map +1 -0
  201. package/lib/langium/trivia/comment-preserver.js +906 -0
  202. package/lib/langium/trivia/comment-preserver.js.map +1 -0
  203. package/lib/langium/trivia/document-ending-preserver.d.ts +43 -0
  204. package/lib/langium/trivia/document-ending-preserver.d.ts.map +1 -0
  205. package/lib/langium/trivia/document-ending-preserver.js +48 -0
  206. package/lib/langium/trivia/document-ending-preserver.js.map +1 -0
  207. package/lib/langium/trivia/index.d.ts +14 -0
  208. package/lib/langium/trivia/index.d.ts.map +1 -0
  209. package/lib/langium/trivia/index.js +14 -0
  210. package/lib/langium/trivia/index.js.map +1 -0
  211. package/lib/langium/trivia/trivia-contribution.d.ts +37 -0
  212. package/lib/langium/trivia/trivia-contribution.d.ts.map +1 -0
  213. package/lib/langium/trivia/trivia-contribution.js +10 -0
  214. package/lib/langium/trivia/trivia-contribution.js.map +1 -0
  215. package/lib/langium/trivia/trivia-preserver.d.ts +50 -0
  216. package/lib/langium/trivia/trivia-preserver.d.ts.map +1 -0
  217. package/lib/langium/trivia/trivia-preserver.js +10 -0
  218. package/lib/langium/trivia/trivia-preserver.js.map +1 -0
  219. package/lib/langium/trivia/trivia-service.d.ts +70 -0
  220. package/lib/langium/trivia/trivia-service.d.ts.map +1 -0
  221. package/lib/langium/trivia/trivia-service.js +56 -0
  222. package/lib/langium/trivia/trivia-service.js.map +1 -0
  223. package/lib/langium/update-rewrite/normalize-empty-strings.d.ts.map +1 -1
  224. package/lib/langium/update-rewrite/update-rewrite-service.d.ts.map +1 -1
  225. package/lib/langium/update-rewrite/update-rewrite-service.js.map +1 -1
  226. package/lib/langium/update-rewrite/update-rewrite.d.ts +1 -1
  227. package/lib/langium/validation/document-validator.d.ts +294 -10
  228. package/lib/langium/validation/document-validator.d.ts.map +1 -1
  229. package/lib/langium/validation/document-validator.js +448 -5
  230. package/lib/langium/validation/document-validator.js.map +1 -1
  231. package/lib/langium/validation/validation-contribution-collector.d.ts.map +1 -1
  232. package/lib/langium/validation/validation-contribution-collector.js.map +1 -1
  233. package/lib/langium/workspace/document-uri-policy.d.ts +17 -12
  234. package/lib/langium/workspace/document-uri-policy.d.ts.map +1 -1
  235. package/lib/langium/workspace/document-uri-policy.js +10 -9
  236. package/lib/langium/workspace/document-uri-policy.js.map +1 -1
  237. package/lib/langium/workspace/file-not-found.d.ts +17 -0
  238. package/lib/langium/workspace/file-not-found.d.ts.map +1 -0
  239. package/lib/langium/workspace/file-not-found.js +20 -0
  240. package/lib/langium/workspace/file-not-found.js.map +1 -0
  241. package/lib/langium/workspace/file-system-provider.d.ts +84 -8
  242. package/lib/langium/workspace/file-system-provider.d.ts.map +1 -1
  243. package/lib/langium/workspace/file-system-provider.js +93 -14
  244. package/lib/langium/workspace/file-system-provider.js.map +1 -1
  245. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts +56 -1
  246. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts.map +1 -1
  247. package/lib/langium/workspace/hydranium-langium-document-factory.js +79 -0
  248. package/lib/langium/workspace/hydranium-langium-document-factory.js.map +1 -1
  249. package/lib/langium/workspace/hydranium-workspace-lock.d.ts +43 -1
  250. package/lib/langium/workspace/hydranium-workspace-lock.d.ts.map +1 -1
  251. package/lib/langium/workspace/hydranium-workspace-lock.js +58 -1
  252. package/lib/langium/workspace/hydranium-workspace-lock.js.map +1 -1
  253. package/lib/langium/workspace/hydranium-workspace-manager.d.ts +81 -8
  254. package/lib/langium/workspace/hydranium-workspace-manager.d.ts.map +1 -1
  255. package/lib/langium/workspace/hydranium-workspace-manager.js +128 -10
  256. package/lib/langium/workspace/hydranium-workspace-manager.js.map +1 -1
  257. package/lib/langium/workspace/in-memory-file-system-provider.d.ts +25 -2
  258. package/lib/langium/workspace/in-memory-file-system-provider.d.ts.map +1 -1
  259. package/lib/langium/workspace/in-memory-file-system-provider.js +41 -15
  260. package/lib/langium/workspace/in-memory-file-system-provider.js.map +1 -1
  261. package/lib/langium/workspace/index-manager.d.ts.map +1 -1
  262. package/lib/langium/workspace/index-manager.js.map +1 -1
  263. package/lib/langium/workspace/initialize-workspace.d.ts +31 -2
  264. package/lib/langium/workspace/initialize-workspace.d.ts.map +1 -1
  265. package/lib/langium/workspace/initialize-workspace.js +13 -5
  266. package/lib/langium/workspace/initialize-workspace.js.map +1 -1
  267. package/lib/langium/workspace/langium-documents.d.ts +86 -17
  268. package/lib/langium/workspace/langium-documents.d.ts.map +1 -1
  269. package/lib/langium/workspace/langium-documents.js +101 -29
  270. package/lib/langium/workspace/langium-documents.js.map +1 -1
  271. package/lib/langium/workspace/persistent-file-system-provider.d.ts +0 -7
  272. package/lib/langium/workspace/persistent-file-system-provider.d.ts.map +1 -1
  273. package/lib/langium/workspace/persistent-file-system-provider.js +0 -7
  274. package/lib/langium/workspace/persistent-file-system-provider.js.map +1 -1
  275. package/lib/langium/workspace/virtual-document.d.ts +48 -15
  276. package/lib/langium/workspace/virtual-document.d.ts.map +1 -1
  277. package/lib/langium/workspace/virtual-document.js +78 -18
  278. package/lib/langium/workspace/virtual-document.js.map +1 -1
  279. package/lib/langium/workspace/write-lock-scope.d.ts +20 -10
  280. package/lib/langium/workspace/write-lock-scope.d.ts.map +1 -1
  281. package/lib/langium/workspace/write-lock-scope.js +12 -4
  282. package/lib/langium/workspace/write-lock-scope.js.map +1 -1
  283. package/lib/locale/index.d.ts +10 -0
  284. package/lib/locale/index.d.ts.map +1 -0
  285. package/lib/locale/index.js +10 -0
  286. package/lib/locale/index.js.map +1 -0
  287. package/lib/locale/server-locale.d.ts +73 -0
  288. package/lib/locale/server-locale.d.ts.map +1 -0
  289. package/lib/locale/server-locale.js +61 -0
  290. package/lib/locale/server-locale.js.map +1 -0
  291. package/lib/lsp/completion/hydranium-completion-provider.d.ts +44 -2
  292. package/lib/lsp/completion/hydranium-completion-provider.d.ts.map +1 -1
  293. package/lib/lsp/completion/hydranium-completion-provider.js +68 -0
  294. package/lib/lsp/completion/hydranium-completion-provider.js.map +1 -1
  295. package/lib/lsp/connection-features.d.ts +23 -0
  296. package/lib/lsp/connection-features.d.ts.map +1 -0
  297. package/lib/lsp/connection-features.js +60 -0
  298. package/lib/lsp/connection-features.js.map +1 -0
  299. package/lib/lsp/diagnostics-connection.d.ts +27 -0
  300. package/lib/lsp/diagnostics-connection.d.ts.map +1 -0
  301. package/lib/lsp/diagnostics-connection.js +53 -0
  302. package/lib/lsp/diagnostics-connection.js.map +1 -0
  303. package/lib/lsp/hydranium-document-update-handler.d.ts +90 -79
  304. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  305. package/lib/lsp/hydranium-document-update-handler.js +153 -99
  306. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  307. package/lib/lsp/index.d.ts +3 -1
  308. package/lib/lsp/index.d.ts.map +1 -1
  309. package/lib/lsp/index.js +3 -1
  310. package/lib/lsp/index.js.map +1 -1
  311. package/lib/lsp/lsp-latency.d.ts +39 -0
  312. package/lib/lsp/lsp-latency.d.ts.map +1 -0
  313. package/lib/lsp/lsp-latency.js +58 -0
  314. package/lib/lsp/lsp-latency.js.map +1 -0
  315. package/lib/lsp/semantic-token-provider.d.ts +34 -1
  316. package/lib/lsp/semantic-token-provider.d.ts.map +1 -1
  317. package/lib/lsp/semantic-token-provider.js +55 -8
  318. package/lib/lsp/semantic-token-provider.js.map +1 -1
  319. package/lib/lsp/shared-module.d.ts +4 -8
  320. package/lib/lsp/shared-module.d.ts.map +1 -1
  321. package/lib/lsp/shared-module.js.map +1 -1
  322. package/lib/lsp/start-language-server.d.ts +8 -7
  323. package/lib/lsp/start-language-server.d.ts.map +1 -1
  324. package/lib/lsp/start-language-server.js +13 -7
  325. package/lib/lsp/start-language-server.js.map +1 -1
  326. package/lib/messages/carriers.d.ts +54 -0
  327. package/lib/messages/carriers.d.ts.map +1 -0
  328. package/lib/messages/carriers.js +62 -0
  329. package/lib/messages/carriers.js.map +1 -0
  330. package/lib/messages/index.d.ts +25 -0
  331. package/lib/messages/index.d.ts.map +1 -0
  332. package/lib/messages/index.js +25 -0
  333. package/lib/messages/index.js.map +1 -0
  334. package/lib/messages/renderer.d.ts +142 -0
  335. package/lib/messages/renderer.d.ts.map +1 -0
  336. package/lib/messages/renderer.js +165 -0
  337. package/lib/messages/renderer.js.map +1 -0
  338. package/lib/node/event-loop-monitor.js.map +1 -1
  339. package/lib/node/heap-ceiling.d.ts +61 -0
  340. package/lib/node/heap-ceiling.d.ts.map +1 -0
  341. package/lib/node/heap-ceiling.js +72 -0
  342. package/lib/node/heap-ceiling.js.map +1 -0
  343. package/lib/node/index.d.ts +1 -0
  344. package/lib/node/index.d.ts.map +1 -1
  345. package/lib/node/index.js +1 -0
  346. package/lib/node/index.js.map +1 -1
  347. package/lib/node/latency-from-env.d.ts +1 -1
  348. package/lib/node/latency-from-env.js +1 -1
  349. package/lib/node/lint-grammar.js.map +1 -1
  350. package/lib/node/measure-memory.d.ts.map +1 -1
  351. package/lib/node/measure-memory.js +1 -0
  352. package/lib/node/measure-memory.js.map +1 -1
  353. package/lib/node/memory-monitor.js.map +1 -1
  354. package/lib/node/node-file-system-provider.d.ts +39 -4
  355. package/lib/node/node-file-system-provider.d.ts.map +1 -1
  356. package/lib/node/node-file-system-provider.js +109 -49
  357. package/lib/node/node-file-system-provider.js.map +1 -1
  358. package/lib/node/process-memory.js.map +1 -1
  359. package/lib/node/profile-capture.d.ts +8 -2
  360. package/lib/node/profile-capture.d.ts.map +1 -1
  361. package/lib/node/profile-capture.js.map +1 -1
  362. package/lib/node/profile-digest.js.map +1 -1
  363. package/lib/node/profiling-run.d.ts +13 -3
  364. package/lib/node/profiling-run.d.ts.map +1 -1
  365. package/lib/node/profiling-run.js +17 -8
  366. package/lib/node/profiling-run.js.map +1 -1
  367. package/lib/node/rename-over-open-readers.d.ts +52 -0
  368. package/lib/node/rename-over-open-readers.d.ts.map +1 -0
  369. package/lib/node/rename-over-open-readers.js +66 -0
  370. package/lib/node/rename-over-open-readers.js.map +1 -0
  371. package/lib/node/socket-launcher.d.ts +16 -1
  372. package/lib/node/socket-launcher.d.ts.map +1 -1
  373. package/lib/node/socket-launcher.js +4 -3
  374. package/lib/node/socket-launcher.js.map +1 -1
  375. package/lib/node/stdio-launcher.d.ts +1 -1
  376. package/lib/node/stdio-launcher.d.ts.map +1 -1
  377. package/lib/node/stdio-launcher.js +1 -1
  378. package/lib/node/stdio-launcher.js.map +1 -1
  379. package/lib/testing/document-uri-policy-conformance.js.map +1 -1
  380. package/lib/testing/fake-description.js.map +1 -1
  381. package/lib/testing/fake-document.d.ts +16 -4
  382. package/lib/testing/fake-document.d.ts.map +1 -1
  383. package/lib/testing/fake-document.js +18 -3
  384. package/lib/testing/fake-document.js.map +1 -1
  385. package/lib/testing/index.d.ts +1 -1
  386. package/lib/testing/index.d.ts.map +1 -1
  387. package/lib/testing/index.js +2 -1
  388. package/lib/testing/index.js.map +1 -1
  389. package/lib/testing/langium-test-helpers.d.ts.map +1 -1
  390. package/lib/testing/langium-test-helpers.js +1 -0
  391. package/lib/testing/langium-test-helpers.js.map +1 -1
  392. package/lib/testing/make-noop-language-services.js.map +1 -1
  393. package/lib/testing/make-noop-shared-services.d.ts +14 -0
  394. package/lib/testing/make-noop-shared-services.d.ts.map +1 -1
  395. package/lib/testing/make-noop-shared-services.js +28 -2
  396. package/lib/testing/make-noop-shared-services.js.map +1 -1
  397. package/lib/testing/make-test-services.d.ts +69 -14
  398. package/lib/testing/make-test-services.d.ts.map +1 -1
  399. package/lib/testing/make-test-services.js +39 -10
  400. package/lib/testing/make-test-services.js.map +1 -1
  401. package/lib/testing/make-test-tracer.js.map +1 -1
  402. package/lib/testing/node/golden-corpus.js.map +1 -1
  403. package/lib/testing/node/index.d.ts +1 -0
  404. package/lib/testing/node/index.d.ts.map +1 -1
  405. package/lib/testing/node/index.js +1 -0
  406. package/lib/testing/node/index.js.map +1 -1
  407. package/lib/testing/node/lsp-harness.d.ts.map +1 -1
  408. package/lib/testing/node/lsp-harness.js +2 -0
  409. package/lib/testing/node/lsp-harness.js.map +1 -1
  410. package/lib/testing/node/lsp-server-connection.d.ts +11 -3
  411. package/lib/testing/node/lsp-server-connection.d.ts.map +1 -1
  412. package/lib/testing/node/lsp-server-connection.js +42 -9
  413. package/lib/testing/node/lsp-server-connection.js.map +1 -1
  414. package/lib/testing/node/scratch-workspace.d.ts.map +1 -1
  415. package/lib/testing/node/scratch-workspace.js +10 -5
  416. package/lib/testing/node/scratch-workspace.js.map +1 -1
  417. package/lib/testing/node/spawned-server.d.ts +14 -0
  418. package/lib/testing/node/spawned-server.d.ts.map +1 -1
  419. package/lib/testing/node/spawned-server.js +33 -1
  420. package/lib/testing/node/spawned-server.js.map +1 -1
  421. package/lib/testing/node/unhandled-rejections.d.ts +20 -0
  422. package/lib/testing/node/unhandled-rejections.d.ts.map +1 -0
  423. package/lib/testing/node/unhandled-rejections.js +32 -0
  424. package/lib/testing/node/unhandled-rejections.js.map +1 -0
  425. package/lib/testing/playwright/browser-capture-bridge.js.map +1 -1
  426. package/lib/testing/playwright/e2e-profiling.js.map +1 -1
  427. package/lib/testing/playwright/flaky-network-proxy.d.ts +76 -0
  428. package/lib/testing/playwright/flaky-network-proxy.d.ts.map +1 -0
  429. package/lib/testing/playwright/flaky-network-proxy.js +164 -0
  430. package/lib/testing/playwright/flaky-network-proxy.js.map +1 -0
  431. package/lib/testing/playwright/index.d.ts +1 -0
  432. package/lib/testing/playwright/index.d.ts.map +1 -1
  433. package/lib/testing/playwright/index.js +1 -0
  434. package/lib/testing/playwright/index.js.map +1 -1
  435. package/lib/testing/playwright/server-log-capture.d.ts +24 -8
  436. package/lib/testing/playwright/server-log-capture.d.ts.map +1 -1
  437. package/lib/testing/playwright/server-log-capture.js +27 -6
  438. package/lib/testing/playwright/server-log-capture.js.map +1 -1
  439. package/lib/testing/playwright/server-log-rename-reporter.d.ts.map +1 -1
  440. package/lib/testing/playwright/server-log-rename-reporter.js +10 -1
  441. package/lib/testing/playwright/server-log-rename-reporter.js.map +1 -1
  442. package/lib/testing/run-update-pipeline.d.ts +4 -4
  443. package/lib/testing/run-update-pipeline.d.ts.map +1 -1
  444. package/lib/testing/stub-ast-document-manager.d.ts +11 -9
  445. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  446. package/lib/testing/stub-ast-document-manager.js +23 -9
  447. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  448. package/lib/testing/stub-document-builder.d.ts +26 -11
  449. package/lib/testing/stub-document-builder.d.ts.map +1 -1
  450. package/lib/testing/stub-document-builder.js +57 -4
  451. package/lib/testing/stub-document-builder.js.map +1 -1
  452. package/lib/testing/stub-hydranium-text-documents.d.ts +22 -7
  453. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  454. package/lib/testing/stub-hydranium-text-documents.js +97 -15
  455. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  456. package/lib/testing/stub-index-manager.js.map +1 -1
  457. package/lib/testing/stub-langium-documents.d.ts +3 -2
  458. package/lib/testing/stub-langium-documents.d.ts.map +1 -1
  459. package/lib/testing/stub-langium-documents.js.map +1 -1
  460. package/lib/testing/stub-model-service.d.ts +7 -6
  461. package/lib/testing/stub-model-service.d.ts.map +1 -1
  462. package/lib/testing/stub-model-service.js +4 -4
  463. package/lib/testing/stub-model-service.js.map +1 -1
  464. package/lib/testing/stub-project-manager.js.map +1 -1
  465. package/lib/testing/stub-service-registry.js.map +1 -1
  466. package/lib/util/connection-liveness.d.ts +7 -6
  467. package/lib/util/connection-liveness.d.ts.map +1 -1
  468. package/lib/util/connection-liveness.js +35 -14
  469. package/lib/util/connection-liveness.js.map +1 -1
  470. package/lib/util/environment.d.ts.map +1 -1
  471. package/lib/util/environment.js.map +1 -1
  472. package/lib/util/registry.d.ts.map +1 -1
  473. package/package.json +35 -26
  474. package/src/documents/ast-document-manager.ts +377 -125
  475. package/src/documents/client-ids.ts +5 -22
  476. package/src/documents/client-session-errors.ts +24 -0
  477. package/src/documents/client-session-registry.ts +240 -0
  478. package/src/documents/file-system-task-queue.ts +68 -0
  479. package/src/documents/hydranium-text-documents.ts +1175 -225
  480. package/src/documents/index.ts +5 -0
  481. package/src/documents/language-client-text-shadow.ts +59 -11
  482. package/src/documents/model-ledger.ts +66 -0
  483. package/src/documents/self-save-registry.ts +52 -13
  484. package/src/documents/version-sync-service.ts +267 -0
  485. package/src/index.ts +6 -0
  486. package/src/langium/ast-extension/ast-node-builder.ts +66 -22
  487. package/src/langium/bootstrap.ts +32 -9
  488. package/src/langium/config/configuration-provider.ts +119 -0
  489. package/src/langium/config/index.ts +1 -0
  490. package/src/langium/diagnostics/logger.ts +12 -5
  491. package/src/langium/diagnostics/lsp-logger.ts +58 -12
  492. package/src/langium/document-builder/build-pipeline-integration.ts +50 -5
  493. package/src/langium/document-builder/build-session.ts +87 -0
  494. package/src/langium/document-builder/document-builder.ts +776 -59
  495. package/src/langium/document-builder/index.ts +1 -0
  496. package/src/langium/integration-services.ts +67 -6
  497. package/src/langium/integrity/integrity-rule.ts +13 -5
  498. package/src/langium/integrity/integrity-service.ts +220 -30
  499. package/src/langium/keys/containment.ts +87 -0
  500. package/src/langium/keys/index.ts +1 -0
  501. package/src/langium/keys/name-based-key-provider.ts +4 -0
  502. package/src/langium/language-module.ts +42 -4
  503. package/src/langium/model-service/client-session.ts +614 -0
  504. package/src/langium/model-service/index.ts +1 -0
  505. package/src/langium/model-service/model-service.ts +570 -433
  506. package/src/langium/module.ts +201 -64
  507. package/src/langium/naming/name-provider.ts +27 -5
  508. package/src/langium/naming/name-separator-validation.ts +34 -5
  509. package/src/langium/project/abstract-project-manager.ts +9 -1
  510. package/src/langium/project/project-change-event.ts +2 -2
  511. package/src/langium/residency/cst-residency-service.ts +46 -15
  512. package/src/langium/scope/hydranium-scope-provider.ts +60 -22
  513. package/src/langium/serialization/abstract-serializer.ts +13 -32
  514. package/src/langium/service-registry.ts +19 -2
  515. package/src/langium/shared-services.ts +25 -4
  516. package/src/langium/transfer/transfer-encoder.ts +102 -34
  517. package/src/langium/trivia/comment-preserver.ts +1100 -0
  518. package/src/langium/trivia/document-ending-preserver.ts +55 -0
  519. package/src/langium/trivia/index.ts +14 -0
  520. package/src/langium/trivia/trivia-contribution.ts +39 -0
  521. package/src/langium/trivia/trivia-preserver.ts +54 -0
  522. package/src/langium/trivia/trivia-service.ts +99 -0
  523. package/src/langium/update-rewrite/update-rewrite.ts +1 -1
  524. package/src/langium/validation/document-validator.ts +537 -11
  525. package/src/langium/workspace/document-uri-policy.ts +17 -12
  526. package/src/langium/workspace/file-not-found.ts +23 -0
  527. package/src/langium/workspace/file-system-provider.ts +153 -16
  528. package/src/langium/workspace/hydranium-langium-document-factory.ts +117 -0
  529. package/src/langium/workspace/hydranium-workspace-lock.ts +86 -2
  530. package/src/langium/workspace/hydranium-workspace-manager.ts +153 -13
  531. package/src/langium/workspace/in-memory-file-system-provider.ts +47 -16
  532. package/src/langium/workspace/initialize-workspace.ts +43 -5
  533. package/src/langium/workspace/langium-documents.ts +124 -33
  534. package/src/langium/workspace/persistent-file-system-provider.ts +0 -7
  535. package/src/langium/workspace/virtual-document.ts +96 -19
  536. package/src/langium/workspace/write-lock-scope.ts +24 -11
  537. package/src/locale/index.ts +10 -0
  538. package/src/locale/server-locale.ts +86 -0
  539. package/src/lsp/completion/hydranium-completion-provider.ts +82 -2
  540. package/src/lsp/connection-features.ts +67 -0
  541. package/src/lsp/diagnostics-connection.ts +56 -0
  542. package/src/lsp/hydranium-document-update-handler.ts +185 -106
  543. package/src/lsp/index.ts +3 -1
  544. package/src/lsp/lsp-latency.ts +62 -0
  545. package/src/lsp/semantic-token-provider.ts +74 -7
  546. package/src/lsp/shared-module.ts +4 -8
  547. package/src/lsp/start-language-server.ts +13 -7
  548. package/src/messages/carriers.ts +78 -0
  549. package/src/messages/index.ts +36 -0
  550. package/src/messages/renderer.ts +212 -0
  551. package/src/node/heap-ceiling.ts +108 -0
  552. package/src/node/index.ts +1 -0
  553. package/src/node/latency-from-env.ts +1 -1
  554. package/src/node/measure-memory.ts +1 -0
  555. package/src/node/node-file-system-provider.ts +122 -51
  556. package/src/node/profile-capture.ts +8 -2
  557. package/src/node/profiling-run.ts +30 -8
  558. package/src/node/rename-over-open-readers.ts +78 -0
  559. package/src/node/socket-launcher.ts +19 -4
  560. package/src/node/stdio-launcher.ts +1 -2
  561. package/src/testing/fake-document.ts +24 -5
  562. package/src/testing/index.ts +2 -1
  563. package/src/testing/langium-test-helpers.ts +1 -0
  564. package/src/testing/make-noop-shared-services.ts +50 -2
  565. package/src/testing/make-test-services.ts +120 -31
  566. package/src/testing/node/index.ts +1 -0
  567. package/src/testing/node/lsp-harness.ts +2 -0
  568. package/src/testing/node/lsp-server-connection.ts +61 -11
  569. package/src/testing/node/scratch-workspace.ts +10 -5
  570. package/src/testing/node/spawned-server.ts +35 -1
  571. package/src/testing/node/unhandled-rejections.ts +31 -0
  572. package/src/testing/playwright/flaky-network-proxy.ts +240 -0
  573. package/src/testing/playwright/index.ts +1 -0
  574. package/src/testing/playwright/server-log-capture.ts +45 -11
  575. package/src/testing/playwright/server-log-rename-reporter.ts +12 -1
  576. package/src/testing/run-update-pipeline.ts +4 -4
  577. package/src/testing/stub-ast-document-manager.ts +37 -22
  578. package/src/testing/stub-document-builder.ts +83 -17
  579. package/src/testing/stub-hydranium-text-documents.ts +133 -23
  580. package/src/testing/stub-langium-documents.ts +3 -2
  581. package/src/testing/stub-model-service.ts +9 -8
  582. package/src/util/connection-liveness.ts +37 -14
  583. package/src/util/environment.ts +9 -1
  584. package/lib/lsp/instrument-connection.d.ts +0 -36
  585. package/lib/lsp/instrument-connection.d.ts.map +0 -1
  586. package/lib/lsp/instrument-connection.js +0 -62
  587. package/lib/lsp/instrument-connection.js.map +0 -1
  588. 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,25 @@ 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
+ type TextState,
56
+ type TextVersion,
57
+ textHash,
58
+ type Tracer,
59
+ STALE_VERSION,
60
+ UNRECORDED_VERSION
61
+ } from '@hydranium/protocol';
49
62
  import { type LogNameOptions } from '../langium/diagnostics/logger.js';
63
+ import { HYDRANIUM_BUILD_REASONS } from '../langium/document-builder/document-builder.js';
64
+ import { isConnectionGoneError } from '../util/connection-liveness.js';
50
65
  import { LANGUAGE_CLIENT_ID } from './client-ids.js';
66
+ import { INTEGRITY_CLIENT_ID } from '../langium/integrity/integrity-rule.js';
67
+ import { type ClientSessionClosedEvent, ClientSessionRegistry, type SessionEndCause } from './client-session-registry.js';
51
68
  import { isFullReplace, LanguageClientTextShadow } from './language-client-text-shadow.js';
52
69
 
53
70
  /**
@@ -74,6 +91,67 @@ export interface HydraniumTextDocumentsOptions<T extends TextDocument = TextDocu
74
91
  * Adopters with a custom text-document type (rare) pass their factory.
75
92
  */
76
93
  readonly configuration?: TextDocumentsConfiguration<T>;
94
+ /**
95
+ * How long a document whose last open closed because its client's
96
+ * connection was lost keeps its text before it reverts to disk. An open by
97
+ * a client lost from the document, within this time of its own loss,
98
+ * cancels the revert, so a client that registers again under its id after
99
+ * a dropped connection finds its unsaved edits. Any other open releases the
100
+ * document first and then opens it as a first open does, from the text the
101
+ * opener supplies (an editor's own) or else from the file. Meanwhile the
102
+ * document counts as open for the integrity service, which therefore writes
103
+ * none of its unsaved text to disk. A close the client makes itself, or
104
+ * ending its session, reverts at once whatever this is.
105
+ *
106
+ * Defaults to 10 s. `0` reverts such a document at once as well, released
107
+ * in the close itself rather than on a timer.
108
+ */
109
+ readonly revertGraceMs?: number;
110
+ }
111
+
112
+ /**
113
+ * The default of {@link HydraniumTextDocumentsOptions.revertGraceMs}: long
114
+ * enough for a client whose connection dropped to register again and reopen
115
+ * its documents, which a data client does as soon as it has a connection.
116
+ */
117
+ const DEFAULT_REVERT_GRACE_MS = 10_000;
118
+
119
+ /** Delivered by {@link HydraniumTextDocuments.onDidSaveInLanguageClient}. */
120
+ export interface LanguageClientSavedEvent {
121
+ readonly uri: string;
122
+ }
123
+
124
+ /** Delivered by {@link HydraniumTextDocuments.onDidCloseLastOpen}. */
125
+ export interface LastOpenClosedEvent {
126
+ readonly uri: CanonicalUri;
127
+ }
128
+
129
+ /** Delivered by {@link HydraniumTextDocuments.onDidChangeDirty}. */
130
+ export interface DocumentDirtyChangedEvent {
131
+ readonly uri: CanonicalUri;
132
+ /**
133
+ * The text the new answer of {@link HydraniumTextDocuments.isDirty} was
134
+ * decided on. The answer changes with the text, before any build, so it can
135
+ * name text whose model has not been sent yet. Absent when the document no
136
+ * longer exists, or when the build that follows its release failed.
137
+ */
138
+ readonly text?: TextState;
139
+ }
140
+
141
+ /**
142
+ * The language client's open of a document under one URI, from its didOpen to
143
+ * its didClose. Each URI is its own editor buffer with its own version counter,
144
+ * so a file reached through a symlink and its real path has one of these each.
145
+ */
146
+ export interface LanguageClientDocumentState {
147
+ /** The version the client last declared for this URI. */
148
+ declaredVersion: number;
149
+ /**
150
+ * The version an applied versioned push moved this URI to, ahead of its
151
+ * echo. Apart from `declaredVersion`, whose staleness guard would drop that
152
+ * echo and strand its pending push.
153
+ */
154
+ pushedVersion?: number;
77
155
  }
78
156
 
79
157
  /**
@@ -89,32 +167,60 @@ export interface HydraniumTextDocumentsOptions<T extends TextDocument = TextDocu
89
167
  * separately-tested diff/apply-verify abstraction that owns its own baseline
90
168
  * text; folding its storage here would couple a clean utility to this record
91
169
  * for no real gain.
170
+ *
171
+ * Returned by the `protected` {@link HydraniumTextDocuments.trackingFor}, so an
172
+ * override has to name it. Restating the shape structurally instead compiles
173
+ * until a field is added here, and then fails at the adopter rather than at the
174
+ * change that caused it.
92
175
  */
93
- interface DocumentTracking {
94
- /** Client ids currently holding this document open (multi-client membership). */
95
- readonly clients: Set<string>;
176
+ export interface DocumentTrackingRecord {
96
177
  /** Author of each version, sparse-indexed by the SHARED (server-assigned) version number. */
97
178
  readonly versionAuthors: string[];
98
179
  /**
99
180
  * Last version id each client declared for this document (didOpen baseline,
100
- * advanced by every accepted didChange). Client version ids are CLIENT-owned
101
- * per LSP (Monaco numbers its own buffer) and are used ONLY for this
102
- * per-client staleness guard — they never leak into the shared version
103
- * sequence, which the server assigns (see {@link HydraniumTextDocuments.__versionSequences}).
181
+ * advanced by every accepted didChange), for the per-client staleness guard.
182
+ * Client version ids are CLIENT-owned per LSP (Monaco numbers its own
183
+ * buffer) — they never leak into the shared version sequence, which the
184
+ * server assigns (see {@link HydraniumTextDocuments.__versionSequences}).
185
+ * The language client's entry is the latest any of its URIs declared; the
186
+ * store checks and addresses it per URI, through
187
+ * {@link DocumentTrackingRecord.languageClientDocuments}.
104
188
  */
105
189
  readonly clientVersions: Map<string, number>;
106
190
  /** Content staged by integrity rules for a closed document. Consumed on next open. */
107
191
  pendingContent?: string;
108
192
  /**
109
- * The URI(s) the LSP textual language client opened this (canonically-keyed)
110
- * document under. Usually one; a set because the same physical file can be
111
- * opened under more than one URI (a symlink path and its real path). This is
112
- * the egress address: `applyEditToLanguageClient`
113
- * targets these, since the document is *keyed* by its canonical identity but
114
- * Monaco holds it under the URI it opened. Lifecycle-bound — cleared with the
115
- * record on last close.
193
+ * Each URI the LSP textual language client opened this (canonically-keyed)
194
+ * document under, with that open's state. Usually one; more when the same
195
+ * file is opened under a symlink path and its real path. These are the
196
+ * egress addresses: the document is *keyed* by its canonical identity, but
197
+ * Monaco holds it under the URI it opened. The language client holds the
198
+ * document while any entry remains.
199
+ */
200
+ languageClientDocuments?: Map<LanguageClientUri, LanguageClientDocumentState>;
201
+ /**
202
+ * The clients whose close of this document was caused by a lost connection
203
+ * and that have not opened it again, each with a stopwatch started at its
204
+ * loss on the store's `Clock`. While the document waits out the revert grace, only an
205
+ * open by one of them within its own
206
+ * {@link HydraniumTextDocumentsOptions.revertGraceMs} of that loss cancels
207
+ * the revert. Every such client counts, not only the last to close: one
208
+ * connection's sessions all end lost together, and any of them may reopen
209
+ * first. Without the time limit, a client that returns late inherits the
210
+ * unsaved text of a holder lost after it. A stopwatch rather than a `now()`
211
+ * reading, because a wall-clock step would otherwise expire a claim early or
212
+ * revive one past its grace, against a grace timer that the step leaves
213
+ * alone. An entry past its grace counts as any other client's and is pruned
214
+ * at the next open of the document.
116
215
  */
117
- languageClientUris?: Set<LanguageClientUri>;
216
+ lostClients?: Map<string, Stopwatch>;
217
+ /**
218
+ * The text the server last knew the file to hold, `undefined` for no file.
219
+ * Set by the first open and moved by {@link HydraniumTextDocuments.updateDiskBaseline}.
220
+ */
221
+ diskBaseline?: string;
222
+ /** The last answer {@link HydraniumTextDocuments.onDidChangeDirty} announced. */
223
+ dirty?: boolean;
118
224
  }
119
225
 
120
226
  /**
@@ -129,15 +235,33 @@ export interface OpenDocument {
129
235
  }
130
236
 
131
237
  /**
132
- * Where a URI's shared version sequence left off — written once at last-client
133
- * close, consulted at the next open so the sequence CONTINUES instead of
134
- * restarting at whatever version id the reopening client declares. One entry
135
- * of {@link HydraniumTextDocuments.__versionSequences}.
238
+ * What {@link HydraniumTextDocuments.commitRepair} did with an integrity repair.
239
+ *
240
+ * Three outcomes rather than a document-or-nothing, because the two ways of
241
+ * getting nothing call for opposite responses: `not-open` means there is no
242
+ * store document to correct and the caller carries on with the one it holds,
243
+ * while `stale` means there IS one and it has moved past the text the repair
244
+ * was computed against — so carrying on would persist a correction over an edit
245
+ * that superseded it. Collapsing them lets the second read as permission to
246
+ * proceed, which is the failure this shape exists to make unrepresentable.
247
+ */
248
+ export type RepairCommit<T extends TextDocument> =
249
+ | { readonly status: 'committed'; readonly document: T }
250
+ | { readonly status: 'stale' }
251
+ | { readonly status: 'not-open' };
252
+
253
+ /**
254
+ * Where a URI's shared version sequence left off while no client holds it —
255
+ * started by its first build, stepped by builds of changed text, written at
256
+ * last-client close, and consulted at the next open so the sequence CONTINUES
257
+ * instead of restarting at whatever version id the opening client declares. One entry
258
+ * of {@link HydraniumTextDocuments.__versionSequences} — a `protected` field, so
259
+ * a subclass reading the map has to name what it holds.
136
260
  */
137
- interface VersionSequence {
138
- /** The shared version at last-client close. */
261
+ export interface VersionSequence {
262
+ /** The shared version of the text last closed or built. */
139
263
  readonly version: number;
140
- /** {@link contentHash} of the synced text at last-client close. */
264
+ /** {@link textHash} of that text. */
141
265
  readonly contentHash: string;
142
266
  }
143
267
 
@@ -161,76 +285,82 @@ interface ConnectionWithTextDocumentSync {
161
285
  /**
162
286
  * One text pushed to the LSP textual language client by
163
287
  * {@link HydraniumTextDocuments.applyEditToLanguageClient} whose echo has not
164
- * come back yet. One entry of {@link HydraniumTextDocuments.__pendingPushes}.
288
+ * come back yet. One entry of {@link HydraniumTextDocuments.__pendingPushes} —
289
+ * a `protected` field, so a subclass reading the queue has to name what it holds.
165
290
  */
166
- interface PendingPush {
291
+ export interface PendingLanguageClientPush {
167
292
  /**
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.
293
+ * The text the client held BEFORE this push, and therefore the text its
294
+ * echo addresses with its ranges. Wider than the baseline the push's edits
295
+ * were diffed against: a full replace is sent with no diff baseline and
296
+ * still lands on a buffer the echo is keyed to.
171
297
  *
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.
298
+ * `undefined` only when that buffer is unknown — the client never declared
299
+ * one, or a rejection invalidated what was tracked. The echo is then
300
+ * reconstructed against the synced text, which is sound only for a
301
+ * position-independent (full-text) change.
177
302
  */
178
303
  readonly before: string | undefined;
179
- /** {@link contentHash} of the text this push moves the client to. */
304
+ /** {@link textHash} of the text this push moves the client to. */
180
305
  readonly afterHash: string;
181
306
  }
182
307
 
183
308
  /**
184
309
  * 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}.
310
+ * against the buffer its ranges address — the return of
311
+ * {@link HydraniumTextDocuments.classifyLanguageClientChange}. That method is
312
+ * `protected`, so an override has to name every arm it can return.
187
313
  */
188
- type LanguageClientChangeOrigin =
314
+ export type LanguageClientChangeOrigin =
189
315
  /** The client is reporting a text we pushed it. The synced document is already there. */
190
316
  | { readonly kind: 'echo' }
191
317
  /**
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.
318
+ * The client's buffer holds a text we did not push it — a keystroke that
319
+ * raced a push, or an edit to a buffer the store has already been written
320
+ * past. The reconstructed text is what it now holds, and is authoritative.
321
+ */
322
+ | { readonly kind: 'divergent'; readonly text: string }
323
+ /**
324
+ * The change carries ranges and no known text addresses them, so no
325
+ * reconstruction is offered. Adopting one anyway splices the document and
326
+ * stores an edit nobody made; dropping costs at most the one keystroke the
327
+ * client still holds and the next push contradicts.
195
328
  */
196
- | { readonly kind: 'divergent'; readonly text: string };
329
+ | { readonly kind: 'unreconstructable' };
197
330
 
198
331
  /**
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).
332
+ * Whether `err` reports that the file of `target` does not exist, as a Node
333
+ * file system and the framework's providers do: code `ENOENT`, with `target`'s
334
+ * path. A missing file of another document is a different failure.
205
335
  */
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}`;
336
+ function isFileNotFound(err: unknown, target: URI): boolean {
337
+ if (typeof err !== 'object' || err === null || !('code' in err) || err.code !== 'ENOENT') {
338
+ return false;
339
+ }
340
+ return !('path' in err) || err.path === target.fsPath;
219
341
  }
220
342
 
221
343
  /**
222
344
  * Multi-client text-document tracking on top of Langium's `NormalizedTextDocuments`.
223
345
  *
224
346
  * 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).
347
+ * - Per-document client membership (multiple clients can attach to the same
348
+ * URI) and the client-session table, both kept by {@link __sessions}.
226
349
  * - A SERVER-OWNED shared version sequence: per-URI, monotonic across
227
350
  * close/reopen cycles, advancing exactly when the synced content changes.
228
351
  * Client-declared version ids (Monaco's buffer numbering) feed only a
229
- * per-client staleness guard and never leak into the shared sequence —
352
+ * per-client staleness guard and never leak into a running sequence —
230
353
  * the two are different things (an editor's edit-operation counter vs the
231
354
  * document's content-revision number), and splicing them lets versions drift
232
- * silently past `baseVersion` gate holders.
355
+ * silently past base-version gate holders. A URI with no sequence, and no
356
+ * root that records a version, starts at its opener's declared id: no version
357
+ * was handed out for it.
233
358
  * - Version-author history so each edit is attributable to its originating client.
359
+ * - The revert to disk once no client has a document open, for every head
360
+ * ({@link revertToDisk}), deferred by
361
+ * {@link HydraniumTextDocumentsOptions.revertGraceMs} after a lost connection.
362
+ * - A disk baseline per open document, and whether its text differs from it
363
+ * ({@link isDirty}).
234
364
  * - Pending-content staging used by the integrity service to thread corrections
235
365
  * through `workspace/applyEdit` cycles for currently-closed documents.
236
366
  * - `didOpen` notifications arriving over the LSP connection wait on the
@@ -240,36 +370,56 @@ function contentHash(text: string): string {
240
370
  */
241
371
  export class HydraniumTextDocuments<T extends TextDocument = TextDocument> extends NormalizedTextDocuments<T> {
242
372
  /**
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`.
373
+ * Per-URI client-facing tracking ({@link DocumentTrackingRecord}), keyed by
374
+ * canonical URI. One record per URI, so dropping it clears every axis at
375
+ * once. Which client has the document open is kept apart, in
376
+ * {@link __sessions}.
249
377
  */
250
- protected __documents = new Map<CanonicalUri, DocumentTracking>();
378
+ protected __documents = new Map<CanonicalUri, DocumentTrackingRecord>();
379
+
380
+ /**
381
+ * Which client has which document open, and which client ids are registered
382
+ * sessions. Every open-state predicate on this class reads it, so an open
383
+ * recorded anywhere else is invisible to the last-close transition.
384
+ */
385
+ protected readonly __sessions = new ClientSessionRegistry();
251
386
 
252
387
  /**
253
388
  * Per-URI shared-version continuity across close/reopen cycles
254
- * ({@link VersionSequence}), written at last-client close and consulted by
255
- * the next first-client open. DELIBERATELY outside {@link DocumentTracking}:
256
- * that record is deleted on last close, while the version sequence must
389
+ * ({@link VersionSequence}), kept for every document a build or a client
390
+ * gave the store, and consulted by the next first-client open. A URI
391
+ * with none, and no root that records a version, starts at its opener's
392
+ * declared version.
393
+ * DELIBERATELY outside {@link DocumentTrackingRecord}: that record is deleted on last close, while the version sequence must
257
394
  * survive it — the shared version is a server-owned, monotonic,
258
395
  * advances-iff-content-changes counter that never resets while the server
259
- * lives. That invariant is what makes an optimistic `baseVersion` gate
396
+ * lives. That invariant is what makes an optimistic base-version gate
260
397
  * sound: "version unchanged ⇔ content unchanged", with no false conflicts
261
398
  * from close/reopen version resets and no false passes from a reopened
262
399
  * sequence coincidentally landing on a stale writer's number.
263
400
  *
264
- * Never pruned: two small values per URI ever touched — bounded by
265
- * workspace size, not by activity.
401
+ * Never pruned, not even when the file is deleted: a recreated file
402
+ * restarting at `0` would let a write based on the deleted text pass. Two
403
+ * small values per URI ever built, however often it changes.
266
404
  */
267
405
  protected readonly __versionSequences = new Map<CanonicalUri, VersionSequence>();
268
406
 
407
+ /**
408
+ * Released documents last announced dirty. Their clean flip waits for the
409
+ * revert, so it carries the reverted text's version; a first open before the
410
+ * revert takes the entry over, and its own dirty answer decides the flip.
411
+ * Each release enters a token of its own, so the revert of an earlier
412
+ * release cannot announce a later one clean before that one's revert.
413
+ */
414
+ protected readonly __releasedDirty = new Map<CanonicalUri, object>();
415
+
416
+ /** Per held document, the {@link textHash} of its text at the version it was taken. */
417
+ protected readonly __textHashes = new WeakMap<TextDocument, { readonly version: number; readonly hash: string }>();
418
+
269
419
  /**
270
420
  * Texts pushed to the LSP textual language client via
271
421
  * {@link applyEditToLanguageClient} whose echoes have not come back yet
272
- * ({@link PendingPush}), keyed like the shadow by language-client URI.
422
+ * ({@link PendingLanguageClientPush}), keyed like the shadow by language-client URI.
273
423
  * Outbound pushes and inbound echoes are uncorrelated on the wire; this
274
424
  * FIFO is the explicit correlation, and it is what
275
425
  * {@link classifyLanguageClientChange} reconstructs against.
@@ -291,7 +441,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
291
441
  * a pathological echo that never arrives; the memory cost until then is
292
442
  * one pre-push text per in-flight push, for milliseconds.
293
443
  */
294
- protected readonly __pendingPushes = new Map<LanguageClientUri, PendingPush[]>();
444
+ protected readonly __pendingPushes = new Map<LanguageClientUri, PendingLanguageClientPush[]>();
295
445
 
296
446
  /**
297
447
  * Tracked text content per URI for the LSP textual language client (Monaco / VS Code).
@@ -316,6 +466,11 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
316
466
 
317
467
  protected readonly tracer: Tracer;
318
468
  protected readonly configuration: TextDocumentsConfiguration<T>;
469
+ /** See {@link HydraniumTextDocumentsOptions.revertGraceMs}. */
470
+ protected readonly revertGraceMs: number;
471
+ protected readonly lastOpenClosedEmitter = new Emitter<LastOpenClosedEvent>();
472
+ protected readonly languageClientSavedEmitter = new Emitter<LanguageClientSavedEvent>();
473
+ protected readonly dirtyChangedEmitter = new Emitter<DocumentDirtyChangedEvent>();
319
474
 
320
475
  constructor(
321
476
  protected services: ServerSharedServices,
@@ -329,6 +484,8 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
329
484
  (uri, reason) => this.tracer.with(uri).warn(`Diff apply-verify fallback (${reason}) — using full-document replace`),
330
485
  this
331
486
  );
487
+ this.revertGraceMs = options.revertGraceMs ?? DEFAULT_REVERT_GRACE_MS;
488
+ this.onDidCloseLastOpen(event => void this.revertToDisk(event.uri));
332
489
  }
333
490
 
334
491
  // Re-exposed configuration factories — for framework-internal callers
@@ -403,7 +560,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
403
560
  const disposables = new DisposableCollection();
404
561
  disposables.push(
405
562
  connection.onDidOpenTextDocument(async (event: DidOpenTextDocumentParams) => {
406
- await this.services.workspace.WorkspaceManager.workspaceInitialized;
563
+ await this.initialBuildFinished();
407
564
  this.notifyDidOpenTextDocument(event);
408
565
  })
409
566
  );
@@ -429,7 +586,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
429
586
  );
430
587
  disposables.push(
431
588
  connection.onDidSaveTextDocument((event: DidSaveTextDocumentParams) => {
432
- this.notifyDidSaveTextDocument(event);
589
+ void this.notifyLanguageClientSave(event);
433
590
  })
434
591
  );
435
592
  return disposables;
@@ -458,28 +615,35 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
458
615
  // client knowing). Gating on the shared version drops real edits in
459
616
  // exactly that lag window. A client with no baseline (never opened —
460
617
  // a protocol anomaly) falls back to the shared-version compare, the
461
- // conservative answer.
618
+ // conservative answer. The language client is checked per URI: each is
619
+ // its own buffer, and one URI's higher id would drop the other's edits.
462
620
  const record = this.trackingFor(uri);
463
- const lastSeen = record.clientVersions.get(clientId) ?? document.version;
621
+ const languageClientDocument =
622
+ clientId === LANGUAGE_CLIENT_ID ? record.languageClientDocuments?.get(this.toLanguageClientUri(td.uri)) : undefined;
623
+ const lastSeen = languageClientDocument?.declaredVersion ?? record.clientVersions.get(clientId) ?? document.version;
464
624
  if (lastSeen >= td.version) {
465
625
  // Distinguish "already at this version" (common: an echo from the client that triggered
466
626
  // the update) from "incoming version older than ours" (stale race).
467
627
  const reason =
468
628
  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');
629
+ this.logUri(uri, `Ignore update by ${this.formatClientId(clientId)}: ${reason}`, 'debug');
470
630
  return;
471
631
  }
472
632
  record.clientVersions.set(clientId, td.version);
633
+ if (languageClientDocument) {
634
+ languageClientDocument.declaredVersion = td.version;
635
+ }
473
636
 
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.
637
+ // A language-client change is keyed to the buffer that client holds,
638
+ // which is the synced text only while the two agree — an authored
639
+ // write advances the synced text without the client knowing, and a
640
+ // push in flight moves the client without the store knowing.
477
641
  // Resolve which it is, and against what, before touching anything.
478
- const inFlight =
642
+ const origin =
479
643
  clientId === LANGUAGE_CLIENT_ID
480
644
  ? this.classifyLanguageClientChange(this.toLanguageClientUri(td.uri), document, changes)
481
645
  : undefined;
482
- if (inFlight?.kind === 'echo') {
646
+ if (origin?.kind === 'echo') {
483
647
  // The client is reporting a text we pushed it, so the synced
484
648
  // document is ALREADY there and the changes must not be applied a
485
649
  // second time. Nothing is minted and no rebuild fires. The
@@ -491,19 +655,23 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
491
655
  this.logUri(uri, `Skip rebuild: echo of a server-authored push (client version ${td.version})`, 'debug');
492
656
  return;
493
657
  }
658
+ if (origin?.kind === 'unreconstructable') {
659
+ this.tracer.with(uri).warn(`Drop change: no known client buffer for its ranges (client version ${td.version})`);
660
+ return;
661
+ }
494
662
 
495
663
  // The SHARED version advances iff the content actually changes — the
496
- // invariant optimistic `baseVersion` gates rely on. The new text is
664
+ // invariant optimistic base-version gates rely on. The new text is
497
665
  // only known after applying the (possibly incremental) changes, so
498
666
  // apply at a tentative +1 and roll the version back on an identical
499
667
  // result (an empty-changes update only re-stamps the version).
500
668
  //
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.
669
+ // A divergent change is applied as its RECONSTRUCTED text rather than
670
+ // as its own ranges: those ranges address the client's own buffer, so
671
+ // applying them here would splice the wrong lines.
504
672
  const previousText = document.getText();
505
673
  const sharedVersion = document.version;
506
- document = this.configuration.update(document, inFlight === undefined ? changes : [{ text: inFlight.text }], sharedVersion + 1);
674
+ document = this.configuration.update(document, origin === undefined ? changes : [{ text: origin.text }], sharedVersion + 1);
507
675
  const changed = document.getText() !== previousText;
508
676
  if (!changed) {
509
677
  document = this.configuration.update(document, [], sharedVersion);
@@ -511,6 +679,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
511
679
  this.__syncedDocuments.set(uri, document);
512
680
  if (changed) {
513
681
  this.setAuthor(uri, document.version, clientId);
682
+ this.refreshDirty(uri);
514
683
  }
515
684
  if (clientId === LANGUAGE_CLIENT_ID) {
516
685
  // Monaco just told us about its new content; record it so the next outbound
@@ -527,7 +696,10 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
527
696
  return;
528
697
  }
529
698
  }
530
- this.log(document.uri, `Update to version ${document.version} by ${clientId}${changed ? '' : ' (content unchanged)'}`);
699
+ this.log(
700
+ document.uri,
701
+ `Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`
702
+ );
531
703
  this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
532
704
  }
533
705
  }
@@ -544,12 +716,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
544
716
  * A content-identical write still fires the change event (rebuild): the
545
717
  * authored write's server-side rebuild is correctness-bearing, it just
546
718
  * mints no new version — nothing observable changed, so watchers'
547
- * `baseVersion` pointers stay valid.
719
+ * base versions stay valid.
548
720
  *
549
721
  * Returns the resulting shared version. Throws when the document is not
550
722
  * open — callers (`AstDocumentManager.update`) open first.
551
723
  */
552
- applyContentChange(uri: DocumentUri, text: string, clientId: string): number {
724
+ applyContentChange(uri: DocumentUri, text: string, clientId: string): TextVersion {
553
725
  const key = this.documentKey(uri);
554
726
  let document = this.__syncedDocuments.get(key);
555
727
  if (document === undefined) {
@@ -560,53 +732,292 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
560
732
  document = this.configuration.update(document, [{ text }], document.version + 1);
561
733
  this.__syncedDocuments.set(key, document);
562
734
  this.setAuthor(key, document.version, clientId);
735
+ this.refreshDirty(key);
563
736
  }
564
- this.log(document.uri, `Update to version ${document.version} by ${clientId}${changed ? '' : ' (content unchanged)'}`);
737
+ this.log(
738
+ document.uri,
739
+ `Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`
740
+ );
565
741
  this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
566
742
  return document.version;
567
743
  }
568
744
 
569
- public notifyDidCloseTextDocument(event: DidCloseTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
745
+ /**
746
+ * Close `clientId`'s open of the document. When it was the last open, the
747
+ * document is released and reverts to disk, at once or, for a `'lost'`
748
+ * close, after {@link HydraniumTextDocumentsOptions.revertGraceMs}.
749
+ */
750
+ public notifyDidCloseTextDocument(
751
+ event: DidCloseTextDocumentParams,
752
+ clientId = LANGUAGE_CLIENT_ID,
753
+ cause: SessionEndCause = 'closed'
754
+ ): void {
570
755
  const uri = this.documentKey(event.textDocument.uri);
571
- if (!this.isOpenInClient(uri, clientId)) {
756
+ if (clientId === LANGUAGE_CLIENT_ID) {
757
+ const clientFacing = this.toLanguageClientUri(event.textDocument.uri);
758
+ const languageClientDocuments = this.__documents.get(uri)?.languageClientDocuments;
759
+ if (languageClientDocuments?.delete(clientFacing) && languageClientDocuments.size > 0) {
760
+ // Another URI still holds the document, so only this one's buffer goes.
761
+ this.__shadow.invalidate(clientFacing);
762
+ this.__pendingPushes.delete(clientFacing);
763
+ return;
764
+ }
765
+ }
766
+ if (!this.__sessions.removeOpen(uri, clientId)) {
572
767
  return;
573
768
  }
574
- const closingRecord = this.__documents.get(uri);
575
- closingRecord?.clients.delete(clientId);
576
- closingRecord?.clientVersions.delete(clientId);
769
+ const record = this.__documents.get(uri);
770
+ record?.clientVersions.delete(clientId);
771
+ if (record && cause === 'lost') {
772
+ (record.lostClients ??= new Map()).set(clientId, this.services.Clock.stopwatch());
773
+ }
577
774
  const syncedDocument = this.__syncedDocuments.get(uri);
578
775
  if (syncedDocument !== undefined) {
579
- this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${clientId}`);
776
+ this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
580
777
  this.__onDidClose.fire(Object.freeze({ document: syncedDocument, clientId }));
581
778
 
582
779
  if (clientId === LANGUAGE_CLIENT_ID) {
583
- // Monaco closed the document; drop the URI it held and the shadow
584
- // baselined under it. (If this was the last URI/client the whole record
585
- // is deleted below, clearing the set anyway.)
780
+ // Monaco closed the document; drop the shadow baselined under the URI
781
+ // it held. (If this was the last client the whole record is deleted on
782
+ // release.)
586
783
  const droppedUri = this.toLanguageClientUri(event.textDocument.uri);
587
- this.__documents.get(uri)?.languageClientUris?.delete(droppedUri);
588
784
  this.__shadow.invalidate(droppedUri);
589
785
  this.__pendingPushes.delete(droppedUri);
590
786
  }
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())
787
+ if (!this.__sessions.isOpen(uri)) {
788
+ if (cause === 'lost' && this.revertGraceMs > 0) {
789
+ this.deferRelease(uri);
790
+ } else {
791
+ this.releaseDocument(uri);
792
+ }
793
+ }
794
+ }
795
+ }
796
+
797
+ /**
798
+ * Keep the document, text and all, for the revert grace, then release it.
799
+ * The document stays in the store meanwhile, so a lost client that opens it
800
+ * again within its own grace attaches to it and finds its unsaved text
801
+ * rather than reading disk; see {@link resolvePendingRevert} for any other
802
+ * open.
803
+ */
804
+ protected deferRelease(uri: CanonicalUri): void {
805
+ this.log(uri, `No client left; revert deferred for ${this.revertGraceMs} ms (connection lost)`);
806
+ const timer = this.services.Clock.setTimer(() => {
807
+ this.__sessions.cancelRevert(uri);
808
+ if (!this.__sessions.isOpen(uri)) {
809
+ this.releaseDocument(uri);
810
+ }
811
+ }, this.revertGraceMs);
812
+ this.__sessions.deferRevert(uri, timer);
813
+ }
814
+
815
+ /**
816
+ * Resolve the pending revert of `uri` for an open by `clientId`. An open by a
817
+ * client lost from the document within its grace cancels the revert, and
818
+ * the client finds its unsaved text. Any other open releases the document
819
+ * first, so it opens as a first open does: cancelling for every open hands
820
+ * a lost client's unsaved text to whoever opens next, a reloaded page or an
821
+ * editor, with nothing marking it unsaved.
822
+ */
823
+ protected resolvePendingRevert(uri: CanonicalUri, clientId: string): void {
824
+ const record = this.__documents.get(uri);
825
+ if (record) {
826
+ this.pruneLostClients(record);
827
+ }
828
+ const returning = record?.lostClients?.delete(clientId) ?? false;
829
+ if (!this.__sessions.isRevertPending(uri)) {
830
+ return;
831
+ }
832
+ this.__sessions.cancelRevert(uri);
833
+ if (!returning) {
834
+ this.releaseDocument(uri);
835
+ }
836
+ }
837
+
838
+ /** Drop the entries of {@link DocumentTrackingRecord.lostClients} whose grace has run out. */
839
+ protected pruneLostClients(record: DocumentTrackingRecord): void {
840
+ for (const [clientId, sinceLoss] of record.lostClients ?? []) {
841
+ if (sinceLoss.elapsedMs >= this.revertGraceMs) {
842
+ record.lostClients?.delete(clientId);
843
+ }
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Drop the document no client has open any more, then announce it on
849
+ * {@link onDidCloseLastOpen}, which is what reverts it to disk.
850
+ */
851
+ protected releaseDocument(uri: CanonicalUri): void {
852
+ const syncedDocument = this.__syncedDocuments.get(uri);
853
+ if (syncedDocument === undefined) {
854
+ return;
855
+ }
856
+ this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`);
857
+ // Persist where the shared version sequence left off (version +
858
+ // content hash) so the next open CONTINUES the sequence instead of
859
+ // restarting at the reopening client's declared id. Hashed once
860
+ // here at release, not on every change.
861
+ this.__versionSequences.set(uri, {
862
+ version: syncedDocument.version,
863
+ contentHash: this.heldTextHash(syncedDocument)
864
+ });
865
+ if (this.__documents.get(uri)?.dirty === true) {
866
+ this.__releasedDirty.set(uri, {});
867
+ }
868
+ this.__syncedDocuments.delete(uri);
869
+ // One delete clears every per-URI axis (version history + any staged
870
+ // pending content) so a future open with the same URI starts fresh.
871
+ this.__documents.delete(uri);
872
+ this.lastOpenClosedEmitter.fire(Object.freeze({ uri }));
873
+ }
874
+
875
+ /**
876
+ * Rebuild a released document from the file system provider, so the build
877
+ * stops carrying the unsaved text of its last client.
878
+ *
879
+ * The provider decides, for every scheme, by `exists`: a document it can
880
+ * serve is rebuilt from its text, and any other — an editor's `untitled:`
881
+ * buffer, a file never saved, or one deleted meanwhile — is removed
882
+ * from the workspace. A `virtual:` document survives, since the framework's
883
+ * provider for that scheme serves it from the index, whatever provider the
884
+ * host passes as `context.fileSystemProvider`; an edited one therefore keeps
885
+ * its last client's text, and keeping it read-only is the client's job.
886
+ *
887
+ * The answer is read in the document's disk queue, so the rebuild follows
888
+ * any save still queued rather than reverting past it; a file that goes
889
+ * after that read is removed when its rebuild finds none.
890
+ *
891
+ * Whether to revert at all is decided inside the write lock, as its holder:
892
+ * decided before waiting for the lock, a client that opens or re-creates the
893
+ * document meanwhile would have its text rebuilt over, or the document
894
+ * removed. A document some client has open again, or that waits out a new
895
+ * grace, is left to that client.
896
+ *
897
+ * A document released dirty is announced clean, so a watcher is never left
898
+ * holding it dirty: once the revert has parsed the file, even if it is then
899
+ * cancelled, or else once the build it requests in its place has parsed it,
900
+ * as the announcement names the store's text. One the revert or that build
901
+ * removed, or that build failed, is announced without text. A reopen before
902
+ * that takes the announcement over.
903
+ */
904
+ protected async revertToDisk(uri: CanonicalUri): Promise<void> {
905
+ const workspace = this.services.workspace;
906
+ const target = UriUtils.toUri(uri);
907
+ const reopened = (): boolean => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri);
908
+ const releasedDirty = this.__releasedDirty.get(uri);
909
+ const owesFlip = (): boolean => releasedDirty !== undefined && this.__releasedDirty.get(uri) === releasedDirty;
910
+ const announceClean = (withText = true): void => {
911
+ if (owesFlip()) {
912
+ this.__releasedDirty.delete(uri);
913
+ // The sequence of a removed document still names the discarded text.
914
+ const text = !withText || workspace.LangiumDocuments.getDocument(target) === undefined ? undefined : this.textState(uri);
915
+ this.dirtyChangedEmitter.fire(Object.freeze(text ? { uri, text } : { uri }));
916
+ }
917
+ };
918
+ let parsedFromFile = false;
919
+ let parses: Disposable | undefined;
920
+ let onDisk: boolean | undefined;
921
+ let cancelled = false;
922
+ let stopWaiting: (() => void) | undefined;
923
+ // Without it, a revert stopped short leaves the root on the released text.
924
+ const buildInstead = (): void => {
925
+ void workspace.VersionSyncService.requestRecoveryBuild(target, {
926
+ deleted: onDisk === false,
927
+ reason: HYDRANIUM_BUILD_REASONS.didClose,
928
+ // The update handler dropped the change it held back at the release.
929
+ ignoreDeferred: true,
930
+ stillNeeded: () => !reopened()
931
+ }).then(built => {
932
+ if (!built) {
933
+ this.tracer.with(uri).error('Build after a revert that stopped short failed; the store keeps the released text');
934
+ // No parse or removal is coming.
935
+ stopWaiting?.();
936
+ announceClean(false);
937
+ }
938
+ });
939
+ };
940
+ try {
941
+ // Read before the lock: every build and read waits while the lock is
942
+ // held, and this read waits on the file's save I/O.
943
+ onDisk = await workspace.FileSystemTaskQueue.enqueue(uri, () => workspace.FileSystemProvider.exists(target));
944
+ await workspace.WorkspaceManager?.ready;
945
+ // Queuing the write cancels the running build, even when it then reverts nothing.
946
+ if (reopened()) {
947
+ return;
948
+ }
949
+ await workspace.WorkspaceLock.write(async token => {
950
+ if (reopened()) {
951
+ return;
952
+ }
953
+ // Observed rather than awaited: a build cancelled after its parse
954
+ // returns early, and the build it yields to does not parse again.
955
+ parses = workspace.VersionSyncService.onDidRecordModel(document => {
956
+ parsedFromFile ||= this.documentKey(document.uri.toString()) === uri;
957
+ });
958
+ workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
959
+ // Cleared once the build ends: a cancelled one throws, and the lock resolves.
960
+ cancelled = true;
961
+ try {
962
+ await (onDisk
963
+ ? workspace.DocumentBuilder.update([target], [], token)
964
+ : workspace.DocumentBuilder.update([], [target], token));
965
+ } catch (err: unknown) {
966
+ if (!onDisk || !isFileNotFound(err, target)) {
967
+ throw err;
968
+ }
969
+ // The file went after its existence was read.
970
+ this.tracer.with(uri).debug('Revert found no file; removing the document instead');
971
+ workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
972
+ await workspace.DocumentBuilder.update([], [target], token);
973
+ }
974
+ cancelled = false;
975
+ });
976
+ // Cancelled before its parse: the write that cancelled it may build nothing.
977
+ if (cancelled && !parsedFromFile && !reopened() && workspace.LangiumDocuments.getDocument(target) !== undefined) {
978
+ buildInstead();
979
+ }
980
+ } catch (err: unknown) {
981
+ // A revert that finishes after the LSP peer went away fails its
982
+ // diagnostics publish, and one that runs after its workspace was torn
983
+ // down finds no file: teardown races, not failed reverts.
984
+ if (isConnectionGoneError(err) || isFileNotFound(err, target)) {
985
+ this.tracer.with(uri).debug(`Revert on last close skipped: ${err instanceof Error ? err.message : String(err)}`);
986
+ return;
987
+ }
988
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
989
+ this.tracer.with(uri).error(`Revert on last close dropped. ${detail}`);
990
+ // The update handler drops a change still debounced at release, so
991
+ // without this build the root stays behind the store's version. A
992
+ // document known to have no file is removed, as the revert would have.
993
+ if (!reopened()) {
994
+ buildInstead();
995
+ }
996
+ } finally {
997
+ parses?.dispose();
998
+ if (parsedFromFile || workspace.LangiumDocuments.getDocument(target) === undefined) {
999
+ announceClean();
1000
+ } else if (owesFlip()) {
1001
+ // Cancelled before its parse or failed, the revert left the released
1002
+ // text in the store: announced now, the clean flip would name it.
1003
+ stopWaiting = (): void => {
1004
+ parsed.dispose();
1005
+ deleted.dispose();
1006
+ };
1007
+ const done = (): void => {
1008
+ stopWaiting?.();
1009
+ announceClean();
1010
+ };
1011
+ const parsed = workspace.VersionSyncService.onDidRecordModel(document => {
1012
+ if (this.documentKey(document.uri.toString()) === uri) {
1013
+ done();
1014
+ }
1015
+ });
1016
+ const deleted = workspace.DocumentBuilder.onUpdate((_changed, deletedUris) => {
1017
+ if (deletedUris.some(deletedUri => this.documentKey(deletedUri.toString()) === uri)) {
1018
+ done();
1019
+ }
605
1020
  });
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);
610
1021
  }
611
1022
  }
612
1023
  }
@@ -630,47 +1041,111 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
630
1041
  }
631
1042
  }
632
1043
 
1044
+ /**
1045
+ * Handle a save the language client reports: fire
1046
+ * {@link onDidSaveInLanguageClient}, then announce it on {@link onDidSave}
1047
+ * only once the file read back from disk holds the store's current text.
1048
+ */
1049
+ protected async notifyLanguageClientSave(event: DidSaveTextDocumentParams): Promise<void> {
1050
+ const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
1051
+ if (syncedDocument === undefined) {
1052
+ return;
1053
+ }
1054
+ const uri = syncedDocument.uri;
1055
+ this.languageClientSavedEmitter.fire(Object.freeze({ uri }));
1056
+ // `onDidSave` tells its listeners that the shared document is on disk.
1057
+ // The editor wrote its own buffer, which lags the store while another
1058
+ // client's edit is still on its way to it, so only the file says
1059
+ // whether that holds. Read it rather than trust the editor: `didSave`
1060
+ // carries text only for a client that honours `includeText`, and asking
1061
+ // for it means rewriting Langium's initialize answer, which advertises
1062
+ // `save` as a Boolean. The read goes through the disk queue and waits on
1063
+ // nothing but the provider, so it follows a server save queued
1064
+ // meanwhile. A file changed again between the editor's write and this
1065
+ // read compares unequal, and the save goes unannounced.
1066
+ let onDisk: string | undefined;
1067
+ try {
1068
+ onDisk = await this.services.workspace.FileSystemTaskQueue.enqueue(uri, () =>
1069
+ this.services.workspace.FileSystemProvider.readFile(UriUtils.toUri(uri))
1070
+ );
1071
+ } catch (err: unknown) {
1072
+ this.tracer
1073
+ .with(uri)
1074
+ .debug(`Editor save not announced: reading the file back failed. ${err instanceof Error ? err.message : String(err)}`);
1075
+ return;
1076
+ }
1077
+ // What the file holds, not what the editor meant to write: an editor
1078
+ // that saved older text leaves the document dirty.
1079
+ this.updateDiskBaseline(uri, onDisk);
1080
+ // An editor that saves and then closes releases the document while the
1081
+ // read is under way; its save is still a save of the text it held.
1082
+ const document = this.__syncedDocuments.get(this.documentKey(uri)) ?? syncedDocument;
1083
+ if (onDisk !== document.getText()) {
1084
+ this.tracer.with(uri).debug(`Editor save not announced: the file does not hold the text of version ${document.version}`);
1085
+ return;
1086
+ }
1087
+ this.announceSave(document, LANGUAGE_CLIENT_ID);
1088
+ }
1089
+
1090
+ /**
1091
+ * Announce a save the server made. `event.text`, when given, is what the
1092
+ * file now holds, written or found there, and becomes the disk baseline
1093
+ * before the save is announced. It lags the store when an edit landed
1094
+ * after the save took its text, and the document then stays dirty.
1095
+ */
633
1096
  public notifyDidSaveTextDocument(event: DidSaveTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
634
1097
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
635
1098
  if (syncedDocument !== undefined) {
636
- this.log(syncedDocument.uri, `Saved synced document: ${syncedDocument.version} by ${clientId}`);
637
- this.__onDidSave.fire(Object.freeze({ document: syncedDocument, clientId }));
1099
+ if (event.text !== undefined) {
1100
+ this.updateDiskBaseline(syncedDocument.uri, event.text);
1101
+ }
1102
+ this.announceSave(syncedDocument, clientId);
638
1103
  }
639
1104
  }
640
1105
 
1106
+ /** Fire {@link onDidSave} for `document`, which need not be synced any more. */
1107
+ protected announceSave(document: T, clientId: string): void {
1108
+ this.log(document.uri, `Saved synced document: ${document.version} by ${this.formatClientId(clientId)}`);
1109
+ this.__onDidSave.fire(Object.freeze({ document, clientId }));
1110
+ }
1111
+
641
1112
  public notifyDidOpenTextDocument(event: DidOpenTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
642
1113
  const td = event.textDocument;
643
1114
  const uri = this.documentKey(td.uri);
1115
+ this.resolvePendingRevert(uri, clientId);
644
1116
  if (this.isOpenInClient(uri, clientId)) {
645
1117
  // Already open for this client under this canonical identity. If this is a
646
1118
  // 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.
1119
+ // divergent path, e.g. a symlink and its real path), record it so outbound
1120
+ // edits reach this tab too — but do NOT re-fire open/rebuild; the document
1121
+ // is already live.
650
1122
  if (clientId === LANGUAGE_CLIENT_ID) {
651
1123
  const clientFacing = this.toLanguageClientUri(td.uri);
652
1124
  const record = this.__documents.get(uri);
653
- if (record && !record.languageClientUris?.has(clientFacing)) {
654
- (record.languageClientUris ??= new Set<LanguageClientUri>()).add(clientFacing);
655
- const open = this.__syncedDocuments.get(uri);
656
- if (open) {
657
- this.__shadow.set(clientFacing, open.getText());
658
- }
1125
+ if (record && !record.languageClientDocuments?.has(clientFacing)) {
1126
+ (record.languageClientDocuments ??= new Map()).set(clientFacing, { declaredVersion: td.version });
1127
+ // This tab's own buffer, NOT the synced text: a second tab is a second
1128
+ // client model, read from disk, so a server-authored write already
1129
+ // applied to the first tab leaves it BEHIND the synced document. Keying
1130
+ // a diff to the synced text here addresses lines this tab does not have
1131
+ // and drops content it does.
1132
+ this.__shadow.setOpenedText(clientFacing, td.text);
659
1133
  }
660
1134
  }
661
1135
  return;
662
1136
  }
663
1137
  let document = this.__syncedDocuments.get(uri);
1138
+ const existingClients = this.__sessions.clientsOf(uri);
1139
+ this.__sessions.addOpen(uri, clientId);
664
1140
  const record = this.trackingFor(uri);
665
- const existingClients = [...record.clients];
666
- record.clients.add(clientId);
667
1141
  // Baseline the per-client staleness guard at the version id the client
668
1142
  // declared for its own buffer (client-owned per LSP).
669
1143
  record.clientVersions.set(clientId, td.version);
670
1144
  if (clientId === LANGUAGE_CLIENT_ID) {
671
1145
  // Remember the URI Monaco opened under (may differ from the canonical key)
672
1146
  // so outbound applyEditToLanguageClient can address the URI it actually holds.
673
- (record.languageClientUris ??= new Set<LanguageClientUri>()).add(this.toLanguageClientUri(td.uri));
1147
+ // A fresh state, since a reopened buffer numbers its versions afresh.
1148
+ (record.languageClientDocuments ??= new Map()).set(this.toLanguageClientUri(td.uri), { declaredVersion: td.version });
674
1149
  }
675
1150
  if (!document) {
676
1151
  // Use integrity-staged content if available, otherwise the client-provided (disk) text.
@@ -679,44 +1154,136 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
679
1154
  const source = pendingText ? ', source=pending' : '';
680
1155
  // The SHARED version is server-assigned: continue the persisted
681
1156
  // sequence — same version when the content is unchanged since the
682
- // last close (so watchers' `baseVersion` pointers stay valid), one
1157
+ // last close (so watchers' base versions stay valid), one
683
1158
  // step when it changed (so no stale pointer can coincidentally pass
684
- // the optimistic gate). Only a first-ever open adopts the client's
685
- // declared id as the sequence seed.
1159
+ // the optimistic gate). An open with no sequence yet seeds it from the built root.
686
1160
  const sequence = this.__versionSequences.get(uri);
687
1161
  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}]`);
1162
+ sequence === undefined
1163
+ ? this.firstOpenVersion(uri, text, td.version)
1164
+ : sequence.contentHash === textHash(text)
1165
+ ? sequence.version
1166
+ : sequence.version + 1;
1167
+ this.log(uri, `Open document: Version ${version} by ${this.formatClientId(clientId)} [first client${source}]`);
690
1168
  document = this.configuration.create(uri, td.languageId, version, text);
691
1169
  this.__syncedDocuments.set(uri, document);
692
1170
  this.setAuthor(uri, version, clientId);
1171
+ // The opener's text, not the staged content: a session's open read
1172
+ // it from the file, and an editor opened its buffer from there. An
1173
+ // editor that opens a buffer it never saved is taken as clean.
1174
+ record.diskBaseline = td.text;
1175
+ if (this.__releasedDirty.delete(uri)) {
1176
+ record.dirty = true;
1177
+ }
1178
+ this.refreshDirty(uri);
693
1179
  if (clientId === LANGUAGE_CLIENT_ID) {
694
1180
  // Baseline the shadow to what Monaco just opened so the next outbound
695
1181
  // applyEditToLanguageClient diffs against the right starting point. Keyed by the
696
1182
  // language-client URI (what Monaco holds), not the canonical document key.
697
- this.__shadow.set(this.toLanguageClientUri(td.uri), document.getText());
1183
+ //
1184
+ // The CLIENT's declared text, never the synced document: staged content
1185
+ // consumed above leaves the two different, and a baseline asserting the
1186
+ // client already holds the staged text suppresses the one sync that would
1187
+ // deliver it. Skipped entirely when a push is already outstanding for this
1188
+ // URI — an `applyEdit` to a closed file has the client open from disk and
1189
+ // apply afterwards, so that shadow records what it is about to hold and
1190
+ // disk text would key the next diff to a buffer nobody has.
1191
+ const clientFacing = this.toLanguageClientUri(td.uri);
1192
+ if (!this.__shadow.isTracked(clientFacing)) {
1193
+ this.__shadow.set(clientFacing, td.text);
1194
+ }
698
1195
  }
699
1196
  const toFire = Object.freeze({ document, clientId });
700
1197
  this.__onDidOpen.fire(toFire);
701
1198
  this.__onDidChangeContent.fire(toFire);
702
1199
  } else {
703
1200
  // 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
- );
1201
+ this.logClientJoined(uri, clientId, document.version, existingClients);
1202
+ if (clientId === LANGUAGE_CLIENT_ID) {
1203
+ // Monaco's own buffer, which on this path is NOT the synced text — another
1204
+ // client opened the document and may already have changed it. Recorded as
1205
+ // an equality-only baseline (never a diff one, see `setOpenedText`) so the
1206
+ // refresh below does not push a full replace of content Monaco already
1207
+ // holds, which would dirty the file on open.
1208
+ this.__shadow.setOpenedText(this.toLanguageClientUri(td.uri), td.text);
1209
+ }
709
1210
  this.refreshContent(uri, clientId);
710
1211
  }
711
1212
  }
712
1213
 
1214
+ /**
1215
+ * Record `clientId` as an additional holder of a document another client
1216
+ * already has open.
1217
+ *
1218
+ * Unlike the attach branch of {@link notifyDidOpenTextDocument} this does
1219
+ * not refresh. That branch re-renders an attaching TEXTUAL view; a client
1220
+ * reaching a document through a non-textual route has nothing to re-render,
1221
+ * and refreshing per attach turns each into a Langium rebuild and dependent
1222
+ * relink cascade.
1223
+ *
1224
+ * The staleness guard baselines at the SYNCED version, not a declared buffer
1225
+ * version, because a client arriving this way holds no buffer of its own.
1226
+ * Unifying the two routes therefore rebaselines a textual client's guard to a
1227
+ * version it never declared.
1228
+ *
1229
+ * Returns whether a hold was added — `false` when `uri` is not open,
1230
+ * `clientId` already holds it, or the document was waiting out the revert
1231
+ * grace for other clients and was released instead (see
1232
+ * {@link resolvePendingRevert}); the caller then opens it anew.
1233
+ */
1234
+ attachClient(uri: DocumentUri, clientId: string): boolean {
1235
+ const key = this.documentKey(uri);
1236
+ if (!this.__syncedDocuments.has(key) || this.isOpenInClient(key, clientId)) {
1237
+ return false;
1238
+ }
1239
+ this.resolvePendingRevert(key, clientId);
1240
+ const document = this.__syncedDocuments.get(key);
1241
+ if (!document) {
1242
+ return false;
1243
+ }
1244
+ const existingClients = this.__sessions.clientsOf(key);
1245
+ this.__sessions.addOpen(key, clientId);
1246
+ const record = this.trackingFor(key);
1247
+ record.clientVersions.set(clientId, document.version);
1248
+ this.logClientJoined(key, clientId, document.version, existingClients);
1249
+ return true;
1250
+ }
1251
+
1252
+ /**
1253
+ * The built root's recorded version, one on when `text` differs from the root's;
1254
+ * `declared` when the root records no store version. Seeded from `declared`, a write based
1255
+ * on the root passes the gate over other text, and the same text looks newer than its model.
1256
+ * A built root has no sequence only under a `LangiumDocuments` that does not reconcile at registration.
1257
+ */
1258
+ protected firstOpenVersion(uri: CanonicalUri, text: string, declared: number): number {
1259
+ const built = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(uri));
1260
+ if (built === undefined) {
1261
+ return declared;
1262
+ }
1263
+ const ledger = this.services.workspace.ModelLedger;
1264
+ const root = built.parseResult.value;
1265
+ const recorded = ledger.versionOf(root);
1266
+ if (recorded === UNRECORDED_VERSION || recorded === STALE_VERSION) {
1267
+ return declared;
1268
+ }
1269
+ return (ledger.textOf(root) ?? built.textDocument.getText()) === text ? recorded : recorded + 1;
1270
+ }
1271
+
1272
+ protected logClientJoined(uri: DocumentUri, clientId: string, version: number, existingClients: readonly string[]): void {
1273
+ this.log(
1274
+ uri,
1275
+ `Attach client: ${this.formatClientId(clientId)} joined existing document (version ${version}, ` +
1276
+ `now open in: ${[...existingClients, clientId].map(id => this.formatClientId(id)).join(', ')})`
1277
+ );
1278
+ }
1279
+
713
1280
  refreshContent(uri: DocumentUri, clientId: string): void {
714
1281
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(uri));
715
1282
  if (syncedDocument) {
716
1283
  // Trigger a (re-)build by firing a change event.
717
1284
  const timer = this.startTimerForUri(
718
1285
  syncedDocument.uri,
719
- `Refresh synced document: Version ${syncedDocument.version} by ${clientId}`
1286
+ `Refresh synced document: Version ${syncedDocument.version} by ${this.formatClientId(clientId)}`
720
1287
  );
721
1288
  this.__onDidChangeContent.fire(Object.freeze({ document: syncedDocument, clientId }));
722
1289
  timer.dispose();
@@ -730,7 +1297,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
730
1297
  * two URIs for one physical file (a symlink path and its real path) into a
731
1298
  * single registration — dedup at the editor layer, not just in
732
1299
  * `LangiumDocuments`. The URI the client opened under is preserved separately
733
- * for egress addressing (see {@link DocumentTracking.languageClientUris}). The
1300
+ * for egress addressing (see {@link DocumentTrackingRecord.languageClientDocuments}). The
734
1301
  * policy is always bound (the framework defaults it to
735
1302
  * `DefaultDocumentUriPolicy`, where canonical ≡ syntactic normalize).
736
1303
  */
@@ -751,10 +1318,10 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
751
1318
  }
752
1319
 
753
1320
  /** Get-or-create the per-URI tracking record. `uri` must already be a {@link documentKey}. */
754
- protected trackingFor(uri: CanonicalUri): DocumentTracking {
1321
+ protected trackingFor(uri: CanonicalUri): DocumentTrackingRecord {
755
1322
  let record = this.__documents.get(uri);
756
1323
  if (!record) {
757
- record = { clients: new Set(), versionAuthors: [], clientVersions: new Map() };
1324
+ record = { versionAuthors: [], clientVersions: new Map() };
758
1325
  this.__documents.set(uri, record);
759
1326
  }
760
1327
  return record;
@@ -786,43 +1353,76 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
786
1353
 
787
1354
  /**
788
1355
  * Current SHARED version of the document at `uri`: the open document's
789
- * version, else where the persisted sequence left off at last close, else
790
- * `0` for a URI this store has never seen. The shared sequence is
1356
+ * version, else where its persisted sequence left off, else `0` for a URI
1357
+ * this store has never seen. The shared sequence is
791
1358
  * server-owned and monotonic across close/reopen cycles, and advances
792
1359
  * exactly when the synced content changes — which is what makes it a sound
793
- * optimistic-concurrency token (`baseVersion` gates): version unchanged ⇔
1360
+ * optimistic-concurrency token (base-version gates): version unchanged ⇔
794
1361
  * content unchanged.
795
1362
  */
796
- version(uri: DocumentUri): number {
1363
+ version(uri: DocumentUri): TextVersion {
797
1364
  return this.get(uri)?.version ?? this.__versionSequences.get(this.documentKey(uri))?.version ?? 0;
798
1365
  }
799
1366
 
1367
+ /**
1368
+ * The text the store holds for `uri`: an open document's, or for a closed
1369
+ * one, the clean text its version sequence left off at, which for a deleted
1370
+ * document is its last text. `undefined` for a URI no build and no client
1371
+ * gave the store.
1372
+ */
1373
+ textState(uri: DocumentUri): TextState | undefined {
1374
+ const document = this.get(uri);
1375
+ if (document) {
1376
+ return { version: document.version, hash: this.heldTextHash(document), dirty: this.isDirty(uri) };
1377
+ }
1378
+ const sequence = this.__versionSequences.get(this.documentKey(uri));
1379
+ return sequence && { version: sequence.version, hash: sequence.contentHash, dirty: false };
1380
+ }
1381
+
1382
+ /**
1383
+ * {@link textHash} of `document`'s text, taken once per version: the store
1384
+ * moves a document's version with every change of its text.
1385
+ */
1386
+ protected heldTextHash(document: T): string {
1387
+ const taken = this.__textHashes.get(document);
1388
+ if (taken?.version === document.version) {
1389
+ return taken.hash;
1390
+ }
1391
+ const hash = textHash(document.getText());
1392
+ this.__textHashes.set(document, { version: document.version, hash });
1393
+ return hash;
1394
+ }
1395
+
800
1396
  /**
801
1397
  * Reconcile the persisted version sequence with content that reached the
802
1398
  * build OUTSIDE the store's write paths — a closed document rebuilt from
803
1399
  * disk (last-close revert) or replaced by a watched-file change. Steps the
804
1400
  * sequence iff `text` differs from the sequence's last-known content and
805
1401
  * returns the resulting sequence version so the caller can re-stamp the
806
- * rebuilt document (see the `AstDocumentManager` Parsed-phase listener) —
1402
+ * rebuilt document (`VersionSyncService.modelProduced`) —
807
1403
  * keeping the "version advances iff content changes" invariant for
808
1404
  * documents no client currently holds.
809
1405
  *
810
- * Returns `undefined` — and does nothing — when the document is open (the
811
- * store's own content is authoritative; external transitions never reach
812
- * the Langium factory for open documents) or was never tracked (no gate
813
- * holder can hold a version pointer for it, and the early exit keeps
814
- * workspace-wide builds from hashing every untouched document).
1406
+ * For an open document, its version when it holds `text`, since a build
1407
+ * that read the file while the document was closed can finish after an
1408
+ * open; else `undefined`, and the caller has to build the document again.
1409
+ *
1410
+ * A document without a sequence starts one at `0` with `text`: snapshots
1411
+ * hand out versions for documents no client opened, so an uncounted change
1412
+ * would let a write based on the old text pass the gate.
815
1413
  */
816
1414
  reconcileExternalContent(uri: DocumentUri, text: string): number | undefined {
817
1415
  const key = this.documentKey(uri);
818
- if (this.__syncedDocuments.has(key)) {
819
- return undefined;
1416
+ const open = this.__syncedDocuments.get(key);
1417
+ if (open !== undefined) {
1418
+ return open.getText() === text ? open.version : undefined;
820
1419
  }
1420
+ const hash = textHash(text);
821
1421
  const sequence = this.__versionSequences.get(key);
822
1422
  if (sequence === undefined) {
823
- return undefined;
1423
+ this.__versionSequences.set(key, { version: 0, contentHash: hash });
1424
+ return 0;
824
1425
  }
825
- const hash = contentHash(text);
826
1426
  if (hash === sequence.contentHash) {
827
1427
  return sequence.version;
828
1428
  }
@@ -832,6 +1432,59 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
832
1432
  return stepped.version;
833
1433
  }
834
1434
 
1435
+ /**
1436
+ * Commit an integrity repair into the OPEN document for `uri`, returning the
1437
+ * document that now holds it, or `undefined` when there was nothing here to
1438
+ * commit into.
1439
+ *
1440
+ * The one thing this exists to do is address the store BY URI. A repair
1441
+ * reaches the resync as a text-document object, and that object is the
1442
+ * store's own only on the LSP path; a document built for an already-open URI
1443
+ * through `LangiumDocumentFactory.fromString` carries its own, so writing the
1444
+ * repair into it corrects a copy the store does not know about and the editor
1445
+ * never sees.
1446
+ *
1447
+ * `parsedFrom` is the text the repaired AST was parsed from, and a mismatch
1448
+ * against the current content REFUSES the commit: the store has moved on,
1449
+ * which means an editor change landed after that parse, and committing would
1450
+ * overwrite a newer edit with a repair computed against text the user has
1451
+ * already replaced. The build that change provokes recomputes the repair.
1452
+ *
1453
+ * Deliberately NOT compared by version. A separately created document seeds
1454
+ * its own numbering, so requiring the two to agree would refuse every commit
1455
+ * on the path this exists for. Content is the thing both sides can be held to.
1456
+ *
1457
+ * A changed text is a new version authored by {@link INTEGRITY_CLIENT_ID}.
1458
+ * Kept at the old version, the repair is invisible to every reader keyed on
1459
+ * versions: a write based on the unrepaired version passes the base-version gate
1460
+ * and replaces the repair, and an echo filter credits the repair to the
1461
+ * client whose edit it corrected. No change event is fired: the repair
1462
+ * rides the build already under way, and an event would re-enter it.
1463
+ */
1464
+ commitRepair(uri: DocumentUri, parsedFrom: string, repaired: string): RepairCommit<T> {
1465
+ const key = this.documentKey(uri);
1466
+ const document = this.__syncedDocuments.get(key);
1467
+ if (document === undefined) {
1468
+ return { status: 'not-open' };
1469
+ }
1470
+ if (document.getText() !== parsedFrom) {
1471
+ this.logUri(key, 'Refuse repair commit: the open document moved on from the text the AST was parsed from', 'debug');
1472
+ return { status: 'stale' };
1473
+ }
1474
+ if (repaired === parsedFrom) {
1475
+ return { status: 'committed', document };
1476
+ }
1477
+ // Reassigned rather than mutated in place: the default configuration
1478
+ // updates and returns the SAME instance, but an adopter-supplied one may
1479
+ // return a new object, and the store must end up holding whichever it is.
1480
+ const updated = this.configuration.update(document, [{ text: repaired }], document.version + 1);
1481
+ this.__syncedDocuments.set(key, updated);
1482
+ this.setAuthor(key, updated.version, INTEGRITY_CLIENT_ID);
1483
+ this.refreshDirty(key);
1484
+ this.log(updated.uri, `Update to version ${updated.version} by ${this.formatClientId(INTEGRITY_CLIENT_ID)} (repair)`);
1485
+ return { status: 'committed', document: updated };
1486
+ }
1487
+
835
1488
  getAuthor(uri: DocumentUri, version?: number): string | undefined {
836
1489
  const history = this.__documents.get(this.documentKey(uri))?.versionAuthors;
837
1490
  // Either the requested version, or the latest. `version !== undefined` so we treat 0 correctly.
@@ -852,17 +1505,17 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
852
1505
  * True iff any client still holds `uri` open. Distinct from {@link isOpen},
853
1506
  * which reads `__syncedDocuments` — that map is cleared only AFTER the
854
1507
  * `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`
1508
+ * during the close event itself. `isOpenInAnyClient` reads the open table
1509
+ * ({@link __sessions}), which is updated BEFORE the fire, so an `onDidClose`
857
1510
  * subscriber that finds this `false` knows the last client just closed and a
858
1511
  * disk re-read / rebuild can proceed.
859
1512
  */
860
1513
  isOpenInAnyClient(uri: DocumentUri): boolean {
861
- return (this.__documents.get(this.documentKey(uri))?.clients.size ?? 0) > 0;
1514
+ return this.__sessions.isOpen(this.documentKey(uri));
862
1515
  }
863
1516
 
864
1517
  isOpenInClient(uri: DocumentUri, client: string): boolean {
865
- return !!this.__documents.get(this.documentKey(uri))?.clients.has(client);
1518
+ return this.__sessions.isOpenIn(this.documentKey(uri), client);
866
1519
  }
867
1520
 
868
1521
  isOpenInLanguageClient(uri: DocumentUri): boolean {
@@ -873,14 +1526,14 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
873
1526
  }
874
1527
 
875
1528
  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);
1529
+ const clients = this.__sessions.clientsOf(this.documentKey(uri));
1530
+ return clients.length === 1 && clients[0] === client;
878
1531
  }
879
1532
 
880
1533
  /**
881
1534
  * 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.
1535
+ * ids holding it. Reads the same open table as {@link isOpenInAnyClient}, so
1536
+ * it reflects a last-client close immediately.
884
1537
  *
885
1538
  * Diagnostics-oriented — the server-state snapshot lists these so an operator
886
1539
  * can see WHY a document is pinned: a document that lingers here after its
@@ -888,28 +1541,265 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
888
1541
  * under a shedding policy, explains why its CST is never shed).
889
1542
  */
890
1543
  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] });
1544
+ return this.__sessions.openDocuments();
1545
+ }
1546
+
1547
+ /** Fires once a client session has ended, after every document it had open was closed. */
1548
+ get onDidCloseSession(): Event<ClientSessionClosedEvent> {
1549
+ return this.__sessions.onDidCloseSession;
1550
+ }
1551
+
1552
+ /**
1553
+ * Fires once a document no client has open is released: at its last close,
1554
+ * or, for a lost client's last close, when its revert grace runs out,
1555
+ * another client opens it, or it is deleted. The document then reverts to
1556
+ * disk.
1557
+ */
1558
+ get onDidCloseLastOpen(): Event<LastOpenClosedEvent> {
1559
+ return this.lastOpenClosedEmitter.event;
1560
+ }
1561
+
1562
+ /**
1563
+ * Fires for every save the language client reports of a document it has
1564
+ * open: the editor has written the file. {@link onDidSave} follows only
1565
+ * when the file holds the store's text.
1566
+ */
1567
+ get onDidSaveInLanguageClient(): Event<LanguageClientSavedEvent> {
1568
+ return this.languageClientSavedEmitter.event;
1569
+ }
1570
+
1571
+ /**
1572
+ * Whether `uri` is waiting out the revert grace: its last open closed with a
1573
+ * lost connection, and it still holds its unsaved text. Such a document is
1574
+ * open for no client, yet not closed either, so a caller that would persist
1575
+ * a closed document's text to disk treats it as open.
1576
+ */
1577
+ isRevertPending(uri: DocumentUri): boolean {
1578
+ return this.__sessions.isRevertPending(this.documentKey(uri));
1579
+ }
1580
+
1581
+ /**
1582
+ * Whether the store holds `uri` with text that differs from its disk
1583
+ * baseline: what the server last knew the file to hold. `false` for a URI
1584
+ * the store does not hold; a document waiting out the revert grace is still
1585
+ * held.
1586
+ *
1587
+ * The baseline is the text a first open brought, or what the server wrote,
1588
+ * or read back after an editor's save or a watched-file change, so it can
1589
+ * trail a change to the file that none of these has seen yet. A check that
1590
+ * must know the file reads it instead.
1591
+ */
1592
+ isDirty(uri: DocumentUri): boolean {
1593
+ return this.__documents.get(this.documentKey(uri))?.dirty ?? false;
1594
+ }
1595
+
1596
+ /**
1597
+ * Fires each time the answer of {@link isDirty} changes. A dirty document's
1598
+ * release fires once a parse of the file reaches the store, at that text's
1599
+ * version, or without text once its revert removed the document or could
1600
+ * not rebuild it, though {@link isDirty} answers clean from the release on.
1601
+ */
1602
+ get onDidChangeDirty(): Event<DocumentDirtyChangedEvent> {
1603
+ return this.dirtyChangedEmitter.event;
1604
+ }
1605
+
1606
+ /**
1607
+ * Record that the file behind `uri` holds `text`, or no file at all for
1608
+ * `undefined`. A no-op for a URI the store does not hold: the next first
1609
+ * open sets the baseline from its own text.
1610
+ */
1611
+ updateDiskBaseline(uri: DocumentUri, text: string | undefined): void {
1612
+ const key = this.documentKey(uri);
1613
+ const record = this.__documents.get(key);
1614
+ if (!record || !this.__syncedDocuments.has(key)) {
1615
+ return;
1616
+ }
1617
+ record.diskBaseline = text;
1618
+ this.refreshDirty(key);
1619
+ }
1620
+
1621
+ /**
1622
+ * Read the file behind `uri` through its disk queue and take it as the
1623
+ * baseline. A file that cannot be read counts as none, the side that
1624
+ * leaves the document dirty.
1625
+ */
1626
+ async reloadDiskBaseline(uri: DocumentUri): Promise<void> {
1627
+ const key = this.documentKey(uri);
1628
+ if (!this.__syncedDocuments.has(key)) {
1629
+ return;
1630
+ }
1631
+ let onDisk: string | undefined;
1632
+ try {
1633
+ onDisk = await this.services.workspace.FileSystemTaskQueue.enqueue(key, () =>
1634
+ this.services.workspace.FileSystemProvider.readFile(UriUtils.toUri(key))
1635
+ );
1636
+ } catch (err: unknown) {
1637
+ this.tracer.with(key).debug(`Disk baseline: the file cannot be read. ${err instanceof Error ? err.message : String(err)}`);
1638
+ }
1639
+ this.updateDiskBaseline(key, onDisk);
1640
+ }
1641
+
1642
+ /** Compare the held text of `uri` with its baseline, and announce a changed answer. */
1643
+ protected refreshDirty(uri: CanonicalUri): void {
1644
+ const record = this.__documents.get(uri);
1645
+ const document = this.__syncedDocuments.get(uri);
1646
+ if (!record || !document) {
1647
+ return;
1648
+ }
1649
+ const dirty = document.getText() !== record.diskBaseline;
1650
+ if (dirty !== (record.dirty ?? false)) {
1651
+ record.dirty = dirty;
1652
+ this.dirtyChangedEmitter.fire(
1653
+ Object.freeze({ uri, text: { version: document.version, hash: this.heldTextHash(document), dirty } })
1654
+ );
1655
+ }
1656
+ }
1657
+
1658
+ /**
1659
+ * Start a client session under `clientId`. Throws where
1660
+ * {@link ClientSessionRegistry.register} refuses the id.
1661
+ */
1662
+ registerSession(clientId: string): void {
1663
+ this.__sessions.register(clientId);
1664
+ this.tracer.info(`Session started: ${this.formatClientId(clientId)}`);
1665
+ this.tracer.trace(`Session started: ${clientId}`);
1666
+ }
1667
+
1668
+ /**
1669
+ * End the client session `clientId`: close every document it has open, then
1670
+ * free the id. Immediate — each close runs the ordinary close path before
1671
+ * this returns, with `cause` as its cause. A no-op for an id that is not a
1672
+ * registered session.
1673
+ */
1674
+ closeSession(clientId: string, cause: SessionEndCause = 'closed'): void {
1675
+ if (!this.__sessions.isRegistered(clientId)) {
1676
+ return;
1677
+ }
1678
+ try {
1679
+ for (const uri of this.__sessions.beginClose(clientId)) {
1680
+ this.notifyDidCloseTextDocument({ textDocument: { uri } }, clientId, cause);
1681
+ }
1682
+ } finally {
1683
+ this.__sessions.unregister(clientId);
1684
+ this.tracer.info(`Session closed: ${this.formatClientId(clientId)}`);
1685
+ this.tracer.trace(`Session closed: ${clientId}`);
1686
+ }
1687
+ }
1688
+
1689
+ /**
1690
+ * Close every document the language client has open, as a `didClose` for
1691
+ * each would, so each last close reverts. For a host whose editor connection
1692
+ * can end while the process lives on, such as a worker whose port's peer
1693
+ * closed: the language client is no session, so nothing else closes them.
1694
+ *
1695
+ * It first waits for the workspace initialization the open handler waits
1696
+ * for, so an open that arrived before the close is closed too; closing at
1697
+ * once would leave that open to land afterwards, held by a client that is
1698
+ * gone.
1699
+ */
1700
+ async closeLanguageClientDocuments(): Promise<void> {
1701
+ await this.initialBuildFinished();
1702
+ for (const uri of this.__sessions.opensOf(LANGUAGE_CLIENT_ID)) {
1703
+ this.untrackLanguageClientDocuments(uri);
1704
+ this.notifyDidCloseTextDocument({ textDocument: { uri } });
1705
+ }
1706
+ }
1707
+
1708
+ /**
1709
+ * Settles once the initial workspace build has finished, whether it
1710
+ * completed, was cancelled or failed. Awaiting `workspaceInitialized` itself
1711
+ * throws after a cancelled build, which any write during startup causes, and
1712
+ * every open and close gated on it is then dropped for the process lifetime.
1713
+ * The workspace manager logs a failed build.
1714
+ */
1715
+ protected async initialBuildFinished(): Promise<void> {
1716
+ await this.services.workspace.WorkspaceManager.workspaceInitialized.catch(() => undefined);
1717
+ }
1718
+
1719
+ /**
1720
+ * Stop tracking every URI the language client holds the document `key`
1721
+ * under, with its shadow and pending pushes. Call before a close by
1722
+ * canonical key that means all of them: a close for one URI while others
1723
+ * remain drops only that URI's and keeps the client's hold.
1724
+ */
1725
+ protected untrackLanguageClientDocuments(key: CanonicalUri): void {
1726
+ const languageClientDocuments = this.__documents.get(key)?.languageClientDocuments;
1727
+ for (const clientUri of languageClientDocuments?.keys() ?? []) {
1728
+ this.__shadow.invalidate(clientUri);
1729
+ this.__pendingPushes.delete(clientUri);
1730
+ }
1731
+ languageClientDocuments?.clear();
1732
+ }
1733
+
1734
+ /**
1735
+ * The file behind `uri` was deleted: close every open of it except the
1736
+ * language client's.
1737
+ *
1738
+ * The editor's open is left alone because the editor owns it: it keeps the
1739
+ * buffer of a deleted file and goes on sending changes for it, and a store
1740
+ * that had closed the document would drop every one of them until the editor
1741
+ * reopened.
1742
+ */
1743
+ notifyDocumentDeleted(uri: DocumentUri): void {
1744
+ const key = this.documentKey(uri);
1745
+ for (const clientId of this.__sessions.clientsOf(key)) {
1746
+ if (clientId !== LANGUAGE_CLIENT_ID) {
1747
+ this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
895
1748
  }
896
1749
  }
897
- return result;
898
1750
  }
899
1751
 
900
1752
  /**
901
- * Stages integrity-updated content for a closed document.
1753
+ * Remove the document for `uri`, closing every open of it first — the
1754
+ * editor's included, since the document it would keep editing is gone.
1755
+ *
1756
+ * The base removes the synced document without consulting the open table,
1757
+ * which leaves every open recorded against a document that no longer exists:
1758
+ * the store then answers that the URI is open, and the next open attaches to
1759
+ * nothing.
1760
+ */
1761
+ override delete(uri: string | URI | T): void {
1762
+ const key = this.documentKey((typeof uri === 'object' && 'uri' in uri ? uri.uri : uri).toString());
1763
+ this.untrackLanguageClientDocuments(key);
1764
+ for (const clientId of this.__sessions.clientsOf(key)) {
1765
+ this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
1766
+ }
1767
+ // A document waiting out the grace has no client left to close, and is
1768
+ // released now as its last close would have released it.
1769
+ if (this.__sessions.isRevertPending(key)) {
1770
+ this.__sessions.cancelRevert(key);
1771
+ this.releaseDocument(key);
1772
+ }
1773
+ super.delete(key);
1774
+ }
1775
+
1776
+ /**
1777
+ * A client id as log lines print it. An id containing `#` is cut eight
1778
+ * characters after its last `#`, which leaves a minted `label#uuid` as the
1779
+ * label and enough of the UUID to tell sessions apart; an id without `#`
1780
+ * prints whole. The full id goes out at trace level wherever a session
1781
+ * starts or ends.
1782
+ */
1783
+ protected formatClientId(clientId: string): string {
1784
+ const separator = clientId.lastIndexOf('#');
1785
+ return separator < 0 ? clientId : clientId.slice(0, separator + 9);
1786
+ }
1787
+
1788
+ /**
1789
+ * Stages integrity-updated content for a document no client holds.
902
1790
  *
903
1791
  * When `workspace/applyEdit` targets a closed file, the client opens it from
904
1792
  * disk (stale) and sends `didOpen` before applying the edit. This staged
905
1793
  * content is consumed by {@link notifyDidOpenTextDocument} to replace the
906
1794
  * stale disk text, preventing a brief revert of the integrity update.
907
1795
  *
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.
1796
+ * Only a FIRST open consumes it, so stage only for a URI no client holds
1797
+ * ({@link isOpenInAnyClient} is `false`). A URI held only through another
1798
+ * head is not closed: an editor attaching to it joins the existing entry and
1799
+ * never reads the stage, and the last close discards the stage with the
1800
+ * tracking record. An entry lingers only if `workspace/applyEdit` fails and
1801
+ * the file is never opened — the memory cost is one serialised string per
1802
+ * URI.
913
1803
  */
914
1804
  stagePendingContent(uri: DocumentUri, text: string): void {
915
1805
  this.trackingFor(this.documentKey(uri)).pendingContent = text;
@@ -942,8 +1832,9 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
942
1832
  * and the client applying, the ranges address the wrong lines and splice the
943
1833
  * buffer (observed as a duplicated declaration, which the integrity tier then
944
1834
  * "repairs" into a suffixed name and persists). The edit is therefore
945
- * addressed at the language client's LAST DECLARED VERSION rather than at
946
- * `null` ("version intentionally unknown"), which is what lets the client
1835
+ * addressed at the language client's last known version for that URI (see
1836
+ * {@link languageClientVersion}) rather than at `null` ("version
1837
+ * intentionally unknown"), which is what lets the client
947
1838
  * reject a push its buffer has outrun. On rejection the shadow is invalidated,
948
1839
  * so the caller's retry is a full-range replace — position-independent, and
949
1840
  * safe to apply to whatever the client now holds.
@@ -963,14 +1854,15 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
963
1854
  // when the same file was opened under a symlink and its real path. Falls back
964
1855
  // to the normalized URI when nothing is tracked. The shadow is keyed by each
965
1856
  // URI, so every diff is against the right baseline.
966
- const recorded = this.__documents.get(this.documentKey(uri))?.languageClientUris;
967
- const targets: Iterable<LanguageClientUri> = recorded && recorded.size > 0 ? recorded : [this.toLanguageClientUri(uri)];
1857
+ const recorded = this.__documents.get(this.documentKey(uri))?.languageClientDocuments;
1858
+ const targets: Iterable<LanguageClientUri> = recorded && recorded.size > 0 ? [...recorded.keys()] : [this.toLanguageClientUri(uri)];
968
1859
  let lastResult: ApplyWorkspaceEditResult | undefined;
969
1860
  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);
1861
+ // Read BEFORE computeEdits, which overwrites the baseline and drops
1862
+ // the opened snapshot. This is the text the client holds, and
1863
+ // therefore the only text its echo of this push can be reconstructed
1864
+ // against — whether or not the push below is keyed to it.
1865
+ const before = this.__shadow.clientText(targetUri);
974
1866
  const edits = this.__shadow.computeEdits(targetUri, newText);
975
1867
  if (edits.length === 0) {
976
1868
  continue;
@@ -984,14 +1876,42 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
984
1876
  // stale-by-one version into a refused update for no safety gain — and
985
1877
  // it is exactly what the caller retries with after a rejection, so
986
1878
  // gating it there would refuse the recovery too.
987
- const version = isFullReplace(edits) ? UNKNOWN_CLIENT_VERSION : this.languageClientVersion(uri);
1879
+ const version = isFullReplace(edits) ? UNKNOWN_CLIENT_VERSION : this.languageClientVersion(uri, targetUri);
1880
+ // Captured before the await: a reopen replaces the state, so a late
1881
+ // reply then writes to the old one instead of the new buffer's.
1882
+ const languageClientDocument = recorded?.get(targetUri);
1883
+ // A full `ApplyWorkspaceEditParams`, `edit` and all — NOT a bare
1884
+ // `WorkspaceEdit` with a `label` beside it. `applyEdit` takes
1885
+ // `ApplyWorkspaceEditParams | WorkspaceEdit` and discriminates on
1886
+ // `!!value.edit`, so `{ label, documentChanges }` is wrapped as
1887
+ // `{ edit: { label, documentChanges } }` — putting the label inside
1888
+ // the edit, where LSP defines no such field and no client reads it.
1889
+ // The union is also what hides it at compile time: excess-property
1890
+ // checking admits a property present in EITHER member, so an object
1891
+ // matching neither type-checks against the union.
988
1892
  const result = await connection.workspace.applyEdit({
989
1893
  label: options?.label,
990
- documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1894
+ edit: {
1895
+ documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1896
+ }
991
1897
  });
992
1898
  if (result && result.applied === false) {
993
1899
  this.__shadow.invalidate(targetUri);
994
1900
  this.__pendingPushes.delete(targetUri);
1901
+ if (version !== UNKNOWN_CLIENT_VERSION) {
1902
+ this.tracer
1903
+ .with(uri)
1904
+ .warn(
1905
+ `Language client refused applyEdit addressed at version ${version} (it last declared version ${languageClientDocument?.declaredVersion})`
1906
+ );
1907
+ }
1908
+ } else if (result?.applied && version !== UNKNOWN_CLIENT_VERSION && languageClientDocument) {
1909
+ // A client steps once per applied edit that changes its buffer. A
1910
+ // line edit does while the shadow is right; a full replace may be a
1911
+ // no-op the client drops. Left to the echo, a push sent first is
1912
+ // refused; advanced after a no-op, one could pass at a version a
1913
+ // keystroke reached.
1914
+ languageClientDocument.pushedVersion = version + 1;
995
1915
  }
996
1916
  lastResult = result;
997
1917
  } catch (err) {
@@ -1004,21 +1924,27 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1004
1924
  }
1005
1925
 
1006
1926
  /**
1007
- * The version the LSP textual language client last declared for `uri`, for
1008
- * addressing an outgoing `workspace/applyEdit`.
1927
+ * The version the LSP textual language client holds `uri` at under
1928
+ * `targetUri`, for addressing an outgoing `workspace/applyEdit`.
1009
1929
  *
1010
1930
  * Client version ids are CLIENT-owned per LSP, so this is the id the client
1011
- * itself stamped on its last `didOpen` / `didChange` — never the shared
1012
- * server version, which advances on authored writes the client knows nothing
1013
- * about and would therefore reject every push.
1931
+ * itself stamped on its last `didOpen` / `didChange` for `targetUri`, or the
1932
+ * one an applied push moved it to ahead of the push's echo
1933
+ * ({@link LanguageClientDocumentState}) — never the shared server version,
1934
+ * which advances on authored writes the client knows nothing about and would
1935
+ * therefore reject every push.
1014
1936
  *
1015
- * Falls back to {@link UNKNOWN_CLIENT_VERSION} when the client has never
1016
- * declared one, which is the honest answer for a document it has not opened.
1017
- * That is also the case in which there is no shadow, so the push is already a
1937
+ * Falls back to {@link UNKNOWN_CLIENT_VERSION} when the client has not opened
1938
+ * the document under `targetUri`, which is the honest answer. That is also
1939
+ * the case in which there is no shadow, so the push is already a
1018
1940
  * position-independent full replace and has nothing to gain from a gate.
1019
1941
  */
1020
- protected languageClientVersion(uri: DocumentUri): number {
1021
- return this.__documents.get(this.documentKey(uri))?.clientVersions.get(LANGUAGE_CLIENT_ID) ?? UNKNOWN_CLIENT_VERSION;
1942
+ protected languageClientVersion(uri: DocumentUri, targetUri: LanguageClientUri = this.toLanguageClientUri(uri)): number {
1943
+ const state = this.__documents.get(this.documentKey(uri))?.languageClientDocuments?.get(targetUri);
1944
+ if (state === undefined) {
1945
+ return UNKNOWN_CLIENT_VERSION;
1946
+ }
1947
+ return Math.max(state.declaredVersion, state.pushedVersion ?? state.declaredVersion);
1022
1948
  }
1023
1949
 
1024
1950
  /**
@@ -1034,7 +1960,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1034
1960
  pending = [];
1035
1961
  this.__pendingPushes.set(targetUri, pending);
1036
1962
  }
1037
- pending.push({ before, afterHash: contentHash(newText) });
1963
+ pending.push({ before, afterHash: textHash(newText) });
1038
1964
  if (pending.length > PENDING_ECHO_CAP) {
1039
1965
  pending.shift();
1040
1966
  this.logUri(targetUri, `Pending-echo queue exceeded ${PENDING_ECHO_CAP} entries; dropped the oldest`, 'debug');
@@ -1043,12 +1969,17 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1043
1969
 
1044
1970
  /**
1045
1971
  * 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`.
1972
+ * reconstructing the client's resulting buffer against the text its ranges
1973
+ * address — the pre-push buffer of the OLDEST push still in flight for
1974
+ * `clientFacing`, else whatever that client is believed to hold.
1048
1975
  *
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.
1976
+ * `undefined` when the client's ranges address the synced text itself —
1977
+ * nothing in flight, and no evidence the client holds anything else. That
1978
+ * is the ordinary path, and the caller then applies the ranges directly.
1979
+ * The two texts part company without a push in flight whenever a client
1980
+ * attaches to a document another client has already written: it opened
1981
+ * from disk, so applying its ranges to the synced text splices lines they
1982
+ * never addressed.
1052
1983
  *
1053
1984
  * **Why the oldest, and why reconstruct at all.** The client applies our
1054
1985
  * pushes in order and echoes each against the buffer it held before that
@@ -1079,22 +2010,41 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1079
2010
  document: T,
1080
2011
  changes: TextDocumentContentChangeEvent[]
1081
2012
  ): LanguageClientChangeOrigin | undefined {
1082
- const pending = this.__pendingPushes.get(clientFacing);
1083
- if (pending === undefined || pending.length === 0) {
2013
+ const queued = this.__pendingPushes.get(clientFacing);
2014
+ const pending = queued?.length ? queued : undefined;
2015
+ // The text the client's ranges address: the buffer it held before the
2016
+ // oldest push still in flight, else the buffer it is believed to hold.
2017
+ const clientText = pending?.[0].before ?? this.__shadow.clientText(clientFacing);
2018
+ if (pending === undefined && (clientText === undefined || clientText === document.getText())) {
1084
2019
  return undefined;
1085
2020
  }
2021
+ if (pending !== undefined && pending[0].before === undefined && changes.some(change => ContentChange.isIncremental(change))) {
2022
+ // A push sent to a client whose buffer was unknown — a first sync, or
2023
+ // the recovery push after a rejection. The shadow now holds the text
2024
+ // that push MOVES the client to, which is the one text the echo's
2025
+ // ranges provably do not address, so the fallback above is a baseline
2026
+ // known to be wrong rather than merely unverified. Drop the queue and
2027
+ // the shadow: the next sync is then a full replace, which lands on
2028
+ // whatever the client holds. A full-text change is exempt because it
2029
+ // reconstructs identically against any baseline.
2030
+ pending.length = 0;
2031
+ this.__shadow.invalidate(clientFacing);
2032
+ return { kind: 'unreconstructable' };
2033
+ }
1086
2034
  // Through the configured factories, not `TextDocument` directly, so an
1087
2035
  // adopter's custom text-document type governs how the ranges are applied
1088
2036
  // 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' };
2037
+ const probe = this.create(clientFacing, document.languageId, 0, clientText ?? document.getText());
2038
+ const reconstructed = this.update(probe, changes, 0).getText();
2039
+ if (pending !== undefined) {
2040
+ const matchIndex = pending.findIndex(push => push.afterHash === textHash(reconstructed));
2041
+ if (matchIndex >= 0) {
2042
+ pending.splice(0, matchIndex + 1);
2043
+ return { kind: 'echo' };
2044
+ }
2045
+ pending.length = 0;
1095
2046
  }
1096
- pending.length = 0;
1097
- return { kind: 'divergent', text: clientText };
2047
+ return { kind: 'divergent', text: reconstructed };
1098
2048
  }
1099
2049
 
1100
2050
  /**