@hydranium/core 1.0.0-next.35 → 1.0.0-next.351

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 (779) hide show
  1. package/README.md +78 -82
  2. package/lib/browser/host-heads-in-worker.d.ts +77 -0
  3. package/lib/browser/host-heads-in-worker.d.ts.map +1 -0
  4. package/lib/browser/host-heads-in-worker.js +169 -0
  5. package/lib/browser/host-heads-in-worker.js.map +1 -0
  6. package/lib/browser/index.d.ts +12 -0
  7. package/lib/browser/index.d.ts.map +1 -0
  8. package/lib/browser/index.js +17 -0
  9. package/lib/browser/index.js.map +1 -0
  10. package/lib/browser/indexeddb-file-system-store.d.ts +82 -0
  11. package/lib/browser/indexeddb-file-system-store.d.ts.map +1 -0
  12. package/lib/browser/indexeddb-file-system-store.js +181 -0
  13. package/lib/browser/indexeddb-file-system-store.js.map +1 -0
  14. package/lib/browser/start-language-server-in-worker.d.ts +32 -0
  15. package/lib/browser/start-language-server-in-worker.d.ts.map +1 -0
  16. package/lib/browser/start-language-server-in-worker.js +40 -0
  17. package/lib/browser/start-language-server-in-worker.js.map +1 -0
  18. package/lib/documents/ast-document-manager.d.ts +266 -124
  19. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  20. package/lib/documents/ast-document-manager.js +293 -132
  21. package/lib/documents/ast-document-manager.js.map +1 -1
  22. package/lib/documents/client-ids.d.ts +5 -19
  23. package/lib/documents/client-ids.d.ts.map +1 -1
  24. package/lib/documents/client-ids.js +5 -19
  25. package/lib/documents/client-ids.js.map +1 -1
  26. package/lib/documents/client-session-errors.d.ts +15 -0
  27. package/lib/documents/client-session-errors.d.ts.map +1 -0
  28. package/lib/documents/client-session-errors.js +15 -0
  29. package/lib/documents/client-session-errors.js.map +1 -0
  30. package/lib/documents/client-session-registry.d.ts +146 -0
  31. package/lib/documents/client-session-registry.d.ts.map +1 -0
  32. package/lib/documents/client-session-registry.js +207 -0
  33. package/lib/documents/client-session-registry.js.map +1 -0
  34. package/lib/documents/dirty-state-tracker.d.ts +101 -0
  35. package/lib/documents/dirty-state-tracker.d.ts.map +1 -0
  36. package/lib/documents/dirty-state-tracker.js +85 -0
  37. package/lib/documents/dirty-state-tracker.js.map +1 -0
  38. package/lib/documents/document-release-handler.d.ts +204 -0
  39. package/lib/documents/document-release-handler.d.ts.map +1 -0
  40. package/lib/documents/document-release-handler.js +317 -0
  41. package/lib/documents/document-release-handler.js.map +1 -0
  42. package/lib/documents/document-release-scheduler.d.ts +67 -0
  43. package/lib/documents/document-release-scheduler.d.ts.map +1 -0
  44. package/lib/documents/document-release-scheduler.js +80 -0
  45. package/lib/documents/document-release-scheduler.js.map +1 -0
  46. package/lib/documents/file-system-task-queue.d.ts +52 -0
  47. package/lib/documents/file-system-task-queue.d.ts.map +1 -0
  48. package/lib/documents/file-system-task-queue.js +37 -0
  49. package/lib/documents/file-system-task-queue.js.map +1 -0
  50. package/lib/documents/hydranium-text-documents.d.ts +445 -282
  51. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  52. package/lib/documents/hydranium-text-documents.js +903 -483
  53. package/lib/documents/hydranium-text-documents.js.map +1 -1
  54. package/lib/documents/index.d.ts +10 -2
  55. package/lib/documents/index.d.ts.map +1 -1
  56. package/lib/documents/index.js +10 -2
  57. package/lib/documents/index.js.map +1 -1
  58. package/lib/documents/language-client-shadow.d.ts +234 -0
  59. package/lib/documents/language-client-shadow.d.ts.map +1 -0
  60. package/lib/documents/language-client-shadow.js +306 -0
  61. package/lib/documents/language-client-shadow.js.map +1 -0
  62. package/lib/documents/model-ledger.d.ts +47 -0
  63. package/lib/documents/model-ledger.d.ts.map +1 -0
  64. package/lib/documents/model-ledger.js +30 -0
  65. package/lib/documents/model-ledger.js.map +1 -0
  66. package/lib/documents/text-ledger.d.ts +64 -0
  67. package/lib/documents/text-ledger.d.ts.map +1 -0
  68. package/lib/documents/text-ledger.js +63 -0
  69. package/lib/documents/text-ledger.js.map +1 -0
  70. package/lib/documents/version-sync-service.d.ts +138 -0
  71. package/lib/documents/version-sync-service.d.ts.map +1 -0
  72. package/lib/documents/version-sync-service.js +173 -0
  73. package/lib/documents/version-sync-service.js.map +1 -0
  74. package/lib/index.d.ts +4 -0
  75. package/lib/index.d.ts.map +1 -1
  76. package/lib/index.js +7 -0
  77. package/lib/index.js.map +1 -1
  78. package/lib/langium/ast-extension/ast-extension-service.d.ts +42 -31
  79. package/lib/langium/ast-extension/ast-extension-service.d.ts.map +1 -1
  80. package/lib/langium/ast-extension/ast-extension-service.js +45 -19
  81. package/lib/langium/ast-extension/ast-extension-service.js.map +1 -1
  82. package/lib/langium/ast-extension/ast-node-builder.d.ts +12 -7
  83. package/lib/langium/ast-extension/ast-node-builder.d.ts.map +1 -1
  84. package/lib/langium/ast-extension/ast-node-builder.js +25 -9
  85. package/lib/langium/ast-extension/ast-node-builder.js.map +1 -1
  86. package/lib/langium/bootstrap.d.ts +49 -36
  87. package/lib/langium/bootstrap.d.ts.map +1 -1
  88. package/lib/langium/bootstrap.js +83 -44
  89. package/lib/langium/bootstrap.js.map +1 -1
  90. package/lib/langium/build-phase-pass/build-phase-pass-service.d.ts +9 -9
  91. package/lib/langium/build-phase-pass/build-phase-pass-service.d.ts.map +1 -1
  92. package/lib/langium/build-phase-pass/build-phase-pass-service.js +25 -16
  93. package/lib/langium/build-phase-pass/build-phase-pass-service.js.map +1 -1
  94. package/lib/langium/build-phase-pass/build-phase-pass.d.ts +20 -8
  95. package/lib/langium/build-phase-pass/build-phase-pass.d.ts.map +1 -1
  96. package/lib/langium/build-phase-pass/build-phase-pass.js +20 -0
  97. package/lib/langium/build-phase-pass/build-phase-pass.js.map +1 -1
  98. package/lib/langium/composite-ast-reflection.d.ts +46 -0
  99. package/lib/langium/composite-ast-reflection.d.ts.map +1 -0
  100. package/lib/langium/composite-ast-reflection.js +97 -0
  101. package/lib/langium/composite-ast-reflection.js.map +1 -0
  102. package/lib/langium/config/configuration-provider.d.ts +81 -0
  103. package/lib/langium/config/configuration-provider.d.ts.map +1 -0
  104. package/lib/langium/config/configuration-provider.js +112 -0
  105. package/lib/langium/config/configuration-provider.js.map +1 -0
  106. package/lib/langium/config/index.d.ts +1 -0
  107. package/lib/langium/config/index.d.ts.map +1 -1
  108. package/lib/langium/config/index.js +1 -0
  109. package/lib/langium/config/index.js.map +1 -1
  110. package/lib/langium/config/settings.js.map +1 -1
  111. package/lib/langium/diagnostics/console-tee.d.ts +27 -0
  112. package/lib/langium/diagnostics/console-tee.d.ts.map +1 -0
  113. package/lib/langium/diagnostics/console-tee.js +142 -0
  114. package/lib/langium/diagnostics/console-tee.js.map +1 -0
  115. package/lib/langium/diagnostics/format-log-arg.d.ts +18 -0
  116. package/lib/langium/diagnostics/format-log-arg.d.ts.map +1 -0
  117. package/lib/langium/diagnostics/format-log-arg.js +36 -0
  118. package/lib/langium/diagnostics/format-log-arg.js.map +1 -0
  119. package/lib/langium/diagnostics/hydranium-langium-profiler.d.ts +9 -20
  120. package/lib/langium/diagnostics/hydranium-langium-profiler.d.ts.map +1 -1
  121. package/lib/langium/diagnostics/hydranium-langium-profiler.js +15 -24
  122. package/lib/langium/diagnostics/hydranium-langium-profiler.js.map +1 -1
  123. package/lib/langium/diagnostics/log-preamble.js +1 -1
  124. package/lib/langium/diagnostics/log-preamble.js.map +1 -1
  125. package/lib/langium/diagnostics/logger.d.ts +25 -6
  126. package/lib/langium/diagnostics/logger.d.ts.map +1 -1
  127. package/lib/langium/diagnostics/logger.js +49 -17
  128. package/lib/langium/diagnostics/logger.js.map +1 -1
  129. package/lib/langium/diagnostics/lsp-logger.d.ts +25 -0
  130. package/lib/langium/diagnostics/lsp-logger.d.ts.map +1 -1
  131. package/lib/langium/diagnostics/lsp-logger.js +81 -45
  132. package/lib/langium/diagnostics/lsp-logger.js.map +1 -1
  133. package/lib/langium/diagnostics/server-tracer.d.ts.map +1 -1
  134. package/lib/langium/diagnostics/server-tracer.js.map +1 -1
  135. package/lib/langium/document-builder/build-pipeline-integration.d.ts +21 -1
  136. package/lib/langium/document-builder/build-pipeline-integration.d.ts.map +1 -1
  137. package/lib/langium/document-builder/build-pipeline-integration.js +28 -30
  138. package/lib/langium/document-builder/build-pipeline-integration.js.map +1 -1
  139. package/lib/langium/document-builder/build-session.d.ts +6 -5
  140. package/lib/langium/document-builder/build-session.d.ts.map +1 -1
  141. package/lib/langium/document-builder/build-session.js +5 -5
  142. package/lib/langium/document-builder/build-session.js.map +1 -1
  143. package/lib/langium/document-builder/document-builder.d.ts +310 -41
  144. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  145. package/lib/langium/document-builder/document-builder.js +490 -79
  146. package/lib/langium/document-builder/document-builder.js.map +1 -1
  147. package/lib/langium/documentation/comment-provider.d.ts.map +1 -1
  148. package/lib/langium/documentation/comment-provider.js.map +1 -1
  149. package/lib/langium/integration-services.d.ts +40 -24
  150. package/lib/langium/integration-services.d.ts.map +1 -1
  151. package/lib/langium/integration-services.js +7 -4
  152. package/lib/langium/integration-services.js.map +1 -1
  153. package/lib/langium/integrity/integrity-rule.d.ts +17 -8
  154. package/lib/langium/integrity/integrity-rule.d.ts.map +1 -1
  155. package/lib/langium/integrity/integrity-rule.js.map +1 -1
  156. package/lib/langium/integrity/integrity-service.d.ts +95 -27
  157. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  158. package/lib/langium/integrity/integrity-service.js +303 -63
  159. package/lib/langium/integrity/integrity-service.js.map +1 -1
  160. package/lib/langium/keys/name-based-key-provider.d.ts.map +1 -1
  161. package/lib/langium/keys/name-based-key-provider.js.map +1 -1
  162. package/lib/langium/keys/positional-key-provider.d.ts.map +1 -1
  163. package/lib/langium/keys/positional-key-provider.js.map +1 -1
  164. package/lib/langium/labeling/label-provider.d.ts.map +1 -1
  165. package/lib/langium/labeling/label-provider.js.map +1 -1
  166. package/lib/langium/language-module.d.ts +30 -5
  167. package/lib/langium/language-module.d.ts.map +1 -1
  168. package/lib/langium/language-module.js +18 -4
  169. package/lib/langium/language-module.js.map +1 -1
  170. package/lib/langium/model-service/client-session.d.ts +383 -0
  171. package/lib/langium/model-service/client-session.d.ts.map +1 -0
  172. package/lib/langium/model-service/client-session.js +455 -0
  173. package/lib/langium/model-service/client-session.js.map +1 -0
  174. package/lib/langium/model-service/index.d.ts +2 -0
  175. package/lib/langium/model-service/index.d.ts.map +1 -1
  176. package/lib/langium/model-service/index.js +2 -0
  177. package/lib/langium/model-service/index.js.map +1 -1
  178. package/lib/langium/model-service/model-events.d.ts +66 -0
  179. package/lib/langium/model-service/model-events.d.ts.map +1 -0
  180. package/lib/langium/model-service/model-events.js +10 -0
  181. package/lib/langium/model-service/model-events.js.map +1 -0
  182. package/lib/langium/model-service/model-service.d.ts +456 -300
  183. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  184. package/lib/langium/model-service/model-service.js +571 -466
  185. package/lib/langium/model-service/model-service.js.map +1 -1
  186. package/lib/langium/module.d.ts +141 -40
  187. package/lib/langium/module.d.ts.map +1 -1
  188. package/lib/langium/module.js +52 -40
  189. package/lib/langium/module.js.map +1 -1
  190. package/lib/langium/naming/name-provider.d.ts +32 -7
  191. package/lib/langium/naming/name-provider.d.ts.map +1 -1
  192. package/lib/langium/naming/name-provider.js +10 -4
  193. package/lib/langium/naming/name-provider.js.map +1 -1
  194. package/lib/langium/naming/name-separator-validation.d.ts.map +1 -1
  195. package/lib/langium/project/abstract-project-manager.d.ts.map +1 -1
  196. package/lib/langium/project/abstract-project-manager.js +13 -6
  197. package/lib/langium/project/abstract-project-manager.js.map +1 -1
  198. package/lib/langium/project/project-change-event.d.ts +2 -2
  199. package/lib/langium/project/single-project-manager.d.ts.map +1 -1
  200. package/lib/langium/project/single-project-manager.js.map +1 -1
  201. package/lib/langium/residency/cst-residency-service.d.ts +38 -14
  202. package/lib/langium/residency/cst-residency-service.d.ts.map +1 -1
  203. package/lib/langium/residency/cst-residency-service.js +24 -58
  204. package/lib/langium/residency/cst-residency-service.js.map +1 -1
  205. package/lib/langium/scope/ast-node-description-provider.d.ts.map +1 -1
  206. package/lib/langium/scope/ast-node-description-provider.js.map +1 -1
  207. package/lib/langium/scope/fallback-scope.d.ts +27 -0
  208. package/lib/langium/scope/fallback-scope.d.ts.map +1 -0
  209. package/lib/langium/scope/fallback-scope.js +35 -0
  210. package/lib/langium/scope/fallback-scope.js.map +1 -0
  211. package/lib/langium/scope/hydranium-scope-computation.d.ts +21 -18
  212. package/lib/langium/scope/hydranium-scope-computation.d.ts.map +1 -1
  213. package/lib/langium/scope/hydranium-scope-computation.js +38 -39
  214. package/lib/langium/scope/hydranium-scope-computation.js.map +1 -1
  215. package/lib/langium/scope/hydranium-scope-provider.d.ts +115 -32
  216. package/lib/langium/scope/hydranium-scope-provider.d.ts.map +1 -1
  217. package/lib/langium/scope/hydranium-scope-provider.js +201 -39
  218. package/lib/langium/scope/hydranium-scope-provider.js.map +1 -1
  219. package/lib/langium/scope/index.d.ts +1 -0
  220. package/lib/langium/scope/index.d.ts.map +1 -1
  221. package/lib/langium/scope/index.js +1 -0
  222. package/lib/langium/scope/index.js.map +1 -1
  223. package/lib/langium/scope/reference-builder.d.ts.map +1 -1
  224. package/lib/langium/scope/reference-builder.js.map +1 -1
  225. package/lib/langium/scope/reference-candidate-provider.d.ts +6 -5
  226. package/lib/langium/scope/reference-candidate-provider.d.ts.map +1 -1
  227. package/lib/langium/scope/reference-candidate-provider.js +3 -2
  228. package/lib/langium/scope/reference-candidate-provider.js.map +1 -1
  229. package/lib/langium/scope/scope-extension-service.d.ts +69 -37
  230. package/lib/langium/scope/scope-extension-service.d.ts.map +1 -1
  231. package/lib/langium/scope/scope-extension-service.js +102 -29
  232. package/lib/langium/scope/scope-extension-service.js.map +1 -1
  233. package/lib/langium/scope/scoped-ast-node-description.d.ts +1 -3
  234. package/lib/langium/scope/scoped-ast-node-description.d.ts.map +1 -1
  235. package/lib/langium/scope/scoped-ast-node-description.js +1 -3
  236. package/lib/langium/scope/scoped-ast-node-description.js.map +1 -1
  237. package/lib/langium/serialization/abstract-serializer.d.ts +38 -27
  238. package/lib/langium/serialization/abstract-serializer.d.ts.map +1 -1
  239. package/lib/langium/serialization/abstract-serializer.js +54 -30
  240. package/lib/langium/serialization/abstract-serializer.js.map +1 -1
  241. package/lib/langium/serialization/json-serializer.d.ts +8 -1
  242. package/lib/langium/serialization/json-serializer.d.ts.map +1 -1
  243. package/lib/langium/serialization/json-serializer.js +13 -3
  244. package/lib/langium/serialization/json-serializer.js.map +1 -1
  245. package/lib/langium/serialization/serializer.d.ts +1 -1
  246. package/lib/langium/serialization/yaml-serializer.d.ts +12 -0
  247. package/lib/langium/serialization/yaml-serializer.d.ts.map +1 -1
  248. package/lib/langium/serialization/yaml-serializer.js +24 -4
  249. package/lib/langium/serialization/yaml-serializer.js.map +1 -1
  250. package/lib/langium/service-registry.d.ts +29 -1
  251. package/lib/langium/service-registry.d.ts.map +1 -1
  252. package/lib/langium/service-registry.js +37 -0
  253. package/lib/langium/service-registry.js.map +1 -1
  254. package/lib/langium/shared-services.d.ts +13 -7
  255. package/lib/langium/shared-services.d.ts.map +1 -1
  256. package/lib/langium/shared-services.js.map +1 -1
  257. package/lib/langium/single-langium-copy.d.ts +22 -0
  258. package/lib/langium/single-langium-copy.d.ts.map +1 -0
  259. package/lib/langium/single-langium-copy.js +36 -0
  260. package/lib/langium/single-langium-copy.js.map +1 -0
  261. package/lib/langium/transfer/transfer-encoder.d.ts +86 -37
  262. package/lib/langium/transfer/transfer-encoder.d.ts.map +1 -1
  263. package/lib/langium/transfer/transfer-encoder.js +36 -25
  264. package/lib/langium/transfer/transfer-encoder.js.map +1 -1
  265. package/lib/langium/trivia/comment-preserver.d.ts +543 -0
  266. package/lib/langium/trivia/comment-preserver.d.ts.map +1 -0
  267. package/lib/langium/trivia/comment-preserver.js +1164 -0
  268. package/lib/langium/trivia/comment-preserver.js.map +1 -0
  269. package/lib/langium/trivia/document-ending-preserver.d.ts +51 -0
  270. package/lib/langium/trivia/document-ending-preserver.d.ts.map +1 -0
  271. package/lib/langium/trivia/document-ending-preserver.js +62 -0
  272. package/lib/langium/trivia/document-ending-preserver.js.map +1 -0
  273. package/lib/langium/trivia/index.d.ts +14 -0
  274. package/lib/langium/trivia/index.d.ts.map +1 -0
  275. package/lib/langium/trivia/index.js +14 -0
  276. package/lib/langium/trivia/index.js.map +1 -0
  277. package/lib/langium/trivia/trivia-contribution.d.ts +37 -0
  278. package/lib/langium/trivia/trivia-contribution.d.ts.map +1 -0
  279. package/lib/langium/trivia/trivia-contribution.js +10 -0
  280. package/lib/langium/trivia/trivia-contribution.js.map +1 -0
  281. package/lib/langium/trivia/trivia-preserver.d.ts +52 -0
  282. package/lib/langium/trivia/trivia-preserver.d.ts.map +1 -0
  283. package/lib/langium/trivia/trivia-preserver.js +10 -0
  284. package/lib/langium/trivia/trivia-preserver.js.map +1 -0
  285. package/lib/langium/trivia/trivia-service.d.ts +70 -0
  286. package/lib/langium/trivia/trivia-service.d.ts.map +1 -0
  287. package/lib/langium/trivia/trivia-service.js +56 -0
  288. package/lib/langium/trivia/trivia-service.js.map +1 -0
  289. package/lib/langium/update-rewrite/normalize-empty-strings.d.ts.map +1 -1
  290. package/lib/langium/update-rewrite/update-rewrite-service.d.ts +1 -1
  291. package/lib/langium/update-rewrite/update-rewrite-service.d.ts.map +1 -1
  292. package/lib/langium/update-rewrite/update-rewrite-service.js.map +1 -1
  293. package/lib/langium/update-rewrite/update-rewrite.d.ts +1 -1
  294. package/lib/langium/validation/document-validator.d.ts +168 -23
  295. package/lib/langium/validation/document-validator.d.ts.map +1 -1
  296. package/lib/langium/validation/document-validator.js +264 -26
  297. package/lib/langium/validation/document-validator.js.map +1 -1
  298. package/lib/langium/validation/validation-contribution-collector.d.ts +9 -1
  299. package/lib/langium/validation/validation-contribution-collector.d.ts.map +1 -1
  300. package/lib/langium/validation/validation-contribution-collector.js +1 -1
  301. package/lib/langium/validation/validation-contribution-collector.js.map +1 -1
  302. package/lib/langium/workspace/document-uri-policy.d.ts +24 -15
  303. package/lib/langium/workspace/document-uri-policy.d.ts.map +1 -1
  304. package/lib/langium/workspace/document-uri-policy.js +22 -13
  305. package/lib/langium/workspace/document-uri-policy.js.map +1 -1
  306. package/lib/langium/workspace/file-not-found.d.ts +23 -0
  307. package/lib/langium/workspace/file-not-found.d.ts.map +1 -0
  308. package/lib/langium/workspace/file-not-found.js +31 -0
  309. package/lib/langium/workspace/file-not-found.js.map +1 -0
  310. package/lib/langium/workspace/file-system-provider.d.ts +167 -8
  311. package/lib/langium/workspace/file-system-provider.d.ts.map +1 -1
  312. package/lib/langium/workspace/file-system-provider.js +115 -13
  313. package/lib/langium/workspace/file-system-provider.js.map +1 -1
  314. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts +31 -1
  315. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts.map +1 -1
  316. package/lib/langium/workspace/hydranium-langium-document-factory.js +54 -0
  317. package/lib/langium/workspace/hydranium-langium-document-factory.js.map +1 -1
  318. package/lib/langium/workspace/hydranium-workspace-lock.d.ts +43 -1
  319. package/lib/langium/workspace/hydranium-workspace-lock.d.ts.map +1 -1
  320. package/lib/langium/workspace/hydranium-workspace-lock.js +58 -1
  321. package/lib/langium/workspace/hydranium-workspace-lock.js.map +1 -1
  322. package/lib/langium/workspace/hydranium-workspace-manager.d.ts +103 -21
  323. package/lib/langium/workspace/hydranium-workspace-manager.d.ts.map +1 -1
  324. package/lib/langium/workspace/hydranium-workspace-manager.js +184 -31
  325. package/lib/langium/workspace/hydranium-workspace-manager.js.map +1 -1
  326. package/lib/langium/workspace/in-memory-file-system-provider.d.ts +26 -9
  327. package/lib/langium/workspace/in-memory-file-system-provider.d.ts.map +1 -1
  328. package/lib/langium/workspace/in-memory-file-system-provider.js +45 -22
  329. package/lib/langium/workspace/in-memory-file-system-provider.js.map +1 -1
  330. package/lib/langium/workspace/index-manager.d.ts.map +1 -1
  331. package/lib/langium/workspace/index-manager.js.map +1 -1
  332. package/lib/langium/workspace/index.d.ts +1 -0
  333. package/lib/langium/workspace/index.d.ts.map +1 -1
  334. package/lib/langium/workspace/index.js +1 -0
  335. package/lib/langium/workspace/index.js.map +1 -1
  336. package/lib/langium/workspace/initialize-workspace.d.ts +40 -3
  337. package/lib/langium/workspace/initialize-workspace.d.ts.map +1 -1
  338. package/lib/langium/workspace/initialize-workspace.js +68 -3
  339. package/lib/langium/workspace/initialize-workspace.js.map +1 -1
  340. package/lib/langium/workspace/langium-documents.d.ts +7 -0
  341. package/lib/langium/workspace/langium-documents.d.ts.map +1 -1
  342. package/lib/langium/workspace/langium-documents.js +18 -1
  343. package/lib/langium/workspace/langium-documents.js.map +1 -1
  344. package/lib/langium/workspace/persistent-file-system-provider.d.ts +16 -10
  345. package/lib/langium/workspace/persistent-file-system-provider.d.ts.map +1 -1
  346. package/lib/langium/workspace/persistent-file-system-provider.js +25 -13
  347. package/lib/langium/workspace/persistent-file-system-provider.js.map +1 -1
  348. package/lib/langium/workspace/self-save-registry.d.ts +84 -0
  349. package/lib/langium/workspace/self-save-registry.d.ts.map +1 -0
  350. package/lib/{documents → langium/workspace}/self-save-registry.js +33 -22
  351. package/lib/langium/workspace/self-save-registry.js.map +1 -0
  352. package/lib/langium/workspace/virtual-document.d.ts +55 -21
  353. package/lib/langium/workspace/virtual-document.d.ts.map +1 -1
  354. package/lib/langium/workspace/virtual-document.js +87 -24
  355. package/lib/langium/workspace/virtual-document.js.map +1 -1
  356. package/lib/langium/workspace/write-lock-scope.d.ts +22 -10
  357. package/lib/langium/workspace/write-lock-scope.d.ts.map +1 -1
  358. package/lib/langium/workspace/write-lock-scope.js +15 -5
  359. package/lib/langium/workspace/write-lock-scope.js.map +1 -1
  360. package/lib/launcher/integrated-server.d.ts +3 -1
  361. package/lib/launcher/integrated-server.d.ts.map +1 -1
  362. package/lib/locale/server-locale.d.ts +19 -5
  363. package/lib/locale/server-locale.d.ts.map +1 -1
  364. package/lib/locale/server-locale.js +1 -1
  365. package/lib/locale/server-locale.js.map +1 -1
  366. package/lib/lsp/completion/hydranium-completion-provider.d.ts +44 -2
  367. package/lib/lsp/completion/hydranium-completion-provider.d.ts.map +1 -1
  368. package/lib/lsp/completion/hydranium-completion-provider.js +68 -0
  369. package/lib/lsp/completion/hydranium-completion-provider.js.map +1 -1
  370. package/lib/lsp/connection-features.d.ts +23 -0
  371. package/lib/lsp/connection-features.d.ts.map +1 -0
  372. package/lib/lsp/connection-features.js +60 -0
  373. package/lib/lsp/connection-features.js.map +1 -0
  374. package/lib/lsp/diagnostics-connection.d.ts +27 -0
  375. package/lib/lsp/diagnostics-connection.d.ts.map +1 -0
  376. package/lib/lsp/diagnostics-connection.js +53 -0
  377. package/lib/lsp/diagnostics-connection.js.map +1 -0
  378. package/lib/lsp/edit-requests-guard.d.ts +31 -0
  379. package/lib/lsp/edit-requests-guard.d.ts.map +1 -0
  380. package/lib/lsp/edit-requests-guard.js +125 -0
  381. package/lib/lsp/edit-requests-guard.js.map +1 -0
  382. package/lib/lsp/hydranium-document-update-handler.d.ts +126 -82
  383. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  384. package/lib/lsp/hydranium-document-update-handler.js +235 -102
  385. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  386. package/lib/lsp/index.d.ts +6 -2
  387. package/lib/lsp/index.d.ts.map +1 -1
  388. package/lib/lsp/index.js +6 -2
  389. package/lib/lsp/index.js.map +1 -1
  390. package/lib/lsp/lsp-latency.d.ts +39 -0
  391. package/lib/lsp/lsp-latency.d.ts.map +1 -0
  392. package/lib/lsp/lsp-latency.js +58 -0
  393. package/lib/lsp/lsp-latency.js.map +1 -0
  394. package/lib/lsp/save-text-document.d.ts +31 -0
  395. package/lib/lsp/save-text-document.d.ts.map +1 -0
  396. package/lib/lsp/save-text-document.js +52 -0
  397. package/lib/lsp/save-text-document.js.map +1 -0
  398. package/lib/lsp/semantic-token-provider.d.ts +58 -2
  399. package/lib/lsp/semantic-token-provider.d.ts.map +1 -1
  400. package/lib/lsp/semantic-token-provider.js +98 -9
  401. package/lib/lsp/semantic-token-provider.js.map +1 -1
  402. package/lib/lsp/shared-module.d.ts +4 -8
  403. package/lib/lsp/shared-module.d.ts.map +1 -1
  404. package/lib/lsp/shared-module.js.map +1 -1
  405. package/lib/lsp/start-language-server.d.ts +12 -7
  406. package/lib/lsp/start-language-server.d.ts.map +1 -1
  407. package/lib/lsp/start-language-server.js +21 -7
  408. package/lib/lsp/start-language-server.js.map +1 -1
  409. package/lib/messages/carriers.d.ts +4 -6
  410. package/lib/messages/carriers.d.ts.map +1 -1
  411. package/lib/messages/carriers.js +4 -6
  412. package/lib/messages/carriers.js.map +1 -1
  413. package/lib/messages/index.d.ts +2 -2
  414. package/lib/messages/index.d.ts.map +1 -1
  415. package/lib/messages/index.js +2 -2
  416. package/lib/messages/index.js.map +1 -1
  417. package/lib/messages/renderer.d.ts +32 -7
  418. package/lib/messages/renderer.d.ts.map +1 -1
  419. package/lib/messages/renderer.js +7 -1
  420. package/lib/messages/renderer.js.map +1 -1
  421. package/lib/node/event-loop-monitor.d.ts.map +1 -1
  422. package/lib/node/event-loop-monitor.js +2 -1
  423. package/lib/node/event-loop-monitor.js.map +1 -1
  424. package/lib/node/heap-ceiling.d.ts +61 -0
  425. package/lib/node/heap-ceiling.d.ts.map +1 -0
  426. package/lib/node/heap-ceiling.js +72 -0
  427. package/lib/node/heap-ceiling.js.map +1 -0
  428. package/lib/node/index.d.ts +1 -1
  429. package/lib/node/index.d.ts.map +1 -1
  430. package/lib/node/index.js +5 -1
  431. package/lib/node/index.js.map +1 -1
  432. package/lib/node/latency-from-env.d.ts +1 -1
  433. package/lib/node/latency-from-env.js +1 -1
  434. package/lib/node/lint-grammar.js.map +1 -1
  435. package/lib/node/log-file-sink.d.ts +3 -2
  436. package/lib/node/log-file-sink.d.ts.map +1 -1
  437. package/lib/node/log-file-sink.js +6 -2
  438. package/lib/node/log-file-sink.js.map +1 -1
  439. package/lib/node/log-preamble-node.d.ts +5 -3
  440. package/lib/node/log-preamble-node.d.ts.map +1 -1
  441. package/lib/node/log-preamble-node.js +13 -10
  442. package/lib/node/log-preamble-node.js.map +1 -1
  443. package/lib/node/measure-memory.d.ts.map +1 -1
  444. package/lib/node/measure-memory.js +5 -4
  445. package/lib/node/measure-memory.js.map +1 -1
  446. package/lib/node/memory-monitor.d.ts +2 -2
  447. package/lib/node/memory-monitor.d.ts.map +1 -1
  448. package/lib/node/memory-monitor.js +9 -10
  449. package/lib/node/memory-monitor.js.map +1 -1
  450. package/lib/node/node-file-system-provider.d.ts +67 -9
  451. package/lib/node/node-file-system-provider.d.ts.map +1 -1
  452. package/lib/node/node-file-system-provider.js +198 -53
  453. package/lib/node/node-file-system-provider.js.map +1 -1
  454. package/lib/node/profile-capture.d.ts +12 -4
  455. package/lib/node/profile-capture.d.ts.map +1 -1
  456. package/lib/node/profile-capture.js +8 -6
  457. package/lib/node/profile-capture.js.map +1 -1
  458. package/lib/node/profile-digest.js.map +1 -1
  459. package/lib/node/profiling-run.d.ts +13 -3
  460. package/lib/node/profiling-run.d.ts.map +1 -1
  461. package/lib/node/profiling-run.js +17 -8
  462. package/lib/node/profiling-run.js.map +1 -1
  463. package/lib/node/rename-over-open-readers.d.ts +61 -0
  464. package/lib/node/rename-over-open-readers.d.ts.map +1 -0
  465. package/lib/node/rename-over-open-readers.js +80 -0
  466. package/lib/node/rename-over-open-readers.js.map +1 -0
  467. package/lib/node/server-diagnostics.js +1 -1
  468. package/lib/node/server-diagnostics.js.map +1 -1
  469. package/lib/node/server-state-snapshot.js +4 -4
  470. package/lib/node/server-state-snapshot.js.map +1 -1
  471. package/lib/node/socket-launcher.d.ts +43 -7
  472. package/lib/node/socket-launcher.d.ts.map +1 -1
  473. package/lib/node/socket-launcher.js +42 -7
  474. package/lib/node/socket-launcher.js.map +1 -1
  475. package/lib/node/startup-cpu-profile.d.ts +29 -0
  476. package/lib/node/startup-cpu-profile.d.ts.map +1 -0
  477. package/lib/node/startup-cpu-profile.js +91 -0
  478. package/lib/node/startup-cpu-profile.js.map +1 -0
  479. package/lib/node/stdio-launcher.d.ts +13 -13
  480. package/lib/node/stdio-launcher.d.ts.map +1 -1
  481. package/lib/node/stdio-launcher.js +21 -11
  482. package/lib/node/stdio-launcher.js.map +1 -1
  483. package/lib/node/validate-workspace.d.ts +1 -1
  484. package/lib/node/validate-workspace.d.ts.map +1 -1
  485. package/lib/node/validate-workspace.js +5 -1
  486. package/lib/node/validate-workspace.js.map +1 -1
  487. package/lib/testing/document-uri-policy-conformance.js +1 -1
  488. package/lib/testing/document-uri-policy-conformance.js.map +1 -1
  489. package/lib/testing/fake-description.js.map +1 -1
  490. package/lib/testing/fake-document.d.ts +2 -1
  491. package/lib/testing/fake-document.d.ts.map +1 -1
  492. package/lib/testing/fake-document.js.map +1 -1
  493. package/lib/testing/index.d.ts +1 -1
  494. package/lib/testing/index.d.ts.map +1 -1
  495. package/lib/testing/index.js +2 -1
  496. package/lib/testing/index.js.map +1 -1
  497. package/lib/testing/langium-test-helpers.d.ts.map +1 -1
  498. package/lib/testing/langium-test-helpers.js +1 -0
  499. package/lib/testing/langium-test-helpers.js.map +1 -1
  500. package/lib/testing/make-noop-language-services.js.map +1 -1
  501. package/lib/testing/make-noop-shared-services.d.ts +4 -4
  502. package/lib/testing/make-noop-shared-services.d.ts.map +1 -1
  503. package/lib/testing/make-noop-shared-services.js +4 -4
  504. package/lib/testing/make-noop-shared-services.js.map +1 -1
  505. package/lib/testing/make-test-services.d.ts +51 -20
  506. package/lib/testing/make-test-services.d.ts.map +1 -1
  507. package/lib/testing/make-test-services.js +29 -13
  508. package/lib/testing/make-test-services.js.map +1 -1
  509. package/lib/testing/make-test-tracer.d.ts +12 -0
  510. package/lib/testing/make-test-tracer.d.ts.map +1 -1
  511. package/lib/testing/make-test-tracer.js +13 -0
  512. package/lib/testing/make-test-tracer.js.map +1 -1
  513. package/lib/testing/node/applied-edit-capture.d.ts +29 -0
  514. package/lib/testing/node/applied-edit-capture.d.ts.map +1 -0
  515. package/lib/testing/node/applied-edit-capture.js +117 -0
  516. package/lib/testing/node/applied-edit-capture.js.map +1 -0
  517. package/lib/testing/node/golden-corpus.js.map +1 -1
  518. package/lib/testing/node/index.d.ts +1 -0
  519. package/lib/testing/node/index.d.ts.map +1 -1
  520. package/lib/testing/node/index.js +1 -0
  521. package/lib/testing/node/index.js.map +1 -1
  522. package/lib/testing/node/lsp-harness.d.ts.map +1 -1
  523. package/lib/testing/node/lsp-harness.js +2 -0
  524. package/lib/testing/node/lsp-harness.js.map +1 -1
  525. package/lib/testing/node/lsp-server-connection.d.ts +21 -6
  526. package/lib/testing/node/lsp-server-connection.d.ts.map +1 -1
  527. package/lib/testing/node/lsp-server-connection.js +49 -106
  528. package/lib/testing/node/lsp-server-connection.js.map +1 -1
  529. package/lib/testing/node/scratch-workspace.d.ts.map +1 -1
  530. package/lib/testing/node/scratch-workspace.js +10 -5
  531. package/lib/testing/node/scratch-workspace.js.map +1 -1
  532. package/lib/testing/node/spawned-server.d.ts +30 -1
  533. package/lib/testing/node/spawned-server.d.ts.map +1 -1
  534. package/lib/testing/node/spawned-server.js +39 -2
  535. package/lib/testing/node/spawned-server.js.map +1 -1
  536. package/lib/testing/node/unhandled-rejections.d.ts +20 -0
  537. package/lib/testing/node/unhandled-rejections.d.ts.map +1 -0
  538. package/lib/testing/node/unhandled-rejections.js +32 -0
  539. package/lib/testing/node/unhandled-rejections.js.map +1 -0
  540. package/lib/testing/playwright/browser-capture-bridge.js.map +1 -1
  541. package/lib/testing/playwright/e2e-profiling.js.map +1 -1
  542. package/lib/testing/playwright/flaky-network-proxy.d.ts +76 -0
  543. package/lib/testing/playwright/flaky-network-proxy.d.ts.map +1 -0
  544. package/lib/testing/playwright/flaky-network-proxy.js +164 -0
  545. package/lib/testing/playwright/flaky-network-proxy.js.map +1 -0
  546. package/lib/testing/playwright/index.d.ts +1 -0
  547. package/lib/testing/playwright/index.d.ts.map +1 -1
  548. package/lib/testing/playwright/index.js +1 -0
  549. package/lib/testing/playwright/index.js.map +1 -1
  550. package/lib/testing/playwright/server-log-capture.d.ts +46 -16
  551. package/lib/testing/playwright/server-log-capture.d.ts.map +1 -1
  552. package/lib/testing/playwright/server-log-capture.js +111 -40
  553. package/lib/testing/playwright/server-log-capture.js.map +1 -1
  554. package/lib/testing/playwright/server-log-fixture.d.ts +16 -0
  555. package/lib/testing/playwright/server-log-fixture.d.ts.map +1 -1
  556. package/lib/testing/playwright/server-log-fixture.js +25 -6
  557. package/lib/testing/playwright/server-log-fixture.js.map +1 -1
  558. package/lib/testing/playwright/server-log-region.d.ts +15 -0
  559. package/lib/testing/playwright/server-log-region.d.ts.map +1 -0
  560. package/lib/testing/playwright/server-log-region.js +30 -0
  561. package/lib/testing/playwright/server-log-region.js.map +1 -0
  562. package/lib/testing/playwright/server-log-rename-reporter.d.ts.map +1 -1
  563. package/lib/testing/playwright/server-log-rename-reporter.js +4 -3
  564. package/lib/testing/playwright/server-log-rename-reporter.js.map +1 -1
  565. package/lib/testing/run-update-pipeline.d.ts +8 -5
  566. package/lib/testing/run-update-pipeline.d.ts.map +1 -1
  567. package/lib/testing/run-update-pipeline.js.map +1 -1
  568. package/lib/testing/stub-ast-document-manager.d.ts +11 -52
  569. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  570. package/lib/testing/stub-ast-document-manager.js +31 -62
  571. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  572. package/lib/testing/stub-document-builder.d.ts +33 -11
  573. package/lib/testing/stub-document-builder.d.ts.map +1 -1
  574. package/lib/testing/stub-document-builder.js +103 -5
  575. package/lib/testing/stub-document-builder.js.map +1 -1
  576. package/lib/testing/stub-hydranium-text-documents.d.ts +33 -10
  577. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  578. package/lib/testing/stub-hydranium-text-documents.js +132 -16
  579. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  580. package/lib/testing/stub-index-manager.js.map +1 -1
  581. package/lib/testing/stub-langium-documents.d.ts +3 -2
  582. package/lib/testing/stub-langium-documents.d.ts.map +1 -1
  583. package/lib/testing/stub-langium-documents.js.map +1 -1
  584. package/lib/testing/stub-model-service.d.ts +9 -8
  585. package/lib/testing/stub-model-service.d.ts.map +1 -1
  586. package/lib/testing/stub-model-service.js +6 -6
  587. package/lib/testing/stub-model-service.js.map +1 -1
  588. package/lib/testing/stub-project-manager.js.map +1 -1
  589. package/lib/testing/stub-self-save-registry.d.ts +1 -1
  590. package/lib/testing/stub-self-save-registry.d.ts.map +1 -1
  591. package/lib/testing/stub-service-registry.d.ts +1 -1
  592. package/lib/testing/stub-service-registry.js.map +1 -1
  593. package/lib/testing/stub-writable-file-system.d.ts +1 -1
  594. package/lib/testing/stub-writable-file-system.d.ts.map +1 -1
  595. package/lib/testing/stub-writable-file-system.js +2 -1
  596. package/lib/testing/stub-writable-file-system.js.map +1 -1
  597. package/lib/util/connection-liveness.d.ts +22 -6
  598. package/lib/util/connection-liveness.d.ts.map +1 -1
  599. package/lib/util/connection-liveness.js +74 -15
  600. package/lib/util/connection-liveness.js.map +1 -1
  601. package/lib/util/environment.d.ts +7 -0
  602. package/lib/util/environment.d.ts.map +1 -1
  603. package/lib/util/environment.js +15 -1
  604. package/lib/util/environment.js.map +1 -1
  605. package/lib/util/registry.d.ts.map +1 -1
  606. package/package.json +41 -60
  607. package/src/browser/host-heads-in-worker.ts +218 -0
  608. package/src/browser/index.ts +17 -0
  609. package/src/browser/indexeddb-file-system-store.ts +192 -0
  610. package/src/browser/start-language-server-in-worker.ts +56 -0
  611. package/src/documents/ast-document-manager.ts +479 -233
  612. package/src/documents/client-ids.ts +5 -22
  613. package/src/documents/client-session-errors.ts +24 -0
  614. package/src/documents/client-session-registry.ts +262 -0
  615. package/src/documents/dirty-state-tracker.ts +167 -0
  616. package/src/documents/document-release-handler.ts +404 -0
  617. package/src/documents/document-release-scheduler.ts +121 -0
  618. package/src/documents/file-system-task-queue.ts +74 -0
  619. package/src/documents/hydranium-text-documents.ts +1029 -595
  620. package/src/documents/index.ts +10 -2
  621. package/src/documents/language-client-shadow.ts +501 -0
  622. package/src/documents/model-ledger.ts +66 -0
  623. package/src/documents/text-ledger.ts +112 -0
  624. package/src/documents/version-sync-service.ts +275 -0
  625. package/src/index.ts +7 -0
  626. package/src/langium/ast-extension/ast-extension-service.ts +77 -41
  627. package/src/langium/ast-extension/ast-node-builder.ts +35 -13
  628. package/src/langium/bootstrap.ts +87 -51
  629. package/src/langium/build-phase-pass/build-phase-pass-service.ts +29 -19
  630. package/src/langium/build-phase-pass/build-phase-pass.ts +37 -8
  631. package/src/langium/composite-ast-reflection.ts +112 -0
  632. package/src/langium/config/configuration-provider.ts +119 -0
  633. package/src/langium/config/index.ts +1 -0
  634. package/src/langium/diagnostics/console-tee.ts +151 -0
  635. package/src/langium/diagnostics/format-log-arg.ts +36 -0
  636. package/src/langium/diagnostics/hydranium-langium-profiler.ts +15 -39
  637. package/src/langium/diagnostics/log-preamble.ts +1 -1
  638. package/src/langium/diagnostics/logger.ts +51 -17
  639. package/src/langium/diagnostics/lsp-logger.ts +83 -44
  640. package/src/langium/document-builder/build-pipeline-integration.ts +46 -32
  641. package/src/langium/document-builder/build-session.ts +4 -3
  642. package/src/langium/document-builder/document-builder.ts +615 -79
  643. package/src/langium/integration-services.ts +59 -28
  644. package/src/langium/integrity/integrity-rule.ts +17 -8
  645. package/src/langium/integrity/integrity-service.ts +332 -69
  646. package/src/langium/language-module.ts +49 -8
  647. package/src/langium/model-service/client-session.ts +726 -0
  648. package/src/langium/model-service/index.ts +2 -0
  649. package/src/langium/model-service/model-events.ts +80 -0
  650. package/src/langium/model-service/model-service.ts +878 -494
  651. package/src/langium/module.ts +195 -72
  652. package/src/langium/naming/name-provider.ts +34 -8
  653. package/src/langium/project/abstract-project-manager.ts +13 -5
  654. package/src/langium/project/project-change-event.ts +2 -2
  655. package/src/langium/residency/cst-residency-service.ts +54 -19
  656. package/src/langium/scope/fallback-scope.ts +38 -0
  657. package/src/langium/scope/hydranium-scope-computation.ts +45 -44
  658. package/src/langium/scope/hydranium-scope-provider.ts +228 -41
  659. package/src/langium/scope/index.ts +1 -0
  660. package/src/langium/scope/reference-candidate-provider.ts +6 -5
  661. package/src/langium/scope/scope-extension-service.ts +148 -56
  662. package/src/langium/scope/scoped-ast-node-description.ts +1 -3
  663. package/src/langium/serialization/abstract-serializer.ts +57 -41
  664. package/src/langium/serialization/json-serializer.ts +14 -3
  665. package/src/langium/serialization/serializer.ts +1 -1
  666. package/src/langium/serialization/yaml-serializer.ts +26 -4
  667. package/src/langium/service-registry.ts +41 -2
  668. package/src/langium/shared-services.ts +16 -7
  669. package/src/langium/single-langium-copy.ts +42 -0
  670. package/src/langium/transfer/transfer-encoder.ts +104 -47
  671. package/src/langium/trivia/comment-preserver.ts +1409 -0
  672. package/src/langium/trivia/document-ending-preserver.ts +71 -0
  673. package/src/langium/trivia/index.ts +14 -0
  674. package/src/langium/trivia/trivia-contribution.ts +39 -0
  675. package/src/langium/trivia/trivia-preserver.ts +56 -0
  676. package/src/langium/trivia/trivia-service.ts +99 -0
  677. package/src/langium/update-rewrite/update-rewrite-service.ts +1 -1
  678. package/src/langium/update-rewrite/update-rewrite.ts +1 -1
  679. package/src/langium/validation/document-validator.ts +312 -31
  680. package/src/langium/validation/validation-contribution-collector.ts +10 -1
  681. package/src/langium/workspace/document-uri-policy.ts +32 -18
  682. package/src/langium/workspace/file-not-found.ts +35 -0
  683. package/src/langium/workspace/file-system-provider.ts +258 -16
  684. package/src/langium/workspace/hydranium-langium-document-factory.ts +83 -0
  685. package/src/langium/workspace/hydranium-workspace-lock.ts +86 -2
  686. package/src/langium/workspace/hydranium-workspace-manager.ts +212 -35
  687. package/src/langium/workspace/in-memory-file-system-provider.ts +54 -23
  688. package/src/langium/workspace/index.ts +1 -0
  689. package/src/langium/workspace/initialize-workspace.ts +96 -4
  690. package/src/langium/workspace/langium-documents.ts +19 -1
  691. package/src/langium/workspace/persistent-file-system-provider.ts +27 -15
  692. package/src/langium/workspace/self-save-registry.ts +118 -0
  693. package/src/langium/workspace/virtual-document.ts +106 -25
  694. package/src/langium/workspace/write-lock-scope.ts +27 -12
  695. package/src/launcher/integrated-server.ts +3 -1
  696. package/src/locale/server-locale.ts +20 -5
  697. package/src/lsp/completion/hydranium-completion-provider.ts +82 -2
  698. package/src/lsp/connection-features.ts +67 -0
  699. package/src/lsp/diagnostics-connection.ts +56 -0
  700. package/src/lsp/edit-requests-guard.ts +152 -0
  701. package/src/lsp/hydranium-document-update-handler.ts +285 -111
  702. package/src/lsp/index.ts +6 -2
  703. package/src/lsp/lsp-latency.ts +62 -0
  704. package/src/lsp/save-text-document.ts +63 -0
  705. package/src/lsp/semantic-token-provider.ts +146 -8
  706. package/src/lsp/shared-module.ts +4 -8
  707. package/src/lsp/start-language-server.ts +21 -7
  708. package/src/messages/carriers.ts +4 -6
  709. package/src/messages/index.ts +11 -2
  710. package/src/messages/renderer.ts +34 -6
  711. package/src/node/event-loop-monitor.ts +2 -1
  712. package/src/node/heap-ceiling.ts +108 -0
  713. package/src/node/index.ts +5 -1
  714. package/src/node/latency-from-env.ts +1 -1
  715. package/src/node/log-file-sink.ts +6 -2
  716. package/src/node/log-preamble-node.ts +20 -10
  717. package/src/node/measure-memory.ts +5 -4
  718. package/src/node/memory-monitor.ts +10 -10
  719. package/src/node/node-file-system-provider.ts +214 -57
  720. package/src/node/profile-capture.ts +16 -15
  721. package/src/node/profiling-run.ts +30 -8
  722. package/src/node/rename-over-open-readers.ts +94 -0
  723. package/src/node/server-diagnostics.ts +1 -1
  724. package/src/node/server-state-snapshot.ts +5 -5
  725. package/src/node/socket-launcher.ts +78 -14
  726. package/src/node/startup-cpu-profile.ts +94 -0
  727. package/src/node/stdio-launcher.ts +29 -19
  728. package/src/node/validate-workspace.ts +8 -2
  729. package/src/testing/document-uri-policy-conformance.ts +1 -1
  730. package/src/testing/fake-document.ts +2 -1
  731. package/src/testing/index.ts +2 -1
  732. package/src/testing/langium-test-helpers.ts +1 -0
  733. package/src/testing/make-noop-shared-services.ts +6 -6
  734. package/src/testing/make-test-services.ts +93 -41
  735. package/src/testing/make-test-tracer.ts +26 -0
  736. package/src/testing/node/applied-edit-capture.ts +141 -0
  737. package/src/testing/node/index.ts +1 -0
  738. package/src/testing/node/lsp-harness.ts +2 -0
  739. package/src/testing/node/lsp-server-connection.ts +83 -119
  740. package/src/testing/node/scratch-workspace.ts +10 -5
  741. package/src/testing/node/spawned-server.ts +57 -3
  742. package/src/testing/node/unhandled-rejections.ts +31 -0
  743. package/src/testing/parse-semantic-root.ts +1 -1
  744. package/src/testing/playwright/flaky-network-proxy.ts +240 -0
  745. package/src/testing/playwright/index.ts +1 -0
  746. package/src/testing/playwright/server-log-capture.ts +137 -47
  747. package/src/testing/playwright/server-log-fixture.ts +46 -6
  748. package/src/testing/playwright/server-log-region.ts +32 -0
  749. package/src/testing/playwright/server-log-rename-reporter.ts +4 -3
  750. package/src/testing/run-update-pipeline.ts +8 -5
  751. package/src/testing/stub-ast-document-manager.ts +47 -128
  752. package/src/testing/stub-document-builder.ts +138 -18
  753. package/src/testing/stub-hydranium-text-documents.ts +184 -27
  754. package/src/testing/stub-langium-documents.ts +3 -2
  755. package/src/testing/stub-model-service.ts +11 -10
  756. package/src/testing/stub-self-save-registry.ts +1 -1
  757. package/src/testing/stub-service-registry.ts +1 -1
  758. package/src/testing/stub-writable-file-system.ts +3 -2
  759. package/src/util/connection-liveness.ts +80 -15
  760. package/src/util/environment.ts +24 -2
  761. package/lib/documents/language-client-text-shadow.d.ts +0 -101
  762. package/lib/documents/language-client-text-shadow.d.ts.map +0 -1
  763. package/lib/documents/language-client-text-shadow.js +0 -156
  764. package/lib/documents/language-client-text-shadow.js.map +0 -1
  765. package/lib/documents/self-save-registry.d.ts +0 -55
  766. package/lib/documents/self-save-registry.d.ts.map +0 -1
  767. package/lib/documents/self-save-registry.js.map +0 -1
  768. package/lib/lsp/instrument-connection.d.ts +0 -36
  769. package/lib/lsp/instrument-connection.d.ts.map +0 -1
  770. package/lib/lsp/instrument-connection.js +0 -62
  771. package/lib/lsp/instrument-connection.js.map +0 -1
  772. package/lib/node/process-memory.d.ts +0 -66
  773. package/lib/node/process-memory.d.ts.map +0 -1
  774. package/lib/node/process-memory.js +0 -252
  775. package/lib/node/process-memory.js.map +0 -1
  776. package/src/documents/language-client-text-shadow.ts +0 -175
  777. package/src/documents/self-save-registry.ts +0 -81
  778. package/src/lsp/instrument-connection.ts +0 -66
  779. package/src/node/process-memory.ts +0 -299
@@ -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,7 +31,7 @@ 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,
@@ -45,10 +45,37 @@ import {
45
45
  type WillSaveTextDocumentParams
46
46
  } from 'vscode-languageserver';
47
47
  import { type DocumentUri, TextDocument, type TextDocumentContentChangeEvent } from 'vscode-languageserver-textdocument';
48
- import { type CanonicalUri, type LanguageClientUri, asLanguageClientUri, DisposableCollection, type Tracer } from '@hydranium/protocol';
48
+ import {
49
+ type CanonicalUri,
50
+ type LanguageClientUri,
51
+ asLanguageClientUri,
52
+ asModelVersion,
53
+ ConflictError,
54
+ DisposableCollection,
55
+ DocumentNotOpenError,
56
+ STALE_BASE_VERSION_SAVE,
57
+ type TextState,
58
+ type TextVersion,
59
+ STALE_VERSION,
60
+ type Tracer,
61
+ UNRECORDED_VERSION,
62
+ describeError,
63
+ describeErrorWithStack
64
+ } from '@hydranium/protocol';
49
65
  import { type LogNameOptions } from '../langium/diagnostics/logger.js';
50
66
  import { LANGUAGE_CLIENT_ID } from './client-ids.js';
51
- import { isFullReplace, LanguageClientTextShadow } from './language-client-text-shadow.js';
67
+ import { INTEGRITY_CLIENT_ID } from '../langium/integrity/integrity-rule.js';
68
+ import { type ClientSessionClosedEvent, ClientSessionRegistry, type SessionEndCause } from './client-session-registry.js';
69
+ import { DefaultLanguageClientShadow, type LanguageClientChangeVerdict, type LanguageClientShadow } from './language-client-shadow.js';
70
+ import { DefaultTextLedger, type TextLedger } from './text-ledger.js';
71
+ import { isDocumentReleaseSkippedError, type ReleasedDocument } from './document-release-handler.js';
72
+ import { DefaultDocumentReleaseScheduler, type DocumentReleaseScheduler } from './document-release-scheduler.js';
73
+ import {
74
+ type CleanAnnouncement,
75
+ DefaultDirtyStateTracker,
76
+ type DirtyStateTracker,
77
+ type DocumentDirtyChangedEvent
78
+ } from './dirty-state-tracker.js';
52
79
 
53
80
  /**
54
81
  * The LSP spec's "version is intentionally unknown" for an
@@ -74,47 +101,39 @@ export interface HydraniumTextDocumentsOptions<T extends TextDocument = TextDocu
74
101
  * Adopters with a custom text-document type (rare) pass their factory.
75
102
  */
76
103
  readonly configuration?: TextDocumentsConfiguration<T>;
104
+ /**
105
+ * How long a document whose last open closed because its client's
106
+ * connection was lost keeps its text before the store releases it. An open
107
+ * by a client lost from the document, within this time of its own loss,
108
+ * cancels the release, so a client that registers again under its id after
109
+ * a dropped connection finds its unsaved edits. Any other open releases the
110
+ * document first and then opens it as a first open does, from the text the
111
+ * opener supplies (an editor's own) or else from the file. Meanwhile the
112
+ * document counts as open for the integrity service, which therefore writes
113
+ * none of its unsaved text to disk. A close the client makes itself, or
114
+ * ending its session, releases at once whatever this is.
115
+ *
116
+ * Defaults to 10 s. `0` releases such a document at once as well, in the
117
+ * close itself rather than on a timer.
118
+ */
119
+ readonly releaseGraceMs?: number;
77
120
  }
78
121
 
79
122
  /**
80
- * All per-URI client-facing tracking the manager keys by normalized URI,
81
- * collapsed into one record so a URI's full state lives in one place and the
82
- * last-client close clears every axis in a single delete. (Parallel per-axis
83
- * maps are the substrate of a close-on-stale-state desync — one map can be
84
- * cleared while another lingers.)
85
- *
86
- * Two neighbours deliberately stay separate:
87
- * - The inherited `__syncedDocuments` (Langium's parsed `TextDocument` store).
88
- * - {@link LanguageClientTextShadow} (`__shadow`), a self-contained,
89
- * separately-tested diff/apply-verify abstraction that owns its own baseline
90
- * text; folding its storage here would couple a clean utility to this record
91
- * for no real gain.
123
+ * The default of {@link HydraniumTextDocumentsOptions.releaseGraceMs}: long
124
+ * enough for a client whose connection dropped to register again and reopen
125
+ * its documents, which a data client does as soon as it has a connection.
92
126
  */
93
- interface DocumentTracking {
94
- /** Client ids currently holding this document open (multi-client membership). */
95
- readonly clients: Set<string>;
96
- /** Author of each version, sparse-indexed by the SHARED (server-assigned) version number. */
97
- readonly versionAuthors: string[];
98
- /**
99
- * 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}).
104
- */
105
- readonly clientVersions: Map<string, number>;
106
- /** Content staged by integrity rules for a closed document. Consumed on next open. */
107
- pendingContent?: string;
108
- /**
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.
116
- */
117
- languageClientUris?: Set<LanguageClientUri>;
127
+ const DEFAULT_RELEASE_GRACE_MS = 10_000;
128
+
129
+ /** Delivered by {@link HydraniumTextDocuments.onDidSaveInLanguageClient}. */
130
+ export interface LanguageClientSavedEvent {
131
+ readonly uri: string;
132
+ }
133
+
134
+ /** Delivered by {@link HydraniumTextDocuments.onDidReleaseDocument}. */
135
+ export interface DocumentReleasedEvent {
136
+ readonly uri: CanonicalUri;
118
137
  }
119
138
 
120
139
  /**
@@ -129,25 +148,20 @@ export interface OpenDocument {
129
148
  }
130
149
 
131
150
  /**
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}.
136
- */
137
- interface VersionSequence {
138
- /** The shared version at last-client close. */
139
- readonly version: number;
140
- /** {@link contentHash} of the synced text at last-client close. */
141
- readonly contentHash: string;
142
- }
143
-
144
- /**
145
- * Upper bound on {@link HydraniumTextDocuments.__pendingPushes} entries
146
- * per URI. Echoes normally return within milliseconds and consume their
147
- * entry; a queue this deep means the client stopped echoing — cap instead
148
- * of leaking.
151
+ * What {@link HydraniumTextDocuments.commitRepair} did with an integrity repair.
152
+ *
153
+ * Three outcomes rather than a document-or-nothing, because the two ways of
154
+ * getting nothing call for opposite responses: `not-open` means there is no
155
+ * store document to correct and the caller carries on with the one it holds,
156
+ * while `stale` means there IS one and it has moved past the text the repair
157
+ * was computed against — so carrying on would persist a correction over an edit
158
+ * that superseded it. Collapsing them lets the second read as permission to
159
+ * proceed, which is the failure this shape exists to make unrepresentable.
149
160
  */
150
- const PENDING_ECHO_CAP = 32;
161
+ export type RepairCommit<T extends TextDocument> =
162
+ | { readonly status: 'committed'; readonly document: T }
163
+ | { readonly status: 'stale' }
164
+ | { readonly status: 'not-open' };
151
165
 
152
166
  /**
153
167
  * The one field of `vscode-languageserver`'s `Connection` this class has to
@@ -159,182 +173,137 @@ interface ConnectionWithTextDocumentSync {
159
173
  }
160
174
 
161
175
  /**
162
- * One text pushed to the LSP textual language client by
163
- * {@link HydraniumTextDocuments.applyEditToLanguageClient} whose echo has not
164
- * come back yet. One entry of {@link HydraniumTextDocuments.__pendingPushes}.
165
- */
166
- interface PendingPush {
167
- /**
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.
171
- *
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.
177
- */
178
- readonly before: string | undefined;
179
- /** {@link contentHash} of the text this push moves the client to. */
180
- readonly afterHash: string;
181
- }
182
-
183
- /**
184
- * 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}.
187
- */
188
- type LanguageClientChangeOrigin =
189
- /** The client is reporting a text we pushed it. The synced document is already there. */
190
- | { readonly kind: 'echo' }
191
- /**
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.
195
- */
196
- | { readonly kind: 'divergent'; readonly text: string };
197
-
198
- /**
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).
205
- */
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}`;
219
- }
220
-
221
- /**
222
- * Multi-client text-document tracking on top of Langium's `NormalizedTextDocuments`.
176
+ * The one text store every head writes to, on top of Langium's
177
+ * `NormalizedTextDocuments`, and the LSP text-sync endpoint: every
178
+ * `textDocument/*` notification and every `workspace/applyEdit` push goes
179
+ * through here.
180
+ *
181
+ * Each open, change, close and save is one synchronous transition over the
182
+ * held document and the collaborators its `create…` methods build. A
183
+ * collaborator that defers its part lets a listener of the transition's event
184
+ * read state from before it. A document no client holds any more is
185
+ * released to the `DocumentReleaseHandler` slot, after
186
+ * {@link HydraniumTextDocumentsOptions.releaseGraceMs} when its last client's
187
+ * connection was lost.
188
+ *
189
+ * Client-declared version ids feed only the per-client staleness guard and
190
+ * never a running shared sequence: the two count different things, and
191
+ * splicing them lets versions drift past base-version gate holders. A URI
192
+ * with no ledger record and no built root starts at its opener's declared id:
193
+ * no version was handed out for it.
223
194
  *
224
- * 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).
226
- * - A SERVER-OWNED shared version sequence: per-URI, monotonic across
227
- * close/reopen cycles, advancing exactly when the synced content changes.
228
- * Client-declared version ids (Monaco's buffer numbering) feed only a
229
- * per-client staleness guard and never leak into the shared sequence —
230
- * the two are different things (an editor's edit-operation counter vs the
231
- * document's content-revision number), and splicing them lets versions drift
232
- * silently past `baseVersion` gate holders.
233
- * - Version-author history so each edit is attributable to its originating client.
234
- * - Pending-content staging used by the integrity service to thread corrections
235
- * through `workspace/applyEdit` cycles for currently-closed documents.
236
- * - `didOpen` notifications arriving over the LSP connection wait on the
237
- * workspace-ready promise, so a client's first open cannot race workspace
238
- * discovery. Direct {@link notifyDidOpenTextDocument} calls (the non-LSP
239
- * heads) do not pass that gate — their caller owns the ordering.
195
+ * `didOpen` notifications arriving over the LSP connection wait on the
196
+ * workspace-ready promise, so a client's first open cannot race workspace
197
+ * discovery. Direct {@link notifyDidOpenTextDocument} calls (the non-LSP
198
+ * heads) do not pass that gate — their caller owns the ordering.
240
199
  */
241
200
  export class HydraniumTextDocuments<T extends TextDocument = TextDocument> extends NormalizedTextDocuments<T> {
242
- /**
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`.
249
- */
250
- protected __documents = new Map<CanonicalUri, DocumentTracking>();
251
-
252
- /**
253
- * 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
257
- * survive it — the shared version is a server-owned, monotonic,
258
- * advances-iff-content-changes counter that never resets while the server
259
- * lives. That invariant is what makes an optimistic `baseVersion` gate
260
- * sound: "version unchanged ⇔ content unchanged", with no false conflicts
261
- * from close/reopen version resets and no false passes from a reopened
262
- * sequence coincidentally landing on a stale writer's number.
263
- *
264
- * Never pruned: two small values per URI ever touched — bounded by
265
- * workspace size, not by activity.
266
- */
267
- protected readonly __versionSequences = new Map<CanonicalUri, VersionSequence>();
201
+ /** Content staged by integrity rules for a document no client holds, consumed by its first open. */
202
+ protected readonly __pendingContent = new Map<CanonicalUri, string>();
268
203
 
269
204
  /**
270
- * Texts pushed to the LSP textual language client via
271
- * {@link applyEditToLanguageClient} whose echoes have not come back yet
272
- * ({@link PendingPush}), keyed like the shadow by language-client URI.
273
- * Outbound pushes and inbound echoes are uncorrelated on the wire; this
274
- * FIFO is the explicit correlation, and it is what
275
- * {@link classifyLanguageClientChange} reconstructs against.
276
- *
277
- * **Each entry keeps the client's PRE-push text, not only a hash of the
278
- * post-push one.** A hash alone can classify a full-text echo, whose
279
- * application is a no-op either way — and that is all it ever classified,
280
- * because a conforming client echoes INCREMENTAL ranges keyed to its
281
- * previous buffer. Those ranges cannot be applied to the synced text (which
282
- * the authored write already advanced) and cannot be reconstructed without
283
- * the baseline: the line they insert lands twice, validates cleanly, and
284
- * compounds on every later edit.
285
- *
286
- * Lifecycle: entries are consumed by the matching echo (together with any
287
- * older entries it supersedes), and the whole queue drops when the client
288
- * stops being a pure mirror — a divergent change, an `applyEdit`
289
- * failure/rejection (shadow invalidation), a close, or an explicit
290
- * shadow (re)baseline. {@link PENDING_ECHO_CAP} bounds the queue against
291
- * a pathological echo that never arrives; the memory cost until then is
292
- * one pre-push text per in-flight push, for milliseconds.
205
+ * Which client has which document open, which client ids are registered
206
+ * sessions, and each client's declared version, the staleness guard's
207
+ * baseline. Every open-state predicate on this class reads it, so an open
208
+ * recorded anywhere else is invisible to the last-close transition.
293
209
  */
294
- protected readonly __pendingPushes = new Map<LanguageClientUri, PendingPush[]>();
210
+ protected readonly __sessions = new ClientSessionRegistry();
295
211
 
296
- /**
297
- * Tracked text content per URI for the LSP textual language client (Monaco / VS Code).
298
- * Owned here so {@link applyEditToLanguageClient} can compute minimal `workspace/applyEdit`
299
- * diffs instead of full-document replaces (5–20 s → <500 ms on 20-30 KB YAML diagrams).
300
- *
301
- * Auto-tracked from the multi-client text-document events: open / change / close of
302
- * the language client (re)baseline or invalidate the shadow. Other client ids
303
- * (form editor, GLSP, integrity) do NOT touch the shadow — only what Monaco believes
304
- * it has matters for the diff.
305
- *
306
- * Apply-verify safety net is built in: if the diff doesn't reconstruct `newText`
307
- * exactly, {@link LanguageClientTextShadow.computeEdits} falls back to a full-range
308
- * replace and invokes the `onFallback` callback — a diff regression becomes log
309
- * noise, not a 0-byte save.
310
- *
311
- * Assigned in the constructor body so the `onFallback` callback can capture
312
- * `this.logger` after the parameter-property assignment has run (field
313
- * initializers fire BEFORE parameter-property assignment in TS).
314
- */
315
- protected readonly __shadow: LanguageClientTextShadow;
212
+ /** The version each open document was opened at; see {@link openedVersion}. */
213
+ protected readonly __openedVersions = new Map<CanonicalUri, TextVersion>();
316
214
 
317
215
  protected readonly tracer: Tracer;
318
216
  protected readonly configuration: TextDocumentsConfiguration<T>;
217
+ protected __textLedger: TextLedger | undefined;
218
+ protected __languageClientShadow: LanguageClientShadow | undefined;
219
+ protected __dirtyStateTracker: DirtyStateTracker | undefined;
220
+ protected __documentReleaseScheduler: DocumentReleaseScheduler | undefined;
221
+ protected readonly documentReleasedEmitter = new Emitter<DocumentReleasedEvent>();
222
+ protected readonly languageClientSavedEmitter = new Emitter<LanguageClientSavedEvent>();
319
223
 
320
224
  constructor(
321
225
  protected services: ServerSharedServices,
322
- options: HydraniumTextDocumentsOptions<T> = {}
226
+ protected readonly options: HydraniumTextDocumentsOptions<T> = {}
323
227
  ) {
324
228
  const configuration = options.configuration ?? (TextDocument as unknown as TextDocumentsConfiguration<T>);
325
229
  super(configuration);
326
230
  this.configuration = configuration;
327
231
  this.tracer = services.Tracer.for(options.logName ?? 'TextDocuments').trace('instantiated');
328
- this.__shadow = new LanguageClientTextShadow(
329
- (uri, reason) => this.tracer.with(uri).warn(`Diff apply-verify fallback (${reason}) — using full-document replace`),
330
- this
331
- );
332
232
  }
333
233
 
334
- // Re-exposed configuration factories — for framework-internal callers
335
- // (LanguageClientTextShadow's apply-verify probe; IntegrityService.resyncDocument)
336
- // that need to materialise documents outside the canonical didOpen/didChange flow
337
- // and must respect the adopter's custom text-document type.
234
+ // Each collaborator is built on first use, after every constructor has run,
235
+ // so a create method may read its subclass's fields and the other collaborators.
236
+
237
+ protected get textLedger(): TextLedger {
238
+ return (this.__textLedger ??= this.createTextLedger());
239
+ }
240
+
241
+ protected get languageClientShadow(): LanguageClientShadow {
242
+ return (this.__languageClientShadow ??= this.createLanguageClientShadow());
243
+ }
244
+
245
+ protected get dirtyStateTracker(): DirtyStateTracker {
246
+ return (this.__dirtyStateTracker ??= this.createDirtyStateTracker());
247
+ }
248
+
249
+ protected get documentReleaseScheduler(): DocumentReleaseScheduler {
250
+ return (this.__documentReleaseScheduler ??= this.createDocumentReleaseScheduler());
251
+ }
252
+
253
+ protected createTextLedger(): TextLedger {
254
+ return new DefaultTextLedger();
255
+ }
256
+
257
+ protected createLanguageClientShadow(): LanguageClientShadow {
258
+ return new DefaultLanguageClientShadow(this, this.tracer);
259
+ }
260
+
261
+ protected createDirtyStateTracker(): DirtyStateTracker {
262
+ return new DefaultDirtyStateTracker(this.textLedger);
263
+ }
264
+
265
+ protected createDocumentReleaseScheduler(): DocumentReleaseScheduler {
266
+ return new DefaultDocumentReleaseScheduler(this.services.Clock, this.options.releaseGraceMs ?? DEFAULT_RELEASE_GRACE_MS);
267
+ }
268
+
269
+ /** Hold `document` as the text of `key`, a new version authored by `author`. */
270
+ protected commitText(key: CanonicalUri, document: T, author: string): void {
271
+ this.__syncedDocuments.set(key, document);
272
+ this.setAuthor(key, document.version, author);
273
+ this.dirtyStateTracker.refreshDirty(key, document);
274
+ }
275
+
276
+ /**
277
+ * Apply `changes` to `document`, the held text of `key`, and hold the
278
+ * result. The version steps only when the text changes, which is what a
279
+ * base-version gate relies on: unchanged text keeps its version and its
280
+ * author. The new text is known only once the changes are applied, so they
281
+ * go in at a tentative step that an identical result rolls back.
282
+ */
283
+ protected commitChange(
284
+ key: CanonicalUri,
285
+ document: T,
286
+ changes: TextDocumentContentChangeEvent[],
287
+ author: string
288
+ ): { document: T; changed: boolean } {
289
+ const previousText = document.getText();
290
+ const version = document.version;
291
+ let next = this.update(document, changes, version + 1);
292
+ const changed = next.getText() !== previousText;
293
+ if (changed) {
294
+ this.commitText(key, next, author);
295
+ } else {
296
+ // An empty-changes update only re-stamps the version.
297
+ next = this.update(next, [], version);
298
+ this.__syncedDocuments.set(key, next);
299
+ }
300
+ return { document: next, changed };
301
+ }
302
+
303
+ // The configuration's factories. Every document the store makes or changes
304
+ // goes through them, so an override sees each call: the store's own writes,
305
+ // callers outside the didOpen/didChange flow, and the shadow's throwaway
306
+ // probes, at version 0 under a client URI.
338
307
 
339
308
  public create(uri: string, languageId: string, version: number, content: string): T {
340
309
  return this.configuration.create(uri, languageId, version, content);
@@ -403,7 +372,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
403
372
  const disposables = new DisposableCollection();
404
373
  disposables.push(
405
374
  connection.onDidOpenTextDocument(async (event: DidOpenTextDocumentParams) => {
406
- await this.services.workspace.WorkspaceManager.workspaceInitialized;
375
+ await this.initialBuildFinished();
407
376
  this.notifyDidOpenTextDocument(event);
408
377
  })
409
378
  );
@@ -429,7 +398,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
429
398
  );
430
399
  disposables.push(
431
400
  connection.onDidSaveTextDocument((event: DidSaveTextDocumentParams) => {
432
- this.notifyDidSaveTextDocument(event);
401
+ void this.notifyLanguageClientSave(event);
433
402
  })
434
403
  );
435
404
  return disposables;
@@ -450,84 +419,66 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
450
419
  const uri = this.documentKey(td.uri);
451
420
  let document = this.__syncedDocuments.get(uri);
452
421
  if (document !== undefined) {
453
- // Per-client staleness guard: client version ids are CLIENT-owned per
454
- // LSP (Monaco numbers its own buffer), so an incoming id is compared
455
- // against THAT client's last declared id — never against the shared
456
- // version, which the server assigns and which routinely runs ahead of
457
- // a client's ids (authored ModelService writes advance it without the
458
- // client knowing). Gating on the shared version drops real edits in
459
- // exactly that lag window. A client with no baseline (never opened —
460
- // a protocol anomaly) falls back to the shared-version compare, the
461
- // conservative answer.
462
- const record = this.trackingFor(uri);
463
- const lastSeen = record.clientVersions.get(clientId) ?? document.version;
422
+ // Client version ids are the client's own, so compared against what
423
+ // that client declared, never against the shared version: an authored
424
+ // write advances that without the client knowing, and gating on it
425
+ // drops real edits. The editor is checked per URI, since each is its
426
+ // own buffer; its client-wide entry is only the fallback for a URI it
427
+ // never opened, and may name another URI's buffer. A client with no
428
+ // baseline falls back to the shared version.
429
+ const clientUri = this.toLanguageClientUri(td.uri);
430
+ const editor = clientId === LANGUAGE_CLIENT_ID;
431
+ const lastSeen =
432
+ (editor ? this.languageClientShadow.declaredVersion(uri, clientUri) : undefined) ??
433
+ this.__sessions.clientVersionOf(uri, clientId) ??
434
+ document.version;
464
435
  if (lastSeen >= td.version) {
465
436
  // Distinguish "already at this version" (common: an echo from the client that triggered
466
437
  // the update) from "incoming version older than ours" (stale race).
467
438
  const reason =
468
439
  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');
440
+ this.logUri(uri, `Ignore update by ${this.formatClientId(clientId)}: ${reason}`, 'debug');
470
441
  return;
471
442
  }
472
- record.clientVersions.set(clientId, td.version);
473
-
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.
477
- // Resolve which it is, and against what, before touching anything.
478
- const inFlight =
479
- clientId === LANGUAGE_CLIENT_ID
480
- ? this.classifyLanguageClientChange(this.toLanguageClientUri(td.uri), document, changes)
481
- : undefined;
482
- if (inFlight?.kind === 'echo') {
483
- // The client is reporting a text we pushed it, so the synced
484
- // document is ALREADY there and the changes must not be applied a
485
- // second time. Nothing is minted and no rebuild fires. The
486
- // per-client baseline advanced above (the client's ids keep
487
- // counting); the shadow is deliberately NOT touched — it
488
- // optimistically tracks the NEWEST pushed text, and dragging it
489
- // back would make the next outbound diff wrong against what the
490
- // client actually holds.
443
+ this.__sessions.setClientVersion(uri, clientId, td.version);
444
+
445
+ // An editor change is keyed to the buffer the editor holds, which is
446
+ // the synced text only while the two agree — an authored write
447
+ // advances the synced text without the editor knowing, and a push in
448
+ // flight moves the editor without the store knowing.
449
+ const verdict: LanguageClientChangeVerdict = editor
450
+ ? this.languageClientShadow.acceptChange(uri, clientUri, td.version, document, changes)
451
+ : { kind: 'direct' };
452
+ if (verdict.kind === 'echo') {
453
+ // The synced document is already there, so the changes must not be
454
+ // applied a second time: nothing is minted and no rebuild fires. The
455
+ // shadow keeps the newest pushed text, which is what the next
456
+ // outbound diff has to be keyed to.
491
457
  this.logUri(uri, `Skip rebuild: echo of a server-authored push (client version ${td.version})`, 'debug');
492
458
  return;
493
459
  }
494
460
 
495
- // The SHARED version advances iff the content actually changes — the
496
- // invariant optimistic `baseVersion` gates rely on. The new text is
497
- // only known after applying the (possibly incremental) changes, so
498
- // apply at a tentative +1 and roll the version back on an identical
499
- // result (an empty-changes update only re-stamps the version).
500
- //
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.
504
- const previousText = document.getText();
505
- const sharedVersion = document.version;
506
- document = this.configuration.update(document, inFlight === undefined ? changes : [{ text: inFlight.text }], sharedVersion + 1);
507
- const changed = document.getText() !== previousText;
508
- if (!changed) {
509
- document = this.configuration.update(document, [], sharedVersion);
510
- }
511
- this.__syncedDocuments.set(uri, document);
512
- if (changed) {
513
- this.setAuthor(uri, document.version, clientId);
514
- }
515
- if (clientId === LANGUAGE_CLIENT_ID) {
516
- // Monaco just told us about its new content; record it so the next outbound
517
- // applyEditToLanguageClient diffs against the right baseline. Keyed by the
518
- // language-client URI (what Monaco holds), not the canonical document key.
519
- this.__shadow.set(this.toLanguageClientUri(td.uri), document.getText());
520
- // Content-identical echo: the language client is echoing text we already had
521
- // (e.g. Monaco re-emitting a server-pushed applyEditToLanguageClient). The model is
522
- // unchanged, so skip the rebuild. The per-client baseline + shadow still advanced
523
- // above so future staleness checks and diffs are correct. Restricted to the
524
- // language client: a ModelService-authored change is never skipped.
525
- if (!changed) {
461
+ // A divergent change is applied as its reconstructed text rather than
462
+ // as its own ranges: those ranges address the editor's own buffer, so
463
+ // applying them here would splice the wrong lines.
464
+ const committed = this.commitChange(uri, document, verdict.kind === 'direct' ? changes : [{ text: verdict.text }], clientId);
465
+ document = committed.document;
466
+ if (editor) {
467
+ this.languageClientShadow.setLanguageClientText(clientUri, document.getText(), document.version);
468
+ // Content-identical echo: the editor is echoing text we already had.
469
+ // The model is unchanged, so skip the rebuild. Restricted to the
470
+ // editor: a ModelService-authored change is never skipped.
471
+ if (!committed.changed) {
526
472
  this.logUri(uri, `Skip rebuild: content unchanged (echo at client version ${td.version})`, 'debug');
527
473
  return;
528
474
  }
529
475
  }
530
- this.log(document.uri, `Update to version ${document.version} by ${clientId}${changed ? '' : ' (content unchanged)'}`);
476
+ // Diagnosis: one comes with every keystroke.
477
+ this.logUri(
478
+ document.uri,
479
+ `Update to version ${document.version} by ${this.formatClientId(clientId)}${committed.changed ? '' : ' (content unchanged)'}`,
480
+ 'debug'
481
+ );
531
482
  this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
532
483
  }
533
484
  }
@@ -544,73 +495,191 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
544
495
  * A content-identical write still fires the change event (rebuild): the
545
496
  * authored write's server-side rebuild is correctness-bearing, it just
546
497
  * mints no new version — nothing observable changed, so watchers'
547
- * `baseVersion` pointers stay valid.
498
+ * base versions stay valid.
548
499
  *
549
500
  * Returns the resulting shared version. Throws when the document is not
550
501
  * open — callers (`AstDocumentManager.update`) open first.
551
502
  */
552
- applyContentChange(uri: DocumentUri, text: string, clientId: string): number {
503
+ applyContentChange(uri: DocumentUri, text: string, clientId: string): TextVersion {
553
504
  const key = this.documentKey(uri);
554
- let document = this.__syncedDocuments.get(key);
555
- if (document === undefined) {
505
+ const synced = this.__syncedDocuments.get(key);
506
+ if (synced === undefined) {
556
507
  throw new Error(`Document ${uri} is not open for content changes`);
557
508
  }
558
- const changed = document.getText() !== text;
559
- if (changed) {
560
- document = this.configuration.update(document, [{ text }], document.version + 1);
561
- this.__syncedDocuments.set(key, document);
562
- this.setAuthor(key, document.version, clientId);
563
- }
564
- this.log(document.uri, `Update to version ${document.version} by ${clientId}${changed ? '' : ' (content unchanged)'}`);
509
+ // Unchanged text calls no update: a configuration may return a new document for one.
510
+ const { document, changed } =
511
+ synced.getText() === text ? { document: synced, changed: false } : this.commitChange(key, synced, [{ text }], clientId);
512
+ this.logUri(
513
+ document.uri,
514
+ `Update to version ${document.version} by ${this.formatClientId(clientId)}${changed ? '' : ' (content unchanged)'}`,
515
+ 'debug'
516
+ );
565
517
  this.__onDidChangeContent.fire(Object.freeze({ document, clientId }));
566
518
  return document.version;
567
519
  }
568
520
 
569
- public notifyDidCloseTextDocument(event: DidCloseTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
521
+ /**
522
+ * Close `clientId`'s open of the document. When it was the last open, the
523
+ * document is released, at once or, for a `'lost'`
524
+ * close, after {@link HydraniumTextDocumentsOptions.releaseGraceMs}.
525
+ */
526
+ public notifyDidCloseTextDocument(
527
+ event: DidCloseTextDocumentParams,
528
+ clientId = LANGUAGE_CLIENT_ID,
529
+ cause: SessionEndCause = 'closed'
530
+ ): void {
570
531
  const uri = this.documentKey(event.textDocument.uri);
571
- if (!this.isOpenInClient(uri, clientId)) {
532
+ if (clientId === LANGUAGE_CLIENT_ID) {
533
+ const clientUri = this.toLanguageClientUri(event.textDocument.uri);
534
+ // A close under a URI the editor never opened the document under ends its hold.
535
+ if (this.languageClientShadow.isOpen(uri, clientUri)) {
536
+ this.languageClientShadow.removeOpen(uri, clientUri);
537
+ } else {
538
+ this.languageClientShadow.removeAllOpens(uri);
539
+ }
540
+ // Another URI still holds the document, so only this one's buffer goes.
541
+ if (this.languageClientShadow.isOpen(uri)) {
542
+ return;
543
+ }
544
+ }
545
+ if (!this.__sessions.removeOpen(uri, clientId)) {
572
546
  return;
573
547
  }
574
- const closingRecord = this.__documents.get(uri);
575
- closingRecord?.clients.delete(clientId);
576
- closingRecord?.clientVersions.delete(clientId);
548
+ if (cause === 'lost') {
549
+ this.documentReleaseScheduler.recordLoss(uri, clientId);
550
+ }
577
551
  const syncedDocument = this.__syncedDocuments.get(uri);
578
552
  if (syncedDocument !== undefined) {
579
- this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${clientId}`);
553
+ this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${this.formatClientId(clientId)}`);
580
554
  this.__onDidClose.fire(Object.freeze({ document: syncedDocument, clientId }));
581
-
582
- 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.)
586
- const droppedUri = this.toLanguageClientUri(event.textDocument.uri);
587
- this.__documents.get(uri)?.languageClientUris?.delete(droppedUri);
588
- this.__shadow.invalidate(droppedUri);
589
- this.__pendingPushes.delete(droppedUri);
555
+ if (!this.isOpenInAnyClient(uri)) {
556
+ if (cause === 'lost') {
557
+ this.deferRelease(uri);
558
+ } else {
559
+ this.releaseDocument(uri);
560
+ }
590
561
  }
591
- if (!this.__documents.get(uri)?.clients.size) {
592
- // Last client closed the document; delete sync state. The downstream
593
- // "rebuild from disk for file URIs / drop from index for ephemeral
594
- // URIs" decision lives in `HydraniumDocumentUpdateHandler.didCloseDocument`,
595
- // which subscribes to `onDidClose` and consults `isOpenInAnyClient`
596
- // to detect the last-close transition.
597
- this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`);
598
- // Persist where the shared version sequence left off (version +
599
- // content hash) so the next open CONTINUES the sequence instead of
600
- // restarting at the reopening client's declared id. Hashed once
601
- // here at close, not on every change.
602
- this.__versionSequences.set(uri, {
603
- version: syncedDocument.version,
604
- contentHash: contentHash(syncedDocument.getText())
605
- });
606
- this.__syncedDocuments.delete(uri);
607
- // One delete clears every per-URI axis (version history + any staged
608
- // pending content) so a future open with the same URI starts fresh.
609
- this.__documents.delete(uri);
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Keep the document, text and all, for the release grace, then release it.
567
+ * The document stays in the store meanwhile, so a lost client that opens it
568
+ * again within its own grace attaches to it and finds its unsaved text
569
+ * rather than reading disk; see {@link resolveDeferredRelease} for any other
570
+ * open.
571
+ */
572
+ protected deferRelease(uri: CanonicalUri): void {
573
+ this.documentReleaseScheduler.defer(uri, () => {
574
+ if (!this.isOpenInAnyClient(uri)) {
575
+ this.releaseDocument(uri);
610
576
  }
577
+ });
578
+ if (this.documentReleaseScheduler.isDeferred(uri)) {
579
+ this.log(uri, `No client left; release deferred for ${this.documentReleaseScheduler.graceMs} ms (connection lost)`);
580
+ }
581
+ }
582
+
583
+ /** Resolve a deferred release of `uri` for an open by `clientId`: a release the scheduler decides on runs now. */
584
+ protected resolveDeferredRelease(uri: CanonicalUri, clientId: string): void {
585
+ if (this.documentReleaseScheduler.resolveOpen(uri, clientId) === 'release') {
586
+ this.releaseDocument(uri);
587
+ }
588
+ }
589
+
590
+ /** Release now a document waiting out the grace, as its last close would have; one that is not waiting is left alone. */
591
+ protected releaseIfDeferred(uri: CanonicalUri): void {
592
+ if (this.documentReleaseScheduler.isDeferred(uri)) {
593
+ this.documentReleaseScheduler.cancel(uri);
594
+ this.releaseDocument(uri);
611
595
  }
612
596
  }
613
597
 
598
+ /**
599
+ * Drop the document no client has open any more, announce it on
600
+ * {@link onDidReleaseDocument}, then hand it to the
601
+ * `DocumentReleaseHandler` slot: its listeners, such as the update
602
+ * handler dropping a change it still holds back, act before any build the
603
+ * handler runs.
604
+ */
605
+ protected releaseDocument(uri: CanonicalUri): void {
606
+ const syncedDocument = this.__syncedDocuments.get(uri);
607
+ if (syncedDocument === undefined) {
608
+ return;
609
+ }
610
+ this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`);
611
+ // The next open continues the sequence instead of restarting at the
612
+ // reopening client's declared id.
613
+ this.textLedger.record(uri, syncedDocument);
614
+ this.textLedger.clearAuthors(uri);
615
+ // Before the tracker's release, which forgets whether a file was behind it.
616
+ const released = this.toReleasedDocument(uri);
617
+ const cleanAnnouncement = this.dirtyStateTracker.release(uri);
618
+ this.__syncedDocuments.delete(uri);
619
+ this.__openedVersions.delete(uri);
620
+ this.__sessions.forgetClientVersions(uri);
621
+ // A stage is for a first open; one released unconsumed is stale.
622
+ this.__pendingContent.delete(uri);
623
+ this.documentReleaseScheduler.clearLosses(uri);
624
+ this.documentReleasedEmitter.fire(Object.freeze({ uri }));
625
+ this.handOverRelease(released, cleanAnnouncement);
626
+ }
627
+
628
+ /**
629
+ * Call the `DocumentReleaseHandler` slot, and announce a document released
630
+ * dirty clean once the promise it returns settles, which is after the
631
+ * release event. A failure is logged rather than thrown: the store has let
632
+ * go of the document by now, and a throw would abort the transition that
633
+ * released it, a session's close of its other documents included.
634
+ */
635
+ protected handOverRelease(released: ReleasedDocument, cleanAnnouncement: CleanAnnouncement | undefined): void {
636
+ let settled: Promise<void>;
637
+ try {
638
+ // Read here: a lazily built slot whose factory throws throws on this read.
639
+ const handler = this.services.workspace.DocumentReleaseHandler;
640
+ settled =
641
+ handler === undefined
642
+ ? Promise.reject(new Error('no workspace.DocumentReleaseHandler bound'))
643
+ : Promise.resolve(handler.didReleaseDocument(released));
644
+ } catch (err: unknown) {
645
+ settled = Promise.reject(err);
646
+ }
647
+ // Names the text the build holds at the settle, so it follows anything a
648
+ // release listener did meanwhile: none when its build failed or it no
649
+ // longer has the document, since a removal leaves the record on the
650
+ // discarded text.
651
+ const announceClean = (built: boolean): void => {
652
+ if (cleanAnnouncement?.isOwed()) {
653
+ const inBuild = built && this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(released.uri)) !== undefined;
654
+ cleanAnnouncement.announce(inBuild ? this.textState(released.uri) : undefined);
655
+ }
656
+ };
657
+ settled.then(
658
+ () => announceClean(true),
659
+ (err: unknown) => {
660
+ // A release skipped at teardown, its peer or its workspace gone, is
661
+ // routine, not a fault to investigate.
662
+ if (isDocumentReleaseSkippedError(err)) {
663
+ this.tracer.with(released.uri).debug(err.message);
664
+ } else {
665
+ this.tracer.with(released.uri).error(`Release handler failed. ${describeErrorWithStack(err)}`);
666
+ }
667
+ // Left unsettled, a watcher keeps the document dirty though the store answers clean.
668
+ announceClean(false);
669
+ }
670
+ );
671
+ }
672
+
673
+ /** `uri` as the `DocumentReleaseHandler` slot receives it. */
674
+ protected toReleasedDocument(uri: CanonicalUri): ReleasedDocument {
675
+ return {
676
+ uri,
677
+ wasOnDisk: this.dirtyStateTracker.hasDiskBaseline(uri),
678
+ isFor: other => this.documentKey(other) === uri,
679
+ isReclaimed: () => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri)
680
+ };
681
+ }
682
+
614
683
  public notifyWillSaveTextDocument(event: WillSaveTextDocumentParams): void {
615
684
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
616
685
  if (syncedDocument !== undefined) {
@@ -630,93 +699,183 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
630
699
  }
631
700
  }
632
701
 
702
+ /**
703
+ * Handle a save the language client reports: fire
704
+ * {@link onDidSaveInLanguageClient}, then announce it on {@link onDidSave}
705
+ * only once the file read back from disk holds the store's current text.
706
+ */
707
+ protected async notifyLanguageClientSave(event: DidSaveTextDocumentParams): Promise<void> {
708
+ const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
709
+ if (syncedDocument === undefined) {
710
+ return;
711
+ }
712
+ const uri = syncedDocument.uri;
713
+ this.languageClientSavedEmitter.fire(Object.freeze({ uri }));
714
+ // `onDidSave` tells its listeners that the shared document is on disk.
715
+ // The editor wrote its own buffer, which lags the store while another
716
+ // client's edit is still on its way to it, so only the file says
717
+ // whether that holds. Read it rather than trust the editor: `didSave`
718
+ // carries text only for a client that honours `includeText`, and asking
719
+ // for it means rewriting Langium's initialize answer, which advertises
720
+ // `save` as a Boolean. The read goes through the disk queue and waits on
721
+ // nothing but the provider, so it follows a server save queued
722
+ // meanwhile. A file changed again between the editor's write and this
723
+ // read compares unequal, and the save goes unannounced.
724
+ let onDisk: string | undefined;
725
+ try {
726
+ onDisk = await this.services.workspace.FileSystemTaskQueue.enqueue(uri, () =>
727
+ this.services.workspace.FileSystemProvider.readFile(UriUtils.toUri(uri))
728
+ );
729
+ } catch (err: unknown) {
730
+ this.tracer.with(uri).debug(`Editor save not announced: reading the file back failed. ${describeError(err)}`);
731
+ return;
732
+ }
733
+ // What the file holds, not what the editor meant to write: an editor
734
+ // that saved older text leaves the document dirty.
735
+ this.setDiskBaseline(uri, onDisk);
736
+ // An editor that saves and then closes releases the document while the
737
+ // read is under way; its save is still a save of the text it held.
738
+ const document = this.__syncedDocuments.get(this.documentKey(uri)) ?? syncedDocument;
739
+ if (onDisk !== document.getText()) {
740
+ this.tracer.with(uri).debug(`Editor save not announced: the file does not hold the text of version ${document.version}`);
741
+ return;
742
+ }
743
+ this.announceSave(document, LANGUAGE_CLIENT_ID);
744
+ }
745
+
746
+ /**
747
+ * Announce a save the server made. `event.text`, when given, is what the
748
+ * file now holds, written or found there, and becomes the disk baseline
749
+ * before the save is announced. It lags the store when an edit landed
750
+ * after the save took its text, and the document then stays dirty.
751
+ */
633
752
  public notifyDidSaveTextDocument(event: DidSaveTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
634
753
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(event.textDocument.uri));
635
754
  if (syncedDocument !== undefined) {
636
- this.log(syncedDocument.uri, `Saved synced document: ${syncedDocument.version} by ${clientId}`);
637
- this.__onDidSave.fire(Object.freeze({ document: syncedDocument, clientId }));
755
+ if (event.text !== undefined) {
756
+ this.setDiskBaseline(syncedDocument.uri, event.text);
757
+ }
758
+ this.announceSave(syncedDocument, clientId);
638
759
  }
639
760
  }
640
761
 
762
+ /** Fire {@link onDidSave} for `document`, which need not be synced any more. */
763
+ protected announceSave(document: T, clientId: string): void {
764
+ this.log(document.uri, `Saved synced document: ${document.version} by ${this.formatClientId(clientId)}`);
765
+ this.__onDidSave.fire(Object.freeze({ document, clientId }));
766
+ }
767
+
641
768
  public notifyDidOpenTextDocument(event: DidOpenTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void {
642
769
  const td = event.textDocument;
643
770
  const uri = this.documentKey(td.uri);
644
- if (this.isOpenInClient(uri, clientId)) {
645
- // Already open for this client under this canonical identity. If this is a
646
- // NEW client-facing URI for the same file (a second tab reached via a
647
- // divergent path, e.g. a symlink and its real path), record it and baseline
648
- // its shadow so outbound edits reach this tab too — but do NOT re-fire
649
- // open/rebuild; the document is already live.
650
- if (clientId === LANGUAGE_CLIENT_ID) {
651
- const clientFacing = this.toLanguageClientUri(td.uri);
652
- 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
- }
659
- }
660
- }
661
- return;
771
+ // Before the deferred release is resolved: refused after it, the open
772
+ // would leave a document whose grace it ended with no holder and no timer.
773
+ // A repeat open stays a no-op.
774
+ if (!this.__sessions.isOpenIn(uri, clientId)) {
775
+ this.__sessions.assertCanOpen(clientId);
662
776
  }
777
+ this.resolveDeferredRelease(uri, clientId);
663
778
  let document = this.__syncedDocuments.get(uri);
664
- const record = this.trackingFor(uri);
665
- const existingClients = [...record.clients];
666
- record.clients.add(clientId);
667
- // Baseline the per-client staleness guard at the version id the client
668
- // declared for its own buffer (client-owned per LSP).
669
- record.clientVersions.set(clientId, td.version);
670
- if (clientId === LANGUAGE_CLIENT_ID) {
671
- // Remember the URI Monaco opened under (may differ from the canonical key)
672
- // so outbound applyEditToLanguageClient can address the URI it actually holds.
673
- (record.languageClientUris ??= new Set<LanguageClientUri>()).add(this.toLanguageClientUri(td.uri));
779
+ const editor = clientId === LANGUAGE_CLIENT_ID;
780
+ // The client's declared text, never the synced document: staged
781
+ // content this open consumes leaves the two different, and a baseline
782
+ // asserting the client already holds it suppresses the one sync that
783
+ // would deliver it. On an attach, another client may already have
784
+ // changed the synced text, so the baseline is equality-only; a refresh
785
+ // pushing the full text would dirty the file on open. A first open's
786
+ // version is known only once its document is made, below.
787
+ if (editor) {
788
+ const held = document?.getText() === td.text ? document.version : undefined;
789
+ this.languageClientShadow.addOpen(uri, this.toLanguageClientUri(td.uri), td.version, td.text, document === undefined, held);
674
790
  }
791
+ if (this.isOpenInClient(uri, clientId)) {
792
+ // A repeat open, or the editor's second URI for the file, e.g. a
793
+ // symlink and its real path: the shadow records it so pushes reach it,
794
+ // and nothing re-fires.
795
+ return;
796
+ }
797
+ const existingClients = this.__sessions.clientsOf(uri);
798
+ this.__sessions.addOpen(uri, clientId);
799
+ this.__sessions.setClientVersion(uri, clientId, td.version);
675
800
  if (!document) {
676
801
  // Use integrity-staged content if available, otherwise the client-provided (disk) text.
677
802
  const pendingText = this.consumePendingContent(uri);
678
803
  const text = pendingText ?? td.text;
679
804
  const source = pendingText ? ', source=pending' : '';
680
- // The SHARED version is server-assigned: continue the persisted
681
- // sequence — same version when the content is unchanged since the
682
- // last close (so watchers' `baseVersion` pointers stay valid), one
683
- // 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.
686
- const sequence = this.__versionSequences.get(uri);
687
- 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}]`);
690
- document = this.configuration.create(uri, td.languageId, version, text);
691
- this.__syncedDocuments.set(uri, document);
692
- this.setAuthor(uri, version, clientId);
693
- if (clientId === LANGUAGE_CLIENT_ID) {
694
- // Baseline the shadow to what Monaco just opened so the next outbound
695
- // applyEditToLanguageClient diffs against the right starting point. Keyed by the
696
- // language-client URI (what Monaco holds), not the canonical document key.
697
- this.__shadow.set(this.toLanguageClientUri(td.uri), document.getText());
805
+ const version = this.textLedger.openingVersion(uri, text) ?? this.builtRootOpeningVersion(uri, text) ?? td.version;
806
+ if (editor && text === td.text) {
807
+ // What `addOpen` already recorded, now with the version it is held at.
808
+ this.languageClientShadow.setLanguageClientText(this.toLanguageClientUri(td.uri), text, version);
698
809
  }
810
+ this.log(uri, `Open document: Version ${version} by ${this.formatClientId(clientId)} [first client${source}]`);
811
+ document = this.create(uri, td.languageId, version, text);
812
+ this.commitText(uri, document, clientId);
813
+ this.__openedVersions.set(uri, version);
814
+ // The opener's text, not the staged content: a session's open read
815
+ // it from the file, and an editor opened its buffer from there. An
816
+ // editor that opens a buffer it never saved is taken as clean.
817
+ this.dirtyStateTracker.track(uri, document, td.text);
699
818
  const toFire = Object.freeze({ document, clientId });
700
819
  this.__onDidOpen.fire(toFire);
701
820
  this.__onDidChangeContent.fire(toFire);
702
821
  } else {
703
822
  // 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
- );
823
+ this.logClientJoined(uri, clientId, document.version, existingClients);
709
824
  this.refreshContent(uri, clientId);
710
825
  }
711
826
  }
712
827
 
828
+ /**
829
+ * Record `clientId` as an additional holder of a document another client
830
+ * already has open.
831
+ *
832
+ * Unlike the attach branch of {@link notifyDidOpenTextDocument} this does
833
+ * not refresh. That branch re-renders an attaching TEXTUAL view; a client
834
+ * reaching a document through a non-textual route has nothing to re-render,
835
+ * and refreshing per attach turns each into a Langium rebuild and dependent
836
+ * relink cascade.
837
+ *
838
+ * Returns whether a hold was added — `false` when `uri` is not open,
839
+ * `clientId` already holds it, or the document was waiting out the release
840
+ * grace for other clients and was released instead (see
841
+ * {@link resolveDeferredRelease}); the caller then opens it anew.
842
+ */
843
+ attachClient(uri: DocumentUri, clientId: string): boolean {
844
+ const key = this.documentKey(uri);
845
+ if (!this.__syncedDocuments.has(key) || this.isOpenInClient(key, clientId)) {
846
+ return false;
847
+ }
848
+ // Before the deferred release is resolved, as for an open.
849
+ this.__sessions.assertCanOpen(clientId);
850
+ this.resolveDeferredRelease(key, clientId);
851
+ const document = this.__syncedDocuments.get(key);
852
+ if (!document) {
853
+ return false;
854
+ }
855
+ const existingClients = this.__sessions.clientsOf(key);
856
+ this.__sessions.addOpen(key, clientId);
857
+ // A client arriving this way holds no buffer of its own, so its guard
858
+ // starts at the synced version.
859
+ this.__sessions.setClientVersion(key, clientId, document.version);
860
+ this.logClientJoined(key, clientId, document.version, existingClients);
861
+ return true;
862
+ }
863
+
864
+ protected logClientJoined(uri: DocumentUri, clientId: string, version: number, existingClients: readonly string[]): void {
865
+ this.log(
866
+ uri,
867
+ `Attach client: ${this.formatClientId(clientId)} joined existing document (version ${version}, ` +
868
+ `now open in: ${[...existingClients, clientId].map(id => this.formatClientId(id)).join(', ')})`
869
+ );
870
+ }
871
+
713
872
  refreshContent(uri: DocumentUri, clientId: string): void {
714
873
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(uri));
715
874
  if (syncedDocument) {
716
875
  // Trigger a (re-)build by firing a change event.
717
876
  const timer = this.startTimerForUri(
718
877
  syncedDocument.uri,
719
- `Refresh synced document: Version ${syncedDocument.version} by ${clientId}`
878
+ `Refresh synced document: Version ${syncedDocument.version} by ${this.formatClientId(clientId)}`
720
879
  );
721
880
  this.__onDidChangeContent.fire(Object.freeze({ document: syncedDocument, clientId }));
722
881
  timer.dispose();
@@ -730,7 +889,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
730
889
  * two URIs for one physical file (a symlink path and its real path) into a
731
890
  * single registration — dedup at the editor layer, not just in
732
891
  * `LangiumDocuments`. The URI the client opened under is preserved separately
733
- * for egress addressing (see {@link DocumentTracking.languageClientUris}). The
892
+ * for egress addressing, by the {@link LanguageClientShadow}. The
734
893
  * policy is always bound (the framework defaults it to
735
894
  * `DefaultDocumentUriPolicy`, where canonical ≡ syntactic normalize).
736
895
  */
@@ -750,18 +909,8 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
750
909
  return asLanguageClientUri(UriUtils.normalize(uri));
751
910
  }
752
911
 
753
- /** Get-or-create the per-URI tracking record. `uri` must already be a {@link documentKey}. */
754
- protected trackingFor(uri: CanonicalUri): DocumentTracking {
755
- let record = this.__documents.get(uri);
756
- if (!record) {
757
- record = { clients: new Set(), versionAuthors: [], clientVersions: new Map() };
758
- this.__documents.set(uri, record);
759
- }
760
- return record;
761
- }
762
-
763
912
  setAuthor(uri: DocumentUri, version: number, author: string): void {
764
- this.trackingFor(this.documentKey(uri)).versionAuthors[version] = author;
913
+ this.textLedger.setAuthor(this.documentKey(uri), version, author);
765
914
  }
766
915
 
767
916
  /**
@@ -786,57 +935,198 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
786
935
 
787
936
  /**
788
937
  * 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
938
+ * version, else where its persisted sequence left off, else `0` for a URI
939
+ * this store has never seen. The shared sequence is
791
940
  * server-owned and monotonic across close/reopen cycles, and advances
792
941
  * exactly when the synced content changes — which is what makes it a sound
793
- * optimistic-concurrency token (`baseVersion` gates): version unchanged ⇔
942
+ * optimistic-concurrency token (base-version gates): version unchanged ⇔
794
943
  * content unchanged.
795
944
  */
796
- version(uri: DocumentUri): number {
797
- return this.get(uri)?.version ?? this.__versionSequences.get(this.documentKey(uri))?.version ?? 0;
945
+ version(uri: DocumentUri): TextVersion {
946
+ return this.get(uri)?.version ?? this.textLedger.recordOf(this.documentKey(uri))?.version ?? 0;
947
+ }
948
+
949
+ /**
950
+ * The version the document at `uri` was opened at, which a client's write
951
+ * or an integrity repair steps past; `undefined` while it is not open.
952
+ */
953
+ openedVersion(uri: DocumentUri): TextVersion | undefined {
954
+ return this.__openedVersions.get(this.documentKey(uri));
955
+ }
956
+
957
+ /**
958
+ * Refuse unless the editor's buffer under `uri`, at its own `version`, holds
959
+ * the synced text, so a save the editor asks for persists what it shows.
960
+ * The editor's version counts its own buffer and the store's counts the
961
+ * synced text, so neither stands in for the other: `version` has to be the
962
+ * one the editor last declared, and the shadow has to know the buffer to
963
+ * hold the store's current version, which a push in flight breaks.
964
+ *
965
+ * A refusal is a {@link ConflictError} based on the store version the buffer
966
+ * last held, or on {@link STALE_VERSION} when it never held one or `version`
967
+ * names a buffer the editor never declared. It is a
968
+ * {@link DocumentNotOpenError} when the editor does not have `uri` open, or
969
+ * a delete of it is under way, which a save landing before the deletion
970
+ * build would undo by writing the file back.
971
+ */
972
+ assertLanguageClientHolds(uri: DocumentUri, version: number): void {
973
+ const key = this.documentKey(uri);
974
+ const clientUri = this.toLanguageClientUri(uri);
975
+ const declared = this.languageClientShadow.declaredVersion(key, clientUri);
976
+ const document = this.__syncedDocuments.get(key);
977
+ if (declared === undefined || document === undefined || this.services.workspace.AstDocumentManager.isDeleting(key)) {
978
+ throw new DocumentNotOpenError(uri, LANGUAGE_CLIENT_ID);
979
+ }
980
+ const held = declared === version ? this.languageClientShadow.heldVersion(key, clientUri) : undefined;
981
+ if (held !== document.version) {
982
+ const base = held ?? STALE_VERSION;
983
+ this.logUri(key, `Refuse editor save: buffer v${version} holds text v${base}, store holds v${document.version}`, 'debug');
984
+ // A snapshot, as the brand means: recorded when the buffer held that text, not read live.
985
+ throw new ConflictError(uri, asModelVersion(base), document.version, STALE_BASE_VERSION_SAVE);
986
+ }
987
+ }
988
+
989
+ /**
990
+ * Whether the editor holds text older than the store's text at `version`,
991
+ * or text the store never held. The first is the case while the push of
992
+ * that text is in flight. An answer computed on the store's text at
993
+ * `version` then refers to text the editor does not have. `false` once the
994
+ * editor holds that text or a later one, and when it does not have `uri`
995
+ * open.
996
+ */
997
+ isLanguageClientBehind(uri: DocumentUri, version: TextVersion): boolean {
998
+ const key = this.documentKey(uri);
999
+ const clientUri = this.toLanguageClientUri(uri);
1000
+ if (!this.languageClientShadow.isOpen(key, clientUri)) {
1001
+ return false;
1002
+ }
1003
+ const held = this.languageClientShadow.heldVersion(key, clientUri);
1004
+ return held === undefined || held < version;
1005
+ }
1006
+
1007
+ /**
1008
+ * The text the store holds for `uri`: an open document's, or for a closed
1009
+ * one, the clean text its version sequence left off at, which for a deleted
1010
+ * document is its last text. `undefined` for a URI no build and no client
1011
+ * gave the store.
1012
+ */
1013
+ textState(uri: DocumentUri): TextState | undefined {
1014
+ const document = this.get(uri);
1015
+ if (document) {
1016
+ return { version: document.version, hash: this.textLedger.hashOf(document), dirty: this.isDirty(uri) };
1017
+ }
1018
+ const recorded = this.textLedger.recordOf(this.documentKey(uri));
1019
+ return recorded && { version: recorded.version, hash: recorded.hash, dirty: false };
1020
+ }
1021
+
1022
+ /**
1023
+ * The built root's recorded version for a first open of `key` with `text`,
1024
+ * one on when the text differs from the root's; `undefined` when the root
1025
+ * records no store version. Seeded from the opener's declared version
1026
+ * instead, a write based on the built root passes the gate over other text,
1027
+ * and the same text looks newer than its model. A built root records none
1028
+ * only under a `LangiumDocuments` that does not reconcile at registration.
1029
+ */
1030
+ protected builtRootOpeningVersion(key: CanonicalUri, text: string): TextVersion | undefined {
1031
+ const built = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(key));
1032
+ if (built === undefined) {
1033
+ return undefined;
1034
+ }
1035
+ const ledger = this.services.workspace.ModelLedger;
1036
+ const root = built.parseResult.value;
1037
+ const recorded = ledger.versionOf(root);
1038
+ if (recorded === UNRECORDED_VERSION || recorded === STALE_VERSION) {
1039
+ return undefined;
1040
+ }
1041
+ return (ledger.textOf(root) ?? built.textDocument.getText()) === text ? recorded : recorded + 1;
798
1042
  }
799
1043
 
800
1044
  /**
801
1045
  * Reconcile the persisted version sequence with content that reached the
802
1046
  * build OUTSIDE the store's write paths — a closed document rebuilt from
803
- * disk (last-close revert) or replaced by a watched-file change. Steps the
1047
+ * disk after its release, or replaced by a watched-file change. Steps the
804
1048
  * sequence iff `text` differs from the sequence's last-known content and
805
1049
  * returns the resulting sequence version so the caller can re-stamp the
806
- * rebuilt document (see the `AstDocumentManager` Parsed-phase listener) —
1050
+ * rebuilt document (`VersionSyncService.modelProduced`) —
807
1051
  * keeping the "version advances iff content changes" invariant for
808
1052
  * documents no client currently holds.
809
1053
  *
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).
1054
+ * For an open document, its version when it holds `text`, since a build
1055
+ * that read the file while the document was closed can finish after an
1056
+ * open; else `undefined`, and the caller has to build the document again.
1057
+ *
1058
+ * A document without a sequence starts one at `0` with `text`: snapshots
1059
+ * hand out versions for documents no client opened, so an uncounted change
1060
+ * would let a write based on the old text pass the gate.
815
1061
  */
816
1062
  reconcileExternalContent(uri: DocumentUri, text: string): number | undefined {
817
1063
  const key = this.documentKey(uri);
818
- if (this.__syncedDocuments.has(key)) {
819
- return undefined;
1064
+ const open = this.__syncedDocuments.get(key);
1065
+ if (open !== undefined) {
1066
+ return open.getText() === text ? open.version : undefined;
820
1067
  }
821
- const sequence = this.__versionSequences.get(key);
822
- if (sequence === undefined) {
823
- return undefined;
1068
+ const before = this.textLedger.recordOf(key)?.version;
1069
+ const version = this.textLedger.reconcile(key, text);
1070
+ if (before !== undefined && version !== before) {
1071
+ this.logUri(key, `External content change while closed: sequence stepped to version ${version}`, 'debug');
1072
+ }
1073
+ return version;
1074
+ }
1075
+
1076
+ /**
1077
+ * Commit an integrity repair into the OPEN document for `uri`, returning the
1078
+ * document that now holds it, or `undefined` when there was nothing here to
1079
+ * commit into.
1080
+ *
1081
+ * The one thing this exists to do is address the store BY URI. A repair
1082
+ * reaches the resync as a text-document object, and that object is the
1083
+ * store's own only on the LSP path; a document built for an already-open URI
1084
+ * through `LangiumDocumentFactory.fromString` carries its own, so writing the
1085
+ * repair into it corrects a copy the store does not know about and the editor
1086
+ * never sees.
1087
+ *
1088
+ * `parsedFrom` is the text the repaired AST was parsed from, and a mismatch
1089
+ * against the current content REFUSES the commit: the store has moved on,
1090
+ * which means an editor change landed after that parse, and committing would
1091
+ * overwrite a newer edit with a repair computed against text the user has
1092
+ * already replaced. The build that change provokes recomputes the repair.
1093
+ *
1094
+ * Deliberately NOT compared by version. A separately created document seeds
1095
+ * its own numbering, so requiring the two to agree would refuse every commit
1096
+ * on the path this exists for. Content is the thing both sides can be held to.
1097
+ *
1098
+ * A changed text is a new version authored by {@link INTEGRITY_CLIENT_ID}.
1099
+ * Kept at the old version, the repair is invisible to every reader keyed on
1100
+ * versions: a write based on the unrepaired version passes the base-version gate
1101
+ * and replaces the repair, and an echo filter credits the repair to the
1102
+ * client whose edit it corrected. No change event is fired: the repair
1103
+ * rides the build already under way, and an event would re-enter it.
1104
+ */
1105
+ commitRepair(uri: DocumentUri, parsedFrom: string, repaired: string): RepairCommit<T> {
1106
+ const key = this.documentKey(uri);
1107
+ const document = this.__syncedDocuments.get(key);
1108
+ if (document === undefined) {
1109
+ return { status: 'not-open' };
1110
+ }
1111
+ if (document.getText() !== parsedFrom) {
1112
+ this.logUri(key, 'Refuse repair commit: the open document moved on from the text the AST was parsed from', 'debug');
1113
+ return { status: 'stale' };
824
1114
  }
825
- const hash = contentHash(text);
826
- if (hash === sequence.contentHash) {
827
- return sequence.version;
1115
+ if (repaired === parsedFrom) {
1116
+ return { status: 'committed', document };
828
1117
  }
829
- const stepped: VersionSequence = { version: sequence.version + 1, contentHash: hash };
830
- this.__versionSequences.set(key, stepped);
831
- this.logUri(key, `External content change while closed: sequence stepped to version ${stepped.version}`, 'debug');
832
- return stepped.version;
1118
+ // Reassigned rather than mutated in place: the default configuration
1119
+ // updates and returns the SAME instance, but an adopter-supplied one may
1120
+ // return a new object, and the store must end up holding whichever it is.
1121
+ const updated = this.commitChange(key, document, [{ text: repaired }], INTEGRITY_CLIENT_ID).document;
1122
+ this.log(updated.uri, `Update to version ${updated.version} by ${this.formatClientId(INTEGRITY_CLIENT_ID)} (repair)`);
1123
+ return { status: 'committed', document: updated };
833
1124
  }
834
1125
 
835
1126
  getAuthor(uri: DocumentUri, version?: number): string | undefined {
836
- const history = this.__documents.get(this.documentKey(uri))?.versionAuthors;
837
- // Either the requested version, or the latest. `version !== undefined` so we treat 0 correctly.
838
- const clientId = version !== undefined ? history?.[version] : history?.at(-1);
839
- if (!clientId && history) {
1127
+ const key = this.documentKey(uri);
1128
+ const clientId = this.textLedger.authorOf(key, version);
1129
+ if (!clientId && this.textLedger.authorOf(key) !== undefined) {
840
1130
  // Only warn when there IS a history but the specific version is missing; no history at all
841
1131
  // means the document was rebuilt internally (e.g. by a project manager), not an error.
842
1132
  this.log(uri, `Could not detect author of version ${version}.`);
@@ -852,17 +1142,19 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
852
1142
  * True iff any client still holds `uri` open. Distinct from {@link isOpen},
853
1143
  * which reads `__syncedDocuments` — that map is cleared only AFTER the
854
1144
  * `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`
857
- * subscriber that finds this `false` knows the last client just closed and a
858
- * disk re-read / rebuild can proceed.
1145
+ * during the close event itself. `isOpenInAnyClient` reads the open table
1146
+ * ({@link __sessions}), which is updated BEFORE the fire, so an `onDidClose`
1147
+ * subscriber that finds this `false` knows the last client just closed.
1148
+ * What the build keeps is the release's: a subscriber that re-read or
1149
+ * rebuilt the document would race the `DocumentReleaseHandler`, and after a
1150
+ * lost connection the release waits out its grace.
859
1151
  */
860
1152
  isOpenInAnyClient(uri: DocumentUri): boolean {
861
- return (this.__documents.get(this.documentKey(uri))?.clients.size ?? 0) > 0;
1153
+ return this.__sessions.isOpen(this.documentKey(uri));
862
1154
  }
863
1155
 
864
1156
  isOpenInClient(uri: DocumentUri, client: string): boolean {
865
- return !!this.__documents.get(this.documentKey(uri))?.clients.has(client);
1157
+ return this.__sessions.isOpenIn(this.documentKey(uri), client);
866
1158
  }
867
1159
 
868
1160
  isOpenInLanguageClient(uri: DocumentUri): boolean {
@@ -872,15 +1164,20 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
872
1164
  return this.isOpenInClient(uri, LANGUAGE_CLIENT_ID);
873
1165
  }
874
1166
 
1167
+ /** The clients that have `uri` open, in the order they opened it. */
1168
+ clientsOf(uri: DocumentUri): string[] {
1169
+ return this.__sessions.clientsOf(this.documentKey(uri));
1170
+ }
1171
+
875
1172
  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);
1173
+ const clients = this.__sessions.clientsOf(this.documentKey(uri));
1174
+ return clients.length === 1 && clients[0] === client;
878
1175
  }
879
1176
 
880
1177
  /**
881
1178
  * 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.
1179
+ * ids holding it. Reads the same open table as {@link isOpenInAnyClient}, so
1180
+ * it reflects a last-client close immediately.
884
1181
  *
885
1182
  * Diagnostics-oriented — the server-state snapshot lists these so an operator
886
1183
  * can see WHY a document is pinned: a document that lingers here after its
@@ -888,31 +1185,268 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
888
1185
  * under a shedding policy, explains why its CST is never shed).
889
1186
  */
890
1187
  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] });
1188
+ return this.__sessions.openDocuments();
1189
+ }
1190
+
1191
+ /** Fires once a client session has ended, after every document it had open was closed. */
1192
+ get onDidCloseSession(): Event<ClientSessionClosedEvent> {
1193
+ return this.__sessions.onDidCloseSession;
1194
+ }
1195
+
1196
+ /**
1197
+ * Fires once a document no client has open is released: at its last close,
1198
+ * or, for a lost client's last close, when its release grace runs out,
1199
+ * another client opens it, or it is deleted, before the
1200
+ * `DocumentReleaseHandler` slot is handed the document.
1201
+ */
1202
+ get onDidReleaseDocument(): Event<DocumentReleasedEvent> {
1203
+ return this.documentReleasedEmitter.event;
1204
+ }
1205
+
1206
+ /**
1207
+ * Fires for every save the language client reports of a document it has
1208
+ * open: the editor has written the file. {@link onDidSave} follows only
1209
+ * when the file holds the store's text.
1210
+ */
1211
+ get onDidSaveInLanguageClient(): Event<LanguageClientSavedEvent> {
1212
+ return this.languageClientSavedEmitter.event;
1213
+ }
1214
+
1215
+ /**
1216
+ * Whether `uri` is waiting out the release grace: its last open closed with a
1217
+ * lost connection, and it still holds its unsaved text. Such a document is
1218
+ * open for no client, yet not closed either, so a caller that would persist
1219
+ * a closed document's text to disk treats it as open.
1220
+ */
1221
+ isReleaseDeferred(uri: DocumentUri): boolean {
1222
+ return this.documentReleaseScheduler.isDeferred(this.documentKey(uri));
1223
+ }
1224
+
1225
+ /**
1226
+ * Whether the store holds `uri` with text that differs from its disk
1227
+ * baseline: what the server last knew the file to hold. `false` for a URI
1228
+ * the store does not hold; a document waiting out the release grace is still
1229
+ * held.
1230
+ *
1231
+ * The baseline is the text a first open brought, or what the server wrote,
1232
+ * or read back after an editor's save or a watched-file change, so it can
1233
+ * trail a change to the file that none of these has seen yet. A check that
1234
+ * must know the file reads it instead.
1235
+ */
1236
+ isDirty(uri: DocumentUri): boolean {
1237
+ return this.dirtyStateTracker.isDirty(this.documentKey(uri));
1238
+ }
1239
+
1240
+ /**
1241
+ * Fires each time the answer of {@link isDirty} changes. For a document
1242
+ * released dirty it fires once the `DocumentReleaseHandler` slot reports the
1243
+ * release settled: at the version of the text the build then holds, or
1244
+ * without text when the document is gone or the handler failed, though
1245
+ * {@link isDirty} answers clean from the release on.
1246
+ */
1247
+ get onDidChangeDirty(): Event<DocumentDirtyChangedEvent> {
1248
+ return this.dirtyStateTracker.onDidChangeDirty;
1249
+ }
1250
+
1251
+ /**
1252
+ * Record that the file behind `uri` holds `text`, or no file at all for
1253
+ * `undefined`. A no-op for a URI the store does not hold: the next first
1254
+ * open sets the baseline from its own text.
1255
+ */
1256
+ setDiskBaseline(uri: DocumentUri, text: string | undefined): void {
1257
+ const key = this.documentKey(uri);
1258
+ const document = this.__syncedDocuments.get(key);
1259
+ if (document !== undefined) {
1260
+ this.dirtyStateTracker.setDiskBaseline(key, document, text);
1261
+ }
1262
+ }
1263
+
1264
+ /**
1265
+ * Record that `uri` was created from a session's text rather than read from
1266
+ * its file: it has no disk baseline, so it is dirty until its first save,
1267
+ * and {@link isUnsavedCreation} answers `true` until then. A no-op for a
1268
+ * URI the store does not hold.
1269
+ */
1270
+ markCreated(uri: DocumentUri): void {
1271
+ const key = this.documentKey(uri);
1272
+ const document = this.__syncedDocuments.get(key);
1273
+ if (document !== undefined) {
1274
+ this.dirtyStateTracker.markCreated(key, document);
1275
+ }
1276
+ }
1277
+
1278
+ /**
1279
+ * Whether `uri` was created from a session's text and the store has not
1280
+ * seen its file since, by a save or a reload that found it. A baseline
1281
+ * cleared because the file went missing does not count. `false` for a URI
1282
+ * the store does not hold.
1283
+ */
1284
+ isUnsavedCreation(uri: DocumentUri): boolean {
1285
+ return this.dirtyStateTracker.isUnsavedCreation(this.documentKey(uri));
1286
+ }
1287
+
1288
+ /**
1289
+ * Read the file behind `uri` through its disk queue and take it as the
1290
+ * baseline. A file that cannot be read counts as none, the side that
1291
+ * leaves the document dirty, once `existsSettled` confirms it gone: a file
1292
+ * it finds is read again. That wait runs outside the queue, so it holds up
1293
+ * no write to the file.
1294
+ */
1295
+ async reloadDiskBaseline(uri: DocumentUri): Promise<void> {
1296
+ const key = this.documentKey(uri);
1297
+ if (!this.__syncedDocuments.has(key)) {
1298
+ return;
1299
+ }
1300
+ const workspace = this.services.workspace;
1301
+ const fileSystem = workspace.FileSystemProvider;
1302
+ const target = UriUtils.toUri(key);
1303
+ const read = async (): Promise<string | undefined> => {
1304
+ try {
1305
+ return await workspace.FileSystemTaskQueue.enqueue(key, () => fileSystem.readFile(target));
1306
+ } catch (err: unknown) {
1307
+ this.tracer.with(key).debug(`Disk baseline: the file cannot be read. ${describeError(err)}`);
1308
+ return undefined;
1309
+ }
1310
+ };
1311
+ let onDisk = await read();
1312
+ if (onDisk === undefined && (await (fileSystem.existsSettled?.(target) ?? fileSystem.exists(target)))) {
1313
+ onDisk = await read();
1314
+ }
1315
+ this.setDiskBaseline(key, onDisk);
1316
+ }
1317
+
1318
+ /**
1319
+ * Start a client session under `clientId`. Throws where
1320
+ * {@link ClientSessionRegistry.register} refuses the id.
1321
+ */
1322
+ registerSession(clientId: string): void {
1323
+ this.__sessions.register(clientId);
1324
+ this.tracer.info(`Session started: ${this.formatClientId(clientId)}`);
1325
+ this.tracer.trace(`Session started: ${clientId}`);
1326
+ }
1327
+
1328
+ /**
1329
+ * End the client session `clientId`: close every document it has open, then
1330
+ * free the id. Immediate — each close runs the ordinary close path before
1331
+ * this returns, with `cause` as its cause. A no-op for an id that is not a
1332
+ * registered session.
1333
+ */
1334
+ closeSession(clientId: string, cause: SessionEndCause = 'closed'): void {
1335
+ if (!this.__sessions.isRegistered(clientId)) {
1336
+ return;
1337
+ }
1338
+ try {
1339
+ for (const uri of this.__sessions.beginClose(clientId)) {
1340
+ this.notifyDidCloseTextDocument({ textDocument: { uri } }, clientId, cause);
895
1341
  }
1342
+ } finally {
1343
+ this.__sessions.unregister(clientId, cause);
1344
+ this.tracer.info(`Session closed: ${this.formatClientId(clientId)}`);
1345
+ this.tracer.trace(`Session closed: ${clientId}`);
896
1346
  }
897
- return result;
898
1347
  }
899
1348
 
900
1349
  /**
901
- * Stages integrity-updated content for a closed document.
1350
+ * Close every document the language client has open, as a `didClose` for
1351
+ * each would, so each last close releases its document. For a host whose editor connection
1352
+ * can end while the process lives on, such as a worker whose port's peer
1353
+ * closed: the language client is no session, so nothing else closes them.
902
1354
  *
903
- * When `workspace/applyEdit` targets a closed file, the client opens it from
904
- * disk (stale) and sends `didOpen` before applying the edit. This staged
905
- * content is consumed by {@link notifyDidOpenTextDocument} to replace the
906
- * stale disk text, preventing a brief revert of the integrity update.
1355
+ * It first waits for the workspace initialization the open handler waits
1356
+ * for, so an open that arrived before the close is closed too; closing at
1357
+ * once would leave that open to land afterwards, held by a client that is
1358
+ * gone.
1359
+ */
1360
+ async closeLanguageClientDocuments(): Promise<void> {
1361
+ await this.initialBuildFinished();
1362
+ for (const uri of this.__sessions.opensOf(LANGUAGE_CLIENT_ID)) {
1363
+ // A close for one URI while others remain keeps the client's hold.
1364
+ this.languageClientShadow.removeAllOpens(uri);
1365
+ this.notifyDidCloseTextDocument({ textDocument: { uri } });
1366
+ }
1367
+ }
1368
+
1369
+ /**
1370
+ * Settles once the initial workspace build has finished, whether it
1371
+ * completed, was cancelled or failed. Awaiting `workspaceInitialized` itself
1372
+ * throws after a cancelled build, which any write during startup causes, and
1373
+ * every open and close gated on it is then dropped for the process lifetime.
1374
+ * The workspace manager logs a failed build.
1375
+ */
1376
+ protected async initialBuildFinished(): Promise<void> {
1377
+ await this.services.workspace.WorkspaceManager.workspaceInitialized.catch(() => undefined);
1378
+ }
1379
+
1380
+ /**
1381
+ * The file behind `uri` was deleted: close every open of it except the
1382
+ * language client's, and release now a document waiting out the grace,
1383
+ * whose lost client's open would otherwise bring its text back with no
1384
+ * file behind it.
907
1385
  *
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.
1386
+ * The editor's open is left alone because the editor owns it: it keeps the
1387
+ * buffer of a deleted file and goes on sending changes for it, and a store
1388
+ * that had closed the document would drop every one of them until the editor
1389
+ * reopened.
1390
+ */
1391
+ notifyDocumentDeleted(uri: DocumentUri): void {
1392
+ const key = this.documentKey(uri);
1393
+ for (const clientId of this.__sessions.clientsOf(key)) {
1394
+ if (clientId !== LANGUAGE_CLIENT_ID) {
1395
+ this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
1396
+ }
1397
+ }
1398
+ this.releaseIfDeferred(key);
1399
+ }
1400
+
1401
+ /**
1402
+ * Remove the document for `uri`, closing every open of it first — the
1403
+ * editor's included, since the document it would keep editing is gone.
1404
+ *
1405
+ * The base removes the synced document without consulting the open table,
1406
+ * which leaves every open recorded against a document that no longer exists:
1407
+ * the store then answers that the URI is open, and the next open attaches to
1408
+ * nothing.
1409
+ */
1410
+ override delete(uri: string | URI | T): void {
1411
+ const key = this.documentKey((typeof uri === 'object' && 'uri' in uri ? uri.uri : uri).toString());
1412
+ this.languageClientShadow.removeAllOpens(key);
1413
+ for (const clientId of this.__sessions.clientsOf(key)) {
1414
+ this.notifyDidCloseTextDocument({ textDocument: { uri: key } }, clientId);
1415
+ }
1416
+ // A document waiting out the grace has no client left to close, and is
1417
+ // released now as its last close would have released it.
1418
+ this.releaseIfDeferred(key);
1419
+ super.delete(key);
1420
+ }
1421
+
1422
+ /**
1423
+ * A client id as log lines print it. An id containing `#` is cut eight
1424
+ * characters after its last `#`, which leaves a minted `label#uuid` as the
1425
+ * label and enough of the UUID to tell sessions apart; an id without `#`
1426
+ * prints whole. The full id goes out at trace level wherever a session
1427
+ * starts or ends.
1428
+ */
1429
+ protected formatClientId(clientId: string): string {
1430
+ const separator = clientId.lastIndexOf('#');
1431
+ return separator < 0 ? clientId : clientId.slice(0, separator + 9);
1432
+ }
1433
+
1434
+ /**
1435
+ * Stages integrity-updated content for a document no client holds.
1436
+ *
1437
+ * Nothing is pushed to a file no client holds. The next open of it reads
1438
+ * disk, which lacks the update; {@link notifyDidOpenTextDocument} takes this
1439
+ * staged content in place of that text, and the open's sync then delivers
1440
+ * it to an editor as an unsaved change.
1441
+ *
1442
+ * Only a FIRST open consumes it, so stage only for a URI no client holds
1443
+ * ({@link isOpenInAnyClient} is `false`). A URI held only through another
1444
+ * head is not closed: an editor attaching to it joins the existing entry and
1445
+ * never reads the stage, and the release discards it. An entry waits for
1446
+ * that first open, at one serialised string per URI.
913
1447
  */
914
1448
  stagePendingContent(uri: DocumentUri, text: string): void {
915
- this.trackingFor(this.documentKey(uri)).pendingContent = text;
1449
+ this.__pendingContent.set(this.documentKey(uri), text);
916
1450
  }
917
1451
 
918
1452
  /**
@@ -924,29 +1458,34 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
924
1458
  * - The shared services have no LSP {@link Connection} bound (non-LSP
925
1459
  * hosts like CLI / tests).
926
1460
  * - The shadow already matches `newText` (no edits needed; quiet skip).
1461
+ * - The language client has not opened the document. A client applies an
1462
+ * edit to a closed file by opening it, and that open races the edit;
1463
+ * the document's own open delivers its text instead.
927
1464
  *
928
1465
  * On `applyEdit` rejection (`result.applied === false`) or RPC failure the
929
- * shadow is invalidated so the next call sends a full-replace baseline.
1466
+ * shadow is invalidated so the next call sends a full replace, or nothing
1467
+ * when the client was last heard to hold that text.
930
1468
  * Errors are re-thrown — callers wrap with their own retry / coalescing
931
1469
  * policy as needed.
932
1470
  *
933
1471
  * The diff path is apply-verify-safe: the shadow internally checks that
934
1472
  * `TextDocument.applyEdits(old, edits) === newText` and falls back to a
935
- * full replace on mismatch (logged via the warn-callback wired in the
936
- * constructor), so a diff regression becomes log noise, not data loss.
1473
+ * full replace on mismatch, logged, so a diff regression becomes log noise,
1474
+ * not data loss.
937
1475
  *
938
1476
  * That safety net verifies the diff against the SHADOW, which is what the
939
1477
  * client is *believed* to hold — so it cannot see the client's buffer moving
940
1478
  * underneath a push. A line-keyed edit is position-dependent: if a genuine
941
- * client keystroke lands between {@link LanguageClientTextShadow.computeEdits}
942
- * and the client applying, the ranges address the wrong lines and splice the
943
- * buffer (observed as a duplicated declaration, which the integrity tier then
1479
+ * client keystroke lands between computing the edits and the client
1480
+ * applying them, the ranges address the wrong lines and splice the buffer
1481
+ * (observed as a duplicated declaration, which the integrity tier then
944
1482
  * "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
947
- * reject a push its buffer has outrun. On rejection the shadow is invalidated,
1483
+ * addressed at the language client's last known version for that URI rather
1484
+ * than at `null` ("version intentionally unknown"), which is what lets the
1485
+ * client reject a push its buffer has outrun. On rejection the shadow is invalidated,
948
1486
  * so the caller's retry is a full-range replace — position-independent, and
949
- * safe to apply to whatever the client now holds.
1487
+ * safe to apply to whatever the client now holds — or nothing when the
1488
+ * client was last heard to hold that text.
950
1489
  */
951
1490
  async applyEditToLanguageClient(
952
1491
  uri: DocumentUri,
@@ -957,34 +1496,19 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
957
1496
  if (!connection) {
958
1497
  return undefined;
959
1498
  }
960
- // The document is keyed by its canonical identity, but Monaco holds it under
961
- // the URI(s) it opened. Address each of those language-client URIs — the one
962
- // R→S translation, kept here at the egress. Usually one; more than one only
963
- // when the same file was opened under a symlink and its real path. Falls back
964
- // to the normalized URI when nothing is tracked. The shadow is keyed by each
965
- // 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)];
1499
+ // The document is keyed by its canonical identity, but the client holds it
1500
+ // under each URI it opened, and each is diffed against its own baseline.
1501
+ const key = this.documentKey(uri);
968
1502
  let lastResult: ApplyWorkspaceEditResult | undefined;
969
- 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);
974
- const edits = this.__shadow.computeEdits(targetUri, newText);
975
- if (edits.length === 0) {
1503
+ for (const clientUri of this.languageClientShadow.pushTargets(key)) {
1504
+ // Prepared per target, after the previous target's reply: prepared up
1505
+ // front, a later target is diffed against what it held before a change
1506
+ // that arrived meanwhile.
1507
+ const push = this.languageClientShadow.preparePush(key, clientUri, newText);
1508
+ if (push === undefined) {
976
1509
  continue;
977
1510
  }
978
- // Record the push for echo correlation BEFORE the RPC — the client's
979
- // echo can race the applyEdit response. See `__pendingPushes`.
980
- this.recordPendingPush(targetUri, before, newText);
981
1511
  try {
982
- // Version the push only when it is position-DEPENDENT. A full-range
983
- // replace lands correctly on any buffer, so gating it would turn a
984
- // stale-by-one version into a refused update for no safety gain — and
985
- // it is exactly what the caller retries with after a rejection, so
986
- // gating it there would refuse the recovery too.
987
- const version = isFullReplace(edits) ? UNKNOWN_CLIENT_VERSION : this.languageClientVersion(uri);
988
1512
  // A full `ApplyWorkspaceEditParams`, `edit` and all — NOT a bare
989
1513
  // `WorkspaceEdit` with a `label` beside it. `applyEdit` takes
990
1514
  // `ApplyWorkspaceEditParams | WorkspaceEdit` and discriminates on
@@ -994,20 +1518,21 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
994
1518
  // The union is also what hides it at compile time: excess-property
995
1519
  // checking admits a property present in EITHER member, so an object
996
1520
  // matching neither type-checks against the union.
1521
+ const version = push.version ?? UNKNOWN_CLIENT_VERSION;
997
1522
  const result = await connection.workspace.applyEdit({
998
1523
  label: options?.label,
999
1524
  edit: {
1000
- documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1525
+ documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(clientUri, version), push.edits)]
1001
1526
  }
1002
1527
  });
1003
1528
  if (result && result.applied === false) {
1004
- this.__shadow.invalidate(targetUri);
1005
- this.__pendingPushes.delete(targetUri);
1529
+ push.notifyOutcome('refused');
1530
+ } else if (result?.applied) {
1531
+ push.notifyOutcome('applied');
1006
1532
  }
1007
1533
  lastResult = result;
1008
- } catch (err) {
1009
- this.__shadow.invalidate(targetUri);
1010
- this.__pendingPushes.delete(targetUri);
1534
+ } catch (err: unknown) {
1535
+ push.notifyOutcome('failed');
1011
1536
  throw err;
1012
1537
  }
1013
1538
  }
@@ -1015,125 +1540,34 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1015
1540
  }
1016
1541
 
1017
1542
  /**
1018
- * The version the LSP textual language client last declared for `uri`, for
1019
- * addressing an outgoing `workspace/applyEdit`.
1020
- *
1021
- * Client version ids are CLIENT-owned per LSP, so this is the id the client
1022
- * itself stamped on its last `didOpen` / `didChange` — never the shared
1023
- * server version, which advances on authored writes the client knows nothing
1024
- * about and would therefore reject every push.
1025
- *
1026
- * Falls back to {@link UNKNOWN_CLIENT_VERSION} when the client has never
1027
- * declared one, which is the honest answer for a document it has not opened.
1028
- * That is also the case in which there is no shadow, so the push is already a
1029
- * position-independent full replace and has nothing to gain from a gate.
1030
- */
1031
- protected languageClientVersion(uri: DocumentUri): number {
1032
- return this.__documents.get(this.documentKey(uri))?.clientVersions.get(LANGUAGE_CLIENT_ID) ?? UNKNOWN_CLIENT_VERSION;
1033
- }
1034
-
1035
- /**
1036
- * Append a push to the in-flight queue for `targetUri`
1037
- * (see {@link __pendingPushes}). Bounded: beyond
1038
- * {@link PENDING_ECHO_CAP} the oldest entry drops with a debug log — an
1039
- * echo that far outstanding means the client is not echoing at all, and
1040
- * an unbounded queue must not become the leak.
1543
+ * Explicitly baseline the language-client shadow for a URI the client has
1544
+ * open, for an adopter that knows what the client holds without a
1545
+ * `didChange` saying so (e.g. after a sideband save). Normal didOpen /
1546
+ * didChange paths from the LSP language client already auto-track the
1547
+ * shadow. A seed for a URI the client has not opened is kept but changes no
1548
+ * push, since nothing is pushed there; its open replaces it with the text it
1549
+ * declares. `text` other than the synced text refuses the editor's saves
1550
+ * until the editor holds the synced text again.
1041
1551
  */
1042
- protected recordPendingPush(targetUri: LanguageClientUri, before: string | undefined, newText: string): void {
1043
- let pending = this.__pendingPushes.get(targetUri);
1044
- if (!pending) {
1045
- pending = [];
1046
- this.__pendingPushes.set(targetUri, pending);
1047
- }
1048
- pending.push({ before, afterHash: contentHash(newText) });
1049
- if (pending.length > PENDING_ECHO_CAP) {
1050
- pending.shift();
1051
- this.logUri(targetUri, `Pending-echo queue exceeded ${PENDING_ECHO_CAP} entries; dropped the oldest`, 'debug');
1052
- }
1053
- }
1054
-
1055
- /**
1056
- * Decide what an incoming language-client change actually is, by
1057
- * reconstructing the client's resulting buffer against the OLDEST push still
1058
- * in flight for `clientFacing`.
1059
- *
1060
- * `undefined` when nothing of ours is in flight, which is the ordinary path:
1061
- * the client's ranges then address the synced text and the caller applies
1062
- * them to it directly.
1063
- *
1064
- * **Why the oldest, and why reconstruct at all.** The client applies our
1065
- * pushes in order and echoes each against the buffer it held before that
1066
- * push, so the first echo to arrive belongs to the oldest entry — a FIFO
1067
- * correspondence the queue preserves by consuming from the front. Comparing
1068
- * the reconstruction against every pending hash, not only the oldest, is
1069
- * what recognises an echo that a newer write already superseded: a rapid
1070
- * write sequence can deliver the echo of push N after the store applied
1071
- * push N+1, and everything older is then accounted for too.
1072
- *
1073
- * A reconstruction matching NO pending push means the client's buffer went
1074
- * somewhere we did not send it — it coalesced a keystroke into the echo, or
1075
- * typed before the push landed. That text is authoritative, and the queue
1076
- * drops: the client has stopped being a pure mirror, so no outstanding echo
1077
- * can match again. (A push still in flight at that point will be refused by
1078
- * the client's own version gate, which invalidates the shadow and makes the
1079
- * next sync a position-independent full replace.)
1080
- *
1081
- * Content equality is a sound echo proof because entries live only between a
1082
- * push and its echo — a milliseconds window, never history — and the
1083
- * client's `didChange` stream is ordered, so every buffer state arrives in
1084
- * mutation order. An UNDO returning the buffer to previously-pushed text is
1085
- * therefore never swallowed: the edit that preceded it already emptied the
1086
- * queue, so undo revisits PAST states while the queue holds IN-FLIGHT ones.
1087
- */
1088
- protected classifyLanguageClientChange(
1089
- clientFacing: LanguageClientUri,
1090
- document: T,
1091
- changes: TextDocumentContentChangeEvent[]
1092
- ): LanguageClientChangeOrigin | undefined {
1093
- const pending = this.__pendingPushes.get(clientFacing);
1094
- if (pending === undefined || pending.length === 0) {
1095
- return undefined;
1096
- }
1097
- // Through the configured factories, not `TextDocument` directly, so an
1098
- // adopter's custom text-document type governs how the ranges are applied
1099
- // here exactly as it does on the synced document.
1100
- const probe = this.create(clientFacing, document.languageId, 0, pending[0].before ?? document.getText());
1101
- const clientText = this.update(probe, changes, 0).getText();
1102
- const matchIndex = pending.findIndex(push => push.afterHash === contentHash(clientText));
1103
- if (matchIndex >= 0) {
1104
- pending.splice(0, matchIndex + 1);
1105
- return { kind: 'echo' };
1106
- }
1107
- pending.length = 0;
1108
- return { kind: 'divergent', text: clientText };
1552
+ setLanguageClientText(uri: DocumentUri, text: string): void {
1553
+ const document = this.get(uri);
1554
+ const held = document?.getText() === text ? document.version : undefined;
1555
+ this.languageClientShadow.setLanguageClientText(this.toLanguageClientUri(uri), text, held);
1109
1556
  }
1110
1557
 
1111
1558
  /**
1112
- * Explicitly baseline the language-client text shadow for a URI. Useful in
1113
- * tests and for adopters that need to seed the shadow without going through
1114
- * a `didOpen` event (e.g. after a sideband save). Normal didOpen / didChange
1115
- * paths from the LSP language client already auto-track the shadow.
1559
+ * Drop the shadow baseline for a URI; the next applyEditToLanguageClient
1560
+ * sends a full replace, or nothing when the client was last heard to hold
1561
+ * that text.
1116
1562
  */
1117
- setLanguageClientText(uri: DocumentUri, text: string): void {
1118
- const clientFacing = this.toLanguageClientUri(uri);
1119
- this.__shadow.set(clientFacing, text);
1120
- // An explicit rebaseline supersedes whatever was in flight.
1121
- this.__pendingPushes.delete(clientFacing);
1122
- }
1123
-
1124
- /** Drop the shadow baseline for a URI; the next applyEditToLanguageClient sends a full replace. */
1125
1563
  invalidateLanguageClientText(uri: DocumentUri): void {
1126
- const clientFacing = this.toLanguageClientUri(uri);
1127
- this.__shadow.invalidate(clientFacing);
1128
- this.__pendingPushes.delete(clientFacing);
1564
+ this.languageClientShadow.invalidateLanguageClientText(this.toLanguageClientUri(uri));
1129
1565
  }
1130
1566
 
1131
1567
  protected consumePendingContent(uri: DocumentUri): string | undefined {
1132
- const record = this.__documents.get(this.documentKey(uri));
1133
- const content = record?.pendingContent;
1134
- if (record) {
1135
- record.pendingContent = undefined;
1136
- }
1568
+ const key = this.documentKey(uri);
1569
+ const content = this.__pendingContent.get(key);
1570
+ this.__pendingContent.delete(key);
1137
1571
  return content;
1138
1572
  }
1139
1573