@herbertgao/pi-extensions 2026.8.3 → 2026.8.5
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 +45 -4
- package/examples/pi-footer.json +275 -0
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +10 -2
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +10 -2
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +24 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +91 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +7 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +7 -6
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +21 -12
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +4 -5
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +5 -8
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +22 -12
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +2 -4
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +8 -4
- package/node_modules/@herbertgao/pi-subagents/src/agent-color.ts +72 -67
- package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +255 -0
- package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +191 -3
- package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +15 -2
- package/node_modules/@herbertgao/pi-subagents/src/index.ts +289 -113
- package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +24 -1
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +24 -2
- package/node_modules/@herbertgao/pi-subagents/src/ui/schedule-menu.ts +9 -8
- package/node_modules/@herbertgao/pi-subagents/src/ui/select-item.ts +48 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +7 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +7 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +16 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +4 -2
- package/node_modules/@narumitw/pi-btw/README.md +8 -7
- package/node_modules/@narumitw/pi-btw/package.json +2 -2
- package/node_modules/@narumitw/pi-btw/src/menu.ts +1 -0
- package/node_modules/pi-footer/CHANGELOG.md +145 -0
- package/node_modules/pi-footer/LICENSE +21 -0
- package/node_modules/pi-footer/README.md +354 -0
- package/node_modules/pi-footer/package.json +68 -0
- package/node_modules/pi-footer/src/cache.ts +106 -0
- package/node_modules/pi-footer/src/colors.ts +237 -0
- package/node_modules/pi-footer/src/config.ts +202 -0
- package/node_modules/pi-footer/src/event-widgets.ts +41 -0
- package/node_modules/pi-footer/src/extension-statuses.ts +102 -0
- package/node_modules/pi-footer/src/git.ts +135 -0
- package/node_modules/pi-footer/src/index.ts +268 -0
- package/node_modules/pi-footer/src/metrics.ts +141 -0
- package/node_modules/pi-footer/src/presets.ts +383 -0
- package/node_modules/pi-footer/src/render.ts +114 -0
- package/node_modules/pi-footer/src/separators.ts +52 -0
- package/node_modules/pi-footer/src/types.ts +122 -0
- package/node_modules/pi-footer/src/ui/color-level-confirm.ts +22 -0
- package/node_modules/pi-footer/src/ui/color-options.ts +195 -0
- package/node_modules/pi-footer/src/ui/config-lifecycle.ts +61 -0
- package/node_modules/pi-footer/src/ui/edit-colors.ts +13 -0
- package/node_modules/pi-footer/src/ui/events.ts +35 -0
- package/node_modules/pi-footer/src/ui/extension-status-picker.ts +60 -0
- package/node_modules/pi-footer/src/ui/extension-statuses.ts +73 -0
- package/node_modules/pi-footer/src/ui/fields.ts +252 -0
- package/node_modules/pi-footer/src/ui/global-menu.ts +126 -0
- package/node_modules/pi-footer/src/ui/helpers.ts +44 -0
- package/node_modules/pi-footer/src/ui/layout.ts +17 -0
- package/node_modules/pi-footer/src/ui/line-list.ts +19 -0
- package/node_modules/pi-footer/src/ui/model.ts +64 -0
- package/node_modules/pi-footer/src/ui/navigation.ts +29 -0
- package/node_modules/pi-footer/src/ui/option-edit.ts +93 -0
- package/node_modules/pi-footer/src/ui/overlay-render.ts +103 -0
- package/node_modules/pi-footer/src/ui/screen-context.ts +18 -0
- package/node_modules/pi-footer/src/ui/screen-controller.ts +27 -0
- package/node_modules/pi-footer/src/ui/screen-render.ts +32 -0
- package/node_modules/pi-footer/src/ui/screen-state.ts +20 -0
- package/node_modules/pi-footer/src/ui/screen.ts +243 -0
- package/node_modules/pi-footer/src/ui/screens/add-widget.ts +92 -0
- package/node_modules/pi-footer/src/ui/screens/confirm-exit.ts +52 -0
- package/node_modules/pi-footer/src/ui/screens/controller.ts +12 -0
- package/node_modules/pi-footer/src/ui/screens/edit-colors.ts +68 -0
- package/node_modules/pi-footer/src/ui/screens/edit-widget.ts +115 -0
- package/node_modules/pi-footer/src/ui/screens/extension-status-row.ts +61 -0
- package/node_modules/pi-footer/src/ui/screens/global.ts +75 -0
- package/node_modules/pi-footer/src/ui/screens/line-list.ts +108 -0
- package/node_modules/pi-footer/src/ui/screens/main.ts +99 -0
- package/node_modules/pi-footer/src/ui/screens/terminal.ts +121 -0
- package/node_modules/pi-footer/src/ui/screens/widget-list.ts +154 -0
- package/node_modules/pi-footer/src/ui/terminal-menu.ts +32 -0
- package/node_modules/pi-footer/src/ui/theme.ts +48 -0
- package/node_modules/pi-footer/src/ui/title-bar.ts +100 -0
- package/node_modules/pi-footer/src/ui/widget-actions.ts +76 -0
- package/node_modules/pi-footer/src/ui.ts +66 -0
- package/node_modules/pi-footer/src/widgets/context.ts +28 -0
- package/node_modules/pi-footer/src/widgets/core/active-tools.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/context-window.ts +24 -0
- package/node_modules/pi-footer/src/widgets/core/cwd-basename.ts +19 -0
- package/node_modules/pi-footer/src/widgets/core/cwd.ts +108 -0
- package/node_modules/pi-footer/src/widgets/core/event.ts +34 -0
- package/node_modules/pi-footer/src/widgets/core/external-status.ts +102 -0
- package/node_modules/pi-footer/src/widgets/core/model-provider.ts +20 -0
- package/node_modules/pi-footer/src/widgets/core/model.ts +35 -0
- package/node_modules/pi-footer/src/widgets/core/provider.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/session-name.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/text-verbosity.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/thinking-level.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/ahead-behind.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/branch.ts +81 -0
- package/node_modules/pi-footer/src/widgets/git/clean.ts +19 -0
- package/node_modules/pi-footer/src/widgets/git/deletions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/diff.ts +38 -0
- package/node_modules/pi-footer/src/widgets/git/insertions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/remote.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/root.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/sha.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/staged.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/status.ts +19 -0
- package/node_modules/pi-footer/src/widgets/git/unstaged.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/untracked.ts +17 -0
- package/node_modules/pi-footer/src/widgets/instance.ts +118 -0
- package/node_modules/pi-footer/src/widgets/layout/custom-text.ts +18 -0
- package/node_modules/pi-footer/src/widgets/layout/flex-separator.ts +32 -0
- package/node_modules/pi-footer/src/widgets/layout/separator.ts +57 -0
- package/node_modules/pi-footer/src/widgets/layout/spacer.ts +31 -0
- package/node_modules/pi-footer/src/widgets/options.ts +122 -0
- package/node_modules/pi-footer/src/widgets/project/runtime.ts +361 -0
- package/node_modules/pi-footer/src/widgets/registry.ts +223 -0
- package/node_modules/pi-footer/src/widgets/session/assistant-messages.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/compactions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/elapsed.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/last-activity.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/messages.ts +19 -0
- package/node_modules/pi-footer/src/widgets/session/session-id.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/session-start.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/tool-results.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/total-messages.ts +19 -0
- package/node_modules/pi-footer/src/widgets/session/total-time.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/user-messages.ts +17 -0
- package/node_modules/pi-footer/src/widgets/store.ts +26 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-hit-rate.ts +55 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-read.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-write.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-bar.ts +108 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-length.ts +26 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-remaining.ts +29 -0
- package/node_modules/pi-footer/src/widgets/tokens/context.ts +29 -0
- package/node_modules/pi-footer/src/widgets/tokens/cost.ts +51 -0
- package/node_modules/pi-footer/src/widgets/tokens/input-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/input-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/output-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/output-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/tokens.ts +20 -0
- package/node_modules/pi-footer/src/widgets/tokens/total-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/total-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/types.ts +181 -0
- package/node_modules/pi-footer/src/widgets/utils/colors.ts +11 -0
- package/node_modules/pi-footer/src/widgets/utils/context.ts +112 -0
- package/node_modules/pi-footer/src/widgets/utils/session.ts +19 -0
- package/node_modules/pi-footer/src/widgets/utils/token-format.ts +78 -0
- package/node_modules/pi-lens/CHANGELOG.md +2440 -954
- package/node_modules/pi-lens/README.md +13 -0
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +73 -18
- package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +68 -16
- package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -1
- package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +37 -10
- package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +63 -0
- package/node_modules/pi-lens/dist/clients/atomic-write.js +114 -14
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +193 -23
- package/node_modules/pi-lens/dist/clients/biome-client.js +29 -25
- package/node_modules/pi-lens/dist/clients/bootstrap.js +11 -2
- package/node_modules/pi-lens/dist/clients/bounded-cache.js +34 -0
- package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +192 -0
- package/node_modules/pi-lens/dist/clients/bus-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +47 -5
- package/node_modules/pi-lens/dist/clients/cache-manager.js +97 -9
- package/node_modules/pi-lens/dist/clients/cache-observability.js +301 -14
- package/node_modules/pi-lens/dist/clients/call-graph.js +449 -117
- package/node_modules/pi-lens/dist/clients/child-unref.js +85 -0
- package/node_modules/pi-lens/dist/clients/codebase-model.js +52 -23
- package/node_modules/pi-lens/dist/clients/collateral-test-role.js +40 -0
- package/node_modules/pi-lens/dist/clients/complexity-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/console-guard-install.js +14 -0
- package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/debug-handles.js +206 -0
- package/node_modules/pi-lens/dist/clients/debug-heap.js +167 -0
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +153 -0
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +347 -75
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +61 -11
- package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +63 -20
- package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +23 -7
- package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +8 -18
- package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +8 -5
- package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +35 -0
- package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +17 -8
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +1085 -681
- package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +14 -0
- package/node_modules/pi-lens/dist/clients/dispatch/plan.js +11 -1
- package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +50 -0
- package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +135 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +23 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +5 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +3 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +154 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +3 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +5 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +14 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -9
- package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +9 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +4 -8
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +163 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +37 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +3 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +13 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +277 -26
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
- package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
- package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
- package/node_modules/pi-lens/dist/clients/extension-log.js +139 -0
- package/node_modules/pi-lens/dist/clients/extension-mode.js +73 -0
- package/node_modules/pi-lens/dist/clients/file-kinds.js +57 -0
- package/node_modules/pi-lens/dist/clients/file-role.js +18 -3
- package/node_modules/pi-lens/dist/clients/file-utils.js +56 -34
- package/node_modules/pi-lens/dist/clients/format-events-publish.js +16 -3
- package/node_modules/pi-lens/dist/clients/format-service.js +24 -8
- package/node_modules/pi-lens/dist/clients/formatters-lazy.js +8 -0
- package/node_modules/pi-lens/dist/clients/formatters.js +358 -61
- package/node_modules/pi-lens/dist/clients/generated-artifacts.js +148 -15
- package/node_modules/pi-lens/dist/clients/git-guard.js +681 -11
- package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
- package/node_modules/pi-lens/dist/clients/go-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/govulncheck-client.js +8 -0
- package/node_modules/pi-lens/dist/clients/grammar-source.js +12 -4
- package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +21 -3
- package/node_modules/pi-lens/dist/clients/host-ports.js +31 -0
- package/node_modules/pi-lens/dist/clients/installer/index.js +390 -99
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +31 -54
- package/node_modules/pi-lens/dist/clients/knip-client.js +15 -36
- package/node_modules/pi-lens/dist/clients/language-policy.js +14 -2
- package/node_modules/pi-lens/dist/clients/language-profile.js +13 -7
- package/node_modules/pi-lens/dist/clients/latency-logger.js +23 -1
- package/node_modules/pi-lens/dist/clients/lens-config.js +12 -1
- package/node_modules/pi-lens/dist/clients/lens-engine.js +86 -17
- package/node_modules/pi-lens/dist/clients/lens-events.js +27 -4
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
- package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
- package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +30 -0
- package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/client.js +493 -48
- package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +86 -0
- package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
- package/node_modules/pi-lens/dist/clients/lsp/index.js +1807 -780
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +32 -26
- package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +83 -0
- package/node_modules/pi-lens/dist/clients/lsp/server.js +196 -32
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
- package/node_modules/pi-lens/dist/clients/lsp-lazy.js +8 -0
- package/node_modules/pi-lens/dist/clients/lsp-mutation.js +248 -0
- package/node_modules/pi-lens/dist/clients/mcp/analyze.js +123 -32
- package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
- package/node_modules/pi-lens/dist/clients/mcp/session.js +216 -8
- package/node_modules/pi-lens/dist/clients/memory-sampler.js +115 -0
- package/node_modules/pi-lens/dist/clients/metrics-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/metrics-history.js +2 -1
- package/node_modules/pi-lens/dist/clients/module-report-lsp.js +5 -0
- package/node_modules/pi-lens/dist/clients/module-report.js +168 -3
- package/node_modules/pi-lens/dist/clients/ndjson-logger.js +319 -118
- package/node_modules/pi-lens/dist/clients/opengrep-client.js +1 -1
- package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +120 -5
- package/node_modules/pi-lens/dist/clients/path-utils.js +110 -9
- package/node_modules/pi-lens/dist/clients/pipeline.js +47 -13
- package/node_modules/pi-lens/dist/clients/print-mode.js +21 -0
- package/node_modules/pi-lens/dist/clients/project-changes.js +40 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +3 -2
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +8 -0
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +21 -1
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +86 -9
- package/node_modules/pi-lens/dist/clients/project-report.js +27 -13
- package/node_modules/pi-lens/dist/clients/project-snapshot.js +175 -20
- package/node_modules/pi-lens/dist/clients/project-trust.js +181 -0
- package/node_modules/pi-lens/dist/clients/quiet-window.js +18 -4
- package/node_modules/pi-lens/dist/clients/read-bridge.js +147 -0
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +159 -1
- package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +150 -29
- package/node_modules/pi-lens/dist/clients/read-guard.js +185 -4
- package/node_modules/pi-lens/dist/clients/resource-sampler.js +70 -84
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +1122 -221
- package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +2 -1
- package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +21 -10
- package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +46 -0
- package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +57 -6
- package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +51 -3
- package/node_modules/pi-lens/dist/clients/review-graph-logger.js +30 -1
- package/node_modules/pi-lens/dist/clients/ruff-client.js +27 -44
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +107 -73
- package/node_modules/pi-lens/dist/clients/runtime-context.js +36 -12
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +74 -6
- package/node_modules/pi-lens/dist/clients/runtime-session.js +405 -117
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +71 -48
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +218 -23
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +237 -64
- package/node_modules/pi-lens/dist/clients/rust-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +2 -1
- package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
- package/node_modules/pi-lens/dist/clients/sg-runner.js +284 -174
- package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
- package/node_modules/pi-lens/dist/clients/smells-rollup.js +224 -0
- package/node_modules/pi-lens/dist/clients/source-filter.js +184 -20
- package/node_modules/pi-lens/dist/clients/source-walker.js +66 -10
- package/node_modules/pi-lens/dist/clients/startup-scan.js +15 -16
- package/node_modules/pi-lens/dist/clients/startup-timing.js +1 -1
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +38 -8
- package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
- package/node_modules/pi-lens/dist/clients/tool-policy.js +388 -39
- package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
- package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +6 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +419 -50
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +21 -0
- package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +7 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +7 -2
- package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +44 -7
- package/node_modules/pi-lens/dist/clients/user-notify.js +51 -0
- package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +57 -0
- package/node_modules/pi-lens/dist/clients/warm-attach.js +33 -13
- package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
- package/node_modules/pi-lens/dist/clients/word-index.js +391 -64
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
- package/node_modules/pi-lens/dist/index.js +49361 -36888
- package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
- package/node_modules/pi-lens/dist/mcp/server.js +211 -44
- package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +200 -36
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +153 -40
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -3
- package/node_modules/pi-lens/dist/tools/module-report.js +9 -0
- package/node_modules/pi-lens/docs/agent-guide.md +2 -2
- package/node_modules/pi-lens/docs/agent-tools.md +21 -10
- package/node_modules/pi-lens/docs/analysisall.md +192 -0
- package/node_modules/pi-lens/docs/api-ports-inventory.md +165 -0
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +5 -2
- package/node_modules/pi-lens/docs/durable-store-audit-1202.md +32 -0
- package/node_modules/pi-lens/docs/environment-variables.md +44 -0
- package/node_modules/pi-lens/docs/fable.md +27 -0
- package/node_modules/pi-lens/docs/features.md +31 -2
- package/node_modules/pi-lens/docs/globalconfig.md +45 -1
- package/node_modules/pi-lens/docs/language-coverage.md +4 -3
- package/node_modules/pi-lens/docs/lsp-capability-matrix.md +2 -2
- package/node_modules/pi-lens/docs/mcp.md +37 -4
- package/node_modules/pi-lens/docs/servercapabilities.md +3 -3
- package/node_modules/pi-lens/docs/settings.md +12 -1
- package/node_modules/pi-lens/docs/tools.md +4 -1
- package/node_modules/pi-lens/docs/usage.md +18 -0
- package/node_modules/pi-lens/package.json +10 -10
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
- package/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
- package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +2 -2
- package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +55 -2
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +25 -0
- package/node_modules/pi-mcp-adapter/README.md +34 -3
- package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
- package/node_modules/pi-mcp-adapter/commands.ts +15 -7
- package/node_modules/pi-mcp-adapter/direct-tools.ts +54 -7
- package/node_modules/pi-mcp-adapter/host-html-template.ts +0 -8
- package/node_modules/pi-mcp-adapter/index.ts +8 -3
- package/node_modules/pi-mcp-adapter/init.ts +29 -5
- package/node_modules/pi-mcp-adapter/lifecycle.ts +0 -4
- package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
- package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
- package/node_modules/pi-mcp-adapter/mcp-panel.ts +45 -9
- package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +22 -20
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +27 -4
- package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
- package/node_modules/pi-mcp-adapter/package.json +3 -3
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
- package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
- package/node_modules/pi-mcp-adapter/server-manager.ts +36 -7
- package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
- package/node_modules/pi-mcp-adapter/tool-approval.ts +48 -7
- package/node_modules/pi-mcp-adapter/tool-metadata.ts +42 -4
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
- package/node_modules/pi-mcp-adapter/types.ts +93 -27
- package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
- package/package.json +17 -13
- package/node_modules/pi-lens/dist/clients/ast-grep-parser.js +0 -86
- package/node_modules/pi-lens/dist/clients/lsp/interactive-install.js +0 -367
- package/node_modules/pi-lens/dist/clients/lsp/lsp-index.js +0 -10
- package/node_modules/pi-lens/dist/clients/lsp/server-strategies.js +0 -5
- package/node_modules/pi-lens/dist/clients/project-metadata.js +0 -690
- package/node_modules/pi-lens/dist/clients/source-groups.js +0 -96
- package/node_modules/pi-lens/dist/clients/startup-marker.js +0 -6
- package/node_modules/pi-lens/dist/clients/types.js +0 -11
|
@@ -1,108 +1,378 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { URL } from "node:url";
|
|
4
|
+
import { isUnderDir, normalizeEphemeralMapKey, normalizeMapKey, pathsEqual, uriToDiskPath, uriToPath, } from "./path-utils.js";
|
|
5
|
+
import { convertCharacterOffset, lineTextAt, } from "./position-encoding.js";
|
|
6
|
+
import { recordLspMutation, } from "../lsp-mutation.js";
|
|
7
|
+
import { detectLineEnding, normalizeToLF, restoreLineEndings, } from "../host-edit-normalize.js";
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
4
11
|
function isPosition(value) {
|
|
5
|
-
return (
|
|
6
|
-
value
|
|
7
|
-
|
|
8
|
-
|
|
12
|
+
return (isRecord(value) &&
|
|
13
|
+
Number.isFinite(value.line) &&
|
|
14
|
+
Number.isInteger(value.line) &&
|
|
15
|
+
value.line >= 0 &&
|
|
16
|
+
Number.isFinite(value.character) &&
|
|
17
|
+
Number.isInteger(value.character) &&
|
|
18
|
+
value.character >= 0);
|
|
19
|
+
}
|
|
20
|
+
function comparePosition(a, b) {
|
|
21
|
+
return a.line === b.line ? a.character - b.character : a.line - b.line;
|
|
9
22
|
}
|
|
10
23
|
function isRange(value) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
24
|
+
if (!isRecord(value) || !isPosition(value.start) || !isPosition(value.end)) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return comparePosition(value.start, value.end) <= 0;
|
|
15
28
|
}
|
|
16
29
|
function isTextEdit(value) {
|
|
17
|
-
return (typeof value === "
|
|
18
|
-
value !== null &&
|
|
19
|
-
isRange(value.range) &&
|
|
20
|
-
typeof value.newText === "string");
|
|
21
|
-
}
|
|
22
|
-
function isTextDocumentEdit(value) {
|
|
23
|
-
return (typeof value === "object" &&
|
|
24
|
-
value !== null &&
|
|
25
|
-
typeof value.textDocument?.uri ===
|
|
26
|
-
"string" &&
|
|
27
|
-
Array.isArray(value.edits));
|
|
30
|
+
return isRecord(value) && isRange(value.range) && typeof value.newText === "string";
|
|
28
31
|
}
|
|
29
|
-
function
|
|
30
|
-
|
|
32
|
+
function parseTextEdits(value, context) {
|
|
33
|
+
if (!Array.isArray(value))
|
|
34
|
+
throw new Error(`${context}.edits must be an array`);
|
|
35
|
+
if (!value.every(isTextEdit))
|
|
36
|
+
throw new Error(`malformed text edit in ${context}`);
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
function parseVersion(value, context) {
|
|
40
|
+
if (value === undefined || value === null)
|
|
41
|
+
return value;
|
|
42
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
43
|
+
throw new Error(`${context}.version must be a nonnegative integer or null`);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function parseResourceOptions(value, kind) {
|
|
48
|
+
if (value === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
if (!isRecord(value))
|
|
51
|
+
throw new Error(`${kind}.options must be an object`);
|
|
52
|
+
const allowed = new Set(kind === "delete"
|
|
53
|
+
? ["ignoreIfNotExists", "recursive"]
|
|
54
|
+
: ["overwrite", "ignoreIfExists"]);
|
|
55
|
+
for (const key of Object.keys(value)) {
|
|
56
|
+
if (!allowed.has(key) || typeof value[key] !== "boolean") {
|
|
57
|
+
throw new Error(`invalid ${kind}.options.${key}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (value.overwrite === true && value.ignoreIfExists === true) {
|
|
61
|
+
throw new Error(`${kind} cannot set both overwrite and ignoreIfExists`);
|
|
62
|
+
}
|
|
63
|
+
if (kind !== "delete" && value.ignoreIfNotExists !== undefined) {
|
|
64
|
+
throw new Error(`${kind} does not support ignoreIfNotExists`);
|
|
65
|
+
}
|
|
66
|
+
if (kind === "delete" && value.overwrite !== undefined) {
|
|
67
|
+
throw new Error(`delete does not support overwrite`);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
function parseTextDocumentEdit(value, context) {
|
|
72
|
+
if (!isRecord(value) || !isRecord(value.textDocument))
|
|
73
|
+
return null;
|
|
74
|
+
if (typeof value.textDocument.uri !== "string") {
|
|
75
|
+
throw new Error(`${context}.textDocument.uri must be a string`);
|
|
76
|
+
}
|
|
77
|
+
const version = parseVersion(value.textDocument.version, `${context}.textDocument`);
|
|
78
|
+
return {
|
|
79
|
+
textDocument: { uri: value.textDocument.uri, version },
|
|
80
|
+
edits: parseTextEdits(value.edits, context),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function parseWorkspaceEdit(edit) {
|
|
84
|
+
if (!isRecord(edit))
|
|
85
|
+
throw new Error("workspace edit must be an object");
|
|
86
|
+
if (edit.changes !== undefined) {
|
|
87
|
+
if (!isRecord(edit.changes))
|
|
88
|
+
throw new Error("workspace edit changes must be an object");
|
|
89
|
+
for (const [uri, edits] of Object.entries(edit.changes)) {
|
|
90
|
+
if (typeof uri !== "string")
|
|
91
|
+
throw new Error("workspace edit URI must be a string");
|
|
92
|
+
parseTextEdits(edits, `changes[${uri}]`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (edit.documentChanges !== undefined && !Array.isArray(edit.documentChanges)) {
|
|
96
|
+
throw new Error("workspace edit documentChanges must be an array");
|
|
97
|
+
}
|
|
98
|
+
for (const [index, change] of (edit.documentChanges ?? []).entries()) {
|
|
99
|
+
const text = parseTextDocumentEdit(change, `documentChanges[${index}]`);
|
|
100
|
+
if (text)
|
|
101
|
+
continue;
|
|
102
|
+
if (!isRecord(change) || typeof change.kind !== "string") {
|
|
103
|
+
throw new Error(`malformed documentChanges[${index}]`);
|
|
104
|
+
}
|
|
105
|
+
switch (change.kind) {
|
|
106
|
+
case "create":
|
|
107
|
+
if (typeof change.uri !== "string")
|
|
108
|
+
throw new Error("create.uri must be a string");
|
|
109
|
+
parseResourceOptions(change.options, "create");
|
|
110
|
+
break;
|
|
111
|
+
case "rename":
|
|
112
|
+
if (typeof change.oldUri !== "string" || typeof change.newUri !== "string") {
|
|
113
|
+
throw new Error("rename requires oldUri and newUri strings");
|
|
114
|
+
}
|
|
115
|
+
parseResourceOptions(change.options, "rename");
|
|
116
|
+
break;
|
|
117
|
+
case "delete":
|
|
118
|
+
if (typeof change.uri !== "string")
|
|
119
|
+
throw new Error("delete.uri must be a string");
|
|
120
|
+
parseResourceOptions(change.options, "delete");
|
|
121
|
+
break;
|
|
122
|
+
default:
|
|
123
|
+
throw new Error(`unsupported workspace resource operation: ${change.kind}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
31
126
|
}
|
|
32
127
|
function formatRange(range) {
|
|
33
128
|
return `${range.start.line + 1}:${range.start.character + 1}-${range.end.line + 1}:${range.end.character + 1}`;
|
|
34
129
|
}
|
|
35
130
|
export function rangesOverlap(a, b) {
|
|
36
|
-
return
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
131
|
+
return comparePosition(a.start, b.end) < 0 && comparePosition(b.start, a.end) < 0;
|
|
132
|
+
}
|
|
133
|
+
function positionsEqual(a, b) {
|
|
134
|
+
return a.line === b.line && a.character === b.character;
|
|
135
|
+
}
|
|
136
|
+
function isEmptyRange(range) {
|
|
137
|
+
return positionsEqual(range.start, range.end);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Order edits for reverse-application to a string: latest position first, so an
|
|
141
|
+
* earlier splice never shifts a not-yet-applied edit's offsets. Ties break to
|
|
142
|
+
* match LSP insertion semantics:
|
|
143
|
+
* - same start, larger END first → a replace anchored at position P applies
|
|
144
|
+
* before a zero-width insert at P, so the insert lands at the (post-replace)
|
|
145
|
+
* boundary instead of being clobbered — and the result no longer depends on
|
|
146
|
+
* listing order (P3-1, insert-at-replace-boundary);
|
|
147
|
+
* - same start AND end (e.g. several zero-width inserts at one point) → later
|
|
148
|
+
* ARRAY index first, so applying in this reverse order reproduces the edits'
|
|
149
|
+
* original array order (the #1066 same-position-insert invariant).
|
|
150
|
+
*
|
|
151
|
+
* This ordering is NOT idempotent for the same-range tie (re-sorting flips the
|
|
152
|
+
* array index), so it MUST be applied exactly once, at the string-write site,
|
|
153
|
+
* over array-order input. `normalizeTextEditsForContent` deliberately returns
|
|
154
|
+
* edits in array order (never pre-sorted) so this single sort is the only pass.
|
|
155
|
+
*/
|
|
156
|
+
function sortEditsForApplication(edits) {
|
|
157
|
+
return edits
|
|
158
|
+
.map((edit, index) => ({ edit, index }))
|
|
159
|
+
.sort((a, b) => {
|
|
160
|
+
const startDelta = comparePosition(b.edit.range.start, a.edit.range.start);
|
|
161
|
+
if (startDelta !== 0)
|
|
162
|
+
return startDelta;
|
|
163
|
+
const endDelta = comparePosition(b.edit.range.end, a.edit.range.end);
|
|
164
|
+
if (endDelta !== 0)
|
|
165
|
+
return endDelta;
|
|
166
|
+
return b.index - a.index;
|
|
167
|
+
})
|
|
168
|
+
.map(({ edit }) => edit);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Deduplicate exact non-empty duplicate edits and reject genuinely overlapping
|
|
172
|
+
* ranges, returning the survivors in their ORIGINAL ARRAY ORDER. Zero-width
|
|
173
|
+
* inserts are never deduplicated (their multiplicity is meaningful). Overlap is
|
|
174
|
+
* checked on a position-sorted copy so the check is order-independent, but the
|
|
175
|
+
* returned order is preserved for the single downstream application sort.
|
|
176
|
+
*/
|
|
177
|
+
function validateTextEdits(edits) {
|
|
178
|
+
const unique = [];
|
|
179
|
+
const seen = new Set();
|
|
180
|
+
for (const edit of edits) {
|
|
181
|
+
if (!isEmptyRange(edit.range)) {
|
|
182
|
+
const key = textEditKey("", edit);
|
|
183
|
+
if (seen.has(key))
|
|
184
|
+
continue;
|
|
185
|
+
seen.add(key);
|
|
186
|
+
}
|
|
187
|
+
unique.push(edit);
|
|
188
|
+
}
|
|
189
|
+
const ordered = sortEditsForApplication(unique);
|
|
190
|
+
for (let index = 0; index < ordered.length - 1; index++) {
|
|
191
|
+
const later = ordered[index]?.range;
|
|
192
|
+
const earlier = ordered[index + 1]?.range;
|
|
48
193
|
if (later && earlier && comparePosition(earlier.end, later.start) > 0) {
|
|
49
194
|
throw new Error(`overlapping LSP edits: ${formatRange(earlier)} conflicts with ${formatRange(later)}`);
|
|
50
195
|
}
|
|
51
196
|
}
|
|
197
|
+
return unique;
|
|
198
|
+
}
|
|
199
|
+
function utf16Position(content, position, encoding) {
|
|
200
|
+
const line = lineTextAt(content, position.line);
|
|
201
|
+
const wireLength = convertCharacterOffset(encoding, line, line.length);
|
|
202
|
+
// `lineTextAt` splits on `\n` only and keeps a trailing `\r`, so the "real"
|
|
203
|
+
// (spec) end of line content is BEFORE that `\r`, not after it. Clamp to
|
|
204
|
+
// that boundary rather than `line.length`/`wireLength`, which include the
|
|
205
|
+
// `\r`. `\r` is exactly one code unit in every encoding, so the clamped
|
|
206
|
+
// wire length is always `wireLength - 1` when a `\r` is present.
|
|
207
|
+
const hasTrailingCR = line.endsWith("\r");
|
|
208
|
+
const clampedLength = hasTrailingCR ? line.length - 1 : line.length;
|
|
209
|
+
const clampedWireLength = hasTrailingCR ? wireLength - 1 : wireLength;
|
|
210
|
+
if (position.character > clampedWireLength) {
|
|
211
|
+
// LSP 3.17: a character past the end of the line defaults to the line
|
|
212
|
+
// length. Clamp to the UTF-16 line length rather than throwing (whole-line
|
|
213
|
+
// and whole-document sentinel ranges rely on this) — landing the position
|
|
214
|
+
// at the CRLF boundary, not between `\r` and `\n`. Otherwise the whole-line
|
|
215
|
+
// sentinel replace `(0,0)-(0,999)` would eat the `\r` and a char-past-EOL
|
|
216
|
+
// insert would land mid-CRLF (P2-1 corruption on Windows repos).
|
|
217
|
+
//
|
|
218
|
+
// This also covers a caller-supplied position that is NOT past the line's
|
|
219
|
+
// full (with-`\r`) length but still lands strictly between `\r` and `\n`
|
|
220
|
+
// (i.e. `position.character === wireLength` when a `\r` is present): that
|
|
221
|
+
// position is `> clampedWireLength` too, since `clampedWireLength ===
|
|
222
|
+
// wireLength - 1`, so it clamps to the same CRLF-safe boundary instead of
|
|
223
|
+
// slipping through as an "in-bounds" position (#1147 P3-5, general class
|
|
224
|
+
// beyond #1120's strict past-EOL clamp).
|
|
225
|
+
return { line: position.line, character: clampedLength };
|
|
226
|
+
}
|
|
227
|
+
if (encoding === "utf-16")
|
|
228
|
+
return position;
|
|
229
|
+
// Find the UTF-16 offset whose encoded prefix has exactly this length. This
|
|
230
|
+
// rejects offsets in the middle of a UTF-8 sequence or UTF-16 surrogate pair.
|
|
231
|
+
for (let offset = 0; offset <= line.length; offset++) {
|
|
232
|
+
// A UTF-32 position cannot split a UTF-16 surrogate pair.
|
|
233
|
+
if (encoding === "utf-32" && offset > 0 && offset < line.length) {
|
|
234
|
+
const previous = line.charCodeAt(offset - 1);
|
|
235
|
+
const current = line.charCodeAt(offset);
|
|
236
|
+
if (previous >= 0xd800 && previous <= 0xdbff && current >= 0xdc00 && current <= 0xdfff)
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (convertCharacterOffset(encoding, line, offset) === position.character) {
|
|
240
|
+
return { line: position.line, character: offset };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
throw new Error(`text edit character ${position.character} is not a ${encoding} boundary`);
|
|
244
|
+
}
|
|
245
|
+
function normalizeTextEditsForContent(content, edits, encoding) {
|
|
52
246
|
const lines = content.split("\n");
|
|
53
|
-
|
|
247
|
+
const lastLine = Math.max(0, lines.length - 1);
|
|
248
|
+
// The target file's dominant EOL style, detected the same way the host edit
|
|
249
|
+
// tool does (first-occurrence-wins). Every `newText` is normalized to this
|
|
250
|
+
// style below so a server-supplied bare `\n` can never be spliced verbatim
|
|
251
|
+
// into a CRLF file and produce mixed line endings — the LSP workspace-edit
|
|
252
|
+
// apply path now shares the exact contract the host-edit path already
|
|
253
|
+
// enforces via `host-edit-normalize.ts` (#1147 P3-5, general class). A no-op
|
|
254
|
+
// for LF files: `restoreLineEndings` is the identity when `ending === "\n"`.
|
|
255
|
+
const ending = detectLineEnding(content);
|
|
256
|
+
const clamp = (position) => {
|
|
257
|
+
// LSP 3.17: a line past the end of the document defaults to the end of the
|
|
258
|
+
// document (last line, its length). Clamp rather than throw so a large
|
|
259
|
+
// sentinel end position — e.g. the (0,0)-(9999,0) whole-document replace
|
|
260
|
+
// idiom — resolves to a real range instead of failing.
|
|
261
|
+
if (position.line >= lines.length) {
|
|
262
|
+
return { line: lastLine, character: (lines[lastLine] ?? "").length };
|
|
263
|
+
}
|
|
264
|
+
return utf16Position(content, position, encoding);
|
|
265
|
+
};
|
|
266
|
+
const converted = edits.map((edit) => {
|
|
267
|
+
const start = clamp(edit.range.start);
|
|
268
|
+
const end = clamp(edit.range.end);
|
|
269
|
+
if (comparePosition(start, end) > 0)
|
|
270
|
+
throw new Error("text edit range is out of order");
|
|
271
|
+
return {
|
|
272
|
+
...edit,
|
|
273
|
+
range: { start, end },
|
|
274
|
+
newText: restoreLineEndings(normalizeToLF(edit.newText), ending),
|
|
275
|
+
};
|
|
276
|
+
});
|
|
277
|
+
// Return array order (validated, deduped); the single application-ordering
|
|
278
|
+
// sort happens once at the string-write site in applyTextEditsToString.
|
|
279
|
+
return validateTextEdits(converted);
|
|
280
|
+
}
|
|
281
|
+
export function applyTextEditsToString(content, edits, positionEncoding = "utf-16") {
|
|
282
|
+
const lines = content.split("\n");
|
|
283
|
+
// normalizeTextEditsForContent returns array order; sort for reverse
|
|
284
|
+
// application exactly once, here, at the single string-write site.
|
|
285
|
+
const normalized = sortEditsForApplication(normalizeTextEditsForContent(content, edits, positionEncoding));
|
|
286
|
+
for (const edit of normalized) {
|
|
54
287
|
const { start, end } = edit.range;
|
|
55
288
|
if (start.line === end.line) {
|
|
56
289
|
const line = lines[start.line] ?? "";
|
|
57
|
-
lines[start.line] =
|
|
58
|
-
line.slice(0, start.character) +
|
|
59
|
-
edit.newText +
|
|
60
|
-
line.slice(end.character);
|
|
290
|
+
lines[start.line] = line.slice(0, start.character) + edit.newText + line.slice(end.character);
|
|
61
291
|
continue;
|
|
62
292
|
}
|
|
63
293
|
const startLine = lines[start.line] ?? "";
|
|
64
294
|
const endLine = lines[end.line] ?? "";
|
|
65
|
-
const replacement = startLine.slice(0, start.character) +
|
|
66
|
-
edit.newText +
|
|
67
|
-
endLine.slice(end.character);
|
|
295
|
+
const replacement = startLine.slice(0, start.character) + edit.newText + endLine.slice(end.character);
|
|
68
296
|
lines.splice(start.line, end.line - start.line + 1, ...replacement.split("\n"));
|
|
69
297
|
}
|
|
70
298
|
return lines.join("\n");
|
|
71
299
|
}
|
|
300
|
+
export async function normalizeWorkspaceEditToUtf16(edit, positionEncoding, cwd) {
|
|
301
|
+
parseWorkspaceEdit(edit);
|
|
302
|
+
if (positionEncoding === "utf-16")
|
|
303
|
+
return edit;
|
|
304
|
+
// Use the same ordered planner and virtual resource/content model as the
|
|
305
|
+
// eventual apply. This is important for edits such as rename(oldDir, newDir)
|
|
306
|
+
// followed by a text edit at newDir/file.ts: the destination is not on disk
|
|
307
|
+
// yet, but its content is available through the virtual move.
|
|
308
|
+
const planned = planWorkspaceEdit(edit, true);
|
|
309
|
+
const prepared = await preflightWorkspaceEdit(planned, cwd, { positionEncoding });
|
|
310
|
+
const normalizedByOrigin = new Map();
|
|
311
|
+
for (const op of planned) {
|
|
312
|
+
if (op.kind !== "text")
|
|
313
|
+
continue;
|
|
314
|
+
for (const origin of op.origins ?? []) {
|
|
315
|
+
normalizedByOrigin.set(textEditOriginKey(origin), prepared.textByOrigin.get(textEditOriginKey(origin)) ?? []);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const changes = {};
|
|
319
|
+
for (const uri of Object.keys(edit.changes ?? {})) {
|
|
320
|
+
changes[uri] = normalizedByOrigin.get(textEditOriginKey({ kind: "changes", uri, edits: [] })) ?? [];
|
|
321
|
+
}
|
|
322
|
+
const documentChanges = [];
|
|
323
|
+
for (const [index, change] of (edit.documentChanges ?? []).entries()) {
|
|
324
|
+
const text = parseTextDocumentEdit(change, `documentChanges[${index}]`);
|
|
325
|
+
if (!text) {
|
|
326
|
+
documentChanges.push(change);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
documentChanges.push({
|
|
330
|
+
...change,
|
|
331
|
+
edits: normalizedByOrigin.get(textEditOriginKey({ kind: "documentChanges", index, edits: [] })) ?? [],
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
...(edit.changes !== undefined ? { changes } : {}),
|
|
336
|
+
...(edit.documentChanges !== undefined ? { documentChanges } : {}),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
72
339
|
export function flattenWorkspaceTextEdits(edit) {
|
|
73
|
-
|
|
340
|
+
parseWorkspaceEdit(edit);
|
|
341
|
+
const buckets = new Map();
|
|
74
342
|
const push = (uri, edits) => {
|
|
75
|
-
const textEdits = edits
|
|
343
|
+
const textEdits = parseTextEdits(edits, uri);
|
|
76
344
|
if (textEdits.length === 0)
|
|
77
345
|
return;
|
|
78
|
-
const
|
|
346
|
+
const key = pathIndexKey(uri);
|
|
347
|
+
const existing = buckets.get(key);
|
|
79
348
|
if (existing)
|
|
80
|
-
existing.push(...textEdits);
|
|
349
|
+
existing.edits.push(...textEdits);
|
|
81
350
|
else
|
|
82
|
-
|
|
351
|
+
buckets.set(key, { uri, edits: [...textEdits] });
|
|
83
352
|
};
|
|
84
|
-
for (const [uri, edits] of Object.entries(edit.changes ?? {}))
|
|
353
|
+
for (const [uri, edits] of Object.entries(edit.changes ?? {}))
|
|
85
354
|
push(uri, edits);
|
|
86
|
-
}
|
|
87
355
|
for (const change of edit.documentChanges ?? []) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
356
|
+
const text = parseTextDocumentEdit(change, "documentChanges");
|
|
357
|
+
if (text)
|
|
358
|
+
push(text.textDocument.uri, text.edits);
|
|
91
359
|
}
|
|
92
|
-
return
|
|
360
|
+
return new Map([...buckets.values()].map(({ uri, edits }) => [uri, edits]));
|
|
93
361
|
}
|
|
94
362
|
function textEditKey(uri, edit) {
|
|
95
|
-
return [
|
|
96
|
-
uri,
|
|
97
|
-
edit.range.start.line,
|
|
98
|
-
edit.range.start.character,
|
|
99
|
-
edit.range.end.line,
|
|
100
|
-
edit.range.end.character,
|
|
101
|
-
edit.newText,
|
|
102
|
-
].join(":");
|
|
363
|
+
return [pathIndexKey(uri), edit.range.start.line, edit.range.start.character, edit.range.end.line, edit.range.end.character, edit.newText].join(":");
|
|
103
364
|
}
|
|
104
365
|
export function mergeWorkspaceTextEditsByPriority(entries) {
|
|
105
366
|
const merged = new Map();
|
|
367
|
+
// Exact-duplicate dedup is a CROSS-SERVER concern only: two servers proposing
|
|
368
|
+
// the identical non-empty replace should collapse to one. Zero-width inserts
|
|
369
|
+
// are never deduplicated here — same as `validateTextEdits` on the normal
|
|
370
|
+
// apply path — because their multiplicity is meaningful: a single server can
|
|
371
|
+
// legitimately emit several identical zero-width inserts at one position
|
|
372
|
+
// (e.g. `willRenameFiles` producing "QQ" twice for an "aQQbc" edit), and
|
|
373
|
+
// deduping on exact key alone would silently drop the duplicate within that
|
|
374
|
+
// one server's own edit, contradicting the invariant documented above
|
|
375
|
+
// `validateTextEdits`.
|
|
106
376
|
const seenExact = new Set();
|
|
107
377
|
let droppedConflicts = 0;
|
|
108
378
|
let inputEditCount = 0;
|
|
@@ -112,117 +382,738 @@ export function mergeWorkspaceTextEditsByPriority(entries) {
|
|
|
112
382
|
if (!entry.edit)
|
|
113
383
|
continue;
|
|
114
384
|
for (const [uri, edits] of flattenWorkspaceTextEdits(entry.edit)) {
|
|
115
|
-
const
|
|
385
|
+
const key = pathIndexKey(uri);
|
|
386
|
+
const bucket = merged.get(key) ?? { uri, edits: [] };
|
|
387
|
+
const kept = bucket.edits;
|
|
116
388
|
for (const edit of edits) {
|
|
117
389
|
inputEditCount += 1;
|
|
118
|
-
const exactKey = textEditKey(uri, edit);
|
|
119
|
-
if (seenExact.has(exactKey))
|
|
390
|
+
const exactKey = isEmptyRange(edit.range) ? undefined : textEditKey(uri, edit);
|
|
391
|
+
if (exactKey !== undefined && seenExact.has(exactKey))
|
|
120
392
|
continue;
|
|
121
393
|
if (kept.some((existing) => rangesOverlap(existing.range, edit.range))) {
|
|
122
394
|
droppedConflicts += 1;
|
|
123
395
|
continue;
|
|
124
396
|
}
|
|
125
|
-
|
|
397
|
+
if (exactKey !== undefined)
|
|
398
|
+
seenExact.add(exactKey);
|
|
126
399
|
kept.push(edit);
|
|
127
400
|
}
|
|
128
401
|
if (kept.length > 0)
|
|
129
|
-
merged.set(
|
|
402
|
+
merged.set(key, bucket);
|
|
130
403
|
}
|
|
131
404
|
}
|
|
132
405
|
const changes = {};
|
|
133
|
-
for (const
|
|
406
|
+
for (const { uri, edits } of merged.values())
|
|
134
407
|
changes[uri] = edits;
|
|
135
|
-
}
|
|
136
408
|
return { edit: { changes }, droppedConflicts, inputEditCount, serverIds };
|
|
137
409
|
}
|
|
410
|
+
function pathIndexKey(uri) {
|
|
411
|
+
// uriToPath already resolves the path through normalizeFilePath, including
|
|
412
|
+
// realpath canonicalization for existing files. Re-canonicalizing that
|
|
413
|
+
// result with normalizeMapKey made every planner lookup perform a second
|
|
414
|
+
// synchronous filesystem walk; this index is call-scoped, so the cheap fold
|
|
415
|
+
// is sufficient after the first canonicalization.
|
|
416
|
+
return normalizeEphemeralMapKey(uriToPath(uri));
|
|
417
|
+
}
|
|
418
|
+
function textEditOriginKey(origin) {
|
|
419
|
+
// This is an input-container identity, not a filesystem/resource key. Keep
|
|
420
|
+
// the original URI spelling so two equivalent `changes` keys cannot overwrite
|
|
421
|
+
// each other's normalized output while the planner still coalesces them.
|
|
422
|
+
return origin.kind === "changes"
|
|
423
|
+
? `changes:${origin.uri}`
|
|
424
|
+
: `documentChanges:${origin.index}`;
|
|
425
|
+
}
|
|
426
|
+
function parseResource(change) {
|
|
427
|
+
const kind = change.kind;
|
|
428
|
+
if (kind === "create")
|
|
429
|
+
return { kind, uri: change.uri, options: parseResourceOptions(change.options, kind) };
|
|
430
|
+
if (kind === "rename")
|
|
431
|
+
return { kind, oldUri: change.oldUri, newUri: change.newUri, options: parseResourceOptions(change.options, kind) };
|
|
432
|
+
if (kind === "delete")
|
|
433
|
+
return { kind, uri: change.uri, options: parseResourceOptions(change.options, kind) };
|
|
434
|
+
throw new Error(`unsupported workspace resource operation: ${String(kind)}`);
|
|
435
|
+
}
|
|
436
|
+
function planWorkspaceEdit(edit, trackOrigins = false) {
|
|
437
|
+
parseWorkspaceEdit(edit);
|
|
438
|
+
const ops = [];
|
|
439
|
+
const pending = new Map();
|
|
440
|
+
const descendants = new Map();
|
|
441
|
+
const indexedAncestors = new Map();
|
|
442
|
+
const seenResources = new Set();
|
|
443
|
+
const pathKeys = new Map();
|
|
444
|
+
const indexKey = (uri) => {
|
|
445
|
+
const cached = pathKeys.get(uri);
|
|
446
|
+
if (cached !== undefined)
|
|
447
|
+
return cached;
|
|
448
|
+
const key = pathIndexKey(uri);
|
|
449
|
+
pathKeys.set(uri, key);
|
|
450
|
+
return key;
|
|
451
|
+
};
|
|
452
|
+
const addIndex = (key) => {
|
|
453
|
+
const ancestors = [];
|
|
454
|
+
let current = key;
|
|
455
|
+
// `key` is a normalized URI index key, not an on-disk directory. The
|
|
456
|
+
// index intentionally uses the key's own separator/root semantics, so
|
|
457
|
+
// walkUpDirs (which resolves through the host filesystem) is not suitable.
|
|
458
|
+
while (true) {
|
|
459
|
+
const set = descendants.get(current) ?? new Set();
|
|
460
|
+
set.add(key);
|
|
461
|
+
descendants.set(current, set);
|
|
462
|
+
ancestors.push(current);
|
|
463
|
+
const parent = path.dirname(current);
|
|
464
|
+
if (parent === current)
|
|
465
|
+
break;
|
|
466
|
+
current = parent;
|
|
467
|
+
}
|
|
468
|
+
indexedAncestors.set(key, ancestors);
|
|
469
|
+
};
|
|
470
|
+
const removeIndex = (key) => {
|
|
471
|
+
for (const ancestor of indexedAncestors.get(key) ?? [])
|
|
472
|
+
descendants.get(ancestor)?.delete(key);
|
|
473
|
+
indexedAncestors.delete(key);
|
|
474
|
+
};
|
|
475
|
+
const queue = (uri, edits, version, origin) => {
|
|
476
|
+
const key = indexKey(uri);
|
|
477
|
+
const existing = pending.get(key);
|
|
478
|
+
if (existing) {
|
|
479
|
+
// Per LSP 3.17, `version: null` means "don't check" — it is not itself a
|
|
480
|
+
// version to conflict against. Only two DIFFERENT numeric versions for the
|
|
481
|
+
// same URI are a genuine conflict; a numeric version is authoritative and
|
|
482
|
+
// adopted over a `null`/`undefined` counterpart from another edit container
|
|
483
|
+
// for the same document, so the later preflight version check (which only
|
|
484
|
+
// fires for numeric `op.version`) still validates it.
|
|
485
|
+
const existingNumeric = typeof existing.version === "number" ? existing.version : undefined;
|
|
486
|
+
const incomingNumeric = typeof version === "number" ? version : undefined;
|
|
487
|
+
if (existingNumeric !== undefined && incomingNumeric !== undefined && existingNumeric !== incomingNumeric) {
|
|
488
|
+
throw new Error(`conflicting text document versions for ${uri}`);
|
|
489
|
+
}
|
|
490
|
+
existing.edits.push(...edits);
|
|
491
|
+
if (origin)
|
|
492
|
+
(existing.origins ??= []).push(origin);
|
|
493
|
+
if (existingNumeric === undefined && incomingNumeric !== undefined)
|
|
494
|
+
existing.version = version;
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (origin) {
|
|
498
|
+
pending.set(key, { uri, edits: [...edits], version, origins: [origin] });
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
pending.set(key, { uri, edits: [...edits], version });
|
|
502
|
+
}
|
|
503
|
+
addIndex(key);
|
|
504
|
+
};
|
|
505
|
+
const flushUri = (uri) => {
|
|
506
|
+
const key = indexKey(uri);
|
|
507
|
+
const item = pending.get(key);
|
|
508
|
+
if (!item)
|
|
509
|
+
return;
|
|
510
|
+
pending.delete(key);
|
|
511
|
+
removeIndex(key);
|
|
512
|
+
if (item.origins) {
|
|
513
|
+
ops.push({
|
|
514
|
+
kind: "text",
|
|
515
|
+
uri: item.uri,
|
|
516
|
+
edits: item.edits,
|
|
517
|
+
version: item.version,
|
|
518
|
+
origins: item.origins,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
ops.push({ kind: "text", uri: item.uri, edits: item.edits, version: item.version });
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
const flushSubtree = (uri) => {
|
|
526
|
+
const key = indexKey(uri);
|
|
527
|
+
for (const candidate of [...(descendants.get(key) ?? [])]) {
|
|
528
|
+
const item = pending.get(candidate);
|
|
529
|
+
if (item)
|
|
530
|
+
flushUri(item.uri);
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
for (const [uri, edits] of Object.entries(edit.changes ?? {})) {
|
|
534
|
+
const parsed = parseTextEdits(edits, `changes[${uri}]`);
|
|
535
|
+
queue(uri, parsed, undefined, trackOrigins ? { kind: "changes", uri, edits: parsed } : undefined);
|
|
536
|
+
}
|
|
537
|
+
for (const [index, change] of (edit.documentChanges ?? []).entries()) {
|
|
538
|
+
const text = parseTextDocumentEdit(change, `documentChanges[${index}]`);
|
|
539
|
+
if (text) {
|
|
540
|
+
queue(text.textDocument.uri, text.edits, text.textDocument.version, trackOrigins
|
|
541
|
+
? {
|
|
542
|
+
kind: "documentChanges",
|
|
543
|
+
index,
|
|
544
|
+
edits: text.edits,
|
|
545
|
+
}
|
|
546
|
+
: undefined);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const resource = parseResource(change);
|
|
550
|
+
const resourceKey = resource.kind === "rename"
|
|
551
|
+
? `rename:${indexKey(resource.oldUri)}:${indexKey(resource.newUri)}`
|
|
552
|
+
: `${resource.kind}:${indexKey(resource.uri)}`;
|
|
553
|
+
if (seenResources.has(resourceKey))
|
|
554
|
+
throw new Error(`duplicate workspace resource operation: ${resourceKey}`);
|
|
555
|
+
seenResources.add(resourceKey);
|
|
556
|
+
if (resource.kind === "create")
|
|
557
|
+
flushUri(resource.uri);
|
|
558
|
+
else if (resource.kind === "rename") {
|
|
559
|
+
flushSubtree(resource.oldUri);
|
|
560
|
+
flushSubtree(resource.newUri);
|
|
561
|
+
}
|
|
562
|
+
else
|
|
563
|
+
flushSubtree(resource.uri);
|
|
564
|
+
ops.push(resource);
|
|
565
|
+
}
|
|
566
|
+
for (const item of [...pending.values()])
|
|
567
|
+
flushUri(item.uri);
|
|
568
|
+
return ops;
|
|
569
|
+
}
|
|
570
|
+
/** Test-only planning probe used by the scaled occupancy regression. */
|
|
571
|
+
export function __planWorkspaceEditForTest(edit) {
|
|
572
|
+
return planWorkspaceEdit(edit).length;
|
|
573
|
+
}
|
|
138
574
|
function relativeToCwd(filePath, cwd) {
|
|
139
575
|
const rel = path.relative(cwd, filePath) || path.basename(filePath);
|
|
140
576
|
return rel.replace(/\\/g, "/");
|
|
141
577
|
}
|
|
578
|
+
// Matches an import declaration or a re-export-from declaration — the two
|
|
579
|
+
// statement shapes that actually change a module's dependency edges. Also
|
|
580
|
+
// matches a bare `from "..."` continuation line WITHOUT a leading `import`/
|
|
581
|
+
// `export`, so a formatter-wrapped multiline import
|
|
582
|
+
// import {
|
|
583
|
+
// foo,
|
|
584
|
+
// } from "./old";
|
|
585
|
+
// still flags its specifier line as import-relevant even though line 1
|
|
586
|
+
// ("import {") doesn't itself contain the module path. Fail-safe direction:
|
|
587
|
+
// this can over-match a non-import line that happens to contain `from "..."`
|
|
588
|
+
// (e.g. a string literal), but that only risks over-reporting `importsChanged`
|
|
589
|
+
// (the safe direction — master's prior /^import\s/m heuristic over-invalidated
|
|
590
|
+
// too), never under-reporting a real specifier change.
|
|
591
|
+
const IMPORT_RELEVANT_LINE = /^\s*import\b|^\s*export\s[^;]*\bfrom\s|\bfrom\s+['"]/;
|
|
592
|
+
/**
|
|
593
|
+
* A stable signature of the import/re-export-from lines in `text`, order-
|
|
594
|
+
* preserved. Used to detect whether a text edit actually changed the file's
|
|
595
|
+
* import graph (P3-6) rather than merely landing in a file that HAS imports —
|
|
596
|
+
* `fileDetails[].importsChanged` gates expensive downstream dependency-graph
|
|
597
|
+
* re-checks (see `cache-manager.ts`'s `importsChanged` filter and
|
|
598
|
+
* `lsp-mutation.ts`'s `addModifiedRange`), so over-reporting "changed" on
|
|
599
|
+
* every edit to an already-import-bearing file defeats that gate. This is a
|
|
600
|
+
* LINE-signature heuristic, not a parse: a multiline import whose specifier
|
|
601
|
+
* ("from" line) is untouched but whose bound-name list changes on an
|
|
602
|
+
* interior line (e.g. renaming one of several named imports without
|
|
603
|
+
* touching the `import {`/`} from "..."` lines) is not detected — narrower
|
|
604
|
+
* than a full import-statement diff, but still strictly safer than the prior
|
|
605
|
+
* "file merely contains any import" heuristic. Known pre-existing gaps
|
|
606
|
+
* (unaddressed here, same as before): dynamic `import(...)` calls and
|
|
607
|
+
* `require(...)` are not import-relevant lines by this heuristic.
|
|
608
|
+
*/
|
|
609
|
+
function importsSignature(text) {
|
|
610
|
+
return text
|
|
611
|
+
.split("\n")
|
|
612
|
+
.filter((line) => IMPORT_RELEVANT_LINE.test(line))
|
|
613
|
+
.join("\n");
|
|
614
|
+
}
|
|
142
615
|
export function summarizeWorkspaceEdit(edit, cwd) {
|
|
143
616
|
const lines = [];
|
|
144
|
-
const
|
|
145
|
-
for (const [uri, edits] of textEditsByUri) {
|
|
617
|
+
for (const [uri, edits] of flattenWorkspaceTextEdits(edit))
|
|
146
618
|
lines.push(`Apply ${edits.length} edit(s) to ${relativeToCwd(uriToPath(uri), cwd)}`);
|
|
147
|
-
}
|
|
148
619
|
for (const change of edit.documentChanges ?? []) {
|
|
149
|
-
if (
|
|
620
|
+
if (!isRecord(change) || typeof change.kind !== "string")
|
|
150
621
|
continue;
|
|
151
|
-
|
|
152
|
-
if (kind === "create" && typeof change.uri === "string") {
|
|
622
|
+
if (change.kind === "create" && typeof change.uri === "string")
|
|
153
623
|
lines.push(`Create ${relativeToCwd(uriToPath(change.uri), cwd)}`);
|
|
154
|
-
|
|
155
|
-
else if (kind === "rename" &&
|
|
156
|
-
typeof change.oldUri === "string" &&
|
|
157
|
-
typeof change.newUri === "string") {
|
|
624
|
+
else if (change.kind === "rename" && typeof change.oldUri === "string" && typeof change.newUri === "string")
|
|
158
625
|
lines.push(`Rename ${relativeToCwd(uriToPath(change.oldUri), cwd)} → ${relativeToCwd(uriToPath(change.newUri), cwd)}`);
|
|
159
|
-
|
|
160
|
-
else if (kind === "delete" &&
|
|
161
|
-
typeof change.uri === "string") {
|
|
626
|
+
else if (change.kind === "delete" && typeof change.uri === "string")
|
|
162
627
|
lines.push(`Delete ${relativeToCwd(uriToPath(change.uri), cwd)}`);
|
|
163
|
-
}
|
|
164
628
|
}
|
|
165
629
|
return lines;
|
|
166
630
|
}
|
|
167
|
-
|
|
631
|
+
async function lstatOrMissing(filePath) {
|
|
632
|
+
try {
|
|
633
|
+
return await fs.lstat(filePath);
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
if (err.code === "ENOENT")
|
|
637
|
+
return undefined;
|
|
638
|
+
throw err;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* True when two `lstat` results denote the SAME on-disk entry. Requires a
|
|
643
|
+
* matching `dev` AND a matching, NONZERO `ino`. The nonzero guard is
|
|
644
|
+
* load-bearing: ino-less filesystems (FAT32/exFAT, some SMB redirectors,
|
|
645
|
+
* VirtualBox shared folders) report `ino: 0` for every entry via libuv, so
|
|
646
|
+
* without it two DISTINCT files would compare `(dev, 0) === (dev, 0)` and a
|
|
647
|
+
* rename would be misclassified as a case-only alias and silently clobber the
|
|
648
|
+
* destination. Comparing the BigInt fields (from `lstat({ bigint: true })`)
|
|
649
|
+
* also avoids the double-precision loss on NTFS 64-bit file IDs >= 2^53 that a
|
|
650
|
+
* `number` `ino` would suffer (which could false-equal distinct files).
|
|
651
|
+
* Anything that is not a confident same-entry match returns false → the caller
|
|
652
|
+
* falls back to the destination-exists check (fail-closed). Exported for direct
|
|
653
|
+
* unit coverage of the ino-0 guard.
|
|
654
|
+
*/
|
|
655
|
+
export function isSameFsIdentity(a, b) {
|
|
656
|
+
return a.dev === b.dev && a.ino !== 0n && b.ino !== 0n && a.ino === b.ino;
|
|
657
|
+
}
|
|
658
|
+
async function bigintLstatOrMissing(filePath) {
|
|
659
|
+
try {
|
|
660
|
+
return await fs.lstat(filePath, { bigint: true });
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
if (err.code === "ENOENT")
|
|
664
|
+
return undefined;
|
|
665
|
+
throw err;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* True when two paths name the SAME on-disk entry — how a case-only rename on a
|
|
670
|
+
* case-insensitive FS is told apart from a genuine destination conflict without
|
|
671
|
+
* branching on `process.platform`. `lstat` (not `stat`) so a symlink is compared
|
|
672
|
+
* as itself, consistent with the rest of the preflight's symlink policy. See
|
|
673
|
+
* `isSameFsIdentity` for the dev/ino identity rules (and the ino-0 fail-closed
|
|
674
|
+
* guard for ino-less filesystems).
|
|
675
|
+
*/
|
|
676
|
+
async function isSameFsEntry(a, b) {
|
|
677
|
+
const [statA, statB] = await Promise.all([
|
|
678
|
+
bigintLstatOrMissing(a),
|
|
679
|
+
bigintLstatOrMissing(b),
|
|
680
|
+
]);
|
|
681
|
+
return Boolean(statA && statB && isSameFsIdentity(statA, statB));
|
|
682
|
+
}
|
|
683
|
+
async function assertParentIsUsable(filePath, stateFor) {
|
|
684
|
+
let current = path.dirname(filePath);
|
|
685
|
+
while (true) {
|
|
686
|
+
const known = await stateFor(current);
|
|
687
|
+
if (known.exists) {
|
|
688
|
+
if (!known.directory)
|
|
689
|
+
throw new Error(`resource parent is not a directory: ${current}`);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const parent = path.dirname(current);
|
|
693
|
+
if (parent === current)
|
|
694
|
+
throw new Error(`resource parent does not exist: ${filePath}`);
|
|
695
|
+
current = parent;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
async function resolveExistingAncestor(filePath) {
|
|
699
|
+
let current = path.resolve(filePath);
|
|
700
|
+
const tail = [];
|
|
701
|
+
while (true) {
|
|
702
|
+
try {
|
|
703
|
+
const resolved = await fs.realpath(current);
|
|
704
|
+
return path.join(resolved, ...tail.reverse());
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
const code = err.code;
|
|
708
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
709
|
+
throw err;
|
|
710
|
+
const parent = path.dirname(current);
|
|
711
|
+
if (parent === current)
|
|
712
|
+
throw err;
|
|
713
|
+
tail.push(path.basename(current));
|
|
714
|
+
current = parent;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
async function createWorkspaceUriConfiner(cwd) {
|
|
719
|
+
const root = await resolveExistingAncestor(cwd);
|
|
720
|
+
return async (uri) => {
|
|
721
|
+
let filePath = "";
|
|
722
|
+
try {
|
|
723
|
+
const parsed = new URL(uri);
|
|
724
|
+
if (parsed.protocol !== "file:" || (parsed.host !== "" && parsed.hostname !== "localhost"))
|
|
725
|
+
throw new Error("URI must be a local file URI");
|
|
726
|
+
filePath = uriToPath(uri);
|
|
727
|
+
}
|
|
728
|
+
catch (err) {
|
|
729
|
+
throw new Error(`invalid workspace edit URI ${uri}: ${err instanceof Error ? err.message : String(err)}`);
|
|
730
|
+
}
|
|
731
|
+
if (!path.isAbsolute(filePath))
|
|
732
|
+
throw new Error(`workspace edit path escapes workspace: ${uri}`);
|
|
733
|
+
const resolved = await resolveExistingAncestor(filePath);
|
|
734
|
+
if (!isUnderDir(resolved, root))
|
|
735
|
+
throw new Error(`workspace edit path escapes workspace: ${uri}`);
|
|
736
|
+
return filePath;
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
/** Validate a resource-only workspace edit without applying any mutation. */
|
|
740
|
+
export async function validateWorkspaceEdit(edit, cwd) {
|
|
741
|
+
const planned = planWorkspaceEdit(edit);
|
|
742
|
+
await preflightWorkspaceEdit(planned, cwd, {});
|
|
743
|
+
}
|
|
744
|
+
async function preflightWorkspaceEdit(planned, cwd, options) {
|
|
745
|
+
const confine = await createWorkspaceUriConfiner(cwd);
|
|
746
|
+
const virtual = new Map();
|
|
747
|
+
// State for a path that `resolveVirtualPath` cannot resolve to a physical
|
|
748
|
+
// address — i.e. a path CURRENTLY vacated by an earlier rename in this same
|
|
749
|
+
// edit (its "from" address, still shadowed by an active virtual move) — but
|
|
750
|
+
// which a later `create` op in the same ordered edit re-establishes at that
|
|
751
|
+
// exact address. Keyed on the raw (unresolved) query path because there is
|
|
752
|
+
// no physical path to key `virtual` on. Consulted only when `resolveVirtualPath`
|
|
753
|
+
// returns undefined, so it never shadows a real physical/virtual-move address.
|
|
754
|
+
const virtualOverrides = new Map();
|
|
755
|
+
const virtualMoves = [];
|
|
756
|
+
const virtualTombstones = [];
|
|
757
|
+
const text = new Map();
|
|
758
|
+
const textByOrigin = new Map();
|
|
759
|
+
const ignored = new Set();
|
|
760
|
+
const resolveVirtualPath = (filePath) => {
|
|
761
|
+
let resolved = filePath;
|
|
762
|
+
for (let index = virtualMoves.length - 1; index >= 0; index--) {
|
|
763
|
+
const move = virtualMoves[index];
|
|
764
|
+
if (isUnderDir(resolved, move.to) &&
|
|
765
|
+
(move.directory || pathsEqual(resolved, move.to))) {
|
|
766
|
+
const suffix = path.relative(move.to, resolved);
|
|
767
|
+
resolved = suffix ? path.join(move.from, suffix) : move.from;
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (isUnderDir(resolved, move.from) &&
|
|
771
|
+
(move.directory || pathsEqual(resolved, move.from))) {
|
|
772
|
+
return undefined;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return resolved;
|
|
776
|
+
};
|
|
777
|
+
const isTombstoned = (filePath) => virtualTombstones.some((tombstone) => pathsEqual(filePath, tombstone.path) ||
|
|
778
|
+
(tombstone.directory && isUnderDir(filePath, tombstone.path)));
|
|
779
|
+
const clearTombstonesUnder = (filePath) => {
|
|
780
|
+
for (let index = virtualTombstones.length - 1; index >= 0; index--) {
|
|
781
|
+
const tombstone = virtualTombstones[index];
|
|
782
|
+
if (pathsEqual(tombstone.path, filePath) || isUnderDir(tombstone.path, filePath)) {
|
|
783
|
+
virtualTombstones.splice(index, 1);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
const stateFor = async (filePath) => {
|
|
788
|
+
if (isTombstoned(filePath))
|
|
789
|
+
return { exists: false, directory: false };
|
|
790
|
+
const physicalPath = resolveVirtualPath(filePath);
|
|
791
|
+
if (!physicalPath) {
|
|
792
|
+
return virtualOverrides.get(normalizeMapKey(filePath)) ?? { exists: false, directory: false };
|
|
793
|
+
}
|
|
794
|
+
const key = normalizeMapKey(physicalPath);
|
|
795
|
+
const known = virtual.get(key);
|
|
796
|
+
if (known)
|
|
797
|
+
return known;
|
|
798
|
+
const stat = await lstatOrMissing(physicalPath);
|
|
799
|
+
const value = { exists: Boolean(stat), directory: stat?.isDirectory() ?? false };
|
|
800
|
+
virtual.set(key, value);
|
|
801
|
+
return value;
|
|
802
|
+
};
|
|
803
|
+
const contentFor = async (filePath, state) => {
|
|
804
|
+
if (!state.exists || state.directory)
|
|
805
|
+
throw new Error(`text edit target is not a file: ${filePath}`);
|
|
806
|
+
if (state.content !== undefined)
|
|
807
|
+
return state.content;
|
|
808
|
+
const physicalPath = resolveVirtualPath(filePath);
|
|
809
|
+
if (!physicalPath)
|
|
810
|
+
throw new Error(`text edit target does not exist: ${filePath}`);
|
|
811
|
+
const content = await fs.readFile(physicalPath, "utf-8");
|
|
812
|
+
state.content = content;
|
|
813
|
+
return content;
|
|
814
|
+
};
|
|
815
|
+
for (const op of planned) {
|
|
816
|
+
if (op.kind === "text") {
|
|
817
|
+
const filePath = await confine(op.uri);
|
|
818
|
+
const state = await stateFor(filePath);
|
|
819
|
+
if (op.version !== undefined && op.version !== null) {
|
|
820
|
+
const current = options.documentVersions?.get(normalizeMapKey(filePath));
|
|
821
|
+
if (current === undefined || current !== op.version)
|
|
822
|
+
throw new Error(`stale text document version for ${filePath}: expected ${op.version}, current ${current ?? "unknown"}`);
|
|
823
|
+
}
|
|
824
|
+
const content = await contentFor(filePath, state);
|
|
825
|
+
const encoding = options.positionEncoding ?? "utf-16";
|
|
826
|
+
const normalized = normalizeTextEditsForContent(content, op.edits, encoding);
|
|
827
|
+
text.set(op, normalized);
|
|
828
|
+
// Keep the normalized edits attached to their original LSP containers.
|
|
829
|
+
// A planner operation can combine `changes` with a documentChanges text
|
|
830
|
+
// edit for the same URI, so the combined validation above remains the
|
|
831
|
+
// authority while this assignment preserves version/resource ordering.
|
|
832
|
+
for (const origin of op.origins ?? []) {
|
|
833
|
+
// Origins are already validated together above, but must retain
|
|
834
|
+
// multiplicity: identical zero-width insertions are meaningful and
|
|
835
|
+
// must not be deduplicated while restoring the original containers.
|
|
836
|
+
textByOrigin.set(textEditOriginKey(origin), normalizeTextEditsForContent(content, origin.edits, encoding));
|
|
837
|
+
}
|
|
838
|
+
state.content = applyTextEditsToString(content, normalized);
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
if (op.kind === "create") {
|
|
842
|
+
const filePath = await confine(op.uri);
|
|
843
|
+
const state = await stateFor(filePath);
|
|
844
|
+
await assertParentIsUsable(filePath, stateFor);
|
|
845
|
+
if (state.exists) {
|
|
846
|
+
if (op.options?.ignoreIfExists) {
|
|
847
|
+
ignored.add(op);
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
if (!op.options?.overwrite || state.directory)
|
|
851
|
+
throw new Error(`create target already exists: ${filePath}`);
|
|
852
|
+
state.content = "";
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
clearTombstonesUnder(filePath);
|
|
856
|
+
state.exists = true;
|
|
857
|
+
state.directory = false;
|
|
858
|
+
state.content = "";
|
|
859
|
+
const physicalPath = resolveVirtualPath(filePath);
|
|
860
|
+
if (physicalPath)
|
|
861
|
+
virtual.set(normalizeMapKey(physicalPath), state);
|
|
862
|
+
// `resolveVirtualPath` returns undefined for a path currently shadowed
|
|
863
|
+
// by an earlier rename's vacated "from" address in this same ordered
|
|
864
|
+
// edit (P3-3): the created state has nowhere physical to live, so it
|
|
865
|
+
// must be recorded in the override overlay keyed on the raw query path
|
|
866
|
+
// instead — otherwise a later op addressing this exact path (e.g. a
|
|
867
|
+
// text edit) would see it as still-vacated/nonexistent.
|
|
868
|
+
else
|
|
869
|
+
virtualOverrides.set(normalizeMapKey(filePath), state);
|
|
870
|
+
}
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (op.kind === "rename") {
|
|
874
|
+
const oldPath = await confine(op.oldUri);
|
|
875
|
+
const newPath = await confine(op.newUri);
|
|
876
|
+
// "Must differ" is a property of the on-disk target, not the case-folded
|
|
877
|
+
// map key. A case-only rename (foo.txt → Foo.txt) is a legitimate refactor
|
|
878
|
+
// whose decoded paths differ even though they collapse to a single key on a
|
|
879
|
+
// case-insensitive filesystem — so compare the decoded disk paths here, and
|
|
880
|
+
// treat a same-key-different-case pair as a real rename below.
|
|
881
|
+
const oldDisk = uriToDiskPath(op.oldUri);
|
|
882
|
+
const newDisk = uriToDiskPath(op.newUri);
|
|
883
|
+
if (path.resolve(oldDisk).replace(/\\/g, "/") === path.resolve(newDisk).replace(/\\/g, "/")) {
|
|
884
|
+
throw new Error("rename source and destination must differ");
|
|
885
|
+
}
|
|
886
|
+
const source = await stateFor(oldPath);
|
|
887
|
+
if (!source.exists)
|
|
888
|
+
throw new Error(`rename source does not exist: ${oldPath}`);
|
|
889
|
+
// Captured BEFORE this rename's own `virtualMoves`/override mutations
|
|
890
|
+
// below, so it reflects how `source` was actually resolved: `true` when
|
|
891
|
+
// `oldPath` is itself shadowed by an earlier rename in this edit and the
|
|
892
|
+
// state came from the `virtualOverrides` overlay (P3-3) rather than a
|
|
893
|
+
// physically-addressable entry.
|
|
894
|
+
const sourceFromOverride = resolveVirtualPath(oldPath) === undefined;
|
|
895
|
+
await assertParentIsUsable(newPath, stateFor);
|
|
896
|
+
const destination = await stateFor(newPath);
|
|
897
|
+
if (destination.exists) {
|
|
898
|
+
// Decide "already exists" by on-disk IDENTITY, not a platform-keyed path
|
|
899
|
+
// fold. A case-only (or otherwise-aliased) rename whose destination
|
|
900
|
+
// resolves to the SAME FS entry as the source is a legitimate refactor,
|
|
901
|
+
// not a conflict — detected here on ANY case-insensitive FS (win32, macOS
|
|
902
|
+
// APFS/HFS+), per the #1024 "probe the FS, don't branch on platform"
|
|
903
|
+
// lesson. Conversely, on a case-SENSITIVE FS where both spellings are
|
|
904
|
+
// distinct real files, this stays a genuine conflict (closing the inverse
|
|
905
|
+
// silent-clobber edge that a win32-only fold left open).
|
|
906
|
+
//
|
|
907
|
+
// P3-8: when the source exists only VIRTUALLY (e.g. created earlier in
|
|
908
|
+
// this same ordered edit and never yet written to disk), `isSameFsEntry`
|
|
909
|
+
// lstats disk and finds nothing there, so it can never recognize a
|
|
910
|
+
// case-only alias for a not-yet-physical file. `stateFor` keys the
|
|
911
|
+
// `virtual`/override maps on the SAME case-folded identity used for
|
|
912
|
+
// physical aliasing, so if `destination` and `source` resolved to the
|
|
913
|
+
// identical cached VirtualFile object, they are — by construction of
|
|
914
|
+
// that keying — the same virtual entry (a case-insensitive-FS alias);
|
|
915
|
+
// checked as a fast, disk-free first branch before falling back to the
|
|
916
|
+
// physical probe (which remains untouched for genuinely-physical paths).
|
|
917
|
+
const aliasesSource = destination === source || (await isSameFsEntry(oldDisk, newDisk));
|
|
918
|
+
if (!aliasesSource) {
|
|
919
|
+
if (op.options?.ignoreIfExists) {
|
|
920
|
+
ignored.add(op);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
if (!op.options?.overwrite)
|
|
924
|
+
throw new Error(`rename destination already exists: ${newPath}`);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
clearTombstonesUnder(newPath);
|
|
928
|
+
if (!source.directory)
|
|
929
|
+
await contentFor(oldPath, source);
|
|
930
|
+
if (sourceFromOverride) {
|
|
931
|
+
// P3 (round-2 review): a purely virtual entry (created earlier in this
|
|
932
|
+
// same edit at a path vacated by an even earlier rename) has no
|
|
933
|
+
// physical address for a `virtualMoves` shadow to resolve against —
|
|
934
|
+
// `resolveVirtualPath` walks that list by PHYSICAL path chasing, which
|
|
935
|
+
// cannot represent "this virtual entry moved again." Pushing a move
|
|
936
|
+
// here would leave the stale `virtualOverrides[oldPath]` entry
|
|
937
|
+
// claiming `exists: true` forever (a later `create(oldPath)` would be
|
|
938
|
+
// falsely rejected, and reads of the new address wouldn't reliably
|
|
939
|
+
// find it either). Instead, migrate the state object directly: drop
|
|
940
|
+
// the stale key and re-key it under the destination, placing it back
|
|
941
|
+
// in `virtual` (physically addressable) or `virtualOverrides` (still
|
|
942
|
+
// shadowed) depending on whether the destination itself currently
|
|
943
|
+
// resolves to a physical path.
|
|
944
|
+
virtualOverrides.delete(normalizeMapKey(oldPath));
|
|
945
|
+
const destinationPhysicalPath = resolveVirtualPath(newPath);
|
|
946
|
+
if (destinationPhysicalPath)
|
|
947
|
+
virtual.set(normalizeMapKey(destinationPhysicalPath), source);
|
|
948
|
+
else
|
|
949
|
+
virtualOverrides.set(normalizeMapKey(newPath), source);
|
|
950
|
+
}
|
|
951
|
+
else {
|
|
952
|
+
// Keep descendants lazy: a later text edit under a renamed directory
|
|
953
|
+
// resolves through this virtual move to the original physical path.
|
|
954
|
+
// This preserves ordered workspace-edit semantics without walking the
|
|
955
|
+
// entire subtree during preflight.
|
|
956
|
+
virtualMoves.push({ from: oldPath, to: newPath, directory: source.directory });
|
|
957
|
+
}
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
const filePath = await confine(op.uri);
|
|
961
|
+
const state = await stateFor(filePath);
|
|
962
|
+
if (!state.exists) {
|
|
963
|
+
if (op.options?.ignoreIfNotExists)
|
|
964
|
+
ignored.add(op);
|
|
965
|
+
else
|
|
966
|
+
throw new Error(`delete target does not exist: ${filePath}`);
|
|
967
|
+
}
|
|
968
|
+
else {
|
|
969
|
+
if (state.directory && !op.options?.recursive)
|
|
970
|
+
throw new Error(`delete directory requires recursive: ${filePath}`);
|
|
971
|
+
state.exists = false;
|
|
972
|
+
state.content = undefined;
|
|
973
|
+
virtualTombstones.push({ path: filePath, directory: state.directory });
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return { text, textByOrigin, ignored };
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Applies a workspace edit. All URI, shape, version, resource-precondition and
|
|
980
|
+
* text-bound checks happen in preflight before the first write. Once preflight
|
|
981
|
+
* succeeds, an unexpected filesystem failure may still leave earlier mutations
|
|
982
|
+
* applied; this is intentionally the existing no-rollback boundary.
|
|
983
|
+
*/
|
|
984
|
+
export async function applyWorkspaceEdit(edit, cwd, options = {}) {
|
|
168
985
|
const descriptions = [];
|
|
169
986
|
const touchedFiles = new Set();
|
|
170
|
-
const
|
|
987
|
+
const fileDetails = [];
|
|
988
|
+
const appliedOperationIndexes = [];
|
|
989
|
+
let appliedOperationTotal = 0;
|
|
990
|
+
let operationIndex = 0;
|
|
991
|
+
const operationCounts = { textEdits: 0, create: 0, rename: 0, delete: 0 };
|
|
992
|
+
const planned = planWorkspaceEdit(edit);
|
|
993
|
+
let operationTotal = 0;
|
|
994
|
+
let prepared;
|
|
995
|
+
const operationSize = (op) => op.kind === "text" ? (prepared.text.get(op)?.length ?? 0) : 1;
|
|
996
|
+
const markApplied = (op) => {
|
|
997
|
+
const count = operationSize(op);
|
|
998
|
+
for (let index = 0; index < count; index++) {
|
|
999
|
+
if (appliedOperationIndexes.length < 100) {
|
|
1000
|
+
appliedOperationIndexes.push(operationIndex + index);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
appliedOperationTotal += count;
|
|
1004
|
+
operationIndex += count;
|
|
1005
|
+
};
|
|
1006
|
+
const skipOperation = (op) => {
|
|
1007
|
+
operationIndex += operationSize(op);
|
|
1008
|
+
};
|
|
1009
|
+
const makeResult = () => ({
|
|
1010
|
+
descriptions,
|
|
1011
|
+
files: [...touchedFiles],
|
|
1012
|
+
operationTotal,
|
|
1013
|
+
appliedOperationTotal,
|
|
1014
|
+
appliedOperationIndexes,
|
|
1015
|
+
operationCounts,
|
|
1016
|
+
fileDetails,
|
|
1017
|
+
});
|
|
171
1018
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
1019
|
+
prepared = await preflightWorkspaceEdit(planned, cwd, options);
|
|
1020
|
+
for (const op of planned) {
|
|
1021
|
+
const size = operationSize(op);
|
|
1022
|
+
if (op.kind === "text")
|
|
1023
|
+
operationCounts.textEdits += size;
|
|
1024
|
+
else
|
|
1025
|
+
operationCounts[op.kind]++;
|
|
1026
|
+
operationTotal += size;
|
|
1027
|
+
}
|
|
1028
|
+
for (const op of planned) {
|
|
1029
|
+
if (prepared.ignored.has(op)) {
|
|
1030
|
+
skipOperation(op);
|
|
182
1031
|
continue;
|
|
183
|
-
|
|
184
|
-
if (kind === "
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
1032
|
+
}
|
|
1033
|
+
if (op.kind === "text") {
|
|
1034
|
+
// Report/key on the normalized path (forward-slash, realpath-canonical),
|
|
1035
|
+
// but read/write the decoded on-disk path so the URI's casing is honored
|
|
1036
|
+
// on win32 (see uriToDiskPath).
|
|
1037
|
+
const filePath = uriToPath(op.uri);
|
|
1038
|
+
const diskPath = uriToDiskPath(op.uri);
|
|
1039
|
+
const edits = prepared.text.get(op) ?? [];
|
|
1040
|
+
if (edits.length === 0) {
|
|
1041
|
+
skipOperation(op);
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
const content = await fs.readFile(diskPath, "utf-8");
|
|
1045
|
+
const updated = applyTextEditsToString(content, edits, "utf-16");
|
|
1046
|
+
await fs.writeFile(diskPath, updated, "utf-8");
|
|
1047
|
+
const start = Math.min(...edits.map((item) => item.range.start.line + 1));
|
|
1048
|
+
const end = Math.max(...edits.map((item) => item.range.end.line + 1));
|
|
1049
|
+
touchedFiles.add(filePath);
|
|
1050
|
+
fileDetails.push({ filePath, range: { start, end }, importsChanged: importsSignature(content) !== importsSignature(updated) });
|
|
1051
|
+
markApplied(op);
|
|
1052
|
+
descriptions.push(`Applied ${edits.length} edit(s) to ${relativeToCwd(filePath, cwd)}`);
|
|
1053
|
+
}
|
|
1054
|
+
else if (op.kind === "create") {
|
|
1055
|
+
// Create on the decoded path so `NewFile.txt` is not lowercased on win32;
|
|
1056
|
+
// report/key on the normalized path.
|
|
1057
|
+
const filePath = uriToPath(op.uri);
|
|
1058
|
+
const diskPath = uriToDiskPath(op.uri);
|
|
1059
|
+
await fs.mkdir(path.dirname(diskPath), { recursive: true });
|
|
1060
|
+
if (op.options?.overwrite)
|
|
1061
|
+
await fs.writeFile(diskPath, "", "utf-8");
|
|
1062
|
+
else
|
|
1063
|
+
await fs.writeFile(diskPath, "", { flag: "wx" });
|
|
194
1064
|
touchedFiles.add(filePath);
|
|
1065
|
+
fileDetails.push({ filePath, range: { start: 1, end: 1 }, importsChanged: false });
|
|
1066
|
+
markApplied(op);
|
|
195
1067
|
descriptions.push(`Created ${relativeToCwd(filePath, cwd)}`);
|
|
196
1068
|
}
|
|
197
|
-
else if (kind === "rename"
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const oldPath = uriToPath(
|
|
201
|
-
const newPath = uriToPath(
|
|
202
|
-
|
|
203
|
-
|
|
1069
|
+
else if (op.kind === "rename") {
|
|
1070
|
+
// Rename on the decoded paths so the destination casing is honored (and a
|
|
1071
|
+
// case-only rename actually changes the name); report on normalized paths.
|
|
1072
|
+
const oldPath = uriToPath(op.oldUri);
|
|
1073
|
+
const newPath = uriToPath(op.newUri);
|
|
1074
|
+
const oldDisk = uriToDiskPath(op.oldUri);
|
|
1075
|
+
const newDisk = uriToDiskPath(op.newUri);
|
|
1076
|
+
await fs.mkdir(path.dirname(newDisk), { recursive: true });
|
|
1077
|
+
if (op.options?.overwrite)
|
|
1078
|
+
await fs.rm(newDisk, { recursive: true, force: true });
|
|
1079
|
+
await fs.rename(oldDisk, newDisk);
|
|
204
1080
|
touchedFiles.add(oldPath);
|
|
205
1081
|
touchedFiles.add(newPath);
|
|
1082
|
+
fileDetails.push({ filePath: oldPath, range: { start: 1, end: 1 }, importsChanged: true }, { filePath: newPath, range: { start: 1, end: 1 }, importsChanged: true });
|
|
1083
|
+
markApplied(op);
|
|
206
1084
|
descriptions.push(`Renamed ${relativeToCwd(oldPath, cwd)} → ${relativeToCwd(newPath, cwd)}`);
|
|
207
1085
|
}
|
|
208
|
-
else
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
await fs.rm(
|
|
1086
|
+
else {
|
|
1087
|
+
const filePath = uriToPath(op.uri);
|
|
1088
|
+
const diskPath = uriToDiskPath(op.uri);
|
|
1089
|
+
await fs.rm(diskPath, { recursive: op.options?.recursive === true, force: false });
|
|
212
1090
|
touchedFiles.add(filePath);
|
|
1091
|
+
fileDetails.push({ filePath, range: { start: 1, end: 1 }, importsChanged: true });
|
|
1092
|
+
markApplied(op);
|
|
213
1093
|
descriptions.push(`Deleted ${relativeToCwd(filePath, cwd)}`);
|
|
214
1094
|
}
|
|
215
1095
|
}
|
|
216
1096
|
}
|
|
217
1097
|
catch (err) {
|
|
1098
|
+
const partial = makeResult();
|
|
1099
|
+
if (options.mutationContext && options.observe !== false) {
|
|
1100
|
+
recordLspMutation(options.mutationContext, { results: [partial], status: "failed" });
|
|
1101
|
+
}
|
|
218
1102
|
const already = [...touchedFiles];
|
|
219
1103
|
if (already.length > 0) {
|
|
220
|
-
const alreadyList = already
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
throw
|
|
1104
|
+
const alreadyList = already.map((f) => ` • ${relativeToCwd(f, cwd)}`).join("\n");
|
|
1105
|
+
const failure = new Error(`Workspace edit failed mid-application — ${already.length} file(s) already written, no rollback performed:\n${alreadyList}\nCause: ${err instanceof Error ? err.message : String(err)}`);
|
|
1106
|
+
failure.appliedWorkspaceEdit = partial;
|
|
1107
|
+
throw failure;
|
|
224
1108
|
}
|
|
225
1109
|
throw err;
|
|
226
1110
|
}
|
|
227
|
-
|
|
1111
|
+
const applied = makeResult();
|
|
1112
|
+
if (options.mutationContext && options.observe !== false) {
|
|
1113
|
+
recordLspMutation(options.mutationContext, {
|
|
1114
|
+
results: [applied],
|
|
1115
|
+
status: appliedOperationTotal > 0 ? "success" : "skipped",
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
return applied;
|
|
228
1119
|
}
|