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