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

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