@animalabs/agent-framework 0.23.3 → 0.24.1
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.
- package/README.md +212 -42
- package/dist/src/agent.d.ts +65 -9
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +98 -12
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +14 -12
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/clock-rows.d.ts +115 -0
- package/dist/src/clock-rows.d.ts.map +1 -0
- package/dist/src/clock-rows.js +222 -0
- package/dist/src/clock-rows.js.map +1 -0
- package/dist/src/code-execution/engine.d.ts +177 -0
- package/dist/src/code-execution/engine.d.ts.map +1 -0
- package/dist/src/code-execution/engine.js +494 -0
- package/dist/src/code-execution/engine.js.map +1 -0
- package/dist/src/code-execution/index.d.ts +5 -10
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js +5 -9
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/isolate/calls.d.ts +114 -0
- package/dist/src/code-execution/isolate/calls.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/calls.js +145 -0
- package/dist/src/code-execution/isolate/calls.js.map +1 -0
- package/dist/src/code-execution/isolate/child-entry.d.ts +2 -0
- package/dist/src/code-execution/isolate/child-entry.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child-entry.js +5 -0
- package/dist/src/code-execution/isolate/child-entry.js.map +1 -0
- package/dist/src/code-execution/isolate/child-main.d.ts +26 -0
- package/dist/src/code-execution/isolate/child-main.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child-main.js +132 -0
- package/dist/src/code-execution/isolate/child-main.js.map +1 -0
- package/dist/src/code-execution/isolate/child.d.ts +44 -0
- package/dist/src/code-execution/isolate/child.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child.js +315 -0
- package/dist/src/code-execution/isolate/child.js.map +1 -0
- package/dist/src/code-execution/isolate/frames.d.ts +80 -0
- package/dist/src/code-execution/isolate/frames.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/frames.js +210 -0
- package/dist/src/code-execution/isolate/frames.js.map +1 -0
- package/dist/src/code-execution/isolate/output.d.ts +68 -0
- package/dist/src/code-execution/isolate/output.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/output.js +143 -0
- package/dist/src/code-execution/isolate/output.js.map +1 -0
- package/dist/src/code-execution/isolate/prelude.d.ts +109 -0
- package/dist/src/code-execution/isolate/prelude.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/prelude.js +401 -0
- package/dist/src/code-execution/isolate/prelude.js.map +1 -0
- package/dist/src/code-execution/isolate/protocol.d.ts +151 -0
- package/dist/src/code-execution/isolate/protocol.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/protocol.js +150 -0
- package/dist/src/code-execution/isolate/protocol.js.map +1 -0
- package/dist/src/code-execution/isolate/render.d.ts +57 -0
- package/dist/src/code-execution/isolate/render.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/render.js +214 -0
- package/dist/src/code-execution/isolate/render.js.map +1 -0
- package/dist/src/code-execution/isolate/runner.d.ts +250 -0
- package/dist/src/code-execution/isolate/runner.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/runner.js +854 -0
- package/dist/src/code-execution/isolate/runner.js.map +1 -0
- package/dist/src/code-execution/isolate/scripts.d.ts +175 -0
- package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/scripts.js +357 -0
- package/dist/src/code-execution/isolate/scripts.js.map +1 -0
- package/dist/src/code-execution/quickjs.wasm +0 -0
- package/dist/src/code-execution/tool-definition.d.ts +93 -43
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +176 -142
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/deadline-timer.d.ts +3 -0
- package/dist/src/deadline-timer.d.ts.map +1 -1
- package/dist/src/deadline-timer.js +1 -1
- package/dist/src/deadline-timer.js.map +1 -1
- package/dist/src/framework.d.ts +1120 -457
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +4635 -2505
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +28 -4
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +89 -13
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -3
- package/dist/src/held-writes.d.ts.map +1 -1
- package/dist/src/held-writes.js +16 -3
- package/dist/src/held-writes.js.map +1 -1
- package/dist/src/index.d.ts +8 -6
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +10 -8
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +483 -115
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +1735 -425
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +19 -39
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +27 -59
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +9 -5
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/inbound-source.d.ts +50 -0
- package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
- package/dist/src/mcpl/inbound-source.js +130 -0
- package/dist/src/mcpl/inbound-source.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -2
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +6 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +8 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +15 -6
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
- package/dist/src/mcpl/modern-connection.js +168 -53
- package/dist/src/mcpl/modern-connection.js.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +4 -5
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.js +4 -5
- package/dist/src/mcpl/prose-grammar.js.map +1 -1
- package/dist/src/mcpl/protocol-family.d.ts +97 -10
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
- package/dist/src/mcpl/protocol-family.js +174 -17
- package/dist/src/mcpl/protocol-family.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +132 -14
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +227 -50
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +9 -20
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +7 -49
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +2 -2
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +4 -4
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +50 -21
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +158 -74
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tags.d.ts +24 -0
- package/dist/src/mcpl/tags.d.ts.map +1 -1
- package/dist/src/mcpl/tags.js +33 -0
- package/dist/src/mcpl/tags.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +2 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +55 -7
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +163 -34
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +22 -6
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +4 -2
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +100 -13
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +8 -13
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +27 -4
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +15 -3
- package/dist/src/modules/history/semantic.d.ts.map +1 -1
- package/dist/src/modules/history/semantic.js +27 -5
- package/dist/src/modules/history/semantic.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/unknown-tool.d.ts +4 -1
- package/dist/src/modules/unknown-tool.d.ts.map +1 -1
- package/dist/src/modules/unknown-tool.js +4 -1
- package/dist/src/modules/unknown-tool.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts +3 -1
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
- package/dist/src/modules/workspace/branch-intent.js +3 -1
- package/dist/src/modules/workspace/branch-intent.js.map +1 -1
- package/dist/src/modules/workspace/edit-plan.d.ts +265 -0
- package/dist/src/modules/workspace/edit-plan.d.ts.map +1 -0
- package/dist/src/modules/workspace/edit-plan.js +794 -0
- package/dist/src/modules/workspace/edit-plan.js.map +1 -0
- package/dist/src/modules/workspace/edit-text.d.ts +117 -0
- package/dist/src/modules/workspace/edit-text.d.ts.map +1 -0
- package/dist/src/modules/workspace/edit-text.js +584 -0
- package/dist/src/modules/workspace/edit-text.js.map +1 -0
- package/dist/src/modules/workspace/effects.d.ts +5 -4
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +78 -25
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +493 -62
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +2216 -652
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +213 -38
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +416 -36
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +15 -8
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/text.d.ts +32 -0
- package/dist/src/modules/workspace/text.d.ts.map +1 -0
- package/dist/src/modules/workspace/text.js +67 -0
- package/dist/src/modules/workspace/text.js.map +1 -0
- package/dist/src/modules/workspace/types.d.ts +210 -6
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +3 -2
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/operator-log.d.ts +52 -24
- package/dist/src/operator-log.d.ts.map +1 -1
- package/dist/src/operator-log.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts +496 -72
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.js +1196 -289
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
- package/dist/src/recovery/offline-branch.d.ts +26 -1
- package/dist/src/recovery/offline-branch.d.ts.map +1 -1
- package/dist/src/recovery/offline-branch.js +164 -39
- package/dist/src/recovery/offline-branch.js.map +1 -1
- package/dist/src/recovery/recover-cli.js +23 -4
- package/dist/src/recovery/recover-cli.js.map +1 -1
- package/dist/src/send-tools.d.ts +11 -0
- package/dist/src/send-tools.d.ts.map +1 -0
- package/dist/src/send-tools.js +11 -0
- package/dist/src/send-tools.js.map +1 -0
- package/dist/src/timezone.d.ts +4 -2
- package/dist/src/timezone.d.ts.map +1 -1
- package/dist/src/timezone.js +4 -2
- package/dist/src/timezone.js.map +1 -1
- package/dist/src/tool-image-ledger.d.ts +46 -7
- package/dist/src/tool-image-ledger.d.ts.map +1 -1
- package/dist/src/tool-image-ledger.js +60 -7
- package/dist/src/tool-image-ledger.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +14 -11
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +17 -14
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +3 -2
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +3 -2
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts +21 -0
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +142 -40
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/types/agent.d.ts +31 -6
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +66 -9
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +69 -62
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +40 -8
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +111 -10
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/src/usage/types.d.ts +16 -0
- package/dist/src/usage/types.d.ts.map +1 -1
- package/dist/src/usage/usage-tracker.d.ts +14 -1
- package/dist/src/usage/usage-tracker.d.ts.map +1 -1
- package/dist/src/usage/usage-tracker.js +81 -41
- package/dist/src/usage/usage-tracker.js.map +1 -1
- package/package.json +6 -8
- package/src/agent.ts +99 -12
- package/src/api/server.ts +14 -12
- package/src/clock-rows.ts +302 -0
- package/src/code-execution/engine.ts +595 -0
- package/src/code-execution/index.ts +5 -17
- package/src/code-execution/isolate/calls.ts +199 -0
- package/src/code-execution/isolate/child-entry.ts +5 -0
- package/src/code-execution/isolate/child-main.ts +132 -0
- package/src/code-execution/isolate/child.ts +327 -0
- package/src/code-execution/isolate/frames.ts +209 -0
- package/src/code-execution/isolate/output.ts +161 -0
- package/src/code-execution/isolate/prelude.ts +409 -0
- package/src/code-execution/isolate/protocol.ts +240 -0
- package/src/code-execution/isolate/render.ts +230 -0
- package/src/code-execution/isolate/runner.ts +911 -0
- package/src/code-execution/isolate/scripts.ts +479 -0
- package/src/code-execution/quickjs.wasm +0 -0
- package/src/code-execution/quickjs.wasm.LICENSES +149 -0
- package/src/code-execution/tool-definition.ts +236 -167
- package/src/deadline-timer.ts +1 -1
- package/src/framework.ts +4978 -2671
- package/src/gate/event-gate.ts +79 -17
- package/src/held-writes.ts +17 -3
- package/src/index.ts +32 -22
- package/src/mcpl/channel-registry.ts +1892 -471
- package/src/mcpl/conversation-router.ts +26 -72
- package/src/mcpl/convert-block.ts +9 -5
- package/src/mcpl/inbound-source.ts +131 -0
- package/src/mcpl/index.ts +4 -2
- package/src/mcpl/log-value.ts +9 -0
- package/src/mcpl/modern-connection.ts +162 -53
- package/src/mcpl/prose-grammar.ts +4 -5
- package/src/mcpl/protocol-family.ts +200 -22
- package/src/mcpl/push-coalescer.ts +306 -62
- package/src/mcpl/push-handler.ts +12 -54
- package/src/mcpl/reference-fetcher.ts +4 -4
- package/src/mcpl/server-connection.ts +180 -79
- package/src/mcpl/tags.ts +36 -0
- package/src/mcpl/tool-result-normalize.ts +2 -1
- package/src/mcpl/transport.ts +162 -33
- package/src/mcpl/types.ts +23 -6
- package/src/module-registry.ts +92 -13
- package/src/modules/discord/index.ts +8 -13
- package/src/modules/history/index.ts +25 -3
- package/src/modules/history/semantic.ts +29 -6
- package/src/modules/index.ts +1 -0
- package/src/modules/unknown-tool.ts +4 -1
- package/src/modules/workspace/branch-intent.ts +3 -1
- package/src/modules/workspace/edit-plan.ts +954 -0
- package/src/modules/workspace/edit-text.ts +618 -0
- package/src/modules/workspace/effects.ts +70 -19
- package/src/modules/workspace/index.ts +2293 -650
- package/src/modules/workspace/observe.ts +465 -56
- package/src/modules/workspace/reconcile.ts +14 -7
- package/src/modules/workspace/text.ts +68 -0
- package/src/modules/workspace/types.ts +211 -6
- package/src/modules/workspace/watcher.ts +3 -2
- package/src/operator-log.ts +47 -19
- package/src/recovery/discord-awareness-outbox.ts +1500 -356
- package/src/recovery/offline-branch.ts +189 -55
- package/src/recovery/recover-cli.ts +25 -4
- package/src/send-tools.ts +11 -0
- package/src/timezone.ts +4 -2
- package/src/tool-image-ledger.ts +62 -8
- package/src/tool-result-guard.ts +18 -15
- package/src/tool-result-history.ts +3 -2
- package/src/tune-out/coordinator.ts +166 -45
- package/src/types/agent.ts +32 -6
- package/src/types/events.ts +66 -9
- package/src/types/framework.ts +69 -62
- package/src/types/module.ts +40 -8
- package/src/types/trace.ts +121 -15
- package/src/usage/types.ts +16 -0
- package/src/usage/usage-tracker.ts +86 -43
- package/dist/src/code-execution/py-runner.d.ts +0 -191
- package/dist/src/code-execution/py-runner.d.ts.map +0 -1
- package/dist/src/code-execution/py-runner.js +0 -655
- package/dist/src/code-execution/py-runner.js.map +0 -1
- package/dist/src/code-execution/runtime-py.d.ts +0 -50
- package/dist/src/code-execution/runtime-py.d.ts.map +0 -1
- package/dist/src/code-execution/runtime-py.js +0 -677
- package/dist/src/code-execution/runtime-py.js.map +0 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +0 -88
- package/dist/src/mcpl/prose-stream-router.d.ts.map +0 -1
- package/dist/src/mcpl/prose-stream-router.js +0 -291
- package/dist/src/mcpl/prose-stream-router.js.map +0 -1
- package/src/code-execution/py-runner.ts +0 -791
- package/src/code-execution/runtime-py.ts +0 -677
- 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
|
+
}
|