@hydranium/core 1.0.0-next.7 → 1.0.0-next.71

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 (286) hide show
  1. package/lib/documents/ast-document-manager.d.ts +58 -5
  2. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  3. package/lib/documents/ast-document-manager.js +50 -18
  4. package/lib/documents/ast-document-manager.js.map +1 -1
  5. package/lib/documents/client-ids.d.ts +5 -19
  6. package/lib/documents/client-ids.d.ts.map +1 -1
  7. package/lib/documents/client-ids.js +5 -19
  8. package/lib/documents/client-ids.js.map +1 -1
  9. package/lib/documents/hydranium-text-documents.d.ts +51 -17
  10. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  11. package/lib/documents/hydranium-text-documents.js +136 -40
  12. package/lib/documents/hydranium-text-documents.js.map +1 -1
  13. package/lib/documents/language-client-text-shadow.d.ts +41 -9
  14. package/lib/documents/language-client-text-shadow.d.ts.map +1 -1
  15. package/lib/documents/language-client-text-shadow.js +56 -11
  16. package/lib/documents/language-client-text-shadow.js.map +1 -1
  17. package/lib/documents/self-save-registry.d.ts +37 -6
  18. package/lib/documents/self-save-registry.d.ts.map +1 -1
  19. package/lib/documents/self-save-registry.js +28 -15
  20. package/lib/documents/self-save-registry.js.map +1 -1
  21. package/lib/index.d.ts +2 -0
  22. package/lib/index.d.ts.map +1 -1
  23. package/lib/index.js +5 -0
  24. package/lib/index.js.map +1 -1
  25. package/lib/langium/ast-extension/ast-node-builder.d.ts +32 -9
  26. package/lib/langium/ast-extension/ast-node-builder.d.ts.map +1 -1
  27. package/lib/langium/ast-extension/ast-node-builder.js +60 -22
  28. package/lib/langium/ast-extension/ast-node-builder.js.map +1 -1
  29. package/lib/langium/bootstrap.d.ts.map +1 -1
  30. package/lib/langium/bootstrap.js +16 -9
  31. package/lib/langium/bootstrap.js.map +1 -1
  32. package/lib/langium/config/configuration-provider.d.ts +81 -0
  33. package/lib/langium/config/configuration-provider.d.ts.map +1 -0
  34. package/lib/langium/config/configuration-provider.js +112 -0
  35. package/lib/langium/config/configuration-provider.js.map +1 -0
  36. package/lib/langium/config/index.d.ts +1 -0
  37. package/lib/langium/config/index.d.ts.map +1 -1
  38. package/lib/langium/config/index.js +1 -0
  39. package/lib/langium/config/index.js.map +1 -1
  40. package/lib/langium/diagnostics/lsp-logger.d.ts +17 -0
  41. package/lib/langium/diagnostics/lsp-logger.d.ts.map +1 -1
  42. package/lib/langium/diagnostics/lsp-logger.js +40 -6
  43. package/lib/langium/diagnostics/lsp-logger.js.map +1 -1
  44. package/lib/langium/document-builder/build-pipeline-integration.d.ts +13 -1
  45. package/lib/langium/document-builder/build-pipeline-integration.d.ts.map +1 -1
  46. package/lib/langium/document-builder/build-pipeline-integration.js +1 -1
  47. package/lib/langium/document-builder/build-pipeline-integration.js.map +1 -1
  48. package/lib/langium/document-builder/build-session.d.ts +93 -0
  49. package/lib/langium/document-builder/build-session.d.ts.map +1 -0
  50. package/lib/langium/document-builder/build-session.js +72 -0
  51. package/lib/langium/document-builder/build-session.js.map +1 -0
  52. package/lib/langium/document-builder/document-builder.d.ts +150 -7
  53. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  54. package/lib/langium/document-builder/document-builder.js +298 -11
  55. package/lib/langium/document-builder/document-builder.js.map +1 -1
  56. package/lib/langium/document-builder/index.d.ts +1 -0
  57. package/lib/langium/document-builder/index.d.ts.map +1 -1
  58. package/lib/langium/document-builder/index.js +1 -0
  59. package/lib/langium/document-builder/index.js.map +1 -1
  60. package/lib/langium/integration-services.d.ts +45 -1
  61. package/lib/langium/integration-services.d.ts.map +1 -1
  62. package/lib/langium/integration-services.js +10 -5
  63. package/lib/langium/integration-services.js.map +1 -1
  64. package/lib/langium/integrity/integrity-service.d.ts +5 -0
  65. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  66. package/lib/langium/integrity/integrity-service.js +34 -1
  67. package/lib/langium/integrity/integrity-service.js.map +1 -1
  68. package/lib/langium/keys/containment.d.ts +64 -0
  69. package/lib/langium/keys/containment.d.ts.map +1 -0
  70. package/lib/langium/keys/containment.js +53 -0
  71. package/lib/langium/keys/containment.js.map +1 -0
  72. package/lib/langium/keys/index.d.ts +1 -0
  73. package/lib/langium/keys/index.d.ts.map +1 -1
  74. package/lib/langium/keys/index.js +1 -0
  75. package/lib/langium/keys/index.js.map +1 -1
  76. package/lib/langium/keys/name-based-key-provider.d.ts +4 -0
  77. package/lib/langium/keys/name-based-key-provider.d.ts.map +1 -1
  78. package/lib/langium/keys/name-based-key-provider.js +4 -0
  79. package/lib/langium/keys/name-based-key-provider.js.map +1 -1
  80. package/lib/langium/model-service/model-service.d.ts +103 -7
  81. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  82. package/lib/langium/model-service/model-service.js +70 -89
  83. package/lib/langium/model-service/model-service.js.map +1 -1
  84. package/lib/langium/module.d.ts +82 -13
  85. package/lib/langium/module.d.ts.map +1 -1
  86. package/lib/langium/module.js +26 -12
  87. package/lib/langium/module.js.map +1 -1
  88. package/lib/langium/naming/name-provider.d.ts +10 -0
  89. package/lib/langium/naming/name-provider.d.ts.map +1 -1
  90. package/lib/langium/naming/name-provider.js.map +1 -1
  91. package/lib/langium/naming/name-separator-validation.d.ts +23 -0
  92. package/lib/langium/naming/name-separator-validation.d.ts.map +1 -1
  93. package/lib/langium/naming/name-separator-validation.js +30 -1
  94. package/lib/langium/naming/name-separator-validation.js.map +1 -1
  95. package/lib/langium/residency/cst-residency-service.d.ts +31 -9
  96. package/lib/langium/residency/cst-residency-service.d.ts.map +1 -1
  97. package/lib/langium/residency/cst-residency-service.js +13 -56
  98. package/lib/langium/residency/cst-residency-service.js.map +1 -1
  99. package/lib/langium/scope/hydranium-scope-provider.d.ts +32 -13
  100. package/lib/langium/scope/hydranium-scope-provider.d.ts.map +1 -1
  101. package/lib/langium/scope/hydranium-scope-provider.js +37 -20
  102. package/lib/langium/scope/hydranium-scope-provider.js.map +1 -1
  103. package/lib/langium/shared-services.d.ts +15 -2
  104. package/lib/langium/shared-services.d.ts.map +1 -1
  105. package/lib/langium/shared-services.js.map +1 -1
  106. package/lib/langium/transfer/transfer-encoder.d.ts +33 -2
  107. package/lib/langium/transfer/transfer-encoder.d.ts.map +1 -1
  108. package/lib/langium/transfer/transfer-encoder.js +14 -3
  109. package/lib/langium/transfer/transfer-encoder.js.map +1 -1
  110. package/lib/langium/validation/document-validator.d.ts +145 -6
  111. package/lib/langium/validation/document-validator.d.ts.map +1 -1
  112. package/lib/langium/validation/document-validator.js +211 -4
  113. package/lib/langium/validation/document-validator.js.map +1 -1
  114. package/lib/langium/workspace/document-uri-policy.d.ts +3 -4
  115. package/lib/langium/workspace/document-uri-policy.d.ts.map +1 -1
  116. package/lib/langium/workspace/document-uri-policy.js +3 -4
  117. package/lib/langium/workspace/document-uri-policy.js.map +1 -1
  118. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts +25 -0
  119. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts.map +1 -1
  120. package/lib/langium/workspace/hydranium-langium-document-factory.js +25 -0
  121. package/lib/langium/workspace/hydranium-langium-document-factory.js.map +1 -1
  122. package/lib/langium/workspace/hydranium-workspace-manager.d.ts +21 -1
  123. package/lib/langium/workspace/hydranium-workspace-manager.d.ts.map +1 -1
  124. package/lib/langium/workspace/hydranium-workspace-manager.js +28 -0
  125. package/lib/langium/workspace/hydranium-workspace-manager.js.map +1 -1
  126. package/lib/langium/workspace/in-memory-file-system-provider.d.ts +8 -0
  127. package/lib/langium/workspace/in-memory-file-system-provider.d.ts.map +1 -1
  128. package/lib/langium/workspace/in-memory-file-system-provider.js +11 -2
  129. package/lib/langium/workspace/in-memory-file-system-provider.js.map +1 -1
  130. package/lib/langium/workspace/initialize-workspace.d.ts +22 -2
  131. package/lib/langium/workspace/initialize-workspace.d.ts.map +1 -1
  132. package/lib/langium/workspace/initialize-workspace.js +13 -5
  133. package/lib/langium/workspace/initialize-workspace.js.map +1 -1
  134. package/lib/langium/workspace/langium-documents.d.ts +79 -17
  135. package/lib/langium/workspace/langium-documents.d.ts.map +1 -1
  136. package/lib/langium/workspace/langium-documents.js +83 -28
  137. package/lib/langium/workspace/langium-documents.js.map +1 -1
  138. package/lib/locale/index.d.ts +10 -0
  139. package/lib/locale/index.d.ts.map +1 -0
  140. package/lib/locale/index.js +10 -0
  141. package/lib/locale/index.js.map +1 -0
  142. package/lib/locale/server-locale.d.ts +73 -0
  143. package/lib/locale/server-locale.d.ts.map +1 -0
  144. package/lib/locale/server-locale.js +61 -0
  145. package/lib/locale/server-locale.js.map +1 -0
  146. package/lib/lsp/hydranium-document-update-handler.js +1 -1
  147. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  148. package/lib/lsp/index.d.ts +1 -1
  149. package/lib/lsp/index.d.ts.map +1 -1
  150. package/lib/lsp/index.js +1 -1
  151. package/lib/lsp/index.js.map +1 -1
  152. package/lib/lsp/lsp-latency.d.ts +39 -0
  153. package/lib/lsp/lsp-latency.d.ts.map +1 -0
  154. package/lib/lsp/lsp-latency.js +58 -0
  155. package/lib/lsp/lsp-latency.js.map +1 -0
  156. package/lib/messages/carriers.d.ts +54 -0
  157. package/lib/messages/carriers.d.ts.map +1 -0
  158. package/lib/messages/carriers.js +62 -0
  159. package/lib/messages/carriers.js.map +1 -0
  160. package/lib/messages/index.d.ts +25 -0
  161. package/lib/messages/index.d.ts.map +1 -0
  162. package/lib/messages/index.js +25 -0
  163. package/lib/messages/index.js.map +1 -0
  164. package/lib/messages/renderer.d.ts +136 -0
  165. package/lib/messages/renderer.d.ts.map +1 -0
  166. package/lib/messages/renderer.js +159 -0
  167. package/lib/messages/renderer.js.map +1 -0
  168. package/lib/node/latency-from-env.d.ts +1 -1
  169. package/lib/node/latency-from-env.js +1 -1
  170. package/lib/node/node-file-system-provider.d.ts.map +1 -1
  171. package/lib/node/node-file-system-provider.js +1 -40
  172. package/lib/node/node-file-system-provider.js.map +1 -1
  173. package/lib/node/profiling-run.d.ts +2 -2
  174. package/lib/node/profiling-run.js +1 -1
  175. package/lib/node/profiling-run.js.map +1 -1
  176. package/lib/node/rename-over-open-readers.d.ts +52 -0
  177. package/lib/node/rename-over-open-readers.d.ts.map +1 -0
  178. package/lib/node/rename-over-open-readers.js +66 -0
  179. package/lib/node/rename-over-open-readers.js.map +1 -0
  180. package/lib/testing/fake-document.d.ts +14 -3
  181. package/lib/testing/fake-document.d.ts.map +1 -1
  182. package/lib/testing/fake-document.js +18 -3
  183. package/lib/testing/fake-document.js.map +1 -1
  184. package/lib/testing/make-noop-shared-services.d.ts +14 -0
  185. package/lib/testing/make-noop-shared-services.d.ts.map +1 -1
  186. package/lib/testing/make-noop-shared-services.js +28 -2
  187. package/lib/testing/make-noop-shared-services.js.map +1 -1
  188. package/lib/testing/make-test-services.d.ts +31 -4
  189. package/lib/testing/make-test-services.d.ts.map +1 -1
  190. package/lib/testing/make-test-services.js +19 -3
  191. package/lib/testing/make-test-services.js.map +1 -1
  192. package/lib/testing/node/spawned-server.d.ts +14 -0
  193. package/lib/testing/node/spawned-server.d.ts.map +1 -1
  194. package/lib/testing/node/spawned-server.js +33 -1
  195. package/lib/testing/node/spawned-server.js.map +1 -1
  196. package/lib/testing/playwright/flaky-network-proxy.d.ts +76 -0
  197. package/lib/testing/playwright/flaky-network-proxy.d.ts.map +1 -0
  198. package/lib/testing/playwright/flaky-network-proxy.js +164 -0
  199. package/lib/testing/playwright/flaky-network-proxy.js.map +1 -0
  200. package/lib/testing/playwright/index.d.ts +1 -0
  201. package/lib/testing/playwright/index.d.ts.map +1 -1
  202. package/lib/testing/playwright/index.js +1 -0
  203. package/lib/testing/playwright/index.js.map +1 -1
  204. package/lib/testing/playwright/server-log-capture.d.ts +14 -4
  205. package/lib/testing/playwright/server-log-capture.d.ts.map +1 -1
  206. package/lib/testing/playwright/server-log-capture.js +21 -4
  207. package/lib/testing/playwright/server-log-capture.js.map +1 -1
  208. package/lib/testing/playwright/server-log-rename-reporter.d.ts.map +1 -1
  209. package/lib/testing/playwright/server-log-rename-reporter.js +10 -1
  210. package/lib/testing/playwright/server-log-rename-reporter.js.map +1 -1
  211. package/lib/testing/stub-ast-document-manager.d.ts +3 -4
  212. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  213. package/lib/testing/stub-ast-document-manager.js +2 -5
  214. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  215. package/lib/testing/stub-document-builder.d.ts +8 -0
  216. package/lib/testing/stub-document-builder.d.ts.map +1 -1
  217. package/lib/testing/stub-document-builder.js +22 -3
  218. package/lib/testing/stub-document-builder.js.map +1 -1
  219. package/lib/testing/stub-model-service.d.ts +1 -1
  220. package/lib/testing/stub-model-service.d.ts.map +1 -1
  221. package/lib/testing/stub-model-service.js +2 -2
  222. package/lib/testing/stub-model-service.js.map +1 -1
  223. package/package.json +17 -8
  224. package/src/documents/ast-document-manager.ts +99 -23
  225. package/src/documents/client-ids.ts +5 -22
  226. package/src/documents/hydranium-text-documents.ts +161 -55
  227. package/src/documents/language-client-text-shadow.ts +59 -11
  228. package/src/documents/self-save-registry.ts +52 -13
  229. package/src/index.ts +5 -0
  230. package/src/langium/ast-extension/ast-node-builder.ts +66 -22
  231. package/src/langium/bootstrap.ts +16 -9
  232. package/src/langium/config/configuration-provider.ts +119 -0
  233. package/src/langium/config/index.ts +1 -0
  234. package/src/langium/diagnostics/lsp-logger.ts +41 -6
  235. package/src/langium/document-builder/build-pipeline-integration.ts +14 -1
  236. package/src/langium/document-builder/build-session.ts +87 -0
  237. package/src/langium/document-builder/document-builder.ts +339 -11
  238. package/src/langium/document-builder/index.ts +1 -0
  239. package/src/langium/integration-services.ts +67 -6
  240. package/src/langium/integrity/integrity-service.ts +35 -1
  241. package/src/langium/keys/containment.ts +87 -0
  242. package/src/langium/keys/index.ts +1 -0
  243. package/src/langium/keys/name-based-key-provider.ts +4 -0
  244. package/src/langium/model-service/model-service.ts +145 -22
  245. package/src/langium/module.ts +104 -19
  246. package/src/langium/naming/name-provider.ts +10 -0
  247. package/src/langium/naming/name-separator-validation.ts +34 -5
  248. package/src/langium/residency/cst-residency-service.ts +36 -12
  249. package/src/langium/scope/hydranium-scope-provider.ts +40 -22
  250. package/src/langium/shared-services.ts +19 -2
  251. package/src/langium/transfer/transfer-encoder.ts +45 -3
  252. package/src/langium/validation/document-validator.ts +255 -8
  253. package/src/langium/workspace/document-uri-policy.ts +3 -4
  254. package/src/langium/workspace/hydranium-langium-document-factory.ts +34 -0
  255. package/src/langium/workspace/hydranium-workspace-manager.ts +30 -1
  256. package/src/langium/workspace/in-memory-file-system-provider.ts +13 -3
  257. package/src/langium/workspace/initialize-workspace.ts +34 -5
  258. package/src/langium/workspace/langium-documents.ts +106 -33
  259. package/src/locale/index.ts +10 -0
  260. package/src/locale/server-locale.ts +86 -0
  261. package/src/lsp/hydranium-document-update-handler.ts +1 -1
  262. package/src/lsp/index.ts +1 -1
  263. package/src/lsp/lsp-latency.ts +62 -0
  264. package/src/messages/carriers.ts +78 -0
  265. package/src/messages/index.ts +27 -0
  266. package/src/messages/renderer.ts +204 -0
  267. package/src/node/latency-from-env.ts +1 -1
  268. package/src/node/node-file-system-provider.ts +1 -41
  269. package/src/node/profiling-run.ts +3 -3
  270. package/src/node/rename-over-open-readers.ts +78 -0
  271. package/src/testing/fake-document.ts +22 -4
  272. package/src/testing/make-noop-shared-services.ts +50 -2
  273. package/src/testing/make-test-services.ts +49 -7
  274. package/src/testing/node/spawned-server.ts +35 -1
  275. package/src/testing/playwright/flaky-network-proxy.ts +240 -0
  276. package/src/testing/playwright/index.ts +1 -0
  277. package/src/testing/playwright/server-log-capture.ts +24 -5
  278. package/src/testing/playwright/server-log-rename-reporter.ts +12 -1
  279. package/src/testing/stub-ast-document-manager.ts +5 -10
  280. package/src/testing/stub-document-builder.ts +32 -3
  281. package/src/testing/stub-model-service.ts +2 -2
  282. package/lib/lsp/instrument-connection.d.ts +0 -36
  283. package/lib/lsp/instrument-connection.d.ts.map +0 -1
  284. package/lib/lsp/instrument-connection.js +0 -62
  285. package/lib/lsp/instrument-connection.js.map +0 -1
  286. package/src/lsp/instrument-connection.ts +0 -66
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydranium/core",
3
- "version": "1.0.0-next.7",
3
+ "version": "1.0.0-next.71",
4
4
  "description": "Foundational runtime of the hydranium framework: AST coordination layer (services, integrity, AST extension, serialization, multi-client documents) plus the LSP textual head at the /lsp subpath. Consumed by protocol-head packages (@hydranium/data-server, @hydranium/glsp-server).",
5
5
  "keywords": [
6
6
  "hydranium",
@@ -72,6 +72,14 @@
72
72
  "./lib/testing/playwright": {
73
73
  "types": "./lib/testing/playwright/index.d.ts",
74
74
  "default": "./lib/testing/playwright/index.js"
75
+ },
76
+ "./messages": {
77
+ "types": "./lib/messages/index.d.ts",
78
+ "default": "./lib/messages/index.js"
79
+ },
80
+ "./lib/messages": {
81
+ "types": "./lib/messages/index.d.ts",
82
+ "default": "./lib/messages/index.js"
75
83
  }
76
84
  },
77
85
  "main": "lib/index.js",
@@ -96,8 +104,8 @@
96
104
  "diff": "^5.2.0"
97
105
  },
98
106
  "devDependencies": {
99
- "@hydranium/langium": "1.0.0-next.7",
100
- "@hydranium/protocol": "1.0.0-next.7",
107
+ "@hydranium/langium": "1.0.0-next.71",
108
+ "@hydranium/protocol": "1.0.0-next.71",
101
109
  "@playwright/test": "^1.40.0",
102
110
  "@types/diff": "^5.2.0",
103
111
  "rimraf": "^5.0.0",
@@ -109,12 +117,12 @@
109
117
  "vscode-languageserver-types": "^3.17.5"
110
118
  },
111
119
  "peerDependencies": {
112
- "@hydranium/langium": "1.0.0-next.7",
113
- "@hydranium/protocol": "1.0.0-next.7",
120
+ "@hydranium/langium": "1.0.0-next.71",
121
+ "@hydranium/protocol": "1.0.0-next.71",
114
122
  "@playwright/test": "^1.40.0",
115
- "vscode-jsonrpc": "^8.0.0 || ^9.0.0",
116
- "vscode-languageserver": "^9.0.0 || ^10.0.0",
117
- "vscode-languageserver-protocol": "^3.17.5",
123
+ "vscode-jsonrpc": "9.0.1",
124
+ "vscode-languageserver": "~10.0.1",
125
+ "vscode-languageserver-protocol": "~3.18.1",
118
126
  "vscode-languageserver-textdocument": "^1.0.12",
119
127
  "vscode-languageserver-types": "^3.17.5"
120
128
  },
@@ -129,6 +137,7 @@
129
137
  "publishConfig": {
130
138
  "access": "public"
131
139
  },
140
+ "//peerDependencies": "`vscode-jsonrpc` is EXACT rather than a range, because it is one link of an atomic chain with no independently movable link: `vscode-languageserver-protocol` depends on it at exactly 9.0.1, so any other value an adopter supplies is a SECOND physical copy rather than an upgrade. This wire stack breaks on copy identity rather than on structure — `ParameterStructures.auto` is a singleton compared by `===` in connection.js, so a request type built by one copy and sent over a connection owned by the other throws `Unknown parameter structure auto`. A caret or union range was rejected because npm then resolves a `vscode-jsonrpc@8.2.0` tree SILENTLY, with no diagnostic at all, and the failure surfaces only later at server init. Exact does not fail the install either — npm downgrades an unsatisfiable peer to a warning — but it is a NAMED ERESOLVE warning printing the required version beside the found one, and a hard error for anyone installing with `--strict-peer-deps`. It does NOT prevent a NESTED copy — `@eclipse-glsp/*` carries its own exact `vscode-jsonrpc@8.2.0` dependency and root `overrides` do not ship — so a consumer of the GLSP head must pin the chain in its own manifest, and docs/adopting/requirements.md carries the block to paste. This manifest declares the two links ABOVE the transport as well, and they carry `~` for the same reason: each admits exactly what `langium@4.3.1` itself admits, and a wider range reaches a `vscode-languageserver@9` / `vscode-languageserver-protocol@3.17.x` that depends on the 8.x transport, which is the same split one level up. `vscode-languageserver-types` and `vscode-languageserver-textdocument` are deliberately NOT narrowed: both declare zero dependencies and expose structural APIs, so a duplicate of either drags no transport in and no identity check reads it.",
132
141
  "//prepack": "The publish guard, and it deliberately is NOT a `prepare`: npm runs a workspace `prepare` BEFORE the root `postinstall` that applies patches/vscode-jsonrpc+9.0.1.patch, so building there fails on a cold clone and npm rolls the entire install back. `prepack` runs only when a tarball is made (`npm pack`, `npm publish`) and never on install, so it cannot break the install it has no business touching. It FAILS rather than rebuilds, because the rebuild is exactly the part that ordering defeats. What it defends against: `files` lists `lib`, `lib` is gitignored, and a `files` entry matching nothing is skipped SILENTLY — so `npm publish` from an unbuilt tree emits a tarball of `src` and nothing else, with no error.",
133
142
  "//sideEffects": "NOT `false`: two barrels do work at module load that their re-exports do not account for, and a bundler skips an unflagged re-export barrel outright. `node/index.js` calls `installNodeLogFileSink()` and `installNodeWriteLockScope()`, which is how a Node host gets the log file-tee and write-lock reentrancy detection merely by importing `@hydranium/core/node`. `util/index.js` carries a bare `import '@hydranium/langium'` so the `UriUtils` namespace augmentation stays loaded for anything importing from here. Both failures are runtime-only and silent — a missing log file, a `UriUtils.toUri` that is `undefined`."
134
143
  }
@@ -166,6 +166,51 @@ export type AstDocumentSavedEvent<TAst extends AstNode, TDiagnostic> = TransferS
166
166
  /** Construction options for {@link AstDocumentManager}. */
167
167
  export type AstDocumentManagerOptions = LogNameOptions;
168
168
 
169
+ /**
170
+ * The document slot the model server and the integrity service talk to:
171
+ * open/close/update/save plus the AST-typed event streams, with the LSP
172
+ * plumbing hidden.
173
+ *
174
+ * Generic over `<TAst extends AstNode, TDiagnostic>` so each consumer projects
175
+ * its own AST root type and diagnostic shape into the emitted
176
+ * {@link AstDocument} envelopes.
177
+ *
178
+ * **URI contract.** Every URI-accepting member takes a URI in *any* spelling
179
+ * (canonical, symlinked, `..`/case-divergent) and canonicalizes internally
180
+ * through the {@link DocumentUriPolicy}, so a caller never has to canonicalize
181
+ * first and an implementation may not require it.
182
+ */
183
+ export interface AstDocumentManager<TAst extends AstNode, TDiagnostic = unknown> {
184
+ open(args: OpenModelArgs): Promise<Disposable>;
185
+ close(args: CloseModelArgs): Promise<void>;
186
+ isOpen(uri: string): boolean;
187
+
188
+ /** Apply `text` as `clientId`'s edit. Resolves to the resulting text-document version. */
189
+ update(uri: string, text: string, clientId: string): Promise<number>;
190
+ save(uri: string, clientId: string): Promise<void>;
191
+
192
+ onUpdate(uri: string, listener: (event: AstDocumentUpdatedEvent<TAst, TDiagnostic>) => void): Disposable;
193
+ onSave(uri: string, listener: (event: AstDocumentSavedEvent<TAst, TDiagnostic>) => void | Promise<void>): Disposable;
194
+ onClientClosed(uri: string, clientId: string, listener: () => void): Disposable;
195
+
196
+ getDocument(uri: string): LangiumDocument | undefined;
197
+
198
+ /** Client id that authored the document's current version, or `undefined` for a framework-internal build. */
199
+ getAuthor(document: LangiumDocument): string | undefined;
200
+
201
+ /**
202
+ * Whether `uri` is one of the URIs the most recent build request named, as
203
+ * opposed to a document that build swept in as a dependent. Deletions are
204
+ * excluded: they never reach this layer, since a deleted document is
205
+ * removed before the build set is computed.
206
+ *
207
+ * The answer refers to the latest request, which may still be building, and
208
+ * it is retained between builds — so outside a build it describes whichever
209
+ * one ran last.
210
+ */
211
+ isTriggeringEdit(uri: string): boolean;
212
+ }
213
+
169
214
  /**
170
215
  * Higher-level facade over {@link HydraniumTextDocuments} that speaks the
171
216
  * model-server protocol (open/close/update/save with the transfer-model event
@@ -186,7 +231,7 @@ export type AstDocumentManagerOptions = LogNameOptions;
186
231
  * resolution (the egress `applyEditToLanguageClient`) stay on
187
232
  * {@link HydraniumTextDocuments}, their owner.
188
233
  */
189
- export class AstDocumentManager<TAst extends AstNode = AstNode, TDiagnostic = unknown> {
234
+ export class DefaultAstDocumentManager<TAst extends AstNode, TDiagnostic = unknown> implements AstDocumentManager<TAst, TDiagnostic> {
190
235
  protected lastUpdate?: UpdateInfo;
191
236
 
192
237
  protected readonly textDocuments: HydraniumTextDocuments<TextDocument>;
@@ -311,11 +356,12 @@ export class AstDocumentManager<TAst extends AstNode = AstNode, TDiagnostic = un
311
356
  const event: TransferUpdatedEvent<AstDocument<TAst, TDiagnostic>> = {
312
357
  document: this.toAstDocument(document),
313
358
  sourceClientId,
314
- reason: this.lastUpdate?.changed.find(changed => UriUtils.equals(changed, document.uri))
315
- ? 'changed'
316
- : this.lastUpdate?.deleted.find(deleted => UriUtils.equals(deleted, document.uri))
317
- ? 'deleted'
318
- : 'rebuilt'
359
+ // No `'deleted'` arm: `DocumentBuilder.update` drops a deleted
360
+ // document from `LangiumDocuments` before deriving the rebuild set
361
+ // from it, so a deleted URI is never built and never reaches this
362
+ // phase listener. A subscriber needing deletions has to be told on
363
+ // a channel that does not require a built document.
364
+ reason: this.lastUpdate?.changed.some(changed => UriUtils.equals(changed, document.uri)) ? 'changed' : 'rebuilt'
319
365
  };
320
366
  this.tracer.with(uri).trace(`emitUpdate start: source=${sourceClientId}, reason=${event.reason}`);
321
367
  listener(event);
@@ -334,7 +380,7 @@ export class AstDocumentManager<TAst extends AstNode = AstNode, TDiagnostic = un
334
380
  * concrete value (the `onUpdate` event's `sourceClientId`, the data-server's
335
381
  * `resolveSourceClientId`).
336
382
  */
337
- getAuthor(document: LangiumDocument<AstNode>): string | undefined {
383
+ getAuthor(document: LangiumDocument): string | undefined {
338
384
  return this.textDocuments.getAuthor(document.textDocument.uri, document.textDocument.version);
339
385
  }
340
386
 
@@ -355,10 +401,17 @@ export class AstDocumentManager<TAst extends AstNode = AstNode, TDiagnostic = un
355
401
  // transiently breaks cross-document linking mid-rebuild (a dependent briefly
356
402
  // seeing an `extends`/`type` reference unresolved). And because the constructor
357
403
  // wires `onDidOpen -> this.open`, it would fire a second, identical rebuild on
358
- // every first open. So an already-open `open()` is a no-op.
404
+ // every first open.
405
+ //
406
+ // So an already-open `open()` records the attaching client's hold and nothing
407
+ // else. The hold is per `(uri, clientId)` and the last-close revert counts down
408
+ // to it, so skipping it lets the first holder's close tear down a document
409
+ // another client is still reading.
359
410
  if (!this.isOpen(args.uri)) {
360
411
  const textDocument = await this.createDocumentFromTextOrFileSystem(args.uri, args.languageId, args.version, args.text);
361
412
  this.textDocuments.notifyDidOpenTextDocument({ textDocument }, args.clientId);
413
+ } else {
414
+ this.textDocuments.attachClient(args.uri, args.clientId);
362
415
  }
363
416
  return Disposable.create(() => this.close(args));
364
417
  }
@@ -393,29 +446,56 @@ export class AstDocumentManager<TAst extends AstNode = AstNode, TDiagnostic = un
393
446
  * callers don't need to pass it — the manager owns content and version
394
447
  * sequencing.
395
448
  *
449
+ * **The write is skipped when the file already holds that text; the
450
+ * notification is not.** A save announces that the content is on disk, which
451
+ * is true either way — so a subscriber clearing a dirty marker or reacting to
452
+ * a persist behaves the same, while an mtime is not moved for byte-identical
453
+ * content that a downstream mtime-keyed build would then read as new work.
454
+ *
396
455
  * Throws if no document is open for `uri`.
397
456
  */
398
457
  async save(uri: string, clientId: string): Promise<void> {
399
- const document = this.textDocuments.get(uri);
458
+ // Canonical throughout: the write and the read that gates it must address
459
+ // the same file the store was keyed by, or a divergent spelling compares
460
+ // one file and writes another.
461
+ const canonical = this.uriPolicy.canonicalUri(uri);
462
+ const document = this.textDocuments.get(canonical);
400
463
  if (!document) {
401
464
  throw new Error(`Document ${uri} hasn't been opened for saving yet`);
402
465
  }
403
466
  const text = document.getText();
404
- await this.tracer.with(uri).time(
405
- `Write file (${text.length} bytes, from ${clientId})`,
406
- // Async write so a slow disk does not block the event loop for the duration of the fs call.
407
- () => this.fileSystemProvider.writeFile(UriUtils.toUri(uri), text),
408
- 'debug'
409
- );
410
- this.textDocuments.notifyDidSaveTextDocument({ textDocument: TextDocumentIdentifier.create(uri), text }, clientId);
467
+ if (!(await this.matchesDisk(canonical, text))) {
468
+ await this.tracer.with(canonical).time(
469
+ `Write file (${text.length} bytes, from ${clientId})`,
470
+ // Async write so a slow disk does not block the event loop for the duration of the fs call.
471
+ () => this.fileSystemProvider.writeFile(UriUtils.toUri(canonical), text),
472
+ 'debug'
473
+ );
474
+ }
475
+ this.textDocuments.notifyDidSaveTextDocument({ textDocument: TextDocumentIdentifier.create(canonical), text }, clientId);
476
+ }
477
+
478
+ /**
479
+ * Whether the file at `uri` already holds `text`.
480
+ *
481
+ * Compared by CONTENT rather than against a remembered version, so a write
482
+ * from outside this server is still corrected: a version this manager already
483
+ * saved says nothing about what is on disk now. `false` when the file cannot
484
+ * be read at all, which is the answer that writes — a document whose file does
485
+ * not exist yet is exactly the one a first save has to create.
486
+ */
487
+ protected async matchesDisk(uri: string, text: string): Promise<boolean> {
488
+ return this.fileSystemProvider
489
+ .readFile(UriUtils.toUri(uri))
490
+ .then(onDisk => onDisk === text)
491
+ .catch(() => false);
411
492
  }
412
493
 
413
494
  isOpen(uri: string): boolean {
414
495
  return !!this.textDocuments.get(uri);
415
496
  }
416
497
 
417
- /** True if the URI was in the directly-changed set of the most recent build (not just affected). */
418
- isDirectChange(uri: string): boolean {
498
+ isTriggeringEdit(uri: string): boolean {
419
499
  const canonical = this.uriPolicy.canonicalUri(uri);
420
500
  return this.lastUpdate?.changed.some(changed => this.uriPolicy.canonicalUri(changed) === canonical) ?? false;
421
501
  }
@@ -437,11 +517,7 @@ export class AstDocumentManager<TAst extends AstNode = AstNode, TDiagnostic = un
437
517
  version = 0,
438
518
  text?: string
439
519
  ): Promise<TextDocumentItem> {
440
- return { uri, languageId, version, text: text ?? (await this.readFile(uri)) };
441
- }
442
-
443
- async readFile(uri: string): Promise<string> {
444
- return this.fileSystemProvider.readFile(UriUtils.toUri(uri));
520
+ return { uri, languageId, version, text: text ?? (await this.fileSystemProvider.readFile(UriUtils.toUri(uri))) };
445
521
  }
446
522
 
447
523
  /**
@@ -8,26 +8,9 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  /**
11
- * Well-known client-kind identifiers used across the multi-client framework.
12
- *
13
- * The framework tracks each document as belonging to one or more "clients"
14
- * — typically an LSP-text-client (Monaco, VS Code, etc.), a GLSP diagram
15
- * client, and/or a structured-form client. Each kind carries a stable id so
16
- * the server can route outbound corrections, edits, and notifications to the
17
- * right transport.
18
- */
19
-
20
- /** Identifier for an LSP-text client (Monaco, VS Code, …). */
21
- export const LANGUAGE_CLIENT_ID = 'language-client';
22
-
23
- /** Fallback identifier when a document's author is unknown (e.g. cold workspace-init load). */
24
- export const UNKNOWN_CLIENT_ID = 'unknown';
25
-
26
- /**
27
- * Synthetic author id on the `onDocumentUpdated` broadcast a data-server
28
- * emits after the LAST client closed a document and the framework rebuilt it
29
- * from its disk content (discarding unsaved in-session edits). Not a real
30
- * client: it lets consumers distinguish the revert broadcast from
31
- * client-authored updates.
11
+ * Re-export, because these are WIRE values and the side that has to recognise
12
+ * them is the client. They are defined in `@hydranium/protocol` so a frontend
13
+ * can name them without depending on the server tier; this path stays so
14
+ * server-side code keeps importing them from where it already does.
32
15
  */
33
- export const REVERT_ON_CLOSE_CLIENT_ID = 'revert-on-close';
16
+ export { FRAMEWORK_CLIENT_IDS, LANGUAGE_CLIENT_ID, REVERT_ON_CLOSE_CLIENT_ID, UNKNOWN_CLIENT_ID } from '@hydranium/protocol';
@@ -37,6 +37,7 @@ import {
37
37
  OptionalVersionedTextDocumentIdentifier,
38
38
  type RequestHandler,
39
39
  type TextDocumentChangeEvent,
40
+ TextDocumentContentChangeEvent as ContentChange,
40
41
  TextDocumentEdit,
41
42
  TextDocumentSyncKind,
42
43
  type TextDocumentWillSaveEvent,
@@ -165,15 +166,15 @@ interface ConnectionWithTextDocumentSync {
165
166
  */
166
167
  interface PendingPush {
167
168
  /**
168
- * The text the client held BEFORE this push — the shadow baseline the
169
- * push's edits were diffed against, and therefore the text the client's
170
- * echo addresses with its ranges.
169
+ * The text the client held BEFORE this push, and therefore the text its
170
+ * echo addresses with its ranges. Wider than the baseline the push's edits
171
+ * were diffed against: a full replace is sent with no diff baseline and
172
+ * still lands on a buffer the echo is keyed to.
171
173
  *
172
- * `undefined` when the push was a full replace sent with no baseline (a
173
- * first sync, or the recovery push after a rejection). There is then no
174
- * knowing what the client held, so the echo is reconstructed against the
175
- * synced text instead — which is sound for a full replace, whose
176
- * application is position-independent.
174
+ * `undefined` only when that buffer is unknown — the client never declared
175
+ * one, or a rejection invalidated what was tracked. The echo is then
176
+ * reconstructed against the synced text, which is sound only for a
177
+ * position-independent (full-text) change.
177
178
  */
178
179
  readonly before: string | undefined;
179
180
  /** {@link contentHash} of the text this push moves the client to. */
@@ -182,18 +183,25 @@ interface PendingPush {
182
183
 
183
184
  /**
184
185
  * What an incoming language-client change turns out to be once reconstructed
185
- * against the pushes still in flight — the return of
186
+ * against the buffer its ranges address — the return of
186
187
  * {@link HydraniumTextDocuments.classifyLanguageClientChange}.
187
188
  */
188
189
  type LanguageClientChangeOrigin =
189
190
  /** The client is reporting a text we pushed it. The synced document is already there. */
190
191
  | { readonly kind: 'echo' }
191
192
  /**
192
- * The client's buffer has moved somewhere we did not push it — it coalesced
193
- * a genuine keystroke into the echo, or typed before the push arrived. The
194
- * reconstructed text is what it now holds, and is authoritative.
193
+ * The client's buffer holds a text we did not push it — a keystroke that
194
+ * raced a push, or an edit to a buffer the store has already been written
195
+ * past. The reconstructed text is what it now holds, and is authoritative.
195
196
  */
196
- | { readonly kind: 'divergent'; readonly text: string };
197
+ | { readonly kind: 'divergent'; readonly text: string }
198
+ /**
199
+ * The change carries ranges and no known text addresses them, so no
200
+ * reconstruction is offered. Adopting one anyway splices the document and
201
+ * stores an edit nobody made; dropping costs at most the one keystroke the
202
+ * client still holds and the next push contradicts.
203
+ */
204
+ | { readonly kind: 'unreconstructable' };
197
205
 
198
206
  /**
199
207
  * Cheap, stable, non-cryptographic content hash (cyrb53) for the version
@@ -471,15 +479,16 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
471
479
  }
472
480
  record.clientVersions.set(clientId, td.version);
473
481
 
474
- // A language-client change arriving while one of our own pushes is in
475
- // flight is keyed to the buffer the client held BEFORE that push, NOT
476
- // to the synced text — the authored write already advanced the latter.
482
+ // A language-client change is keyed to the buffer that client holds,
483
+ // which is the synced text only while the two agree — an authored
484
+ // write advances the synced text without the client knowing, and a
485
+ // push in flight moves the client without the store knowing.
477
486
  // Resolve which it is, and against what, before touching anything.
478
- const inFlight =
487
+ const origin =
479
488
  clientId === LANGUAGE_CLIENT_ID
480
489
  ? this.classifyLanguageClientChange(this.toLanguageClientUri(td.uri), document, changes)
481
490
  : undefined;
482
- if (inFlight?.kind === 'echo') {
491
+ if (origin?.kind === 'echo') {
483
492
  // The client is reporting a text we pushed it, so the synced
484
493
  // document is ALREADY there and the changes must not be applied a
485
494
  // second time. Nothing is minted and no rebuild fires. The
@@ -491,6 +500,10 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
491
500
  this.logUri(uri, `Skip rebuild: echo of a server-authored push (client version ${td.version})`, 'debug');
492
501
  return;
493
502
  }
503
+ if (origin?.kind === 'unreconstructable') {
504
+ this.tracer.with(uri).warn(`Drop change: no known client buffer for its ranges (client version ${td.version})`);
505
+ return;
506
+ }
494
507
 
495
508
  // The SHARED version advances iff the content actually changes — the
496
509
  // invariant optimistic `baseVersion` gates rely on. The new text is
@@ -498,12 +511,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
498
511
  // apply at a tentative +1 and roll the version back on an identical
499
512
  // result (an empty-changes update only re-stamps the version).
500
513
  //
501
- // A divergent in-flight change is applied as its RECONSTRUCTED text
502
- // rather than as its own ranges: the ranges address the pre-push
503
- // baseline, so applying them here would splice the wrong lines.
514
+ // A divergent change is applied as its RECONSTRUCTED text rather than
515
+ // as its own ranges: those ranges address the client's own buffer, so
516
+ // applying them here would splice the wrong lines.
504
517
  const previousText = document.getText();
505
518
  const sharedVersion = document.version;
506
- document = this.configuration.update(document, inFlight === undefined ? changes : [{ text: inFlight.text }], sharedVersion + 1);
519
+ document = this.configuration.update(document, origin === undefined ? changes : [{ text: origin.text }], sharedVersion + 1);
507
520
  const changed = document.getText() !== previousText;
508
521
  if (!changed) {
509
522
  document = this.configuration.update(document, [], sharedVersion);
@@ -644,18 +657,20 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
644
657
  if (this.isOpenInClient(uri, clientId)) {
645
658
  // Already open for this client under this canonical identity. If this is a
646
659
  // NEW client-facing URI for the same file (a second tab reached via a
647
- // divergent path, e.g. a symlink and its real path), record it and baseline
648
- // its shadow so outbound edits reach this tab too — but do NOT re-fire
649
- // open/rebuild; the document is already live.
660
+ // divergent path, e.g. a symlink and its real path), record it so outbound
661
+ // edits reach this tab too — but do NOT re-fire open/rebuild; the document
662
+ // is already live.
650
663
  if (clientId === LANGUAGE_CLIENT_ID) {
651
664
  const clientFacing = this.toLanguageClientUri(td.uri);
652
665
  const record = this.__documents.get(uri);
653
666
  if (record && !record.languageClientUris?.has(clientFacing)) {
654
667
  (record.languageClientUris ??= new Set<LanguageClientUri>()).add(clientFacing);
655
- const open = this.__syncedDocuments.get(uri);
656
- if (open) {
657
- this.__shadow.set(clientFacing, open.getText());
658
- }
668
+ // This tab's own buffer, NOT the synced text: a second tab is a second
669
+ // client model, read from disk, so a server-authored write already
670
+ // applied to the first tab leaves it BEHIND the synced document. Keying
671
+ // a diff to the synced text here addresses lines this tab does not have
672
+ // and drops content it does.
673
+ this.__shadow.setOpenedText(clientFacing, td.text);
659
674
  }
660
675
  }
661
676
  return;
@@ -694,22 +709,77 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
694
709
  // Baseline the shadow to what Monaco just opened so the next outbound
695
710
  // applyEditToLanguageClient diffs against the right starting point. Keyed by the
696
711
  // language-client URI (what Monaco holds), not the canonical document key.
697
- this.__shadow.set(this.toLanguageClientUri(td.uri), document.getText());
712
+ //
713
+ // The CLIENT's declared text, never the synced document: staged content
714
+ // consumed above leaves the two different, and a baseline asserting the
715
+ // client already holds the staged text suppresses the one sync that would
716
+ // deliver it. Skipped entirely when a push is already outstanding for this
717
+ // URI — an `applyEdit` to a closed file has the client open from disk and
718
+ // apply afterwards, so that shadow records what it is about to hold and
719
+ // disk text would key the next diff to a buffer nobody has.
720
+ const clientFacing = this.toLanguageClientUri(td.uri);
721
+ if (!this.__shadow.isTracked(clientFacing)) {
722
+ this.__shadow.set(clientFacing, td.text);
723
+ }
698
724
  }
699
725
  const toFire = Object.freeze({ document, clientId });
700
726
  this.__onDidOpen.fire(toFire);
701
727
  this.__onDidChangeContent.fire(toFire);
702
728
  } else {
703
729
  // An additional client attaches to a document already open by another client.
704
- this.log(
705
- uri,
706
- `Attach client: ${clientId} joined existing document (version ${document.version}, ` +
707
- `now open in: ${[...existingClients, clientId].join(', ')})`
708
- );
730
+ this.logClientJoined(uri, clientId, document.version, existingClients);
731
+ if (clientId === LANGUAGE_CLIENT_ID) {
732
+ // Monaco's own buffer, which on this path is NOT the synced text — another
733
+ // client opened the document and may already have changed it. Recorded as
734
+ // an equality-only baseline (never a diff one, see `setOpenedText`) so the
735
+ // refresh below does not push a full replace of content Monaco already
736
+ // holds, which would dirty the file on open.
737
+ this.__shadow.setOpenedText(this.toLanguageClientUri(td.uri), td.text);
738
+ }
709
739
  this.refreshContent(uri, clientId);
710
740
  }
711
741
  }
712
742
 
743
+ /**
744
+ * Record `clientId` as an additional holder of a document another client
745
+ * already has open.
746
+ *
747
+ * Unlike the attach branch of {@link notifyDidOpenTextDocument} this does
748
+ * not refresh. That branch re-renders an attaching TEXTUAL view; a client
749
+ * reaching a document through a non-textual route has nothing to re-render,
750
+ * and refreshing per attach turns each into a Langium rebuild and dependent
751
+ * relink cascade.
752
+ *
753
+ * The staleness guard baselines at the SYNCED version, not a declared buffer
754
+ * version, because a client arriving this way holds no buffer of its own.
755
+ * Unifying the two routes therefore rebaselines a textual client's guard to a
756
+ * version it never declared.
757
+ *
758
+ * Returns whether a hold was added — `false` when `uri` is not open, or
759
+ * `clientId` already holds it.
760
+ */
761
+ attachClient(uri: DocumentUri, clientId: string): boolean {
762
+ const key = this.documentKey(uri);
763
+ const document = this.__syncedDocuments.get(key);
764
+ if (!document || this.isOpenInClient(key, clientId)) {
765
+ return false;
766
+ }
767
+ const record = this.trackingFor(key);
768
+ const existingClients = [...record.clients];
769
+ record.clients.add(clientId);
770
+ record.clientVersions.set(clientId, document.version);
771
+ this.logClientJoined(key, clientId, document.version, existingClients);
772
+ return true;
773
+ }
774
+
775
+ protected logClientJoined(uri: DocumentUri, clientId: string, version: number, existingClients: readonly string[]): void {
776
+ this.log(
777
+ uri,
778
+ `Attach client: ${clientId} joined existing document (version ${version}, ` +
779
+ `now open in: ${[...existingClients, clientId].join(', ')})`
780
+ );
781
+ }
782
+
713
783
  refreshContent(uri: DocumentUri, clientId: string): void {
714
784
  const syncedDocument = this.__syncedDocuments.get(this.documentKey(uri));
715
785
  if (syncedDocument) {
@@ -967,10 +1037,11 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
967
1037
  const targets: Iterable<LanguageClientUri> = recorded && recorded.size > 0 ? recorded : [this.toLanguageClientUri(uri)];
968
1038
  let lastResult: ApplyWorkspaceEditResult | undefined;
969
1039
  for (const targetUri of targets) {
970
- // Read BEFORE computeEdits, which overwrites it. This is the text the
971
- // edits below are keyed to, and therefore the only text the client's
972
- // echo of them can be reconstructed against.
973
- const before = this.__shadow.get(targetUri);
1040
+ // Read BEFORE computeEdits, which overwrites the baseline and drops
1041
+ // the opened snapshot. This is the text the client holds, and
1042
+ // therefore the only text its echo of this push can be reconstructed
1043
+ // against — whether or not the push below is keyed to it.
1044
+ const before = this.__shadow.clientText(targetUri);
974
1045
  const edits = this.__shadow.computeEdits(targetUri, newText);
975
1046
  if (edits.length === 0) {
976
1047
  continue;
@@ -985,9 +1056,20 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
985
1056
  // it is exactly what the caller retries with after a rejection, so
986
1057
  // gating it there would refuse the recovery too.
987
1058
  const version = isFullReplace(edits) ? UNKNOWN_CLIENT_VERSION : this.languageClientVersion(uri);
1059
+ // A full `ApplyWorkspaceEditParams`, `edit` and all — NOT a bare
1060
+ // `WorkspaceEdit` with a `label` beside it. `applyEdit` takes
1061
+ // `ApplyWorkspaceEditParams | WorkspaceEdit` and discriminates on
1062
+ // `!!value.edit`, so `{ label, documentChanges }` is wrapped as
1063
+ // `{ edit: { label, documentChanges } }` — putting the label inside
1064
+ // the edit, where LSP defines no such field and no client reads it.
1065
+ // The union is also what hides it at compile time: excess-property
1066
+ // checking admits a property present in EITHER member, so an object
1067
+ // matching neither type-checks against the union.
988
1068
  const result = await connection.workspace.applyEdit({
989
1069
  label: options?.label,
990
- documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1070
+ edit: {
1071
+ documentChanges: [TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(targetUri, version), edits)]
1072
+ }
991
1073
  });
992
1074
  if (result && result.applied === false) {
993
1075
  this.__shadow.invalidate(targetUri);
@@ -1043,12 +1125,17 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1043
1125
 
1044
1126
  /**
1045
1127
  * Decide what an incoming language-client change actually is, by
1046
- * reconstructing the client's resulting buffer against the OLDEST push still
1047
- * in flight for `clientFacing`.
1128
+ * reconstructing the client's resulting buffer against the text its ranges
1129
+ * address — the pre-push buffer of the OLDEST push still in flight for
1130
+ * `clientFacing`, else whatever that client is believed to hold.
1048
1131
  *
1049
- * `undefined` when nothing of ours is in flight, which is the ordinary path:
1050
- * the client's ranges then address the synced text and the caller applies
1051
- * them to it directly.
1132
+ * `undefined` when the client's ranges address the synced text itself —
1133
+ * nothing in flight, and no evidence the client holds anything else. That
1134
+ * is the ordinary path, and the caller then applies the ranges directly.
1135
+ * The two texts part company without a push in flight whenever a client
1136
+ * attaches to a document another client has already written: it opened
1137
+ * from disk, so applying its ranges to the synced text splices lines they
1138
+ * never addressed.
1052
1139
  *
1053
1140
  * **Why the oldest, and why reconstruct at all.** The client applies our
1054
1141
  * pushes in order and echoes each against the buffer it held before that
@@ -1079,22 +1166,41 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1079
1166
  document: T,
1080
1167
  changes: TextDocumentContentChangeEvent[]
1081
1168
  ): LanguageClientChangeOrigin | undefined {
1082
- const pending = this.__pendingPushes.get(clientFacing);
1083
- if (pending === undefined || pending.length === 0) {
1169
+ const queued = this.__pendingPushes.get(clientFacing);
1170
+ const pending = queued?.length ? queued : undefined;
1171
+ // The text the client's ranges address: the buffer it held before the
1172
+ // oldest push still in flight, else the buffer it is believed to hold.
1173
+ const clientText = pending?.[0].before ?? this.__shadow.clientText(clientFacing);
1174
+ if (pending === undefined && (clientText === undefined || clientText === document.getText())) {
1084
1175
  return undefined;
1085
1176
  }
1177
+ if (pending !== undefined && pending[0].before === undefined && changes.some(change => ContentChange.isIncremental(change))) {
1178
+ // A push sent to a client whose buffer was unknown — a first sync, or
1179
+ // the recovery push after a rejection. The shadow now holds the text
1180
+ // that push MOVES the client to, which is the one text the echo's
1181
+ // ranges provably do not address, so the fallback above is a baseline
1182
+ // known to be wrong rather than merely unverified. Drop the queue and
1183
+ // the shadow: the next sync is then a full replace, which lands on
1184
+ // whatever the client holds. A full-text change is exempt because it
1185
+ // reconstructs identically against any baseline.
1186
+ pending.length = 0;
1187
+ this.__shadow.invalidate(clientFacing);
1188
+ return { kind: 'unreconstructable' };
1189
+ }
1086
1190
  // Through the configured factories, not `TextDocument` directly, so an
1087
1191
  // adopter's custom text-document type governs how the ranges are applied
1088
1192
  // here exactly as it does on the synced document.
1089
- const probe = this.create(clientFacing, document.languageId, 0, pending[0].before ?? document.getText());
1090
- const clientText = this.update(probe, changes, 0).getText();
1091
- const matchIndex = pending.findIndex(push => push.afterHash === contentHash(clientText));
1092
- if (matchIndex >= 0) {
1093
- pending.splice(0, matchIndex + 1);
1094
- return { kind: 'echo' };
1193
+ const probe = this.create(clientFacing, document.languageId, 0, clientText ?? document.getText());
1194
+ const reconstructed = this.update(probe, changes, 0).getText();
1195
+ if (pending !== undefined) {
1196
+ const matchIndex = pending.findIndex(push => push.afterHash === contentHash(reconstructed));
1197
+ if (matchIndex >= 0) {
1198
+ pending.splice(0, matchIndex + 1);
1199
+ return { kind: 'echo' };
1200
+ }
1201
+ pending.length = 0;
1095
1202
  }
1096
- pending.length = 0;
1097
- return { kind: 'divergent', text: clientText };
1203
+ return { kind: 'divergent', text: reconstructed };
1098
1204
  }
1099
1205
 
1100
1206
  /**