@animalabs/agent-framework 0.23.1 → 0.24.0

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 (338) hide show
  1. package/README.md +212 -42
  2. package/dist/src/agent.d.ts +60 -9
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +92 -12
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +14 -12
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/code-execution/engine.d.ts +177 -0
  10. package/dist/src/code-execution/engine.d.ts.map +1 -0
  11. package/dist/src/code-execution/engine.js +494 -0
  12. package/dist/src/code-execution/engine.js.map +1 -0
  13. package/dist/src/code-execution/index.d.ts +5 -10
  14. package/dist/src/code-execution/index.d.ts.map +1 -1
  15. package/dist/src/code-execution/index.js +5 -9
  16. package/dist/src/code-execution/index.js.map +1 -1
  17. package/dist/src/code-execution/isolate/calls.d.ts +114 -0
  18. package/dist/src/code-execution/isolate/calls.d.ts.map +1 -0
  19. package/dist/src/code-execution/isolate/calls.js +145 -0
  20. package/dist/src/code-execution/isolate/calls.js.map +1 -0
  21. package/dist/src/code-execution/isolate/child-entry.d.ts +2 -0
  22. package/dist/src/code-execution/isolate/child-entry.d.ts.map +1 -0
  23. package/dist/src/code-execution/isolate/child-entry.js +5 -0
  24. package/dist/src/code-execution/isolate/child-entry.js.map +1 -0
  25. package/dist/src/code-execution/isolate/child-main.d.ts +26 -0
  26. package/dist/src/code-execution/isolate/child-main.d.ts.map +1 -0
  27. package/dist/src/code-execution/isolate/child-main.js +132 -0
  28. package/dist/src/code-execution/isolate/child-main.js.map +1 -0
  29. package/dist/src/code-execution/isolate/child.d.ts +44 -0
  30. package/dist/src/code-execution/isolate/child.d.ts.map +1 -0
  31. package/dist/src/code-execution/isolate/child.js +315 -0
  32. package/dist/src/code-execution/isolate/child.js.map +1 -0
  33. package/dist/src/code-execution/isolate/frames.d.ts +80 -0
  34. package/dist/src/code-execution/isolate/frames.d.ts.map +1 -0
  35. package/dist/src/code-execution/isolate/frames.js +210 -0
  36. package/dist/src/code-execution/isolate/frames.js.map +1 -0
  37. package/dist/src/code-execution/isolate/output.d.ts +68 -0
  38. package/dist/src/code-execution/isolate/output.d.ts.map +1 -0
  39. package/dist/src/code-execution/isolate/output.js +143 -0
  40. package/dist/src/code-execution/isolate/output.js.map +1 -0
  41. package/dist/src/code-execution/isolate/prelude.d.ts +109 -0
  42. package/dist/src/code-execution/isolate/prelude.d.ts.map +1 -0
  43. package/dist/src/code-execution/isolate/prelude.js +401 -0
  44. package/dist/src/code-execution/isolate/prelude.js.map +1 -0
  45. package/dist/src/code-execution/isolate/protocol.d.ts +151 -0
  46. package/dist/src/code-execution/isolate/protocol.d.ts.map +1 -0
  47. package/dist/src/code-execution/isolate/protocol.js +150 -0
  48. package/dist/src/code-execution/isolate/protocol.js.map +1 -0
  49. package/dist/src/code-execution/isolate/render.d.ts +57 -0
  50. package/dist/src/code-execution/isolate/render.d.ts.map +1 -0
  51. package/dist/src/code-execution/isolate/render.js +214 -0
  52. package/dist/src/code-execution/isolate/render.js.map +1 -0
  53. package/dist/src/code-execution/isolate/runner.d.ts +250 -0
  54. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -0
  55. package/dist/src/code-execution/isolate/runner.js +854 -0
  56. package/dist/src/code-execution/isolate/runner.js.map +1 -0
  57. package/dist/src/code-execution/isolate/scripts.d.ts +175 -0
  58. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -0
  59. package/dist/src/code-execution/isolate/scripts.js +357 -0
  60. package/dist/src/code-execution/isolate/scripts.js.map +1 -0
  61. package/dist/src/code-execution/quickjs.wasm +0 -0
  62. package/dist/src/code-execution/tool-definition.d.ts +93 -43
  63. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  64. package/dist/src/code-execution/tool-definition.js +176 -142
  65. package/dist/src/code-execution/tool-definition.js.map +1 -1
  66. package/dist/src/deadline-timer.d.ts +3 -0
  67. package/dist/src/deadline-timer.d.ts.map +1 -1
  68. package/dist/src/deadline-timer.js +1 -1
  69. package/dist/src/deadline-timer.js.map +1 -1
  70. package/dist/src/framework.d.ts +1036 -440
  71. package/dist/src/framework.d.ts.map +1 -1
  72. package/dist/src/framework.js +4131 -2228
  73. package/dist/src/framework.js.map +1 -1
  74. package/dist/src/gate/event-gate.d.ts +28 -1
  75. package/dist/src/gate/event-gate.d.ts.map +1 -1
  76. package/dist/src/gate/event-gate.js +88 -10
  77. package/dist/src/gate/event-gate.js.map +1 -1
  78. package/dist/src/held-writes.d.ts +16 -3
  79. package/dist/src/held-writes.d.ts.map +1 -1
  80. package/dist/src/held-writes.js +16 -3
  81. package/dist/src/held-writes.js.map +1 -1
  82. package/dist/src/index.d.ts +6 -6
  83. package/dist/src/index.d.ts.map +1 -1
  84. package/dist/src/index.js +9 -8
  85. package/dist/src/index.js.map +1 -1
  86. package/dist/src/mcpl/channel-registry.d.ts +471 -75
  87. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  88. package/dist/src/mcpl/channel-registry.js +1673 -337
  89. package/dist/src/mcpl/channel-registry.js.map +1 -1
  90. package/dist/src/mcpl/conversation-router.d.ts +12 -7
  91. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  92. package/dist/src/mcpl/conversation-router.js +12 -7
  93. package/dist/src/mcpl/conversation-router.js.map +1 -1
  94. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  95. package/dist/src/mcpl/convert-block.js +9 -5
  96. package/dist/src/mcpl/convert-block.js.map +1 -1
  97. package/dist/src/mcpl/inbound-source.d.ts +50 -0
  98. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  99. package/dist/src/mcpl/inbound-source.js +130 -0
  100. package/dist/src/mcpl/inbound-source.js.map +1 -1
  101. package/dist/src/mcpl/index.d.ts +2 -1
  102. package/dist/src/mcpl/index.d.ts.map +1 -1
  103. package/dist/src/mcpl/index.js +3 -2
  104. package/dist/src/mcpl/index.js.map +1 -1
  105. package/dist/src/mcpl/log-value.d.ts +6 -0
  106. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  107. package/dist/src/mcpl/log-value.js +8 -0
  108. package/dist/src/mcpl/log-value.js.map +1 -1
  109. package/dist/src/mcpl/modern-connection.d.ts +15 -6
  110. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  111. package/dist/src/mcpl/modern-connection.js +168 -53
  112. package/dist/src/mcpl/modern-connection.js.map +1 -1
  113. package/dist/src/mcpl/prose-grammar.d.ts +4 -5
  114. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  115. package/dist/src/mcpl/prose-grammar.js +4 -5
  116. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  117. package/dist/src/mcpl/protocol-family.d.ts +97 -10
  118. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  119. package/dist/src/mcpl/protocol-family.js +173 -16
  120. package/dist/src/mcpl/protocol-family.js.map +1 -1
  121. package/dist/src/mcpl/push-coalescer.d.ts +132 -14
  122. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  123. package/dist/src/mcpl/push-coalescer.js +227 -50
  124. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  125. package/dist/src/mcpl/push-handler.d.ts +6 -1
  126. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  127. package/dist/src/mcpl/push-handler.js +4 -1
  128. package/dist/src/mcpl/push-handler.js.map +1 -1
  129. package/dist/src/mcpl/reference-fetcher.d.ts +2 -2
  130. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  131. package/dist/src/mcpl/reference-fetcher.js +4 -4
  132. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  133. package/dist/src/mcpl/server-connection.d.ts +50 -21
  134. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  135. package/dist/src/mcpl/server-connection.js +158 -74
  136. package/dist/src/mcpl/server-connection.js.map +1 -1
  137. package/dist/src/mcpl/tags.d.ts +24 -0
  138. package/dist/src/mcpl/tags.d.ts.map +1 -1
  139. package/dist/src/mcpl/tags.js +33 -0
  140. package/dist/src/mcpl/tags.js.map +1 -1
  141. package/dist/src/mcpl/tool-result-normalize.d.ts +2 -1
  142. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  143. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  144. package/dist/src/mcpl/transport.d.ts +55 -7
  145. package/dist/src/mcpl/transport.d.ts.map +1 -1
  146. package/dist/src/mcpl/transport.js +163 -34
  147. package/dist/src/mcpl/transport.js.map +1 -1
  148. package/dist/src/mcpl/types.d.ts +22 -6
  149. package/dist/src/mcpl/types.d.ts.map +1 -1
  150. package/dist/src/mcpl/types.js.map +1 -1
  151. package/dist/src/module-registry.d.ts +4 -2
  152. package/dist/src/module-registry.d.ts.map +1 -1
  153. package/dist/src/module-registry.js +100 -13
  154. package/dist/src/module-registry.js.map +1 -1
  155. package/dist/src/modules/discord/index.d.ts.map +1 -1
  156. package/dist/src/modules/discord/index.js +8 -13
  157. package/dist/src/modules/discord/index.js.map +1 -1
  158. package/dist/src/modules/history/index.d.ts.map +1 -1
  159. package/dist/src/modules/history/index.js +27 -4
  160. package/dist/src/modules/history/index.js.map +1 -1
  161. package/dist/src/modules/history/semantic.d.ts +15 -3
  162. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  163. package/dist/src/modules/history/semantic.js +27 -5
  164. package/dist/src/modules/history/semantic.js.map +1 -1
  165. package/dist/src/modules/index.d.ts +1 -1
  166. package/dist/src/modules/index.d.ts.map +1 -1
  167. package/dist/src/modules/index.js.map +1 -1
  168. package/dist/src/modules/unknown-tool.d.ts +4 -1
  169. package/dist/src/modules/unknown-tool.d.ts.map +1 -1
  170. package/dist/src/modules/unknown-tool.js +4 -1
  171. package/dist/src/modules/unknown-tool.js.map +1 -1
  172. package/dist/src/modules/workspace/branch-intent.d.ts +3 -1
  173. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  174. package/dist/src/modules/workspace/branch-intent.js +3 -1
  175. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  176. package/dist/src/modules/workspace/edit-plan.d.ts +265 -0
  177. package/dist/src/modules/workspace/edit-plan.d.ts.map +1 -0
  178. package/dist/src/modules/workspace/edit-plan.js +794 -0
  179. package/dist/src/modules/workspace/edit-plan.js.map +1 -0
  180. package/dist/src/modules/workspace/edit-text.d.ts +117 -0
  181. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -0
  182. package/dist/src/modules/workspace/edit-text.js +584 -0
  183. package/dist/src/modules/workspace/edit-text.js.map +1 -0
  184. package/dist/src/modules/workspace/effects.d.ts +5 -4
  185. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  186. package/dist/src/modules/workspace/effects.js +78 -25
  187. package/dist/src/modules/workspace/effects.js.map +1 -1
  188. package/dist/src/modules/workspace/index.d.ts +484 -61
  189. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  190. package/dist/src/modules/workspace/index.js +2173 -645
  191. package/dist/src/modules/workspace/index.js.map +1 -1
  192. package/dist/src/modules/workspace/observe.d.ts +213 -38
  193. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  194. package/dist/src/modules/workspace/observe.js +416 -36
  195. package/dist/src/modules/workspace/observe.js.map +1 -1
  196. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  197. package/dist/src/modules/workspace/reconcile.js +15 -8
  198. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  199. package/dist/src/modules/workspace/text.d.ts +32 -0
  200. package/dist/src/modules/workspace/text.d.ts.map +1 -0
  201. package/dist/src/modules/workspace/text.js +67 -0
  202. package/dist/src/modules/workspace/text.js.map +1 -0
  203. package/dist/src/modules/workspace/types.d.ts +210 -6
  204. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  205. package/dist/src/modules/workspace/types.js.map +1 -1
  206. package/dist/src/modules/workspace/watcher.d.ts +3 -2
  207. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  208. package/dist/src/modules/workspace/watcher.js.map +1 -1
  209. package/dist/src/operator-log.d.ts +52 -24
  210. package/dist/src/operator-log.d.ts.map +1 -1
  211. package/dist/src/operator-log.js.map +1 -1
  212. package/dist/src/recovery/discord-awareness-outbox.d.ts +496 -72
  213. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  214. package/dist/src/recovery/discord-awareness-outbox.js +1196 -289
  215. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  216. package/dist/src/recovery/offline-branch.d.ts +26 -1
  217. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  218. package/dist/src/recovery/offline-branch.js +164 -39
  219. package/dist/src/recovery/offline-branch.js.map +1 -1
  220. package/dist/src/recovery/recover-cli.js +23 -4
  221. package/dist/src/recovery/recover-cli.js.map +1 -1
  222. package/dist/src/send-tools.d.ts +11 -0
  223. package/dist/src/send-tools.d.ts.map +1 -0
  224. package/dist/src/send-tools.js +11 -0
  225. package/dist/src/send-tools.js.map +1 -0
  226. package/dist/src/tool-result-guard.d.ts +4 -3
  227. package/dist/src/tool-result-guard.d.ts.map +1 -1
  228. package/dist/src/tool-result-guard.js +5 -4
  229. package/dist/src/tool-result-guard.js.map +1 -1
  230. package/dist/src/tool-result-history.d.ts +1 -1
  231. package/dist/src/tool-result-history.js +1 -1
  232. package/dist/src/tune-out/coordinator.d.ts +21 -0
  233. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  234. package/dist/src/tune-out/coordinator.js +139 -37
  235. package/dist/src/tune-out/coordinator.js.map +1 -1
  236. package/dist/src/types/agent.d.ts +4 -0
  237. package/dist/src/types/agent.d.ts.map +1 -1
  238. package/dist/src/types/events.d.ts +62 -9
  239. package/dist/src/types/events.d.ts.map +1 -1
  240. package/dist/src/types/framework.d.ts +62 -58
  241. package/dist/src/types/framework.d.ts.map +1 -1
  242. package/dist/src/types/module.d.ts +40 -8
  243. package/dist/src/types/module.d.ts.map +1 -1
  244. package/dist/src/types/trace.d.ts +111 -10
  245. package/dist/src/types/trace.d.ts.map +1 -1
  246. package/dist/src/usage/types.d.ts +16 -0
  247. package/dist/src/usage/types.d.ts.map +1 -1
  248. package/dist/src/usage/usage-tracker.d.ts +14 -1
  249. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  250. package/dist/src/usage/usage-tracker.js +81 -41
  251. package/dist/src/usage/usage-tracker.js.map +1 -1
  252. package/package.json +6 -5
  253. package/src/agent.ts +93 -12
  254. package/src/api/server.ts +14 -12
  255. package/src/code-execution/engine.ts +595 -0
  256. package/src/code-execution/index.ts +5 -17
  257. package/src/code-execution/isolate/calls.ts +199 -0
  258. package/src/code-execution/isolate/child-entry.ts +5 -0
  259. package/src/code-execution/isolate/child-main.ts +132 -0
  260. package/src/code-execution/isolate/child.ts +327 -0
  261. package/src/code-execution/isolate/frames.ts +209 -0
  262. package/src/code-execution/isolate/output.ts +161 -0
  263. package/src/code-execution/isolate/prelude.ts +409 -0
  264. package/src/code-execution/isolate/protocol.ts +240 -0
  265. package/src/code-execution/isolate/render.ts +230 -0
  266. package/src/code-execution/isolate/runner.ts +911 -0
  267. package/src/code-execution/isolate/scripts.ts +479 -0
  268. package/src/code-execution/quickjs.wasm +0 -0
  269. package/src/code-execution/quickjs.wasm.LICENSES +149 -0
  270. package/src/code-execution/tool-definition.ts +236 -167
  271. package/src/deadline-timer.ts +1 -1
  272. package/src/framework.ts +4507 -2406
  273. package/src/gate/event-gate.ts +78 -11
  274. package/src/held-writes.ts +17 -3
  275. package/src/index.ts +30 -22
  276. package/src/mcpl/channel-registry.ts +1825 -368
  277. package/src/mcpl/conversation-router.ts +12 -7
  278. package/src/mcpl/convert-block.ts +9 -5
  279. package/src/mcpl/inbound-source.ts +131 -0
  280. package/src/mcpl/index.ts +4 -2
  281. package/src/mcpl/log-value.ts +9 -0
  282. package/src/mcpl/modern-connection.ts +162 -53
  283. package/src/mcpl/prose-grammar.ts +4 -5
  284. package/src/mcpl/protocol-family.ts +199 -21
  285. package/src/mcpl/push-coalescer.ts +306 -62
  286. package/src/mcpl/push-handler.ts +10 -2
  287. package/src/mcpl/reference-fetcher.ts +4 -4
  288. package/src/mcpl/server-connection.ts +180 -79
  289. package/src/mcpl/tags.ts +36 -0
  290. package/src/mcpl/tool-result-normalize.ts +2 -1
  291. package/src/mcpl/transport.ts +162 -33
  292. package/src/mcpl/types.ts +23 -6
  293. package/src/module-registry.ts +92 -13
  294. package/src/modules/discord/index.ts +8 -13
  295. package/src/modules/history/index.ts +25 -3
  296. package/src/modules/history/semantic.ts +29 -6
  297. package/src/modules/index.ts +1 -0
  298. package/src/modules/unknown-tool.ts +4 -1
  299. package/src/modules/workspace/branch-intent.ts +3 -1
  300. package/src/modules/workspace/edit-plan.ts +954 -0
  301. package/src/modules/workspace/edit-text.ts +618 -0
  302. package/src/modules/workspace/effects.ts +70 -19
  303. package/src/modules/workspace/index.ts +2247 -642
  304. package/src/modules/workspace/observe.ts +465 -56
  305. package/src/modules/workspace/reconcile.ts +14 -7
  306. package/src/modules/workspace/text.ts +68 -0
  307. package/src/modules/workspace/types.ts +211 -6
  308. package/src/modules/workspace/watcher.ts +3 -2
  309. package/src/operator-log.ts +47 -19
  310. package/src/recovery/discord-awareness-outbox.ts +1500 -356
  311. package/src/recovery/offline-branch.ts +189 -55
  312. package/src/recovery/recover-cli.ts +25 -4
  313. package/src/send-tools.ts +11 -0
  314. package/src/tool-result-guard.ts +6 -5
  315. package/src/tool-result-history.ts +1 -1
  316. package/src/tune-out/coordinator.ts +163 -42
  317. package/src/types/agent.ts +4 -0
  318. package/src/types/events.ts +62 -9
  319. package/src/types/framework.ts +62 -58
  320. package/src/types/module.ts +40 -8
  321. package/src/types/trace.ts +121 -15
  322. package/src/usage/types.ts +16 -0
  323. package/src/usage/usage-tracker.ts +86 -43
  324. package/dist/src/code-execution/py-runner.d.ts +0 -191
  325. package/dist/src/code-execution/py-runner.d.ts.map +0 -1
  326. package/dist/src/code-execution/py-runner.js +0 -655
  327. package/dist/src/code-execution/py-runner.js.map +0 -1
  328. package/dist/src/code-execution/runtime-py.d.ts +0 -50
  329. package/dist/src/code-execution/runtime-py.d.ts.map +0 -1
  330. package/dist/src/code-execution/runtime-py.js +0 -677
  331. package/dist/src/code-execution/runtime-py.js.map +0 -1
  332. package/dist/src/mcpl/prose-stream-router.d.ts +0 -88
  333. package/dist/src/mcpl/prose-stream-router.d.ts.map +0 -1
  334. package/dist/src/mcpl/prose-stream-router.js +0 -291
  335. package/dist/src/mcpl/prose-stream-router.js.map +0 -1
  336. package/src/code-execution/py-runner.ts +0 -791
  337. package/src/code-execution/runtime-py.ts +0 -677
  338. package/src/mcpl/prose-stream-router.ts +0 -268
@@ -0,0 +1,954 @@
1
+ /**
2
+ * Exact-text matching for edit and edit_many: where each edit's old text
3
+ * occurs in a file, what stops an edit applying, and the file's new bytes once
4
+ * every edit applies. Every edit is matched in the text as read, never in
5
+ * another edit's result, so the order of the edits doesn't matter and no edit
6
+ * can take away the text another needs. No I/O: the caller reads, stores and
7
+ * words the refusals.
8
+ *
9
+ * Ported from anima-research/hands-mcp's src/match.ts, where it serves the
10
+ * Edit and EditMany tools.
11
+ */
12
+
13
+ import { MAX_LINE_CHARS, hasBom, numbered, safeCut } from './text.js';
14
+
15
+ /** One edit as the planner takes it. */
16
+ export interface EditSpec {
17
+ /** Its place in the call's list, from 0. */
18
+ index: number;
19
+ /** The old and new text, with every CRLF made LF. */
20
+ oldText: string;
21
+ newText: string;
22
+ /** How many times oldText must occur: a count, or 'all' (edit's replaceAll) for at least once. */
23
+ expected: number | 'all';
24
+ }
25
+
26
+ /** A file's text as matching sees it. */
27
+ export interface FileText {
28
+ /** The text as stored, after a UTF-8 BOM. */
29
+ raw: string;
30
+ /** The same text with every CRLF made LF, the text edits are matched in. */
31
+ value: string;
32
+ /** Ascending, the index in `value` of each LF that followed a CR. */
33
+ crs: number[];
34
+ /** How many line ends the text has in all. */
35
+ lfs: number;
36
+ bom: boolean;
37
+ }
38
+
39
+ /**
40
+ * The file's text, or undefined when it isn't valid UTF-8: rewriting such a
41
+ * file would change bytes outside the edits.
42
+ */
43
+ export function decodeForEdit(bytes: Buffer): FileText | undefined {
44
+ const bom = hasBom(bytes);
45
+ let raw: string;
46
+ try {
47
+ // fatal: a byte sequence that isn't UTF-8 would come back as U+FFFD and be
48
+ // written over the file.
49
+ raw = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bom ? bytes.subarray(3) : bytes);
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ return { raw, bom, ...lineEnds(raw) };
54
+ }
55
+
56
+ /**
57
+ * A file's text with every CRLF made LF, and where the CRs were: `crs` holds,
58
+ * ascending, the index in `value` of each LF that followed a CR. Matching works
59
+ * on `value`; rawIndex maps an index of it back to the file's own text, so a
60
+ * replacement touches only the bytes it replaces.
61
+ */
62
+ function lineEnds(raw: string): { value: string; crs: number[]; lfs: number } {
63
+ let lfs = 0;
64
+ for (let at = raw.indexOf('\n'); at !== -1; at = raw.indexOf('\n', at + 1)) lfs++;
65
+ if (!raw.includes('\r\n')) return { value: raw, crs: [], lfs };
66
+ const parts: string[] = [];
67
+ const crs: number[] = [];
68
+ let length = 0;
69
+ let from = 0;
70
+ for (let at = raw.indexOf('\r\n'); at !== -1; at = raw.indexOf('\r\n', at + 2)) {
71
+ parts.push(raw.slice(from, at));
72
+ length += at - from;
73
+ crs.push(length);
74
+ from = at + 1; // keep the LF
75
+ }
76
+ parts.push(raw.slice(from));
77
+ return { value: parts.join(''), crs, lfs };
78
+ }
79
+
80
+ /**
81
+ * Straight and curly quotes compared as equal, for a second-chance match on
82
+ * prose edits. Each quote is one code unit either way, so offsets carry over.
83
+ */
84
+ function normalizeQuotes(value: string): string {
85
+ return value.replace(/[‘’‚‛]/g, "'").replace(/[“”„‟]/g, '"');
86
+ }
87
+
88
+ /** What a reading of an old text leaves out of it, as matching leaves it out of the file's text. */
89
+ export type Leaves = 'nothing' | 'cr' | 'bom' | 'both';
90
+
91
+ /**
92
+ * The readings of an edit other than as given, where its old text carries
93
+ * what matching leaves out of a file's text: the BOM, which decodeForEdit
94
+ * takes off before the first line, at its start, or a line's CR, which
95
+ * matching reads as part of the line end, at its end. A character page shows
96
+ * both, and a line whose own text ends in a CR shows its end's CR too
97
+ * (shownLine). Each reading leaves out the CR, the BOM, or both, its new text
98
+ * read the same way, so a replacement keeps the file's one BOM and its CRLF
99
+ * line end, and may match only where what it leaves out is the file's: at the
100
+ * text's start for the BOM, and before a CRLF line end for the CR. None where
101
+ * the old text carries neither where the file has them. A reading that
102
+ * leaves nothing, of an old text that is the BOM or the CR alone, is one too:
103
+ * planFile finds it where what it leaves out is the file's.
104
+ */
105
+ function readingsOf(spec: EditSpec, file: FileText, crAt: (lf: number) => boolean): Array<{ spec: EditSpec; accept: (at: number) => boolean; leaves: Leaves }> {
106
+ const leading = file.bom && spec.oldText.startsWith('\uFEFF');
107
+ const trailing = file.crs.length > 0 && spec.oldText.endsWith('\r');
108
+ const read = (bom: boolean, cr: boolean, leaves: Leaves) => {
109
+ const oldText = spec.oldText.slice(bom ? 1 : 0, cr ? -1 : undefined);
110
+ let newText = spec.newText;
111
+ if (bom && newText.startsWith('\uFEFF')) newText = newText.slice(1);
112
+ if (cr && newText.endsWith('\r')) newText = newText.slice(0, -1);
113
+ return [{
114
+ spec: { ...spec, oldText, newText },
115
+ // crs holds only LFs that followed a CR, so one there is a CRLF line end.
116
+ accept: (at: number) => (!bom || at === 0) && (!cr || crAt(at + oldText.length)),
117
+ leaves,
118
+ }];
119
+ };
120
+ return [
121
+ ...(trailing ? read(false, true, 'cr') : []),
122
+ ...(leading ? read(true, false, 'bom') : []),
123
+ ...(leading && trailing ? read(true, true, 'both') : []),
124
+ ];
125
+ }
126
+
127
+ /** Where an old text occurs in a file: what scan finds. */
128
+ interface Found {
129
+ /** The first `keep` positions it starts at. */
130
+ starts: number[];
131
+ /** How many positions it starts at, or with `atLeast`, how many the scan found before it stopped. */
132
+ count: number;
133
+ /** Whether one occurrence starts before the one before it ends. */
134
+ overlapping: boolean;
135
+ atLeast: boolean;
136
+ /** Whether it stopped because the call's count budget ran out, so `count` is a floor for that reason. */
137
+ spent: boolean;
138
+ }
139
+
140
+ /**
141
+ * What one call may count of occurrences no answer needs, all of its edits
142
+ * together: those past what an edit asks for and the one more that shows it
143
+ * finds more, which only make a refusal's count exact. applyEdits sizes it
144
+ * before it plans, to the larger of the mount's limit and the largest file
145
+ * the call names, in bytes, which no file's occurrences of a nonempty text
146
+ * pass, as a file has no more code units than bytes; so it's the same
147
+ * whatever order the files are planned in, and an edit's count in a call of
148
+ * its own is exact, even in a file stored before its mount's limit was
149
+ * lowered. planFile raises it to any text longer than it, for a caller that
150
+ * sizes it smaller. Past it, a count is a floor. Without it, each of many
151
+ * edits of a text with millions of occurrences counted them all, at a cost
152
+ * of the edits times the occurrences. COUNT_MAX, the default limit's, serves
153
+ * a caller with none.
154
+ */
155
+ export interface CountBudget {
156
+ left: number;
157
+ /** What it holds in all: what it was sized to, or the longest text planned with it, if that's more. */
158
+ size: number;
159
+ }
160
+ export const COUNT_MAX = 5 * 1024 * 1024;
161
+ export const countBudget = (maxBytes = COUNT_MAX): CountBudget => ({ left: maxBytes, size: maxBytes });
162
+
163
+ /**
164
+ * Where needle starts in haystack: every position, overlapping ones included,
165
+ * so "aa" starts twice in "aaa". The first `keep` positions are kept; `count`
166
+ * is all of them, unless occurrences overlap. Those can't each be replaced, so
167
+ * the edit is refused whatever their count, and the scan stops once it has
168
+ * found one more than the SHOWN_MATCHES a refusal shows, so a count it stopped
169
+ * at is more than those shown. Otherwise a needle that overlaps itself,
170
+ * such as a run of spaces, would be compared in full at every position of a
171
+ * long run of the same, at a cost of the run's length times the needle's.
172
+ * With `accept`, only the positions it accepts are occurrences, and the stop
173
+ * can't apply to the positions it passes over, so those are found in one
174
+ * pass over the haystack (everyOccurrence). The first `free` occurrences are
175
+ * counted freely; each past them takes one from `budget`, and the scan stops
176
+ * once it's spent, its count then a floor (`spent`).
177
+ */
178
+ function scan(haystack: string, needle: string, keep: number, accept?: (at: number) => boolean, free = Infinity, budget?: CountBudget): Found {
179
+ const starts: number[] = [];
180
+ let count = 0;
181
+ let overlapping = false;
182
+ let spent = false;
183
+ // indexOf finds the empty string at every position, never -1: callers refuse
184
+ // an empty old text, and this keeps any other caller from looping.
185
+ if (!needle) return { starts, count, overlapping, atLeast: false, spent };
186
+ let previous = -Infinity;
187
+ // Takes the occurrence at `at`, and says whether to look for the next.
188
+ const take = (at: number): boolean => {
189
+ if (accept && !accept(at)) return true;
190
+ if (at < previous + needle.length) overlapping = true;
191
+ if (count < keep) starts.push(at);
192
+ count++;
193
+ previous = at;
194
+ if (overlapping && count > SHOWN_MATCHES) return false;
195
+ if (count > free) {
196
+ if (!budget || budget.left <= 0) return !(spent = true);
197
+ budget.left--;
198
+ }
199
+ return true;
200
+ };
201
+ if (accept) {
202
+ // Run before the answer is made, since the answer reads what it counts.
203
+ const stopped = !everyOccurrence(haystack, needle, take);
204
+ return { starts, count, overlapping, atLeast: stopped, spent };
205
+ }
206
+ for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + 1)) {
207
+ if (!take(at)) return { starts, count, overlapping, atLeast: true, spent };
208
+ }
209
+ return { starts, count, overlapping, atLeast: false, spent };
210
+ }
211
+
212
+ /**
213
+ * Every position needle starts at in haystack, overlapping ones included,
214
+ * each given to `visit` in turn until it returns false, in one pass over the
215
+ * haystack however the needle repeats itself (Knuth–Morris–Pratt). False once
216
+ * `visit` returns false, even for an occurrence that ends the haystack, as
217
+ * scan's indexOf loop stops; true when every occurrence was visited.
218
+ */
219
+ function everyOccurrence(haystack: string, needle: string, visit: (at: number) => boolean): boolean {
220
+ // border[i]: how long the needle's longest prefix is that also ends its
221
+ // first i + 1 code units, short of all of them.
222
+ const border = new Int32Array(needle.length);
223
+ for (let i = 1, k = 0; i < needle.length; i++) {
224
+ const c = needle.charCodeAt(i);
225
+ while (k > 0 && c !== needle.charCodeAt(k)) k = border[k - 1]!;
226
+ if (c === needle.charCodeAt(k)) k++;
227
+ border[i] = k;
228
+ }
229
+ for (let i = 0, k = 0; i < haystack.length; i++) {
230
+ const c = haystack.charCodeAt(i);
231
+ while (k > 0 && c !== needle.charCodeAt(k)) k = border[k - 1]!;
232
+ if (c === needle.charCodeAt(k)) k++;
233
+ if (k === needle.length) {
234
+ if (!visit(i - k + 1)) return false;
235
+ k = border[k - 1]!;
236
+ }
237
+ }
238
+ return true;
239
+ }
240
+
241
+ /**
242
+ * The 1-based line of each index (ascending), in one pass over the text: it
243
+ * keeps the next line end it found, so once none remains it reads no further.
244
+ */
245
+ export function lineNumbersAt(value: string, indices: number[]): number[] {
246
+ const lines: number[] = [];
247
+ let line = 1;
248
+ let next = value.indexOf('\n');
249
+ for (const index of indices) {
250
+ while (next !== -1 && next < index) {
251
+ line++;
252
+ next = value.indexOf('\n', next + 1);
253
+ }
254
+ lines.push(line);
255
+ }
256
+ return lines;
257
+ }
258
+
259
+ /** How many of the ascending values are below x. */
260
+ function countBelow(values: number[], x: number): number {
261
+ let lo = 0;
262
+ let hi = values.length;
263
+ while (lo < hi) {
264
+ const mid = (lo + hi) >> 1;
265
+ if (values[mid]! < x) lo = mid + 1;
266
+ else hi = mid;
267
+ }
268
+ return lo;
269
+ }
270
+
271
+ /** Bigram counts of a string. */
272
+ function grams(value: string): Map<string, number> {
273
+ const counts = new Map<string, number>();
274
+ for (let i = 0; i + 1 < value.length; i++) {
275
+ const gram = value.slice(i, i + 2);
276
+ counts.set(gram, (counts.get(gram) ?? 0) + 1);
277
+ }
278
+ return counts;
279
+ }
280
+
281
+ /**
282
+ * Above this many UTF-16 code units of the text (line ends as LF) a miss gets
283
+ * no closest-region hint: scoring every line would hold the host's event loop.
284
+ */
285
+ export const HINT_MAX_CHARS = 2 * 1024 * 1024;
286
+
287
+ /**
288
+ * What one call's misses may search for their closest regions, in UTF-16 code
289
+ * units of file text, all of them together: as much as one edit's miss may
290
+ * (HINT_MAX_CHARS), so the closest-region search of an edit_many call of many
291
+ * misses holds the event loop no longer than one edit's could. Matching itself
292
+ * scans each file once per edit, as any edit must, and what it counts past
293
+ * what the edits need takes from the call's count budget (CountBudget).
294
+ */
295
+ export interface HintBudget {
296
+ left: number;
297
+ }
298
+ export const hintBudget = (): HintBudget => ({ left: HINT_MAX_CHARS });
299
+
300
+ /**
301
+ * The file region most like the first meaningful line of the old text,
302
+ * numbered for the retry, within the call's hint budget.
303
+ */
304
+ function closestRegion(value: string, needle: string, budget: HintBudget): string {
305
+ if (value.length > HINT_MAX_CHARS) {
306
+ return `(No closest region for a file over ${HINT_MAX_CHARS.toLocaleString('en-US')} UTF-16 code units; find the text with grep.)`;
307
+ }
308
+ if (value.length > budget.left) {
309
+ return `(No closest region: this call's other misses used its ${HINT_MAX_CHARS.toLocaleString('en-US')} UTF-16 code units of search; find the text with grep.)`;
310
+ }
311
+ budget.left -= value.length;
312
+ // The first line with a bigram to score: a line of one character, such as
313
+ // a closing brace, has none, and no line would score above it. At most its
314
+ // first MAX_LINE_CHARS code units, as many as a hint shows of a line, so a
315
+ // long old text holds no more than that.
316
+ const first = needle
317
+ .split('\n')
318
+ .map((l) => l.trim())
319
+ .find((l) => l.length > 1) ?? needle.trim();
320
+ const probe = first.slice(0, safeCut(first, MAX_LINE_CHARS));
321
+ // Dice coefficient over character bigrams: cheap, order-sensitive enough for
322
+ // "closest line". Each line's bigrams are counted against the probe's, by
323
+ // a slot for each of the probe's, so what scoring holds follows the probe,
324
+ // not the longest line.
325
+ const slot = new Map<string, number>();
326
+ const most: number[] = [];
327
+ for (const [gram, n] of grams(probe)) {
328
+ slot.set(gram, most.length);
329
+ most.push(n);
330
+ }
331
+ const met = new Int32Array(most.length);
332
+ const touched: number[] = [];
333
+ const probeTotal = Math.max(0, probe.length - 1);
334
+ const lines = value.split('\n');
335
+ // A final line end ends the last line rather than starting an empty one, as
336
+ // read numbers a file's lines.
337
+ if (lines.length > 1 && lines.at(-1) === '') lines.pop();
338
+ let best = 0;
339
+ let bestScore = -1;
340
+ lines.forEach((line, i) => {
341
+ const trimmed = line.trim();
342
+ let shared = 0;
343
+ for (let at = 0; at + 1 < trimmed.length; at++) {
344
+ const k = slot.get(trimmed.slice(at, at + 2));
345
+ if (k === undefined) continue;
346
+ if (met[k] === 0) touched.push(k);
347
+ if (met[k]! < most[k]!) shared++;
348
+ met[k] = met[k]! + 1;
349
+ }
350
+ for (const k of touched) met[k] = 0;
351
+ touched.length = 0;
352
+ const total = probeTotal + Math.max(0, trimmed.length - 1);
353
+ const score = total ? (2 * shared) / total : 0;
354
+ if (score > bestScore) {
355
+ bestScore = score;
356
+ best = i;
357
+ }
358
+ });
359
+ if (bestScore <= 0) return '';
360
+ const from = Math.max(0, best - 3);
361
+ const to = Math.min(lines.length, best + 4);
362
+ // Every line comes before a line feed but a last one the text doesn't end with.
363
+ const shown = lines.slice(from, to).map((line, k) => shownLine(line, from + k < lines.length - 1 || value.endsWith('\n')));
364
+ return `Closest region (line ${best + 1}, similarity ${bestScore.toFixed(2)}):\n${numbered(shown, from + 1)}`;
365
+ }
366
+
367
+ /**
368
+ * A line of the text edits are matched in, as an answer shows it: one whose
369
+ * own text ends in a CR, before a line feed, shows its line end's CR too, as
370
+ * read shows such a line. Matching reads a CR before a line feed in old text
371
+ * as part of the line end, so text copied across the line with one CR would
372
+ * match the same text with CRLF ends alone. In that text a CR before a line
373
+ * feed is always such a line's own, since lineEnds made each CRLF a line feed.
374
+ */
375
+ export function shownLine(text: string, beforeLf: boolean): string {
376
+ return beforeLf && text.endsWith('\r') ? `${text}\r` : text;
377
+ }
378
+
379
+ /** How many matches a refusal names by line and shows. */
380
+ export const SHOWN_MATCHES = 20;
381
+ /** A matched line longer than this many UTF-16 code units is shown as an excerpt this long around its match. */
382
+ const EXCERPT_CHARS = 240;
383
+
384
+ /**
385
+ * The lines the first matches fall on, numbered as read numbers them, each
386
+ * line once: what a count refusal shows, so an over-broad match can be judged
387
+ * without another read. A long line, such as a paragraph kept on one line, is
388
+ * shown as an excerpt around its first match.
389
+ */
390
+ function matchedLines(value: string, starts: number[], lines: number[]): string {
391
+ const shown: string[] = [];
392
+ let last = 0;
393
+ starts.forEach((start, k) => {
394
+ const line = lines[k]!;
395
+ if (line === last) return;
396
+ last = line;
397
+ const lineFrom = start === 0 ? 0 : value.lastIndexOf('\n', start - 1) + 1;
398
+ const newline = value.indexOf('\n', start);
399
+ const lineTo = newline === -1 ? value.length : newline;
400
+ let from = lineFrom;
401
+ let to = lineTo;
402
+ if (lineTo - lineFrom > EXCERPT_CHARS) {
403
+ from = Math.max(lineFrom, start - EXCERPT_CHARS / 3);
404
+ to = Math.min(lineTo, from + EXCERPT_CHARS);
405
+ from = Math.max(lineFrom, to - EXCERPT_CHARS);
406
+ // Never half of a surrogate pair at either cut.
407
+ from = safeCut(value, from);
408
+ to = safeCut(value, to);
409
+ }
410
+ shown.push(`${String(line).padStart(6)}\t${from > lineFrom ? '…' : ''}${shownLine(value.slice(from, to), to === lineTo && newline !== -1)}${to < lineTo ? '…' : ''}`);
411
+ });
412
+ return shown.join('\n');
413
+ }
414
+
415
+ /** Why an edit can't apply to its file, as the planner finds it. Edits are numbered by `index`, from 0. */
416
+ export type MatchProblem =
417
+ /** The old text doesn't occur, even with straight and curly quotes treated as equal. */
418
+ | { kind: 'miss'; edit: number; hint: string; crlf: boolean }
419
+ /**
420
+ * It occurs a number of times other than the one asked for, or its
421
+ * occurrences overlap one another, or the call's count budget stopped its
422
+ * count, which is then a floor, even at the number asked for.
423
+ */
424
+ | {
425
+ kind: 'count';
426
+ edit: number;
427
+ expected: number | 'all';
428
+ /**
429
+ * How many times it occurs, or with `atLeast` how many the scan found
430
+ * before it stopped: at an overlap, or with `spent` where the call's count
431
+ * budget ran out (CountBudget).
432
+ */
433
+ found: number;
434
+ atLeast: boolean;
435
+ spent: boolean;
436
+ /** The line of each of its first SHOWN_MATCHES occurrences, one per occurrence; `shown` shows each of those lines once. */
437
+ lines: number[];
438
+ shown: string;
439
+ quoteNormalized: boolean;
440
+ overlapping: boolean;
441
+ }
442
+ /** Two edits' matches overlap, or are the same text. */
443
+ | { kind: 'clash'; edits: [number, number]; line: number; same: boolean }
444
+ /**
445
+ * Edits that found the occurrences they ask for, or stopped counting short
446
+ * of them, asking between them for more than the text is long (`length`, in
447
+ * UTF-16 code units with line ends as LF): each takes one or more, and one
448
+ * edit's don't overlap one another, so some of different edits' would.
449
+ * Named in place of their clashes, whose places aren't kept (planFile).
450
+ */
451
+ | { kind: 'crowded'; edits: number[]; asked: number; length: number }
452
+ /**
453
+ * It matches only without the BOM or the line end's CR its old text
454
+ * carries (readForm), and without them it is the same as its new text.
455
+ */
456
+ | { kind: 'same'; edit: number }
457
+ /**
458
+ * Its old text, which carries the file's BOM at its start (`bom`) or a CR
459
+ * at its end (`cr`), occurs in more than one reading, as given or without
460
+ * either or both (readingsOf), so which the edit means can't be told. Each
461
+ * reading's occurrences as a count's are: how many, and the lines of the
462
+ * first SHOWN_MATCHES; `same` where read that way the edit changes
463
+ * nothing, as the file keeps the BOM or line end's CR that reading leaves
464
+ * out.
465
+ */
466
+ | {
467
+ kind: 'readings';
468
+ edit: number;
469
+ bom: boolean;
470
+ cr: boolean;
471
+ readings: Array<{ leaves: Leaves; same: boolean; found: number; atLeast: boolean; lines: number[] }>;
472
+ };
473
+
474
+ /** One replacement made, in the updated text. */
475
+ export interface Change {
476
+ edit: number;
477
+ /** Where its new text starts in the updated text, and that index's 1-based line. */
478
+ at: number;
479
+ line: number;
480
+ /**
481
+ * How many lines its new text spans, as a receipt shows them: a final line
482
+ * end closes its last line rather than starting another.
483
+ */
484
+ lines: number;
485
+ }
486
+
487
+ /**
488
+ * What one edit replaced in a file: its first replacement, which a receipt
489
+ * shows, and where the rest fell, as far as a receipt names them. One edit
490
+ * can make millions of replacements, so the rest are counted, not kept.
491
+ */
492
+ export interface EditChanges {
493
+ /** Its first replacement in the file. */
494
+ first: Change;
495
+ /** How many replacements it made. */
496
+ count: number;
497
+ /** The lines of its replacements after the first, each line once: the first SHOWN_MATCHES of them. */
498
+ otherLines: number[];
499
+ /** How many lines those replacements fall on in all, each line once. */
500
+ otherLineCount: number;
501
+ }
502
+
503
+ /** A file's edits, applied together. */
504
+ export interface FilePlan {
505
+ /** The file's new bytes, BOM and every line end it keeps included. */
506
+ bytes: Buffer;
507
+ /** Its new text with line ends as LF, for the receipt. */
508
+ updated: string;
509
+ /**
510
+ * What each edit replaced, in the order of its first replacement in the
511
+ * file: its first replacement, how many it made, and the lines of the rest
512
+ * a receipt names, not the replacements themselves.
513
+ */
514
+ edits: EditChanges[];
515
+ /** How many replacements the edits made in all. */
516
+ replacements: number;
517
+ /** The edits matched with straight and curly quotes treated as equal. */
518
+ quoteNormalized: number[];
519
+ /** The edits matched without the BOM or the line end's CR their old text carried (readingsOf). */
520
+ readForm: number[];
521
+ crlf: boolean;
522
+ mixed: boolean;
523
+ bom: boolean;
524
+ }
525
+
526
+ /**
527
+ * Text built from many pieces, joined a batch at a time, so the pieces of a
528
+ * replaceAll with millions of occurrences aren't all held at once.
529
+ */
530
+ function textBuilder(): { add: (piece: string) => void; text: () => string } {
531
+ const chunks: string[] = [];
532
+ let pieces: string[] = [];
533
+ const flush = () => {
534
+ chunks.push(pieces.join(''));
535
+ pieces = [];
536
+ };
537
+ return {
538
+ add: (piece) => {
539
+ if (!piece) return;
540
+ pieces.push(piece);
541
+ if (pieces.length >= 4096) flush();
542
+ },
543
+ text: () => {
544
+ flush();
545
+ return chunks.join('');
546
+ },
547
+ };
548
+ }
549
+
550
+ /** How many line feeds a string holds. */
551
+ function lineFeeds(value: string): number {
552
+ let count = 0;
553
+ for (let at = value.indexOf('\n'); at !== -1; at = value.indexOf('\n', at + 1)) count++;
554
+ return count;
555
+ }
556
+
557
+ /**
558
+ * A file's edits applied together, or every problem that stops them. Each
559
+ * edit is matched in the file's text as read: its old text must occur the
560
+ * number of times it asks for, counting overlapping occurrences (so "aa"
561
+ * occurs twice in "aaa"), and occurrences that overlap one another can't each
562
+ * be replaced. An old text that carries the file's BOM at its start or a
563
+ * line's CR at its end is also read without the one, the other or both
564
+ * (readingsOf): one found in more than one reading, as given among them, is
565
+ * refused, since which it means can't be told, a reading in which the edit
566
+ * changes nothing included, and one found only in another reading, and there
567
+ * the same as its new text, has nothing to change. An edit that replaces
568
+ * every occurrence counts no reading in which it changes nothing or that
569
+ * leaves nothing, since edits keep line ends and the BOM. Only where no
570
+ * reading finds it is it matched again with straight and curly quotes
571
+ * treated as equal. Two edits' matches may touch but not overlap. What a
572
+ * call counts past what its edits need takes from its count budget
573
+ * (CountBudget). Only the replaced text changes: every other byte stays as
574
+ * it was, and each new text's line ends take, by position, those of the
575
+ * text it replaces. For each occurrence an edit replaces it holds numbers,
576
+ * not objects: the occurrence's start, and with several edits a key in one
577
+ * Float64Array. The
578
+ * new text is built a batch of pieces at a time, and of each edit's
579
+ * replacements it keeps only what a receipt names. Two bounds keep a call
580
+ * from holding more than its file: two or more edits whose asks, past what a
581
+ * refusal shows, come to more occurrences than the text is long keep no more
582
+ * starts than a refusal shows (one edit keeps the starts it finds, which the
583
+ * text's length bounds), and a file whose counts take it past `maxBytes` is
584
+ * refused as `tooLarge`, at least that many bytes, before its new text is
585
+ * built. One that the CRLF ends its new text takes, or quotes read as equal,
586
+ * take past it is built and returned, for its caller to refuse by its bytes.
587
+ */
588
+ export function planFile(
589
+ file: FileText,
590
+ bytes: Buffer,
591
+ specs: EditSpec[],
592
+ hints: HintBudget = hintBudget(),
593
+ maxBytes = Infinity,
594
+ counts: CountBudget = countBudget(),
595
+ ): { plan: FilePlan } | { problems: MatchProblem[] } | { tooLarge: number } {
596
+ const { raw, value, crs, lfs } = file;
597
+ const crlf = crs.length > 0;
598
+ const mixed = crlf && crs.length < lfs;
599
+ const crAt = (lf: number) => {
600
+ const i = countBelow(crs, lf);
601
+ return i < crs.length && crs[i] === lf;
602
+ };
603
+ const problems: MatchProblem[] = [];
604
+ // Each edit that matches, with where: the edit as matched (readForm's for an
605
+ // old text read without its BOM or CR), its starts, ascending, and how many.
606
+ const applied: Array<{ spec: EditSpec; starts: number[]; count: number }> = [];
607
+ const quoteNormalized: number[] = [];
608
+ const readForms: number[] = [];
609
+ let quoted: string | undefined;
610
+ // Each edit keeps the starts it asks for, to replace them. What several ask
611
+ // for past the SHOWN_MATCHES each keeps for a refusal has to fit in the
612
+ // text, a character or more each; when it doesn't, they can't all apply
613
+ // (one finds fewer than it asks for, or two overlap), so they keep only
614
+ // what a refusal shows, and a call asking for millions of each doesn't
615
+ // hold millions for each.
616
+ const pastShown = specs.reduce((sum, spec) => sum + (spec.expected === 'all' ? 0 : Math.max(spec.expected - SHOWN_MATCHES, 0)), 0);
617
+ const crowded = specs.length > 1 && pastShown > value.length;
618
+ // The call's count budget holds as many as this text is long, at least:
619
+ // applyEdits sizes it to the larger of the mount's limit and the largest
620
+ // file the call names, and a caller that sizes it smaller has it raised
621
+ // here.
622
+ if (value.length > counts.size) {
623
+ counts.left += value.length - counts.size;
624
+ counts.size = value.length;
625
+ }
626
+ // Crowded edits whose counts stopped past what a refusal shows, and so
627
+ // can't be set against what they ask for, each with its count problem,
628
+ // made where the crowded check doesn't name it.
629
+ const uncounted: Array<{ spec: EditSpec; problem: () => MatchProblem }> = [];
630
+ for (const spec of specs) {
631
+ // Every occurrence when every one is replaced; otherwise enough to replace or to show.
632
+ const keep = crowded ? SHOWN_MATCHES : spec.expected === 'all' ? Infinity : Math.max(spec.expected, SHOWN_MATCHES);
633
+ // What it counts freely: every occurrence it replaces, and the one more
634
+ // that shows it finds more than it asks for. Past that, a count only makes
635
+ // a refusal exact, and takes from the call's budget (CountBudget). Crowded
636
+ // edits are refused whatever they find (below), so they count freely no
637
+ // more than a refusal shows, but for one that replaces every occurrence,
638
+ // which asks for as many as there are, and so counts them all.
639
+ const free = spec.expected === 'all' ? Infinity : crowded ? SHOWN_MATCHES : Math.max(spec.expected, SHOWN_MATCHES) + 1;
640
+ const budget = counts;
641
+ let matched = spec;
642
+ let { starts, count, overlapping, atLeast, spent } = scan(value, spec.oldText, keep, undefined, free, budget);
643
+ let normalized = false;
644
+ // An old text with the file's BOM at its start or a CR at its end reads
645
+ // more ways than as given: without either, or both, as a character page
646
+ // or a line shown with its CRLF end's CR gives it (readingsOf). A line
647
+ // whose own text ends in one CR, shown with its end's, reads as given as a
648
+ // line ending in two. Where more than one reading that changes something
649
+ // finds it, which the edit means can't be told, so it's refused; where one
650
+ // does, it's taken, before quotes are read as equal. That holds for one
651
+ // that changes nothing, its new text read the same way as its old: a copy
652
+ // of a line end's CR or the BOM, as a character page shows them, may mean
653
+ // them, which the file keeps, and alone it has nothing to change. Beside a
654
+ // match as given, a reading need only be found, and shown as a refusal
655
+ // shows it. A reading that leaves nothing, of an old text that is the BOM
656
+ // or a CR alone, is found where what it leaves out is the file's, the BOM
657
+ // at the text's start and a CR before each CRLF line end; it counts only
658
+ // beside another match, as an empty text can't be replaced. An edit that
659
+ // replaces every occurrence counts neither: edits keep line ends and the
660
+ // BOM, so where a reading changes nothing, or leaves nothing, it can only
661
+ // mean the occurrences that change.
662
+ const all = spec.expected === 'all';
663
+ const emptyAt = (leaves: Leaves): number[] => (leaves === 'cr' ? crs : file.bom && (leaves === 'bom' || crAt(0)) ? [0] : []);
664
+ const allReads = readingsOf(spec, file, crAt)
665
+ .filter((read) => read.spec.oldText || !all)
666
+ .map((read) => {
667
+ if (read.spec.oldText) {
668
+ return { read, found: count > 0 ? scan(value, read.spec.oldText, SHOWN_MATCHES, read.accept, SHOWN_MATCHES) : scan(value, read.spec.oldText, keep, read.accept, free, budget) };
669
+ }
670
+ const at = emptyAt(read.leaves);
671
+ return { read, found: { starts: at.slice(0, SHOWN_MATCHES), count: at.length, overlapping: false, atLeast: false, spent: false } };
672
+ })
673
+ .filter(({ found }) => found.count > 0);
674
+ const same = ({ read }: { read: { spec: EditSpec } }): boolean => read.spec.oldText === read.spec.newText;
675
+ const counted = all ? allReads.filter((reading) => !same(reading)) : allReads;
676
+ const others = (count > 0 ? 1 : 0) + counted.filter(({ read }) => read.spec.oldText).length;
677
+ const reads = counted.filter(({ read }) => read.spec.oldText || others > 0);
678
+ if (reads.length + (count > 0 ? 1 : 0) > 1) {
679
+ const where = (found: Found) => ({ found: found.count, atLeast: found.atLeast, lines: lineNumbersAt(value, found.starts.slice(0, SHOWN_MATCHES)) });
680
+ problems.push({
681
+ kind: 'readings',
682
+ edit: spec.index,
683
+ bom: file.bom && spec.oldText.startsWith('\uFEFF'),
684
+ cr: crlf && spec.oldText.endsWith('\r'),
685
+ readings: [
686
+ ...(count > 0 ? [{ leaves: 'nothing' as const, same: false, ...where({ starts, count, overlapping, atLeast, spent }) }] : []),
687
+ ...reads.map((reading) => ({ leaves: reading.read.leaves, same: same(reading), ...where(reading.found) })),
688
+ ],
689
+ });
690
+ continue;
691
+ }
692
+ if (count === 0 && reads.length === 1 && !same(reads[0]!)) {
693
+ const { read, found: where } = reads[0]!;
694
+ ({ starts, count, overlapping, atLeast, spent } = where);
695
+ matched = read.spec;
696
+ readForms.push(spec.index);
697
+ } else if (count === 0 && reads.length < 2 && allReads.some((reading) => reading.read.spec.oldText && same(reading))) {
698
+ // Read as the file's own, the BOM or CR was all the edit changed. A
699
+ // reading that leaves nothing is never taken alone, so it misses.
700
+ problems.push({ kind: 'same', edit: spec.index });
701
+ continue;
702
+ }
703
+ if (count === 0) {
704
+ ({ starts, count, overlapping, atLeast, spent } = scan((quoted ??= normalizeQuotes(value)), normalizeQuotes(spec.oldText), keep, undefined, free, budget));
705
+ normalized = count > 0;
706
+ }
707
+ if (count === 0) {
708
+ problems.push({ kind: 'miss', edit: spec.index, hint: closestRegion(value, spec.oldText, hints), crlf });
709
+ continue;
710
+ }
711
+ // A count the budget stopped is a floor, and so isn't the one asked for
712
+ // even where it's that many, which only a crowded edit's can be.
713
+ if (overlapping || (spec.expected !== 'all' && (count !== spec.expected || spent))) {
714
+ const found = { count, atLeast, spent, overlapping, normalized, first: starts.slice(0, SHOWN_MATCHES) };
715
+ const problem = (): MatchProblem => {
716
+ const lines = lineNumbersAt(value, found.first);
717
+ return {
718
+ kind: 'count',
719
+ edit: spec.index,
720
+ expected: spec.expected,
721
+ found: found.count,
722
+ atLeast: found.atLeast,
723
+ spent: found.spent,
724
+ lines,
725
+ shown: matchedLines(value, found.first, lines),
726
+ quoteNormalized: found.normalized,
727
+ overlapping: found.overlapping,
728
+ };
729
+ };
730
+ // A crowded edit whose count stopped at no more than it asks for may
731
+ // find what it asks for: the crowded check below names it, or else its
732
+ // count, as a floor.
733
+ if (crowded && spent && !overlapping && count <= (spec.expected as number)) uncounted.push({ spec, problem });
734
+ else problems.push(problem());
735
+ continue;
736
+ }
737
+ if (normalized) quoteNormalized.push(spec.index);
738
+ applied.push({ spec: matched, starts, count });
739
+ }
740
+ if (crowded) {
741
+ // Where the edits that found what they ask for, and those whose counts
742
+ // stopped short of it, ask between them for more occurrences than the
743
+ // text is long, each takes one or more, so some overlap, though where
744
+ // isn't kept: they're named as crowded. Otherwise some edit found other
745
+ // than it asks for, and each that stopped short is named by its count,
746
+ // which says it's a floor.
747
+ // An edit that replaces every occurrence asks for as many as it found.
748
+ const asks = [
749
+ ...applied.map(({ spec, count }) => ({ edit: spec.index, asked: spec.expected === 'all' ? count : spec.expected })),
750
+ ...uncounted.map(({ spec }) => ({ edit: spec.index, asked: spec.expected as number })),
751
+ ].sort((a, b) => a.edit - b.edit);
752
+ const asked = asks.reduce((sum, ask) => sum + ask.asked, 0);
753
+ if (asked > value.length) problems.push({ kind: 'crowded', edits: asks.map((ask) => ask.edit), asked, length: value.length });
754
+ // A loop, not a spread: a call's edits can be more than a spread passes.
755
+ else for (const { problem } of uncounted) problems.push(problem());
756
+ return { problems };
757
+ }
758
+ // Every replacement in file order. One edit's starts are that already; for
759
+ // several, each replacement is a number, its start times the edits plus
760
+ // the edit's slot, so sorting them sorts by start, then by the edit's place
761
+ // in the call's list.
762
+ applied.sort((a, b) => a.spec.index - b.spec.index);
763
+ const slots = applied.length;
764
+ const total = applied.reduce((sum, { starts }) => sum + starts.length, 0);
765
+ let order: Float64Array | undefined;
766
+ if (slots > 1) {
767
+ order = new Float64Array(total);
768
+ let k = 0;
769
+ applied.forEach(({ starts }, slot) => {
770
+ for (const start of starts) order![k++] = start * slots + slot;
771
+ });
772
+ order.sort();
773
+ }
774
+ const slotOf = (k: number) => (order ? order[k]! % slots : 0);
775
+ const startOf = (k: number) => (order ? (order[k]! - (order[k]! % slots)) / slots : applied[0]!.starts[k]!);
776
+ // Overlapping matches are named at their first overlap, whatever order the
777
+ // list gave them in, and each edit at most once as the later of a pair, so
778
+ // the problems grow with the edits rather than their pairs: a thousand edits
779
+ // of one stretch are a thousand problems at most, not half a million. One
780
+ // edit's own occurrences don't overlap (scan), so only several can clash.
781
+ const named = new Set<number>();
782
+ const pairs = new Set<string>();
783
+ const clashes: Array<{ edits: [number, number]; at: number; same: boolean }> = [];
784
+ // A later replacement whose edit is already named can't name it again, so
785
+ // the first loop to meet it links past it (nextLater), and the loops after
786
+ // cross a run of such replacements in one step: many edits matching one
787
+ // text cost their replacements, not their pairs at every place.
788
+ const nextLater = slots > 1 ? new Int32Array(total + 1) : undefined;
789
+ for (let k = 0; nextLater && k <= total; k++) nextLater[k] = k;
790
+ const laterFrom = (k: number): number => {
791
+ let found = k;
792
+ while (nextLater![found] !== found) found = nextLater![found]!;
793
+ while (nextLater![k] !== found) {
794
+ const step = nextLater![k]!;
795
+ nextLater![k] = found;
796
+ k = step;
797
+ }
798
+ return found;
799
+ };
800
+ for (let i = 0; slots > 1 && i < total; i++) {
801
+ const first = applied[slotOf(i)]!.spec;
802
+ const firstStart = startOf(i);
803
+ const firstEnd = firstStart + first.oldText.length;
804
+ for (let j = laterFrom(i + 1); j < total && startOf(j) < firstEnd; j = laterFrom(j + 1)) {
805
+ const later = applied[slotOf(j)]!.spec;
806
+ if (named.has(later.index)) {
807
+ nextLater![j] = j + 1;
808
+ continue;
809
+ }
810
+ const laterStart = startOf(j);
811
+ const a = Math.min(first.index, later.index);
812
+ const b = Math.max(first.index, later.index);
813
+ if (a === b || pairs.has(`${a} ${b}`)) continue;
814
+ named.add(later.index);
815
+ pairs.add(`${a} ${b}`);
816
+ clashes.push({ edits: [a, b], at: laterStart, same: laterStart === firstStart && laterStart + later.oldText.length === firstEnd });
817
+ }
818
+ }
819
+ // Every clash's line in one pass over the text, rather than a pass each.
820
+ const clashAts = [...new Set(clashes.map((clash) => clash.at))].sort((x, y) => x - y);
821
+ const lineAt = new Map(lineNumbersAt(value, clashAts).map((line, k) => [clashAts[k]!, line]));
822
+ for (const clash of clashes) problems.push({ kind: 'clash', edits: clash.edits, line: lineAt.get(clash.at)!, same: clash.same });
823
+ if (problems.length) return { problems };
824
+
825
+ // The new bytes are at least the file's, and for each replacement its new
826
+ // text's, less the most its replaced text can take: its own bytes, in a
827
+ // file with CRLF line ends one more for each line feed, which it may hold
828
+ // as two, and matched with quotes read as equal, two more for each straight
829
+ // quote, which a curly one takes as three. That's exact for a file with no
830
+ // CRLF line end matched as given. So a file
831
+ // the edits take past maxBytes is refused before its new text, which could
832
+ // run to gigabytes, is built. One that this floor leaves within it but the
833
+ // CRLF ends its new text takes, or its quotes, take past it is built, and
834
+ // its caller refuses it by its bytes.
835
+ const quotesRead = new Set(quoteNormalized);
836
+ const straightQuotes = (text: string) => text.length - text.replace(/['"]/g, '').length;
837
+ const leastBytes = applied.reduce((size, { spec, count }) => size + count * (Buffer.byteLength(spec.newText)
838
+ - Buffer.byteLength(spec.oldText) - (crlf ? lineFeeds(spec.oldText) : 0) - (quotesRead.has(spec.index) ? 2 * straightQuotes(spec.oldText) : 0)), bytes.length);
839
+ if (leastBytes > maxBytes) return { tooLarge: leastBytes };
840
+
841
+ const endingAt = (lf: number) => (crAt(lf) ? '\r\n' : '\n');
842
+ // The line end after a replaced text that holds none: its line's own, found
843
+ // once per line as the matches ascend, or for an unterminated last line the
844
+ // file's last one. Only a file with CRLF line ends asks, so it has one.
845
+ let nextLf = -1;
846
+ let lastLf: number | undefined;
847
+ const lineEnding = (start: number) => {
848
+ if (nextLf !== Infinity && nextLf < start) {
849
+ const at = value.indexOf('\n', start);
850
+ nextLf = at === -1 ? Infinity : at;
851
+ }
852
+ return endingAt(nextLf !== Infinity ? nextLf : (lastLf ??= value.lastIndexOf('\n')));
853
+ };
854
+ // The new text's line ends take, by position, the line ends of the text it
855
+ // replaces: the k-th the k-th, any beyond them the last, so a mixed span
856
+ // keeps its mixture. A match's line ends sit where oldText's do,
857
+ // quote-normalized or not.
858
+ const shapes = new Map<EditSpec, { oldBreaks: number[]; newPieces: string[] }>();
859
+ const withEndings = (spec: EditSpec, start: number) => {
860
+ if (!crlf) return spec.newText;
861
+ let shape = shapes.get(spec);
862
+ if (!shape) {
863
+ const oldBreaks: number[] = [];
864
+ for (let at = spec.oldText.indexOf('\n'); at !== -1; at = spec.oldText.indexOf('\n', at + 1)) oldBreaks.push(at);
865
+ shapes.set(spec, (shape = { oldBreaks, newPieces: spec.newText.split('\n') }));
866
+ }
867
+ const { oldBreaks, newPieces } = shape;
868
+ if (newPieces.length === 1) return spec.newText;
869
+ const replaced = oldBreaks.length ? oldBreaks.map((k) => endingAt(start + k)) : [lineEnding(start)];
870
+ let joined = newPieces[0]!;
871
+ for (let k = 1; k < newPieces.length; k++) joined += replaced[Math.min(k - 1, replaced.length - 1)]! + newPieces[k]!;
872
+ return joined;
873
+ };
874
+ // The CRs below an index of the text, counted as the replacements ascend:
875
+ // each index asked is at or past the one before.
876
+ let crsBelow = 0;
877
+ const rawIndex = (i: number) => {
878
+ while (crsBelow < crs.length && crs[crsBelow]! < i) crsBelow++;
879
+ return i + crsBelow;
880
+ };
881
+ // What each edit's new text adds: its line feeds, and the lines a receipt
882
+ // shows it on.
883
+ const added = applied.map(({ spec }) => {
884
+ const feeds = lineFeeds(spec.newText);
885
+ return { feeds, lines: feeds + 1 - (spec.newText.endsWith('\n') ? 1 : 0) };
886
+ });
887
+ const out = textBuilder();
888
+ const updated = textBuilder();
889
+ const kept: Array<EditChanges | undefined> = applied.map(() => undefined);
890
+ // The line of each edit's last replacement after its first; lines start at 1.
891
+ const lastOther = applied.map(() => 0);
892
+ let rawFrom = 0;
893
+ let from = 0;
894
+ let length = 0;
895
+ // The updated text's line at `from`: the line feeds of the text kept before
896
+ // it, and of each new text put in, counted as the replacements ascend. The
897
+ // text's next line feed is kept as it's found, so text with none left is
898
+ // read to its end once, not once per replacement.
899
+ let line = 1;
900
+ let nextFeed = value.indexOf('\n');
901
+ const feedsBefore = (to: number) => {
902
+ let feeds = 0;
903
+ while (nextFeed !== -1 && nextFeed < to) {
904
+ feeds++;
905
+ nextFeed = value.indexOf('\n', nextFeed + 1);
906
+ }
907
+ return feeds;
908
+ };
909
+ for (let k = 0; k < total; k++) {
910
+ const slot = slotOf(k);
911
+ const { spec } = applied[slot]!;
912
+ const start = startOf(k);
913
+ out.add(raw.slice(rawFrom, rawIndex(start)));
914
+ out.add(withEndings(spec, start));
915
+ rawFrom = rawIndex(start + spec.oldText.length);
916
+ updated.add(value.slice(from, start));
917
+ updated.add(spec.newText);
918
+ line += feedsBefore(start);
919
+ length += start - from;
920
+ const edit = kept[slot];
921
+ if (!edit) {
922
+ kept[slot] = { first: { edit: spec.index, at: length, line, lines: added[slot]!.lines }, count: 1, otherLines: [], otherLineCount: 0 };
923
+ } else {
924
+ edit.count++;
925
+ // An edit's replacements ascend, so their lines never fall: a line
926
+ // already counted is the last one.
927
+ if (lastOther[slot] !== line) {
928
+ lastOther[slot] = line;
929
+ edit.otherLineCount++;
930
+ if (edit.otherLines.length < SHOWN_MATCHES) edit.otherLines.push(line);
931
+ }
932
+ }
933
+ length += spec.newText.length;
934
+ line += added[slot]!.feeds;
935
+ from = start + spec.oldText.length;
936
+ // The old text's line feeds are replaced, not kept.
937
+ feedsBefore(from);
938
+ }
939
+ out.add(raw.slice(rawFrom));
940
+ updated.add(value.slice(from));
941
+ return {
942
+ plan: {
943
+ bytes: Buffer.concat([file.bom ? bytes.subarray(0, 3) : Buffer.alloc(0), Buffer.from(out.text(), 'utf8')]),
944
+ updated: updated.text(),
945
+ edits: kept.filter((edit): edit is EditChanges => edit !== undefined).sort((a, b) => a.first.at - b.first.at),
946
+ replacements: total,
947
+ quoteNormalized,
948
+ readForm: readForms,
949
+ crlf,
950
+ mixed,
951
+ bom: file.bom,
952
+ },
953
+ };
954
+ }