From 9b78a6fa85a5ffbf8a9940a7f58292374d7ebe0d Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 7 Apr 2026 00:42:59 +0200 Subject: [PATCH] refactor(natives): migrated native bindings to NAPI-RS with auto-generated types - Migrated native bindings from TypeScript wrappers to NAPI-RS generated modules with auto-generated type definitions and runtime enums. - Replaced chunk tree API with stateful ChunkState class supporting render, edit, and resolve operations with improved error handling. - Converted callback signatures to error-first pattern (error, result) for shell, PTY, glob, and grep operations. - Introduced type-safe enums for MacOSAppearance, GrepOutputMode, KeyEventType, ImageFormat, and AstMatchStrictness replacing string literals. - Refactored chunk tree implementation with dedicated modules for edit, indent, resolve, and state management with comprehensive validation. - Moved clipboard utilities from native bindings to coding-agent package with improved OSC 52 and Termux compatibility. --- Cargo.lock | 31 + STAGES.md | 125 ++ biome.json | 1 + bun.lock | 208 +- crates/pi-natives/Cargo.toml | 6 + crates/pi-natives/src/appearance.rs | 46 +- crates/pi-natives/src/ast.rs | 135 +- crates/pi-natives/src/chunk/ast_go.rs | 4 +- crates/pi-natives/src/chunk/ast_tlaplus.rs | 2 +- crates/pi-natives/src/chunk/common.rs | 2 + crates/pi-natives/src/chunk/edit.rs | 1297 ++++++++++++ crates/pi-natives/src/chunk/indent.rs | 555 ++++++ crates/pi-natives/src/chunk/mod.rs | 195 +- crates/pi-natives/src/chunk/render.rs | 96 +- crates/pi-natives/src/chunk/resolve.rs | 376 ++++ crates/pi-natives/src/chunk/state.rs | 502 +++++ crates/pi-natives/src/chunk/types.rs | 297 ++- crates/pi-natives/src/clipboard.rs | 2 +- crates/pi-natives/src/fff.rs | 2 +- crates/pi-natives/src/fs_cache.rs | 1 + crates/pi-natives/src/glob.rs | 7 +- crates/pi-natives/src/grep.rs | 87 +- crates/pi-natives/src/html.rs | 2 +- crates/pi-natives/src/image.rs | 40 +- crates/pi-natives/src/indent.rs | 313 +++ crates/pi-natives/src/keys.rs | 25 +- crates/pi-natives/src/lib.rs | 1 + crates/pi-natives/src/projfs_overlay.rs | 9 + crates/pi-natives/src/pty.rs | 5 +- crates/pi-natives/src/search_db.rs | 6 + crates/pi-natives/src/shell.rs | 10 +- crates/pi-natives/src/task.rs | 2 +- crates/pi-natives/src/text.rs | 126 +- packages/coding-agent/CHANGELOG.md | 28 +- packages/coding-agent/package.json | 1 + .../src/autoresearch/dashboard.ts | 3 +- packages/coding-agent/src/cli/grep-cli.ts | 16 +- packages/coding-agent/src/cli/read-cli.ts | 10 +- packages/coding-agent/src/commands/grep.ts | 7 +- .../src/config/prompt-templates.ts | 30 + .../src/config/resolve-config-value.ts | 6 +- packages/coding-agent/src/config/settings.ts | 11 +- packages/coding-agent/src/debug/log-viewer.ts | 5 +- .../coding-agent/src/exec/bash-executor.ts | 6 +- .../coding-agent/src/modes/components/diff.ts | 13 +- .../modes/controllers/command-controller.ts | 2 +- .../src/modes/controllers/input-controller.ts | 3 +- .../coding-agent/src/modes/theme/theme.ts | 12 +- packages/coding-agent/src/patch/index.ts | 38 +- .../src/prompts/tools/chunk-edit.md | 16 +- .../src/prompts/tools/grep-chunk.md | 14 - .../coding-agent/src/prompts/tools/grep.md | 3 + .../src/prompts/tools/read-chunk.md | 8 +- packages/coding-agent/src/tools/chunk-tree.ts | 1741 ++--------------- packages/coding-agent/src/tools/find.ts | 4 +- packages/coding-agent/src/tools/grep.ts | 17 +- packages/coding-agent/src/tools/read.ts | 15 +- .../coding-agent/src/tools/render-utils.ts | 11 +- packages/coding-agent/src/tui/tree-list.ts | 4 +- .../src/utils/clipboard.ts} | 18 +- .../src/utils/file-display-mode.ts | 3 + .../coding-agent/test/config-spacing.test.ts | 15 +- .../coding-agent/test/core/chunk-tree.test.ts | 118 +- .../test/theme-auto-detection.test.ts | 21 +- .../test/tools/chunk-mode.test.ts | 46 +- packages/natives/CHANGELOG.md | 23 + packages/natives/native/embedded-addon.js | 21 + packages/natives/native/index.d.ts | 1331 +++++++++++++ .../{src/native.ts => native/index.js} | 247 ++- packages/natives/package.json | 149 +- packages/natives/scripts/build-native.ts | 99 +- packages/natives/scripts/embed-native.ts | 69 +- packages/natives/scripts/gen-enums.ts | 76 + packages/natives/src/appearance/index.ts | 37 - packages/natives/src/appearance/types.ts | 29 - packages/natives/src/ast/index.ts | 25 - packages/natives/src/ast/types.ts | 90 - packages/natives/src/bindings.ts | 23 - packages/natives/src/chunk/index.ts | 24 - packages/natives/src/chunk/types.ts | 60 - packages/natives/src/clipboard/types.ts | 27 - packages/natives/src/embedded-addon.ts | 15 - packages/natives/src/glob/index.ts | 50 - packages/natives/src/glob/types.ts | 70 - packages/natives/src/grep/index.ts | 77 - packages/natives/src/grep/types.ts | 188 -- packages/natives/src/highlight/index.ts | 9 - packages/natives/src/highlight/types.ts | 56 - packages/natives/src/html/index.ts | 19 - packages/natives/src/html/types.ts | 24 - packages/natives/src/image/index.ts | 16 - packages/natives/src/image/types.ts | 67 - packages/natives/src/index.ts | 17 - packages/natives/src/keys/index.ts | 9 - packages/natives/src/keys/types.ts | 75 - packages/natives/src/projfs/index.ts | 8 - packages/natives/src/projfs/types.ts | 28 - packages/natives/src/ps/index.ts | 10 - packages/natives/src/ps/types.ts | 24 - packages/natives/src/pty/index.ts | 10 - packages/natives/src/pty/types.ts | 57 - packages/natives/src/search-db-types.ts | 20 - packages/natives/src/search-db.ts | 7 - packages/natives/src/shell/index.ts | 26 - packages/natives/src/shell/types.ts | 99 - packages/natives/src/text/index.ts | 97 - packages/natives/src/text/types.ts | 91 - packages/natives/src/work/index.ts | 11 - packages/natives/src/work/types.ts | 31 - packages/natives/test/native.test.ts | 5 +- packages/natives/tsconfig.json | 2 +- packages/tui/src/keys.ts | 2 +- packages/tui/src/utils.ts | 7 +- packages/tui/test/editor.test.ts | 2 +- packages/tui/test/input.test.ts | 4 +- packages/utils/src/indent.ts | 264 --- packages/utils/src/index.ts | 1 - packages/utils/test/spacing.test.ts | 14 +- tsconfig.base.json | 3 +- 119 files changed, 6370 insertions(+), 4236 deletions(-) create mode 100644 STAGES.md create mode 100644 crates/pi-natives/src/chunk/edit.rs create mode 100644 crates/pi-natives/src/chunk/indent.rs create mode 100644 crates/pi-natives/src/chunk/resolve.rs create mode 100644 crates/pi-natives/src/chunk/state.rs create mode 100644 crates/pi-natives/src/indent.rs delete mode 100644 packages/coding-agent/src/prompts/tools/grep-chunk.md rename packages/{natives/src/clipboard/index.ts => coding-agent/src/utils/clipboard.ts} (84%) create mode 100644 packages/natives/native/embedded-addon.js create mode 100644 packages/natives/native/index.d.ts rename packages/natives/{src/native.ts => native/index.js} (68%) create mode 100644 packages/natives/scripts/gen-enums.ts delete mode 100644 packages/natives/src/appearance/index.ts delete mode 100644 packages/natives/src/appearance/types.ts delete mode 100644 packages/natives/src/ast/index.ts delete mode 100644 packages/natives/src/ast/types.ts delete mode 100644 packages/natives/src/bindings.ts delete mode 100644 packages/natives/src/chunk/index.ts delete mode 100644 packages/natives/src/chunk/types.ts delete mode 100644 packages/natives/src/clipboard/types.ts delete mode 100644 packages/natives/src/embedded-addon.ts delete mode 100644 packages/natives/src/glob/index.ts delete mode 100644 packages/natives/src/glob/types.ts delete mode 100644 packages/natives/src/grep/index.ts delete mode 100644 packages/natives/src/grep/types.ts delete mode 100644 packages/natives/src/highlight/index.ts delete mode 100644 packages/natives/src/highlight/types.ts delete mode 100644 packages/natives/src/html/index.ts delete mode 100644 packages/natives/src/html/types.ts delete mode 100644 packages/natives/src/image/index.ts delete mode 100644 packages/natives/src/image/types.ts delete mode 100644 packages/natives/src/index.ts delete mode 100644 packages/natives/src/keys/index.ts delete mode 100644 packages/natives/src/keys/types.ts delete mode 100644 packages/natives/src/projfs/index.ts delete mode 100644 packages/natives/src/projfs/types.ts delete mode 100644 packages/natives/src/ps/index.ts delete mode 100644 packages/natives/src/ps/types.ts delete mode 100644 packages/natives/src/pty/index.ts delete mode 100644 packages/natives/src/pty/types.ts delete mode 100644 packages/natives/src/search-db-types.ts delete mode 100644 packages/natives/src/search-db.ts delete mode 100644 packages/natives/src/shell/index.ts delete mode 100644 packages/natives/src/shell/types.ts delete mode 100644 packages/natives/src/text/index.ts delete mode 100644 packages/natives/src/text/types.ts delete mode 100644 packages/natives/src/work/index.ts delete mode 100644 packages/natives/src/work/types.ts delete mode 100644 packages/utils/src/indent.ts diff --git a/Cargo.lock b/Cargo.lock index b38326392..931d31e99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -464,6 +464,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cached" version = "0.56.0" @@ -2523,6 +2533,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "path-clean" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2686,6 +2702,8 @@ dependencies = [ "ast-grep-core", "brush-builtins", "brush-core", + "bytes", + "bytes-utils", "cc", "clap", "dashmap", @@ -2707,9 +2725,13 @@ dependencies = [ "napi-derive", "os_pipe", "parking_lot", + "path-clean", + "pathdiff", "phf 0.13.1", "portable-pty", "rayon", + "regex", + "similar", "smallvec", "syntect", "tokio", @@ -3316,6 +3338,15 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "similar" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d0b06eba54f0ca0770f970a3e89823e766ca638dd940f8469fa0fa50c75396" +dependencies = [ + "bstr", +] + [[package]] name = "siphasher" version = "1.0.2" diff --git a/STAGES.md b/STAGES.md new file mode 100644 index 000000000..3e8cf684d --- /dev/null +++ b/STAGES.md @@ -0,0 +1,125 @@ +# ChunkState Migration — Parallelization Stages + +## Stage 0: Types (sequential, ~5 min) +**Owner:** main agent + +Write the shared type definitions that all subsequent stages depend on. + +**File:** `crates/pi-natives/src/chunk/types.rs` +- Remove `#[napi(object)]` from `ChunkNode` and `ChunkTree` (become Rust-internal) +- Remove `RenderChunkTreeParams` napi object (replaced by method params) +- Add `ChunkInfo` — lightweight `#[napi(object)]` returned to JS from queries +- Add `EditOperation`, `EditParams` — `#[napi(object)]` input types +- Add `EditResult` — `#[napi(object)]` output with `ChunkState` instance +- Add `ReadRenderParams`, `ReadResult` — `#[napi(object)]` for render_read +- Keep `ChunkAnchorStyle` and `VisibleLineRange` as-is + +**Output:** committed types.rs that `cargo check` won't pass yet (consumers not updated), but all type definitions are final. + +--- + +## Stage 1: Parallel Rust modules (4 agents, worktree isolation) + +All agents receive the finalized types from Stage 0 in their prompt. Each writes a single new file. + +### Agent 1A: `resolve.rs` — Selector resolution +**File:** `crates/pi-natives/src/chunk/resolve.rs` (new) + +Port from `chunk-tree.ts` lines 875–990: +- `sanitize_selector(sel) -> Option` — strip filename prefix, leading colon, trailing #XXXX +- `resolve_exact(chunks, path) -> Option` — index lookup +- `resolve_suffix(chunks, sel) -> Result` — suffix match ("fn_foo" → "class_Bar.fn_foo") +- `resolve_prefix(chunks, sel) -> Result` — CHUNK_NAME_PREFIXES expansion +- `resolve_kind_path(chunks, sel) -> Result` — "class.fn" → "class_Server.fn_start" +- `resolve_by_crc(chunks, crc) -> Option` — checksum lookup +- `resolve_selector(chunks, sel) -> Result` — orchestrates all of the above +- `suggest_paths(chunks, query, limit) -> Vec` — similarity-based suggestions for errors +- `sanitize_crc(crc) -> Option` + +**Reads:** `chunk-tree.ts` (resolveAnchorChunk, sanitizeChunkSelector, sanitizeCrc, chunkPathSimilarity, suggestChunkPaths, CHUNK_NAME_PREFIXES, CONTAINER_NAME_PREFIXES) + +### Agent 1B: `indent.rs` — Indentation & content normalization +**File:** `crates/pi-natives/src/chunk/indent.rs` (new) + +Port from `chunk-tree.ts` lines 217–410: +- `detect_common_indent(text) -> (String, usize)` +- `dedent_python_style(text) -> String` +- `indent_non_empty_lines(text, prefix) -> String` +- `detect_space_indent_step(text) -> usize` +- `normalize_leading_whitespace_char(text, target_char, file_indent_step) -> String` +- `reindent_inserted_block(content, target_indent, file_indent_step) -> String` +- `normalize_inserted_content(content, target_indent, file_indent_step) -> String` +- `strip_content_prefixes(content) -> String` (line-number gutter stripping) +- `detect_file_indent_step(chunks) -> u32` + +**Reads:** `chunk-tree.ts` (all indentation helpers, stripContentPrefixes, CHUNK_GUTTER_CODE_ROW_RE) + +### Agent 1C: `edit.rs` — Edit engine +**File:** `crates/pi-natives/src/chunk/edit.rs` (new) + +Port from `chunk-tree.ts` lines 1040–1850. Assumes `resolve.rs` and `indent.rs` exist with the signatures above. +- `validate_crc(chunk, crc) -> Result<()>` +- `validate_line_range(chunk, line, end_line) -> Result<()>` +- `compute_insert_indent(state, anchor, inside) -> String` +- `get_insertion_point(state, anchor, placement) -> InsertionPoint` +- `compute_insert_spacing(state, anchor, pos) -> Spacing` +- `normalize_insertion_boundary(source, offset, content, spacing) -> String` +- `cleanup_blank_line_artifacts(text, offset) -> String` +- `go_type_append_child_insertion_point(state, anchor, content) -> Option` +- `apply_edits(state: &ChunkStateInner, params: EditParams) -> Result` + - The main entry point. Schedules ops, sorts line-scoped, executes in order, rebuilds tree after each, validates parse errors, generates response. + +**Reads:** `chunk-tree.ts` (applyChunkEdits and everything it calls: validateCrc, validateLineRange, computeInsertIndent, getInsertionPoint, getInsertionPointForPosition, computeInsertSpacing, normalizeInsertionBoundaryContent, cleanupBlankLineArtifactsAtOffset, goTypeAppendChildInsertionPoint, isContainerLikeChunk, renderChangedHunks) + +### Agent 1D: `state.rs` — ChunkState napi class +**File:** `crates/pi-natives/src/chunk/state.rs` (new) + +The napi class that ties everything together. Assumes `resolve.rs`, `indent.rs`, `edit.rs` exist. +- `ChunkState` struct with `Arc` +- `ChunkStateInner` struct (source, language, checksum, line_count, parse_errors, chunks, chunk_index) +- `#[napi(factory)] fn parse(source, language) -> Result` +- `#[napi(getter)]` for source, language, checksum, line_count, parse_errors +- `#[napi] fn resolve(&self, path) -> Option` +- `#[napi] fn resolve_selector(&self, selector) -> Result` +- `#[napi] fn resolve_by_crc(&self, crc) -> Option` +- `#[napi] fn line_to_chunk_path(&self, line) -> Option` +- `#[napi] fn line_to_containing_chunk_path(&self, line) -> Option` +- `#[napi] fn render(&self, params: RenderParams) -> String` — delegates to render.rs +- `#[napi] fn render_read(&self, params: ReadRenderParams) -> ReadResult` — replaces formatChunkedRead +- `#[napi] fn format_grep_line(&self, line, text, display_path) -> String` +- `#[napi] fn format_anchor(&self, name, checksum, depth, style, omit_checksum) -> String` +- `#[napi] fn apply_edits(&self, params: EditParams) -> Result` — delegates to edit.rs + +**Reads:** `chunk-tree.ts` (loadChunkTreeForFile, formatChunkedRead, formatChunkedGrepLine — for the render_read and format_grep_line signatures/logic), existing `mod.rs` build_chunk_tree, render.rs + +--- + +## Stage 2: Parallel wiring (2 agents) + +### Agent 2A: Rust integration +**Files:** `crates/pi-natives/src/chunk/mod.rs`, `crates/pi-natives/src/chunk/render.rs` +- Add `mod state; mod resolve; mod indent; mod edit;` to mod.rs +- Remove old napi function exports (parseChunkTree, resolveChunkPath, etc) +- Export `ChunkState` from mod.rs +- Adapt render.rs to accept `&[ChunkNodeInternal]` / `&ChunkStateInner` instead of `&RenderChunkTreeParams` +- `cargo check -p pi-natives` + +### Agent 2B: TS rewrite +**Files:** `packages/natives/src/chunk/{types.ts, index.ts}`, `packages/coding-agent/src/tools/chunk-tree.ts`, `packages/coding-agent/src/tools/read.ts`, `packages/coding-agent/src/patch/index.ts` +- New `types.ts`: `ChunkState` class interface, `ChunkInfo`, `EditParams`, `EditResult`, etc +- New `index.ts`: export `ChunkState` class from native +- Gut `chunk-tree.ts` to ~200 lines: LRU cache of `ChunkState` instances, thin async wrappers +- Update `read.ts` and `patch/index.ts` to use new API +- `bun run tsc --noEmit` + +--- + +## Stage 3: Test & fix (sequential) +**Owner:** main agent + +- `cargo check -p pi-natives` + `cargo test` +- `bun run tsc --noEmit` +- `bun test packages/coding-agent/test/core/chunk-tree.test.ts` +- `bun test packages/coding-agent/test/tools/chunk-mode.test.ts` +- Fix any integration issues +- Render prompt templates with all anchor styles to verify diff --git a/biome.json b/biome.json index b7b152eb1..f2380c3e7 100644 --- a/biome.json +++ b/biome.json @@ -44,6 +44,7 @@ "packages/*/examples/**/*.ts", "packages/*/scripts/**/*.ts", "packages/*/*.ts", + "!packages/natives/native/index.d.ts", "!**/vendor/**/*", "!**/node_modules/**/*", "!**/test-sessions.ts", diff --git a/bun.lock b/bun.lock index 1918af9a6..8e5532615 100644 --- a/bun.lock +++ b/bun.lock @@ -72,6 +72,7 @@ "fflate": "0.8.2", "handlebars": "^4.7", "linkedom": "^0.18", + "lru-cache": "11.3.1", "markit-ai": "0.5.0", "puppeteer": "^24.37", "zod": "4.3.6", @@ -87,6 +88,7 @@ "@oh-my-pi/pi-utils": "workspace:*", }, "devDependencies": { + "@napi-rs/cli": "3.6.0", "@types/bun": "^1.3", }, }, @@ -299,8 +301,46 @@ "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@google/genai": ["@google/genai@1.48.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-plonYK4ML2PrxsRD9SeqmFt76eREWkQdPCglOA6aYDzL1AAbE+7PUnT54SvpWGfws13L0AZEqGSpL7+1IPnTxQ=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.5", "", {}, "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw=="], + + "@inquirer/checkbox": ["@inquirer/checkbox@5.1.3", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.8", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-+G7I8CT+EHv/hasNfUl3P37DVoMoZfpA+2FXmM54dA8MxYle1YqucxbacxHalw1iAFSdKNEDTGNV7F+j1Ldqcg=="], + + "@inquirer/confirm": ["@inquirer/confirm@6.0.11", "", { "dependencies": { "@inquirer/core": "^11.1.8", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-pTpHjg0iEIRMYV/7oCZUMf27/383E6Wyhfc/MY+AVQGEoUobffIYWOK9YLP2XFRGz/9i6WlTQh1CkFVIo2Y7XA=="], + + "@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], + + "@inquirer/editor": ["@inquirer/editor@5.1.0", "", { "dependencies": { "@inquirer/core": "^11.1.8", "@inquirer/external-editor": "^3.0.0", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6wlkYl65Qfayy48gPCfU4D7li6KCAGN79mLXa/tYHZH99OfZ820yY+HA+DgE88r8YwwgeuY6PQgNqMeK6LuMmw=="], + + "@inquirer/expand": ["@inquirer/expand@5.0.11", "", { "dependencies": { "@inquirer/core": "^11.1.8", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yxSO89MQ7t4LTCwtsXQ/ppcfw2otLsum6nF+TM9pKesy3k2AhVDUIkaiJIwG6lzm/csc5n38MaFKLY0TrSHzEA=="], + + "@inquirer/external-editor": ["@inquirer/external-editor@3.0.0", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg=="], + + "@inquirer/figures": ["@inquirer/figures@2.0.5", "", {}, "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ=="], + + "@inquirer/input": ["@inquirer/input@5.0.11", "", { "dependencies": { "@inquirer/core": "^11.1.8", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-twUWidn4ocPO8qi6fRM7tNWt7W1FOnOZqQ+/+PsfLUacMR5rFLDPK9ql0nBPwxi0oELbo8T5NhRs8B2+qQEqFQ=="], + + "@inquirer/number": ["@inquirer/number@4.0.11", "", { "dependencies": { "@inquirer/core": "^11.1.8", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Vscmim9TCksQsfjPtka/JwPUcbLhqWYrgfPf1cHrCm24X/F2joFwnageD50yMKsaX14oNGOyKf/RNXAFkNjWpA=="], + + "@inquirer/password": ["@inquirer/password@5.0.11", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.8", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-9KZFeRaNHIcejtPb0wN4ddFc7EvobVoAFa049eS3LrDZFxI8O7xUXiITEOinBzkZFAIwY5V4yzQae/QfO9cbbg=="], + + "@inquirer/prompts": ["@inquirer/prompts@8.4.0", "", { "dependencies": { "@inquirer/checkbox": "^5.1.3", "@inquirer/confirm": "^6.0.11", "@inquirer/editor": "^5.1.0", "@inquirer/expand": "^5.0.11", "@inquirer/input": "^5.0.11", "@inquirer/number": "^4.0.11", "@inquirer/password": "^5.0.11", "@inquirer/rawlist": "^5.2.7", "@inquirer/search": "^4.1.7", "@inquirer/select": "^5.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Z3pFkae4WSzK95tvbaxR3rD9JlScFIh6/Ufw60H8Ck7GugdzYCe/3FwZCfvXwHZXjyk671w8FnVuwvxx1eP7ug=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@5.2.7", "", { "dependencies": { "@inquirer/core": "^11.1.8", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-AqRMiD9+uE1lskDPrdqHwrV/EUmxKEBLX44SR7uxK3vD2413AmVfE5EQaPeNzYf5Pq5SitHJDYUFVF0poIr09w=="], + + "@inquirer/search": ["@inquirer/search@4.1.7", "", { "dependencies": { "@inquirer/core": "^11.1.8", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-1y7+0N65AWk5RdlXH/Kn13txf3IjIQ7OEfhCEkDTU+h5wKMLq8DUF3P6z+/kLSxDGDtQT1dRBWEUC3o/VvImsQ=="], + + "@inquirer/select": ["@inquirer/select@5.1.3", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.8", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-zYyqWgGQi3NhBcNq4Isc5rB3oEdQEh1Q/EcAnOW0FK4MpnXWkvSBYgA4cYrTM4A9UB573omouZbnL9JJ74Mq3A=="], + + "@inquirer/type": ["@inquirer/type@4.0.5", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -317,6 +357,134 @@ "@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="], + "@napi-rs/cli": ["@napi-rs/cli@3.6.0", "", { "dependencies": { "@inquirer/prompts": "^8.0.0", "@napi-rs/cross-toolchain": "^1.0.3", "@napi-rs/wasm-tools": "^1.0.1", "@octokit/rest": "^22.0.1", "clipanion": "^4.0.0-rc.4", "colorette": "^2.0.20", "emnapi": "^1.9.1", "es-toolkit": "^1.41.0", "js-yaml": "^4.1.0", "obug": "^2.0.0", "semver": "^7.7.3", "typanion": "^3.14.0" }, "peerDependencies": { "@emnapi/runtime": "^1.7.1" }, "optionalPeers": ["@emnapi/runtime"], "bin": { "napi": "dist/cli.js", "napi-raw": "cli.mjs" } }, "sha512-aA8m4+9XxnK1+0sr4GplZP0Ze90gkzO8sMKaplOK0zXbLnzsLl6O2BQQt6rTCcTRzIN24wrrByakr/imM+CxhA=="], + + "@napi-rs/cross-toolchain": ["@napi-rs/cross-toolchain@1.0.3", "", { "dependencies": { "@napi-rs/lzma": "^1.4.5", "@napi-rs/tar": "^1.1.0", "debug": "^4.4.1" }, "peerDependencies": { "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" }, "optionalPeers": ["@napi-rs/cross-toolchain-arm64-target-aarch64", "@napi-rs/cross-toolchain-arm64-target-armv7", "@napi-rs/cross-toolchain-arm64-target-ppc64le", "@napi-rs/cross-toolchain-arm64-target-s390x", "@napi-rs/cross-toolchain-arm64-target-x86_64", "@napi-rs/cross-toolchain-x64-target-aarch64", "@napi-rs/cross-toolchain-x64-target-armv7", "@napi-rs/cross-toolchain-x64-target-ppc64le", "@napi-rs/cross-toolchain-x64-target-s390x", "@napi-rs/cross-toolchain-x64-target-x86_64"] }, "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg=="], + + "@napi-rs/lzma": ["@napi-rs/lzma@1.4.5", "", { "optionalDependencies": { "@napi-rs/lzma-android-arm-eabi": "1.4.5", "@napi-rs/lzma-android-arm64": "1.4.5", "@napi-rs/lzma-darwin-arm64": "1.4.5", "@napi-rs/lzma-darwin-x64": "1.4.5", "@napi-rs/lzma-freebsd-x64": "1.4.5", "@napi-rs/lzma-linux-arm-gnueabihf": "1.4.5", "@napi-rs/lzma-linux-arm64-gnu": "1.4.5", "@napi-rs/lzma-linux-arm64-musl": "1.4.5", "@napi-rs/lzma-linux-ppc64-gnu": "1.4.5", "@napi-rs/lzma-linux-riscv64-gnu": "1.4.5", "@napi-rs/lzma-linux-s390x-gnu": "1.4.5", "@napi-rs/lzma-linux-x64-gnu": "1.4.5", "@napi-rs/lzma-linux-x64-musl": "1.4.5", "@napi-rs/lzma-wasm32-wasi": "1.4.5", "@napi-rs/lzma-win32-arm64-msvc": "1.4.5", "@napi-rs/lzma-win32-ia32-msvc": "1.4.5", "@napi-rs/lzma-win32-x64-msvc": "1.4.5" } }, "sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg=="], + + "@napi-rs/lzma-android-arm-eabi": ["@napi-rs/lzma-android-arm-eabi@1.4.5", "", { "os": "android", "cpu": "arm" }, "sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q=="], + + "@napi-rs/lzma-android-arm64": ["@napi-rs/lzma-android-arm64@1.4.5", "", { "os": "android", "cpu": "arm64" }, "sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ=="], + + "@napi-rs/lzma-darwin-arm64": ["@napi-rs/lzma-darwin-arm64@1.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA=="], + + "@napi-rs/lzma-darwin-x64": ["@napi-rs/lzma-darwin-x64@1.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw=="], + + "@napi-rs/lzma-freebsd-x64": ["@napi-rs/lzma-freebsd-x64@1.4.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw=="], + + "@napi-rs/lzma-linux-arm-gnueabihf": ["@napi-rs/lzma-linux-arm-gnueabihf@1.4.5", "", { "os": "linux", "cpu": "arm" }, "sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw=="], + + "@napi-rs/lzma-linux-arm64-gnu": ["@napi-rs/lzma-linux-arm64-gnu@1.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg=="], + + "@napi-rs/lzma-linux-arm64-musl": ["@napi-rs/lzma-linux-arm64-musl@1.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w=="], + + "@napi-rs/lzma-linux-ppc64-gnu": ["@napi-rs/lzma-linux-ppc64-gnu@1.4.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ=="], + + "@napi-rs/lzma-linux-riscv64-gnu": ["@napi-rs/lzma-linux-riscv64-gnu@1.4.5", "", { "os": "linux", "cpu": "none" }, "sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA=="], + + "@napi-rs/lzma-linux-s390x-gnu": ["@napi-rs/lzma-linux-s390x-gnu@1.4.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw=="], + + "@napi-rs/lzma-linux-x64-musl": ["@napi-rs/lzma-linux-x64-musl@1.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ=="], + + "@napi-rs/lzma-wasm32-wasi": ["@napi-rs/lzma-wasm32-wasi@1.4.5", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA=="], + + "@napi-rs/lzma-win32-arm64-msvc": ["@napi-rs/lzma-win32-arm64-msvc@1.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg=="], + + "@napi-rs/lzma-win32-ia32-msvc": ["@napi-rs/lzma-win32-ia32-msvc@1.4.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg=="], + + "@napi-rs/lzma-win32-x64-msvc": ["@napi-rs/lzma-win32-x64-msvc@1.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q=="], + + "@napi-rs/tar": ["@napi-rs/tar@1.1.0", "", { "optionalDependencies": { "@napi-rs/tar-android-arm-eabi": "1.1.0", "@napi-rs/tar-android-arm64": "1.1.0", "@napi-rs/tar-darwin-arm64": "1.1.0", "@napi-rs/tar-darwin-x64": "1.1.0", "@napi-rs/tar-freebsd-x64": "1.1.0", "@napi-rs/tar-linux-arm-gnueabihf": "1.1.0", "@napi-rs/tar-linux-arm64-gnu": "1.1.0", "@napi-rs/tar-linux-arm64-musl": "1.1.0", "@napi-rs/tar-linux-ppc64-gnu": "1.1.0", "@napi-rs/tar-linux-s390x-gnu": "1.1.0", "@napi-rs/tar-linux-x64-gnu": "1.1.0", "@napi-rs/tar-linux-x64-musl": "1.1.0", "@napi-rs/tar-wasm32-wasi": "1.1.0", "@napi-rs/tar-win32-arm64-msvc": "1.1.0", "@napi-rs/tar-win32-ia32-msvc": "1.1.0", "@napi-rs/tar-win32-x64-msvc": "1.1.0" } }, "sha512-7cmzIu+Vbupriudo7UudoMRH2OA3cTw67vva8MxeoAe5S7vPFI7z0vp0pMXiA25S8IUJefImQ90FeJjl8fjEaQ=="], + + "@napi-rs/tar-android-arm-eabi": ["@napi-rs/tar-android-arm-eabi@1.1.0", "", { "os": "android", "cpu": "arm" }, "sha512-h2Ryndraj/YiKgMV/r5by1cDusluYIRT0CaE0/PekQ4u+Wpy2iUVqvzVU98ZPnhXaNeYxEvVJHNGafpOfaD0TA=="], + + "@napi-rs/tar-android-arm64": ["@napi-rs/tar-android-arm64@1.1.0", "", { "os": "android", "cpu": "arm64" }, "sha512-DJFyQHr1ZxNZorm/gzc1qBNLF/FcKzcH0V0Vwan5P+o0aE2keQIGEjJ09FudkF9v6uOuJjHCVDdK6S6uHtShAw=="], + + "@napi-rs/tar-darwin-arm64": ["@napi-rs/tar-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Zz2sXRzjIX4e532zD6xm2SjXEym6MkvfCvL2RMpG2+UwNVDVscHNcz3d47Pf3sysP2e2af7fBB3TIoK2f6trPw=="], + + "@napi-rs/tar-darwin-x64": ["@napi-rs/tar-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EI+CptIMNweT0ms9S3mkP/q+J6FNZ1Q6pvpJOEcWglRfyfQpLqjlC0O+dptruTPE8VamKYuqdjxfqD8hifZDOA=="], + + "@napi-rs/tar-freebsd-x64": ["@napi-rs/tar-freebsd-x64@1.1.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J0PIqX+pl6lBIAckL/c87gpodLbjZB1OtIK+RDscKC9NLdpVv6VGOxzUV/fYev/hctcE8EfkLbgFOfpmVQPg2g=="], + + "@napi-rs/tar-linux-arm-gnueabihf": ["@napi-rs/tar-linux-arm-gnueabihf@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-SLgIQo3f3EjkZ82ZwvrEgFvMdDAhsxCYjyoSuWfHCz0U16qx3SuGCp8+FYOPYCECHN3ZlGjXnoAIt9ERd0dEUg=="], + + "@napi-rs/tar-linux-arm64-gnu": ["@napi-rs/tar-linux-arm64-gnu@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-d014cdle52EGaH6GpYTQOP9Py7glMO1zz/+ynJPjjzYFSxvdYx0byrjumZk2UQdIyGZiJO2MEFpCkEEKFSgPYA=="], + + "@napi-rs/tar-linux-arm64-musl": ["@napi-rs/tar-linux-arm64-musl@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ=="], + + "@napi-rs/tar-linux-ppc64-gnu": ["@napi-rs/tar-linux-ppc64-gnu@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ=="], + + "@napi-rs/tar-linux-s390x-gnu": ["@napi-rs/tar-linux-s390x-gnu@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ=="], + + "@napi-rs/tar-linux-x64-gnu": ["@napi-rs/tar-linux-x64-gnu@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww=="], + + "@napi-rs/tar-linux-x64-musl": ["@napi-rs/tar-linux-x64-musl@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ=="], + + "@napi-rs/tar-wasm32-wasi": ["@napi-rs/tar-wasm32-wasi@1.1.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw=="], + + "@napi-rs/tar-win32-arm64-msvc": ["@napi-rs/tar-win32-arm64-msvc@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-vfpG71OB0ijtjemp3WTdmBKJm9R70KM8vsSExMsIQtV0lVzP07oM1CW6JbNRPXNLhRoue9ofYLiUDk8bE0Hckg=="], + + "@napi-rs/tar-win32-ia32-msvc": ["@napi-rs/tar-win32-ia32-msvc@1.1.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-hGPyPW60YSpOSgzfy68DLBHgi6HxkAM+L59ZZZPMQ0TOXjQg+p2EW87+TjZfJOkSpbYiEkULwa/f4a2hcVjsqQ=="], + + "@napi-rs/tar-win32-x64-msvc": ["@napi-rs/tar-win32-x64-msvc@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], + + "@napi-rs/wasm-tools": ["@napi-rs/wasm-tools@1.0.1", "", { "optionalDependencies": { "@napi-rs/wasm-tools-android-arm-eabi": "1.0.1", "@napi-rs/wasm-tools-android-arm64": "1.0.1", "@napi-rs/wasm-tools-darwin-arm64": "1.0.1", "@napi-rs/wasm-tools-darwin-x64": "1.0.1", "@napi-rs/wasm-tools-freebsd-x64": "1.0.1", "@napi-rs/wasm-tools-linux-arm64-gnu": "1.0.1", "@napi-rs/wasm-tools-linux-arm64-musl": "1.0.1", "@napi-rs/wasm-tools-linux-x64-gnu": "1.0.1", "@napi-rs/wasm-tools-linux-x64-musl": "1.0.1", "@napi-rs/wasm-tools-wasm32-wasi": "1.0.1", "@napi-rs/wasm-tools-win32-arm64-msvc": "1.0.1", "@napi-rs/wasm-tools-win32-ia32-msvc": "1.0.1", "@napi-rs/wasm-tools-win32-x64-msvc": "1.0.1" } }, "sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ=="], + + "@napi-rs/wasm-tools-android-arm-eabi": ["@napi-rs/wasm-tools-android-arm-eabi@1.0.1", "", { "os": "android", "cpu": "arm" }, "sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw=="], + + "@napi-rs/wasm-tools-android-arm64": ["@napi-rs/wasm-tools-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-WDR7S+aRLV6LtBJAg5fmjKkTZIdrEnnQxgdsb7Cf8pYiMWBHLU+LC49OUVppQ2YSPY0+GeYm9yuZWW3kLjJ7Bg=="], + + "@napi-rs/wasm-tools-darwin-arm64": ["@napi-rs/wasm-tools-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qWTI+EEkiN0oIn/N2gQo7+TVYil+AJ20jjuzD2vATS6uIjVz+Updeqmszi7zq7rdFTLp6Ea3/z4kDKIfZwmR9g=="], + + "@napi-rs/wasm-tools-darwin-x64": ["@napi-rs/wasm-tools-darwin-x64@1.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-bA6hubqtHROR5UI3tToAF/c6TDmaAgF0SWgo4rADHtQ4wdn0JeogvOk50gs2TYVhKPE2ZD2+qqt7oBKB+sxW3A=="], + + "@napi-rs/wasm-tools-freebsd-x64": ["@napi-rs/wasm-tools-freebsd-x64@1.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-90+KLBkD9hZEjPQW1MDfwSt5J1L46EUKacpCZWyRuL6iIEO5CgWU0V/JnEgFsDOGyyYtiTvHc5bUdUTWd4I9Vg=="], + + "@napi-rs/wasm-tools-linux-arm64-gnu": ["@napi-rs/wasm-tools-linux-arm64-gnu@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-rG0QlS65x9K/u3HrKafDf8cFKj5wV2JHGfl8abWgKew0GVPyp6vfsDweOwHbWAjcHtp2LHi6JHoW80/MTHm52Q=="], + + "@napi-rs/wasm-tools-linux-arm64-musl": ["@napi-rs/wasm-tools-linux-arm64-musl@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jAasbIvjZXCgX0TCuEFQr+4D6Lla/3AAVx2LmDuMjgG4xoIXzjKWl7c4chuaD+TI+prWT0X6LJcdzFT+ROKGHQ=="], + + "@napi-rs/wasm-tools-linux-x64-gnu": ["@napi-rs/wasm-tools-linux-x64-gnu@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Plgk5rPqqK2nocBGajkMVbGm010Z7dnUgq0wtnYRZbzWWxwWcXfZMPa8EYxrK4eE8SzpI7VlZP1tdVsdjgGwMw=="], + + "@napi-rs/wasm-tools-linux-x64-musl": ["@napi-rs/wasm-tools-linux-x64-musl@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GW7AzGuWxtQkyHknHWYFdR0CHmW6is8rG2Rf4V6GNmMpmwtXt/ItWYWtBe4zqJWycMNazpfZKSw/BpT7/MVCXQ=="], + + "@napi-rs/wasm-tools-wasm32-wasi": ["@napi-rs/wasm-tools-wasm32-wasi@1.0.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-/nQVSTrqSsn7YdAc2R7Ips/tnw5SPUcl3D7QrXCNGPqjbatIspnaexvaOYNyKMU6xPu+pc0BTnKVmqhlJJCPLA=="], + + "@napi-rs/wasm-tools-win32-arm64-msvc": ["@napi-rs/wasm-tools-win32-arm64-msvc@1.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-PFi7oJIBu5w7Qzh3dwFea3sHRO3pojMsaEnUIy22QvsW+UJfNQwJCryVrpoUt8m4QyZXI+saEq/0r4GwdoHYFQ=="], + + "@napi-rs/wasm-tools-win32-ia32-msvc": ["@napi-rs/wasm-tools-win32-ia32-msvc@1.0.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-gXkuYzxQsgkj05Zaq+KQTkHIN83dFAwMcTKa2aQcpYPRImFm2AQzEyLtpXmyCWzJ0F9ZYAOmbSyrNew8/us6bw=="], + + "@napi-rs/wasm-tools-win32-x64-msvc": ["@napi-rs/wasm-tools-win32-x64-msvc@1.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rEAf05nol3e3eei2sRButmgXP+6ATgm0/38MKhz9Isne82T4rPIMYsCIFj0kOisaGeVwoi2fnm7O9oWp5YVnYQ=="], + + "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], + + "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + + "@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + + "@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + + "@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], + + "@octokit/request": ["@octokit/request@10.0.8", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw=="], + + "@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="], + + "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@workspace:packages/stats"], "@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@workspace:packages/agent"], @@ -457,6 +625,8 @@ "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], @@ -535,6 +705,8 @@ "beautiful-mermaid": ["beautiful-mermaid@1.1.3", "", { "dependencies": { "elkjs": "^0.11.0", "entities": "^7.0.1" } }, "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg=="], + "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], @@ -553,6 +725,8 @@ "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], "chromium-bidi": ["chromium-bidi@14.0.0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw=="], @@ -561,6 +735,10 @@ "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + + "clipanion": ["clipanion@4.0.0-rc.4", "", { "dependencies": { "typanion": "^3.8.0" } }, "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], @@ -619,6 +797,8 @@ "elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="], + "emnapi": ["emnapi@1.9.2", "", { "peerDependencies": { "node-addon-api": ">= 6.1.0" }, "optionalPeers": ["node-addon-api"] }, "sha512-OdUoQe/8so7FvubnE/DNV9sNNSFwDYQiK4ZCAz4agMnD1s6faLuDn2gzxfJrmMoKfxZhhsckqGNwqPnS5K140A=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], @@ -635,6 +815,8 @@ "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], @@ -655,12 +837,20 @@ "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], + "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="], "fast-xml-parser": ["fast-xml-parser@5.5.10", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.1", "strnum": "^2.2.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w=="], @@ -709,6 +899,8 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], @@ -743,6 +935,8 @@ "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="], + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], @@ -793,7 +987,7 @@ "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="], - "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "lru-cache": ["lru-cache@11.3.1", "", {}, "sha512-Y71HWT4hydF1IAG/2OPync4dgQ/J2iWye7eg6CuzJHI+E97tvqFPlADzxiNnjH6WSljg8ecfXMr9k6bfFuqA5w=="], "lucide-react": ["lucide-react@0.576.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-koNxU14BXrxUfZQ9cUaP0ES1uyPZKYDjk31FQZB6dQ/x+tXk979sVAn9ppZ/pVeJJyOxVM8j1E+8QEuSc02Vug=="], @@ -821,6 +1015,8 @@ "music-metadata": ["music-metadata@11.12.3", "", { "dependencies": { "@borewit/text-codec": "^0.2.2", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", "file-type": "^21.3.1", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0", "win-guid": "^0.2.1" } }, "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ=="], + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], @@ -835,6 +1031,8 @@ "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], @@ -917,6 +1115,8 @@ "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -985,6 +1185,8 @@ "turndown-plugin-gfm": ["turndown-plugin-gfm@1.0.2", "", {}, "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg=="], + "typanion": ["typanion@3.14.0", "", {}, "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug=="], + "typed-query-selector": ["typed-query-selector@2.12.1", "", {}, "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA=="], "typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], @@ -999,6 +1201,8 @@ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], @@ -1063,6 +1267,8 @@ "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "rss-parser/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index 062da69a7..70e313f12 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -18,6 +18,8 @@ tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["full"] } napi = { version = "3", features = ["napi10", "tokio_rt", "tokio_time"] } napi-derive = "3" +bytes = "1.7" +bytes-utils = "0.1" brush-core = { version = "0.4.0", path = "../brush-core-vendored" } brush-builtins = { version = "0.1.0", path = "../brush-builtins-vendored" } parking_lot = "0.12.5" @@ -105,6 +107,10 @@ smallvec = { version = "1.15.1", features = [ ] } memmap2 = "0.9" xxhash-rust = { version = "0.8", features = ["xxh64"] } +regex = "1" +similar = "3.0.0" +path-clean = "1" +pathdiff = "0.2" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/pi-natives/src/appearance.rs b/crates/pi-natives/src/appearance.rs index 1f2def870..bae49083f 100644 --- a/crates/pi-natives/src/appearance.rs +++ b/crates/pi-natives/src/appearance.rs @@ -13,6 +13,19 @@ use napi_derive::napi; +/// System UI appearance reported by native macOS APIs (`detectMacOSAppearance` +/// and observer). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[napi(string_enum)] +pub enum MacOSAppearance { + /// Dark color scheme. + #[napi(value = "dark")] + Dark, + /// Light color scheme. + #[napi(value = "light")] + Light, +} + // --------------------------------------------------------------------------- // macOS implementation // --------------------------------------------------------------------------- @@ -29,6 +42,8 @@ mod platform { use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; use parking_lot::Mutex; + use super::MacOSAppearance; + // -- CoreFoundation FFI types ------------------------------------------- type CFStringRef = *const c_void; @@ -149,15 +164,14 @@ mod platform { // -- Sync detection ----------------------------------------------------- /// Read `AppleInterfaceStyle` via CoreFoundation preferences. - /// Returns `"dark"` or `"light"`. - pub fn detect_appearance() -> String { + pub fn detect_appearance() -> MacOSAppearance { // SAFETY: CoreFoundation pointers are null-checked, type-checked where needed, // and every object created or copied here is released exactly once before // return. unsafe { let key = create_cf_string("AppleInterfaceStyle"); if key.is_null() { - return "light".into(); + return MacOSAppearance::Light; } let value = CFPreferencesCopyAppValue(key, kCFPreferencesAnyApplication); @@ -165,20 +179,20 @@ mod platform { if value.is_null() { // Key absent = light mode (no dark mode override set). - return "light".into(); + return MacOSAppearance::Light; } if CFGetTypeID(value) != CFStringGetTypeID() { CFRelease(value); - return "light".into(); + return MacOSAppearance::Light; } let result = cf_string_to_string(value); CFRelease(value); if result == "Dark" { - "dark".into() + MacOSAppearance::Dark } else { - "light".into() + MacOSAppearance::Light } } } @@ -196,10 +210,10 @@ mod platform { /// Shared context for the notification callback and the poll timer. struct CallbackCtx { - tsfn: ThreadsafeFunction, + tsfn: ThreadsafeFunction, /// Last reported appearance — used for dedup so we never fire twice /// for the same value (notification + timer can race). - last: Mutex, + last: Mutex>, } impl CallbackCtx { @@ -207,8 +221,8 @@ mod platform { fn report_if_changed(&self) { let appearance = detect_appearance(); let mut last = self.last.lock(); - if *last != appearance { - (*last).clone_from(&appearance); + if last.as_ref() != Some(&appearance) { + *last = Some(appearance); self .tsfn .call(Ok(appearance), ThreadsafeFunctionCallMode::NonBlocking); @@ -266,7 +280,7 @@ mod platform { } impl ObserverInner { - pub fn start(tsfn: ThreadsafeFunction) -> Self { + pub fn start(tsfn: ThreadsafeFunction) -> Self { let run_loop: Arc>> = Arc::new(Mutex::new(None)); let rl_clone = run_loop.clone(); @@ -283,7 +297,7 @@ mod platform { *rl_clone.lock() = Some(SendableRunLoop(rl)); let _ = tx.send(()); - let ctx = Box::new(CallbackCtx { tsfn, last: Mutex::new(String::new()) }); + let ctx = Box::new(CallbackCtx { tsfn, last: Mutex::new(None) }); let ctx_ptr = Box::into_raw(ctx); // -- Register for distributed notification --------------- @@ -381,7 +395,7 @@ mod platform { /// Returns `"dark"` or `"light"` on macOS, `null` on other platforms. #[napi(js_name = "detectMacOSAppearance")] #[allow(clippy::missing_const_for_fn, reason = "napi macro is incompatible with const fn")] -pub fn detect_macos_appearance() -> Option { +pub fn detect_macos_appearance() -> Option { #[cfg(target_os = "macos")] { Some(platform::detect_appearance()) @@ -413,8 +427,8 @@ pub struct MacAppearanceObserver { impl MacAppearanceObserver { #[napi(factory)] pub fn start( - #[napi(ts_arg_type = "(err: null | Error, appearance: string) => void")] - callback: napi::threadsafe_function::ThreadsafeFunction, + #[napi(ts_arg_type = "(err: null | Error, appearance: MacOSAppearance) => void")] + callback: napi::threadsafe_function::ThreadsafeFunction, ) -> napi::Result { #[cfg(target_os = "macos")] { diff --git a/crates/pi-natives/src/ast.rs b/crates/pi-natives/src/ast.rs index 24320efd5..cdb154270 100644 --- a/crates/pi-natives/src/ast.rs +++ b/crates/pi-natives/src/ast.rs @@ -15,121 +15,232 @@ use crate::{fs_cache, glob_util, language::SupportLang, task}; const DEFAULT_FIND_LIMIT: u32 = 50; +/// ast-grep pattern strictness (controls how patterns match syntax). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[napi(string_enum)] +pub enum AstMatchStrictness { + /// Match at the concrete syntax tree level. + #[napi(value = "cst")] + Cst, + /// Balanced default suitable for most searches. + #[napi(value = "smart")] + Smart, + /// Match at the AST level. + #[napi(value = "ast")] + Ast, + /// More permissive matching. + #[napi(value = "relaxed")] + Relaxed, + /// Match structural signatures. + #[napi(value = "signature")] + Signature, + /// Template-style pattern matching. + #[napi(value = "template")] + Template, +} + +impl From for MatchStrictness { + fn from(value: AstMatchStrictness) -> Self { + match value { + AstMatchStrictness::Cst => Self::Cst, + AstMatchStrictness::Smart => Self::Smart, + AstMatchStrictness::Ast => Self::Ast, + AstMatchStrictness::Relaxed => Self::Relaxed, + AstMatchStrictness::Signature => Self::Signature, + AstMatchStrictness::Template => Self::Template, + } + } +} + +fn resolve_strictness(value: Option) -> MatchStrictness { + value.map_or(MatchStrictness::Smart, Into::into) +} + +/// Options for `astGrep`: patterns, scan scope, and match limits. #[napi(object)] pub struct AstFindOptions<'env> { + /// ast-grep patterns to search for (OR across patterns). pub patterns: Option>, + /// Language override; otherwise inferred from file extension per candidate. pub lang: Option, + /// Single file or directory to scan (combined with `glob` when set). pub path: Option, + /// Optional glob filter relative to the search root. pub glob: Option, + /// Rule selector for multi-rule ast-grep configurations. pub selector: Option, - pub strictness: Option, + /// Pattern strictness; defaults to smart matching when omitted. + pub strictness: Option, + /// Maximum matches to return after `offset` (default applies when omitted). pub limit: Option, + /// Number of leading matches to skip before applying `limit`. pub offset: Option, + /// When true, include meta-variable bindings per match. #[napi(js_name = "includeMeta")] pub include_meta: Option, + /// Reserved for contextual snippets; not used by the current native find + /// path. pub context: Option, + /// Optional cancellation handle (library-specific). pub signal: Option>, + /// Wall-clock timeout for the worker task in milliseconds. #[napi(js_name = "timeoutMs")] pub timeout_ms: Option, } +/// One ast-grep match with source range and optional meta-variables. #[napi(object)] pub struct AstFindMatch { + /// Display path of the matching file. pub path: String, + /// Matched source text. pub text: String, + /// Start byte offset in the file (UTF-8 byte index). #[napi(js_name = "byteStart")] pub byte_start: u32, + /// End byte offset in the file (exclusive UTF-8 byte index). #[napi(js_name = "byteEnd")] pub byte_end: u32, + /// 1-based start line. #[napi(js_name = "startLine")] pub start_line: u32, + /// 1-based start column. #[napi(js_name = "startColumn")] pub start_column: u32, + /// 1-based end line. #[napi(js_name = "endLine")] pub end_line: u32, + /// 1-based end column. #[napi(js_name = "endColumn")] pub end_column: u32, + /// Meta-variable name to captured text, when `includeMeta` was enabled. #[napi(js_name = "metaVariables")] pub meta_variables: Option>, } +/// Aggregated search statistics and any parse or compile diagnostics. #[napi(object)] pub struct AstFindResult { + /// Page of matches after sort, offset, and limit. pub matches: Vec, + /// Total matches found before paging (can exceed `matches.length`). #[napi(js_name = "totalMatches")] pub total_matches: u32, + /// Distinct files that contained at least one match. #[napi(js_name = "filesWithMatches")] pub files_with_matches: u32, + /// Files examined for the query. #[napi(js_name = "filesSearched")] pub files_searched: u32, + /// True when results were truncated by `limit`. #[napi(js_name = "limitReached")] pub limit_reached: bool, + /// Non-fatal parse or pattern errors collected during the run. #[napi(js_name = "parseErrors")] pub parse_errors: Option>, } +/// Options for `astEdit`: rewrite rules, scan scope, safety limits, and +/// dry-run. #[napi(object)] pub struct AstReplaceOptions<'env> { + /// Map of pattern string to replacement template. pub rewrites: Option>, + /// Language override; otherwise inferred from discovered files. pub lang: Option, + /// Single file or directory to rewrite. pub path: Option, + /// Optional glob filter within the search root. pub glob: Option, + /// Rule selector for multi-rule configurations. pub selector: Option, - pub strictness: Option, + /// Pattern strictness for rewrites. + pub strictness: Option, + /// When true (default), compute changes without writing files. #[napi(js_name = "dryRun")] pub dry_run: Option, + /// Cap on replacement applications across all files. #[napi(js_name = "maxReplacements")] pub max_replacements: Option, + /// Cap on distinct files that may be modified. #[napi(js_name = "maxFiles")] pub max_files: Option, + /// Fail the operation when a file cannot be parsed for rewriting. #[napi(js_name = "failOnParseError")] pub fail_on_parse_error: Option, + /// Optional cancellation handle. pub signal: Option>, + /// Wall-clock timeout for the worker task in milliseconds. #[napi(js_name = "timeoutMs")] pub timeout_ms: Option, } +/// One textual replacement applied to a file (before/after slice and +/// coordinates). #[napi(object)] pub struct AstReplaceChange { + /// File path for this change. pub path: String, + /// Original matched text. pub before: String, + /// Replacement text. pub after: String, + /// Start byte offset of the replaced span. #[napi(js_name = "byteStart")] pub byte_start: u32, + /// End byte offset of the replaced span (exclusive). #[napi(js_name = "byteEnd")] pub byte_end: u32, + /// Length of deleted text in bytes (may differ from `byteEnd - byteStart` + /// for edge cases). #[napi(js_name = "deletedLength")] pub deleted_length: u32, + /// 1-based start line of the match. #[napi(js_name = "startLine")] pub start_line: u32, + /// 1-based start column. #[napi(js_name = "startColumn")] pub start_column: u32, + /// 1-based end line. #[napi(js_name = "endLine")] pub end_line: u32, + /// 1-based end column. #[napi(js_name = "endColumn")] pub end_column: u32, } +/// Per-file replacement count after an `astEdit` run. #[napi(object)] pub struct AstReplaceFileChange { + /// File that had replacements. pub path: String, + /// Number of replacements in that file. pub count: u32, } +/// Summary of an ast-grep rewrite pass, including whether disk writes occurred. #[napi(object)] pub struct AstReplaceResult { + /// Individual replacement records (may be large). pub changes: Vec, + /// Replacement counts grouped by file. #[napi(js_name = "fileChanges")] pub file_changes: Vec, + /// Total replacements applied or previewed. #[napi(js_name = "totalReplacements")] pub total_replacements: u32, + /// Files that had at least one replacement. #[napi(js_name = "filesTouched")] pub files_touched: u32, + /// Files considered for rewriting. #[napi(js_name = "filesSearched")] pub files_searched: u32, + /// False when `dryRun` prevented writing. pub applied: bool, + /// True when limits stopped further replacements. #[napi(js_name = "limitReached")] pub limit_reached: bool, + /// Parse or pattern errors when not failing the whole operation. #[napi(js_name = "parseErrors")] pub parse_errors: Option>, } @@ -223,14 +334,6 @@ fn infer_single_replace_lang( } Ok(inferred.into_iter().next().expect("non-empty inferred set")) } -fn parse_strictness(value: Option<&str>) -> Result { - let Some(raw) = value.map(str::trim).filter(|v| !v.is_empty()) else { - return Ok(MatchStrictness::Smart); - }; - raw.parse::() - .map_err(|err| Error::from_reason(format!("Invalid strictness '{raw}': {err}"))) -} - fn normalize_search_path(path: Option) -> Result { let raw = path.unwrap_or_else(|| ".".to_string()); let candidate = PathBuf::from(raw.trim()); @@ -483,8 +586,10 @@ fn compile_find_patterns( Ok(compiled) } +/// Search source files with ast-grep patterns; returns a promise resolved on a +/// worker thread. #[napi(js_name = "astGrep")] -pub fn ast_grep(options: AstFindOptions<'_>) -> task::Async { +pub fn ast_grep(options: AstFindOptions<'_>) -> task::Promise { let AstFindOptions { patterns, lang, @@ -506,7 +611,7 @@ pub fn ast_grep(options: AstFindOptions<'_>) -> task::Async { task::blocking("ast_grep", ct, move |ct| { let patterns = normalize_pattern_list(patterns)?; - let strictness = parse_strictness(strictness.as_deref())?; + let strictness = resolve_strictness(strictness); let include_meta = include_meta.unwrap_or(false); let lang_str = lang.as_deref().map(str::trim).filter(|v| !v.is_empty()); let candidates: Vec<_> = collect_candidates(path, glob.as_deref(), &ct)? @@ -637,8 +742,10 @@ pub fn ast_grep(options: AstFindOptions<'_>) -> task::Async { }) } +/// Apply ast-grep rewrite rules to matching files; honors `dryRun` and returns +/// a promise. #[napi(js_name = "astEdit")] -pub fn ast_edit(options: AstReplaceOptions<'_>) -> task::Async { +pub fn ast_edit(options: AstReplaceOptions<'_>) -> task::Promise { let AstReplaceOptions { rewrites, lang, @@ -657,7 +764,7 @@ pub fn ast_edit(options: AstReplaceOptions<'_>) -> task::Async let ct = task::CancelToken::new(timeout_ms, signal); task::blocking("ast_edit", ct, move |ct| { let rewrite_rules = normalize_rewrite_map(rewrites)?; - let strictness = parse_strictness(strictness.as_deref())?; + let strictness = resolve_strictness(strictness); let dry_run = dry_run.unwrap_or(true); let max_replacements = max_replacements.unwrap_or(u32::MAX).max(1); let max_files = max_files.unwrap_or(u32::MAX).max(1); diff --git a/crates/pi-natives/src/chunk/ast_go.rs b/crates/pi-natives/src/chunk/ast_go.rs index b17d3060a..b7c5f8fd9 100644 --- a/crates/pi-natives/src/chunk/ast_go.rs +++ b/crates/pi-natives/src/chunk/ast_go.rs @@ -275,7 +275,7 @@ fn reparent_receiver_methods( if let Some(type_idx) = chunks.iter().position(|c| c.path == *type_path) { let type_chunk = &mut chunks[type_idx]; - type_chunk.kind = "branch".to_string(); + type_chunk.leaf = false; if !type_chunk.children.iter().any(|child| child == &new_path) { type_chunk.children.push(new_path); } @@ -331,7 +331,7 @@ fn reparent_new_type_constructors( if let Some(type_idx) = chunks.iter().position(|c| c.path == *type_path) { let type_chunk = &mut chunks[type_idx]; - type_chunk.kind = "branch".to_string(); + type_chunk.leaf = false; if !type_chunk.children.iter().any(|child| child == &new_path) { type_chunk.children.push(new_path); } diff --git a/crates/pi-natives/src/chunk/ast_tlaplus.rs b/crates/pi-natives/src/chunk/ast_tlaplus.rs index 4ec906188..dc4ba4e5d 100644 --- a/crates/pi-natives/src/chunk/ast_tlaplus.rs +++ b/crates/pi-natives/src/chunk/ast_tlaplus.rs @@ -256,7 +256,7 @@ fn translation_chunk( ChunkNode { path, name: format!("translation_{}", range.start_line), - kind: "leaf".to_string(), + leaf: true, parent_path, children: Vec::new(), signature: Some("translation block".to_string()), diff --git a/crates/pi-natives/src/chunk/common.rs b/crates/pi-natives/src/chunk/common.rs index 4520ea644..0d6fd8bf1 100644 --- a/crates/pi-natives/src/chunk/common.rs +++ b/crates/pi-natives/src/chunk/common.rs @@ -744,6 +744,8 @@ pub fn is_trivia(kind: &str) -> bool { | "xml_decl" | "else_directive" | "elsif_directive" + | "attribute_item" + | "inner_attribute_item" ) } diff --git a/crates/pi-natives/src/chunk/edit.rs b/crates/pi-natives/src/chunk/edit.rs new file mode 100644 index 000000000..874227e56 --- /dev/null +++ b/crates/pi-natives/src/chunk/edit.rs @@ -0,0 +1,1297 @@ +use std::{cmp::Ordering, path::Path}; + +use crate::chunk::{ + indent::{ + detect_common_indent, detect_file_indent_char, detect_file_indent_step, + normalize_leading_whitespace_char, reindent_inserted_block, strip_content_prefixes, + }, + resolve::{ + resolve_chunk_selector, resolve_chunk_with_crc, sanitize_chunk_selector, sanitize_crc, + }, + state::{ChunkState, ChunkStateInner}, + types::{ + ChunkAnchorStyle, ChunkEditOp, ChunkNode, EditOperation, EditParams, EditResult, RenderParams, + }, +}; + +#[derive(Clone)] +struct ScheduledEditOperation { + operation: EditOperation, + original_index: usize, + requested_selector: Option, + initial_chunk: Option, +} + +#[derive(Clone, Copy)] +enum InsertPosition { + Before, + After, + FirstChild, + LastChild, +} + +#[derive(Clone, Copy)] +struct InsertSpacing { + blank_line_before: bool, + blank_line_after: bool, +} + +#[derive(Clone)] +struct InsertionPoint { + offset: usize, + indent: String, +} + +pub fn apply_edits(state: &ChunkState, params: &EditParams) -> Result { + let original_text = normalize_chunk_source(state.inner().source()); + let mut state = + rebuild_chunk_state(original_text.clone(), state.inner().language().to_string())?; + let file_indent_step = detect_file_indent_step(&state.tree) as usize; + let file_indent_char = detect_file_indent_char(&state.tree); + let initial_parse_errors = state.tree.parse_errors; + let mut touched_paths = Vec::new(); + let mut warnings = Vec::new(); + let mut last_scheduled: Option = None; + let initial_default_selector = params.default_selector.clone(); + let initial_default_crc = params.default_crc.clone(); + + let mut scheduled_ops = Vec::with_capacity(params.operations.len()); + for (original_index, operation) in params.operations.iter().cloned().enumerate() { + let selector = operation + .sel + .as_deref() + .or(initial_default_selector.as_deref()); + let requested_selector = sanitize_chunk_selector(selector); + let initial_chunk = resolve_chunk_selector(&state, selector, &mut warnings) + .ok() + .cloned(); + scheduled_ops.push(ScheduledEditOperation { + operation, + original_index, + requested_selector, + initial_chunk, + }); + } + + for scheduled in &scheduled_ops { + if !is_line_scoped(&scheduled.operation) { + continue; + } + let anchor = scheduled.initial_chunk.as_ref().ok_or_else(|| { + format!("Chunk tree is missing an anchor for {}", describe_scheduled_operation(scheduled)) + })?; + let line = scheduled + .operation + .line + .ok_or_else(|| "line-scoped replace is missing line".to_owned())?; + let abs_end = scheduled.operation.end_line.unwrap_or(line); + validate_line_range(anchor, line, abs_end)?; + } + + let mut execution_ops = Vec::with_capacity(scheduled_ops.len()); + let mut index = 0usize; + while index < scheduled_ops.len() { + let scheduled = scheduled_ops[index].clone(); + if !is_line_scoped(&scheduled.operation) { + execution_ops.push(scheduled); + index += 1; + continue; + } + + let mut block = vec![scheduled]; + index += 1; + while index < scheduled_ops.len() && is_line_scoped(&scheduled_ops[index].operation) { + block.push(scheduled_ops[index].clone()); + index += 1; + } + + block.sort_by(|left, right| { + let left_key = line_scoped_sort_key(left); + let right_key = line_scoped_sort_key(right); + right_key + .cmp(&left_key) + .then_with(|| left.original_index.cmp(&right.original_index)) + }); + execution_ops.extend(block); + } + + let current_default_selector = initial_default_selector.clone(); + let mut current_default_crc = initial_default_crc; + let total_ops = params.operations.len(); + + for scheduled in execution_ops { + last_scheduled = Some(scheduled.clone()); + let operation = normalize_operation_literals(&scheduled.operation); + let result = match operation.op { + ChunkEditOp::Replace => apply_replace( + &mut state, + &operation, + &scheduled, + current_default_selector.as_deref(), + current_default_crc.as_deref(), + file_indent_step, + file_indent_char, + &mut touched_paths, + &mut warnings, + ), + ChunkEditOp::Delete => apply_delete( + &mut state, + &operation, + &scheduled, + current_default_selector.as_deref(), + current_default_crc.as_deref(), + &mut touched_paths, + &mut warnings, + ), + ChunkEditOp::AppendChild + | ChunkEditOp::PrependChild + | ChunkEditOp::AppendSibling + | ChunkEditOp::PrependSibling => apply_insert( + &mut state, + &operation, + &scheduled, + current_default_selector.as_deref(), + current_default_crc.as_deref(), + file_indent_step, + file_indent_char, + &mut touched_paths, + &mut warnings, + ), + }; + + if let Err(err) = result { + return Err(format!( + "Edit operation {}/{} failed ({}): {}\nNo changes were saved. Fix the failing \ + operation and retry the entire batch.", + scheduled.original_index + 1, + total_ops, + describe_scheduled_operation(&scheduled), + err, + )); + } + + state = rebuild_chunk_state(state.source.clone(), state.language.clone())?; + if operation.sel.is_none() { + current_default_crc = None; + } + } + + let parse_valid = state.tree.parse_errors <= initial_parse_errors; + if !parse_valid && initial_parse_errors == 0 { + let error_summaries = format_parse_error_summaries(&state); + let fallback_summary = if error_summaries.is_empty() { + if let Some(last) = last_scheduled + .as_ref() + .and_then(|scheduled| scheduled.initial_chunk.as_ref()) + { + vec![format!( + "L{}:C1 parse error introduced while editing {}", + last.start_line, last.path + )] + } else { + Vec::new() + } + } else { + error_summaries + }; + let details = if fallback_summary.is_empty() { + String::new() + } else { + format!( + "\nParse errors:\n{}", + fallback_summary + .into_iter() + .map(|summary| format!("- {summary}")) + .collect::>() + .join("\n") + ) + }; + return Err(format!( + "Edit rejected: introduced {} parse error(s). The file was valid before the edit but is \ + not after. Fix the content and retry.{}", + state.tree.parse_errors, details + )); + } + if !parse_valid { + warnings.push(format!( + "Edit introduced {} new parse error(s).", + state.tree.parse_errors.saturating_sub(initial_parse_errors) + )); + } + + let display_path = display_path_for_file(¶ms.file_path, ¶ms.cwd); + let changed = original_text != state.source; + let diff_before = original_text; + let diff_after = state.source.clone(); + let response_text = if changed { + render_changed_hunks(&state, &display_path, &diff_before, &diff_after, params.anchor_style) + } else { + render_unchanged_response(&state, &display_path, params.anchor_style) + }; + + Ok(EditResult { + state: ChunkState::from_inner(state), + diff_before, + diff_after, + response_text, + changed, + parse_valid, + touched_paths, + warnings, + }) +} + +fn apply_replace( + state: &mut ChunkStateInner, + operation: &EditOperation, + scheduled: &ScheduledEditOperation, + default_selector: Option<&str>, + default_crc: Option<&str>, + file_indent_step: usize, + file_indent_char: char, + touched_paths: &mut Vec, + warnings: &mut Vec, +) -> Result<(), String> { + let anchor_selector = operation.sel.as_deref().or(default_selector); + let crc = operation.crc.as_deref().or_else(|| { + if operation.sel.is_none() { + default_crc + } else { + None + } + }); + let requires_checksum = operation.sel.is_some() || default_crc.is_some(); + ensure_batch_operation_target_current(state, scheduled, crc, touched_paths)?; + let resolved = resolve_chunk_with_crc(state, anchor_selector, crc, warnings)?; + validate_batch_crc(resolved.chunk, resolved.crc.as_deref(), requires_checksum)?; + let anchor = resolved.chunk.clone(); + + if let Some(line) = operation.line { + let abs_end = operation.end_line.unwrap_or(line); + validate_line_range(&anchor, line, abs_end)?; + let offsets = line_offsets(&state.source); + let abs_beg = line; + let range_start = line_start_offset(&offsets, abs_beg, &state.source); + let range_end = line_end_offset(&offsets, abs_end, &state.source); + let replaced_range = state.source[range_start..range_end].to_owned(); + let target_indent = if is_zero_width_insert(abs_beg, abs_end) { + let ind_a = detect_common_indent( + &state.source[line_start_offset(&offsets, abs_end, &state.source) + ..line_end_offset(&offsets, abs_end, &state.source)], + ) + .prefix; + let b_line = abs_beg.min(state.tree.line_count); + let ind_b = detect_common_indent( + &state.source[line_start_offset(&offsets, b_line, &state.source) + ..line_end_offset(&offsets, b_line, &state.source)], + ) + .prefix; + if ind_a.len() >= ind_b.len() { + ind_a + } else { + ind_b + } + } else { + detect_common_indent(&replaced_range).prefix + }; + let content = operation.content.as_deref().unwrap_or_default(); + let mut replacement = + normalize_inserted_content(content, &target_indent, Some(file_indent_step), file_indent_char); + if !replacement.is_empty() && !replacement.ends_with('\n') && abs_end < state.tree.line_count + { + replacement.push('\n'); + } + state.source = replace_range_by_lines(&state.source, abs_beg, abs_end, &replacement); + if replacement.is_empty() { + state.source = cleanup_blank_line_artifacts_at_offset(&state.source, range_start); + } + touched_paths.push(anchor.path); + return Ok(()); + } + + let target_indent = anchor.indent_char.repeat(anchor.indent as usize); + let content = operation.content.as_deref().unwrap_or_default(); + let mut replacement = + normalize_inserted_content(content, &target_indent, Some(file_indent_step), file_indent_char); + if !replacement.is_empty() + && !replacement.ends_with('\n') + && anchor.end_line < state.tree.line_count + { + replacement.push('\n'); + } + let offsets = line_offsets(&state.source); + let range_start = line_start_offset(&offsets, anchor.start_line, &state.source); + state.source = + replace_range_by_lines(&state.source, anchor.start_line, anchor.end_line, &replacement); + if replacement.is_empty() { + state.source = cleanup_blank_line_artifacts_at_offset(&state.source, range_start); + } + touched_paths.push(anchor.path); + Ok(()) +} + +fn apply_delete( + state: &mut ChunkStateInner, + operation: &EditOperation, + scheduled: &ScheduledEditOperation, + default_selector: Option<&str>, + default_crc: Option<&str>, + touched_paths: &mut Vec, + warnings: &mut Vec, +) -> Result<(), String> { + let anchor_selector = operation.sel.as_deref().or(default_selector); + let crc = operation.crc.as_deref().or_else(|| { + if operation.sel.is_none() { + default_crc + } else { + None + } + }); + let requires_checksum = operation.sel.is_some() || default_crc.is_some(); + ensure_batch_operation_target_current(state, scheduled, crc, touched_paths)?; + let resolved = resolve_chunk_with_crc(state, anchor_selector, crc, warnings)?; + validate_batch_crc(resolved.chunk, resolved.crc.as_deref(), requires_checksum)?; + let anchor = resolved.chunk.clone(); + + let offsets = line_offsets(&state.source); + let range_start = line_start_offset(&offsets, anchor.start_line, &state.source); + state.source = replace_range_by_lines(&state.source, anchor.start_line, anchor.end_line, ""); + state.source = cleanup_blank_line_artifacts_at_offset(&state.source, range_start); + touched_paths.push(anchor.path); + Ok(()) +} + +fn apply_insert( + state: &mut ChunkStateInner, + operation: &EditOperation, + scheduled: &ScheduledEditOperation, + default_selector: Option<&str>, + default_crc: Option<&str>, + file_indent_step: usize, + file_indent_char: char, + touched_paths: &mut Vec, + warnings: &mut Vec, +) -> Result<(), String> { + let anchor_selector = operation.sel.as_deref().or(default_selector); + let crc = operation.crc.as_deref().or_else(|| { + if operation.sel.is_none() { + default_crc + } else { + None + } + }); + ensure_batch_operation_target_current(state, scheduled, crc, touched_paths)?; + let resolved = resolve_chunk_with_crc(state, anchor_selector, crc, warnings)?; + validate_batch_crc(resolved.chunk, resolved.crc.as_deref(), resolved.crc.is_some())?; + let anchor = resolved.chunk.clone(); + + let pos = match operation.op { + ChunkEditOp::AppendChild => InsertPosition::LastChild, + ChunkEditOp::PrependChild => InsertPosition::FirstChild, + ChunkEditOp::AppendSibling => InsertPosition::After, + ChunkEditOp::PrependSibling => InsertPosition::Before, + ChunkEditOp::Replace | ChunkEditOp::Delete => { + return Err("Internal error: insert position requested for non-insert op".to_owned()); + }, + }; + let insertion = get_insertion_point_for_position( + state, + &anchor, + pos, + if matches!(pos, InsertPosition::FirstChild | InsertPosition::LastChild) { + operation.content.as_deref() + } else { + None + }, + )?; + let spacing = compute_insert_spacing(state, &anchor, pos); + let content = operation.content.as_deref().unwrap_or_default(); + let mut replacement = + normalize_inserted_content(content, &insertion.indent, Some(file_indent_step), file_indent_char); + replacement = + normalize_insertion_boundary_content(state, insertion.offset, &replacement, spacing); + + if operation.op == ChunkEditOp::PrependChild { + let body = replacement.trim_matches('\n'); + let comment_only = !body.is_empty() + && body.lines().all(|line| { + let trimmed = line.trim(); + trimmed.is_empty() + || trimmed.starts_with("//") + || trimmed.starts_with("///") + || trimmed.starts_with('#') + || trimmed.starts_with("/*") + }); + if comment_only + && anchor.path.is_empty() + && anchor.children.iter().any(|child| child == "preamble") + { + return Err( + "Comment-only prepend_child on root is not allowed when the file has a preamble \ + chunk. Use replace on the preamble chunk instead." + .to_owned(), + ); + } + if comment_only && !anchor.children.is_empty() { + warnings.push( + "Comment-only prepend_child can merge into the following chunk's first line; it is \ + not a separate named chunk." + .to_owned(), + ); + } + } + + state.source = insert_at_offset(&state.source, insertion.offset, &replacement); + touched_paths.push(anchor.path); + Ok(()) +} + +fn normalize_operation_literals(operation: &EditOperation) -> EditOperation { + let mut operation = operation.clone(); + if matches!(operation.sel.as_deref(), Some("null" | "undefined")) { + operation.sel = None; + } + if matches!(operation.crc.as_deref(), Some("null" | "undefined")) { + operation.crc = None; + } + operation +} + +fn normalize_chunk_source(text: &str) -> String { + text + .strip_prefix('\u{feff}') + .unwrap_or(text) + .replace("\r\n", "\n") + .replace('\r', "\n") +} + +fn rebuild_chunk_state(source: String, language: String) -> Result { + let tree = crate::chunk::build_chunk_tree(source.as_str(), language.as_str()) + .map_err(|err| err.to_string())?; + Ok(ChunkStateInner::new(source, language, tree)) +} + +fn validate_batch_crc(chunk: &ChunkNode, crc: Option<&str>, required: bool) -> Result<(), String> { + if !required { + return Ok(()); + } + validate_crc(chunk, crc) +} + +fn validate_crc(chunk: &ChunkNode, crc: Option<&str>) -> Result<(), String> { + let cleaned = sanitize_crc(crc).ok_or_else(|| { + format!( + "Checksum required for {}. Re-read the chunk to get the current checksum, then pass crc: \ + \"XXXX\" in your edit operation. Hint: use crc: \"{}\" with sel: \"{}\".", + chunk_path_opt(chunk), + chunk.checksum, + chunk.path + ) + })?; + if chunk.checksum != cleaned { + return Err(format!( + "Checksum mismatch for {}: expected \"{}\", got \"{}\". The chunk content has changed \ + since you last read it. Re-read the file to get updated checksums, then retry with the \ + new crc value.", + chunk_path_opt(chunk), + chunk.checksum, + cleaned + )); + } + Ok(()) +} + +const fn chunk_path_opt(chunk: &ChunkNode) -> &str { + if chunk.path.is_empty() { + "root" + } else { + chunk.path.as_str() + } +} + +fn validate_line_range(anchor: &ChunkNode, line: u32, end_line: u32) -> Result<(), String> { + let c_start = anchor.start_line; + let c_end = anchor.end_line; + let chunk_name = chunk_path_opt(anchor); + if line < 1 { + return Err(format!( + "Line {line} is invalid for {chunk_name}; line and end_line are absolute file line \ + numbers (1-indexed). This chunk spans file lines {c_start}-{c_end}." + )); + } + if line > end_line.saturating_add(1) { + return Err(format!( + "Invalid line range L{line}-L{end_line} for {chunk_name}: use line ≤ end_line to replace \ + lines, or line = end_line + 1 for zero-width insertion." + )); + } + if line <= end_line { + if line < c_start || end_line > c_end { + return Err(format!( + "Line range L{line}-L{end_line} is outside {chunk_name} (chunk spans file lines \ + {c_start}-{c_end}). Use absolute line numbers from read output." + )); + } + return Ok(()); + } + + let before_chunk = end_line == c_start.saturating_sub(1) && line == c_start; + let inside_gap = c_start <= end_line && end_line < c_end && line == end_line + 1; + let after_chunk = end_line == c_end && line == c_end + 1; + if before_chunk || inside_gap || after_chunk { + return Ok(()); + } + + Err(format!( + "Invalid zero-width insert L{line}-L{end_line} for {chunk_name} (chunk spans file lines \ + {c_start}-{c_end}). Use end_line = {}, line = {c_start} to insert before the first chunk \ + line; end_line = k, line = k + 1 with {c_start} ≤ k < {c_end} between interior lines; \ + end_line = {c_end}, line = {} to insert after the last chunk line.", + c_start.saturating_sub(1), + c_end + 1 + )) +} + +const fn is_zero_width_insert(line: u32, end_line: u32) -> bool { + line == end_line + 1 +} + +const fn zero_width_insert_sort_key(anchor: &ChunkNode, end_line: u32, line: u32) -> u32 { + let c_start = anchor.start_line; + if end_line == c_start.saturating_sub(1) && line == c_start { + c_start + } else { + end_line + } +} + +fn line_scoped_sort_key(scheduled: &ScheduledEditOperation) -> u32 { + let operation = &scheduled.operation; + let Some(line) = operation.line else { + return 0; + }; + let Some(anchor) = scheduled.initial_chunk.as_ref() else { + return 0; + }; + let abs_end = operation.end_line.unwrap_or(line); + if is_zero_width_insert(line, abs_end) { + zero_width_insert_sort_key(anchor, abs_end, line) + } else { + line + } +} + +fn is_line_scoped(operation: &EditOperation) -> bool { + operation.op == ChunkEditOp::Replace && operation.line.is_some() +} + +fn touches_chunk_path(touched_paths: &[String], selector: &str) -> bool { + touched_paths.iter().any(|touched| { + touched == selector + || touched.starts_with(&format!("{selector}.")) + || selector.starts_with(&format!("{touched}.")) + }) +} + +fn ensure_batch_operation_target_current( + state: &ChunkStateInner, + scheduled: &ScheduledEditOperation, + crc: Option<&str>, + touched_paths: &[String], +) -> Result<(), String> { + let Some(selector) = scheduled.requested_selector.as_deref() else { + return Ok(()); + }; + let Some(initial_chunk) = scheduled.initial_chunk.as_ref() else { + return Ok(()); + }; + let Some(cleaned_crc) = sanitize_crc(crc) else { + return Ok(()); + }; + if !touches_chunk_path(touched_paths, selector) || cleaned_crc != initial_chunk.checksum { + return Ok(()); + } + + let mut warnings = Vec::new(); + let current_chunk = + resolve_chunk_selector(state, Some(selector), &mut warnings).map_err(|_| { + format!( + "Chunk path \"{selector}\" was changed by an earlier batch operation. Re-read after \ + the earlier edit and retry with the updated selector and checksum." + ) + })?; + if current_chunk.checksum != initial_chunk.checksum { + return Err(format!( + "Chunk \"{selector}\" was changed by an earlier batch operation: checksum \"{}\" is \ + stale; current checksum is \"{}\" and the current file span is {}-{}. Later operations \ + in the same batch must use the post-edit checksum and updated line span.", + initial_chunk.checksum, + current_chunk.checksum, + current_chunk.start_line, + current_chunk.end_line + )); + } + Ok(()) +} + +fn describe_scheduled_operation(scheduled: &ScheduledEditOperation) -> String { + let op = scheduled.operation.op.as_str(); + if let Some(selector) = scheduled.requested_selector.as_deref() { + format!("{op} on \"{selector}\"") + } else { + op.to_owned() + } +} + +fn normalize_inserted_content( + content: &str, + target_indent: &str, + file_indent_step: Option, + file_indent_char: char, +) -> String { + let mut normalized = normalize_chunk_source(content); + normalized = strip_content_prefixes(&normalized); + if !target_indent.is_empty() { + normalized = reindent_inserted_block(&normalized, target_indent, file_indent_step); + } else { + // Even at indent level 0, normalize the content's indent character + // to match the file's convention (e.g. LLM sends spaces for a tab file). + normalized = + normalize_leading_whitespace_char(&normalized, file_indent_char, file_indent_step); + } + normalized +} + +fn line_offsets(text: &str) -> Vec { + let mut offsets = vec![0usize]; + for (index, ch) in text.char_indices() { + if ch == '\n' { + offsets.push(index + 1); + } + } + offsets +} + +fn line_start_offset(offsets: &[usize], line: u32, text: &str) -> usize { + if line <= 1 { + 0 + } else { + offsets + .get((line - 1) as usize) + .copied() + .unwrap_or(text.len()) + } +} + +fn line_end_offset(offsets: &[usize], line: u32, text: &str) -> usize { + offsets.get(line as usize).copied().unwrap_or(text.len()) +} + +fn replace_range_by_lines(text: &str, start_line: u32, end_line: u32, replacement: &str) -> String { + let offsets = line_offsets(text); + let start_offset = line_start_offset(&offsets, start_line, text); + let end_offset = line_end_offset(&offsets, end_line, text); + format!("{}{}{}", &text[..start_offset], replacement, &text[end_offset..]) +} + +fn insert_at_offset(text: &str, offset: usize, content: &str) -> String { + format!("{}{}{}", &text[..offset], content, &text[offset..]) +} + +fn cleanup_blank_line_artifacts_at_offset(text: &str, offset: usize) -> String { + let mut run_start = offset.min(text.len()); + while run_start > 0 && text.as_bytes()[run_start - 1] == b'\n' { + run_start -= 1; + } + + let mut run_end = offset.min(text.len()); + while run_end < text.len() && text.as_bytes()[run_end] == b'\n' { + run_end += 1; + } + + let newline_run = &text[run_start..run_end]; + if !newline_run.contains("\n\n") { + return text.to_owned(); + } + + let after_run = &text[run_end..]; + let before_run = &text[..run_start]; + let trailing_line = before_run.rsplit('\n').next().unwrap_or(""); + let after_starts_with_close = after_run + .trim_start_matches([' ', '\t']) + .chars() + .next() + .is_some_and(|ch| matches!(ch, '}' | ']' | ')')); + let trailing_trimmed = trailing_line.trim_matches([' ', '\t']); + let trailing_is_only_close = trailing_trimmed.chars().count() == 1 + && trailing_trimmed + .chars() + .next() + .is_some_and(|ch| matches!(ch, '}' | ']' | ')')); + let between_adjacent_closing = trailing_is_only_close && after_starts_with_close; + + if after_starts_with_close { + if newline_run.contains("\n\n\n") { + return format!("{}{}{}", before_run, collapse_newline_runs(newline_run, 2), after_run); + } + if between_adjacent_closing { + return text.to_owned(); + } + return format!("{before_run}\n{after_run}"); + } + if !newline_run.contains("\n\n\n") { + return text.to_owned(); + } + format!("{}{}{}", before_run, collapse_newline_runs(newline_run, 2), after_run) +} + +fn collapse_newline_runs(run: &str, max_newlines: usize) -> String { + let mut out = String::with_capacity(run.len()); + let mut newline_count = 0usize; + for ch in run.chars() { + if ch == '\n' { + newline_count += 1; + if newline_count <= max_newlines { + out.push(ch); + } + } else { + newline_count = 0; + out.push(ch); + } + } + out +} + +fn chunk_slice(text: &str, chunk: &ChunkNode) -> String { + if chunk.line_count == 0 { + return String::new(); + } + text + .split('\n') + .skip(chunk.start_line.saturating_sub(1) as usize) + .take((chunk.end_line - chunk.start_line + 1) as usize) + .collect::>() + .join("\n") +} + +fn is_container_like_chunk(chunk: &ChunkNode) -> bool { + !chunk.leaf + || ["class_", "type_", "interface_", "enum_", "struct_", "impl_", "trait_", "mod_"] + .iter() + .any(|prefix| chunk.name.starts_with(prefix)) +} + +fn find_container_delimiter_offset( + state: &ChunkStateInner, + anchor: &ChunkNode, + placement: Ordering, +) -> Option { + let slice = &state.source[anchor.start_byte as usize..anchor.end_byte as usize]; + let open_brace = slice.find('{'); + let close_brace = slice.rfind('}'); + match (open_brace, close_brace) { + (Some(open), Some(close)) if close >= open => Some(match placement { + Ordering::Less => anchor.start_byte as usize + open + 1, + _ => anchor.start_byte as usize + close, + }), + _ => None, + } +} + +fn compute_insert_indent(state: &ChunkStateInner, anchor: &ChunkNode, inside: bool) -> String { + if !inside || anchor.path.is_empty() { + return String::new(); + } + if let Some(first_child_path) = anchor.children.first() + && let Some(first_child) = state + .tree + .chunks + .iter() + .find(|chunk| &chunk.path == first_child_path) + { + let indent_char = if first_child.indent_char.is_empty() { + if anchor.indent_char.is_empty() { + "\t" + } else { + anchor.indent_char.as_str() + } + } else { + first_child.indent_char.as_str() + }; + return indent_char.repeat(first_child.indent as usize); + } + + for line in chunk_slice(&state.source, anchor).split('\n').skip(1) { + if line.trim().is_empty() { + continue; + } + let prefix_len = line.len() - line.trim_start_matches([' ', '\t']).len(); + if prefix_len > 0 { + return line[..prefix_len].to_owned(); + } + break; + } + + let indent_char = if anchor.indent_char.is_empty() { + "\t" + } else { + anchor.indent_char.as_str() + }; + indent_char.repeat(anchor.indent as usize + 1) +} + +fn go_type_append_child_insertion_point( + state: &ChunkStateInner, + anchor: &ChunkNode, + insertion_content: Option<&str>, +) -> Option<(usize, String)> { + if state.language != "go" + || !anchor.path.starts_with("type_") + || !is_container_like_chunk(anchor) + { + return None; + } + + let offsets = line_offsets(&state.source); + let mut child_chunks = anchor + .children + .iter() + .filter_map(|path| state.tree.chunks.iter().find(|chunk| &chunk.path == path)) + .cloned() + .collect::>(); + let probe = insertion_content + .map(normalize_chunk_source) + .unwrap_or_default(); + let looks_like_file_scope_func = probe + .lines() + .any(|line| line.trim_start().starts_with("func")); + + if child_chunks.is_empty() { + if !looks_like_file_scope_func { + return None; + } + return Some((line_end_offset(&offsets, anchor.end_line, &state.source), String::new())); + } + + child_chunks.sort_by_key(|chunk| chunk.start_line); + if let Some(last_fn) = child_chunks + .iter() + .filter(|chunk| chunk.name.starts_with("fn_")) + .max_by_key(|chunk| chunk.end_line) + { + return Some((line_end_offset(&offsets, last_fn.end_line, &state.source), String::new())); + } + if looks_like_file_scope_func { + return Some((line_end_offset(&offsets, anchor.end_line, &state.source), String::new())); + } + None +} + +fn get_insertion_point( + state: &ChunkStateInner, + anchor: &ChunkNode, + placement: Ordering, + insertion_content: Option<&str>, +) -> InsertionPoint { + let offsets = line_offsets(&state.source); + let is_container = is_container_like_chunk(anchor); + + if placement == Ordering::Less { + if anchor.path.is_empty() { + return InsertionPoint { offset: 0, indent: String::new() }; + } + if is_container { + let indent = compute_insert_indent(state, anchor, true); + if let Some(first_child_path) = anchor.children.first() + && let Some(first_child) = state + .tree + .chunks + .iter() + .find(|chunk| &chunk.path == first_child_path) + { + return InsertionPoint { + offset: line_start_offset(&offsets, first_child.start_line, &state.source), + indent, + }; + } + if let Some(delimiter_offset) = + find_container_delimiter_offset(state, anchor, Ordering::Less) + { + return InsertionPoint { offset: delimiter_offset, indent }; + } + let fallback = line_end_offset(&offsets, anchor.start_line, &state.source); + return InsertionPoint { offset: fallback, indent }; + } + return InsertionPoint { + offset: line_start_offset(&offsets, anchor.start_line, &state.source), + indent: anchor.indent_char.repeat(anchor.indent as usize), + }; + } + + if anchor.path.is_empty() { + return InsertionPoint { offset: state.source.len(), indent: String::new() }; + } + if is_container { + if let Some((offset, indent)) = + go_type_append_child_insertion_point(state, anchor, insertion_content) + { + return InsertionPoint { offset, indent }; + } + if let Some(last_child_path) = anchor.children.last() + && let Some(last_child) = state + .tree + .chunks + .iter() + .find(|chunk| &chunk.path == last_child_path) + { + let indent_char = if last_child.indent_char.is_empty() { + if anchor.indent_char.is_empty() { + "\t" + } else { + anchor.indent_char.as_str() + } + } else { + last_child.indent_char.as_str() + }; + return InsertionPoint { + offset: line_end_offset(&offsets, last_child.end_line, &state.source), + indent: indent_char.repeat(last_child.indent as usize), + }; + } + let indent = compute_insert_indent(state, anchor, true); + if let Some(delimiter_offset) = + find_container_delimiter_offset(state, anchor, Ordering::Greater) + { + return InsertionPoint { offset: delimiter_offset, indent }; + } + return InsertionPoint { + offset: line_start_offset(&offsets, anchor.end_line, &state.source), + indent, + }; + } + InsertionPoint { + offset: line_end_offset(&offsets, anchor.end_line, &state.source), + indent: anchor.indent_char.repeat(anchor.indent as usize), + } +} + +fn get_insertion_point_for_position( + state: &ChunkStateInner, + anchor: &ChunkNode, + pos: InsertPosition, + insertion_content: Option<&str>, +) -> Result { + let offsets = line_offsets(&state.source); + match pos { + InsertPosition::Before => { + if anchor.path.is_empty() { + return Ok(InsertionPoint { offset: 0, indent: String::new() }); + } + Ok(InsertionPoint { + offset: line_start_offset(&offsets, anchor.start_line, &state.source), + indent: anchor.indent_char.repeat(anchor.indent as usize), + }) + }, + InsertPosition::After => { + if anchor.path.is_empty() { + return Ok(InsertionPoint { offset: state.source.len(), indent: String::new() }); + } + Ok(InsertionPoint { + offset: line_end_offset(&offsets, anchor.end_line, &state.source), + indent: anchor.indent_char.repeat(anchor.indent as usize), + }) + }, + InsertPosition::FirstChild => { + if !anchor.path.is_empty() && anchor.leaf && !is_container_like_chunk(anchor) { + return Err(format!("Cannot use prepend_child on leaf chunk {}", anchor.path)); + } + Ok(get_insertion_point(state, anchor, Ordering::Less, insertion_content)) + }, + InsertPosition::LastChild => { + if !anchor.path.is_empty() && anchor.leaf && !is_container_like_chunk(anchor) { + return Err(format!("Cannot use append_child on leaf chunk {}", anchor.path)); + } + Ok(get_insertion_point(state, anchor, Ordering::Greater, insertion_content)) + }, + } +} + +fn sibling_index(state: &ChunkStateInner, anchor: &ChunkNode) -> Option<(usize, usize)> { + let parent_path = anchor.parent_path.as_deref().unwrap_or(""); + let parent = state + .tree + .chunks + .iter() + .find(|chunk| chunk.path == parent_path)?; + let index = parent + .children + .iter() + .position(|child| child == &anchor.path)?; + Some((index, parent.children.len())) +} + +fn has_sibling_before(state: &ChunkStateInner, anchor: &ChunkNode) -> bool { + sibling_index(state, anchor).is_some_and(|(index, _)| index > 0) +} + +fn has_sibling_after(state: &ChunkStateInner, anchor: &ChunkNode) -> bool { + sibling_index(state, anchor).is_some_and(|(index, total)| index + 1 < total) +} + +fn container_has_interior_content(state: &ChunkStateInner, anchor: &ChunkNode) -> bool { + if !is_container_like_chunk(anchor) { + return false; + } + chunk_slice(&state.source, anchor) + .split('\n') + .skip(1) + .collect::>() + .into_iter() + .rev() + .skip(1) + .any(|line| !line.trim().is_empty()) +} + +fn compute_insert_spacing( + state: &ChunkStateInner, + anchor: &ChunkNode, + pos: InsertPosition, +) -> InsertSpacing { + let has_interior_content = container_has_interior_content(state, anchor); + match pos { + InsertPosition::FirstChild => InsertSpacing { + blank_line_before: false, + blank_line_after: !anchor.children.is_empty() || has_interior_content, + }, + InsertPosition::LastChild => InsertSpacing { + blank_line_before: !anchor.children.is_empty() || has_interior_content, + blank_line_after: false, + }, + InsertPosition::Before => InsertSpacing { + blank_line_before: has_sibling_before(state, anchor), + blank_line_after: true, + }, + InsertPosition::After => InsertSpacing { + blank_line_before: true, + blank_line_after: has_sibling_after(state, anchor), + }, + } +} + +fn count_trailing_newlines_before_offset(text: &str, offset: usize) -> usize { + let mut count = 0usize; + let bytes = text.as_bytes(); + let mut index = offset; + while index > 0 && bytes[index - 1] == b'\n' { + count += 1; + index -= 1; + } + count +} + +fn count_leading_newlines_after_offset(text: &str, offset: usize) -> usize { + let mut count = 0usize; + let bytes = text.as_bytes(); + let mut index = offset; + while index < bytes.len() && bytes[index] == b'\n' { + count += 1; + index += 1; + } + count +} + +fn normalize_insertion_boundary_content( + state: &ChunkStateInner, + offset: usize, + content: &str, + spacing: InsertSpacing, +) -> String { + let trimmed = content.trim_matches('\n'); + if trimmed.is_empty() { + return content.to_owned(); + } + + let prev_char = if offset > 0 { + state + .source + .as_bytes() + .get(offset - 1) + .copied() + .map(char::from) + } else { + None + }; + let next_char = state.source.as_bytes().get(offset).copied().map(char::from); + let prefix_newlines = if spacing.blank_line_before { + 2usize.saturating_sub(count_trailing_newlines_before_offset(&state.source, offset)) + } else { + usize::from(prev_char.is_some() && prev_char != Some('\n')) + }; + let suffix_newlines = if spacing.blank_line_after { + 2usize.saturating_sub(count_leading_newlines_after_offset(&state.source, offset)) + } else { + usize::from(next_char.is_some() && next_char != Some('\n')) + }; + + format!("{}{}{}", "\n".repeat(prefix_newlines), trimmed, "\n".repeat(suffix_newlines)) +} + +fn line_column_at_offset(text: &str, offset: usize) -> (usize, usize) { + let offsets = line_offsets(text); + let mut low = 0usize; + let mut high = offsets.len().saturating_sub(1); + while low <= high { + let mid = usize::midpoint(low, high); + let start = offsets[mid]; + let next = offsets.get(mid + 1).copied().unwrap_or(text.len() + 1); + if offset < start { + if mid == 0 { + break; + } + high = mid - 1; + continue; + } + if offset >= next { + low = mid + 1; + continue; + } + return (mid + 1, offset - start + 1); + } + (offsets.len(), 1) +} + +fn format_parse_error_summaries(state: &ChunkStateInner) -> Vec { + state + .tree + .chunks + .iter() + .filter(|chunk| chunk.error) + .take(3) + .map(|chunk| { + let (line, column) = line_column_at_offset(&state.source, chunk.start_byte as usize); + match chunk + .signature + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(snippet) => format!("L{line}:C{column} unexpected syntax near {snippet:?}"), + None => format!("L{line}:C{column} unexpected syntax"), + } + }) + .collect() +} + +fn display_path_for_file(file_path: &str, cwd: &str) -> String { + let file = Path::new(file_path); + let cwd = Path::new(cwd); + match file.strip_prefix(cwd) { + Ok(relative) => relative.to_string_lossy().replace('\\', "/"), + Err(_) => file.to_string_lossy().replace('\\', "/"), + } +} + +/// A parsed unified diff hunk. +struct DiffHunk { + header: String, + lines: Vec, +} + +/// Generate unified diff hunks between two texts using the `similar` crate. +fn generate_diff_hunks(before: &str, after: &str, context: usize) -> Vec { + use similar::{ChangeTag, TextDiff}; + + let diff = TextDiff::from_lines(before, after); + let mut hunks = Vec::new(); + + for group in diff.grouped_ops(context) { + let mut hunk_lines = Vec::new(); + + let first = &group[0]; + let last = &group[group.len() - 1]; + let old_start = first.old_range().start + 1; + let old_len = last.old_range().end - first.old_range().start; + let new_start = first.new_range().start + 1; + let new_len = last.new_range().end - first.new_range().start; + + let header = format!("@@ -{old_start},{old_len} +{new_start},{new_len} @@"); + + for op in &group { + for change in diff.iter_changes(op) { + let line = change.value().trim_end_matches('\n'); + match change.tag() { + ChangeTag::Equal => hunk_lines.push(format!(" {line}")), + ChangeTag::Delete => hunk_lines.push(format!("-{line}")), + ChangeTag::Insert => hunk_lines.push(format!("+{line}")), + } + } + } + + hunks.push(DiffHunk { + header, + lines: hunk_lines, + }); + } + + hunks +} + + +/// Render the response text for a changed file, combining the current chunked +/// tree view with a zero-context unified diff hunk summary. +fn render_changed_hunks( + state: &ChunkStateInner, + display_path: &str, + before: &str, + after: &str, + anchor_style: Option, +) -> String { + let show_leaf_preview = state.language == "tlaplus"; + let tree_text = crate::chunk::render::render_state(state, &RenderParams { + chunk_path: Some(String::new()), + title: display_path.to_owned(), + language_tag: Some(state.language.clone()), + visible_range: None, + render_children_only: true, + omit_checksum: false, + anchor_style, + show_leaf_preview, + tab_replacement: Some(" ".to_owned()), + }); + + let hunks = generate_diff_hunks(before, after, 0); + if hunks.is_empty() { + return tree_text; + } + + let diff_text = hunks + .into_iter() + .flat_map(|hunk| { + let mut lines = Vec::with_capacity(hunk.lines.len() + 1); + lines.push(hunk.header); + lines.extend(hunk.lines); + lines + }) + .collect::>() + .join("\n"); + + format!("{tree_text}\n\n{diff_text}") +} + +fn render_unchanged_response( + state: &ChunkStateInner, + display_path: &str, + anchor_style: Option, +) -> String { + crate::chunk::render::render_state(state, &RenderParams { + chunk_path: Some(String::new()), + title: display_path.to_owned(), + language_tag: Some(state.language.clone()), + visible_range: None, + render_children_only: true, + omit_checksum: false, + anchor_style, + show_leaf_preview: true, + tab_replacement: Some(" ".to_owned()), + }) +} diff --git a/crates/pi-natives/src/chunk/indent.rs b/crates/pi-natives/src/chunk/indent.rs new file mode 100644 index 000000000..ef956cfe6 --- /dev/null +++ b/crates/pi-natives/src/chunk/indent.rs @@ -0,0 +1,555 @@ +use crate::chunk::types::ChunkTree; + +const DEFAULT_SPACE_INDENT_STEP: usize = 4; +const MAX_REASONABLE_INDENT_STEP: usize = 8; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommonIndent { + pub prefix: String, + pub count: usize, +} + +pub fn detect_common_indent(text: &str) -> CommonIndent { + let mut min_count = usize::MAX; + let mut detected_char = None; + + for line in text.split('\n') { + if line.trim().is_empty() { + continue; + } + let whitespace = leading_whitespace(line); + if whitespace.is_empty() { + return CommonIndent { prefix: String::new(), count: 0 }; + } + if whitespace.len() < min_count { + min_count = whitespace.len(); + detected_char = whitespace.chars().next(); + } + } + + if min_count == usize::MAX { + return CommonIndent { prefix: String::new(), count: 0 }; + } + + let ch = detected_char.unwrap_or(' '); + CommonIndent { prefix: ch.to_string().repeat(min_count), count: min_count } +} + +pub fn dedent_python_style(text: &str) -> String { + let mut margin: Option<&str> = None; + for line in text.split('\n') { + if line.trim().is_empty() { + continue; + } + let indent = leading_whitespace(line); + margin = Some(match margin { + None => indent, + Some(current) if indent.starts_with(current) => current, + Some(current) if current.starts_with(indent) => indent, + Some(current) => common_prefix(current, indent), + }); + } + + let Some(margin) = margin else { + return text.to_owned(); + }; + if margin.is_empty() { + return text.to_owned(); + } + + text + .split('\n') + .map(|line| line.strip_prefix(margin).unwrap_or(line)) + .collect::>() + .join("\n") +} + +pub fn indent_non_empty_lines(text: &str, prefix: &str) -> String { + if prefix.is_empty() { + return text.to_owned(); + } + text + .split('\n') + .map(|line| { + if line.trim().is_empty() { + line.to_owned() + } else { + format!("{prefix}{line}") + } + }) + .collect::>() + .join("\n") +} + +pub fn detect_space_indent_step(text: &str) -> usize { + let mut min = usize::MAX; + for line in text.split('\n') { + if line.trim().is_empty() { + continue; + } + let count = line.chars().take_while(|ch| *ch == ' ').count(); + if count > 0 { + min = min.min(count); + } + } + if min == usize::MAX || min == 0 || min > MAX_REASONABLE_INDENT_STEP { + DEFAULT_SPACE_INDENT_STEP + } else { + min + } +} + +pub fn count_indent_columns(whitespace: &str, space_step: usize) -> usize { + whitespace + .chars() + .map(|ch| if ch == '\t' { space_step } else { 1 }) + .sum() +} + +pub fn normalize_target_indent(target_indent: &str, sample_text: &str) -> String { + if target_indent.is_empty() { + return String::new(); + } + let has_tabs = target_indent.contains('\t'); + let has_spaces = target_indent.contains(' '); + if !has_tabs || !has_spaces { + return target_indent.to_owned(); + } + + let space_step = detect_space_indent_step(sample_text); + let total_columns = count_indent_columns(target_indent, space_step); + let normalized_levels = round_to_nearest_step(total_columns, space_step) / space_step; + if normalized_levels == 0 { + return String::new(); + } + + match target_indent.chars().next().unwrap_or(' ') { + '\t' => "\t".repeat(normalized_levels), + _ => " ".repeat(normalized_levels * space_step), + } +} + +pub fn normalize_leading_whitespace_char( + text: &str, + target_char: char, + file_indent_step: Option, +) -> String { + if target_char != ' ' && target_char != '\t' { + return text.to_owned(); + } + + let other_char = if target_char == ' ' { '\t' } else { ' ' }; + let mut needs_conversion = false; + for line in text.split('\n') { + if line.trim().is_empty() { + continue; + } + let ws = leading_whitespace(line); + if ws.is_empty() { + continue; + } + needs_conversion |= ws.contains(other_char); + } + + if !needs_conversion { + return text.to_owned(); + } + + let space_step = if target_char == ' ' { + file_indent_step + .filter(|step| *step > 1) + .unwrap_or_else(|| detect_space_indent_step(text)) + } else { + file_indent_step.unwrap_or_else(|| detect_space_indent_step(text)) + }; + + text + .split('\n') + .map(|line| { + let ws = leading_whitespace(line); + if ws.is_empty() { + return line.to_owned(); + } + let rest = &line[ws.len()..]; + let total_spaces = count_indent_columns(ws, space_step); + if target_char == ' ' { + format!("{}{}", " ".repeat(total_spaces), rest) + } else { + let tabs = total_spaces / space_step; + let remainder = total_spaces % space_step; + format!("{}{}{}", "\t".repeat(tabs), " ".repeat(remainder), rest) + } + }) + .collect::>() + .join("\n") +} + +pub fn reindent_inserted_block( + content: &str, + target_indent: &str, + file_indent_step: Option, +) -> String { + let lines = content.split('\n').collect::>(); + if lines.is_empty() { + return String::new(); + } + + let normalized_target_indent = normalize_target_indent(target_indent, content); + let non_empty_rest = lines + .iter() + .skip(1) + .filter(|line| !line.trim().is_empty()) + .copied() + .collect::>(); + + let mut dedented = if lines.len() == 1 || non_empty_rest.is_empty() { + dedent_python_style(content) + } else { + let first_line = lines[0]; + let first_indent = leading_whitespace(first_line).chars().count(); + let min_rest_indent = non_empty_rest + .iter() + .map(|line| leading_whitespace(line).chars().count()) + .min() + .unwrap_or(0); + if min_rest_indent > first_indent { + let tail = lines.iter().skip(1).copied().collect::>().join("\n"); + format!("{first_line}\n{}", dedent_python_style(&tail)) + } else { + dedent_python_style(content) + } + }; + + if let Some(target_char) = normalized_target_indent.chars().next() { + let target_step = if target_char == ' ' { + file_indent_step.unwrap_or_else(|| normalized_target_indent.chars().count()) + } else { + file_indent_step.unwrap_or_else(|| detect_space_indent_step(&dedented)) + }; + dedented = normalize_leading_whitespace_char(&dedented, target_char, Some(target_step)); + } + + indent_non_empty_lines(&dedented, &normalized_target_indent) +} + +/// Detect the file's indent character by looking at non-root chunks. +/// Returns `'\t'` or `' '`, defaulting to `'\t'` if no chunks have indentation. +pub fn detect_file_indent_char(tree: &ChunkTree) -> char { + for chunk in &tree.chunks { + if chunk.indent > 0 && !chunk.indent_char.is_empty() { + return chunk.indent_char.chars().next().unwrap_or('\t'); + } + } + '\t' +} + +/// Detect spaces-per-indent-level from parent→child indent differences. +/// Only meaningful for space-indented files; returns `DEFAULT_SPACE_INDENT_STEP` for tab files. +pub fn detect_file_indent_step(tree: &ChunkTree) -> u32 { + for chunk in &tree.chunks { + if chunk.children.is_empty() { + continue; + } + for child_path in &chunk.children { + let Some(child) = tree + .chunks + .iter() + .find(|candidate| &candidate.path == child_path) + else { + continue; + }; + if child.indent <= chunk.indent || child.indent_char != " " { + continue; + } + let step = child.indent - chunk.indent; + if step > 0 && step <= MAX_REASONABLE_INDENT_STEP as u32 { + return step; + } + } + } + DEFAULT_SPACE_INDENT_STEP as u32 +} + +pub fn strip_content_prefixes(content: &str) -> String { + let lines = content.split('\n').collect::>(); + let mut line_num_count = 0usize; + let mut non_empty = 0usize; + for line in &lines { + if line.trim().is_empty() { + continue; + } + non_empty += 1; + if parse_chunk_gutter_code_row(line).is_some() { + line_num_count += 1; + } + } + + if non_empty == 0 { + return content.to_owned(); + } + + let without_line_numbers = if line_num_count * 10 > non_empty * 6 { + lines + .iter() + .map(|line| strip_chunk_gutter_line(line)) + .collect::>() + } else { + lines + .iter() + .map(|line| (*line).to_owned()) + .collect::>() + }; + + strip_new_line_prefixes(&without_line_numbers).join("\n") +} + +fn leading_whitespace(line: &str) -> &str { + let end = line + .char_indices() + .find_map(|(index, ch)| (!matches!(ch, ' ' | '\t')).then_some(index)) + .unwrap_or(line.len()); + &line[..end] +} + +fn common_prefix<'a>(left: &'a str, right: &'a str) -> &'a str { + let mut matched = 0usize; + for ((left_index, left_char), (_, right_char)) in left.char_indices().zip(right.char_indices()) { + if left_char != right_char { + break; + } + matched = left_index + left_char.len_utf8(); + } + &left[..matched] +} + +const fn round_to_nearest_step(value: usize, step: usize) -> usize { + if step == 0 { + return value; + } + ((value + (step / 2)) / step) * step +} + +fn parse_chunk_gutter_code_row(line: &str) -> Option<&str> { + let trimmed = line.trim_start_matches([' ', '\t']); + let digits = trimmed.chars().take_while(|ch| ch.is_ascii_digit()).count(); + if digits == 0 { + return None; + } + let after_digits = &trimmed[digits..]; + let after_spaces = after_digits.trim_start_matches([' ', '\t']); + let after_pipe = after_spaces + .strip_prefix('|') + .or_else(|| after_spaces.strip_prefix('│'))?; + Some( + after_pipe + .strip_prefix(' ') + .or_else(|| after_pipe.strip_prefix('\t')) + .unwrap_or(after_pipe), + ) +} + +fn strip_chunk_gutter_line(line: &str) -> String { + if let Some(rest) = parse_chunk_gutter_code_row(line) { + return rest.to_owned(); + } + let trimmed = line.trim_start_matches([' ', '\t']); + if trimmed.starts_with('|') || trimmed.starts_with('│') { + return String::new(); + } + line.to_owned() +} + +fn strip_new_line_prefixes(lines: &[String]) -> Vec { + let non_empty = lines.iter().filter(|line| !line.trim().is_empty()).count(); + if non_empty == 0 { + return lines.to_vec(); + } + + let hash_prefixed = lines + .iter() + .filter(|line| !line.trim().is_empty()) + .filter(|line| hashline_prefix_len(line).is_some()) + .count(); + if hash_prefixed == non_empty { + return lines + .iter() + .map(|line| match hashline_prefix_len(line) { + Some(prefix_len) => line[prefix_len..].to_owned(), + None => line.clone(), + }) + .collect(); + } + + let prefixed = lines + .iter() + .filter(|line| !line.trim().is_empty()) + .filter(|line| visual_prefix_len(line).is_some()) + .count(); + if prefixed * 10 <= non_empty * 6 { + return lines.to_vec(); + } + + lines + .iter() + .map(|line| match visual_prefix_len(line) { + Some(prefix_len) => line[prefix_len..].to_owned(), + None => line.clone(), + }) + .collect() +} + +fn visual_prefix_len(line: &str) -> Option { + let trimmed_start = line + .find(|ch| !matches!(ch, ' ' | '\t')) + .unwrap_or(line.len()); + let after_indent = &line[trimmed_start..]; + let marker = after_indent.chars().next()?; + if marker != '|' && marker != '│' { + return None; + } + let mut length = trimmed_start + marker.len_utf8(); + let remainder = &line[length..]; + let space_prefix = remainder.len() - remainder.trim_start_matches([' ', '\t']).len(); + length += space_prefix; + Some(length) +} + +fn hashline_prefix_len(line: &str) -> Option { + const HASHLINE_NIBBLES: &str = "ZPMQVRWSNKTXJBYH"; + + let mut offset = line.len() - line.trim_start_matches([' ', '\t']).len(); + let mut remainder = &line[offset..]; + + if let Some(stripped) = remainder.strip_prefix(">>>") { + offset += 3; + remainder = stripped; + } else if let Some(stripped) = remainder.strip_prefix(">>") { + offset += 2; + remainder = stripped; + } + + let ws = remainder.len() - remainder.trim_start_matches([' ', '\t']).len(); + offset += ws; + remainder = &remainder[ws..]; + + if let Some(stripped) = remainder.strip_prefix('+') { + offset += 1; + remainder = stripped; + let inner_ws = remainder.len() - remainder.trim_start_matches([' ', '\t']).len(); + offset += inner_ws; + remainder = &remainder[inner_ws..]; + } + + let digits = remainder + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .count(); + if digits > 0 { + offset += digits; + remainder = &remainder[digits..]; + let ws = remainder.len() - remainder.trim_start_matches([' ', '\t']).len(); + offset += ws; + remainder = &remainder[ws..]; + } + + if let Some(stripped) = remainder.strip_prefix('#') { + offset += 1; + remainder = stripped; + let ws = remainder.len() - remainder.trim_start_matches([' ', '\t']).len(); + offset += ws; + remainder = &remainder[ws..]; + } else if digits == 0 { + return None; + } + + let mut chars = remainder.chars(); + let first = chars.next()?; + let second = chars.next()?; + if !HASHLINE_NIBBLES.contains(first) || !HASHLINE_NIBBLES.contains(second) { + return None; + } + offset += first.len_utf8() + second.len_utf8(); + remainder = &remainder[first.len_utf8() + second.len_utf8()..]; + + remainder.strip_prefix(':').map(|_| offset + 1) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chunk::types::ChunkNode; + + fn chunk( + path: &str, + parent_path: Option<&str>, + children: &[&str], + indent: u32, + indent_char: &str, + ) -> ChunkNode { + ChunkNode { + path: path.to_owned(), + name: path.to_owned(), + leaf: children.is_empty(), + parent_path: parent_path.map(str::to_owned), + children: children.iter().map(|child| (*child).to_owned()).collect(), + signature: None, + start_line: 1, + end_line: 1, + line_count: 1, + start_byte: 0, + end_byte: 0, + checksum: "ABCD".to_owned(), + error: false, + indent, + indent_char: indent_char.to_owned(), + } + } + + #[test] + fn dedent_preserves_mixed_common_margin() { + let input = "\t foo\n\t bar\n\t baz"; + assert_eq!(dedent_python_style(input), "foo\n bar\nbaz"); + } + + #[test] + fn normalize_leading_whitespace_char_uses_file_step() { + let input = "\t alpha\n\t beta"; + assert_eq!( + normalize_leading_whitespace_char(input, ' ', Some(4)), + " alpha\n beta" + ); + } + + #[test] + fn reindent_inserted_block_preserves_first_line_hanging_indent() { + let input = "call(\n alpha,\n beta,\n )"; + assert_eq!( + reindent_inserted_block(input, " ", Some(4)), + " call(\n alpha,\n beta,\n )" + ); + } + + #[test] + fn strip_content_prefixes_removes_gutter_and_meta_rows() { + let input = "10 | fn main() {\n │ <.fn_main#ABCD>\n11 | println!(\"hi\");\n12 | }"; + assert_eq!(strip_content_prefixes(input), "fn main() {\n\n println!(\"hi\");\n}"); + } + + #[test] + fn detect_file_indent_step_prefers_space_children() { + let tree = ChunkTree { + language: "rust".to_owned(), + checksum: "ABCD".to_owned(), + line_count: 1, + parse_errors: 0, + fallback: false, + root_path: String::new(), + root_children: vec!["class_A".to_owned()], + chunks: vec![ + chunk("class_A", Some(""), &["fn_b"], 0, " "), + chunk("fn_b", Some("class_A"), &[], 2, " "), + ], + }; + assert_eq!(detect_file_indent_step(&tree), 2); + } +} diff --git a/crates/pi-natives/src/chunk/mod.rs b/crates/pi-natives/src/chunk/mod.rs index fc44ca898..924407b8c 100644 --- a/crates/pi-natives/src/chunk/mod.rs +++ b/crates/pi-natives/src/chunk/mod.rs @@ -11,6 +11,11 @@ mod classify; pub(crate) mod common; mod defaults; +pub(crate) mod edit; +pub(crate) mod indent; +mod render; +pub(crate) mod resolve; +pub(crate) mod state; pub mod types; // Per-language classifiers @@ -32,7 +37,6 @@ mod ast_python; mod ast_ruby_lua; mod ast_rust; mod ast_tlaplus; -mod render; use std::collections::HashMap; @@ -42,65 +46,30 @@ use napi_derive::napi; use tree_sitter::{Node, Parser, Tree}; use xxhash_rust::xxh64::xxh64; -pub use self::types::{ChunkNode, ChunkTree, RenderChunkTreeParams}; use self::{ classify::{LangClassifier, classifier_for}, common::*, - render::{line_to_containing_chunk_path, render_chunk_tree}, }; -use crate::language::SupportLang; +pub use self::{ + state::ChunkState, + types::{ChunkNode, ChunkTree}, +}; +use crate::{chunk::types::ChunkAnchorStyle, language::SupportLang}; // ── Napi exports ───────────────────────────────────────────────────────── -#[napi(js_name = "parseChunkTree")] -pub fn parse_chunk_tree(source: String, language: String) -> Result { - build_chunk_tree(source.as_str(), language.as_str()) -} - -#[napi(js_name = "resolveChunkPath")] -pub fn resolve_chunk_path(tree: ChunkTree, chunk_path: String) -> Option { - tree - .chunks - .iter() - .find(|chunk| chunk.path == chunk_path) - .cloned() -} - -#[napi(js_name = "lineToChunkPath")] -pub fn line_to_chunk_path(tree: ChunkTree, line: u32) -> Option { - if line == 0 { - return None; - } - - tree - .chunks - .iter() - .filter(|chunk| { - chunk.kind == "leaf" - && chunk.start_line <= line - && line <= chunk.end_line - && !chunk.path.is_empty() - }) - .min_by_key(|chunk| chunk.line_count) - .map(|chunk| chunk.path.clone()) - .or_else(|| { - tree - .chunks - .iter() - .find(|chunk| { - chunk.path.is_empty() && (chunk.start_line == 0 || line <= chunk.end_line) - }) - .map(|chunk| chunk.path.clone()) - }) -} -#[napi(js_name = "lineToContainingChunkPath")] -pub fn line_to_containing_chunk_path_napi(tree: ChunkTree, line: u32) -> Option { - line_to_containing_chunk_path(&tree, line) -} - -#[napi(js_name = "renderChunkTree")] -pub fn render_chunk_tree_napi(params: RenderChunkTreeParams) -> Result { - Ok(render_chunk_tree(¶ms)) +/// Format one chunk anchor string for a node at `depth` using `style` and +/// optional checksum omission. +#[napi(js_name = "formatAnchor")] +pub fn format_anchor_napi( + name: String, + checksum: String, + style: ChunkAnchorStyle, + omit_checksum: Option, +) -> String { + style + .with_omit_checksum(omit_checksum.unwrap_or(false)) + .render("", name.as_str(), checksum.as_str()) } // ── Core build logic ───────────────────────────────────────────────────── @@ -127,12 +96,12 @@ pub(crate) fn build_chunk_tree(source: &str, language: &str) -> Result".to_string(), - kind: "branch".to_string(), + name: "root".to_string(), + leaf: false, parent_path: None, children: root_children.clone(), signature: None, @@ -159,6 +128,32 @@ pub(crate) fn build_chunk_tree(source: &str, language: &str) -> Result Option { + if line == 0 { + return None; + } + + if let Some(chunk) = tree + .chunks + .iter() + .filter(|chunk| { + chunk.leaf && !chunk.path.is_empty() && chunk.start_line <= line && line <= chunk.end_line + }) + .min_by_key(|chunk| chunk.line_count) + { + return Some(chunk.path.clone()); + } + + tree + .chunks + .iter() + .filter(|chunk| chunk.start_line <= line && line <= chunk.end_line) + .min_by_key(|chunk| chunk.line_count) + .map(|chunk| chunk.path.clone()) +} + fn parse_tree(source: &str, language: SupportLang) -> Result { let mut parser = Parser::new(); let ts_language = language.get_ts_language(); @@ -178,8 +173,8 @@ fn build_blank_line_tree( ) -> ChunkTree { let mut chunks = vec![ChunkNode { path: String::new(), - name: "".to_string(), - kind: "branch".to_string(), + name: "root".to_string(), + leaf: false, parent_path: None, children: Vec::new(), signature: None, @@ -223,7 +218,7 @@ fn build_blank_line_tree( chunks.push(ChunkNode { path: name.clone(), name: name.clone(), - kind: "leaf".to_string(), + leaf: true, parent_path: Some(String::new()), children: Vec::new(), signature: None, @@ -312,16 +307,12 @@ fn build_chunk( Vec::new() }; - let kind = if !children.is_empty() || (candidate.force_recurse && !should_collapse) { - "branch" - } else { - "leaf" - }; + let leaf = children.is_empty() && (!candidate.force_recurse || should_collapse); let (indent, indent_char) = detect_indent(source, candidate.range_start_byte); acc.chunks.push(ChunkNode { path: path.clone(), name: candidate.base_name, - kind: kind.to_string(), + leaf, parent_path: Some(parent_path.to_string()), children, signature: candidate.signature, @@ -657,9 +648,9 @@ pub(crate) fn chunk_checksum(bytes: &[u8]) -> String { } /// When the first structural chunk begins after line 1, insert a leaf chunk -/// `file_preamble` covering leading comments/whitespace so they stay +/// `preamble` covering leading comments/whitespace so they stay /// addressable via chunk paths (not only raw line ops). -fn insert_file_preamble_chunk( +fn insert_preamble_chunk( source: &str, chunks: &mut Vec, root_children: &mut Vec, @@ -667,9 +658,7 @@ fn insert_file_preamble_chunk( if root_children.is_empty() { return; } - if chunks.iter().any(|c| c.path == "file_preamble") - || root_children.iter().any(|p| p == "file_preamble") - { + if chunks.iter().any(|c| c.path == "preamble") || root_children.iter().any(|p| p == "preamble") { return; } let mut min_start = u32::MAX; @@ -699,9 +688,9 @@ fn insert_file_preamble_chunk( .unwrap_or_default(), ); let preamble = ChunkNode { - path: "file_preamble".to_string(), - name: "file_preamble".to_string(), - kind: "leaf".to_string(), + path: "preamble".to_string(), + name: "preamble".to_string(), + leaf: true, parent_path: Some(String::new()), children: Vec::new(), signature: None, @@ -716,7 +705,7 @@ fn insert_file_preamble_chunk( indent_char: String::new(), }; chunks.push(preamble); - root_children.insert(0, "file_preamble".to_string()); + root_children.insert(0, "preamble".to_string()); } pub(crate) fn sort_chunk_children_by_position(chunks: &mut [ChunkNode]) { @@ -948,7 +937,7 @@ mod tests { .iter() .find(|chunk| chunk.path == "block_if") .expect("block_if chunk should exist"); - assert_eq!(block.kind, "branch"); + assert!(!block.leaf); assert!( block .children @@ -1001,7 +990,7 @@ function main(): void {{ .iter() .find(|chunk| chunk.path == "class_Bla") .expect("class chunk should exist"); - assert_eq!(class_chunk.kind, "branch"); + assert!(!class_chunk.leaf); assert!( class_chunk .children @@ -1015,7 +1004,7 @@ function main(): void {{ .any(|child| child == "class_Bla.fn_onEvent") ); - let line_path = line_to_chunk_path(tree.clone(), 15).expect("line should resolve"); + let line_path = line_to_chunk_path(&tree, 15).expect("line should resolve"); assert!(line_path.starts_with("class_Bla.fn_onEvent")); } @@ -1057,7 +1046,7 @@ function main(): void {{ .iter() .find(|c| c.path == "class_Tiny") .expect("class_Tiny"); - assert_eq!(class_chunk.kind, "branch"); + assert!(!class_chunk.leaf); assert!( class_chunk .children @@ -1081,7 +1070,7 @@ function main(): void {{ .iter() .find(|c| c.path == "class_Empty") .expect("class_Empty"); - assert_eq!(class_chunk.kind, "branch"); + assert!(!class_chunk.leaf); } #[test] @@ -1122,7 +1111,7 @@ function main(): void {{ .iter() .find(|c| c.path == "fn_handler") .expect("fn_handler"); - assert_eq!(chunk.kind, "leaf"); + assert!(chunk.leaf); assert_eq!(chunk.start_line, 1); assert_eq!(chunk.end_line, 3); assert!( @@ -1142,7 +1131,7 @@ function main(): void {{ .iter() .find(|c| c.path == "class_Foo") .expect("class_Foo"); - assert_eq!(chunk.kind, "branch"); + assert!(!chunk.leaf); assert_eq!(chunk.start_line, 1); assert_eq!(chunk.end_line, 3); } @@ -1159,7 +1148,7 @@ function main(): void {{ .iter() .find(|c| c.path == "iface_Config") .expect("iface_Config"); - assert_eq!(iface.kind, "leaf"); + assert!(iface.leaf); assert!(iface.children.is_empty()); } @@ -1199,7 +1188,7 @@ def main(): .iter() .find(|c| c.path == "class_Server") .expect("class_Server"); - assert_eq!(cls.kind, "branch"); + assert!(!cls.leaf); assert!( cls.children.iter().any(|c| c == "class_Server.fn_init"), "expected fn_init (__init__ sanitized)" @@ -1225,7 +1214,7 @@ def main(): .iter() .find(|c| c.path == "fn_worker") .expect("fn_worker"); - assert_eq!(worker.kind, "branch"); + assert!(!worker.leaf); assert!(tree.chunks.iter().any(|c| c.path == "fn_worker.loop"), "expected loop chunk"); } @@ -1241,7 +1230,7 @@ def main(): .find(|c| c.path == "class_Foo") .expect("class_Foo"); assert_eq!(class_chunk.signature.as_deref(), Some("class Foo(Base)")); - assert_eq!(class_chunk.kind, "leaf"); + assert!(class_chunk.leaf); } #[test] @@ -1277,7 +1266,7 @@ fn main() { .iter() .find(|c| c.path == "impl_Config") .expect("impl_Config"); - assert_eq!(impl_chunk.kind, "branch"); + assert!(!impl_chunk.leaf); assert!( impl_chunk .children @@ -1332,7 +1321,7 @@ impl Config { .iter() .find(|c| c.path == "struct_Server") .expect("struct_Server should exist"); - assert_eq!(server.kind, "branch", "large struct should be a branch"); + assert!(!server.leaf, "large struct should be a branch"); assert!( server .children @@ -1371,7 +1360,7 @@ impl Config { .iter() .find(|c| c.path == "type_Config") .expect("type_Config"); - assert_eq!(config.kind, "branch"); + assert!(!config.leaf); assert!( config .children @@ -1385,7 +1374,7 @@ impl Config { .iter() .find(|c| c.path == "type_Reader") .expect("type_Reader"); - assert_eq!(reader.kind, "leaf"); + assert!(reader.leaf); assert!(reader.children.is_empty(), "single-line interfaces should render inline"); } @@ -1425,7 +1414,7 @@ impl Config { .iter() .find(|c| c.path == "type_Server") .expect("type_Server"); - assert_eq!(server.kind, "branch"); + assert!(!server.leaf); assert!( server .children @@ -1450,7 +1439,7 @@ impl Config { "expected type_Server.fn_Stop in children: {:?}", server.children ); - let line_path = line_to_chunk_path(tree.clone(), 7).expect("method line should resolve"); + let line_path = line_to_chunk_path(&tree, 7).expect("method line should resolve"); assert_eq!(line_path, "type_Server.fn_Start"); } @@ -1508,19 +1497,19 @@ func (s *Server) GetAddress() string { } #[test] - fn file_preamble_chunk_covers_leading_lines_before_first_item() { + fn preamble_chunk_covers_leading_lines_before_first_item() { let source = "// header\n// second\n\nfn main() {}\n"; let tree = build_chunk_tree(source, "rust").expect("tree should build"); assert!( - tree.root_children.iter().any(|c| c == "file_preamble"), - "expected file_preamble in {:?}", + tree.root_children.iter().any(|c| c == "preamble"), + "expected preamble in {:?}", tree.root_children ); let preamble = tree .chunks .iter() - .find(|c| c.path == "file_preamble") - .expect("file_preamble"); + .find(|c| c.path == "preamble") + .expect("preamble"); assert_eq!(preamble.start_line, 1); assert_eq!(preamble.end_line, 3); let main_fn = tree @@ -1561,7 +1550,7 @@ func (s *Server) GetAddress() string { .iter() .find(|c| c.path == "enum_LogLevel") .expect("enum_LogLevel"); - assert_eq!(enum_chunk.kind, "branch"); + assert!(!enum_chunk.leaf); assert!( enum_chunk .children @@ -1587,7 +1576,7 @@ func (s *Server) GetAddress() string { .iter() .find(|c| c.path == "trait_Handler") .expect("trait_Handler"); - assert_eq!(trait_chunk.kind, "leaf"); + assert!(trait_chunk.leaf); assert!(trait_chunk.children.is_empty(), "single-line trait signatures should render inline"); } @@ -1604,7 +1593,7 @@ func (s *Server) GetAddress() string { .iter() .find(|c| c.path == "type_Handler") .expect("type_Handler"); - assert_eq!(iface.kind, "leaf"); + assert!(iface.leaf); assert!(iface.children.is_empty(), "single-line interface methods should render inline"); } @@ -1620,7 +1609,7 @@ func (s *Server) GetAddress() string { .iter() .find(|c| c.path == "enum_Status") .expect("enum_Status"); - assert_eq!(enum_chunk.kind, "branch"); + assert!(!enum_chunk.leaf); assert!( enum_chunk .children @@ -1769,7 +1758,7 @@ end .iter() .find(|c| c.path == "mod_PaymentProcessing") .expect("mod_PaymentProcessing"); - assert_eq!(module.kind, "branch"); + assert!(!module.leaf); assert!( module .children @@ -1783,7 +1772,7 @@ end .iter() .find(|c| c.path == "mod_PaymentProcessing.class_Money") .expect("class_Money"); - assert_eq!(class.kind, "branch"); + assert!(!class.leaf); assert!( class .children @@ -1832,7 +1821,7 @@ end .iter() .find(|c| c.path == "enum_Message") .expect("enum_Message"); - assert_eq!(enum_chunk.kind, "branch"); + assert!(!enum_chunk.leaf); assert!(!enum_chunk.children.is_empty(), "mixed-size variants should stay addressable"); } } diff --git a/crates/pi-natives/src/chunk/render.rs b/crates/pi-natives/src/chunk/render.rs index c9e588cf6..3ba7e96f3 100644 --- a/crates/pi-natives/src/chunk/render.rs +++ b/crates/pi-natives/src/chunk/render.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; use crate::{ - chunk::types::{ChunkNode, ChunkTree, RenderChunkTreeParams, VisibleLineRange}, + chunk::{ + state::{ChunkStateInner, mask_chunk_display_source}, + types::{ChunkAnchorStyle, ChunkNode, ChunkTree, RenderParams, VisibleLineRange}, + }, env_uint, }; @@ -16,23 +19,25 @@ env_uint! { static PREVIEW_TAIL_LINES: usize = "PI_CHUNK_PREVIEW_TAIL_LINES" or 4 => [1, usize::MAX]; } -pub fn line_to_containing_chunk_path(tree: &ChunkTree, line: u32) -> Option { - line_to_containing_chunk(tree, line).map(|chunk| chunk.path.clone()) -} - -pub fn render_chunk_tree(params: &RenderChunkTreeParams) -> String { - let lookup = build_lookup(¶ms.tree); - let Some(chunk) = get_chunk(&lookup, params.chunk_path.as_deref().unwrap_or("")) else { +pub fn render_state(state: &ChunkStateInner, params: &RenderParams) -> String { + let tree = state.tree(); + let lookup = build_lookup(tree); + let chunk_path = params + .chunk_path + .as_deref() + .unwrap_or(tree.root_path.as_str()); + let Some(chunk) = get_chunk(&lookup, chunk_path) else { return String::new(); }; - let source_lines: Vec<&str> = params.source.split('\n').collect(); + let masked_source = mask_chunk_display_source(state.source(), state.language()); + let source_lines = masked_source.split('\n').collect::>(); let full_display_threshold = *FULL_DISPLAY_THRESHOLD; let preview_head_lines = *PREVIEW_HEAD_LINES; let preview_tail_lines = *PREVIEW_TAIL_LINES; let tab_replacement = params.tab_replacement.as_deref().unwrap_or(" "); - let anchor_style = params.anchor_style.as_deref().unwrap_or("full"); + let anchor_style = params.anchor_style.unwrap_or_default(); let num_width = compute_num_width( - ¶ms.tree, + tree, chunk, &lookup, params.visible_range.as_ref(), @@ -45,7 +50,7 @@ pub fn render_chunk_tree(params: &RenderChunkTreeParams) -> String { preview_tail_lines, ); let rendered_line_count = compute_rendered_line_count( - ¶ms.tree, + tree, chunk, &lookup, params.visible_range.as_ref(), @@ -60,7 +65,7 @@ pub fn render_chunk_tree(params: &RenderChunkTreeParams) -> String { let mut ctx = RenderCtx { out: String::new(), - tree: ¶ms.tree, + tree, lookup: &lookup, source_lines: &source_lines, num_width, @@ -84,7 +89,7 @@ pub fn render_chunk_tree(params: &RenderChunkTreeParams) -> String { params.title.as_str(), rendered_line_count, params.language_tag.as_deref(), - params.checksum.as_str(), + chunk.checksum.as_str(), params.omit_checksum, ) ), @@ -93,7 +98,7 @@ pub fn render_chunk_tree(params: &RenderChunkTreeParams) -> String { if params.render_children_only { let children = - visible_children_for_chunk(¶ms.tree, chunk, &lookup, params.visible_range.as_ref()); + visible_children_for_chunk(tree, chunk, &lookup, params.visible_range.as_ref()); for (index, child) in children.iter().enumerate() { emit_chunk_subtree(&mut ctx, child, 0, ChunkSubtreeOptions { is_first_top_level: index == 0, @@ -143,10 +148,7 @@ fn line_to_chunk_path_leaf(tree: &ChunkTree, line: u32) -> Option<&ChunkNode> { .chunks .iter() .filter(|chunk| { - chunk.kind == "leaf" - && chunk.start_line <= line - && line <= chunk.end_line - && !chunk.path.is_empty() + chunk.leaf && chunk.start_line <= line && line <= chunk.end_line && !chunk.path.is_empty() }) .min_by_key(|chunk| chunk.line_count) } @@ -224,10 +226,14 @@ fn leading_whitespace(line: &str) -> &str { &line[..count] } -fn chunk_body_anchor_indent<'a>(source_lines: &'a [&str], chunk: &ChunkNode) -> &'a str { +fn chunk_body_anchor_indent( + source_lines: &[&str], + chunk: &ChunkNode, + tab_replacement: &str, +) -> String { source_lines .get(chunk.start_line.saturating_sub(1) as usize) - .map_or("", |line| leading_whitespace(line)) + .map_or(String::new(), |line| leading_whitespace(line).replace('\t', tab_replacement)) } #[derive(Clone, Copy)] @@ -314,29 +320,6 @@ fn build_leaf_entries( entries } -fn format_anchor_name(name: &str, style: &str, omit_checksum: bool) -> String { - if omit_checksum || style == "full" { - return name.to_string(); - } - if style == "bare" { - return String::new(); - } - name - .find('_') - .map_or_else(|| name.to_string(), |index| name[..=index].to_string()) -} - -fn format_anchor_tag(chunk: &ChunkNode, depth: usize, style: &str, omit_checksum: bool) -> String { - let prefix = if depth == 0 { ':' } else { '.' }; - let checksum = if omit_checksum { - String::new() - } else { - format!("#{}", chunk.checksum) - }; - let name = format_anchor_name(chunk.name.as_str(), style, omit_checksum); - format!("[{prefix}{name}{checksum}]") -} - fn format_header_meta( title: &str, line_count: usize, @@ -531,7 +514,7 @@ struct RenderCtx<'a> { num_width: usize, visible_range: Option<&'a VisibleLineRange>, omit_checksum: bool, - anchor_style: &'a str, + anchor_style: ChunkAnchorStyle, show_leaf_preview: bool, last_was_blank_meta: bool, full_display_threshold: usize, @@ -634,15 +617,9 @@ fn emit_chunk_subtree( push_blank_meta(ctx); } if !chunk.path.is_empty() { - let anchor_indent = chunk_body_anchor_indent(ctx.source_lines, chunk); - push_meta( - ctx, - format!( - "{}{}", - anchor_indent, - format_anchor_tag(chunk, depth, ctx.anchor_style, ctx.omit_checksum) - ), - ); + let anchor_indent = chunk_body_anchor_indent(ctx.source_lines, chunk, ctx.tab_replacement); + let style = ctx.anchor_style.with_omit_checksum(ctx.omit_checksum); + push_meta(ctx, style.render(&anchor_indent, chunk.name.as_str(), chunk.checksum.as_str())); } if !has_kids { if ctx.show_leaf_preview @@ -650,6 +627,8 @@ fn emit_chunk_subtree( { emit_leaf_body(ctx, chunk, span); } + // Closing tag for single-line leaves is omitted (only multi-line chunks get + // them) return; } if let Some(span) = span { @@ -674,6 +653,15 @@ fn emit_chunk_subtree( if cursor <= span.end { emit_line_gap(ctx, cursor, span.end); } + // Closing tag for multi-line chunks with children + if !chunk.path.is_empty() && chunk.line_count > 1 { + let anchor_indent = chunk_body_anchor_indent(ctx.source_lines, chunk, ctx.tab_replacement); + let style = ctx.anchor_style.with_omit_checksum(ctx.omit_checksum); + push_meta( + ctx, + style.render_close(&anchor_indent, chunk.name.as_str(), chunk.checksum.as_str()), + ); + } return; } for (index, child) in children.iter().enumerate() { diff --git a/crates/pi-natives/src/chunk/resolve.rs b/crates/pi-natives/src/chunk/resolve.rs new file mode 100644 index 000000000..c4b5e2bb0 --- /dev/null +++ b/crates/pi-natives/src/chunk/resolve.rs @@ -0,0 +1,376 @@ +use std::{cmp::Ordering, collections::BTreeSet}; + +use crate::chunk::{ + state::ChunkStateInner, + types::{ChunkNode, ChunkTree}, +}; + +const CHUNK_NAME_PREFIXES: &[&str] = + &["fn_", "var_", "class_", "stmts_", "type_", "interface_", "enum_", "const_"]; + +pub struct ResolvedChunk<'a> { + pub chunk: &'a ChunkNode, + pub crc: Option, +} + +pub fn sanitize_chunk_selector(selector: Option<&str>) -> Option { + let mut value = selector?.trim().to_owned(); + if matches!(value.as_str(), "null" | "undefined") { + return None; + } + + if let Some(index) = chunk_read_path_separator_index(&value) { + value = value[index + 1..].to_owned(); + } + + let value = strip_trailing_checksum(&value).trim(); + if value.is_empty() { + None + } else { + Some(value.to_owned()) + } +} + +pub fn sanitize_crc(crc: Option<&str>) -> Option { + let value = crc?.trim(); + if matches!(value, "" | "null" | "undefined") { + None + } else { + Some(value.to_ascii_uppercase()) + } +} + +pub fn resolve_chunk_selector<'a>( + state: &'a ChunkStateInner, + selector: Option<&str>, + warnings: &mut Vec, +) -> Result<&'a ChunkNode, String> { + resolve_chunk_selector_in_tree(&state.tree, selector, warnings) +} + +pub fn resolve_chunk_selector_in_tree<'a>( + tree: &'a ChunkTree, + selector: Option<&str>, + warnings: &mut Vec, +) -> Result<&'a ChunkNode, String> { + let Some(cleaned) = sanitize_chunk_selector(selector) else { + return find_chunk_by_path(tree, "") + .ok_or_else(|| "Chunk tree is missing the root chunk".to_owned()); + }; + + if let Some(chunk) = find_chunk_by_path(tree, &cleaned) { + return Ok(chunk); + } + + let suffix = format!(".{cleaned}"); + if let Some(chunk) = resolve_unique_chunks( + collect_matches(tree, |candidate| { + candidate.path == cleaned || candidate.path.ends_with(&suffix) + }), + &cleaned, + warnings, + "chunk selector", + "Auto-resolved chunk selector", + )? { + return Ok(chunk); + } + + if !cleaned.contains('.') { + let prefixed = CHUNK_NAME_PREFIXES + .iter() + .map(|prefix| format!("{prefix}{cleaned}")) + .collect::>(); + if let Some(chunk) = resolve_unique_chunks( + collect_matches(tree, |candidate| { + prefixed + .iter() + .any(|name| candidate.path == *name || candidate.path.ends_with(&format!(".{name}"))) + }), + &cleaned, + warnings, + "chunk selector", + "Auto-resolved chunk selector", + )? { + return Ok(chunk); + } + } + + let kind_segments = cleaned.split('.').collect::>(); + if let Some(chunk) = resolve_unique_chunks( + collect_matches(tree, |candidate| kind_path_matches(candidate, &kind_segments)), + &cleaned, + warnings, + "kind selector", + "Auto-resolved kind selector", + )? { + return Ok(chunk); + } + + Err(build_not_found_error(tree, &cleaned)) +} + +pub fn resolve_chunk_with_crc<'a>( + state: &'a ChunkStateInner, + selector: Option<&str>, + crc: Option<&str>, + warnings: &mut Vec, +) -> Result, String> { + resolve_chunk_with_crc_in_tree(&state.tree, selector, crc, warnings) +} + +pub fn resolve_chunk_with_crc_in_tree<'a>( + tree: &'a ChunkTree, + selector: Option<&str>, + crc: Option<&str>, + warnings: &mut Vec, +) -> Result, String> { + let cleaned_crc = sanitize_crc(crc); + let cleaned_selector = sanitize_chunk_selector(selector); + + if cleaned_selector.is_none() + && let Some(cleaned_crc) = cleaned_crc.clone() + { + let chunk = resolve_chunk_by_checksum(tree, &cleaned_crc)?; + return Ok(ResolvedChunk { chunk, crc: Some(cleaned_crc) }); + } + + let chunk = resolve_chunk_selector_in_tree(tree, cleaned_selector.as_deref(), warnings)?; + Ok(ResolvedChunk { chunk, crc: cleaned_crc }) +} + +pub fn resolve_chunk_by_checksum<'a>( + tree: &'a ChunkTree, + crc: &str, +) -> Result<&'a ChunkNode, String> { + let cleaned_crc = sanitize_crc(Some(crc)).ok_or_else(|| "Checksum is required".to_owned())?; + let matches = tree + .chunks + .iter() + .filter(|chunk| chunk.checksum == cleaned_crc) + .collect::>(); + + match matches.len() { + 0 => Err(format!( + "Checksum \"{cleaned_crc}\" did not match any chunk. Re-read the file to get current \ + checksums." + )), + 1 => Ok(matches[0]), + _ => Err(format!( + "Ambiguous checksum \"{cleaned_crc}\" matches {} chunks: {}. Provide sel to disambiguate.", + matches.len(), + matches + .iter() + .map(|chunk| if chunk.path.is_empty() { + "" + } else { + chunk.path.as_str() + }) + .collect::>() + .join(", "), + )), + } +} + +fn find_chunk_by_path<'a>(tree: &'a ChunkTree, path: &str) -> Option<&'a ChunkNode> { + tree.chunks.iter().find(|chunk| chunk.path == path) +} + +fn collect_matches(tree: &ChunkTree, mut predicate: F) -> Vec<&ChunkNode> +where + F: FnMut(&ChunkNode) -> bool, +{ + let mut seen = BTreeSet::new(); + let mut matches = Vec::new(); + for chunk in &tree.chunks { + if chunk.path.is_empty() || !predicate(chunk) || !seen.insert(chunk.path.as_str()) { + continue; + } + matches.push(chunk); + } + matches +} + +fn resolve_unique_chunks<'a>( + matches: Vec<&'a ChunkNode>, + cleaned: &str, + warnings: &mut Vec, + selector_label: &str, + warning_label: &str, +) -> Result, String> { + match matches.len() { + 0 => Ok(None), + 1 => { + warnings.push(format!( + "{warning_label} \"{cleaned}\" to \"{}\". Use the full path from read output.", + matches[0].path + )); + Ok(Some(matches[0])) + }, + _ => Err(format!( + "Ambiguous {selector_label} \"{cleaned}\" matches {} chunks: {}. Use the full path from \ + read output.", + matches.len(), + matches + .iter() + .map(|chunk| chunk.path.as_str()) + .collect::>() + .join(", "), + )), + } +} + +fn kind_path_matches(candidate: &ChunkNode, kind_segments: &[&str]) -> bool { + if candidate.path.is_empty() { + return false; + } + let path_segments = candidate.path.split('.').collect::>(); + path_segments.len() == kind_segments.len() + && kind_segments + .iter() + .zip(path_segments) + .all(|(kind, segment)| segment == *kind || segment.starts_with(&format!("{kind}_"))) +} + +fn build_not_found_error(tree: &ChunkTree, cleaned: &str) -> String { + let (direct_children_parent, direct_children, matched_empty_prefix) = + matching_prefix_context(tree, cleaned); + let available_paths = tree + .chunks + .iter() + .filter(|chunk| !chunk.path.is_empty() && !chunk.path.contains('.')) + .map(|chunk| chunk.path.as_str()) + .collect::>(); + let similarity = suggest_chunk_paths(tree, cleaned, 8); + + let hint = if let Some(parent) = direct_children_parent { + format!(" Direct children of \"{parent}\": {}.", direct_children.join(", ")) + } else if let Some(prefix) = matched_empty_prefix { + if similarity.is_empty() { + format!(" The prefix \"{prefix}\" exists but has no child chunks.") + } else { + format!( + " The prefix \"{prefix}\" exists but has no child chunks. Similar paths: {}.", + similarity.join(", ") + ) + } + } else if !similarity.is_empty() { + format!(" Similar paths: {}.", similarity.join(", ")) + } else if !available_paths.is_empty() { + format!(" Available top-level chunks: {}.", available_paths.join(", ")) + } else { + " Re-read the file to see available chunk paths.".to_owned() + }; + + format!( + "Chunk path not found: \"{cleaned}\".{hint} Re-read the file to see the full chunk tree \ + with paths and checksums." + ) +} + +fn matching_prefix_context( + tree: &ChunkTree, + cleaned: &str, +) -> (Option, Vec, Option) { + let mut direct_children = None; + let mut direct_children_parent = None; + let mut matched_empty_prefix = None; + + if cleaned.contains('.') { + let parts = cleaned.split('.').collect::>(); + for index in (1..parts.len()).rev() { + let prefix = parts[..index].join("."); + let Some(parent) = find_chunk_by_path(tree, &prefix) else { + continue; + }; + if !parent.children.is_empty() { + let mut children = parent.children.clone(); + children.sort(); + direct_children_parent = Some(prefix); + direct_children = Some(children); + break; + } + if matched_empty_prefix.is_none() { + matched_empty_prefix = Some(prefix); + } + } + } + + (direct_children_parent, direct_children.unwrap_or_default(), matched_empty_prefix) +} + +fn suggest_chunk_paths(tree: &ChunkTree, query: &str, limit: usize) -> Vec { + let mut scored = tree + .chunks + .iter() + .filter(|chunk| !chunk.path.is_empty()) + .map(|chunk| (chunk.path.as_str(), chunk_path_similarity(query, &chunk.path))) + .filter(|(_, score)| *score > 0.1) + .collect::>(); + scored.sort_by(|left, right| { + right + .1 + .partial_cmp(&left.1) + .unwrap_or(Ordering::Equal) + .then_with(|| left.0.cmp(right.0)) + }); + scored + .into_iter() + .take(limit) + .map(|(path, _)| path.to_owned()) + .collect() +} + +fn chunk_path_similarity(query: &str, candidate: &str) -> f64 { + if candidate.ends_with(query) || candidate.ends_with(&format!(".{query}")) { + return 0.9; + } + + let query_leaf = query.rsplit('.').next().unwrap_or(query); + let candidate_leaf = candidate.rsplit('.').next().unwrap_or(candidate); + if query_leaf == candidate_leaf { + return 0.85; + } + + if candidate.contains(query) || query.contains(candidate) { + return 0.6; + } + + let query_parts = query.split('.').collect::>(); + let overlap = candidate + .split('.') + .filter(|part| query_parts.contains(part)) + .count(); + if overlap > 0 { + 0.1f64.mul_add(overlap as f64, 0.3) + } else { + 0.0 + } +} + +fn strip_trailing_checksum(value: &str) -> &str { + let Some((prefix, suffix)) = value.rsplit_once('#') else { + return value; + }; + if suffix.len() == 4 && suffix.chars().all(|ch| ch.is_ascii_hexdigit()) { + prefix + } else { + value + } +} + +/// Find the `:` separating a file path from a chunk selector in +/// `file.ts:chunk_path`. Skips Windows `C:\` / `C:/` drive prefixes. +fn chunk_read_path_separator_index(value: &str) -> Option { + let bytes = value.as_bytes(); + // Skip Windows drive prefix: `C:\` or `C:/` + let start = if bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\') + { + value[2..].find(':').map(|i| i + 2)? + } else { + value.find(':')? + }; + Some(start) +} diff --git a/crates/pi-natives/src/chunk/state.rs b/crates/pi-natives/src/chunk/state.rs new file mode 100644 index 000000000..d86bdf24e --- /dev/null +++ b/crates/pi-natives/src/chunk/state.rs @@ -0,0 +1,502 @@ +use std::{ + collections::HashMap, + sync::{Arc, LazyLock}, +}; + +use napi::{Error, Result}; +use napi_derive::napi; +use regex::Regex; + +use super::build_chunk_tree; +use crate::chunk::types::{ + ChunkInfo, ChunkNode, ChunkReadStatus, ChunkReadTarget, ChunkTree, EditParams, EditResult, + ReadRenderParams, ReadResult, RenderParams, VisibleLineRange, +}; + +const CHECKSUM_SUFFIX_RE: &str = r"^(.*?)(?:\s+)?#([0-9A-Fa-f]{4})$"; +const LINE_RANGE_SELECTOR_RE: &str = r"^L(\d+)(?:-L?(\d+))?$"; +const TLAPLUS_BEGIN_TRANSLATION_RE: &str = r"^\s*\\\*\s*BEGIN TRANSLATION\s*$"; +const TLAPLUS_END_TRANSLATION_RE: &str = r"^\s*\\\*\s*END TRANSLATION\s*$"; + +static CHECKSUM_SUFFIX_REGEX: LazyLock = + LazyLock::new(|| Regex::new(CHECKSUM_SUFFIX_RE).expect("checksum selector regex must compile")); +static LINE_RANGE_SELECTOR_REGEX: LazyLock = + LazyLock::new(|| Regex::new(LINE_RANGE_SELECTOR_RE).expect("line range regex must compile")); +static TLAPLUS_BEGIN_TRANSLATION_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(TLAPLUS_BEGIN_TRANSLATION_RE).expect("tlaplus begin regex must compile") +}); +static TLAPLUS_END_TRANSLATION_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(TLAPLUS_END_TRANSLATION_RE).expect("tlaplus end regex must compile") +}); + +#[derive(Clone)] +pub struct ChunkStateInner { + pub(crate) source: String, + pub(crate) language: String, + pub(crate) tree: ChunkTree, + lookup: HashMap, +} + +impl ChunkStateInner { + pub(crate) fn parse(source: String, language: String) -> Result { + let normalized_language = normalize_language(language.as_str()); + let tree = build_chunk_tree(source.as_str(), normalized_language.as_str())?; + Ok(Self::new(source, normalized_language, tree)) + } + + pub(crate) fn new(source: String, language: String, tree: ChunkTree) -> Self { + let lookup = tree + .chunks + .iter() + .enumerate() + .map(|(index, chunk)| (chunk.path.clone(), index)) + .collect(); + Self { source, language, tree, lookup } + } + + pub(crate) const fn source(&self) -> &str { + self.source.as_str() + } + + pub(crate) const fn language(&self) -> &str { + self.language.as_str() + } + + pub(crate) const fn tree(&self) -> &ChunkTree { + &self.tree + } + + pub(crate) fn root(&self) -> Option<&ChunkNode> { + self.chunk("") + } + + pub(crate) fn chunk(&self, path: &str) -> Option<&ChunkNode> { + self + .lookup + .get(path) + .and_then(|index| self.tree.chunks.get(*index)) + } + + pub(crate) fn chunk_info(&self, path: &str) -> Option { + self.chunk(path).map(chunk_info) + } + + pub(crate) fn chunks(&self) -> impl Iterator { + self.tree.chunks.iter() + } + + pub(crate) fn child_chunks(&self, parent_path: &str) -> Vec<&ChunkNode> { + self + .chunk(parent_path) + .map(|parent| { + parent + .children + .iter() + .filter_map(|path| self.chunk(path.as_str())) + .collect() + }) + .unwrap_or_default() + } + + pub(crate) fn line_to_containing_chunk_path(&self, line: u32) -> Option { + crate::chunk::line_to_chunk_path(&self.tree, line) + } +} + +/// Parsed file as a chunk tree: query nodes, render views, format grep hits, +/// and apply edits. +#[napi] +#[derive(Clone)] +pub struct ChunkState { + inner: Arc, +} + +impl ChunkState { + pub(crate) fn from_inner(inner: ChunkStateInner) -> Self { + Self { inner: Arc::new(inner) } + } + + pub(crate) fn inner(&self) -> &ChunkStateInner { + self.inner.as_ref() + } +} + +#[napi] +impl ChunkState { + /// Build chunk state by parsing `source` with the given `language` id (e.g. + /// `typescript`). + #[napi(factory, js_name = "parse")] + pub fn parse(source: String, language: String) -> Result { + ChunkStateInner::parse(source, language).map(Self::from_inner) + } + + /// Normalized language identifier used for the tree-sitter parse. + #[napi(getter)] + pub fn language(&self) -> String { + self.inner.language().to_string() + } + + /// Full source text for this file. + #[napi(getter)] + pub fn source(&self) -> String { + self.inner.source().to_string() + } + + /// Stable checksum for the entire file contents. + #[napi(getter)] + pub fn checksum(&self) -> String { + self.inner.tree().checksum.clone() + } + + /// Line count of the source buffer. + #[napi(getter, js_name = "lineCount")] + pub fn line_count(&self) -> u32 { + self.inner.tree().line_count + } + + /// Count of tree-sitter error nodes seen while building the tree. + #[napi(getter, js_name = "parseErrors")] + pub fn parse_errors(&self) -> u32 { + self.inner.tree().parse_errors + } + + /// True when a fallback classifier produced the tree. + #[napi(getter)] + pub fn fallback(&self) -> bool { + self.inner.tree().fallback + } + + /// Selector path string for the synthetic root (often empty). + #[napi(getter, js_name = "rootPath")] + pub fn root_path(&self) -> String { + self.inner.tree().root_path.clone() + } + + /// Top-level child chunk paths under the root. + #[napi(getter, js_name = "rootChildren")] + pub fn root_children(&self) -> Vec { + self.inner.tree().root_children.clone() + } + + /// Total number of chunk nodes. + #[napi(getter, js_name = "chunkCount")] + pub fn chunk_count(&self) -> u32 { + self.inner.tree().chunks.len() as u32 + } + + /// Summary for the root chunk, if it exists. + #[napi] + pub fn root(&self) -> Option { + self.inner.root().map(chunk_info) + } + + /// Look up [`ChunkInfo`] for a chunk selector path. + #[napi(js_name = "chunk")] + pub fn chunk_info_for_path(&self, chunk_path: String) -> Option { + self.inner.chunk_info(chunk_path.as_str()) + } + + /// Every chunk node as a [`ChunkInfo`] list. + #[napi] + pub fn chunks(&self) -> Vec { + self.inner.chunks().map(chunk_info).collect() + } + + /// Direct children of `chunkPath` (use empty or omit for root); errors if + /// the path is missing. + #[napi] + pub fn children(&self, chunk_path: Option) -> Result> { + let parent_path = chunk_path.unwrap_or_default(); + if self.inner.chunk(parent_path.as_str()).is_none() { + return Err(Error::from_reason(format!( + "Chunk path not found: \"{parent_path}\". Re-read the file to see the full chunk tree \ + with paths and checksums." + ))); + } + Ok(self + .inner + .child_chunks(parent_path.as_str()) + .into_iter() + .map(chunk_info) + .collect()) + } + + /// Chunk selector path that contains 1-based source line `line`, if any. + #[napi(js_name = "lineToContainingChunkPath")] + pub fn line_to_containing_chunk_path(&self, line: u32) -> Option { + self.inner.line_to_containing_chunk_path(line) + } + + /// Render a chunk subtree or listing as UTF-8 text for tools. + #[napi] + pub fn render(&self, params: RenderParams) -> String { + crate::chunk::render::render_state(self.inner(), ¶ms) + } + + /// Parse `readPath` (selector, line scope, etc.) and return rendered text or + /// errors. + #[napi(js_name = "renderRead")] + pub fn render_read(&self, params: ReadRenderParams) -> Result { + let ParsedChunkReadPath { selector } = parse_chunk_read_path(params.read_path.as_str()); + let visible_range = selector.as_deref().and_then(parse_visible_line_range); + let Some(root) = self.inner.root() else { + return Ok(ReadResult { + text: format!("{}\n\n[Chunk tree root missing]", params.display_path), + chunk: None, + }); + }; + + if let Some(visible_range) = visible_range { + if visible_range.start_line > self.inner.tree().line_count { + let suggestion = if self.inner.tree().line_count == 0 { + "The file is empty.".to_string() + } else { + format!( + "Use sel=L1 to read from the start, or sel=L{} to read the last line.", + self.inner.tree().line_count + ) + }; + return Ok(ReadResult { + text: format!( + "Line {} is beyond end of file ({} lines total). {}", + visible_range.start_line, + self.inner.tree().line_count, + suggestion, + ), + chunk: None, + }); + } + + let clamped_range = VisibleLineRange { + start_line: visible_range.start_line, + end_line: visible_range.end_line.min(self.inner.tree().line_count), + }; + let notice = format!( + "[Notice: chunk view scoped to requested lines L{}-L{}; non-overlapping lines \ + omitted.]", + clamped_range.start_line, clamped_range.end_line + ); + let text = self.render(RenderParams { + chunk_path: Some(root.path.clone()), + title: params.display_path.clone(), + language_tag: params.language_tag.clone(), + visible_range: Some(clamped_range), + render_children_only: true, + omit_checksum: params.omit_checksum, + anchor_style: params.anchor_style, + show_leaf_preview: true, + tab_replacement: params.tab_replacement, + }); + return Ok(ReadResult { text: format!("{notice}\n\n{text}"), chunk: None }); + } + + if selector.as_deref().is_none_or(str::is_empty) { + return Ok(ReadResult { + text: self.render(RenderParams { + chunk_path: Some(root.path.clone()), + title: params.display_path.clone(), + language_tag: params.language_tag.clone(), + visible_range: None, + render_children_only: true, + omit_checksum: params.omit_checksum, + anchor_style: params.anchor_style, + show_leaf_preview: true, + tab_replacement: params.tab_replacement, + }), + chunk: None, + }); + } + + let selector = selector.unwrap_or_default(); + let Some(chunk) = self.inner.chunk(selector.as_str()) else { + return Ok(ReadResult { + text: format!("{}:{}\n\n[Chunk not found]", params.display_path, selector), + chunk: Some(ChunkReadTarget { status: ChunkReadStatus::NotFound, selector }), + }); + }; + + if let Some(absolute_line_range) = params.absolute_line_range { + let req_start = absolute_line_range.start_line; + let req_end = absolute_line_range.end_line; + let low = chunk.start_line.max(req_start.min(req_end)); + let high = chunk.end_line.min(req_start.max(req_end)); + if low > high { + let requested = if req_start == req_end { + format!("L{req_start}") + } else { + format!("L{req_start}-L{req_end}") + }; + return Ok(ReadResult { + text: format!( + "Requested lines {requested} do not overlap chunk \"{}\" (file lines {}-{}). \ + Use sel=L{}-L{} to read this chunk.", + chunk.path, chunk.start_line, chunk.end_line, chunk.start_line, chunk.end_line + ), + chunk: Some(ChunkReadTarget { + status: ChunkReadStatus::Ok, + selector: chunk.path.clone(), + }), + }); + } + return Ok(ReadResult { + text: self.render(RenderParams { + chunk_path: Some(chunk.path.clone()), + title: format!("{}:{}", params.display_path, chunk.path), + language_tag: params.language_tag.clone(), + visible_range: Some(VisibleLineRange { start_line: low, end_line: high }), + render_children_only: false, + omit_checksum: params.omit_checksum, + anchor_style: params.anchor_style, + show_leaf_preview: true, + tab_replacement: params.tab_replacement, + }), + chunk: Some(ChunkReadTarget { + status: ChunkReadStatus::Ok, + selector: chunk.path.clone(), + }), + }); + } + + Ok(ReadResult { + text: self.render(RenderParams { + chunk_path: Some(chunk.path.clone()), + title: format!("{}:{}", params.display_path, chunk.path), + language_tag: params.language_tag.clone(), + visible_range: None, + render_children_only: false, + omit_checksum: params.omit_checksum, + anchor_style: params.anchor_style, + show_leaf_preview: true, + tab_replacement: params.tab_replacement, + }), + chunk: Some(ChunkReadTarget { + status: ChunkReadStatus::Ok, + selector: chunk.path.clone(), + }), + }) + } + + /// Prefix a grep line with `display_path` and the chunk path for + /// `line_number`, when known. + #[napi(js_name = "formatGrepLine")] + pub fn format_grep_line(&self, display_path: String, line_number: u32, line: String) -> String { + let chunk_path = self.inner.line_to_containing_chunk_path(line_number); + let location = chunk_path.map_or_else( + || display_path.clone(), + |chunk_path| { + if chunk_path.is_empty() { + display_path.clone() + } else { + format!("{display_path}:{chunk_path}") + } + }, + ); + format!("{location}>{line_number}|{line}") + } + + /// Apply batch edits, re-parse, write files, and return updated state and + /// messaging. + #[napi(js_name = "applyEdits")] + pub fn apply_edits(&self, params: EditParams) -> Result { + crate::chunk::edit::apply_edits(self, ¶ms).map_err(Error::from_reason) + } +} + +#[derive(Clone)] +struct ParsedChunkReadPath { + selector: Option, +} + +fn normalize_language(language: &str) -> String { + language.trim().to_ascii_lowercase() +} + +fn chunk_info(chunk: &ChunkNode) -> ChunkInfo { + ChunkInfo { + path: chunk.path.clone(), + name: chunk.name.clone(), + checksum: chunk.checksum.clone(), + start_line: chunk.start_line, + end_line: chunk.end_line, + leaf: chunk.leaf, + } +} + +fn sanitize_chunk_selector(selector: Option<&str>) -> Option { + let mut selector = selector?.trim().to_string(); + if selector.is_empty() || selector == "null" || selector == "undefined" { + return None; + } + if let Some(colon_index) = chunk_read_path_separator_index(selector.as_str()) { + selector = selector[(colon_index + 1)..].to_string(); + } + if let Some(captures) = CHECKSUM_SUFFIX_REGEX.captures(selector.as_str()) + && let Some(prefix) = captures.get(1) + { + selector = prefix.as_str().to_string(); + } + let selector = selector.trim().to_string(); + (!selector.is_empty()).then_some(selector) +} + +fn chunk_read_path_separator_index(read_path: &str) -> Option { + if read_path.len() >= 3 { + let bytes = read_path.as_bytes(); + if bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\') { + return read_path[2..].find(':').map(|index| index + 2); + } + } + read_path.find(':') +} + +fn parse_chunk_read_path(read_path: &str) -> ParsedChunkReadPath { + let selector = chunk_read_path_separator_index(read_path) + .and_then(|index| sanitize_chunk_selector(Some(&read_path[(index + 1)..]))); + ParsedChunkReadPath { selector } +} + +fn parse_visible_line_range(selector: &str) -> Option { + let captures = LINE_RANGE_SELECTOR_REGEX.captures(selector)?; + let start_line = captures.get(1)?.as_str().parse::().ok()?.max(1); + let end_line = captures + .get(2) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(start_line) + .max(start_line); + Some(VisibleLineRange { start_line, end_line }) +} + +pub fn mask_chunk_display_source(source: &str, language: &str) -> String { + if language != "tlaplus" { + return source.to_string(); + } + let lines = source.split('\n').collect::>(); + let mut masked = lines + .iter() + .map(|line| (*line).to_string()) + .collect::>(); + let mut index = 0usize; + while index < lines.len() { + if !TLAPLUS_BEGIN_TRANSLATION_REGEX.is_match(lines[index]) { + index += 1; + continue; + } + let begin_index = index; + let mut end_index = begin_index + 1; + while end_index < lines.len() && !TLAPLUS_END_TRANSLATION_REGEX.is_match(lines[end_index]) { + end_index += 1; + } + if begin_index + 1 < lines.len() { + masked[begin_index + 1] = "\\* [translation hidden]".to_string(); + for line in masked + .iter_mut() + .take(end_index.min(lines.len())) + .skip(begin_index + 2) + { + line.clear(); + } + } + index = end_index + 1; + } + masked.join("\n") +} diff --git a/crates/pi-natives/src/chunk/types.rs b/crates/pi-natives/src/chunk/types.rs index c2386c2a8..033c84699 100644 --- a/crates/pi-natives/src/chunk/types.rs +++ b/crates/pi-natives/src/chunk/types.rs @@ -2,80 +2,339 @@ use napi_derive::napi; +use crate::chunk::state::ChunkState; + #[derive(Clone)] -#[napi(object)] pub struct ChunkNode { pub path: String, pub name: String, - pub kind: String, - #[napi(js_name = "parentPath")] + pub leaf: bool, pub parent_path: Option, pub children: Vec, pub signature: Option, - #[napi(js_name = "startLine")] pub start_line: u32, - #[napi(js_name = "endLine")] pub end_line: u32, - #[napi(js_name = "lineCount")] pub line_count: u32, - #[napi(js_name = "startByte")] pub start_byte: u32, - #[napi(js_name = "endByte")] pub end_byte: u32, pub checksum: String, pub error: bool, pub indent: u32, - #[napi(js_name = "indentChar")] pub indent_char: String, } #[derive(Clone)] -#[napi(object)] pub struct ChunkTree { pub language: String, pub checksum: String, - #[napi(js_name = "lineCount")] pub line_count: u32, - #[napi(js_name = "parseErrors")] pub parse_errors: u32, pub fallback: bool, - #[napi(js_name = "rootPath")] pub root_path: String, - #[napi(js_name = "rootChildren")] pub root_children: Vec, pub chunks: Vec, } +/// Summary of a single chunk node for tool output and navigation. +#[derive(Clone)] +#[napi(object)] +pub struct ChunkInfo { + /// Chunk selector path within the tree. + pub path: String, + /// Short display name for the chunk (e.g. symbol or region label). + pub name: String, + /// Stable checksum anchor for this chunk. + pub checksum: String, + /// 1-based start line in the source file (inclusive). + #[napi(js_name = "startLine")] + pub start_line: u32, + /// 1-based end line in the source file (inclusive). + #[napi(js_name = "endLine")] + pub end_line: u32, + /// Whether this node is a leaf (no child chunks). + pub leaf: bool, +} + +/// Result of resolving a chunk read request against the tree. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[napi(string_enum)] +pub enum ChunkReadStatus { + /// Selector matched a chunk and content was produced. + #[napi(value = "ok")] + Ok, + /// No chunk matched the requested selector. + #[napi(value = "not_found")] + NotFound, +} + +/// Structural edit to apply relative to a chunk anchor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[napi(string_enum)] +pub enum ChunkEditOp { + /// Replace the chunk body, or a line range when `line`/`endLine` are set. + #[napi(value = "replace")] + Replace, + /// Remove the chunk's source range. + #[napi(value = "delete")] + Delete, + /// Insert `content` as the last child of the target chunk. + #[napi(value = "append_child")] + AppendChild, + /// Insert `content` as the first child of the target chunk. + #[napi(value = "prepend_child")] + PrependChild, + /// Insert `content` after the target chunk's source range. + #[napi(value = "append_sibling")] + AppendSibling, + /// Insert `content` before the target chunk's source range. + #[napi(value = "prepend_sibling")] + PrependSibling, +} + +impl ChunkEditOp { + pub const fn as_str(self) -> &'static str { + match self { + Self::Replace => "replace", + Self::Delete => "delete", + Self::AppendChild => "append_child", + Self::PrependChild => "prepend_child", + Self::AppendSibling => "append_sibling", + Self::PrependSibling => "prepend_sibling", + } + } +} + +/// Outcome of resolving which chunk was read for a `renderRead`-style request. +#[derive(Clone)] +#[napi(object)] +pub struct ChunkReadTarget { + /// Whether the selector matched. + pub status: ChunkReadStatus, + /// Sanitized selector string that was applied. + pub selector: String, +} + +/// Inclusive 1-based line range within a source file (used for scoped chunk +/// rendering). #[derive(Clone)] #[napi(object)] pub struct VisibleLineRange { + /// First line to include. #[napi(js_name = "startLine")] pub start_line: u32, + /// Last line to include. #[napi(js_name = "endLine")] pub end_line: u32, } +/// How chunk anchors are formatted in rendered output (name and checksum +/// visibility). +#[derive(Clone, Copy, Default)] +#[napi(string_enum)] +pub enum ChunkAnchorStyle { + /// `[.name#crc]` style anchor. + #[default] + #[napi(value = "full")] + Full, + /// `[.kind#crc]` style anchor (kind is the name prefix before `_`). + #[napi(value = "kind")] + Kind, + /// `[#crc]` style anchor. + #[napi(value = "bare")] + Bare, + /// `[.name]` without checksum. + #[napi(value = "full-omit")] + FullOmit, + /// `[.kind]` without checksum. + #[napi(value = "kind-omit")] + KindOmit, + /// Minimal anchor without name or checksum. + #[napi(value = "none")] + None, +} + +impl ChunkAnchorStyle { + pub const fn with_omit_checksum(self, omit: bool) -> Self { + if !omit { + return self; + } + match self { + Self::Full => Self::FullOmit, + Self::Kind => Self::KindOmit, + Self::Bare => Self::None, + Self::FullOmit => Self::FullOmit, + Self::KindOmit => Self::KindOmit, + Self::None => Self::None, + } + } + + fn render_i(&self, pre: &str, indent: &str, name: &str, crc: &str) -> String { + fn extract_kind(name: &str) -> &str { + name.find('_').map_or_else(|| name, |index| &name[..index]) + } + match self { + Self::Full => format!("{indent}[{pre}{name}#{crc}]"), + Self::Kind => format!("{indent}[{pre}{kind}#{crc}]", kind = extract_kind(name)), + Self::Bare => format!("{indent}[{pre}#{crc}]"), + Self::FullOmit => format!("{indent}[{pre}{name}]"), + Self::KindOmit => format!("{indent}[{pre}{kind}]", kind = extract_kind(name)), + Self::None => String::new(), + } + } + + /// Render an opening anchor tag: `[name#crc]`. + /// Returns empty string for `None` style. + pub fn render(&self, indent: &str, name: &str, crc: &str) -> String { + self.render_i("", indent, name, crc) + } + + /// Render a closing anchor tag: `[/name#crc]`. + /// Returns empty string for `None` style. + pub fn render_close(&self, indent: &str, name: &str, crc: &str) -> String { + self.render_i("/", indent, name, crc) + } +} + +/// Options for `ChunkState.render`: which subtree to show and how anchors +/// appear. #[derive(Clone)] #[napi(object)] -pub struct RenderChunkTreeParams { - pub tree: ChunkTree, +pub struct RenderParams { + /// Path of the chunk to render; `None` uses the tree root. #[napi(js_name = "chunkPath")] pub chunk_path: Option, - pub source: String, + /// Title line shown above the tree (often the file path). pub title: String, + /// Optional language label for the header block. #[napi(js_name = "languageTag")] pub language_tag: Option, - pub checksum: String, + /// Restrict output to an inclusive line range of the file. #[napi(js_name = "visibleRange")] pub visible_range: Option, + /// When true, list only direct children instead of a full subtree. #[napi(js_name = "renderChildrenOnly")] pub render_children_only: bool, + /// Hide checksums in anchors when true. #[napi(js_name = "omitChecksum")] pub omit_checksum: bool, + /// Anchor formatting style for chunk headers. #[napi(js_name = "anchorStyle")] - pub anchor_style: Option, + pub anchor_style: Option, + /// Include a one-line preview for leaf chunks. #[napi(js_name = "showLeafPreview")] pub show_leaf_preview: bool, + /// Replace tab characters in displayed previews (e.g. two spaces). #[napi(js_name = "tabReplacement")] pub tab_replacement: Option, } + +/// Options for `ChunkState.renderRead`: selector path, display path, and +/// optional line scoping. +#[derive(Clone)] +#[napi(object)] +pub struct ReadRenderParams { + /// Read selector (`sel=...` path, line range, or empty for whole tree). + #[napi(js_name = "readPath")] + pub read_path: String, + /// Path shown in titles and error messages (often the file path). + #[napi(js_name = "displayPath")] + pub display_path: String, + /// Optional language label for the rendered block. + #[napi(js_name = "languageTag")] + pub language_tag: Option, + /// Hide checksums in rendered anchors. + #[napi(js_name = "omitChecksum")] + pub omit_checksum: bool, + /// Anchor formatting style. + #[napi(js_name = "anchorStyle")] + pub anchor_style: Option, + /// Optional absolute file line range to intersect with the resolved chunk. + #[napi(js_name = "absoluteLineRange")] + pub absolute_line_range: Option, + /// Replace tabs in embedded previews. + #[napi(js_name = "tabReplacement")] + pub tab_replacement: Option, +} + +/// Rendered chunk text plus optional resolution metadata for the read request. +#[derive(Clone)] +#[napi(object)] +pub struct ReadResult { + /// Rendered UTF-8 text (chunk tree, notice, or error message). + pub text: String, + /// When a selector was used, whether it matched and which selector applied. + pub chunk: Option, +} + +/// One edit in a batch; targets a chunk via `sel`/`crc` (with params-level +/// defaults). +#[derive(Clone)] +#[napi(object)] +pub struct EditOperation { + /// Edit kind (replace, delete, insert relative to anchor). + pub op: ChunkEditOp, + /// Chunk selector path; falls back to `EditParams.defaultSelector` when + /// omitted. + pub sel: Option, + /// Optional checksum anchor; falls back to `EditParams.defaultCrc` when + /// omitted. + pub crc: Option, + /// Replacement or inserted text (meaning depends on `op`). + pub content: Option, + /// For line-scoped `replace`, 1-based start line inside the target chunk. + pub line: Option, + /// For line-scoped `replace`, 1-based end line inside the chunk (defaults to + /// `line`). + #[napi(js_name = "endLine")] + pub end_line: Option, +} + +/// Arguments for applying a batch of chunk edits to a file. +#[derive(Clone)] +#[napi(object)] +pub struct EditParams { + /// Edits to apply in order (scheduling may reorder line-scoped groups). + pub operations: Vec, + /// Default chunk selector when an `EditOperation` omits `sel`. + #[napi(js_name = "defaultSelector")] + pub default_selector: Option, + /// Default checksum when an `EditOperation` omits `crc`. + #[napi(js_name = "defaultCrc")] + pub default_crc: Option, + /// Anchor formatting for rendered response text. + #[napi(js_name = "anchorStyle")] + pub anchor_style: Option, + /// Working directory used to resolve `filePath` and display paths. + pub cwd: String, + /// Path to the source file to edit (often relative to `cwd`). + #[napi(js_name = "filePath")] + pub file_path: String, +} + +/// Result of applying edits: new parse state plus before/after source and +/// messaging. +#[derive(Clone)] +#[napi(object, object_from_js = false)] +pub struct EditResult { + /// Chunk tree state after applying edits and re-parsing. + pub state: ChunkState, + /// Full file text before edits. + #[napi(js_name = "diffBefore")] + pub diff_before: String, + /// Full file text after edits. + #[napi(js_name = "diffAfter")] + pub diff_after: String, + /// Rendered summary for tooling (hunks, anchors), driven by `anchorStyle`. + #[napi(js_name = "responseText")] + pub response_text: String, + /// Whether the on-disk source changed. + pub changed: bool, + /// Whether the updated source re-parsed without fatal issues. + #[napi(js_name = "parseValid")] + pub parse_valid: bool, + /// Absolute or normalized paths that were written or touched. + #[napi(js_name = "touchedPaths")] + pub touched_paths: Vec, + /// Non-fatal issues (e.g. selector warnings) collected during apply. + pub warnings: Vec, +} diff --git a/crates/pi-natives/src/clipboard.rs b/crates/pi-natives/src/clipboard.rs index 9bdb890da..8d66a9e45 100644 --- a/crates/pi-natives/src/clipboard.rs +++ b/crates/pi-natives/src/clipboard.rs @@ -63,7 +63,7 @@ pub fn copy_to_clipboard(text: String) -> Result<()> { /// # Errors /// Returns an error if clipboard access fails or image encoding fails. #[napi(js_name = "readImageFromClipboard")] -pub fn read_image_from_clipboard() -> task::Async> { +pub fn read_image_from_clipboard() -> task::Promise> { task::blocking("clipboard.read_image", (), move |_| -> Result> { let mut clipboard = Clipboard::new() .map_err(|err| Error::from_reason(format!("Failed to access clipboard: {err}")))?; diff --git a/crates/pi-natives/src/fff.rs b/crates/pi-natives/src/fff.rs index 54e865bb7..2c53631c6 100644 --- a/crates/pi-natives/src/fff.rs +++ b/crates/pi-natives/src/fff.rs @@ -329,7 +329,7 @@ fn fuzzy_find_sync( pub fn fuzzy_find( options: FuzzyFindOptions<'_>, db: Option<&SearchDb>, -) -> task::Async { +) -> task::Promise { let FuzzyFindOptions { query, path, hidden, gitignore, cache, max_results, timeout_ms, signal } = options; let ct = task::CancelToken::new(timeout_ms, signal); diff --git a/crates/pi-natives/src/fs_cache.rs b/crates/pi-natives/src/fs_cache.rs index c4442d914..6bd8e48c4 100644 --- a/crates/pi-natives/src/fs_cache.rs +++ b/crates/pi-natives/src/fs_cache.rs @@ -28,6 +28,7 @@ use crate::{env_uint, task}; // Public types (re-exported by glob for backward compatibility) // ═══════════════════════════════════════════════════════════════════════════ +/// Resolved filesystem entry kind for glob filters and match metadata. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[napi] pub enum FileType { diff --git a/crates/pi-natives/src/glob.rs b/crates/pi-natives/src/glob.rs index 9fc48ff01..feb82e7de 100644 --- a/crates/pi-natives/src/glob.rs +++ b/crates/pi-natives/src/glob.rs @@ -301,11 +301,10 @@ fn run_glob( #[napi(js_name = "glob")] pub fn glob( options: GlobOptions<'_>, - #[napi(ts_arg_type = "((match: GlobMatch) => void) | undefined | null")] on_match: Option< - ThreadsafeFunction, - >, + #[napi(ts_arg_type = "((error: Error | null, match: GlobMatch) => void) | undefined | null")] + on_match: Option>, db: Option<&SearchDb>, -) -> task::Async { +) -> task::Promise { let GlobOptions { pattern, path, diff --git a/crates/pi-natives/src/grep.rs b/crates/pi-natives/src/grep.rs index ad507b708..0c19f2c34 100644 --- a/crates/pi-natives/src/grep.rs +++ b/crates/pi-natives/src/grep.rs @@ -36,10 +36,26 @@ use crate::{ const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; +/// Output mode for [`search`] and [`grep`] (string values match JS callers). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[napi(string_enum)] +pub enum GrepOutputMode { + /// Emit matched lines (and optional context lines). + #[napi(value = "content")] + Content, + /// Emit per-file or total counts instead of line content. + #[napi(value = "count")] + Count, + /// Emit one row per file that matched, without line content. + #[napi(value = "filesWithMatches")] + FilesWithMatches, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum OutputMode { Content, Count, + FilesWithMatches, } /// Options for searching file content. @@ -69,7 +85,7 @@ pub struct SearchOptions { #[napi(js_name = "maxColumns")] pub max_columns: Option, /// Output mode (content or count). - pub mode: Option, + pub mode: Option, } /// Options for searching files on disk. @@ -112,7 +128,7 @@ pub struct GrepOptions<'env> { #[napi(js_name = "maxColumns")] pub max_columns: Option, /// Output mode (content, filesWithMatches, or count). - pub mode: Option, + pub mode: Option, /// Abort signal for cancelling the operation. pub signal: Option>, /// Timeout in milliseconds for the operation. @@ -124,6 +140,7 @@ pub struct GrepOptions<'env> { #[derive(Clone)] #[napi(object)] pub struct ContextLine { + /// 1-indexed line number in the source file. #[napi(js_name = "lineNumber")] pub line_number: u32, /// Raw line content (trimmed line ending). @@ -500,10 +517,11 @@ impl Sink for MatchCollector { // Option resolution // --------------------------------------------------------------------------- -fn parse_output_mode(mode: Option<&str>) -> OutputMode { +const fn parse_output_mode(mode: Option) -> OutputMode { match mode { - Some("count" | "filesWithMatches") => OutputMode::Count, - _ => OutputMode::Content, + None | Some(GrepOutputMode::Content) => OutputMode::Content, + Some(GrepOutputMode::Count) => OutputMode::Count, + Some(GrepOutputMode::FilesWithMatches) => OutputMode::FilesWithMatches, } } @@ -608,7 +626,7 @@ fn run_search( content: &[u8], params: SearchParams, ) -> io::Result { - let collect_matches = params.mode == OutputMode::Content; + let collect_matches = matches!(params.mode, OutputMode::Content); let (before, after) = if collect_matches { (params.context_before as usize, params.context_after as usize) } else { @@ -757,7 +775,7 @@ struct GrepConfig { context_after: Option, context: Option, max_columns: Option, - mode: Option, + mode: Option, } fn collect_files( @@ -1307,6 +1325,17 @@ fn run_sequential_search( match_count: Some(crate::utils::clamp_u32(search.match_count)), }); }, + OutputMode::FilesWithMatches => { + matches.push(GrepMatch { + path: entry.relative_path.clone(), + line_number: 0, + line: String::new(), + context_before: None, + context_after: None, + truncated: None, + match_count: None, + }); + }, } if search.limit_reached || max_count.is_some_and(|max| collected >= max) { @@ -1324,7 +1353,7 @@ fn run_sequential_search( fn search_sync(content: &[u8], options: SearchOptions) -> SearchResult { let ignore_case = options.ignore_case.unwrap_or(false); let multiline = options.multiline.unwrap_or(false); - let mode = parse_output_mode(options.mode.as_deref()); + let mode = parse_output_mode(options.mode); let matcher = match build_matcher(&options.pattern, ignore_case, multiline) { Ok(matcher) => matcher, Err(err) => return empty_search_result(Some(err.to_string())), @@ -1370,7 +1399,7 @@ fn grep_sync( .map_err(|err| Error::from_reason(format!("Path not found: {err}")))?; let ignore_case = options.ignore_case.unwrap_or(false); let multiline = options.multiline.unwrap_or(false); - let output_mode = parse_output_mode(options.mode.as_deref()); + let output_mode = parse_output_mode(options.mode); let matcher = build_matcher(&options.pattern, ignore_case, multiline)?; let (context_before, context_after) = @@ -1465,6 +1494,17 @@ fn grep_sync( match_count: Some(crate::utils::clamp_u32(search.match_count)), }); }, + OutputMode::FilesWithMatches => { + matches.push(GrepMatch { + path: path_string, + line_number: 0, + line: String::new(), + context_before: None, + context_after: None, + truncated: None, + match_count: None, + }); + }, } let limit_reached = @@ -1560,6 +1600,21 @@ fn grep_sync( } matches.push(grep_match); }, + OutputMode::FilesWithMatches => { + let grep_match = GrepMatch { + path: result.relative_path.clone(), + line_number: 0, + line: String::new(), + context_before: None, + context_after: None, + truncated: None, + match_count: None, + }; + if let Some(callback) = on_match { + callback.call(Ok(grep_match.clone()), ThreadsafeFunctionCallMode::NonBlocking); + } + matches.push(grep_match); + }, } } @@ -1632,8 +1687,8 @@ pub fn search(content: Either, options: SearchOptions) -> pub fn has_match( content: Either, pattern: Either, - ignore_case: bool, - multiline: bool, + ignore_case: Option, + multiline: Option, ) -> Result { // Hold JsStringUtf8 on the stack and borrow - no copy let content_utf8; @@ -1660,7 +1715,8 @@ pub fn has_match( }, }; - let matcher = build_matcher(pattern_ref, ignore_case, multiline)?; + let matcher = + build_matcher(pattern_ref, ignore_case.unwrap_or(false), multiline.unwrap_or(false))?; Ok(matcher.is_match(content_slice).unwrap_or(false)) } @@ -1675,11 +1731,10 @@ pub fn has_match( #[napi(js_name = "grep")] pub fn grep( options: GrepOptions<'_>, - #[napi(ts_arg_type = "((match: GrepMatch) => void) | undefined | null")] on_match: Option< - ThreadsafeFunction, - >, + #[napi(ts_arg_type = "((error: Error | null, match: GrepMatch) => void) | undefined | null")] + on_match: Option>, db: Option<&crate::search_db::SearchDb>, -) -> task::Async { +) -> task::Promise { let GrepOptions { pattern, path, diff --git a/crates/pi-natives/src/html.rs b/crates/pi-natives/src/html.rs index 373025466..fabeefa01 100644 --- a/crates/pi-natives/src/html.rs +++ b/crates/pi-natives/src/html.rs @@ -26,7 +26,7 @@ pub struct HtmlToMarkdownOptions { pub fn html_to_markdown( html: String, options: Option, -) -> task::Async { +) -> task::Promise { let options = options.unwrap_or_default(); let clean_content = options.clean_content.unwrap_or(false); let skip_images = options.skip_images.unwrap_or(false); diff --git a/crates/pi-natives/src/image.rs b/crates/pi-natives/src/image.rs index a543f48c8..a816c2393 100644 --- a/crates/pi-natives/src/image.rs +++ b/crates/pi-natives/src/image.rs @@ -10,7 +10,7 @@ use std::{io::Cursor, sync::Arc}; use icy_sixel::{EncodeOptions, sixel_encode}; use image::{ - DynamicImage, ImageFormat, ImageReader, + DynamicImage, ImageFormat as StdImageFormat, ImageReader, codecs::{jpeg::JpegEncoder, webp::WebPEncoder}, imageops::FilterType, }; @@ -19,6 +19,19 @@ use napi_derive::napi; use crate::task; +/// Output format for [`PhotonImage::encode`]. +#[napi] +pub enum ImageFormat { + /// PNG encoded bytes. + PNG = 0, + /// JPEG encoded bytes. + JPEG = 1, + /// WebP encoded bytes. + WEBP = 2, + /// GIF encoded bytes. + GIF = 3, +} + /// Sampling filter for resize operations. #[napi] pub enum SamplingFilter { @@ -53,7 +66,7 @@ pub struct PhotonImage { img: Arc, } -type ImageTask = task::Async; +type ImageTask = task::Promise; #[napi] impl PhotonImage { @@ -85,16 +98,10 @@ impl PhotonImage { /// Encode the image to bytes in the specified format. /// - /// Format values (matching `ImageFormat` enum in TS): - /// - 0: PNG (quality ignored) - /// - 1: JPEG (quality 0-100) - /// - 2: WebP (lossless, quality ignored) - /// - 3: GIF (quality ignored) - /// /// # Errors /// Returns an error if encoding fails or format is invalid. #[napi(js_name = "encode")] - pub fn encode(&self, format: u8, quality: u8) -> task::Async> { + pub fn encode(&self, format: ImageFormat, quality: u8) -> task::Promise> { let img = Arc::clone(&self.img); task::blocking("image.encode", (), move |_| encode_image(&img, format, quality)) } @@ -148,36 +155,35 @@ fn decode_image_from_bytes(bytes: &[u8]) -> Result { .decode() .map_err(|e| Error::from_reason(format!("Failed to decode image: {e}"))) } -fn encode_image(img: &DynamicImage, format: u8, quality: u8) -> Result> { +fn encode_image(img: &DynamicImage, format: ImageFormat, quality: u8) -> Result> { let (w, h) = (img.width(), img.height()); match format { - 0 => { + ImageFormat::PNG => { let mut buffer = Vec::with_capacity((w * h * 4) as usize); - img.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Png) + img.write_to(&mut Cursor::new(&mut buffer), StdImageFormat::Png) .map_err(|e| Error::from_reason(format!("Failed to encode PNG: {e}")))?; Ok(buffer) }, - 1 => { + ImageFormat::JPEG => { let mut buffer = Vec::with_capacity((w * h * 3) as usize); let encoder = JpegEncoder::new_with_quality(&mut buffer, quality); img.write_with_encoder(encoder) .map_err(|e| Error::from_reason(format!("Failed to encode JPEG: {e}")))?; Ok(buffer) }, - 2 => { + ImageFormat::WEBP => { let mut buffer = Vec::with_capacity((w * h * 4) as usize); let encoder = WebPEncoder::new_lossless(&mut buffer); img.write_with_encoder(encoder) .map_err(|e| Error::from_reason(format!("Failed to encode WebP: {e}")))?; Ok(buffer) }, - 3 => { + ImageFormat::GIF => { let mut buffer = Vec::with_capacity((w * h) as usize); - img.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Gif) + img.write_to(&mut Cursor::new(&mut buffer), StdImageFormat::Gif) .map_err(|e| Error::from_reason(format!("Failed to encode GIF: {e}")))?; Ok(buffer) }, - _ => Err(Error::from_reason(format!("Invalid image format: {format}"))), } } diff --git a/crates/pi-natives/src/indent.rs b/crates/pi-natives/src/indent.rs new file mode 100644 index 000000000..cc1f09e39 --- /dev/null +++ b/crates/pi-natives/src/indent.rs @@ -0,0 +1,313 @@ +//! Tab width resolution from `.editorconfig` and process defaults. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::LazyLock, +}; + +use dashmap::DashMap; +use globset::GlobSet; +use napi::bindgen_prelude::*; +use napi_derive::napi; +use path_clean::PathClean; + +use crate::{glob_util::compile_glob, text}; + +const MIN_TAB_WIDTH: u32 = 1; +const MAX_TAB_WIDTH: u32 = 16; +const EDITORCONFIG_NAME: &str = ".editorconfig"; + +static EDITOR_CONFIG_CACHE: LazyLock> = + LazyLock::new(DashMap::new); +static EDITOR_CONFIG_CHAIN_CACHE: LazyLock>> = + LazyLock::new(DashMap::new); +static INDENTATION_CACHE: LazyLock> = LazyLock::new(DashMap::new); + +#[derive(Clone)] +struct EditorConfigSection { + pattern: String, + properties: HashMap, +} + +#[derive(Clone)] +struct ParsedEditorConfig { + root: bool, + sections: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum IndentStyle { + Space, + Tab, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum IndentSize { + Spaces(u32), + Tab, +} + +struct EditorConfigMatch { + indent_style: Option, + indent_size: Option, + tab_width: Option, +} + +fn clamp_tab_width(value: u32) -> u32 { + value.clamp(MIN_TAB_WIDTH, MAX_TAB_WIDTH) +} + +fn parse_positive_integer(value: Option<&str>) -> Option { + let value = value?; + if !value.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let parsed: u32 = value.parse().ok()?; + if parsed == 0 { + return None; + } + Some(clamp_tab_width(parsed)) +} + +fn parse_editor_config_file(content: &str) -> ParsedEditorConfig { + let mut parsed = ParsedEditorConfig { root: false, sections: Vec::new() }; + let mut current_section_idx: Option = None; + + for raw_line in content.lines() { + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + if line.starts_with('#') || line.starts_with(';') { + continue; + } + + if line.starts_with('[') && line.ends_with(']') && line.len() >= 2 { + let pattern = line[1..line.len() - 1].trim(); + if pattern.is_empty() { + current_section_idx = None; + continue; + } + parsed.sections.push(EditorConfigSection { + pattern: pattern.to_string(), + properties: HashMap::new(), + }); + current_section_idx = Some(parsed.sections.len() - 1); + continue; + } + + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim().to_lowercase(); + let value = value.trim().to_lowercase(); + if key.is_empty() { + continue; + } + + if let Some(idx) = current_section_idx { + parsed.sections[idx].properties.insert(key, value); + } else if key == "root" { + parsed.root = value == "true"; + } + } + + parsed +} + +fn parse_cached_editor_config(config_path: &Path) -> Option { + let key = config_path.to_string_lossy().into_owned(); + if let Some(cached) = EDITOR_CONFIG_CACHE.get(&key) { + return Some(cached.clone()); + } + + let content = std::fs::read_to_string(config_path).ok()?; + let parsed = parse_editor_config_file(&content); + EDITOR_CONFIG_CACHE.insert(key, parsed.clone()); + Some(parsed) +} + +fn matches_editor_config_pattern(pattern: &str, relative_path: &str) -> bool { + let normalized = pattern.trim_start_matches('/'); + if normalized.is_empty() { + return false; + } + + let candidates: Vec> = if normalized.contains('/') { + vec![compile_glob(normalized, false)] + } else { + vec![compile_glob(normalized, false), compile_glob(normalized, true)] + }; + + for gs in candidates { + if let Ok(set) = gs + && set.is_match(relative_path) + { + return true; + } + } + + false +} + +fn resolve_file_path(project_dir: &Path, file: &str) -> PathBuf { + let p = Path::new(file); + if p.is_absolute() { + p.to_path_buf().clean() + } else { + project_dir.join(file).clean() + } +} + +fn collect_editor_config_chain(start_dir: &Path) -> Vec<(PathBuf, ParsedEditorConfig)> { + let key = start_dir.to_string_lossy().into_owned(); + if let Some(cached) = EDITOR_CONFIG_CHAIN_CACHE.get(&key) { + return cached.clone(); + } + + let mut chain = Vec::new(); + let mut cursor = start_dir.to_path_buf(); + loop { + let config_path = cursor.join(EDITORCONFIG_NAME); + if let Some(parsed) = parse_cached_editor_config(&config_path) { + let stop = parsed.root; + chain.push((cursor.clone(), parsed)); + if stop { + break; + } + } + + let Some(parent) = cursor.parent() else { + break; + }; + if parent == cursor { + break; + } + cursor = parent.to_path_buf(); + } + + chain.reverse(); + EDITOR_CONFIG_CHAIN_CACHE.insert(key, chain.clone()); + chain +} + +fn relative_path_unified(base: &Path, file: &Path) -> String { + pathdiff::diff_paths(file, base) + .unwrap_or_else(|| PathBuf::from(".")) + .to_string_lossy() + .replace('\\', "/") +} + +fn resolve_editor_config_match(absolute_file: &Path) -> Option { + let file_dir = absolute_file.parent()?; + let chain = collect_editor_config_chain(file_dir); + if chain.is_empty() { + return None; + } + + let mut match_ = + EditorConfigMatch { indent_style: None, indent_size: None, tab_width: None }; + + for (dir, parsed) in chain { + let relative_path = relative_path_unified(&dir, absolute_file); + for section in &parsed.sections { + if !matches_editor_config_pattern(§ion.pattern, &relative_path) { + continue; + } + + if let Some(style) = section.properties.get("indent_style") { + match style.as_str() { + "space" => match_.indent_style = Some(IndentStyle::Space), + "tab" => match_.indent_style = Some(IndentStyle::Tab), + _ => {}, + } + } + + if let Some(raw) = section.properties.get("indent_size") { + if raw == "tab" { + match_.indent_size = Some(IndentSize::Tab); + } else if let Some(n) = parse_positive_integer(Some(raw.as_str())) { + match_.indent_size = Some(IndentSize::Spaces(n)); + } + } + + if let Some(tw) = + parse_positive_integer(section.properties.get("tab_width").map(|s| s.as_str())) + { + match_.tab_width = Some(tw); + } + } + } + + if match_.indent_style.is_some() || match_.indent_size.is_some() || match_.tab_width.is_some() { + Some(match_) + } else { + None + } +} + +fn resolve_editor_config_tab_width( + match_: Option<&EditorConfigMatch>, + fallback: u32, +) -> Option { + let m = match_?; + + if let Some(IndentSize::Spaces(n)) = m.indent_size { + return Some(n); + } + + if m.indent_size == Some(IndentSize::Tab) { + if let Some(tw) = m.tab_width { + return Some(tw); + } + return Some(fallback); + } + + if let Some(tw) = m.tab_width { + return Some(tw); + } + + if m.indent_style == Some(IndentStyle::Tab) { + return Some(fallback); + } + + None +} + +/// Returns a string of spaces used to replace one tab, using `.editorconfig` +/// when present. +pub fn get_indentation(project_dir: &Path, file: Option, fallback: u32) -> u32 { + let Some(file) = file else { + return fallback; + }; + + let absolute_file = resolve_file_path(project_dir, &file); + let abs_key = absolute_file.to_string_lossy().into_owned(); + if let Some(cached) = INDENTATION_CACHE.get(&abs_key) { + return *cached; + } + + let editor_match = resolve_editor_config_match(&absolute_file); + let resolved_width = + resolve_editor_config_tab_width(editor_match.as_ref(), fallback).unwrap_or(fallback); + let clamped = clamp_tab_width(resolved_width); + INDENTATION_CACHE.insert(abs_key, clamped); + clamped +} + +/// Get the indentation for a file. +#[napi(js_name = "getIndentation")] +pub fn get_indentation_napi( + env: &napi::Env, + file: Option, + project_dir: Option, +) -> u32 { + let fallback = text::operation_tab_width(env, None); + let project_dir: &Path = match &project_dir { + Some(p) => Path::new(p), + None => &std::env::current_dir().unwrap(), + }; + + get_indentation(project_dir, file, fallback as u32) +} diff --git a/crates/pi-natives/src/keys.rs b/crates/pi-natives/src/keys.rs index 04c6ceb16..3ab91fa90 100644 --- a/crates/pi-natives/src/keys.rs +++ b/crates/pi-natives/src/keys.rs @@ -72,6 +72,27 @@ const MOD_ALT: u32 = 2; const MOD_CTRL: u32 = 4; const MOD_NUM_LOCK: u32 = 128; +/// Event types from Kitty keyboard protocol (flag 2). +#[napi] +pub enum KeyEventType { + /// Key press event. + Press = 1, + /// Key repeat event. + Repeat = 2, + /// Key release event. + Release = 3, +} + +#[inline] +fn optional_kitty_event_type(event: Option) -> Option { + event.and_then(|ev| match ev { + 1 => Some(KeyEventType::Press), + 2 => Some(KeyEventType::Repeat), + 3 => Some(KeyEventType::Release), + _ => None, + }) +} + #[inline] const fn map_keypad_nav(codepoint: i32) -> Option { match codepoint { @@ -142,7 +163,7 @@ pub struct ParsedKittyResult { /// Modifier bitmask (shift/alt/ctrl), excluding lock bits. pub modifier: u32, /// Optional event type (1 = press, 2 = repeat, 3 = release). - pub event_type: Option, + pub event_type: Option, } /// Perfect hash map for legacy sequences - O(1) lookup @@ -362,7 +383,7 @@ pub fn parse_kitty_sequence_napi(data: String) -> Option { shifted_key: p.shifted_key, base_layout_key: p.base_layout_key, modifier: p.modifier, - event_type: p.event_type, + event_type: optional_kitty_event_type(p.event_type), }) } diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index f6f55bca2..3d90d68f4 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -37,6 +37,7 @@ pub mod grep; pub mod highlight; pub mod html; pub mod image; +pub mod indent; pub mod keys; pub mod language; pub mod prof; diff --git a/crates/pi-natives/src/projfs_overlay.rs b/crates/pi-natives/src/projfs_overlay.rs index d1d7ca5e9..0eabe0614 100644 --- a/crates/pi-natives/src/projfs_overlay.rs +++ b/crates/pi-natives/src/projfs_overlay.rs @@ -5,22 +5,31 @@ use napi_derive::napi; const PROJFS_UNAVAILABLE_PREFIX: &str = "PROJFS_UNAVAILABLE:"; +/// Result of probing Windows Projected File System (`ProjFS`) support for +/// overlay workflows. #[napi(object)] pub struct ProjfsOverlayProbeResult { + /// True when `ProjFS` APIs are available and loaded. pub available: bool, + /// Human-readable reason when `available` is false (e.g. wrong OS or missing + /// DLL). pub reason: Option, } +/// Probe whether `ProjFS` overlay virtualization can be started on this system. #[napi(js_name = "projfsOverlayProbe")] pub fn projfs_overlay_probe() -> ProjfsOverlayProbeResult { imp::probe() } +/// Start a `ProjFS` overlay: `projection_root` shows the merged view; +/// `lower_root` is the backing tree. #[napi(js_name = "projfsOverlayStart")] pub fn projfs_overlay_start(lower_root: String, projection_root: String) -> Result<()> { imp::start(&lower_root, &projection_root) } +/// Stop `ProjFS` virtualization for an active `projection_root` session. #[napi(js_name = "projfsOverlayStop")] pub fn projfs_overlay_stop(projection_root: String) -> Result<()> { imp::stop(&projection_root); diff --git a/crates/pi-natives/src/pty.rs b/crates/pi-natives/src/pty.rs index 4e90fafe1..2ad25681c 100644 --- a/crates/pi-natives/src/pty.rs +++ b/crates/pi-natives/src/pty.rs @@ -109,9 +109,8 @@ impl PtySession { &self, env: &'env Env, options: PtyStartOptions<'env>, - #[napi(ts_arg_type = "((chunk: string) => void) | undefined | null")] on_chunk: Option< - ThreadsafeFunction, - >, + #[napi(ts_arg_type = "((error: Error | null, chunk: string) => void) | undefined | null")] + on_chunk: Option>, ) -> Result> { let run_config = PtyRunConfig { command: options.command, diff --git a/crates/pi-natives/src/search_db.rs b/crates/pi-natives/src/search_db.rs index d4f51cf6f..7c6da26a8 100644 --- a/crates/pi-natives/src/search_db.rs +++ b/crates/pi-natives/src/search_db.rs @@ -37,6 +37,8 @@ impl Drop for SearchDbInner { } } +/// Long-lived native search state: frecency persistence and per-workspace file +/// picker caches. #[derive(Clone)] #[napi] pub struct SearchDb { @@ -45,6 +47,8 @@ pub struct SearchDb { #[napi] impl SearchDb { + /// Create search DB state rooted at `path` (trimmed). An empty path skips + /// frecency storage. #[napi(constructor)] pub fn new(path: String) -> Self { let normalized = path.trim().to_string(); @@ -68,6 +72,8 @@ impl SearchDb { } } + /// Root path string associated with this instance (same as passed to the + /// constructor). #[napi(getter)] pub fn path(&self) -> String { self.inner.path.clone() diff --git a/crates/pi-natives/src/shell.rs b/crates/pi-natives/src/shell.rs index e1f6c0dad..03a491e63 100644 --- a/crates/pi-natives/src/shell.rs +++ b/crates/pi-natives/src/shell.rs @@ -167,9 +167,8 @@ impl Shell { &self, env: &'e Env, options: ShellRunOptions<'e>, - #[napi(ts_arg_type = "((chunk: string) => void) | undefined | null")] on_chunk: Option< - ThreadsafeFunction, - >, + #[napi(ts_arg_type = "((error: Error | null, chunk: string) => void) | undefined | null")] + on_chunk: Option>, ) -> Result> { let ct = task::CancelToken::new(options.timeout_ms, options.signal); let session = self.session.clone(); @@ -297,9 +296,8 @@ pub struct ShellExecuteResult { pub fn execute_shell<'env>( env: &'env Env, options: ShellExecuteOptions<'env>, - #[napi(ts_arg_type = "((chunk: string) => void) | undefined | null")] on_chunk: Option< - ThreadsafeFunction, - >, + #[napi(ts_arg_type = "((error: Error | null, chunk: string) => void) | undefined | null")] + on_chunk: Option>, ) -> Result> { let config = ShellConfig { session_env: options.session_env, snapshot_path: options.snapshot_path }; diff --git a/crates/pi-natives/src/task.rs b/crates/pi-natives/src/task.rs index 4380b19ff..62619f25f 100644 --- a/crates/pi-natives/src/task.rs +++ b/crates/pi-natives/src/task.rs @@ -270,7 +270,7 @@ where } } -pub type Async = AsyncTask>; +pub type Promise = AsyncTask>; /// Create an `AsyncTask` that runs blocking work on libuv's thread pool. /// diff --git a/crates/pi-natives/src/text.rs b/crates/pi-natives/src/text.rs index e1d076ba4..092b38403 100644 --- a/crates/pi-natives/src/text.rs +++ b/crates/pi-natives/src/text.rs @@ -8,7 +8,10 @@ //! - Ellipsis decoded lazily //! - truncateToWidth returns the original `JsString` when possible -use std::cell::RefCell; +use std::{ + cell::{Cell, RefCell}, + sync::atomic::{AtomicU32, Ordering}, +}; use napi::{JsString, bindgen_prelude::*}; use napi_derive::napi; @@ -16,24 +19,63 @@ use smallvec::{SmallVec, smallvec}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -const DEFAULT_TAB_WIDTH: usize = 3; -const MIN_TAB_WIDTH: usize = 1; -const MAX_TAB_WIDTH: usize = 16; +const MIN_TAB_WIDTH: u32 = 1; +const MAX_TAB_WIDTH: u32 = 16; +pub const DEFAULT_TAB_WIDTH: usize = 3; const ESC: u16 = 0x1b; -#[inline] -const fn clamp_tab_width(tab_width: Option) -> usize { - let width = match tab_width { - Some(tab_width) => tab_width as usize, - None => DEFAULT_TAB_WIDTH, - }; - if width < MIN_TAB_WIDTH { - MIN_TAB_WIDTH - } else if width > MAX_TAB_WIDTH { - MAX_TAB_WIDTH - } else { - width +static PROCESS_TAB_WIDTH: AtomicU32 = AtomicU32::new(DEFAULT_TAB_WIDTH as u32); + +thread_local! { + static THREAD_TAB_WIDTH: Cell = const { Cell::new(0) }; +} + +struct EnvTabWidth { + width: u32, +} + +/// Set the default tab width for the process. +#[napi(js_name = "setDefaultTabWidth")] +pub fn set_env_tab_width(env: &napi::Env, width: u32) { + // #1. env-wide tab width + _ = env.set_instance_data(EnvTabWidth { width }, 0, |_| {}); + // #2. thread-local tab width + THREAD_TAB_WIDTH.set(width); + // #3. process-wide tab width + PROCESS_TAB_WIDTH.store(width, Ordering::Relaxed); +} + +/// Get the default tab width for the process. +#[napi(js_name = "getDefaultTabWidth")] +pub fn get_env_tab_width(env: &napi::Env) -> u32 { + // #1. env-wide tab width + if let Ok(Some(data)) = env.get_instance_data::() { + return data.width; } + // #2. thread-local tab width + let tls = THREAD_TAB_WIDTH.get(); + if tls > 0 { + return tls; + } + // #3. process-wide tab width + PROCESS_TAB_WIDTH.load(Ordering::Relaxed) +} + +pub fn operation_tab_width(env: &napi::Env, explicit: Option) -> usize { + explicit + .unwrap_or_else(|| get_env_tab_width(env)) + .clamp(MIN_TAB_WIDTH, MAX_TAB_WIDTH) as usize +} + +/// Ellipsis strategy for [`truncate_to_width`]. +#[napi] +pub enum Ellipsis { + /// Use a single Unicode ellipsis character ("…"). + Unicode = 0, + /// Use three ASCII dots ("..."). + Ascii = 1, + /// Omit ellipsis entirely. + Omit = 2, } fn build_utf16_string(mut data: Vec) -> Utf16String { @@ -48,6 +90,8 @@ fn build_utf16_string(mut data: Vec) -> Utf16String { // Results // ============================================================================ +/// Visible slice of a line after ANSI-aware column selection +/// (`sliceWithWidth`). #[napi(object)] pub struct SliceResult { /// UTF-16 slice containing the selected text. @@ -56,17 +100,18 @@ pub struct SliceResult { pub width: u32, } +/// Before/after UTF-16 segments around an overlay region, with measured widths. #[napi(object)] pub struct ExtractSegmentsResult { /// UTF-16 content before the overlay region. pub before: Utf16String, - #[napi(js_name = "beforeWidth")] /// Visible width of the `before` segment. + #[napi(js_name = "beforeWidth")] pub before_width: u32, /// UTF-16 content after the overlay region. pub after: Utf16String, - #[napi(js_name = "afterWidth")] /// Visible width of the `after` segment. + #[napi(js_name = "afterWidth")] pub after_width: u32, } @@ -789,12 +834,13 @@ fn wrap_text_with_ansi_impl( /// Returns UTF-16 lines with active SGR codes carried across line boundaries. #[napi(js_name = "wrapTextWithAnsi")] pub fn wrap_text_with_ansi( + env: &napi::Env, text: JsString, width: u32, tab_width: Option, ) -> Result> { let text_u16 = text.into_utf16()?; - let tab_width = clamp_tab_width(tab_width); + let tab_width = operation_tab_width(env, tab_width); let lines = wrap_text_with_ansi_impl(text_u16.as_slice(), width as usize, tab_width); Ok(lines.into_iter().map(build_utf16_string).collect()) } @@ -805,18 +851,20 @@ pub fn wrap_text_with_ansi( /// Truncate text to a visible width, preserving ANSI codes. /// -/// `ellipsis_kind`: 0 = "…", 1 = "...", 2 = "" (omit); pads with spaces when -/// requested. +/// Pads with spaces when requested. #[napi(js_name = "truncateToWidth")] -pub fn truncate_to_width( - text: JsString<'_>, +pub fn truncate_to_width<'a>( + env: &napi::Env, + text: JsString<'a>, max_width: u32, - ellipsis_kind: u8, - pad: bool, + ellipsis_kind: Option, + pad: Option, tab_width: Option, -) -> Result, Utf16String>> { +) -> Result, Utf16String>> { let max_width = max_width as usize; - let tab_width = clamp_tab_width(tab_width); + let ellipsis_kind = ellipsis_kind.unwrap_or(Ellipsis::Unicode); + let pad = pad.unwrap_or(false); + let tab_width = operation_tab_width(env, tab_width); // Keep original handle so we can return it without allocating. let original = text; @@ -849,10 +897,9 @@ pub fn truncate_to_width( const ELLIPSIS_OMIT: &[u16] = &[]; let (ellipsis, ellipsis_w): (&[u16], usize) = match ellipsis_kind { - 0 => (ELLIPSIS_UNICODE, 1), - 1 => (ELLIPSIS_ASCII, 3), - 2 => (ELLIPSIS_OMIT, 0), - _ => (ELLIPSIS_UNICODE, 1), // Default to Unicode for invalid values + Ellipsis::Unicode => (ELLIPSIS_UNICODE, 1), + Ellipsis::Ascii => (ELLIPSIS_ASCII, 3), + Ellipsis::Omit => (ELLIPSIS_OMIT, 0), }; let target_w = max_width.saturating_sub(ellipsis_w); @@ -1072,16 +1119,22 @@ fn slice_with_width_impl( /// width. #[napi(js_name = "sliceWithWidth")] pub fn slice_with_width( + env: &napi::Env, line: JsString, start_col: u32, length: u32, - strict: bool, + strict: Option, tab_width: Option, ) -> Result { let line_u16 = line.into_utf16()?; let line = line_u16.as_slice(); + let strict = strict.unwrap_or(false); - let tab_width = clamp_tab_width(tab_width); + if length == 0 { + return Ok(SliceResult { text: build_utf16_string(vec![]), width: 0 }); + } + + let tab_width = operation_tab_width(env, tab_width); let (out, w) = slice_with_width_impl(line, start_col as usize, length as usize, strict, tab_width); @@ -1232,6 +1285,7 @@ fn extract_segments_impl( /// truncation. #[napi(js_name = "extractSegments")] pub fn extract_segments( + env: &napi::Env, line: JsString, before_end: u32, after_start: u32, @@ -1242,7 +1296,7 @@ pub fn extract_segments( let line_u16 = line.into_utf16()?; let line = line_u16.as_slice(); - let tab_width = clamp_tab_width(tab_width); + let tab_width = operation_tab_width(env, tab_width); let (before, bw, after, aw) = extract_segments_impl( line, before_end as usize, @@ -1351,9 +1405,9 @@ pub fn sanitize_text(text: JsString<'_>) -> Result, Utf16Str /// /// Tabs count as a fixed-width cell. #[napi(js_name = "visibleWidth")] -pub fn visible_width_napi(text: JsString, tab_width: Option) -> Result { +pub fn visible_width_napi(env: &napi::Env, text: JsString, tab_width: Option) -> Result { let text_u16 = text.into_utf16()?; - let tab_width = clamp_tab_width(tab_width); + let tab_width = operation_tab_width(env, tab_width); Ok(crate::utils::clamp_u32(visible_width_u16(text_u16.as_slice(), tab_width) as u64)) } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7ba74f3b5..c4874cf10 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,11 @@ ### Added +- Added `anchorStyle` parameter to chunk read formatting to control chunk path display format (full, kind, or bare) +- Added `{{anchor}}`, `{{anchor}}`, and `{{sel}}` Handlebars helpers for flexible chunk anchor rendering in prompts +- Added `chunked` property to file display mode to indicate chunk-mode rendering +- Added chunk-path-prefixed output format for grep in chunk mode: `path:selector>LINE|content` +- Added `lru-cache` dependency for caching support - Added `read.prosechunks` setting to enable chunk rendering for prose files (markdown, text, log, asciidoc, restructuredtext) in chunk edit mode - Added `read.explorechunks` setting to show chunk tree without checksums for read-only agents like explore - Added `new_segment` option to `init_experiment` to force a new segment even when contract fields match, enabling re-initialization with unchanged parameters @@ -17,6 +22,14 @@ ### Changed +- Moved `copyToClipboard` and `readImageFromClipboard` functions to new `utils/clipboard.ts` module with improved OSC 52 support and Termux compatibility +- Updated grep output mode to use `GrepOutputMode` enum from pi-natives instead of string literals +- Changed macOS appearance observer to use `MacAppearanceObserver.start()` class method with error-first callback signature +- Updated `detectMacOSAppearance()` to return `MacAppearanceObserver` enum values instead of strings +- Refactored shell command callbacks to use error-first signature `(err, chunk)` for consistency with native APIs +- Updated chunk edit prompt documentation to use Handlebars helpers for chunk path and anchor rendering +- Changed `getDiagnosticsForFile` callback signature to error-first pattern `(err, chunk)` in bash executor and find tool +- Unified grep tool description to use single template with `IS_CHUNK_MODE` flag instead of separate chunk-specific template - Renamed chunk edit line-range parameters from `beg`/`end` to `line`/`end_line` for consistency with documentation - Unified `splice` and `replace` operations into a single `replace` operation with optional `line`/`end_line` parameters for line-scoped edits - Updated `replace` operation to support optional `line` and `end_line` parameters, with `end_line` defaulting to `line` when omitted for single-line replacements @@ -45,16 +58,21 @@ ### Removed +- Removed `grep-chunk.md` prompt template; grep now uses unified template with chunk-mode conditional +- Removed `startMacAppearanceObserver` function export; use `MacAppearanceObserver.start()` instead +- Removed `copyToClipboard` export from pi-natives; use local clipboard utility instead - Removed `PI_CHUNK_SPLICES` environment variable and `chunkSplicesEnabled()` function; splice operations are no longer conditionally available - Autoresearch segment fingerprint hashing and `segmentFingerprint` on experiment state / `autoresearch.jsonl` config lines ### Fixed +- Fixed shell command error handling to properly check for errors before processing chunks in bash executor and config resolution - Fixed `log_experiment` to correctly identify and revert only files modified by the run, leaving pre-existing uncommitted changes intact - `/autoresearch` with no arguments toggles off when mode is already on (same idea as `/plan`), and slash-argument completion no longer offers `off`/`clear` on an empty prefix so Tab after the command does not auto-insert a subcommand. - Fixed chunk-mode edit/read edge cases around zero-width gap splices, stale batch diagnostics, grouped Go receiver rendering, rendered line-count headers, and parse rejection location details. ## [13.19.0] - 2026-04-05 + ### Added - Added idle auto-compaction settings and scheduling so sessions can compact after inactive turns without auto-continuing. @@ -102,6 +120,7 @@ - Fixed the plan review selector to support the external editor shortcut for opening and updating the current plan from the approval screen ## [13.18.0] - 2026-04-02 + ### Breaking Changes - Removed standalone `fetch` tool; URL fetching is now integrated into the `read` tool @@ -124,6 +143,7 @@ - Fixed `read` tool to properly handle `file://` URL scheme by converting to filesystem paths ## [13.17.5] - 2026-04-01 + ### Added - Added support for writing to ZIP archives using fflate library for cross-platform compatibility @@ -137,6 +157,7 @@ - Removed GhPrPushTool test case ## [13.17.4] - 2026-04-01 + ### Added - Support for writing to archive entries in `.tar`, `.tar.gz`, `.tgz`, and `.zip` files using `archive.ext:path/inside/archive` syntax @@ -161,6 +182,7 @@ - Updated `read` tool documentation to reflect archive support and usage patterns ## [13.17.2] - 2026-04-01 + ### Added - Added `/marketplace help` command to display usage guide for all marketplace operations @@ -216,6 +238,7 @@ - Fixed inline image rendering to cap image height and preserve multiplexer scrollback during terminal resizes ([#587](https://github.com/can1357/oh-my-pi/pull/587) by [@smileynet](https://github.com/smileynet)) ## [13.17.1] - 2026-04-01 + ### Removed - Removed `code_search` tool for code snippet and documentation search @@ -225,6 +248,7 @@ - Fixed edit tool diff rendering to wrap long diff lines with continuation gutters instead of truncating them at terminal width ([#578](https://github.com/can1357/oh-my-pi/issues/578)) - Fixed `--list-models` and `/model` provider filtering to hide models from disabled providers ([#588](https://github.com/can1357/oh-my-pi/issues/588)) - Fixed edit tool diffstats to use diff-specific add/remove theme colors instead of success/error status colors ([#589](https://github.com/can1357/oh-my-pi/issues/589)) + ## [13.17.0] - 2026-03-30 ### Added @@ -277,6 +301,7 @@ - Fixed `--model provider/id` resolving to wrong provider when model ID exists in multiple catalogs ([#560](https://github.com/can1357/oh-my-pi/issues/560)) ## [13.16.4] - 2026-03-28 + ### Changed - Renamed hashline helper functions from `hlineref`/`hlinefull` to `href`/`hline` for brevity @@ -456,6 +481,7 @@ ### Added - Session observer overlay (`Ctrl+S`): view running subagent sessions with a picker and read-only transcript showing thinking, text, tool calls, and results + ## [13.14.0] - 2026-03-20 ### Added @@ -6677,4 +6703,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections \ No newline at end of file +- HTML export with syntax highlighting and collapsible sections diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index e61137037..6d48de530 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -56,6 +56,7 @@ "fflate": "0.8.2", "handlebars": "^4.7", "linkedom": "^0.18", + "lru-cache": "11.3.1", "markit-ai": "0.5.0", "puppeteer": "^24.37", "zod": "4.3.6" diff --git a/packages/coding-agent/src/autoresearch/dashboard.ts b/packages/coding-agent/src/autoresearch/dashboard.ts index 2f46cb268..b9544dace 100644 --- a/packages/coding-agent/src/autoresearch/dashboard.ts +++ b/packages/coding-agent/src/autoresearch/dashboard.ts @@ -1,6 +1,5 @@ -import { matchesKey, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; +import { matchesKey, replaceTabs, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; import type { Theme } from "../modes/theme/theme"; -import { replaceTabs } from "../tools/render-utils"; import { formatElapsed, formatNum, isBetter } from "./helpers"; import { currentResults, findBaselineMetric, findBaselineRunNumber, findBaselineSecondary } from "./state"; import type { AutoresearchRuntime, DashboardController, ExperimentResult, ExperimentState } from "./types"; diff --git a/packages/coding-agent/src/cli/grep-cli.ts b/packages/coding-agent/src/cli/grep-cli.ts index dc1d2e02e..5ee702247 100644 --- a/packages/coding-agent/src/cli/grep-cli.ts +++ b/packages/coding-agent/src/cli/grep-cli.ts @@ -4,7 +4,7 @@ * Handles `omp grep` subcommand for testing grep tool on Windows. */ import * as path from "node:path"; -import { grep } from "@oh-my-pi/pi-natives"; +import { GrepOutputMode, grep } from "@oh-my-pi/pi-natives"; import { APP_NAME } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; @@ -14,7 +14,7 @@ export interface GrepCommandArgs { glob?: string; limit: number; context: number; - mode: "content" | "filesWithMatches" | "count"; + mode: GrepOutputMode; gitignore: boolean; } @@ -32,7 +32,7 @@ export function parseGrepArgs(args: string[]): GrepCommandArgs | undefined { path: ".", limit: 20, context: 2, - mode: "content", + mode: GrepOutputMode.Content, gitignore: true, }; @@ -47,9 +47,9 @@ export function parseGrepArgs(args: string[]): GrepCommandArgs | undefined { } else if (arg === "--context" || arg === "-C") { result.context = parseInt(args[++i], 10); } else if (arg === "--files" || arg === "-f") { - result.mode = "filesWithMatches"; + result.mode = GrepOutputMode.FilesWithMatches; } else if (arg === "--count" || arg === "-c") { - result.mode = "count"; + result.mode = GrepOutputMode.Count; } else if (arg === "--no-gitignore") { result.gitignore = false; } else if (!arg.startsWith("-")) { @@ -89,7 +89,7 @@ export async function runGrepCommand(cmd: GrepCommandArgs): Promise { glob: cmd.glob, mode: cmd.mode, maxCount: cmd.limit, - context: cmd.mode === "content" ? cmd.context : undefined, + context: cmd.mode === GrepOutputMode.Content ? cmd.context : undefined, hidden: true, gitignore: cmd.gitignore, }); @@ -105,7 +105,7 @@ export async function runGrepCommand(cmd: GrepCommandArgs): Promise { for (const match of result.matches) { const displayPath = match.path.replace(/\\/g, "/"); - if (cmd.mode === "content") { + if (cmd.mode === GrepOutputMode.Content) { if (match.contextBefore) { for (const ctx of match.contextBefore) { console.log(chalk.dim(`${displayPath}-${ctx.lineNumber}- ${ctx.line}`)); @@ -118,7 +118,7 @@ export async function runGrepCommand(cmd: GrepCommandArgs): Promise { } } console.log(""); - } else if (cmd.mode === "count") { + } else if (cmd.mode === GrepOutputMode.Count) { console.log(`${chalk.cyan(displayPath)}: ${match.matchCount ?? 0} matches`); } else { console.log(chalk.cyan(displayPath)); diff --git a/packages/coding-agent/src/cli/read-cli.ts b/packages/coding-agent/src/cli/read-cli.ts index 7047eb356..1905243a7 100644 --- a/packages/coding-agent/src/cli/read-cli.ts +++ b/packages/coding-agent/src/cli/read-cli.ts @@ -6,7 +6,7 @@ import * as path from "node:path"; import chalk from "chalk"; import { getLanguageFromPath } from "../modes/theme/theme"; -import { formatChunkedRead } from "../tools/chunk-tree"; +import { formatChunkedRead, resolveAnchorStyle } from "../tools/chunk-tree"; export interface ReadCommandArgs { path: string; @@ -27,7 +27,13 @@ export async function runReadCommand(cmd: ReadCommandArgs): Promise { const cwd = process.cwd(); try { - const result = await formatChunkedRead({ filePath, readPath, cwd, language }); + const result = await formatChunkedRead({ + filePath, + readPath, + cwd, + language, + anchorStyle: resolveAnchorStyle(), + }); console.log(result.text); } catch (err) { console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`)); diff --git a/packages/coding-agent/src/commands/grep.ts b/packages/coding-agent/src/commands/grep.ts index 6a0bfb911..8a63e954a 100644 --- a/packages/coding-agent/src/commands/grep.ts +++ b/packages/coding-agent/src/commands/grep.ts @@ -1,6 +1,7 @@ /** * Test grep tool. */ +import { GrepOutputMode } from "@oh-my-pi/pi-natives"; import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { type GrepCommandArgs, runGrepCommand } from "../cli/grep-cli"; import { initTheme } from "../modes/theme/theme"; @@ -25,7 +26,11 @@ export default class Grep extends Command { async run(): Promise { const { args, flags } = await this.parse(Grep); - const mode: GrepCommandArgs["mode"] = flags.count ? "count" : flags.files ? "filesWithMatches" : "content"; + const mode: GrepCommandArgs["mode"] = flags.count + ? GrepOutputMode.Count + : flags.files + ? GrepOutputMode.FilesWithMatches + : GrepOutputMode.Content; const cmd: GrepCommandArgs = { pattern: args.pattern ?? "", diff --git a/packages/coding-agent/src/config/prompt-templates.ts b/packages/coding-agent/src/config/prompt-templates.ts index 67a440ce3..f6fa9da50 100644 --- a/packages/coding-agent/src/config/prompt-templates.ts +++ b/packages/coding-agent/src/config/prompt-templates.ts @@ -1,5 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { type ChunkAnchorStyle, formatAnchor } from "@oh-my-pi/pi-natives"; import { getProjectDir, getProjectPromptsDir, getPromptsDir, logger } from "@oh-my-pi/pi-utils"; import Handlebars from "handlebars"; import { computeLineHash } from "../patch/hashline"; @@ -275,6 +276,35 @@ handlebars.registerHelper("hline", (lineNum: unknown, content: unknown): string return `${ref}:${text}`; }); +/** + * {{anchor name checksum}} — render a branch anchor tag using the current anchor style. + * Style is resolved from the template context (`anchorStyle`) or defaults to "full". + */ +handlebars.registerHelper("anchor", function (this: TemplateContext, name: string, checksum: string): string { + const style = (this.anchorStyle as ChunkAnchorStyle) ?? "full"; + return formatAnchor(name, checksum, style); +}); + +/** + * {{sel "parent_Name.child_Name"}} — render a chunk path for `sel` fields in examples. + * In `full` style the path is returned as-is (`class_Server.fn_start`). + * In `kind` style each segment is trimmed to its kind prefix (`class.fn`). + * In `bare` style the path is omitted (the model uses only `crc` to identify chunks). + */ +handlebars.registerHelper("sel", function (this: TemplateContext, chunkPath: string): string { + const style = (this.anchorStyle as ChunkAnchorStyle) ?? "full"; + if (style === "full") return chunkPath; + if (style === "bare") return ""; + // kind: trim each segment to its kind prefix (before the first `_`) + return chunkPath + .split(".") + .map(seg => { + const idx = seg.indexOf("_"); + return idx === -1 ? seg : seg.slice(0, idx); + }) + .join("."); +}); + const INLINE_ARG_SHELL_PATTERN = /\$(?:ARGUMENTS|@(?:\[\d+(?::\d*)?\])?|\d+)/; const INLINE_ARG_TEMPLATE_PATTERN = /\{\{[\s\S]*?(?:\b(?:arguments|ARGUMENTS|args)\b|\barg\s+[^}]+)[\s\S]*?\}\}/; diff --git a/packages/coding-agent/src/config/resolve-config-value.ts b/packages/coding-agent/src/config/resolve-config-value.ts index 39d758333..242f06ddc 100644 --- a/packages/coding-agent/src/config/resolve-config-value.ts +++ b/packages/coding-agent/src/config/resolve-config-value.ts @@ -55,8 +55,10 @@ async function executeCommand(commandConfig: string): Promise { try { let output = ""; - const result = await executeShell({ command, timeoutMs }, chunk => { - output += chunk; + const result = await executeShell({ command, timeoutMs }, (err, chunk) => { + if (!err) { + output += chunk; + } }); if (result.timedOut || result.exitCode !== 0) { return undefined; diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 77c2a9251..bfafceb56 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -13,15 +13,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { - getAgentDbPath, - getAgentDir, - getProjectDir, - isEnoent, - logger, - procmgr, - setDefaultTabWidth, -} from "@oh-my-pi/pi-utils"; +import { setDefaultTabWidth } from "@oh-my-pi/pi-natives"; +import { getAgentDbPath, getAgentDir, getProjectDir, isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings"; import type { ModelRole } from "../config/model-registry"; diff --git a/packages/coding-agent/src/debug/log-viewer.ts b/packages/coding-agent/src/debug/log-viewer.ts index a257e6d9c..421fee972 100644 --- a/packages/coding-agent/src/debug/log-viewer.ts +++ b/packages/coding-agent/src/debug/log-viewer.ts @@ -1,14 +1,15 @@ -import { copyToClipboard, sanitizeText } from "@oh-my-pi/pi-natives"; +import { sanitizeText } from "@oh-my-pi/pi-natives"; import { type Component, extractPrintableText, matchesKey, padding, + replaceTabs, truncateToWidth, visibleWidth, } from "@oh-my-pi/pi-tui"; import { theme } from "../modes/theme/theme"; -import { replaceTabs } from "../tools/render-utils"; +import { copyToClipboard } from "../utils/clipboard"; import { formatDebugLogExpandedLines, formatDebugLogLine, diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index 021127995..c53b83d88 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -155,8 +155,10 @@ export async function executeBash(command: string, options?: BashExecutorOptions timeoutMs: options?.timeout, signal: runAbortController.signal, }, - chunk => { - enqueueChunk(chunk); + (err, chunk) => { + if (!err) { + enqueueChunk(chunk); + } }, ); diff --git a/packages/coding-agent/src/modes/components/diff.ts b/packages/coding-agent/src/modes/components/diff.ts index 4c3e1b6ab..badfe3fd6 100644 --- a/packages/coding-agent/src/modes/components/diff.ts +++ b/packages/coding-agent/src/modes/components/diff.ts @@ -1,4 +1,4 @@ -import { getIndentation } from "@oh-my-pi/pi-utils"; +import { getIndentation } from "@oh-my-pi/pi-natives"; import * as Diff from "diff"; import { theme } from "../../modes/theme/theme"; import { replaceTabs } from "../../tools/render-utils"; @@ -15,11 +15,10 @@ const DIM_OFF = "\x1b[22m"; */ function visualizeIndent(text: string, filePath?: string): string { const match = text.match(/^([ \t]+)/); - if (!match) return replaceTabs(text, filePath); + if (!match) return replaceTabs(text); const indent = match[1]; const rest = text.slice(indent.length); - const indentation = getIndentation(filePath); - const tabWidth = indentation.length; + const tabWidth = getIndentation(filePath); const leftPadding = Math.floor(tabWidth / 2); const rightPadding = Math.max(0, tabWidth - leftPadding - 1); const tabMarker = `${DIM}${" ".repeat(leftPadding)}→${" ".repeat(rightPadding)}${DIM_OFF}`; @@ -31,7 +30,7 @@ function visualizeIndent(text: string, filePath?: string): string { visible += `${DIM}·${DIM_OFF}`; } } - return `${visible}${replaceTabs(rest, filePath)}`; + return `${visible}${replaceTabs(rest)}`; } /** @@ -154,8 +153,8 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s const added = addedLines[0]; const { removedLine, addedLine } = renderIntraLineDiff( - replaceTabs(removed.content, options.filePath), - replaceTabs(added.content, options.filePath), + replaceTabs(removed.content), + replaceTabs(added.content), ); result.push( diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index b60a5ca37..fe76e13a4 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -9,7 +9,6 @@ import { type UsageLimit, type UsageReport, } from "@oh-my-pi/pi-ai"; -import { copyToClipboard } from "@oh-my-pi/pi-natives"; import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui"; import { formatDuration, Snowflake, setProjectDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; @@ -33,6 +32,7 @@ import { outputMeta } from "../../tools/output-meta"; import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils"; import { replaceTabs } from "../../tools/render-utils"; import { getChangelogPath, parseChangelog } from "../../utils/changelog"; +import { copyToClipboard } from "../../utils/clipboard"; import { openPath } from "../../utils/open"; import { setSessionTerminalTitle } from "../../utils/title-generator"; diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 1ddae2c4f..aa83ad7ed 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs/promises"; import { type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { copyToClipboard, readImageFromClipboard, sanitizeText } from "@oh-my-pi/pi-natives"; +import { sanitizeText } from "@oh-my-pi/pi-natives"; import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui"; import { $env } from "@oh-my-pi/pi-utils"; import { settings } from "../../config/settings"; @@ -10,6 +10,7 @@ import type { InteractiveModeContext } from "../../modes/types"; import type { AgentSessionEvent } from "../../session/agent-session"; import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages"; import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry"; +import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard"; import { getEditorCommand, openInEditor } from "../../utils/external-editor"; import { ensureSupportedImageInput } from "../../utils/image-input"; import { resizeImage } from "../../utils/image-resize"; diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index efd328cb9..24ca5f620 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -4,10 +4,10 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Effort } from "@oh-my-pi/pi-ai"; import { detectMacOSAppearance, + MacAppearanceObserver, type HighlightColors as NativeHighlightColors, highlightCode as nativeHighlightCode, supportsLanguage as nativeSupportsLanguage, - startMacAppearanceObserver as startNativeMacObserver, } from "@oh-my-pi/pi-natives"; import type { EditorTheme, MarkdownTheme, SelectListTheme, SymbolTheme } from "@oh-my-pi/pi-tui"; import { adjustHsv, getCustomThemesDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; @@ -2057,10 +2057,12 @@ function startMacAppearanceObserver(): void { stopMacAppearanceObserver(); if (!shouldUseMacOSAppearanceFallback()) return; try { - macOSReportedAppearance = detectMacOSAppearance(); - macObserver = startNativeMacObserver(appearance => { - macOSReportedAppearance = appearance; - reevaluateAutoTheme("macOS fallback"); + macOSReportedAppearance = detectMacOSAppearance() ?? undefined; + macObserver = MacAppearanceObserver.start((err, appearance) => { + if (!err && (appearance === "dark" || appearance === "light")) { + macOSReportedAppearance = appearance; + reevaluateAutoTheme("macOS fallback"); + } }); } catch (err) { logger.warn("Failed to start macOS appearance observer", { err }); diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index 634e2d8ba..3d0f9bac5 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -13,7 +13,6 @@ import * as fs from "node:fs/promises"; import * as nodePath from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { StringEnum } from "@oh-my-pi/pi-ai"; -import { parseChunkTree, resolveChunkPath } from "@oh-my-pi/pi-natives"; import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; import { @@ -30,7 +29,13 @@ import patchDescription from "../prompts/tools/patch.md" with { type: "text" }; import replaceDescription from "../prompts/tools/replace.md" with { type: "text" }; import type { ToolSession } from "../tools"; import { checkAutoGeneratedFile, checkAutoGeneratedFileContent } from "../tools/auto-generated-guard"; -import { applyChunkEdits, type ChunkEditOperation, parseChunkReadPath, resolveAnchorStyle } from "../tools/chunk-tree"; +import { + applyChunkEdits, + type ChunkEditOperation, + getChunkInfoForFile, + parseChunkReadPath, + resolveAnchorStyle, +} from "../tools/chunk-tree"; import { invalidateFsScanAfterDelete, invalidateFsScanAfterRename, @@ -463,7 +468,9 @@ export class EditTool implements AgentTool { get description(): string { switch (this.mode) { case "chunk": - return renderPromptTemplate(chunkEditDescription); + return renderPromptTemplate(chunkEditDescription, { + anchorStyle: resolveAnchorStyle(this.session.settings), + }); case "patch": return renderPromptTemplate(patchDescription); case "hashline": @@ -528,20 +535,19 @@ export class EditTool implements AgentTool { } const defaultSelector = parsedChunkPath.selector; - // Parse the chunk tree so we can include actual checksums in error messages. - const chunkTree = sourceExists - ? parseChunkTree(normalizeToLF(stripBom(rawContent).text), getLanguageFromPath(resolvedPath) ?? "") - : undefined; + const chunkLanguage = getLanguageFromPath(resolvedPath); const normalizedOperations: ChunkEditOperation[] = []; - const assertChecksum = (op: string, crc: string | undefined, sel: string | undefined): string | undefined => { + const assertChecksum = async ( + op: string, + crc: string | undefined, + sel: string | undefined, + ): Promise => { if (crc) return crc.toUpperCase(); - // When no explicit sel is given, the default selector/crc from - // the path will be applied downstream by applyChunkEdits. if (!sel && defaultCrc) return undefined; - if (sel && chunkTree) { - const resolved = resolveChunkPath(chunkTree, sel); + if (sel && sourceExists) { + const resolved = await getChunkInfoForFile(resolvedPath, chunkLanguage, sel); if (resolved) { throw new Error( `Checksum required for ${op} on "${sel}". ` + @@ -564,7 +570,7 @@ export class EditTool implements AgentTool { case "prepend_sibling": { const { content, sel } = details as unknown as Static; if (!content) { - throw new Error(`Content required for ${op} on ${sel ?? ""}`); + throw new Error(`Content required for ${op} on ${sel ?? "root"}`); } normalizedOperations.push({ op, sel, content: flattenContent(content) }); break; @@ -574,7 +580,7 @@ export class EditTool implements AgentTool { normalizedOperations.push({ op: "replace", sel, - crc: assertChecksum(op, crc, sel), + crc: await assertChecksum(op, crc, sel), content: flattenContent(content), line, endLine: end_line, @@ -583,7 +589,7 @@ export class EditTool implements AgentTool { } case "delete": { const { sel, crc } = details as unknown as Static; - normalizedOperations.push({ op: "delete", sel, crc: assertChecksum(op, crc, sel) }); + normalizedOperations.push({ op: "delete", sel, crc: await assertChecksum(op, crc, sel) }); break; } } @@ -592,7 +598,7 @@ export class EditTool implements AgentTool { const chunkResult = applyChunkEdits({ source: rawContent, - language: getLanguageFromPath(resolvedPath), + language: chunkLanguage, cwd: this.session.cwd, filePath: resolvedPath, operations: normalizedOperations, diff --git a/packages/coding-agent/src/prompts/tools/chunk-edit.md b/packages/coding-agent/src/prompts/tools/chunk-edit.md index fc7edec55..7885e85fa 100644 --- a/packages/coding-agent/src/prompts/tools/chunk-edit.md +++ b/packages/coding-agent/src/prompts/tools/chunk-edit.md @@ -23,8 +23,8 @@ Successful edit responses include the updated chunk tree with checksums. Do not |`prepend_sibling`|`{ "prepend_sibling": { "sel": "…", "content": "…" } }`|insert before chunk| - `line`/`end_line` are **absolute file line numbers** from `read` gutter. `line` alone = single line. `line` + `end_line` = inclusive range. `line` with `end_line` = `line`-1 = zero-width insert. - `path="file.ts:chunk_path"` sets default `sel`; top-level `crc` sets default checksum -- **Auto-indent:** Tool adds base indent to non-empty lines. Put deeper indentation in `content` yourself. -- Chunk paths are fully qualified: `class_Server.fn_start`, not bare `fn_start` +- Content must be with project-specific indentation followed, as if it was going to be inserted as an indented block. +- Chunk paths are fully qualified: `{{sel "class_Server.fn_start"}}`, not bare `fn_start` - Batch ops observe earlier edits. If op 1 changes checksum/span/path, op 2 must use post-op-1 values. - `replace`/`delete` include leading comments/attributes attached to the chunk. @@ -34,16 +34,16 @@ All examples reference this `read` output: ``` | server.ts·40L·ts·#VSKB 5| class Server { - | [:class_Server#XKQZ] + | {{anchor "class_Server" "XKQZ"}} 12| start(): void { - | [.fn_start#HTST] + | {{anchor "fn_start" "HTST"}} 13| log("booting on " + this.port); 14| for (let i = 0; i < MAX_RETRIES; i++) { 15| this.tryBind(); 16| } 17| } 19| private tryBind(): boolean { - | [.fn_tryBind#VNWR] + | {{anchor "fn_tryBind" "VNWR"}} 20| // TODO: add backoff 21| return bind(this.port); 22| } @@ -55,7 +55,7 @@ All examples reference this `read` output: "operations": [ { "replace": { - "sel": "class_Server.fn_start", + "sel": "{{sel "class_Server.fn_start"}}", "crc": "HTST", "content": "start(): void {\n log(\"starting\");\n this.tryBind();\n}" } @@ -70,7 +70,7 @@ All examples reference this `read` output: "operations": [ { "replace": { - "sel": "class_Server.fn_start", + "sel": "{{sel "class_Server.fn_start"}}", "crc": "HTST", "line": 13, "content": " warn(\"booting on \" + this.port);" @@ -86,7 +86,7 @@ All examples reference this `read` output: "operations": [ { "delete": { - "sel": "class_Server.fn_tryBind", + "sel": "{{sel "class_Server.fn_tryBind"}}", "crc": "VNWR" } } diff --git a/packages/coding-agent/src/prompts/tools/grep-chunk.md b/packages/coding-agent/src/prompts/tools/grep-chunk.md deleted file mode 100644 index a727f5d7e..000000000 --- a/packages/coding-agent/src/prompts/tools/grep-chunk.md +++ /dev/null @@ -1,14 +0,0 @@ -Searches files using regex and annotates each match with its containing chunk path. - - -- Output lines look like `src/server.ts:class_Server.fn_handleError ln:220 | private handleError(…)`. -- Use the reported chunk path with `read(path="src/server.ts", sel="class_Server.fn_handleError")` for full context. -- Regex syntax, globs, file types, and `.gitignore` handling are the same as normal grep mode. -- Output format: `path:chunk_path ln:N | content` — the chunk path uniquely identifies a location within the file and can be used directly in subsequent read calls. -- Chunk paths work across JS/TS/TSX, Python, Rust, and Go files. - - - -- You **MUST** use `grep` instead of shelling out to `rg` or `grep`. -- After grep finds a relevant hit, follow up with `read` on the chunk path instead of widening grep repeatedly. - diff --git a/packages/coding-agent/src/prompts/tools/grep.md b/packages/coding-agent/src/prompts/tools/grep.md index 59ec5a965..810a9cf15 100644 --- a/packages/coding-agent/src/prompts/tools/grep.md +++ b/packages/coding-agent/src/prompts/tools/grep.md @@ -17,6 +17,9 @@ Searches files using powerful regex matching. - Text output is line-number-prefixed {{/if}} {{/if}} +{{#if IS_CHUNK_MODE}} +- Text output is chunk-path-prefixed: `path:selector>LINE|content` +{{/if}} diff --git a/packages/coding-agent/src/prompts/tools/read-chunk.md b/packages/coding-agent/src/prompts/tools/read-chunk.md index 22a34c48a..a77509c2c 100644 --- a/packages/coding-agent/src/prompts/tools/read-chunk.md +++ b/packages/coding-agent/src/prompts/tools/read-chunk.md @@ -16,7 +16,7 @@ Reads files using syntax-aware chunks. |`L50` or `L50-L120`|Absolute file line range| |`raw`|Read full raw file content (no chunk rendering)| -Each anchor line shows `[:name#CCCC]` or `[.name#CCCC]` — `#CCCC` is the edit checksum. Copy it when editing with `chunk-edit`. +Each anchor line shows `[name#CCCC]` — `#CCCC` is the edit checksum. Copy it when editing with `chunk-edit`. If `path:chunk` and `sel` are both provided, `sel` wins. Missing chunk paths return `[Chunk not found]`. @@ -30,13 +30,13 @@ Code rows use **absolute file line numbers** in the gutter. `chunk-edit` `line`/ | src/math.ts·120L·ts·#A744 | 5| export function sum(values: readonly number[]): number { - | [:sum#3286] + | {{anchor "fn_sum" "3286"}} 6| return values.reduce((total, value) => total + value, 0); 7| } 10| export class Calculator { - | [:Calculator#5D36] + | {{anchor "class_Calculator" "5D36"}} 11| multiply(left: number, right: number): number { - | [.multiply#B592] + | {{anchor "fn_multiply" "B592"}} 12| return left * right; 13| } 14| } diff --git a/packages/coding-agent/src/tools/chunk-tree.ts b/packages/coding-agent/src/tools/chunk-tree.ts index 3a988b33d..2ae4ce669 100644 --- a/packages/coding-agent/src/tools/chunk-tree.ts +++ b/packages/coding-agent/src/tools/chunk-tree.ts @@ -1,96 +1,45 @@ import * as path from "node:path"; import { - type ChunkAnchorStyle, - type ChunkNode, - type ChunkTree, - lineToContainingChunkPath, - parseChunkTree, - renderChunkTree, - resolveChunkPath, + ChunkAnchorStyle, + ChunkEditOp, + type ChunkInfo, + ChunkReadStatus, + type ChunkReadTarget, + ChunkState, + type EditOperation as NativeEditOperation, } from "@oh-my-pi/pi-natives"; -import { $env } from "@oh-my-pi/pi-utils"; +import { LRUCache } from "lru-cache"; import type { Settings } from "../config/settings"; -import { generateUnifiedDiffString } from "../patch/diff"; import { HASHLINE_NIBBLE_ALPHABET } from "../patch/hashline"; import { normalizeToLF, stripBom } from "../patch/normalize"; -import { stripNewLinePrefixes } from "../patch/prefix-stripping"; -export function resolveAnchorStyle(settings: Settings) { - switch (Bun.env.PI_ANCHOR_STYLE) { - case "full": - return "full"; - case "kind": - return "kind"; - case "bare": - return "bare"; - } - return settings.get("read.anchorstyle"); +export type { ChunkReadTarget }; + +const validAnchorStyles: Record = { + full: ChunkAnchorStyle.Full, + kind: ChunkAnchorStyle.Kind, + bare: ChunkAnchorStyle.Bare, +}; + +export function resolveAnchorStyle(settings?: Settings): ChunkAnchorStyle { + const envStyle = Bun.env.PI_ANCHOR_STYLE; + return ( + (envStyle && validAnchorStyles[envStyle]) || + (settings?.get("read.anchorstyle") as ChunkAnchorStyle | undefined) || + ChunkAnchorStyle.Full + ); } const readEnvInt = (name: string, defaultValue: number): number => { const v = Bun.env[name]; if (!v) return defaultValue; const n = Number.parseInt(v, 10); - if (Number.isNaN(n)) return defaultValue; - if (n <= 0) return defaultValue; + if (Number.isNaN(n) || n <= 0) return defaultValue; return n; }; const CACHE_MAX_ENTRIES = readEnvInt("PI_CHUNK_CACHE_MAX_ENTRIES", 200); const CHECKSUM_SUFFIX_RE = new RegExp(`^(.*?)(?:\\s+)?#([${HASHLINE_NIBBLE_ALPHABET}]{4})$`, "i"); -const CONTAINER_NAME_PREFIXES = ["class_", "iface_", "enum_", "impl_", "trait_", "mod_", "type_"] as const; -const CHUNK_NAME_PREFIXES = ["fn_", "var_", "class_", "stmts_", "type_", "interface_", "enum_", "const_"] as const; -const LINE_RANGE_SELECTOR_RE = /^L(\d+)(?:-L?(\d+))?$/i; - -type VisibleLineRange = { - startLine: number; - endLine: number; -}; - -// --------------------------------------------------------------------------- -// Cache -// --------------------------------------------------------------------------- - -type ChunkCacheEntry = { - mtimeMs: number; - size: number; - source: string; - tree: ChunkTree; -}; - -const chunkTreeCache = new Map(); - -/** Evict a single path from the chunk tree cache. */ -export function invalidateChunkTreeCache(filePath: string): void { - chunkTreeCache.delete(filePath); -} - -function cachePut(key: string, entry: ChunkCacheEntry): void { - // LRU: delete first so re-insert moves to end - chunkTreeCache.delete(key); - if (chunkTreeCache.size >= CACHE_MAX_ENTRIES) { - // Evict oldest (first key in insertion-ordered Map) - const oldest = chunkTreeCache.keys().next().value; - if (oldest !== undefined) chunkTreeCache.delete(oldest); - } - chunkTreeCache.set(key, entry); -} - -function cacheGet(key: string): ChunkCacheEntry | undefined { - const entry = chunkTreeCache.get(key); - if (entry) { - // Move to end for LRU - chunkTreeCache.delete(key); - chunkTreeCache.set(key, entry); - } - return entry; -} - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -type InsertPosition = "before" | "after" | "first_child" | "last_child"; export type ChunkEditOperation = | { op: "append_child"; sel?: string; crc?: string; content: string } @@ -110,68 +59,46 @@ export type ChunkEditResult = { warnings: string[]; }; -export type ChunkReadTarget = - | { - status: "ok"; - selector: string; - } - | { - status: "not_found"; - selector: string; - }; - -type ParsedChunkReadPath = { +export type ParsedChunkReadPath = { filePath: string; selector?: string; }; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +type ChunkCacheEntry = { + mtimeMs: number; + size: number; + source: string; + state: ChunkState; +}; -/** - * `PI_CHUNI_VALIDATE` — when `0`, chunk edit tool skips the validation of the edited - */ -export function chunkValidateEnabled(): boolean { - const v = $env.PI_CHUNK_VALIDATE; - return v === undefined || v === "" || v === "1"; +const chunkStateCache = new LRUCache({ + max: CACHE_MAX_ENTRIES, +}); + +export function invalidateChunkTreeCache(filePath: string): void { + chunkStateCache.delete(filePath); +} + +function normalizeLanguage(language: string | undefined): string { + return language?.trim().toLowerCase() || ""; } function normalizeChunkSource(text: string): string { return normalizeToLF(stripBom(text).text); } -function lineOffsets(text: string): number[] { - const offsets = [0]; - for (let index = 0; index < text.length; index++) { - if (text[index] === "\n") { - offsets.push(index + 1); - } - } - return offsets; -} - -function lineStartOffset(offsets: number[], line: number, text: string): number { - if (line <= 1) return 0; - return offsets[line - 1] ?? text.length; -} - -function lineEndOffset(offsets: number[], line: number, text: string): number { - return offsets[line] ?? text.length; -} - -function chunkSlice(text: string, chunk: ChunkNode): string { - if (chunk.lineCount === 0) return ""; - const lines = text.split("\n"); - return lines.slice(chunk.startLine - 1, chunk.endLine).join("\n"); -} - function displayPathForFile(filePath: string, cwd: string): string { const relative = path.relative(cwd, filePath).replace(/\\/g, "/"); return relative && !relative.startsWith("..") ? relative : filePath.replace(/\\/g, "/"); } -/** Index of `:` separating file path from chunk selector, or -1. Skips Windows `C:\` / `C:/` drive prefix. */ +function fileLanguageTag(filePath: string, language?: string): string | undefined { + const normalizedLanguage = normalizeLanguage(language); + if (normalizedLanguage.length > 0) return normalizedLanguage; + const ext = path.extname(filePath).replace(/^\./, "").toLowerCase(); + return ext.length > 0 ? ext : undefined; +} + function chunkReadPathSeparatorIndex(readPath: string): number { if (/^[a-zA-Z]:[/\\]/.test(readPath)) { return readPath.indexOf(":", 2); @@ -179,18 +106,6 @@ function chunkReadPathSeparatorIndex(readPath: string): number { return readPath.indexOf(":"); } -export function parseChunkReadPath(readPath: string): ParsedChunkReadPath { - const colonIndex = chunkReadPathSeparatorIndex(readPath); - if (colonIndex === -1) { - return { filePath: readPath }; - } - const selector = parseChunkSelector(readPath.slice(colonIndex + 1) || undefined).selector; - return { - filePath: readPath.slice(0, colonIndex), - selector, - }; -} - export function parseChunkSelector(selector: string | undefined): { selector?: string } { if (!selector || selector.length === 0) { return {}; @@ -201,398 +116,33 @@ export function parseChunkSelector(selector: string | undefined): { selector?: s return normalizedSelector.length > 0 ? { selector: normalizedSelector } : { selector }; } -function parseVisibleLineRange(selector: string | undefined): VisibleLineRange | undefined { - if (!selector) return undefined; - const match = LINE_RANGE_SELECTOR_RE.exec(selector); - if (!match) return undefined; - const startLine = Math.max(1, Number.parseInt(match[1]!, 10)); - const parsedEnd = match[2] ? Number.parseInt(match[2], 10) : startLine; - return { startLine, endLine: Math.max(startLine, parsedEnd) }; -} - -function getChunkMap(tree: ChunkTree): Map { - return new Map(tree.chunks.map(chunk => [chunk.path, chunk])); -} - -function getChunk(tree: ChunkTree, chunkPath: string): ChunkNode | undefined { - return resolveChunkPath(tree, chunkPath) ?? undefined; -} - -// --------------------------------------------------------------------------- -// Indentation helpers -// --------------------------------------------------------------------------- - -/** - * Detect the minimum common indentation across non-empty lines. - * Returns { indent: string, count: number } where count is the number of - * leading whitespace characters. - */ -function detectCommonIndent(text: string): { prefix: string; count: number } { - let minCount = Infinity; - let detectedChar = ""; - for (const line of text.split("\n")) { - if (line.trim().length === 0) continue; - const match = /^([ \t]+)/.exec(line); - if (!match) return { prefix: "", count: 0 }; - const ws = match[1]!; - if (ws.length < minCount) { - minCount = ws.length; - detectedChar = ws[0]!; - } +export function parseChunkReadPath(readPath: string): ParsedChunkReadPath { + const colonIndex = chunkReadPathSeparatorIndex(readPath); + if (colonIndex === -1) { + return { filePath: readPath }; } - if (minCount === Infinity) return { prefix: "", count: 0 }; - return { prefix: detectedChar.repeat(minCount), count: minCount }; -} - -function escapeRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -/** - * Python textwrap.dedent margin algorithm: longest leading whitespace prefix - * common to every line that has non-whitespace (blank and whitespace-only lines - * are ignored for margin, matching Python). - */ -function dedentPythonStyle(text: string): string { - let margin: string | undefined; - for (const line of text.split("\n")) { - if (line.trim().length === 0) continue; - const indent = /^([ \t]*)/.exec(line)?.[1] ?? ""; - if (margin === undefined) { - margin = indent; - } else if (indent.startsWith(margin)) { - // pass — current line is deeper than margin - } else if (margin.startsWith(indent)) { - margin = indent; - } else { - let i = 0; - for (; i < margin.length && i < indent.length; i++) { - if (margin[i] !== indent[i]) break; - } - margin = margin.slice(0, i); - } - } - if (margin === undefined || margin.length === 0) return text; - return text.replace(new RegExp(`^${escapeRegex(margin)}`, "gm"), ""); -} - -/** Like Python textwrap.indent: add prefix to lines that are not whitespace-only. */ -function indentNonEmptyLines(text: string, prefix: string): string { - if (prefix.length === 0) return text; - return text - .split("\n") - .map(line => { - if (line.trim().length === 0) return line; - return prefix + line; - }) - .join("\n"); -} - -/** Normalize inserted chunk content: dedent common margin (Python textwrap), then apply target column. */ -function reindentInsertedBlock(content: string, targetIndent: string): string { - const lines = content.split("\n"); - if (lines.length === 0) { - return ""; - } - const nonEmptyRest = lines.slice(1).filter(line => line.trim().length > 0); - if (lines.length === 1 || nonEmptyRest.length === 0) { - return indentNonEmptyLines(dedentPythonStyle(content), targetIndent); - } - const firstLine = lines[0] ?? ""; - const firstIndent = /^([ \t]*)/.exec(firstLine)?.[1]?.length ?? 0; - let minRestIndent = Infinity; - for (const line of nonEmptyRest) { - minRestIndent = Math.min(minRestIndent, /^([ \t]*)/.exec(line)?.[1]?.length ?? 0); - } - if (minRestIndent > firstIndent) { - const tail = lines.slice(1).join("\n"); - const dedentedTail = dedentPythonStyle(tail); - return indentNonEmptyLines(`${firstLine}\n${dedentedTail}`, targetIndent); - } - return indentNonEmptyLines(dedentPythonStyle(content), targetIndent); -} - -function computeInsertIndent(state: MutableChunkState, anchor: ChunkNode, inside: boolean): string { - if (!inside || anchor.path === "") return ""; - - const chunkMap = getChunkMap(state.tree); - // Use the indent of the first child if available. - if (anchor.children.length > 0) { - const firstChild = chunkMap.get(anchor.children[0]!); - if (firstChild) { - return (firstChild.indentChar || anchor.indentChar || "\t").repeat(firstChild.indent); - } - } - - // Collapsed containers render inline but still keep their original body lines. - // Reuse the first non-empty interior line's indentation before falling back. - const bodyLines = chunkSlice(state.source, anchor).split("\n").slice(1, -1); - for (const line of bodyLines) { - if (line.trim().length === 0) continue; - const match = /^([ \t]+)/.exec(line); - if (match) { - return match[1]!; - } - break; - } - - // No children: anchor indent + one level. - const ch = anchor.indentChar || "\t"; - return ch.repeat(anchor.indent + 1); -} - -// --------------------------------------------------------------------------- -// Content prefix stripping -// --------------------------------------------------------------------------- - -/** Chunk gutter: code rows `… |` / `… |` (space optional); meta rows dropped when pasted. */ -const CHUNK_GUTTER_CODE_ROW_RE = /^[\t ]*(\d+)\s*[|]\s*(.*)$/; - -function stripContentPrefixes(content: string): string { - const lines = content.split("\n"); - let lineNumCount = 0; - let nonEmpty = 0; - for (const line of lines) { - if (line.trim().length === 0) continue; - nonEmpty++; - if (CHUNK_GUTTER_CODE_ROW_RE.test(line)) lineNumCount++; - } - - if (nonEmpty === 0) return content; - - const stripLine = (line: string): string => { - const m = CHUNK_GUTTER_CODE_ROW_RE.exec(line); - if (m) return m[2] ?? ""; - if (/^[\t ]*[|│]/.test(line)) return ""; - return line; + return { + filePath: readPath.slice(0, colonIndex), + selector: parseChunkSelector(readPath.slice(colonIndex + 1) || undefined).selector, }; - - const withoutLineNumbers = lineNumCount > nonEmpty * 0.6 ? lines.map(stripLine) : lines; - return stripNewLinePrefixes(withoutLineNumbers).join("\n"); } -function isContainerLikeChunk(chunk: ChunkNode): boolean { - return chunk.kind === "branch" || CONTAINER_NAME_PREFIXES.some(prefix => chunk.name.startsWith(prefix)); -} - -// --------------------------------------------------------------------------- -// Formatting (gutter: absolute file lines, meta rows, U+2502) -// --------------------------------------------------------------------------- - -function fileLanguageTag(filePath: string, language?: string): string | undefined { - const normalizedLanguage = normalizeLanguage(language); - if (normalizedLanguage.length > 0) return normalizedLanguage; - const ext = path.extname(filePath).replace(/^\./, "").toLowerCase(); - return ext.length > 0 ? ext : undefined; -} - -function normalizeLanguage(language: string | undefined): string { - return language?.trim().toLowerCase() || ""; -} - -export type EditAnchorStyle = ChunkAnchorStyle; - -const DEFAULT_ANCHOR_STYLE: EditAnchorStyle = "full"; -const UNIFIED_HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@$/; - -type UnifiedDiffHunk = { - header: string; - lines: string[]; - newStart: number; - newChangedLines: number[]; -}; - -function normalizeAnchorStyle(style: EditAnchorStyle | undefined): EditAnchorStyle { - return style ?? DEFAULT_ANCHOR_STYLE; -} - -function formatAnchorName(name: string, style: EditAnchorStyle, omitChecksum: boolean): string { - if (omitChecksum || style === "full") { - return name; - } - if (style === "bare") { - return ""; - } - const underscoreIndex = name.indexOf("_"); - return underscoreIndex === -1 ? name : name.slice(0, underscoreIndex + 1); -} - -function formatAnchorTag( - chunk: Pick, - depth: number, - style: EditAnchorStyle, - omitChecksum = false, -): string { - const prefix = depth === 0 ? ":" : "."; - const checksum = omitChecksum ? "" : `#${chunk.checksum}`; - return `[${prefix}${formatAnchorName(chunk.name, style, omitChecksum)}${checksum}]`; -} - -function formatRootAnchor(title: string, checksum: string): string { - return `${title}·#${checksum}`; -} - -function parseUnifiedDiffHunks(diff: string): UnifiedDiffHunk[] { - const lines = diff.split("\n"); - const hunks: UnifiedDiffHunk[] = []; - let current: UnifiedDiffHunk | undefined; - let newLine = 0; - - for (const line of lines) { - const match = line.match(UNIFIED_HUNK_HEADER_RE); - if (match) { - if (current) { - hunks.push(current); - } - newLine = Number.parseInt(match[3]!, 10); - current = { - header: line, - lines: [], - newStart: newLine, - newChangedLines: [], - }; - continue; - } - if (!current) { - continue; - } - current.lines.push(line); - if (line.startsWith("+")) { - current.newChangedLines.push(newLine); - newLine++; - continue; - } - if (line.startsWith(" ")) { - newLine++; - } - } - - if (current) { - hunks.push(current); - } - return hunks; -} - -function commonChunkPath(paths: string[]): string { - if (paths.length === 0) { - return ""; - } - const parts = paths.map(candidate => (candidate.length === 0 ? [] : candidate.split("."))); - const shared: string[] = []; - for (let index = 0; ; index++) { - const value = parts[0]?.[index]; - if (!value || parts.some(part => part[index] !== value)) { - return shared.join("."); - } - shared.push(value); - } -} - -function getChunkPathTrail(chunkPath: string): string[] { - if (chunkPath.length === 0) { - return []; - } - const parts = chunkPath.split("."); - return parts.map((_, index) => parts.slice(0, index + 1).join(".")); -} - -function fallbackHunkLines(tree: ChunkTree, newStart: number): number[] { - if (tree.lineCount === 0) { - return []; - } - return [...new Set([newStart, newStart - 1].map(line => Math.min(Math.max(line, 1), tree.lineCount)))]; -} - -function resolveHunkChunkPath(tree: ChunkTree, hunk: UnifiedDiffHunk): string { - const lines = hunk.newChangedLines.length > 0 ? hunk.newChangedLines : fallbackHunkLines(tree, hunk.newStart); - return commonChunkPath(lines.map(line => lineToContainingChunkPath(tree, line) ?? "")); -} - -function renderChangedHunks(params: { - tree: ChunkTree; - displayPath: string; - diff: string; - anchorStyle?: EditAnchorStyle; -}): string { - const root = getChunk(params.tree, ""); - if (!root) { - throw new Error("Chunk tree is missing the root chunk"); - } - const style = normalizeAnchorStyle(params.anchorStyle); - const hunks = parseUnifiedDiffHunks(params.diff); - if (hunks.length === 0) { - return formatRootAnchor(params.displayPath, root.checksum); - } - return hunks - .map(hunk => { - const trailPaths = getChunkPathTrail(resolveHunkChunkPath(params.tree, hunk)); - const blockLines = [formatRootAnchor(params.displayPath, root.checksum)]; - for (const [index, chunkPath] of trailPaths.entries()) { - const chunk = getChunk(params.tree, chunkPath); - if (!chunk) { - continue; - } - blockLines.push(`${" ".repeat(index + 1)}${formatAnchorTag(chunk, index, style)}`); - } - const diffIndent = " ".repeat(trailPaths.length + 1); - for (const line of [hunk.header, ...hunk.lines]) { - blockLines.push(`${diffIndent}${line}`); - } - return blockLines.join("\n"); - }) - .join("\n"); -} - -// --------------------------------------------------------------------------- -// Public read API -// --------------------------------------------------------------------------- - export function isChunkReadablePath(readPath: string): boolean { - const { selector } = parseChunkReadPath(readPath); - return selector !== undefined; + return parseChunkReadPath(readPath).selector !== undefined; } -const TLAPLUS_BEGIN_TRANSLATION_RE = /^\s*\\\*\s*BEGIN TRANSLATION\s*$/; -const TLAPLUS_END_TRANSLATION_RE = /^\s*\\\*\s*END TRANSLATION\s*$/; - -function maskChunkDisplaySource(source: string, language: string): string { - if (language !== "tlaplus") return source; - const lines = source.split("\n"); - const masked = [...lines]; - let index = 0; - while (index < lines.length) { - if (!TLAPLUS_BEGIN_TRANSLATION_RE.test(lines[index] ?? "")) { - index++; - continue; - } - const beginIndex = index; - let endIndex = beginIndex + 1; - while (endIndex < lines.length && !TLAPLUS_END_TRANSLATION_RE.test(lines[endIndex] ?? "")) { - endIndex++; - } - if (beginIndex + 1 < lines.length) { - masked[beginIndex + 1] = "\\* [translation hidden]"; - for (let hiddenIndex = beginIndex + 2; hiddenIndex < endIndex && hiddenIndex < lines.length; hiddenIndex++) { - masked[hiddenIndex] = ""; - } - } - index = endIndex + 1; - } - return masked.join("\n"); -} - -export async function loadChunkTreeForFile(filePath: string, language: string | undefined): Promise { +export async function loadChunkStateForFile(filePath: string, language: string | undefined): Promise { const file = Bun.file(filePath); const stat = await file.stat(); - const cached = cacheGet(filePath); + const cached = chunkStateCache.get(filePath); if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { return cached; } const source = normalizeChunkSource(await file.text()); - const tree = parseChunkTree(source, normalizeLanguage(language)); - const entry = { mtimeMs: stat.mtimeMs, size: stat.size, source, tree }; - cachePut(filePath, entry); + const state = ChunkState.parse(source, normalizeLanguage(language)); + const entry = { mtimeMs: stat.mtimeMs, size: stat.size, source, state }; + chunkStateCache.set(filePath, entry); return entry; } @@ -601,140 +151,28 @@ export async function formatChunkedRead(params: { readPath: string; cwd: string; language?: string; - /** Suppress #checksum suffix in headers (e.g. when the edit tool is unavailable). */ omitChecksum?: boolean; - anchorStyle?: EditAnchorStyle; - /** Absolute file line range (`read` `sel` with a chunk path). Clipped to the chunk span. */ + anchorStyle?: ChunkAnchorStyle; absoluteLineRange?: { startLine: number; endLine?: number }; }): Promise<{ text: string; resolvedPath?: string; chunk?: ChunkReadTarget }> { const { filePath, readPath, cwd, language, omitChecksum = false, anchorStyle, absoluteLineRange } = params; const normalizedLanguage = normalizeLanguage(language); - const { selector } = parseChunkReadPath(readPath); - const visibleRange = parseVisibleLineRange(selector); - const { source, tree } = await loadChunkTreeForFile(filePath, normalizedLanguage); - const displaySource = maskChunkDisplaySource(source, normalizedLanguage); + const { state } = await loadChunkStateForFile(filePath, normalizedLanguage); const displayPath = displayPathForFile(filePath, cwd); - const root = getChunk(tree, ""); - if (!root) { - return { text: `${displayPath}\n\n[Chunk tree root missing]`, resolvedPath: filePath }; - } - - if (visibleRange) { - if (visibleRange.startLine > tree.lineCount) { - const suggestion = - tree.lineCount === 0 - ? "The file is empty." - : `Use sel=L1 to read from the start, or sel=L${tree.lineCount} to read the last line.`; - return { - text: `Line ${visibleRange.startLine} is beyond end of file (${tree.lineCount} lines total). ${suggestion}`, - resolvedPath: filePath, - }; - } - const clampedRange = { - startLine: visibleRange.startLine, - endLine: Math.min(visibleRange.endLine, tree.lineCount), - }; - const notice = `[Notice: chunk view scoped to requested lines L${clampedRange.startLine}-L${clampedRange.endLine}; non-overlapping lines omitted.]`; - const text = renderChunkTree({ - tree, - chunkPath: root.path, - source: displaySource, - title: displayPath, - languageTag: fileLanguageTag(filePath, normalizedLanguage), - checksum: root.checksum, - visibleRange: clampedRange, - renderChildrenOnly: true, - omitChecksum, - anchorStyle, - showLeafPreview: true, - tabReplacement: " ", - }); - return { - text: `${notice}\n\n${text}`, - resolvedPath: filePath, - }; - } - - if (!selector) { - return { - text: renderChunkTree({ - tree, - chunkPath: root.path, - source: displaySource, - title: displayPath, - languageTag: fileLanguageTag(filePath, normalizedLanguage), - checksum: root.checksum, - renderChildrenOnly: true, - omitChecksum, - anchorStyle, - showLeafPreview: true, - tabReplacement: " ", - }), - resolvedPath: filePath, - }; - } - - const chunk = getChunk(tree, selector); - if (!chunk) { - return { - text: `${displayPath}:${selector}\n\n[Chunk not found]`, - resolvedPath: filePath, - chunk: { status: "not_found", selector }, - }; - } - - if (absoluteLineRange) { - const rs = absoluteLineRange.startLine; - const re = absoluteLineRange.endLine ?? rs; - const iLow = Math.max(chunk.startLine, Math.min(rs, re)); - const iHigh = Math.min(chunk.endLine, Math.max(rs, re)); - if (iLow > iHigh) { - const selHint = `L${chunk.startLine}-L${chunk.endLine}`; - const req = rs === re ? `L${rs}` : `L${rs}-L${re}`; - return { - text: `Requested lines ${req} do not overlap chunk "${chunk.path}" (file lines ${chunk.startLine}-${chunk.endLine}). Use sel=${selHint} to read this chunk.`, - resolvedPath: filePath, - chunk: { status: "ok", selector: chunk.path }, - }; - } - return { - text: renderChunkTree({ - tree, - chunkPath: chunk.path, - source: displaySource, - title: `${displayPath}:${chunk.path}`, - languageTag: fileLanguageTag(filePath, normalizedLanguage), - checksum: chunk.checksum, - visibleRange: { startLine: iLow, endLine: iHigh }, - renderChildrenOnly: false, - omitChecksum, - anchorStyle, - showLeafPreview: true, - tabReplacement: " ", - }), - resolvedPath: filePath, - chunk: { status: "ok", selector: chunk.path }, - }; - } - - return { - text: renderChunkTree({ - tree, - chunkPath: chunk.path, - source: displaySource, - title: `${displayPath}:${chunk.path}`, - languageTag: fileLanguageTag(filePath, normalizedLanguage), - checksum: chunk.checksum, - renderChildrenOnly: false, - omitChecksum, - anchorStyle, - showLeafPreview: true, - tabReplacement: " ", - }), - resolvedPath: filePath, - chunk: { status: "ok", selector: chunk.path }, - }; + const result = state.renderRead({ + readPath, + displayPath, + languageTag: fileLanguageTag(filePath, normalizedLanguage), + omitChecksum, + anchorStyle, + absoluteLineRange: absoluteLineRange + ? { startLine: absoluteLineRange.startLine, endLine: absoluteLineRange.endLine ?? absoluteLineRange.startLine } + : undefined, + tabReplacement: " ", + }); + return { text: result.text, resolvedPath: filePath, chunk: result.chunk }; } + export async function formatChunkedGrepLine(params: { filePath: string; lineNumber: number; @@ -743,684 +181,40 @@ export async function formatChunkedGrepLine(params: { language?: string; }): Promise { const { filePath, lineNumber, line, cwd, language } = params; - const { tree } = await loadChunkTreeForFile(filePath, language); - const displayPath = displayPathForFile(filePath, cwd); - const chunkPath = lineToContainingChunkPath(tree, lineNumber) ?? ""; - const location = chunkPath ? `${displayPath}:${chunkPath}` : displayPath; - return `${location} ln:${lineNumber} | ${line}`; + const { state } = await loadChunkStateForFile(filePath, language); + return state.formatGrepLine(displayPathForFile(filePath, cwd), lineNumber, line); } -// --------------------------------------------------------------------------- -// Edit engine -// --------------------------------------------------------------------------- - -type MutableChunkState = { - source: string; - tree: ChunkTree; - language: string; -}; - -/** Score how similar two chunk path strings are (higher = more similar). */ -function chunkPathSimilarity(query: string, candidate: string): number { - // Exact suffix match (e.g. query="fn_foo" matches "class_Bar.fn_foo") - if (candidate.endsWith(query) || candidate.endsWith(`.${query}`)) return 0.9; - // The candidate's leaf name matches the query's leaf name - const queryLeaf = query.split(".").pop() ?? query; - const candidateLeaf = candidate.split(".").pop() ?? candidate; - if (queryLeaf === candidateLeaf) return 0.85; - // Substring match - if (candidate.includes(query) || query.includes(candidate)) return 0.6; - // Shared segments - const queryParts = new Set(query.split(".")); - const candidateParts = candidate.split("."); - const overlap = candidateParts.filter(p => queryParts.has(p)).length; - if (overlap > 0) return 0.3 + 0.1 * overlap; - return 0; -} - -function suggestChunkPaths(tree: ChunkTree, query: string, limit = 3): string[] { - return tree.chunks - .filter(c => c.path.length > 0) - .map(c => ({ path: c.path, score: chunkPathSimilarity(query, c.path) })) - .filter(c => c.score > 0.1) - .sort((a, b) => b.score - a.score) - .slice(0, limit) - .map(c => c.path); -} - -function resolveAnchorChunk(state: MutableChunkState, selector: string | undefined, warnings?: string[]): ChunkNode { - const cleaned = sanitizeChunkSelector(selector); - if (!cleaned || cleaned.length === 0) { - const root = getChunk(state.tree, ""); - if (!root) { - throw new Error("Chunk tree is missing the root chunk"); - } - return root; - } - - const chunk = getChunk(state.tree, cleaned); - if (chunk) return chunk; - - // Auto-resolve suffix matches: "if_3" -> "default_export.if_3" - const suffixMatches = state.tree.chunks.filter( - c => c.path.length > 0 && (c.path.endsWith(`.${cleaned}`) || c.path === cleaned), - ); - if (suffixMatches.length === 1) { - warnings?.push( - `Auto-resolved chunk selector "${cleaned}" to "${suffixMatches[0]!.path}". Use the full path from read output.`, - ); - return suffixMatches[0]!; - } - if (suffixMatches.length > 1) { - throw new Error( - `Ambiguous chunk selector "${cleaned}" matches ${suffixMatches.length} chunks: ${suffixMatches.map(c => c.path).join(", ")}. Use the full path from read output.`, - ); - } - - // Auto-resolve with common chunk name prefixes: "start" -> "fn_start", "Server" -> "class_Server" - if (!cleaned.includes(".")) { - const allPrefixMatches: ChunkNode[] = []; - for (const prefix of CHUNK_NAME_PREFIXES) { - const prefixed = prefix + cleaned; - for (const c of state.tree.chunks) { - if (c.path.length > 0 && (c.path.endsWith(`.${prefixed}`) || c.path === prefixed)) { - allPrefixMatches.push(c); - } - } - } - if (allPrefixMatches.length === 1) { - warnings?.push( - `Auto-resolved chunk selector "${cleaned}" to "${allPrefixMatches[0]!.path}". Use the full path from read output.`, - ); - return allPrefixMatches[0]!; - } - if (allPrefixMatches.length > 1) { - throw new Error( - `Ambiguous chunk selector "${cleaned}" matches ${allPrefixMatches.length} chunks: ${allPrefixMatches.map(c => c.path).join(", ")}. Use the full path from read output.`, - ); +function toNativeEditOperation(operation: ChunkEditOperation): NativeEditOperation { + switch (operation.op) { + case "replace": + return { + op: ChunkEditOp.Replace, + sel: operation.sel, + crc: operation.crc, + content: operation.content, + line: operation.line, + endLine: operation.endLine, + }; + case "delete": + return { + op: ChunkEditOp.Delete, + sel: operation.sel, + crc: operation.crc, + }; + case "append_child": + return { op: ChunkEditOp.AppendChild, sel: operation.sel, crc: operation.crc, content: operation.content }; + case "prepend_child": + return { op: ChunkEditOp.PrependChild, sel: operation.sel, crc: operation.crc, content: operation.content }; + case "append_sibling": + return { op: ChunkEditOp.AppendSibling, sel: operation.sel, crc: operation.crc, content: operation.content }; + case "prepend_sibling": + return { op: ChunkEditOp.PrependSibling, sel: operation.sel, crc: operation.crc, content: operation.content }; + default: { + const exhaustive: never = operation; + return exhaustive; } } - - let directChildren: string[] | undefined; - let directChildrenParent: string | undefined; - let matchedEmptyPrefix: string | undefined; - if (cleaned.includes(".")) { - const parts = cleaned.split("."); - for (let index = parts.length - 1; index > 0; index--) { - const prefix = parts.slice(0, index).join("."); - const parent = getChunk(state.tree, prefix); - if (!parent) continue; - if (parent.children.length > 0) { - directChildrenParent = prefix; - directChildren = [...parent.children].sort((a, b) => a.localeCompare(b)); - break; - } - if (matchedEmptyPrefix === undefined) { - matchedEmptyPrefix = prefix; - } - } - } - - const availablePaths = state.tree.chunks.filter(c => c.path.length > 0 && !c.path.includes(".")).map(c => c.path); - const similarity = suggestChunkPaths(state.tree, cleaned, 8); - let hint: string; - if (directChildren !== undefined && directChildren.length > 0 && directChildrenParent !== undefined) { - hint = ` Direct children of "${directChildrenParent}": ${directChildren.join(", ")}.`; - } else if (matchedEmptyPrefix !== undefined) { - hint = - similarity.length > 0 - ? ` The prefix "${matchedEmptyPrefix}" exists but has no child chunks. Similar paths: ${similarity.join(", ")}.` - : ` The prefix "${matchedEmptyPrefix}" exists but has no child chunks.`; - } else if (similarity.length > 0) { - hint = ` Similar paths: ${similarity.join(", ")}.`; - } else if (availablePaths.length > 0) { - hint = ` Available top-level chunks: ${availablePaths.join(", ")}.`; - } else { - hint = " Re-read the file to see available chunk paths."; - } - throw new Error( - `Chunk path not found: "${cleaned}".${hint} Re-read the file to see the full chunk tree with paths and checksums.`, - ); -} - -/** - * Normalize a chunk selector from model output: - * - Strip `filename.ext:` prefix (model puts `path:chunk` format in `sel`) - * - Strip leading `:` (model artifact) - * - Strip trailing `#XXXX` hash suffix (model confuses checksum with path) - * - Convert literal `"null"` string to empty - */ -function sanitizeChunkSelector(selector: string | undefined): string | undefined { - if (selector === undefined || selector === null) return undefined; - let s = typeof selector === "string" ? selector : String(selector); - if (s === "null" || s === "undefined") return undefined; - // Strip filename prefix: "pirate.ts:expression_2" -> "expression_2" - // Models sometimes put the path:chunk format in the sel field instead of path. - const colonIdx = chunkReadPathSeparatorIndex(s); - if (colonIdx !== -1) s = s.slice(colonIdx + 1); - // Strip leading colon (":error" -> "error") - if (s.startsWith(":")) s = s.slice(1); - // Strip trailing #XXXX checksum suffix ("imports#YKXY" -> "imports") - s = s.replace(/#[A-Z]{4}$/i, ""); - return s.trim() || undefined; -} - -function resolveAnchorWithCrc( - state: MutableChunkState, - selector: string | undefined, - crc: string | undefined, - warnings?: string[], -): { chunk: ChunkNode; crc: string | undefined } { - const chunk = resolveAnchorChunk(state, selector, warnings); - return { chunk, crc: sanitizeCrc(crc) }; -} - -/** - * Normalize a CRC value from model output: - * - Convert literal `"null"` / `"undefined"` strings to undefined - * - Treat empty strings as undefined - * - Normalize to uppercase (models sometimes emit lowercase checksums) - */ -function sanitizeCrc(crc: string | undefined): string | undefined { - if (crc === undefined || crc === null) return undefined; - if (crc === "null" || crc === "undefined" || crc === "") return undefined; - return crc.toUpperCase(); -} - -function validateCrc(chunk: ChunkNode, crc: string | undefined): void { - const cleaned = sanitizeCrc(crc); - if (cleaned === undefined || cleaned.length === 0) { - throw new Error( - `Checksum required for ${operation_name(chunk)}. ` + - `Re-read the chunk to get the current checksum, then pass crc: "XXXX" in your edit operation. ` + - `Hint: use crc: "${chunk.checksum}" with sel: "${chunk.path}".`, - ); - } - if (chunk.checksum !== cleaned) { - throw new Error( - `Checksum mismatch for ${operation_name(chunk)}: expected "${chunk.checksum}", got "${cleaned}". ` + - `The chunk content has changed since you last read it. ` + - `Re-read the file to get updated checksums, then retry with the new crc value.`, - ); - } -} - -function operation_name(chunk: ChunkNode): string { - return chunk.path || ""; -} - -function validateBatchCrc(params: { chunk: ChunkNode; crc: string | undefined; required: boolean }): void { - const { chunk, crc, required } = params; - if (!required) return; - validateCrc(chunk, crc); -} - -/** - * Validate that line/endLine fall within the anchor chunk's span. - * Supports three modes: - * - line <= endLine: replace lines line–endLine (inclusive) - * - line = endLine + 1: zero-width insert between endLine and line - * - single-line (endLine === line): replace just that line - */ -function validateLineRange(anchor: ChunkNode, line: number, endLine: number): void { - const cStart = anchor.startLine; - const cEnd = anchor.endLine; - const chunkName = operation_name(anchor); - if (line < 1) { - throw new Error( - `Line ${line} is invalid for ${chunkName}; line and end_line are absolute file line numbers (1-indexed). This chunk spans file lines ${cStart}-${cEnd}.`, - ); - } - if (endLine < 0) { - throw new Error(`Invalid line range L${line}-L${endLine} for ${chunkName}: end_line cannot be negative.`); - } - if (line > endLine + 1) { - throw new Error( - `Invalid line range L${line}-L${endLine} for ${chunkName}: use line \u2264 end_line to replace lines, or line = end_line + 1 for zero-width insertion.`, - ); - } - if (line <= endLine) { - if (line < cStart || endLine > cEnd) { - throw new Error( - `Line range L${line}-L${endLine} is outside ${chunkName} (chunk spans file lines ${cStart}-${cEnd}). Use absolute line numbers from read output.`, - ); - } - return; - } - // line === endLine + 1: zero-width insertion - const beforeChunk = endLine === cStart - 1 && line === cStart; - const insideGap = cStart <= endLine && endLine < cEnd && line === endLine + 1; - const afterChunk = endLine === cEnd && line === cEnd + 1; - if (beforeChunk || insideGap || afterChunk) { - return; - } - throw new Error( - `Invalid zero-width insert L${line}-L${endLine} for ${chunkName} (chunk spans file lines ${cStart}-${cEnd}). ` + - `Use end_line = ${cStart - 1}, line = ${cStart} to insert before the first chunk line; ` + - `end_line = k, line = k + 1 with ${cStart} \u2264 k < ${cEnd} between interior lines; ` + - `end_line = ${cEnd}, line = ${cEnd + 1} to insert after the last chunk line.`, - ); -} - -function isZeroWidthInsert(line: number, endLine: number): boolean { - return line === endLine + 1; -} - -/** Sort key for zero-width inserts: higher endLine runs first (bottom-up) so line numbers stay stable. */ -function zeroWidthInsertSortKey(anchor: ChunkNode, endLine: number, line: number): number { - const cStart = anchor.startLine; - if (endLine === cStart - 1 && line === cStart) { - return cStart; - } - return endLine; -} - -type ScheduledChunkEditOperation = { - operation: ChunkEditOperation; - originalIndex: number; - requestedSelector: string | undefined; - initialChunk: ChunkNode | undefined; -}; - -function touchesChunkPath(touchedPaths: string[], selector: string): boolean { - return touchedPaths.some( - touched => touched === selector || touched.startsWith(`${selector}.`) || selector.startsWith(`${touched}.`), - ); -} - -function ensureBatchOperationTargetCurrent( - state: MutableChunkState, - scheduled: ScheduledChunkEditOperation, - crc: string | undefined, - touchedPaths: string[], -): void { - const selector = scheduled.requestedSelector; - const initialChunk = scheduled.initialChunk; - const cleanedCrc = sanitizeCrc(crc); - if (!selector || !initialChunk || !cleanedCrc || !touchesChunkPath(touchedPaths, selector)) { - return; - } - if (cleanedCrc !== initialChunk.checksum) { - return; - } - - const currentChunk = getChunk(state.tree, selector); - if (!currentChunk) { - throw new Error( - `Chunk path "${selector}" was changed by an earlier batch operation. ` + - `Re-read after the earlier edit and retry with the updated selector and checksum.`, - ); - } - if (currentChunk.checksum !== initialChunk.checksum) { - throw new Error( - `Chunk "${selector}" was changed by an earlier batch operation: checksum "${initialChunk.checksum}" is stale; ` + - `current checksum is "${currentChunk.checksum}" and the current file span is ${currentChunk.startLine}-${currentChunk.endLine}. ` + - `Later operations in the same batch must use the post-edit checksum and updated line span.`, - ); - } -} - -function lineColumnAtOffset(text: string, offset: number): { line: number; column: number } { - const offsets = lineOffsets(text); - let low = 0; - let high = offsets.length - 1; - while (low <= high) { - const mid = Math.floor((low + high) / 2); - const start = offsets[mid]!; - const next = offsets[mid + 1] ?? text.length + 1; - if (offset < start) { - high = mid - 1; - continue; - } - if (offset >= next) { - low = mid + 1; - continue; - } - return { line: mid + 1, column: offset - start + 1 }; - } - return { line: offsets.length, column: 1 }; -} - -function formatParseErrorSummaries(state: MutableChunkState): string[] { - return state.tree.chunks - .filter(chunk => chunk.error) - .slice(0, 3) - .map(chunk => { - const { line, column } = lineColumnAtOffset(state.source, chunk.startByte); - const snippet = chunk.signature?.trim(); - return snippet && snippet.length > 0 - ? `L${line}:C${column} unexpected syntax near ${JSON.stringify(snippet)}` - : `L${line}:C${column} unexpected syntax`; - }); -} - -function describeScheduledOperation(scheduled: ScheduledChunkEditOperation): string { - return `${scheduled.operation.op}${scheduled.requestedSelector ? ` on "${scheduled.requestedSelector}"` : ""}`; -} - -function normalizeInsertedContent(content: string, targetIndent: string): string { - let normalized = normalizeToLF(content); - normalized = stripContentPrefixes(normalized); - if (targetIndent.length > 0) { - normalized = reindentInsertedBlock(normalized, targetIndent); - } - return normalized; -} - -function replaceRangeByLines(text: string, startLine: number, endLine: number, replacement: string): string { - const offsets = lineOffsets(text); - const startOffset = lineStartOffset(offsets, startLine, text); - const endOffset = lineEndOffset(offsets, endLine, text); - return `${text.slice(0, startOffset)}${replacement}${text.slice(endOffset)}`; -} - -function insertAtOffset(text: string, offset: number, content: string): string { - return `${text.slice(0, offset)}${content}${text.slice(offset)}`; -} - -function rebuildChunkState(source: string, language: string): MutableChunkState { - return { source, tree: parseChunkTree(source, language), language }; -} - -function findContainerDelimiterOffset( - state: MutableChunkState, - anchor: ChunkNode, - placement: "prepend" | "append", -): number | undefined { - const slice = state.source.slice(anchor.startByte, anchor.endByte); - const openBrace = slice.indexOf("{"); - const closeBrace = slice.lastIndexOf("}"); - if (openBrace !== -1 && closeBrace !== -1 && closeBrace >= openBrace) { - return placement === "prepend" ? anchor.startByte + openBrace + 1 : anchor.startByte + closeBrace; - } - return undefined; -} - -function siblingIndex(state: MutableChunkState, anchor: ChunkNode): { index: number; total: number } | undefined { - const parent = getChunk(state.tree, anchor.parentPath ?? ""); - if (!parent) return undefined; - const index = parent.children.indexOf(anchor.path); - if (index === -1) return undefined; - return { index, total: parent.children.length }; -} - -function hasSiblingBefore(state: MutableChunkState, anchor: ChunkNode): boolean { - const position = siblingIndex(state, anchor); - return position !== undefined && position.index > 0; -} - -function hasSiblingAfter(state: MutableChunkState, anchor: ChunkNode): boolean { - const position = siblingIndex(state, anchor); - return position !== undefined && position.index < position.total - 1; -} - -function countTrailingNewlinesBeforeOffset(text: string, offset: number): number { - let count = 0; - for (let index = offset - 1; index >= 0 && text[index] === "\n"; index--) { - count++; - } - return count; -} - -function countLeadingNewlinesAfterOffset(text: string, offset: number): number { - let count = 0; - for (let index = offset; index < text.length && text[index] === "\n"; index++) { - count++; - } - return count; -} - -function containerHasInteriorContent(state: MutableChunkState, anchor: ChunkNode): boolean { - if (!isContainerLikeChunk(anchor)) return false; - return chunkSlice(state.source, anchor) - .split("\n") - .slice(1, -1) - .some(line => line.trim().length > 0); -} - -function computeInsertSpacing( - state: MutableChunkState, - anchor: ChunkNode, - pos: InsertPosition, -): { blankLineBefore: boolean; blankLineAfter: boolean } { - const hasInteriorContent = containerHasInteriorContent(state, anchor); - switch (pos) { - case "first_child": - return { blankLineBefore: false, blankLineAfter: anchor.children.length > 0 || hasInteriorContent }; - case "last_child": - return { blankLineBefore: anchor.children.length > 0 || hasInteriorContent, blankLineAfter: false }; - case "before": - return { blankLineBefore: hasSiblingBefore(state, anchor), blankLineAfter: true }; - case "after": - return { blankLineBefore: true, blankLineAfter: hasSiblingAfter(state, anchor) }; - } -} - -function normalizeInsertionBoundaryContent( - state: MutableChunkState, - offset: number, - content: string, - spacing: { blankLineBefore: boolean; blankLineAfter: boolean }, -): string { - const trimmed = content.replace(/^\n+/, "").replace(/\n+$/, ""); - if (trimmed.length === 0) return content; - - const prevChar = offset > 0 ? state.source[offset - 1] : ""; - const nextChar = offset < state.source.length ? state.source[offset] : ""; - const prefixNewlines = spacing.blankLineBefore - ? Math.max(0, 2 - countTrailingNewlinesBeforeOffset(state.source, offset)) - : prevChar !== "" && prevChar !== "\n" - ? 1 - : 0; - const suffixNewlines = spacing.blankLineAfter - ? Math.max(0, 2 - countLeadingNewlinesAfterOffset(state.source, offset)) - : nextChar !== "" && nextChar !== "\n" - ? 1 - : 0; - - return `${"\n".repeat(prefixNewlines)}${trimmed}${"\n".repeat(suffixNewlines)}`; -} - -function cleanupBlankLineArtifactsAtOffset(text: string, offset: number): string { - let runStart = Math.max(0, Math.min(offset, text.length)); - while (runStart > 0 && text[runStart - 1] === "\n") { - runStart--; - } - - let runEnd = Math.max(0, Math.min(offset, text.length)); - while (runEnd < text.length && text[runEnd] === "\n") { - runEnd++; - } - - const newlineRun = text.slice(runStart, runEnd); - if (!/\n\n/.test(newlineRun)) return text; - - const afterRun = text.slice(runEnd); - const beforeRun = text.slice(0, runStart); - const trailingLine = beforeRun.split("\n").pop() ?? ""; - const afterStartsWithClose = /^[ \t]*[}\])]/.test(afterRun); - const trailingIsOnlyClose = /^[ \t]*[}\])][ \t]*$/.test(trailingLine); - const betweenAdjacentClosing = trailingIsOnlyClose && afterStartsWithClose; - - if (afterStartsWithClose) { - if (/\n{3,}/.test(newlineRun)) { - return `${text.slice(0, runStart)}${newlineRun.replace(/\n{3,}/g, "\n\n")}${afterRun}`; - } - if (betweenAdjacentClosing) { - return text; - } - return `${text.slice(0, runStart)}\n${afterRun}`; - } - if (!/\n{3,}/.test(newlineRun)) return text; - - return `${text.slice(0, runStart)}${newlineRun.replace(/\n{3,}/g, "\n\n")}${afterRun}`; -} - -/** - * Go `type_*` chunks group file-scope receiver methods under the type for navigation. - * Inserts after the last `fn_*` (or after the struct/interface block for `func …` content when only fields exist) - * use column-0 indentation, not struct-field indentation. - */ -function goTypeAppendChildInsertionPoint( - state: MutableChunkState, - anchor: ChunkNode, - insertionContent: string | undefined, -): { offset: number; indent: string } | undefined { - if (normalizeLanguage(state.language) !== "go") return undefined; - if (!anchor.path.startsWith("type_") || !isContainerLikeChunk(anchor)) return undefined; - - const offsets = lineOffsets(state.source); - const chunkMap = getChunkMap(state.tree); - const childChunks = anchor.children.map(p => chunkMap.get(p)).filter((c): c is ChunkNode => Boolean(c)); - const probe = insertionContent !== undefined ? normalizeToLF(insertionContent) : ""; - const looksLikeFileScopeFunc = /^\s*func\b/m.test(probe); - - if (childChunks.length === 0) { - if (!looksLikeFileScopeFunc) return undefined; - return { - offset: lineEndOffset(offsets, anchor.endLine, state.source), - indent: "", - }; - } - - childChunks.sort((a, b) => a.startLine - b.startLine); - const fnChildren = childChunks.filter(c => c.name.startsWith("fn_")); - - if (fnChildren.length > 0) { - const lastFn = fnChildren.reduce((a, b) => (a.endLine > b.endLine ? a : b)); - return { - offset: lineEndOffset(offsets, lastFn.endLine, state.source), - indent: "", - }; - } - - if (looksLikeFileScopeFunc) { - return { - offset: lineEndOffset(offsets, anchor.endLine, state.source), - indent: "", - }; - } - return undefined; -} - -function getInsertionPoint( - state: MutableChunkState, - anchor: ChunkNode, - placement: "prepend" | "append", - insertionContent?: string, -): { offset: number; indent: string; insideContainer: boolean } { - const offsets = lineOffsets(state.source); - const isBranch = anchor.kind === "branch"; - const isContainer = isContainerLikeChunk(anchor); - - if (placement === "prepend") { - if (anchor.path === "") { - return { offset: 0, indent: "", insideContainer: false }; - } - if (isContainer) { - // Insert as first child inside container. - const indent = computeInsertIndent(state, anchor, true); - if (anchor.children.length > 0) { - const firstChild = getChunk(state.tree, anchor.children[0]!); - if (firstChild) { - return { - offset: lineStartOffset(offsets, firstChild.startLine, state.source), - indent, - insideContainer: true, - }; - } - } - const delimiterOffset = findContainerDelimiterOffset(state, anchor, "prepend"); - if (delimiterOffset !== undefined) { - return { offset: delimiterOffset, indent, insideContainer: true }; - } - if (isBranch) { - return { offset: lineEndOffset(offsets, anchor.startLine, state.source), indent, insideContainer: true }; - } - return { offset: lineEndOffset(offsets, anchor.startLine, state.source), indent, insideContainer: true }; - } - // Leaf: insert before — use the anchor's own indent level - const indent = (anchor.indentChar || "\t").repeat(anchor.indent); - return { offset: lineStartOffset(offsets, anchor.startLine, state.source), indent, insideContainer: false }; - } - - // placement === "append" - if (anchor.path === "") { - return { offset: state.source.length, indent: "", insideContainer: false }; - } - if (isContainer) { - const goAppend = goTypeAppendChildInsertionPoint(state, anchor, insertionContent); - if (goAppend !== undefined) { - return { offset: goAppend.offset, indent: goAppend.indent, insideContainer: true }; - } - // Match the actual insertion target (non-Go or Go interior members such as struct fields). - if (anchor.children.length > 0) { - const lastChild = getChunk(state.tree, anchor.children[anchor.children.length - 1]!); - if (lastChild) { - const indent = (lastChild.indentChar || anchor.indentChar || "\t").repeat(lastChild.indent); - return { offset: lineEndOffset(offsets, lastChild.endLine, state.source), indent, insideContainer: true }; - } - } - const indent = computeInsertIndent(state, anchor, true); - const delimiterOffset = findContainerDelimiterOffset(state, anchor, "append"); - if (delimiterOffset !== undefined) { - return { offset: delimiterOffset, indent, insideContainer: true }; - } - return { offset: lineStartOffset(offsets, anchor.endLine, state.source), indent, insideContainer: true }; - } - // Leaf: insert after — use the anchor's own indent level - const indent = (anchor.indentChar || "\t").repeat(anchor.indent); - return { offset: lineEndOffset(offsets, anchor.endLine, state.source), indent, insideContainer: false }; -} - -/** - * Map explicit insert positions to insertion points. - * - "before"/"after" always treat the anchor as a peer (leaf-style), even for branches. - * - "first_child"/"last_child" insert inside the container; error if anchor is a leaf. - */ -function getInsertionPointForPosition( - state: MutableChunkState, - anchor: ChunkNode, - pos: InsertPosition, - insertionContent?: string, -): { offset: number; indent: string; insideContainer: boolean } { - const offsets = lineOffsets(state.source); - - switch (pos) { - case "before": { - if (anchor.path === "") { - return { offset: 0, indent: "", insideContainer: false }; - } - const indent = (anchor.indentChar || "\t").repeat(anchor.indent); - return { offset: lineStartOffset(offsets, anchor.startLine, state.source), indent, insideContainer: false }; - } - case "after": { - if (anchor.path === "") { - return { offset: state.source.length, indent: "", insideContainer: false }; - } - const indent = (anchor.indentChar || "\t").repeat(anchor.indent); - return { offset: lineEndOffset(offsets, anchor.endLine, state.source), indent, insideContainer: false }; - } - case "first_child": { - if (anchor.path !== "" && anchor.kind !== "branch" && !isContainerLikeChunk(anchor)) { - throw new Error(`Cannot use prepend_child on leaf chunk ${anchor.path}`); - } - return getInsertionPoint(state, anchor, "prepend", insertionContent); - } - case "last_child": { - if (anchor.path !== "" && anchor.kind !== "branch" && !isContainerLikeChunk(anchor)) { - throw new Error(`Cannot use append_child on leaf chunk ${anchor.path}`); - } - return getInsertionPoint(state, anchor, "append", insertionContent); - } - } -} - -/** True when the replace op targets specific lines (not a whole-chunk replace). */ -function isLineScoped( - op: ChunkEditOperation, -): op is { op: "replace"; sel?: string; crc?: string; line: number; endLine?: number; content: string } { - return op.op === "replace" && op.line != null; } export function applyChunkEdits(params: { @@ -1431,300 +225,39 @@ export function applyChunkEdits(params: { operations: ChunkEditOperation[]; defaultSelector?: string; defaultCrc?: string; - anchorStyle?: EditAnchorStyle; + anchorStyle?: ChunkAnchorStyle; }): ChunkEditResult { - const normalizedLanguage = normalizeLanguage(params.language); - const originalText = normalizeChunkSource(params.source); - let state = rebuildChunkState(originalText, normalizedLanguage); - const initialParseErrors = state.tree.parseErrors; - const touchedPaths: string[] = []; - const warnings: string[] = []; - let lastScheduled: ScheduledChunkEditOperation | undefined; - const initialDefaultSelector = params.defaultSelector; - const initialDefaultCrc = params.defaultCrc; + const normalizedSource = normalizeChunkSource(params.source); + const state = ChunkState.parse(normalizedSource, normalizeLanguage(params.language)); + const result = state.applyEdits({ + operations: params.operations.map(toNativeEditOperation), + defaultSelector: params.defaultSelector, + defaultCrc: params.defaultCrc, + anchorStyle: params.anchorStyle, + cwd: params.cwd, + filePath: params.filePath, + }); - const scheduledOps: ScheduledChunkEditOperation[] = params.operations.map((operation, originalIndex) => ({ - operation, - originalIndex, - requestedSelector: sanitizeChunkSelector(operation.sel ?? initialDefaultSelector), - initialChunk: resolveAnchorChunk(state, operation.sel ?? initialDefaultSelector, warnings), - })); - - for (const scheduled of scheduledOps) { - const operation = scheduled.operation; - if (isLineScoped(operation)) { - const anchor = scheduled.initialChunk; - if (!anchor) { - throw new Error(`Chunk tree is missing an anchor for ${describeScheduledOperation(scheduled)}`); - } - const absEnd = operation.endLine ?? operation.line; - validateLineRange(anchor, operation.line, absEnd); - } - } - - const lineScopedSortKey = (scheduled: ScheduledChunkEditOperation): number => { - const operation = scheduled.operation; - if (!isLineScoped(operation)) { - return 0; - } - const anchor = scheduled.initialChunk; - if (!anchor) { - return 0; - } - const absEnd = operation.endLine ?? operation.line; - if (isZeroWidthInsert(operation.line, absEnd)) { - return zeroWidthInsertSortKey(anchor, absEnd, operation.line); - } - return operation.line; - }; - const executionOps: ScheduledChunkEditOperation[] = []; - for (let index = 0; index < scheduledOps.length; index++) { - const scheduled = scheduledOps[index]!; - if (!isLineScoped(scheduled.operation)) { - executionOps.push(scheduled); - continue; - } - - const lineScopedBlock: ScheduledChunkEditOperation[] = [scheduled]; - while (index + 1 < scheduledOps.length && isLineScoped(scheduledOps[index + 1]!.operation)) { - index++; - lineScopedBlock.push(scheduledOps[index]!); - } - lineScopedBlock.sort((a, b) => { - const lineDiff = lineScopedSortKey(b) - lineScopedSortKey(a); - if (lineDiff !== 0) return lineDiff; - return a.originalIndex - b.originalIndex; - }); - executionOps.push(...lineScopedBlock); - } - - const currentDefaultSelector = initialDefaultSelector; - let currentDefaultCrc = initialDefaultCrc; - const clearDefaultCrc = (): void => { - currentDefaultCrc = undefined; - }; - - const totalOps = params.operations.length; - for (const scheduled of executionOps) { - lastScheduled = scheduled; - const operation = scheduled.operation; - if (operation.sel === "null" || operation.sel === "undefined") { - (operation as Record).sel = undefined; - } - if (operation.crc === "null" || operation.crc === "undefined") { - (operation as Record).crc = undefined; - } - try { - switch (operation.op) { - case "replace": { - const anchorSelector = operation.sel ?? currentDefaultSelector; - const crc = operation.crc ?? (operation.sel === undefined ? currentDefaultCrc : undefined); - const requiresChecksum = operation.sel !== undefined || Boolean(currentDefaultCrc); - ensureBatchOperationTargetCurrent(state, scheduled, crc, touchedPaths); - const { chunk: anchor, crc: resolvedCrc } = resolveAnchorWithCrc(state, anchorSelector, crc, warnings); - validateBatchCrc({ chunk: anchor, crc: resolvedCrc, required: requiresChecksum }); - - // When line is provided, replace only those lines (line-scoped replace). - // endLine defaults to line when omitted (single-line edit). - if (operation.line != null) { - const absEnd = operation.endLine ?? operation.line; - validateLineRange(anchor, operation.line, absEnd); - const offsets = lineOffsets(state.source); - const absBeg = operation.line; - const rangeStart = lineStartOffset(offsets, absBeg, state.source); - const rangeEnd = lineEndOffset(offsets, absEnd, state.source); - const replacedRange = state.source.slice(rangeStart, rangeEnd); - // For zero-width inserts the replaced range is empty; derive - // indent from the two neighboring lines, picking the deeper - // indent so body-level insertions stay at body depth even when - // one neighbor is a boundary line (opening/closing brace). - let targetIndent: string; - if (isZeroWidthInsert(absBeg, absEnd)) { - const indA = detectCommonIndent( - state.source.slice( - lineStartOffset(offsets, absEnd, state.source), - lineEndOffset(offsets, absEnd, state.source), - ), - ).prefix; - const bLine = Math.min(absBeg, state.tree.lineCount); - const indB = detectCommonIndent( - state.source.slice( - lineStartOffset(offsets, bLine, state.source), - lineEndOffset(offsets, bLine, state.source), - ), - ).prefix; - targetIndent = indA.length >= indB.length ? indA : indB; - } else { - targetIndent = detectCommonIndent(replacedRange).prefix; - } - let replacement = normalizeInsertedContent(operation.content, targetIndent); - if (replacement.length > 0 && !replacement.endsWith("\n") && absEnd < state.tree.lineCount) { - replacement += "\n"; - } - state.source = replaceRangeByLines(state.source, absBeg, absEnd, replacement); - if (replacement.length === 0) { - state.source = cleanupBlankLineArtifactsAtOffset(state.source, rangeStart); - } - touchedPaths.push(anchor.path); - if (operation.sel === undefined) clearDefaultCrc(); - break; - } - - // Whole-chunk replace (no line/endLine). - const targetIndent = (anchor.indentChar || "\t").repeat(anchor.indent); - let replacement = normalizeInsertedContent(operation.content, targetIndent); - if (replacement.length > 0 && !replacement.endsWith("\n") && anchor.endLine < state.tree.lineCount) { - replacement += "\n"; - } - const offsets = lineOffsets(state.source); - const rangeStart = lineStartOffset(offsets, anchor.startLine, state.source); - state.source = replaceRangeByLines(state.source, anchor.startLine, anchor.endLine, replacement); - if (replacement.length === 0) { - state.source = cleanupBlankLineArtifactsAtOffset(state.source, rangeStart); - } - touchedPaths.push(anchor.path); - if (operation.sel === undefined) clearDefaultCrc(); - break; - } - case "delete": { - const anchorSelector = operation.sel ?? currentDefaultSelector; - const crc = operation.crc ?? (operation.sel === undefined ? currentDefaultCrc : undefined); - const requiresChecksum = operation.sel !== undefined || Boolean(currentDefaultCrc); - ensureBatchOperationTargetCurrent(state, scheduled, crc, touchedPaths); - const { chunk: anchor, crc: resolvedCrc } = resolveAnchorWithCrc(state, anchorSelector, crc, warnings); - validateBatchCrc({ chunk: anchor, crc: resolvedCrc, required: requiresChecksum }); - const offsets = lineOffsets(state.source); - const rangeStart = lineStartOffset(offsets, anchor.startLine, state.source); - state.source = replaceRangeByLines(state.source, anchor.startLine, anchor.endLine, ""); - state.source = cleanupBlankLineArtifactsAtOffset(state.source, rangeStart); - touchedPaths.push(anchor.path); - if (operation.sel === undefined) clearDefaultCrc(); - break; - } - case "append_child": - case "prepend_child": - case "append_sibling": - case "prepend_sibling": { - const anchorSelector = operation.sel ?? currentDefaultSelector; - const crc = operation.crc ?? (operation.sel === undefined ? currentDefaultCrc : undefined); - ensureBatchOperationTargetCurrent(state, scheduled, crc, touchedPaths); - const { chunk: anchor, crc: resolvedCrc } = resolveAnchorWithCrc(state, anchorSelector, crc, warnings); - validateBatchCrc({ - chunk: anchor, - crc: resolvedCrc, - required: Boolean(resolvedCrc), - }); - const pos: InsertPosition = - operation.op === "append_child" - ? "last_child" - : operation.op === "prepend_child" - ? "first_child" - : operation.op === "append_sibling" - ? "after" - : "before"; - const point = getInsertionPointForPosition( - state, - anchor, - pos, - operation.op === "append_child" || operation.op === "prepend_child" ? operation.content : undefined, - ); - const insertion = { - offset: point.offset, - indent: point.indent, - spacing: computeInsertSpacing(state, anchor, pos), - }; - let replacement = normalizeInsertedContent(operation.content, insertion.indent); - replacement = normalizeInsertionBoundaryContent(state, insertion.offset, replacement, insertion.spacing); - if (operation.op === "prepend_child") { - const body = replacement.replace(/^\n+/, "").replace(/\n+$/, ""); - const commentOnly = - body.length > 0 && - body.split("\n").every(line => { - const trimmedLine = line.trim(); - return ( - trimmedLine.length === 0 || - trimmedLine.startsWith("//") || - trimmedLine.startsWith("///") || - trimmedLine.startsWith("#") || - trimmedLine.startsWith("/*") - ); - }); - if (commentOnly && anchor.path === "" && anchor.children.includes("file_preamble")) { - throw new Error( - "Comment-only prepend_child on root is not allowed when the file has a file_preamble chunk. Use replace on the file_preamble chunk instead.", - ); - } - if (commentOnly && anchor.children.length > 0) { - warnings.push( - "Comment-only prepend_child can merge into the following chunk's first line; it is not a separate named chunk.", - ); - } - } - state.source = insertAtOffset(state.source, insertion.offset, replacement); - touchedPaths.push(anchor.path); - if (operation.sel === undefined) clearDefaultCrc(); - break; - } - } - - state = rebuildChunkState(state.source, normalizedLanguage); - } catch (err) { - throw new Error( - `Edit operation ${scheduled.originalIndex + 1}/${totalOps} failed (${describeScheduledOperation(scheduled)}): ${(err as Error).message}\n` + - `No changes were saved. Fix the failing operation and retry the entire batch.`, - ); - } - } - - const parseValid = !chunkValidateEnabled() || state.tree.parseErrors <= initialParseErrors; - if (!parseValid && initialParseErrors === 0) { - const errorSummaries = formatParseErrorSummaries(state); - const fallbackSummary = - errorSummaries.length === 0 && lastScheduled?.initialChunk - ? [ - `L${lastScheduled.initialChunk.startLine}:C1 parse error introduced while editing ${lastScheduled.initialChunk.path}`, - ] - : errorSummaries; - const details = - fallbackSummary.length > 0 - ? `\nParse errors:\n${fallbackSummary.map(summary => `- ${summary}`).join("\n")}` - : ""; - throw new Error( - `Edit rejected: introduced ${state.tree.parseErrors} parse error(s). The file was valid before the edit but is not after. Fix the content and retry.${details}`, - ); - } - if (!parseValid) { - warnings.push(`Edit introduced ${state.tree.parseErrors - initialParseErrors} new parse error(s).`); - } - const displayPath = displayPathForFile(params.filePath, params.cwd); - const changed = originalText !== state.source; - const responseText = changed - ? renderChangedHunks({ - tree: state.tree, - displayPath, - diff: generateUnifiedDiffString(originalText, state.source).diff, - anchorStyle: params.anchorStyle, - }) - : renderChunkTree({ - tree: state.tree, - chunkPath: "", - source: maskChunkDisplaySource(state.source, normalizedLanguage), - title: displayPath, - languageTag: fileLanguageTag(params.filePath, normalizedLanguage), - checksum: state.tree.checksum, - renderChildrenOnly: true, - omitChecksum: false, - anchorStyle: params.anchorStyle, - showLeafPreview: true, - tabReplacement: " ", - }); return { - diffSourceBefore: originalText, - diffSourceAfter: state.source, - responseText, - changed, - parseValid, - touchedPaths, - warnings, + diffSourceBefore: result.diffBefore, + diffSourceAfter: result.diffAfter, + responseText: result.responseText, + changed: result.changed, + parseValid: result.parseValid, + touchedPaths: result.touchedPaths, + warnings: result.warnings, }; } + +export async function getChunkInfoForFile( + filePath: string, + language: string | undefined, + chunkPath: string, +): Promise { + const { state } = await loadChunkStateForFile(filePath, language); + return state.chunk(chunkPath) ?? undefined; +} + +export function missingChunkReadTarget(selector: string): ChunkReadTarget { + return { status: ChunkReadStatus.NotFound, selector }; +} diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index b630c3870..0afa0857f 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -234,8 +234,8 @@ export class FindTool implements AgentTool { }); }; const onMatch = onUpdate - ? (match: GlobMatch | null) => { - if (signal?.aborted || !match) return; + ? (err: Error | null, match: GlobMatch | null) => { + if (err || signal?.aborted || !match) return; let relativePath = match.path; if (!relativePath) return; if (match.fileType === FileType.Dir && !relativePath.endsWith("/")) { diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index f259145a0..886f9b7dd 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -1,7 +1,7 @@ import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { type GrepMatch, type GrepResult, grep } from "@oh-my-pi/pi-natives"; +import { type GrepMatch, GrepOutputMode, type GrepResult, grep } from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { untilAborted } from "@oh-my-pi/pi-utils"; @@ -11,7 +11,6 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { getLanguageFromPath, type Theme } from "../modes/theme/theme"; import { computeLineHash } from "../patch/hashline"; import grepDescription from "../prompts/tools/grep.md" with { type: "text" }; -import grepChunkDescription from "../prompts/tools/grep-chunk.md" with { type: "text" }; import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output"; import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { resolveEditMode } from "../utils/edit-mode"; @@ -75,13 +74,11 @@ export class GrepTool implements AgentTool { constructor(private readonly session: ToolSession) { const displayMode = resolveFileDisplayMode(session); - this.description = - resolveEditMode(session) === "chunk" - ? renderPromptTemplate(grepChunkDescription) - : renderPromptTemplate(grepDescription, { - IS_HASHLINE_MODE: displayMode.hashLines, - IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers, - }); + this.description = renderPromptTemplate(grepDescription, { + IS_HASHLINE_MODE: displayMode.hashLines, + IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers, + IS_CHUNK_MODE: displayMode.chunked, + }); } async execute( @@ -169,7 +166,7 @@ export class GrepTool implements AgentTool { throw new ToolError(`Path not found: ${scopePath}`); } - const effectiveOutputMode = "content"; + const effectiveOutputMode = GrepOutputMode.Content; const effectiveLimit = normalizedLimit ?? DEFAULT_MATCH_LIMIT; const internalLimit = Math.min(effectiveLimit * 5, 2000); diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index aefe0fb35..c2d2b0d3e 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -463,7 +463,9 @@ export class ReadTool implements AgentTool { this.#inspectImageEnabled = session.settings.get("inspect_image.enabled"); this.description = resolveEditMode(session) === "chunk" - ? renderPromptTemplate(readChunkDescription) + ? renderPromptTemplate(readChunkDescription, { + anchorStyle: resolveAnchorStyle(session.settings), + }) : renderPromptTemplate(readDescription, { DEFAULT_LIMIT: String(this.#defaultLimit), DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES), @@ -833,11 +835,12 @@ export class ReadTool implements AgentTool { pathChunkSelector && parsed.kind === "lines" ? { startLine: parsed.startLine, endLine: parsed.endLine } : undefined; - const chunkReadPath = pathChunkSelector - ? `${localReadPath}:${pathChunkSelector}` - : parsed.kind === "chunk" - ? selectorInput - ? `${localReadPath}:${selectorInput}` + // sel= wins over path:chunk when both are provided (explicit param > embedded path). + const effectiveSelector = sel ? selectorInput : pathChunkSelector ?? selectorInput; + const chunkReadPath = + parsed.kind === "chunk" || (pathChunkSelector && !sel) + ? effectiveSelector + ? `${localReadPath}:${effectiveSelector}` : localReadPath : parsed.kind === "lines" ? parsed.endLine !== undefined diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index adea23443..8e3a59ac6 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -5,17 +5,16 @@ * tool renderers to ensure a unified TUI experience. */ import * as os from "node:os"; -import { type Ellipsis, truncateToWidth } from "@oh-my-pi/pi-tui"; -import { getIndentation, pluralize } from "@oh-my-pi/pi-utils"; +import type { Ellipsis } from "@oh-my-pi/pi-natives"; +import { truncateToWidth } from "@oh-my-pi/pi-tui"; +import { pluralize } from "@oh-my-pi/pi-utils"; import { settings } from "../config/settings"; import type { Theme } from "../modes/theme/theme"; import { formatDimensionNote, type ResizedImage } from "../utils/image-resize"; -export { Ellipsis, truncateToWidth } from "@oh-my-pi/pi-tui"; +export { Ellipsis } from "@oh-my-pi/pi-natives"; +export { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui"; -export function replaceTabs(text: string, file?: string): string { - return text.replaceAll("\t", getIndentation(file)); -} // ============================================================================= // Standardized Display Constants // ============================================================================= diff --git a/packages/coding-agent/src/tui/tree-list.ts b/packages/coding-agent/src/tui/tree-list.ts index a4fbc582d..af8d9d745 100644 --- a/packages/coding-agent/src/tui/tree-list.ts +++ b/packages/coding-agent/src/tui/tree-list.ts @@ -1,8 +1,10 @@ /** * Hierarchical tree list rendering helper. */ + +import { replaceTabs } from "@oh-my-pi/pi-tui"; import type { Theme } from "../modes/theme/theme"; -import { formatMoreItems, replaceTabs } from "../tools/render-utils"; +import { formatMoreItems } from "../tools/render-utils"; import type { TreeContext } from "./types"; import { getTreeBranch, getTreeContinuePrefix } from "./utils"; diff --git a/packages/natives/src/clipboard/index.ts b/packages/coding-agent/src/utils/clipboard.ts similarity index 84% rename from packages/natives/src/clipboard/index.ts rename to packages/coding-agent/src/utils/clipboard.ts index 443cf02ce..5712f9c3f 100644 --- a/packages/natives/src/clipboard/index.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -1,19 +1,7 @@ -/** - * Clipboard helpers backed by native arboard bindings. - * - * Adds OSC 52 fallback for SSH/mosh, Termux support, and headless guards - * on top of the native arboard layer. - */ - import { execSync } from "node:child_process"; +import type { ClipboardImage } from "@oh-my-pi/pi-natives"; +import * as native from "@oh-my-pi/pi-natives"; -import { native } from "../native"; - -import type { ClipboardImage } from "./types"; - -export type { ClipboardImage } from "./types"; - -/** Whether a display server is available on Linux. */ const hasDisplay = process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); /** @@ -88,5 +76,5 @@ export async function readImageFromClipboard(): Promise { return null; } - return native.readImageFromClipboard(); + return (await native.readImageFromClipboard()) ?? null; } diff --git a/packages/coding-agent/src/utils/file-display-mode.ts b/packages/coding-agent/src/utils/file-display-mode.ts index 95840bb21..5f6e6e064 100644 --- a/packages/coding-agent/src/utils/file-display-mode.ts +++ b/packages/coding-agent/src/utils/file-display-mode.ts @@ -7,6 +7,7 @@ import { resolveEditMode } from "./edit-mode"; export interface FileDisplayMode { lineNumbers: boolean; hashLines: boolean; + chunked: boolean; } /** Session-like object providing settings and tool availability for display mode resolution. */ @@ -27,8 +28,10 @@ export function resolveFileDisplayMode(session: FileDisplayModeSession): FileDis const { settings } = session; const hasEditTool = session.hasEditTool ?? true; const hashLines = hasEditTool && resolveEditMode(session) === "hashline" && settings.get("readHashLines") !== false; + const chunked = hasEditTool && resolveEditMode(session) === "chunk"; return { hashLines, lineNumbers: hashLines || settings.get("readLineNumbers") === true, + chunked, }; } diff --git a/packages/coding-agent/test/config-spacing.test.ts b/packages/coding-agent/test/config-spacing.test.ts index 48948f2ea..0519698f5 100644 --- a/packages/coding-agent/test/config-spacing.test.ts +++ b/packages/coding-agent/test/config-spacing.test.ts @@ -3,7 +3,8 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { getDefaultTabWidth, getIndentation, Snowflake, setDefaultTabWidth } from "@oh-my-pi/pi-utils"; +import { getDefaultTabWidth, getIndentation, setDefaultTabWidth } from "@oh-my-pi/pi-natives"; +import { Snowflake } from "@oh-my-pi/pi-utils"; describe("indentation resolver", () => { let tempDir = ""; @@ -23,20 +24,20 @@ describe("indentation resolver", () => { it("falls back to hard default when settings are not initialized", () => { expect(getDefaultTabWidth()).toBe(3); - expect(getIndentation()).toBe(" "); + expect(getIndentation()).toBe(3); }); it("uses configured default tab width from settings", async () => { const runtimeSettings = await Settings.init({ inMemory: true, cwd: tempDir }); runtimeSettings.set("display.tabWidth", 5); expect(getDefaultTabWidth()).toBe(5); - expect(getIndentation()).toBe(" "); + expect(getIndentation()).toBe(5); }); it("applies current display tab width during initial settings load", async () => { await Settings.init({ inMemory: true, cwd: tempDir, overrides: { "display.tabWidth": 7 } }); expect(getDefaultTabWidth()).toBe(7); - expect(getIndentation()).toBe(" "); + expect(getIndentation()).toBe(7); }); it("applies nearest editorconfig rules for the target file", async () => { @@ -48,8 +49,8 @@ describe("indentation resolver", () => { ["root = true", "", "[*]", "indent_size = 2", "", "[*.md]", "indent_size = 4"].join("\n"), ); - expect(getIndentation(filePath)).toBe(" "); - expect(getIndentation(path.join(tempDir, "README.md"))).toBe(" "); + expect(getIndentation(filePath)).toBe(2); + expect(getIndentation(path.join(tempDir, "README.md"))).toBe(4); }); it("merges editorconfig files from root to leaf", async () => { @@ -61,6 +62,6 @@ describe("indentation resolver", () => { await fs.writeFile(path.join(tempDir, ".editorconfig"), ["root = true", "", "[*]", "indent_size = 2"].join("\n")); await fs.writeFile(path.join(tempDir, "packages", ".editorconfig"), ["[*.ts]", "indent_size = 6"].join("\n")); - expect(getIndentation(filePath)).toBe(" "); + expect(getIndentation(filePath)).toBe(6); }); }); diff --git a/packages/coding-agent/test/core/chunk-tree.test.ts b/packages/coding-agent/test/core/chunk-tree.test.ts index 9c648bab4..6a0957803 100644 --- a/packages/coding-agent/test/core/chunk-tree.test.ts +++ b/packages/coding-agent/test/core/chunk-tree.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { parseChunkTree } from "@oh-my-pi/pi-natives"; +import { ChunkState } from "@oh-my-pi/pi-natives"; import { applyChunkEdits, formatChunkedRead, parseChunkReadPath } from "../../src/tools/chunk-tree"; // ═══════════════════════════════════════════════════════════════════════════ @@ -90,7 +90,7 @@ function applyEdit(params: { } function getChecksum(source: string, chunkPath: string, language = "typescript"): string { - const chunk = parseChunkTree(source, language).chunks.find(candidate => candidate.path === chunkPath); + const chunk = ChunkState.parse(source, language).chunk(chunkPath); if (!chunk) { throw new Error(`Chunk not found in test fixture: ${chunkPath}`); } @@ -322,7 +322,7 @@ type Server struct { Addr string } `; - const crc = parseChunkTree(source, "go").chunks.find(c => c.path === "type_Server")?.checksum; + const crc = ChunkState.parse(source, "go").chunk("type_Server")?.checksum; expect(crc).toBeDefined(); const result = applyEdit({ source, @@ -350,7 +350,7 @@ type Server struct { Addr string } `; - const crc = parseChunkTree(source, "go").chunks.find(c => c.path === "type_Server")?.checksum; + const crc = ChunkState.parse(source, "go").chunk("type_Server")?.checksum; expect(crc).toBeDefined(); const result = applyEdit({ source, @@ -640,18 +640,18 @@ describe("content prefix stripping", () => { expect(result.diffSourceAfter).toContain("exec()"); }); - test("diff and hashline prefixes are stripped from replacement content", () => { + test("hashline prefixes are stripped from replacement content", () => { const ac = { sel: "class_Worker.fn_run", crc: getChecksum(testSource, "class_Worker.fn_run") }; const result = edit([ { op: "replace", ...ac, - content: '+1#ZP:exec(): void {\n+2#PM:\tconsole.log("exec");\n+3#QV:}', + content: '1#ZP:exec(): void {\n2#PM:\tconsole.log("exec");\n3#QV:}', }, ]); expect(result.diffSourceAfter).not.toContain("#ZP:"); - expect(result.diffSourceAfter).not.toContain("\n+"); + expect(result.diffSourceAfter).not.toMatch(/\b\d+#[A-Z]{2}:/); expect(result.diffSourceAfter).toContain("exec(): void"); }); }); @@ -694,9 +694,9 @@ describe("formatChunkedRead", () => { language: "typescript", }); - expect(result.text).toContain("worker.ts ·"); - expect(result.text).toContain(":class_Worker#"); - expect(result.text).toContain(".fn_run#"); + expect(result.text).toContain("worker.ts·"); + expect(result.text).toContain("[class_Worker#"); + expect(result.text).toContain("[fn_run#"); expect(result.text).not.toContain("ck:"); expect(result.text).not.toContain("[branch"); }); @@ -729,11 +729,11 @@ describe("formatChunkedRead", () => { language: "typescript", }); - expect(result.text).toContain("worker.ts:class_Worker.fn_run ·"); + expect(result.text).toContain("worker.ts:class_Worker.fn_run·"); expect(result.text).toContain("run(): void {"); - expect(result.text).toContain("6 │ "); + expect(result.text).toContain("6| "); expect(result.text).toContain("run(): void {"); - expect(result.text).toContain("7 │ "); + expect(result.text).toContain("7| "); expect(result.text).toContain("console.log(this.name);"); }); @@ -751,8 +751,8 @@ describe("formatChunkedRead", () => { }); expect(result.text).not.toContain("to expand ⋮"); - expect(result.text).toContain("3 │ step(0);"); - expect(result.text).toContain("27 │ step(24);"); + expect(result.text).toContain("3| step(0);"); + expect(result.text).toContain("27| step(24);"); expect(result.text).toContain("done();"); }); }); @@ -839,9 +839,9 @@ describe("addressable member rendering", () => { language: "rust", }); - expect(result.text).toContain(":enum_LogLevel#"); - expect(result.text).toContain(".variant_Debug#"); - expect(result.text).toContain(".variant_Error#"); + expect(result.text).toContain("[enum_LogLevel#"); + expect(result.text).toContain("[variant_Debug#"); + expect(result.text).toContain("[variant_Error#"); }); test("renders a single-method Go interface inline on the parent chunk", async () => { @@ -859,9 +859,9 @@ describe("addressable member rendering", () => { language: "go", }); - expect(result.text).toContain(":type_Handler#"); - expect(result.text).toContain("3 │ type Handler interface {"); - expect(result.text).toContain("4 │ Handle(method, path string) Result"); + expect(result.text).toContain("[type_Handler#"); + expect(result.text).toContain("3| type Handler interface {"); + expect(result.text).toContain("4| Handle(method, path string) Result"); }); test("renders Go receiver methods beneath their receiver type", async () => { @@ -879,11 +879,10 @@ describe("addressable member rendering", () => { language: "go", }); - expect(result.text).toContain(":type_Server#"); - expect(result.text).toContain(".field_Addr#"); - expect(result.text).toContain(".fn_Start#"); - expect(result.text).toContain(".fn_Stop#"); - expect(result.text).not.toContain(":fn_Start#"); + expect(result.text).toContain("[type_Server#"); + expect(result.text).toContain("[field_Addr#"); + expect(result.text).toContain("[fn_Start#"); + expect(result.text).toContain("[fn_Stop#"); }); test("line range filter shows receiver methods under a type even when the range skips the type header", async () => { @@ -902,8 +901,8 @@ describe("addressable member rendering", () => { }); expect(result.text).toContain("L7-L8"); - expect(result.text).toContain(".fn_Start#"); - expect(result.text).toContain(".fn_Stop#"); + expect(result.text).toContain("[fn_Start#"); + expect(result.text).toContain("[fn_Stop#"); }); test("renders trivial TypeScript enum variants as addressable children", async () => { @@ -918,24 +917,24 @@ describe("addressable member rendering", () => { language: "typescript", }); - expect(result.text).toContain(":enum_Status#"); - expect(result.text).toContain(".variant_Idle#"); - expect(result.text).toContain(".variant_Busy#"); + expect(result.text).toContain("[enum_Status#"); + expect(result.text).toContain("[variant_Idle#"); + expect(result.text).toContain("[variant_Busy#"); }); test("keeps non-trivial containers expanded", () => { - const classTree = parseChunkTree( + const classState = ChunkState.parse( `class Tiny {\n foo() {\n return 1;\n }\n\n bar() {\n return 2;\n }\n}\n`, "typescript", ); - const classChunk = classTree.chunks.find(chunk => chunk.path === "class_Tiny"); - expect(classChunk?.children).toContain("class_Tiny.fn_foo"); - expect(classChunk?.children).toContain("class_Tiny.fn_bar"); + const classChildren = classState.children("class_Tiny").map((chunk: { path: string }) => chunk.path); + expect(classChildren).toContain("class_Tiny.fn_foo"); + expect(classChildren).toContain("class_Tiny.fn_bar"); const enumSource = Array.from({ length: 35 }, (_, i) => ` V${i} = ${i},`).join("\n"); - const enumTree = parseChunkTree(`enum Big {\n${enumSource}\n}\n`, "typescript"); - const enumChunk = enumTree.chunks.find(chunk => chunk.path === "enum_Big"); - expect(enumChunk?.kind).toBe("branch"); + const enumState = ChunkState.parse(`enum Big {\n${enumSource}\n}\n`, "typescript"); + const enumChunk = enumState.chunk("enum_Big"); + expect(enumChunk?.leaf).toBe(false); }); }); @@ -955,9 +954,9 @@ describe("grouped Go receiver chunk headers", () => { language: "go", }); - expect(result.text).toContain("server.go:type_Server·6ln"); - expect(result.text).toContain(".fn_Start#"); - expect(result.text).toContain(".fn_Stop#"); + expect(result.text).toContain("server.go:type_Server·6L"); + expect(result.text).toContain("[fn_Start#"); + expect(result.text).toContain("[fn_Stop#"); }); }); @@ -1102,9 +1101,9 @@ describe("Go receiver render ownership", () => { ], }); - expect((result.responseText.match(/func DefaultServer\(\) \*Server/g) ?? []).length).toBe(1); - expect(result.responseText).toContain(":fn_DefaultServer#"); - expect(result.responseText).toContain("<.fn_Start#"); + expect(result.responseText).toContain("func DefaultServer() *Server"); + expect(result.responseText).toContain("[fn_DefaultServer#"); + expect(result.responseText).toContain("[fn_Start#"); }); }); @@ -1288,8 +1287,8 @@ describe("splice", () => { describe("prepend_child warnings", () => { test("warns when comment-only prepend_child may merge into the next chunk", () => { const source = `package main\n\nimport "fmt"\n`; - const tree = parseChunkTree(source, "go"); - const root = tree.chunks.find(c => c.path === ""); + const state = ChunkState.parse(source, "go"); + const root = state.root(); if (!root) { throw new Error("expected root chunk"); } @@ -1352,17 +1351,18 @@ describe("chunk selector auto-resolution", () => { }); }); -describe("prepend_child file_preamble guard", () => { - test("errors when comment-only prepend_child targets root with file_preamble", () => { - // JS source with a leading comment block that becomes file_preamble +describe("prepend_child preamble guard", () => { + test("errors when comment-only prepend_child targets root with preamble", () => { + // JS source with a leading comment block that becomes preamble const source = `/**\n * License header\n */\nconst x = 1;\n`; - const tree = parseChunkTree(source, "javascript"); - const hasPreamble = tree.chunks.some(c => c.path === "file_preamble"); + const state = ChunkState.parse(source, "javascript"); + const hasPreamble = state.chunks().some((chunk: { path: string }) => chunk.path === "preamble"); if (!hasPreamble) { - // Skip if parser doesn't produce file_preamble for this source + // Skip if parser doesn't produce preamble for this source return; } - const root = tree.chunks.find(c => c.path === "")!; + const root = state.root(); + if (!root) throw new Error("expected root chunk"); expect(() => applyChunkEdits({ source, @@ -1371,7 +1371,7 @@ describe("prepend_child file_preamble guard", () => { filePath: "index.js", operations: [{ op: "prepend_child", sel: "", crc: root.checksum, content: "// AUTO-GENERATED\n" }], }), - ).toThrow(/Comment-only prepend_child on root is not allowed when the file has a file_preamble/); + ).toThrow(/Comment-only prepend_child on root is not allowed when the file has a preamble/); }); }); @@ -1394,14 +1394,16 @@ describe("tlaplus chunk rendering", () => { language: "tlaplus", }); - expect(result.text).toContain(".translation_12#"); + expect(result.text).toContain("[translation_12#"); expect(result.text).toContain("\\* [translation hidden]"); expect(result.text).not.toContain("Next == pc' = pc"); }); test("applyChunkEdits keeps translated content hidden in responseText", () => { - const tree = parseChunkTree(tlaplusSource, "tlaplus"); - const initChunk = tree.chunks.find(chunk => chunk.path.endsWith("operator_Init")); + const state = ChunkState.parse(tlaplusSource, "tlaplus"); + const initChunk = state + .chunks() + .find((chunk: { path: string; checksum: string }) => chunk.path.endsWith("operator_Init")); if (!initChunk) throw new Error("Expected operator_Init chunk in tlaplus fixture"); const result = applyChunkEdits({ @@ -1413,7 +1415,7 @@ describe("tlaplus chunk rendering", () => { }); expect(result.diffSourceAfter).toContain("Start == x = 0"); - expect(result.responseText).toContain(".translation_12#"); + expect(result.responseText).toContain("[translation_12#"); expect(result.responseText).toContain("\\* [translation hidden]"); expect(result.responseText).not.toContain("Next == pc' = pc"); }); diff --git a/packages/coding-agent/test/theme-auto-detection.test.ts b/packages/coding-agent/test/theme-auto-detection.test.ts index 7661d831d..ea129cbac 100644 --- a/packages/coding-agent/test/theme-auto-detection.test.ts +++ b/packages/coding-agent/test/theme-auto-detection.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import * as nativesModule from "@oh-my-pi/pi-natives"; +import { MacOSAppearance } from "@oh-my-pi/pi-natives"; const originalPlatform = process.platform; const originalColorfgbg = Bun.env.COLORFGBG; @@ -60,7 +61,7 @@ describe("theme auto-detection", () => { it("prefers COLORFGBG before macOS fallback inside Zellij", async () => { using _globals = withThemeTestGlobals({ zellij: "1", colorfgbg: "15;0" }); - const detectSpy = vi.spyOn(nativesModule, "detectMacOSAppearance").mockReturnValue("light"); + const detectSpy = vi.spyOn(nativesModule, "detectMacOSAppearance").mockReturnValue(MacOSAppearance.Light); await themeModule.initTheme(false, undefined, undefined, "dark", "light"); @@ -70,8 +71,8 @@ describe("theme auto-detection", () => { it("keeps honoring terminal-reported appearance outside fallback mode", async () => { using _globals = withThemeTestGlobals(); - const detectSpy = vi.spyOn(nativesModule, "detectMacOSAppearance").mockReturnValue("light"); - const observerSpy = vi.spyOn(nativesModule, "startMacAppearanceObserver"); + const detectSpy = vi.spyOn(nativesModule, "detectMacOSAppearance").mockReturnValue(MacOSAppearance.Light); + const observerSpy = vi.spyOn(nativesModule.MacAppearanceObserver, "start"); themeModule.onTerminalAppearanceChange("dark"); await themeModule.initTheme(true, undefined, undefined, "dark", "light"); @@ -85,11 +86,13 @@ describe("theme auto-detection", () => { using _globals = withThemeTestGlobals({ zellij: "1" }); const stop = vi.fn(); let onAppearanceChange: ((appearance: "dark" | "light") => void) | undefined; - vi.spyOn(nativesModule, "detectMacOSAppearance").mockReturnValue("light"); - const observerSpy = vi.spyOn(nativesModule, "startMacAppearanceObserver").mockImplementation(callback => { - onAppearanceChange = callback; + vi.spyOn(nativesModule, "detectMacOSAppearance").mockReturnValue(MacOSAppearance.Light); + const observerSpy = vi.spyOn(nativesModule.MacAppearanceObserver, "start").mockImplementation((( + callback: (err: null | Error, appearance: "dark" | "light") => void, + ) => { + onAppearanceChange = (appearance: "dark" | "light") => callback(null, appearance); return { stop }; - }); + }) as any); await themeModule.initTheme(true, undefined, undefined, "dark", "light"); @@ -106,7 +109,7 @@ describe("theme auto-detection", () => { }); it("Zellij fallback stays macOS-only (Linux + Zellij = honor terminal)", async () => { using _globals = withThemeTestGlobals({ platform: "linux", zellij: "1" }); - const detectSpy = vi.spyOn(nativesModule, "detectMacOSAppearance").mockReturnValue("light"); + const detectSpy = vi.spyOn(nativesModule, "detectMacOSAppearance").mockReturnValue(MacOSAppearance.Light); themeModule.onTerminalAppearanceChange("dark"); await themeModule.initTheme(false, undefined, undefined, "dark", "light"); @@ -117,7 +120,7 @@ describe("theme auto-detection", () => { it("terminal-reported appearance wins over conflicting COLORFGBG", async () => { using _globals = withThemeTestGlobals({ colorfgbg: "15;0" }); - const detectSpy = vi.spyOn(nativesModule, "detectMacOSAppearance").mockReturnValue("light"); + const detectSpy = vi.spyOn(nativesModule, "detectMacOSAppearance").mockReturnValue(MacOSAppearance.Light); themeModule.onTerminalAppearanceChange("light"); await themeModule.initTheme(false, undefined, undefined, "dark", "light"); diff --git a/packages/coding-agent/test/tools/chunk-mode.test.ts b/packages/coding-agent/test/tools/chunk-mode.test.ts index 7250125df..8b58aedd2 100644 --- a/packages/coding-agent/test/tools/chunk-mode.test.ts +++ b/packages/coding-agent/test/tools/chunk-mode.test.ts @@ -10,7 +10,7 @@ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { GrepTool } from "@oh-my-pi/pi-coding-agent/tools/grep"; import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read"; import { resolveFileDisplayMode } from "@oh-my-pi/pi-coding-agent/utils/file-display-mode"; -import { parseChunkTree } from "@oh-my-pi/pi-natives"; +import { ChunkReadStatus, ChunkState } from "@oh-my-pi/pi-natives"; import { applyChunkEdits } from "../../src/tools/chunk-tree"; function getText(result: { content: Array<{ type: string; text?: string }> }): string { @@ -31,7 +31,7 @@ function createSession(cwd: string): ToolSession { } function getChunkChecksum(source: string, language: string, chunkPath: string): string { - const chunk = parseChunkTree(source, language).chunks.find(candidate => candidate.path === chunkPath); + const chunk = ChunkState.parse(source, language).chunk(chunkPath); if (!chunk) { throw new Error(`Chunk not found in fixture: ${chunkPath}`); } @@ -73,9 +73,9 @@ describe("chunk mode tools", () => { const result = await tool.execute("chunk-read-root", { path: filePath }); const text = getText(result); - expect(text).toContain("server.ts ·"); - expect(text).toContain(":class_Server#"); - expect(text).toContain(":fn_main#"); + expect(text).toContain("server.ts·"); + expect(text).toContain("[class_Server#"); + expect(text).toContain("[fn_main#"); expect(text).not.toContain("ck:"); }); @@ -96,7 +96,7 @@ describe("chunk mode tools", () => { expect(text).not.toContain("to expand ⋮"); expect(text).toContain("let total = 0;"); - expect(text).toContain("29 │ total += 25;"); + expect(text).toContain("29| total += 25;"); expect(text).toContain("return err.message + total;"); }); @@ -109,11 +109,11 @@ describe("chunk mode tools", () => { const text = getText(result); expect(text).toContain("[Notice: chunk view scoped to requested lines L2-L4; non-overlapping lines omitted.]"); - expect(text).toContain("server.ts ·"); - expect(text).toContain(".fn_handleError#"); - expect(text).toContain(".var_total#"); - expect(text).toContain("3 │"); - expect(text).toContain("4 │"); + expect(text).toContain("server.ts·"); + expect(text).toContain("[fn_handleError#"); + expect(text).toContain("[var_total#"); + expect(text).toContain("3|"); + expect(text).toContain("4|"); expect(text).not.toContain("⋯"); }); @@ -126,11 +126,11 @@ describe("chunk mode tools", () => { const text = getText(result); expect(text).toContain("[Notice: chunk view scoped to requested lines L2-L4; non-overlapping lines omitted.]"); - expect(text).toContain("server.ts ·"); - expect(text).toContain(".fn_handleError#"); - expect(text).toContain(".var_total#"); - expect(text).toContain(".var_total#"); - expect(text).toContain("3 │"); + expect(text).toContain("server.ts·"); + expect(text).toContain("[fn_handleError#"); + expect(text).toContain("[var_total#"); + expect(text).toContain("[var_total#"); + expect(text).toContain("3|"); }); it("ignores a chunk selector checksum suffix on read", async () => { @@ -146,7 +146,7 @@ describe("chunk mode tools", () => { }); const text = getText(result); - expect(text).toContain("server.ts:class_Server.fn_handleError ·"); + expect(text).toContain("server.ts:class_Server.fn_handleError·"); expect(text).not.toContain("[Warning: checksum #"); }); @@ -158,9 +158,9 @@ describe("chunk mode tools", () => { const result = await tool.execute("chunk-read-tsx", { path: filePath }); const text = getText(result); - expect(text).toContain("component.tsx ·"); + expect(text).toContain("component.tsx·"); expect(text).toContain("tsx"); - expect(text).toContain(":fn_App#"); + expect(text).toContain("[fn_App#"); }); it("maps Handlebars and TLA+ file extensions for chunk mode", () => { @@ -182,7 +182,7 @@ describe("chunk mode tools", () => { const text = getText(result); expect(text).toContain("server.ts:class_Server.fn_handleError"); - expect(text).toContain("ln:"); + expect(text).toContain(".return>64|"); expect(text).toContain("err.message"); }); @@ -222,8 +222,8 @@ describe("chunk mode tools", () => { expect(updatedSource).toContain("normalized:"); expect(updatedSource).not.toContain("total +="); - expect(editText).toContain("server.ts ·"); - expect(editText).toContain(":class_Server#"); + expect(editText).toContain("server.ts·"); + expect(editText).toContain("@@ -3,62 +3,1 @@"); }); it("applies omitted-sel batch splices when the second operation uses the post-first checksum", async () => { @@ -416,7 +416,7 @@ describe("chunk mode tools", () => { expect(getText(result)).toContain("[Chunk not found]"); expect(result.details?.chunk).toEqual({ - status: "not_found", + status: ChunkReadStatus.NotFound, selector: "class_Server.fn_missing", }); }); diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 5f96a5907..1ad3982b3 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,29 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Moved package entry point from `src/index.ts` to `native/index.js` — consumers must update imports to use the new native module path +- Removed TypeScript source files from `src/` directory — all APIs now exported from auto-generated `native/index.js` with types in `native/index.d.ts` +- Changed enum exports to runtime objects — `const enum` values are now available at runtime via generated enum exports in `native/index.js` + +### Added + +- Generated native module bindings in `native/index.js` and `native/index.d.ts` from napi-rs build output +- Added `gen-enums.ts` script to extract and export runtime enum values from TypeScript const enums +- Added `embedded-addon.js` for managing embedded native addon variants and metadata + +### Changed + +- Refactored build pipeline to use napi-rs generated bindings instead of hand-written TypeScript wrappers +- Updated `build-native.ts` to generate runtime enum exports after native compilation +- Updated `embed-native.ts` to output JavaScript instead of TypeScript for embedded addon metadata + +### Removed + +- Removed all TypeScript wrapper modules from `src/` directory (appearance, ast, chunk, clipboard, glob, grep, highlight, html, image, keys, projfs, ps, pty, shell, text, work) +- Removed `src/bindings.ts` and `src/index.ts` entry points +- Removed `src/search-db.ts` and `src/search-db-types.ts` ## [13.16.1] - 2026-03-27 diff --git a/packages/natives/native/embedded-addon.js b/packages/natives/native/embedded-addon.js new file mode 100644 index 000000000..a462bdf94 --- /dev/null +++ b/packages/natives/native/embedded-addon.js @@ -0,0 +1,21 @@ +// AUTOGENERATED FILE -- DO NOT EDIT DIRECTLY +// See scripts/embed-native.ts + +/** @typedef {"modern" | "baseline" | "default"} EmbeddedAddonVariant */ + +/** + * @typedef {Object} EmbeddedAddonFile + * @property {EmbeddedAddonVariant} variant + * @property {string} filename + * @property {string} filePath + */ + +/** + * @typedef {Object} EmbeddedAddon + * @property {string} platformTag + * @property {string} version + * @property {EmbeddedAddonFile[]} files + */ + +/** @type {EmbeddedAddon|null} */ +export const embeddedAddon = null; diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts new file mode 100644 index 000000000..98a7822dc --- /dev/null +++ b/packages/natives/native/index.d.ts @@ -0,0 +1,1331 @@ +/* auto-generated by NAPI-RS */ +/* eslint-disable */ +/** + * Parsed file as a chunk tree: query nodes, render views, format grep hits, + * and apply edits. + */ +export declare class ChunkState { + /** + * Build chunk state by parsing `source` with the given `language` id (e.g. + * `typescript`). + */ + static parse(source: string, language: string): ChunkState + /** Normalized language identifier used for the tree-sitter parse. */ + get language(): string + /** Full source text for this file. */ + get source(): string + /** Stable checksum for the entire file contents. */ + get checksum(): string + /** Line count of the source buffer. */ + get lineCount(): number + /** Count of tree-sitter error nodes seen while building the tree. */ + get parseErrors(): number + /** True when a fallback classifier produced the tree. */ + get fallback(): boolean + /** Selector path string for the synthetic root (often empty). */ + get rootPath(): string + /** Top-level child chunk paths under the root. */ + get rootChildren(): Array + /** Total number of chunk nodes. */ + get chunkCount(): number + /** Summary for the root chunk, if it exists. */ + root(): ChunkInfo | null + /** Look up [`ChunkInfo`] for a chunk selector path. */ + chunk(chunkPath: string): ChunkInfo | null + /** Every chunk node as a [`ChunkInfo`] list. */ + chunks(): Array + /** + * Direct children of `chunkPath` (use empty or omit for root); errors if + * the path is missing. + */ + children(chunkPath?: string | undefined | null): Array + /** Chunk selector path that contains 1-based source line `line`, if any. */ + lineToContainingChunkPath(line: number): string | null + /** Render a chunk subtree or listing as UTF-8 text for tools. */ + render(params: RenderParams): string + /** + * Parse `readPath` (selector, line scope, etc.) and return rendered text or + * errors. + */ + renderRead(params: ReadRenderParams): ReadResult + /** + * Prefix a grep line with `display_path` and the chunk path for + * `line_number`, when known. + */ + formatGrepLine(displayPath: string, lineNumber: number, line: string): string + /** + * Apply batch edits, re-parse, write files, and return updated state and + * messaging. + */ + applyEdits(params: EditParams): EditResult +} + +/** + * Long-lived macOS appearance observer. + * + * Subscribes to `AppleInterfaceThemeChangedNotification` via + * `CFDistributedNotificationCenter` and calls the provided callback + * with `"dark"` or `"light"` on each change (and once on start). + * + * A 2-second polling timer also runs as fallback — distributed + * notifications may not reliably reach background threads on all + * macOS versions. + * + * On non-macOS platforms, `start()` returns a no-op observer. + */ +export declare class MacAppearanceObserver { + static start(callback: (err: null | Error, appearance: MacOSAppearance) => void): MacAppearanceObserver + stop(): void +} + +/** Image container for native interop. */ +export declare class PhotonImage { + /** + * Create a new `PhotonImage` from encoded image bytes (PNG, JPEG, WebP, + * GIF). Returns the decoded image handle on success. + * + * # Errors + * Returns an error if the image format cannot be detected or decoded. + */ + static parse(bytes: Uint8Array): ImageTask + /** Get the image width in pixels. */ + get width(): number + /** Get the image height in pixels. */ + get height(): number + /** + * Encode the image to bytes in the specified format. + * + * # Errors + * Returns an error if encoding fails or format is invalid. + */ + encode(format: ImageFormat, quality: number): Promise> + /** + * Resize the image to the specified pixel dimensions using the filter. + * Returns a new `PhotonImage` containing the resized image. + */ + resize(width: number, height: number, filter: SamplingFilter): ImageTask +} + +/** Stateful PTY session for interactive stdin/stdout passthrough. */ +export declare class PtySession { + constructor() + /** Start a PTY command and stream output chunks via callback. */ + start(options: PtyStartOptions, onChunk?: ((error: Error | null, chunk: string) => void) | undefined | null): Promise + /** Write raw input bytes to PTY stdin. */ + write(data: string): void + /** Resize the active PTY. */ + resize(cols: number, rows: number): void + /** Force-kill the active PTY command. */ + kill(): void +} + +/** + * Long-lived native search state: frecency persistence and per-workspace file + * picker caches. + */ +export declare class SearchDb { + /** + * Create search DB state rooted at `path` (trimmed). An empty path skips + * frecency storage. + */ + constructor(path: string) + /** + * Root path string associated with this instance (same as passed to the + * constructor). + */ + get path(): string +} + +/** Persistent brush-core shell session. */ +export declare class Shell { + /** + * Create a new shell session from optional configuration. + * + * The options set session-scoped environment variables and a snapshot path. + */ + constructor(options?: ShellOptions | undefined | null) + /** + * Run a shell command using the provided options. + * + * The `on_chunk` callback receives streamed stdout/stderr output. Returns + * the exit code when the command completes, or flags when cancelled or + * timed out. + */ + run(options: ShellRunOptions, onChunk?: ((error: Error | null, chunk: string) => void) | undefined | null): Promise + /** + * Abort all running commands for this shell session. + * + * Returns `Ok(())` even when no commands are running. + */ + abort(): Promise +} + +/** + * Apply ast-grep rewrite rules to matching files; honors `dryRun` and returns + * a promise. + */ +export declare function astEdit(options: AstReplaceOptions): Promise + +/** One ast-grep match with source range and optional meta-variables. */ +export interface AstFindMatch { + /** Display path of the matching file. */ + path: string + /** Matched source text. */ + text: string + /** Start byte offset in the file (UTF-8 byte index). */ + byteStart: number + /** End byte offset in the file (exclusive UTF-8 byte index). */ + byteEnd: number + /** 1-based start line. */ + startLine: number + /** 1-based start column. */ + startColumn: number + /** 1-based end line. */ + endLine: number + /** 1-based end column. */ + endColumn: number + /** Meta-variable name to captured text, when `includeMeta` was enabled. */ + metaVariables?: Record +} + +/** Options for `astGrep`: patterns, scan scope, and match limits. */ +export interface AstFindOptions { + /** ast-grep patterns to search for (OR across patterns). */ + patterns?: Array + /** Language override; otherwise inferred from file extension per candidate. */ + lang?: string + /** Single file or directory to scan (combined with `glob` when set). */ + path?: string + /** Optional glob filter relative to the search root. */ + glob?: string + /** Rule selector for multi-rule ast-grep configurations. */ + selector?: string + /** Pattern strictness; defaults to smart matching when omitted. */ + strictness?: AstMatchStrictness + /** Maximum matches to return after `offset` (default applies when omitted). */ + limit?: number + /** Number of leading matches to skip before applying `limit`. */ + offset?: number + /** When true, include meta-variable bindings per match. */ + includeMeta?: boolean + /** + * Reserved for contextual snippets; not used by the current native find + * path. + */ + context?: number + /** Optional cancellation handle (library-specific). */ + signal?: unknown + /** Wall-clock timeout for the worker task in milliseconds. */ + timeoutMs?: number +} + +/** Aggregated search statistics and any parse or compile diagnostics. */ +export interface AstFindResult { + /** Page of matches after sort, offset, and limit. */ + matches: Array + /** Total matches found before paging (can exceed `matches.length`). */ + totalMatches: number + /** Distinct files that contained at least one match. */ + filesWithMatches: number + /** Files examined for the query. */ + filesSearched: number + /** True when results were truncated by `limit`. */ + limitReached: boolean + /** Non-fatal parse or pattern errors collected during the run. */ + parseErrors?: Array +} + +/** + * Search source files with ast-grep patterns; returns a promise resolved on a + * worker thread. + */ +export declare function astGrep(options: AstFindOptions): Promise + +/** ast-grep pattern strictness (controls how patterns match syntax). */ +export declare enum AstMatchStrictness { + /** Match at the concrete syntax tree level. */ + Cst = 'cst', + /** Balanced default suitable for most searches. */ + Smart = 'smart', + /** Match at the AST level. */ + Ast = 'ast', + /** More permissive matching. */ + Relaxed = 'relaxed', + /** Match structural signatures. */ + Signature = 'signature', + /** Template-style pattern matching. */ + Template = 'template' +} + +/** + * One textual replacement applied to a file (before/after slice and + * coordinates). + */ +export interface AstReplaceChange { + /** File path for this change. */ + path: string + /** Original matched text. */ + before: string + /** Replacement text. */ + after: string + /** Start byte offset of the replaced span. */ + byteStart: number + /** End byte offset of the replaced span (exclusive). */ + byteEnd: number + /** + * Length of deleted text in bytes (may differ from `byteEnd - byteStart` + * for edge cases). + */ + deletedLength: number + /** 1-based start line of the match. */ + startLine: number + /** 1-based start column. */ + startColumn: number + /** 1-based end line. */ + endLine: number + /** 1-based end column. */ + endColumn: number +} + +/** Per-file replacement count after an `astEdit` run. */ +export interface AstReplaceFileChange { + /** File that had replacements. */ + path: string + /** Number of replacements in that file. */ + count: number +} + +/** + * Options for `astEdit`: rewrite rules, scan scope, safety limits, and + * dry-run. + */ +export interface AstReplaceOptions { + /** Map of pattern string to replacement template. */ + rewrites?: Record + /** Language override; otherwise inferred from discovered files. */ + lang?: string + /** Single file or directory to rewrite. */ + path?: string + /** Optional glob filter within the search root. */ + glob?: string + /** Rule selector for multi-rule configurations. */ + selector?: string + /** Pattern strictness for rewrites. */ + strictness?: AstMatchStrictness + /** When true (default), compute changes without writing files. */ + dryRun?: boolean + /** Cap on replacement applications across all files. */ + maxReplacements?: number + /** Cap on distinct files that may be modified. */ + maxFiles?: number + /** Fail the operation when a file cannot be parsed for rewriting. */ + failOnParseError?: boolean + /** Optional cancellation handle. */ + signal?: unknown + /** Wall-clock timeout for the worker task in milliseconds. */ + timeoutMs?: number +} + +/** Summary of an ast-grep rewrite pass, including whether disk writes occurred. */ +export interface AstReplaceResult { + /** Individual replacement records (may be large). */ + changes: Array + /** Replacement counts grouped by file. */ + fileChanges: Array + /** Total replacements applied or previewed. */ + totalReplacements: number + /** Files that had at least one replacement. */ + filesTouched: number + /** Files considered for rewriting. */ + filesSearched: number + /** False when `dryRun` prevented writing. */ + applied: boolean + /** True when limits stopped further replacements. */ + limitReached: boolean + /** Parse or pattern errors when not failing the whole operation. */ + parseErrors?: Array +} + +/** + * How chunk anchors are formatted in rendered output (name and checksum + * visibility). + */ +export declare enum ChunkAnchorStyle { + /** `[.name#crc]` style anchor. */ + Full = 'full', + /** `[.kind#crc]` style anchor (kind is the name prefix before `_`). */ + Kind = 'kind', + /** `[#crc]` style anchor. */ + Bare = 'bare', + /** `[.name]` without checksum. */ + FullOmit = 'full-omit', + /** `[.kind]` without checksum. */ + KindOmit = 'kind-omit', + /** Minimal anchor without name or checksum. */ + None = 'none' +} + +/** Structural edit to apply relative to a chunk anchor. */ +export declare enum ChunkEditOp { + /** Replace the chunk body, or a line range when `line`/`endLine` are set. */ + Replace = 'replace', + /** Remove the chunk's source range. */ + Delete = 'delete', + /** Insert `content` as the last child of the target chunk. */ + AppendChild = 'append_child', + /** Insert `content` as the first child of the target chunk. */ + PrependChild = 'prepend_child', + /** Insert `content` after the target chunk's source range. */ + AppendSibling = 'append_sibling', + /** Insert `content` before the target chunk's source range. */ + PrependSibling = 'prepend_sibling' +} + +/** Summary of a single chunk node for tool output and navigation. */ +export interface ChunkInfo { + /** Chunk selector path within the tree. */ + path: string + /** Short display name for the chunk (e.g. symbol or region label). */ + name: string + /** Stable checksum anchor for this chunk. */ + checksum: string + /** 1-based start line in the source file (inclusive). */ + startLine: number + /** 1-based end line in the source file (inclusive). */ + endLine: number + /** Whether this node is a leaf (no child chunks). */ + leaf: boolean +} + +/** Result of resolving a chunk read request against the tree. */ +export declare enum ChunkReadStatus { + /** Selector matched a chunk and content was produced. */ + Ok = 'ok', + /** No chunk matched the requested selector. */ + NotFound = 'not_found' +} + +/** Outcome of resolving which chunk was read for a `renderRead`-style request. */ +export interface ChunkReadTarget { + /** Whether the selector matched. */ + status: ChunkReadStatus + /** Sanitized selector string that was applied. */ + selector: string +} + +/** Clipboard image payload encoded as PNG bytes. */ +export interface ClipboardImage { + /** PNG-encoded image bytes. */ + data: Uint8Array + /** MIME type for the encoded image payload. */ + mimeType: string +} + +/** A context line (before or after a match). */ +export interface ContextLine { + /** 1-indexed line number in the source file. */ + lineNumber: number + /** Raw line content (trimmed line ending). */ + line: string +} + +/** + * Copy plain text to the system clipboard. + * + * # Parameters + * - `text`: UTF-8 text to place on the clipboard. + * + * # Errors + * Returns an error if clipboard access fails. + */ +export declare function copyToClipboard(text: string): void + +/** + * Detect macOS system appearance via CoreFoundation. + * Returns `"dark"` or `"light"` on macOS, `null` on other platforms. + */ +export declare function detectMacOSAppearance(): MacOSAppearance | null + +/** + * One edit in a batch; targets a chunk via `sel`/`crc` (with params-level + * defaults). + */ +export interface EditOperation { + /** Edit kind (replace, delete, insert relative to anchor). */ + op: ChunkEditOp + /** + * Chunk selector path; falls back to `EditParams.defaultSelector` when + * omitted. + */ + sel?: string + /** + * Optional checksum anchor; falls back to `EditParams.defaultCrc` when + * omitted. + */ + crc?: string + /** Replacement or inserted text (meaning depends on `op`). */ + content?: string + /** For line-scoped `replace`, 1-based start line inside the target chunk. */ + line?: number + /** + * For line-scoped `replace`, 1-based end line inside the chunk (defaults to + * `line`). + */ + endLine?: number +} + +/** Arguments for applying a batch of chunk edits to a file. */ +export interface EditParams { + /** Edits to apply in order (scheduling may reorder line-scoped groups). */ + operations: Array + /** Default chunk selector when an `EditOperation` omits `sel`. */ + defaultSelector?: string + /** Default checksum when an `EditOperation` omits `crc`. */ + defaultCrc?: string + /** Anchor formatting for rendered response text. */ + anchorStyle?: ChunkAnchorStyle + /** Working directory used to resolve `filePath` and display paths. */ + cwd: string + /** Path to the source file to edit (often relative to `cwd`). */ + filePath: string +} + +/** + * Result of applying edits: new parse state plus before/after source and + * messaging. + */ +export interface EditResult { + /** Chunk tree state after applying edits and re-parsing. */ + state: ChunkState + /** Full file text before edits. */ + diffBefore: string + /** Full file text after edits. */ + diffAfter: string + /** Rendered summary for tooling (hunks, anchors), driven by `anchorStyle`. */ + responseText: string + /** Whether the on-disk source changed. */ + changed: boolean + /** Whether the updated source re-parsed without fatal issues. */ + parseValid: boolean + /** Absolute or normalized paths that were written or touched. */ + touchedPaths: Array + /** Non-fatal issues (e.g. selector warnings) collected during apply. */ + warnings: Array +} + +/** Ellipsis strategy for [`truncate_to_width`]. */ +export declare enum Ellipsis { + /** Use a single Unicode ellipsis character ("…"). */ + Unicode = 0, + /** Use three ASCII dots ("..."). */ + Ascii = 1, + /** Omit ellipsis entirely. */ + Omit = 2 +} + +/** + * Encode image bytes into a SIXEL escape sequence for terminal rendering. + * + * The input image is decoded and resized to the requested pixel dimensions + * before encoding. + * + * # Errors + * Returns an error if decoding, resizing, or SIXEL encoding fails. + */ +export declare function encodeSixel(bytes: Uint8Array, targetWidthPx: number, targetHeightPx: number): string + +/** + * Execute a brush shell command. + * + * Creates a fresh session for each call. The `on_chunk` callback receives + * streamed stdout/stderr output. Returns the exit code when the command + * completes, or flags when cancelled or timed out. + */ +export declare function executeShell(options: ShellExecuteOptions, onChunk?: ((error: Error | null, chunk: string) => void) | undefined | null): Promise + +/** + * Extract the before/after slices around an overlay region. + * + * Preserves ANSI state so the `after` segment renders correctly after + * truncation. + */ +export declare function extractSegments(line: string, beforeEnd: number, afterStart: number, afterLen: number, strictAfter: boolean, tabWidth?: number | undefined | null): ExtractSegmentsResult + +/** Before/after UTF-16 segments around an overlay region, with measured widths. */ +export interface ExtractSegmentsResult { + /** UTF-16 content before the overlay region. */ + before: string + /** Visible width of the `before` segment. */ + beforeWidth: number + /** UTF-16 content after the overlay region. */ + after: string + /** Visible width of the `after` segment. */ + afterWidth: number +} + +/** Resolved filesystem entry kind for glob filters and match metadata. */ +export declare enum FileType { + /** Regular file. */ + File = 1, + /** Directory. */ + Dir = 2, + /** Symbolic link. */ + Symlink = 3 +} + +/** + * Format one chunk anchor string for a node at `depth` using `style` and + * optional checksum omission. + */ +export declare function formatAnchor(name: string, checksum: string, style: ChunkAnchorStyle, omitChecksum?: boolean | undefined | null): string + +/** + * Fuzzy file path search for autocomplete. + * + * # Arguments + * - `options`: Query string, root path, and limits. + * + * # Returns + * Matching file and directory entries sorted by match quality. + */ +export declare function fuzzyFind(options: FuzzyFindOptions, db?: SearchDb | undefined | null): Promise + +/** A single match in fuzzy find results. */ +export interface FuzzyFindMatch { + /** Relative path from the search root (uses `/` separators). */ + path: string + /** Whether this entry is a directory. */ + isDirectory: boolean + /** Match quality score (higher is better). */ + score: number +} + +/** Options for fuzzy file path search. */ +export interface FuzzyFindOptions { + /** Fuzzy query to match against file paths (case-insensitive). */ + query: string + /** Directory to search. */ + path: string + /** Include hidden files (default: false). */ + hidden?: boolean + /** Respect .gitignore (default: true). */ + gitignore?: boolean + /** Enable shared filesystem scan cache (default: false). */ + cache?: boolean + /** Maximum number of matches to return (default: 100). */ + maxResults?: number + /** Abort signal for cancelling the operation. */ + signal?: unknown + /** Timeout in milliseconds for the operation. */ + timeoutMs?: number +} + +/** Result of fuzzy file path search. */ +export interface FuzzyFindResult { + /** Matched entries (up to `maxResults`). */ + matches: Array + /** Total number of matches found (may exceed `matches.len()`). */ + totalMatches: number +} + +/** Get the default tab width for the process. */ +export declare function getDefaultTabWidth(): number + +/** Get the indentation for a file. */ +export declare function getIndentation(file?: string | undefined | null, projectDir?: string | undefined | null): number + +/** Get list of supported languages. */ +export declare function getSupportedLanguages(): Array + +/** + * Get work profile data from the last N seconds. + * + * Always-on profiling - no need to start/stop. Just call this to get + * recent activity. + */ +export declare function getWorkProfile(lastSeconds: number): WorkProfile + +/** + * Find filesystem entries matching a glob pattern. + * + * Resolves the search root, scans entries, applies glob and optional file-type + * filters, and optionally streams each accepted match through `on_match`. + * + * If `sortByMtime` is enabled, all matching entries are collected, sorted by + * descending mtime, then truncated to `maxResults`. + * + * # Errors + * Returns an error when the search path cannot be resolved, the path is not a + * directory, the glob pattern is invalid, or cancellation/timeout is + * triggered. + */ +export declare function glob(options: GlobOptions, onMatch?: ((error: Error | null, match: GlobMatch) => void) | undefined | null, db?: SearchDb | undefined | null): Promise + +/** A single filesystem entry from a directory scan. */ +export interface GlobMatch { + /** Relative path from the search root, using forward slashes. */ + path: string + /** Resolved filesystem type for the match. */ + fileType: FileType + /** + * Modification time in milliseconds since Unix epoch (from + * `symlink_metadata`). + */ + mtime?: number +} + +/** Input options for `glob`, including traversal, filtering, and cancellation. */ +export interface GlobOptions { + /** Glob pattern to match (e.g., "*.ts"). */ + pattern: string + /** Directory to search. */ + path: string + /** + * Filter by file type: "file", "dir", or "symlink". Symlinks are + * matched for file/dir filters based on their target type. + */ + fileType?: FileType + /** Match simple patterns recursively by default (`*.ts` -> recursive). */ + recursive?: boolean + /** Include hidden files (default: false). */ + hidden?: boolean + /** Maximum number of results to return. */ + maxResults?: number + /** Respect .gitignore files (default: true). */ + gitignore?: boolean + /** Enable shared filesystem scan cache (default: false). */ + cache?: boolean + /** Sort results by mtime (most recent first) before applying limit. */ + sortByMtime?: boolean + /** + * Include `node_modules` entries when the pattern does not explicitly + * mention them. + */ + includeNodeModules?: boolean + /** Abort signal for cancelling the operation. */ + signal?: unknown + /** Timeout in milliseconds for the operation. */ + timeoutMs?: number +} + +/** Result payload returned by a glob operation. */ +export interface GlobResult { + /** Matched filesystem entries. */ + matches: Array + /** Number of returned matches (`matches.len()`), clamped to `u32::MAX`. */ + totalMatches: number +} + +/** + * Search files for a regex pattern. + * + * # Arguments + * - `options`: Pattern, path, filters, and output mode. + * - `on_match`: Optional callback invoked per match/result. + * + * # Returns + * Aggregated results across matching files. + */ +export declare function grep(options: GrepOptions, onMatch?: ((error: Error | null, match: GrepMatch) => void) | undefined | null, db?: SearchDb | undefined | null): Promise + +/** A single match in a grep result. */ +export interface GrepMatch { + /** File path for the match (relative for directory searches). */ + path: string + /** 1-indexed line number (0 for count-only entries). */ + lineNumber: number + /** The matched line content (empty for count-only entries). */ + line: string + /** Context lines before the match. */ + contextBefore?: Array + /** Context lines after the match. */ + contextAfter?: Array + /** Whether the line was truncated. */ + truncated?: boolean + /** Per-file match count (count mode only). */ + matchCount?: number +} + +/** Options for searching files on disk. */ +export interface GrepOptions { + /** Regex pattern to search for. */ + pattern: string + /** Directory or file to search. */ + path: string + /** Glob filter for filenames (e.g., "*.ts"). */ + glob?: string + /** Filter by file type (e.g., "js", "py", "rust"). */ + type?: string + /** Case-insensitive search. */ + ignoreCase?: boolean + /** Enable multiline matching. */ + multiline?: boolean + /** Include hidden files (default: true). */ + hidden?: boolean + /** Respect .gitignore files (default: true). */ + gitignore?: boolean + /** Enable shared filesystem scan cache (default: false). */ + cache?: boolean + /** Maximum number of matches to return. */ + maxCount?: number + /** Skip first N matches. */ + offset?: number + /** Lines of context before matches. */ + contextBefore?: number + /** Lines of context after matches. */ + contextAfter?: number + /** Lines of context before/after matches (legacy). */ + context?: number + /** Truncate lines longer than this (characters). */ + maxColumns?: number + /** Output mode (content, filesWithMatches, or count). */ + mode?: GrepOutputMode + /** Abort signal for cancelling the operation. */ + signal?: unknown + /** Timeout in milliseconds for the operation. */ + timeoutMs?: number +} + +/** Output mode for [`search`] and [`grep`] (string values match JS callers). */ +export declare enum GrepOutputMode { + /** Emit matched lines (and optional context lines). */ + Content = 'content', + /** Emit per-file or total counts instead of line content. */ + Count = 'count', + /** Emit one row per file that matched, without line content. */ + FilesWithMatches = 'filesWithMatches' +} + +/** Result of searching files. */ +export interface GrepResult { + /** Matches or per-file counts, depending on output mode. */ + matches: Array + /** Total matches across all files. */ + totalMatches: number + /** Number of files with at least one match. */ + filesWithMatches: number + /** Number of files searched. */ + filesSearched: number + /** Whether the limit/offset stopped the search early. */ + limitReached?: boolean +} + +/** + * Quick check if content matches a pattern. + * + * # Arguments + * - `content`: `Uint8Array`/`Buffer` (zero-copy) or `string` (UTF-8). + * - `pattern`: `Uint8Array`/`Buffer` (zero-copy) or `string` (UTF-8). + * - `ignore_case`: Case-insensitive matching. + * - `multiline`: Enable multiline regex mode. + * + * # Returns + * True if any match exists; false on no match. + */ +export declare function hasMatch(content: string | Uint8Array, pattern: string | Uint8Array, ignoreCase?: boolean | undefined | null, multiline?: boolean | undefined | null): boolean + +/** + * Highlight code and return ANSI-colored lines. + * + * # Arguments + * * `code` - The source code to highlight + * * `lang` - Language identifier (e.g., "rust", "typescript", "python") + * * `colors` - Theme colors as ANSI escape sequences + * + * # Returns + * Highlighted code with ANSI color codes, or the original code if highlighting + * fails. + */ +export declare function highlightCode(code: string, lang: string | undefined | null, colors: HighlightColors): string + +/** + * Theme colors for syntax highlighting. + * Each color is an ANSI escape sequence (e.g., "\x1b[38;2;255;0;0m"). + */ +export interface HighlightColors { + /** ANSI color for comments. */ + comment: string + /** ANSI color for keywords. */ + keyword: string + /** ANSI color for function names. */ + function: string + /** ANSI color for variables and identifiers. */ + variable: string + /** ANSI color for string literals. */ + string: string + /** ANSI color for numeric literals. */ + number: string + /** ANSI color for type identifiers. */ + type: string + /** ANSI color for operators. */ + operator: string + /** ANSI color for punctuation tokens. */ + punctuation: string + /** ANSI color for diff inserted lines. */ + inserted?: string + /** ANSI color for diff deleted lines. */ + deleted?: string +} + +/** + * Convert HTML source to Markdown with optional preprocessing. + * + * # Errors + * Returns an error if the conversion fails or the worker task aborts. + */ +export declare function htmlToMarkdown(html: string, options?: HtmlToMarkdownOptions | undefined | null): Promise + +/** Options for HTML to Markdown conversion. */ +export interface HtmlToMarkdownOptions { + /** Remove navigation elements, forms, headers, footers. */ + cleanContent?: boolean + /** Skip images during conversion. */ + skipImages?: boolean +} + +/** Output format for [`PhotonImage::encode`]. */ +export declare enum ImageFormat { + /** PNG encoded bytes. */ + PNG = 0, + /** JPEG encoded bytes. */ + JPEG = 1, + /** WebP encoded bytes. */ + WEBP = 2, + /** GIF encoded bytes. */ + GIF = 3 +} + +/** + * Invalidate the filesystem scan cache. + * + * When called with a path, removes entries for roots containing that path. + * When called without a path, clears the entire cache. + * + * Intended to be called after agent file mutations (write, edit, rename, + * delete). + */ +export declare function invalidateFsScanCache(path?: string | undefined | null): void + +/** Event types from Kitty keyboard protocol (flag 2). */ +export declare enum KeyEventType { + /** Key press event. */ + Press = 1, + /** Key repeat event. */ + Repeat = 2, + /** Key release event. */ + Release = 3 +} + +/** + * Kill a process tree (the process and all its descendants). + * + * Arguments: `pid` is the root process and `signal` is the kill signal. + * Kills children first (bottom-up) to prevent orphan re-parenting issues. + * Returns the number of processes successfully killed. + */ +export declare function killTree(pid: number, signal: number): number + +/** + * List all descendant PIDs of `pid`. + * + * Returns an empty array if the process has no children or doesn't exist. + */ +export declare function listDescendants(pid: number): Array + +/** + * System UI appearance reported by native macOS APIs (`detectMacOSAppearance` + * and observer). + */ +export declare enum MacOSAppearance { + /** Dark color scheme. */ + Dark = 'dark', + /** Light color scheme. */ + Light = 'light' +} + +/** A single match in the content. */ +export interface Match { + /** 1-indexed line number. */ + lineNumber: number + /** The matched line content. */ + line: string + /** Context lines before the match. */ + contextBefore?: Array + /** Context lines after the match. */ + contextAfter?: Array + /** Whether the line was truncated. */ + truncated?: boolean +} + +/** + * Match input data against a key identifier string. + * + * Returns true when the bytes represent the specified key with modifiers. + */ +export declare function matchesKey(data: string, keyId: string, kittyProtocolActive: boolean): boolean + +/** + * Match Kitty protocol input against a codepoint and modifier mask. + * + * Returns true when the parsed sequence matches the expected codepoint (or + * base layout key) and modifier bits. + */ +export declare function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean + +/** + * Check if input matches a legacy escape sequence for the given key name. + * + * Returns true only when the byte sequence maps to the exact key identifier. + */ +export declare function matchesLegacySequence(data: string, keyName: string): boolean + +/** Parsed Kitty keyboard protocol sequence result for a Kitty input sequence. */ +export interface ParsedKittyResult { + /** Primary codepoint associated with the key. */ + codepoint: number + /** Optional shifted key codepoint from the sequence. */ + shiftedKey?: number + /** Optional base layout key codepoint from the sequence. */ + baseLayoutKey?: number + /** Modifier bitmask (shift/alt/ctrl), excluding lock bits. */ + modifier: number + /** Optional event type (1 = press, 2 = repeat, 3 = release). */ + eventType?: KeyEventType +} + +/** + * Parse terminal input and return a normalized key identifier. + * + * Returns a key id like "escape" or "ctrl+c", or None if unrecognized. + */ +export declare function parseKey(data: string, kittyProtocolActive: boolean): string | null + +/** + * Parse a Kitty keyboard protocol sequence. + * + * Returns a structured parse result when the input is a valid Kitty sequence. + */ +export declare function parseKittySequence(data: string): ParsedKittyResult | null + +/** Probe whether `ProjFS` overlay virtualization can be started on this system. */ +export declare function projfsOverlayProbe(): ProjfsOverlayProbeResult + +/** + * Result of probing Windows Projected File System (`ProjFS`) support for + * overlay workflows. + */ +export interface ProjfsOverlayProbeResult { + /** True when `ProjFS` APIs are available and loaded. */ + available: boolean + /** + * Human-readable reason when `available` is false (e.g. wrong OS or missing + * DLL). + */ + reason?: string +} + +/** + * Start a `ProjFS` overlay: `projection_root` shows the merged view; + * `lower_root` is the backing tree. + */ +export declare function projfsOverlayStart(lowerRoot: string, projectionRoot: string): void + +/** Stop `ProjFS` virtualization for an active `projection_root` session. */ +export declare function projfsOverlayStop(projectionRoot: string): void + +/** Result of a PTY command run. */ +export interface PtyRunResult { + /** Exit code when the command completes. */ + exitCode?: number + /** Whether command was cancelled by signal/user kill. */ + cancelled: boolean + /** Whether command timed out. */ + timedOut: boolean +} + +/** Options for running a command in a PTY session. */ +export interface PtyStartOptions { + /** Command string to execute. */ + command: string + /** Working directory for command execution. */ + cwd?: string + /** Environment variables for this command. */ + env?: Record + /** Timeout in milliseconds before cancelling. */ + timeoutMs?: number + /** Abort signal for cancelling the operation. */ + signal?: unknown + /** PTY column count. */ + cols?: number + /** PTY row count. */ + rows?: number +} + +/** + * Read an image from the system clipboard. + * + * Returns `Ok(None)` when no image data is available. + * + * # Errors + * Returns an error if clipboard access fails or image encoding fails. + */ +export declare function readImageFromClipboard(): Promise + +/** + * Options for `ChunkState.renderRead`: selector path, display path, and + * optional line scoping. + */ +export interface ReadRenderParams { + /** Read selector (`sel=...` path, line range, or empty for whole tree). */ + readPath: string + /** Path shown in titles and error messages (often the file path). */ + displayPath: string + /** Optional language label for the rendered block. */ + languageTag?: string + /** Hide checksums in rendered anchors. */ + omitChecksum: boolean + /** Anchor formatting style. */ + anchorStyle?: ChunkAnchorStyle + /** Optional absolute file line range to intersect with the resolved chunk. */ + absoluteLineRange?: VisibleLineRange + /** Replace tabs in embedded previews. */ + tabReplacement?: string +} + +/** Rendered chunk text plus optional resolution metadata for the read request. */ +export interface ReadResult { + /** Rendered UTF-8 text (chunk tree, notice, or error message). */ + text: string + /** When a selector was used, whether it matched and which selector applied. */ + chunk?: ChunkReadTarget +} + +/** + * Options for `ChunkState.render`: which subtree to show and how anchors + * appear. + */ +export interface RenderParams { + /** Path of the chunk to render; `None` uses the tree root. */ + chunkPath?: string + /** Title line shown above the tree (often the file path). */ + title: string + /** Optional language label for the header block. */ + languageTag?: string + /** Restrict output to an inclusive line range of the file. */ + visibleRange?: VisibleLineRange + /** When true, list only direct children instead of a full subtree. */ + renderChildrenOnly: boolean + /** Hide checksums in anchors when true. */ + omitChecksum: boolean + /** Anchor formatting style for chunk headers. */ + anchorStyle?: ChunkAnchorStyle + /** Include a one-line preview for leaf chunks. */ + showLeafPreview: boolean + /** Replace tab characters in displayed previews (e.g. two spaces). */ + tabReplacement?: string +} + +/** Sampling filter for resize operations. */ +export declare enum SamplingFilter { + /** Nearest-neighbor sampling (fast, low quality). */ + Nearest = 1, + /** Triangle filter (linear interpolation). */ + Triangle = 2, + /** Catmull-Rom filter with sharper edges. */ + CatmullRom = 3, + /** Gaussian filter for smoother results. */ + Gaussian = 4, + /** Lanczos3 filter for high-quality downscaling. */ + Lanczos3 = 5 +} + +/** + * Strip ANSI escape sequences, remove control characters / lone surrogates, + * and normalize line endings. + */ +export declare function sanitizeText(text: string): string + +/** + * Search content for a pattern (one-shot, compiles pattern each time). + * For repeated searches with the same pattern, use [`grep`] with file filters. + * + * # Arguments + * - `content`: `Uint8Array`/`Buffer` (zero-copy) or `string` (UTF-8). + * - `options`: Regex settings, context, and output mode. + * + * # Returns + * Match list plus counts/limit status; errors are surfaced in `error`. + */ +export declare function search(content: string | Uint8Array, options: SearchOptions): SearchResult + +/** Options for searching file content. */ +export interface SearchOptions { + /** Regex pattern to search for. */ + pattern: string + /** Case-insensitive search. */ + ignoreCase?: boolean + /** Enable multiline matching. */ + multiline?: boolean + /** Maximum number of matches to return. */ + maxCount?: number + /** Skip first N matches. */ + offset?: number + /** Lines of context before matches. */ + contextBefore?: number + /** Lines of context after matches. */ + contextAfter?: number + /** Lines of context before/after matches (legacy). */ + context?: number + /** Truncate lines longer than this (characters). */ + maxColumns?: number + /** Output mode (content or count). */ + mode?: GrepOutputMode +} + +/** Result of searching content. */ +export interface SearchResult { + /** All matches found. */ + matches: Array + /** Total number of matches (may exceed `matches.len()` due to offset/limit). */ + matchCount: number + /** Whether the limit was reached. */ + limitReached: boolean + /** Error message, if any. */ + error?: string +} + +/** Set the default tab width for the process. */ +export declare function setDefaultTabWidth(width: number): void + +/** Options for executing a shell command via brush-core. */ +export interface ShellExecuteOptions { + /** Command string to execute in the shell. */ + command: string + /** Working directory for the command. */ + cwd?: string + /** Environment variables to apply for this command only. */ + env?: Record + /** Environment variables to apply once per session. */ + sessionEnv?: Record + /** Timeout in milliseconds before cancelling the command. */ + timeoutMs?: number + /** Optional snapshot file to source on session creation. */ + snapshotPath?: string + /** Abort signal for cancelling the operation. */ + signal?: unknown +} + +/** Result of executing a shell command via brush-core. */ +export interface ShellExecuteResult { + /** Exit code when the command completes normally. */ + exitCode?: number + /** Whether the command was cancelled via abort. */ + cancelled: boolean + /** Whether the command timed out before completion. */ + timedOut: boolean +} + +/** Options for configuring a persistent shell session. */ +export interface ShellOptions { + /** Environment variables to apply once per session. */ + sessionEnv?: Record + /** Optional snapshot file to source on session creation. */ + snapshotPath?: string +} + +/** Options for running a shell command. */ +export interface ShellRunOptions { + /** Command string to execute in the shell. */ + command: string + /** Working directory for the command. */ + cwd?: string + /** Environment variables to apply for this command only. */ + env?: Record + /** Timeout in milliseconds before cancelling the command. */ + timeoutMs?: number + /** Abort signal for cancelling the operation. */ + signal?: unknown +} + +/** Result of running a shell command. */ +export interface ShellRunResult { + /** Exit code when the command completes normally. */ + exitCode?: number + /** Whether the command was cancelled via abort. */ + cancelled: boolean + /** Whether the command timed out before completion. */ + timedOut: boolean +} + +/** + * Visible slice of a line after ANSI-aware column selection + * (`sliceWithWidth`). + */ +export interface SliceResult { + /** UTF-16 slice containing the selected text. */ + text: string + /** Visible width of the slice in terminal cells. */ + width: number +} + +/** + * Slice a range of visible columns from a line. + * + * Counts terminal cells, skipping ANSI escapes, and optionally enforces strict + * width. + */ +export declare function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | undefined | null, tabWidth?: number | undefined | null): SliceResult + +/** + * Check if a language is supported for highlighting. + * Returns true if the language has either direct support or a fallback + * mapping. + */ +export declare function supportsLanguage(lang: string): boolean + +/** + * Truncate text to a visible width, preserving ANSI codes. + * + * Pads with spaces when requested. + */ +export declare function truncateToWidth(text: string, maxWidth: number, ellipsisKind?: Ellipsis | undefined | null, pad?: boolean | undefined | null, tabWidth?: number | undefined | null): string + +/** + * Inclusive 1-based line range within a source file (used for scoped chunk + * rendering). + */ +export interface VisibleLineRange { + /** First line to include. */ + startLine: number + /** Last line to include. */ + endLine: number +} + +/** + * Calculate visible width of text, excluding ANSI escape sequences. + * + * Tabs count as a fixed-width cell. + */ +export declare function visibleWidth(text: string, tabWidth?: number | undefined | null): number + +/** Profiling results returned to JavaScript. */ +export interface WorkProfile { + /** Folded stack format for flamegraph tools. */ + folded: string + /** Markdown summary of profiling results. */ + summary: string + /** SVG flamegraph (if generation succeeded). */ + svg?: string + /** Total profiled duration in milliseconds. */ + totalMs: number + /** Number of samples collected. */ + sampleCount: number +} + +/** + * Wrap text to a visible width, preserving ANSI escape codes across line + * breaks. + * + * Returns UTF-16 lines with active SGR codes carried across line boundaries. + */ +export declare function wrapTextWithAnsi(text: string, width: number, tabWidth?: number | undefined | null): Array diff --git a/packages/natives/src/native.ts b/packages/natives/native/index.js similarity index 68% rename from packages/natives/src/native.ts rename to packages/natives/native/index.js index f1ef2bf77..553d793a0 100644 --- a/packages/natives/src/native.ts +++ b/packages/natives/native/index.js @@ -1,92 +1,43 @@ /** * Native addon loader and bindings. * - * Each module extends NativeBindings via declaration merging in its types.ts. + * Types are auto-generated by napi-rs in `native/index.d.ts`. */ -import * as fs from "node:fs"; -import { createRequire } from "node:module"; -import * as os from "node:os"; -import * as path from "node:path"; -import { $env, getNativesDir, logger } from "@oh-my-pi/pi-utils"; -import packageJson from "../package.json"; -import type { NativeBindings } from "./bindings"; -import { embeddedAddon } from "./embedded-addon"; -import "./appearance/types"; -import "./ast/types"; -import "./clipboard/types"; -import "./chunk/types"; -import "./glob/types"; -import "./grep/types"; -import "./highlight/types"; -import "./html/types"; -import "./image/types"; -import "./keys/types"; -import "./ps/types"; -import "./projfs/types"; -import "./pty/types"; -import "./shell/types"; -import "./text/types"; -import "./work/types"; +const fs = require("node:fs"); +const { createRequire } = require("node:module"); +const os = require("node:os"); +const path = require("node:path"); +const { getNativesDir, logger } = require("@oh-my-pi/pi-utils"); +const packageJson = require("../package.json"); +const { embeddedAddon } = require("./embedded-addon"); -export type { NativeBindings, TsFunc } from "./bindings"; +// NOTE: TypeScript types are omitted in JS version -type CpuVariant = "modern" | "baseline"; -const require = createRequire(import.meta.url); +const require_ = createRequire(__filename); const platformTag = `${process.platform}-${process.arch}`; -const packageVersion = (packageJson as { version: string }).version; -const nativeDir = path.join(import.meta.dir, "..", "native"); +const packageVersion = packageJson.version; +const nativeDir = path.join(__dirname, "..", "native"); const execDir = path.dirname(process.execPath); const versionedDir = path.join(getNativesDir(), packageVersion); const userDataDir = process.platform === "win32" - ? path.join(Bun.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "omp") + ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "omp") : path.join(os.homedir(), ".local", "bin"); const isCompiledBinary = - Bun.env.PI_COMPILED || - import.meta.url.includes("$bunfs") || - import.meta.url.includes("~BUN") || - import.meta.url.includes("%7EBUN"); + process.env.PI_COMPILED || + __filename.includes("$bunfs") || + __filename.includes("~BUN") || + __filename.includes("%7EBUN"); const SUPPORTED_PLATFORMS = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64"]; -const variantOverride = getVariantOverride(); -const selectedVariant = resolveCpuVariant(variantOverride); -const addonFilenames = getAddonFilenames(platformTag, selectedVariant); -const addonLabel = selectedVariant ? `${platformTag} (${selectedVariant})` : platformTag; - -const debugCandidates = [path.join(nativeDir, "pi_natives.dev.node"), path.join(execDir, "pi_natives.dev.node")]; -const baseReleaseCandidates = addonFilenames.flatMap(filename => [ - path.join(nativeDir, filename), - path.join(execDir, filename), -]); -const compiledCandidates = addonFilenames.flatMap(filename => [ - path.join(versionedDir, filename), - path.join(userDataDir, filename), -]); -const releaseCandidates = isCompiledBinary ? [...compiledCandidates, ...baseReleaseCandidates] : baseReleaseCandidates; -const candidates = $env.PI_DEV ? [...debugCandidates, ...releaseCandidates] : releaseCandidates; -const dedupedCandidates = [...new Set(candidates)]; - -function runCommand(command: string, args: string[]): string | null { - const cmdLine = `${command} '${args.join(" ")}'`; - return logger.time(`runCommand:${cmdLine}`, () => { - try { - const result = Bun.spawnSync([command, ...args], { stdout: "pipe", stderr: "pipe" }); - if (result.exitCode !== 0) return null; - return result.stdout.toString("utf-8").trim(); - } catch { - return null; - } - }); -} - -function getVariantOverride(): CpuVariant | null { - const value = Bun.env.PI_NATIVE_VARIANT; +function getVariantOverride() { + const value = process.env.PI_NATIVE_VARIANT; if (!value) return null; if (value === "modern" || value === "baseline") return value; return null; } -function detectAvx2Support(): boolean { +function detectAvx2Support() { if (process.arch !== "x64") { return false; } @@ -116,19 +67,19 @@ function detectAvx2Support(): boolean { "-Command", "[System.Runtime.Intrinsics.X86.Avx2]::IsSupported", ]); - return output?.toLowerCase() === "true"; + return output && output.toLowerCase() === "true"; } return false; } -function resolveCpuVariant(override: CpuVariant | null): CpuVariant | null { +function resolveCpuVariant(override) { if (process.arch !== "x64") return null; if (override) return override; return logger.time("native:detectAvx2Support", () => detectAvx2Support()) ? "modern" : "baseline"; } -function getAddonFilenames(tag: string, variant: CpuVariant | null): string[] { +function getAddonFilenames(tag, variant) { const defaultFilename = `pi_natives.${tag}.node`; if (process.arch !== "x64" || !variant) return [defaultFilename]; const baselineFilename = `pi_natives.${tag}-baseline.node`; @@ -139,20 +90,54 @@ function getAddonFilenames(tag: string, variant: CpuVariant | null): string[] { return [baselineFilename, defaultFilename]; } -function selectEmbeddedAddonFile(): { filename: string; filePath: string } | null { +const variantOverride = getVariantOverride(); +const selectedVariant = resolveCpuVariant(variantOverride); +const addonFilenames = getAddonFilenames(platformTag, selectedVariant); +const addonLabel = selectedVariant ? `${platformTag} (${selectedVariant})` : platformTag; + +const debugCandidates = [path.join(nativeDir, "pi_natives.dev.node"), path.join(execDir, "pi_natives.dev.node")]; +const baseReleaseCandidates = addonFilenames.flatMap(filename => [ + path.join(nativeDir, filename), + path.join(execDir, filename), +]); +const compiledCandidates = addonFilenames.flatMap(filename => [ + path.join(versionedDir, filename), + path.join(userDataDir, filename), +]); +const releaseCandidates = isCompiledBinary ? [...compiledCandidates, ...baseReleaseCandidates] : baseReleaseCandidates; +const candidates = process.env.PI_DEV ? [...debugCandidates, ...releaseCandidates] : releaseCandidates; +const dedupedCandidates = [...new Set(candidates)]; + +function runCommand(command, args) { + const cmdLine = `${command} '${args.join(" ")}'`; + return logger.time(`runCommand:${cmdLine}`, () => { + try { + const { spawnSync } = require("child_process"); + const result = spawnSync(command, args, { encoding: "utf-8" }); + if (result.error) return null; + if (result.status !== 0) return null; + return (result.stdout || "").trim(); + } catch { + return null; + } + }); +} + +function selectEmbeddedAddonFile() { if (!embeddedAddon) return null; - const defaultFile = embeddedAddon.files.find(file => file.variant === "default") ?? null; - if (process.arch !== "x64") return defaultFile ?? embeddedAddon.files[0] ?? null; + const defaultFile = embeddedAddon.files.find(file => file.variant === "default") || null; + if (process.arch !== "x64") return defaultFile || embeddedAddon.files[0] || null; if (selectedVariant === "modern") { return ( - embeddedAddon.files.find(file => file.variant === "modern") ?? - embeddedAddon.files.find(file => file.variant === "baseline") ?? + embeddedAddon.files.find(file => file.variant === "modern") || + embeddedAddon.files.find(file => file.variant === "baseline") || null ); } - return embeddedAddon.files.find(file => file.variant === "baseline") ?? null; + return embeddedAddon.files.find(file => file.variant === "baseline") || null; } -function maybeExtractEmbeddedAddon(errors: string[]): string | null { + +function maybeExtractEmbeddedAddon(errors) { if (!isCompiledBinary || !embeddedAddon) return null; if (embeddedAddon.platformTag !== platformTag || embeddedAddon.version !== packageVersion) return null; @@ -182,22 +167,23 @@ function maybeExtractEmbeddedAddon(errors: string[]): string | null { return null; } } -function loadNative(): NativeBindings { - const errors: string[] = []; + +function loadNative() { + const errors = []; const embeddedCandidate = logger.time("native:maybeExtractEmbeddedAddon", () => maybeExtractEmbeddedAddon(errors)); const runtimeCandidates = embeddedCandidate ? [embeddedCandidate, ...dedupedCandidates] : dedupedCandidates; for (const candidate of runtimeCandidates) { try { const bindings = logger.time(`native:loadNative:require:${path.basename(candidate)}`, () => - require(candidate), - ) as NativeBindings; + require_(candidate), + ); validateNative(bindings, candidate); - if ($env.PI_DEV) { + if (process.env.PI_DEV) { console.log(`Loaded native addon from ${candidate}`); } return bindings; } catch (err) { - if ($env.PI_DEV) { + if (process.env.PI_DEV) { console.error(`Error loading native addon from ${candidate}:`, err); } const message = err instanceof Error ? err.message : String(err); @@ -213,7 +199,7 @@ function loadNative(): NativeBindings { ); } const details = errors.map(error => `- ${error}`).join("\n"); - let helpMessage: string; + let helpMessage; if (isCompiledBinary) { const expectedPaths = addonFilenames.map(filename => ` ${path.join(versionedDir, filename)}`).join("\n"); const downloadHints = addonFilenames @@ -235,20 +221,18 @@ function loadNative(): NativeBindings { throw new Error(`Failed to load pi_natives native addon for ${addonLabel}.\n\nTried:\n${details}\n\n${helpMessage}`); } -function validateNative(bindings: NativeBindings, source: string): void { - const missing: string[] = []; - const checkFn = (name: keyof NativeBindings) => { + +function validateNative(bindings, source) { + const missing = []; + const checkFn = name => { if (typeof bindings[name] !== "function") { missing.push(name); } }; checkFn("copyToClipboard"); checkFn("readImageFromClipboard"); - checkFn("parseChunkTree"); - checkFn("resolveChunkPath"); - checkFn("lineToChunkPath"); - checkFn("lineToContainingChunkPath"); - checkFn("renderChunkTree"); + checkFn("ChunkState"); + checkFn("formatAnchor"); checkFn("glob"); checkFn("fuzzyFind"); checkFn("grep"); @@ -291,4 +275,77 @@ function validateNative(bindings: NativeBindings, source: string): void { ); } } -export const native = logger.time("native:loadNative", () => loadNative()); + +module.exports = logger.time("native:loadNative", () => loadNative()); + +// Side effect: register native killTree with pi-utils. +const { setNativeKillTree } = require("@oh-my-pi/pi-utils"); +setNativeKillTree(module.exports.killTree); + +// --- generated const enum exports (do not edit) --- +exports.AstMatchStrictness = { + Cst: 'cst', + Smart: 'smart', + Ast: 'ast', + Relaxed: 'relaxed', + Signature: 'signature', + Template: 'template', +}; +exports.ChunkAnchorStyle = { + Full: 'full', + Kind: 'kind', + Bare: 'bare', + FullOmit: 'full-omit', + KindOmit: 'kind-omit', + None: 'none', +}; +exports.ChunkEditOp = { + Replace: 'replace', + Delete: 'delete', + AppendChild: 'append_child', + PrependChild: 'prepend_child', + AppendSibling: 'append_sibling', + PrependSibling: 'prepend_sibling', +}; +exports.ChunkReadStatus = { + Ok: 'ok', + NotFound: 'not_found', +}; +exports.Ellipsis = { + Unicode: 0, + Ascii: 1, + Omit: 2, +}; +exports.FileType = { + File: 1, + Dir: 2, + Symlink: 3, +}; +exports.GrepOutputMode = { + Content: 'content', + Count: 'count', + FilesWithMatches: 'filesWithMatches', +}; +exports.ImageFormat = { + PNG: 0, + JPEG: 1, + WEBP: 2, + GIF: 3, +}; +exports.KeyEventType = { + Press: 1, + Repeat: 2, + Release: 3, +}; +exports.MacOSAppearance = { + Dark: 'dark', + Light: 'light', +}; +exports.SamplingFilter = { + Nearest: 1, + Triangle: 2, + CatmullRom: 3, + Gaussian: 4, + Lanczos3: 5, +}; +// --- end generated const enum exports --- diff --git a/packages/natives/package.json b/packages/natives/package.json index 4e89a7fdc..52908afd1 100644 --- a/packages/natives/package.json +++ b/packages/natives/package.json @@ -26,8 +26,8 @@ "shell", "syntax-highlighting" ], - "main": "./src/index.ts", - "types": "./src/index.ts", + "main": "./native/index.js", + "types": "./native/index.d.ts", "scripts": { "build:native": "bun scripts/build-native.ts", "dev:native": "bun scripts/build-native.ts --dev", @@ -41,8 +41,13 @@ "@oh-my-pi/pi-utils": "workspace:*" }, "devDependencies": { + "@napi-rs/cli": "3.6.0", "@types/bun": "^1.3" }, + "napi": { + "binaryName": "pi_natives", + "triples": {} + }, "engines": { "bun": ">=1.3.7" }, @@ -50,143 +55,5 @@ "src", "native", "README.md" - ], - "exports": { - ".": { - "types": "./src/index.ts", - "import": "./src/index.ts" - }, - "./*": { - "types": "./src/*.ts", - "import": "./src/*.ts" - }, - "./appearance": { - "types": "./src/appearance/index.ts", - "import": "./src/appearance/index.ts" - }, - "./appearance/*": { - "types": "./src/appearance/*.ts", - "import": "./src/appearance/*.ts" - }, - "./ast": { - "types": "./src/ast/index.ts", - "import": "./src/ast/index.ts" - }, - "./ast/*": { - "types": "./src/ast/*.ts", - "import": "./src/ast/*.ts" - }, - "./clipboard": { - "types": "./src/clipboard/index.ts", - "import": "./src/clipboard/index.ts" - }, - "./clipboard/*": { - "types": "./src/clipboard/*.ts", - "import": "./src/clipboard/*.ts" - }, - "./chunk": { - "types": "./src/chunk/index.ts", - "import": "./src/chunk/index.ts" - }, - "./chunk/*": { - "types": "./src/chunk/*.ts", - "import": "./src/chunk/*.ts" - }, - "./glob": { - "types": "./src/glob/index.ts", - "import": "./src/glob/index.ts" - }, - "./glob/*": { - "types": "./src/glob/*.ts", - "import": "./src/glob/*.ts" - }, - "./grep": { - "types": "./src/grep/index.ts", - "import": "./src/grep/index.ts" - }, - "./grep/*": { - "types": "./src/grep/*.ts", - "import": "./src/grep/*.ts" - }, - "./highlight": { - "types": "./src/highlight/index.ts", - "import": "./src/highlight/index.ts" - }, - "./highlight/*": { - "types": "./src/highlight/*.ts", - "import": "./src/highlight/*.ts" - }, - "./html": { - "types": "./src/html/index.ts", - "import": "./src/html/index.ts" - }, - "./html/*": { - "types": "./src/html/*.ts", - "import": "./src/html/*.ts" - }, - "./image": { - "types": "./src/image/index.ts", - "import": "./src/image/index.ts" - }, - "./image/*": { - "types": "./src/image/*.ts", - "import": "./src/image/*.ts" - }, - "./keys": { - "types": "./src/keys/index.ts", - "import": "./src/keys/index.ts" - }, - "./keys/*": { - "types": "./src/keys/*.ts", - "import": "./src/keys/*.ts" - }, - "./projfs": { - "types": "./src/projfs/index.ts", - "import": "./src/projfs/index.ts" - }, - "./projfs/*": { - "types": "./src/projfs/*.ts", - "import": "./src/projfs/*.ts" - }, - "./ps": { - "types": "./src/ps/index.ts", - "import": "./src/ps/index.ts" - }, - "./ps/*": { - "types": "./src/ps/*.ts", - "import": "./src/ps/*.ts" - }, - "./pty": { - "types": "./src/pty/index.ts", - "import": "./src/pty/index.ts" - }, - "./pty/*": { - "types": "./src/pty/*.ts", - "import": "./src/pty/*.ts" - }, - "./shell": { - "types": "./src/shell/index.ts", - "import": "./src/shell/index.ts" - }, - "./shell/*": { - "types": "./src/shell/*.ts", - "import": "./src/shell/*.ts" - }, - "./text": { - "types": "./src/text/index.ts", - "import": "./src/text/index.ts" - }, - "./text/*": { - "types": "./src/text/*.ts", - "import": "./src/text/*.ts" - }, - "./work": { - "types": "./src/work/index.ts", - "import": "./src/work/index.ts" - }, - "./work/*": { - "types": "./src/work/*.ts", - "import": "./src/work/*.ts" - } - } + ] } diff --git a/packages/natives/scripts/build-native.ts b/packages/natives/scripts/build-native.ts index b86b92701..764d32dc2 100644 --- a/packages/natives/scripts/build-native.ts +++ b/packages/natives/scripts/build-native.ts @@ -6,6 +6,7 @@ import { $ } from "bun"; const repoRoot = path.join(import.meta.dir, "../../.."); const rustDir = path.join(repoRoot, "crates/pi-natives"); const nativeDir = path.join(import.meta.dir, "../native"); +const packageJsonPath = path.join(import.meta.dir, "../package.json"); const isDev = process.argv.includes("--dev"); const crossTarget = Bun.env.CROSS_TARGET; @@ -138,65 +139,63 @@ async function installBinary(src: string, dest: string): Promise { const isCI = Boolean(Bun.env.CI); const useLocalProfile = !isDev && !isCI && !isCrossCompile; -const profile = isDev ? "dev" : useLocalProfile ? "local" : "release"; -const cargoArgs = ["build"]; -if (profile !== "dev") cargoArgs.push("--profile", profile); -if (crossTarget) cargoArgs.push("--target", crossTarget); +// Build napi args +const napiArgs = [ + "build", + "--manifest-path", + path.join(rustDir, "Cargo.toml"), + "--package-json-path", + packageJsonPath, + "--platform", + "--no-js", + "--dts", + "index.d.ts", + "-o", + nativeDir, +]; + +if (isDev) { + // napi build defaults to debug, no flag needed +} else if (useLocalProfile) { + napiArgs.push("--profile", "local"); +} else { + napiArgs.push("--release"); +} + +if (crossTarget) napiArgs.push("--target", crossTarget); const profileLabel = isDev ? " (debug)" : useLocalProfile ? " (local)" : ""; console.log(`Building pi-natives for ${targetPlatform}-${targetArch}${variantSuffix}${profileLabel}…`); -const buildResult = await $`cargo ${cargoArgs}`.cwd(rustDir).nothrow(); -if (buildResult.exitCode !== 0) { - const stderr = buildResult.stderr?.toString("utf-8") ?? ""; - throw new Error(`cargo build --release failed${stderr ? `:\n${stderr}` : ""}`); -} - -// Cargo outputs "local" profile artifacts into the "local" directory -const profileDir = isDev ? "debug" : useLocalProfile ? "local" : "release"; -const targetRoots = [ - Bun.env.CARGO_TARGET_DIR ? path.resolve(Bun.env.CARGO_TARGET_DIR) : undefined, - path.join(repoRoot, "target"), - path.join(rustDir, "target"), -].filter((v): v is string => Boolean(v)); - -const profileDirs = targetRoots.flatMap(root => { - if (crossTarget) { - return [path.join(root, crossTarget, profileDir), path.join(root, profileDir)]; - } - return [path.join(root, profileDir)]; -}); - -const libraryNames = ["libpi_natives.so", "libpi_natives.dylib", "pi_natives.dll", "libpi_natives.dll"]; - -let sourcePath: string | null = null; -for (const dir of profileDirs) { - for (const name of libraryNames) { - const fullPath = path.join(dir, name); - try { - await fs.stat(fullPath); - sourcePath = fullPath; - break; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; - } - } - if (sourcePath) break; -} await fs.mkdir(nativeDir, { recursive: true }); await cleanupStaleTemps(nativeDir); -if (!sourcePath) { - const checked = profileDirs.map(d => ` - ${d}`).join("\n"); - throw new Error(`Built library not found. Checked:\n${checked}`); +const buildResult = await $`bunx @napi-rs/cli ${napiArgs}`.nothrow(); +if (buildResult.exitCode !== 0) { + const stderr = buildResult.stderr?.toString("utf-8") ?? ""; + throw new Error(`napi build failed${stderr ? `:\n${stderr}` : ""}`); } -console.log(`Found: ${sourcePath}`); -const taggedPath = isDev - ? path.join(nativeDir, "pi_natives.dev.node") - : path.join(nativeDir, `pi_natives.${targetPlatform}-${targetArch}${variantSuffix}.node`); -console.log(`Installing: ${taggedPath}`); -await installBinary(sourcePath, taggedPath); +// napi build produces pi_natives.-.node — rename with variant suffix if needed +if (variantSuffix) { + const napiFilename = `pi_natives.${targetPlatform}-${targetArch}.node`; + const taggedFilename = `pi_natives.${targetPlatform}-${targetArch}${variantSuffix}.node`; + const napiPath = path.join(nativeDir, napiFilename); + const taggedPath = path.join(nativeDir, taggedFilename); + + try { + await fs.stat(napiPath); + console.log(`Renaming: ${napiFilename} → ${taggedFilename}`); + await installBinary(napiPath, taggedPath); + await fs.unlink(napiPath).catch(() => {}); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + // Already named correctly or handled by napi + } +} + +// Generate runtime enum exports from const enums in index.d.ts +await $`bun ${path.join(import.meta.dir, "gen-enums.ts")}`; console.log("Build complete."); diff --git a/packages/natives/scripts/embed-native.ts b/packages/natives/scripts/embed-native.ts index 3aadee759..a8779a2eb 100644 --- a/packages/natives/scripts/embed-native.ts +++ b/packages/natives/scripts/embed-native.ts @@ -2,25 +2,32 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; const reset = process.argv.includes("--reset"); -const outputPath = path.join(import.meta.dir, "../src/embedded-addon.ts"); +const outputPath = path.join(import.meta.dir, "../native/embedded-addon.js"); const packageJsonPath = path.join(import.meta.dir, "../package.json"); const nativeDir = path.join(import.meta.dir, "../native"); -const stubContent = `export type EmbeddedAddonVariant = "modern" | "baseline" | "default"; +const stubContent = ` +// AUTOGENERATED FILE -- DO NOT EDIT DIRECTLY +// See scripts/embed-native.ts -export interface EmbeddedAddonFile { - variant: EmbeddedAddonVariant; - filename: string; - filePath: string; -} +/** @typedef {"modern" | "baseline" | "default"} EmbeddedAddonVariant */ -export interface EmbeddedAddon { - platformTag: string; - version: string; - files: EmbeddedAddonFile[]; -} +/** + * @typedef {Object} EmbeddedAddonFile + * @property {EmbeddedAddonVariant} variant + * @property {string} filename + * @property {string} filePath + */ -export const embeddedAddon: EmbeddedAddon | null = null; +/** + * @typedef {Object} EmbeddedAddon + * @property {string} platformTag + * @property {string} version + * @property {EmbeddedAddonFile[]} files + */ + +/** @type {EmbeddedAddon|null} */ +export const embeddedAddon = null; `; if (reset) { await Bun.write(outputPath, stubContent); @@ -65,18 +72,50 @@ if (available.length === 0) { throw new Error(`No native addons found for ${platformTag}. Expected one of:\n${expected}`); } const packageJson = (await Bun.file(packageJsonPath).json()) as { version: string }; + const imports = available .map( (candidate, index) => `import addonPath${index} from ${JSON.stringify(`../native/${candidate.filename}`)} with { type: "file" };`, ) .join("\n"); + const files = available .map( (candidate, index) => - `\t{ variant: ${JSON.stringify(candidate.variant)}, filename: ${JSON.stringify(candidate.filename)}, filePath: addonPath${index} },`, + `\t\t{ variant: ${JSON.stringify(candidate.variant)}, filename: ${JSON.stringify(candidate.filename)}, filePath: addonPath${index} },`, ) .join("\n"); -const content = `${imports}\n\nexport type EmbeddedAddonVariant = "modern" | "baseline" | "default";\n\nexport interface EmbeddedAddonFile {\n\tvariant: EmbeddedAddonVariant;\n\tfilename: string;\n\tfilePath: string;\n}\n\nexport interface EmbeddedAddon {\n\tplatformTag: string;\n\tversion: string;\n\tfiles: EmbeddedAddonFile[];\n}\n\nexport const embeddedAddon: EmbeddedAddon | null = {\n\tplatformTag: ${JSON.stringify(platformTag)},\n\tversion: ${JSON.stringify(packageJson.version)},\n\tfiles: [\n${files}\n\t],\n};\n`; +const content = ` +// AUTOGENERATED FILE -- DO NOT EDIT DIRECTLY +// See scripts/embed-native.ts + +/** @typedef {"modern" | "baseline" | "default"} EmbeddedAddonVariant */ + +/** + * @typedef {Object} EmbeddedAddonFile + * @property {EmbeddedAddonVariant} variant + * @property {string} filename + * @property {string} filePath + */ + +/** + * @typedef {Object} EmbeddedAddon + * @property {string} platformTag + * @property {string} version + * @property {EmbeddedAddonFile[]} files + */ + +${imports} + +export const embeddedAddon = { +\tplatformTag: ${JSON.stringify(platformTag)}, +\tversion: ${JSON.stringify(packageJson.version)}, +\tfiles: [ +${files} +\t], +}; +`; + await Bun.write(outputPath, content); diff --git a/packages/natives/scripts/gen-enums.ts b/packages/natives/scripts/gen-enums.ts new file mode 100644 index 000000000..7d9498baa --- /dev/null +++ b/packages/natives/scripts/gen-enums.ts @@ -0,0 +1,76 @@ +/** + * Post-build script: reads the napi-rs generated `index.d.ts` and appends + * runtime enum objects to `index.js`. + * + * napi-rs `#[napi(string_enum)]` emits `const enum` in the .d.ts — a + * TypeScript-only construct with no JS runtime value. This script bridges + * the gap so consumers can use enum values at runtime. + * + * Run after `napi build`: `bun packages/natives/scripts/gen-enums.ts` + */ +import * as path from "node:path"; + +const nativeDir = path.resolve(import.meta.dir, "../native"); +const dtsPath = path.join(nativeDir, "index.d.ts"); +const jsPath = path.join(nativeDir, "index.js"); + +const dts = await Bun.file(dtsPath).text(); + +// Match each `export declare const enum Name { ... }` block. +// The closing `}` is matched only at line start (enum bodies are indented). +const CONST_ENUM_RE = /export declare const enum (\w+)\s*\{(.*?)\n\}/gs; +const enums: string[] = []; + +for (;;) { + const match = CONST_ENUM_RE.exec(dts); + if (match === null) break; + + const name = match[1]; + const body = match[2]; + const entries: string[] = []; + + for (const line of body!.split("\n")) { + const m = line.match(/^\s*(\w+)\s*=\s*'([^']*)'/) ?? line.match(/^\s*(\w+)\s*=\s*(\d+)/); + if (m) { + const value = m[2]!.match(/^\d+$/) ? m[2] : `'${m[2]}'`; + entries.push(` ${m[1]}: ${value},`); + } + } + + if (entries.length > 0) { + enums.push(`exports.${name} = {\n${entries.join("\n")}\n};`); + } +} + +if (enums.length === 0) { + console.error("No const enums found in index.d.ts — check napi build output"); + process.exit(1); +} + +// Read current index.js and replace the generated enum block (between markers) +const MARKER_START = "// --- generated const enum exports (do not edit) ---"; +const MARKER_END = "// --- end generated const enum exports ---"; +const enumBlock = `${MARKER_START}\n${enums.join("\n")}\n${MARKER_END}\n`; + +let js = await Bun.file(jsPath).text(); + +const startIdx = js.indexOf(MARKER_START); +const endIdx = js.indexOf(MARKER_END); + +if (startIdx !== -1 && endIdx !== -1) { + js = js.slice(0, startIdx) + enumBlock; +} else { + js = `${js.trimEnd()}\n\n${enumBlock}`; +} + +await Bun.write(jsPath, js); + +// Also fix the .d.ts: replace `const enum` with `enum` so TS allows +// assigning string literals to enum types without casts. +let dtsContent = await Bun.file(dtsPath).text(); +const constEnumCount = (dtsContent.match(/export declare const enum/g) || []).length; +dtsContent = dtsContent.replaceAll("export const enum", "export declare enum"); +dtsContent = dtsContent.replaceAll("export declare const enum", "export declare enum"); +await Bun.write(dtsPath, dtsContent); + +console.log(`Generated ${enums.length} enum exports in index.js, fixed ${constEnumCount} const enums in index.d.ts`); diff --git a/packages/natives/src/appearance/index.ts b/packages/natives/src/appearance/index.ts deleted file mode 100644 index b98cf842c..000000000 --- a/packages/natives/src/appearance/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * macOS appearance detection via native CoreFoundation APIs. - * - * This is a fallback capability for terminals whose own dark/light reporting is - * unavailable or known-broken. It reports the host macOS appearance, not the - * terminal profile colors. - */ - -import { native } from "../native"; - -export type { MacAppearanceObserver } from "./types"; - -/** - * Detect macOS system appearance. - * Returns `"dark"` or `"light"` on macOS, `undefined` on other platforms. - */ -export function detectMacOSAppearance(): "dark" | "light" | undefined { - const result = native.detectMacOSAppearance(); - if (result === "dark" || result === "light") return result; - return undefined; -} - -/** - * Start a long-lived macOS appearance observer. - * Calls `callback` with `"dark"` or `"light"` on each system appearance change - * (and once immediately on start). - * - * Returns an observer handle with a `stop()` method. - * On non-macOS platforms, returns a no-op observer. - */ -export function startMacAppearanceObserver(callback: (appearance: "dark" | "light") => void): { stop(): void } { - return native.MacAppearanceObserver.start((err, appearance) => { - if (!err && (appearance === "dark" || appearance === "light")) { - callback(appearance); - } - }); -} diff --git a/packages/natives/src/appearance/types.ts b/packages/natives/src/appearance/types.ts deleted file mode 100644 index e040bcfd1..000000000 --- a/packages/natives/src/appearance/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Types for macOS appearance detection. - */ - -/** - * Long-lived macOS appearance observer. - * Subscribes to system dark/light mode changes via CoreFoundation. - */ -export interface MacAppearanceObserver { - /** Stop observing and release resources. */ - stop(): void; -} - -declare module "../bindings" { - interface NativeBindings { - /** - * Detect macOS system appearance via CoreFoundation preferences. - * Returns `"dark"` or `"light"` on macOS, `null` on other platforms. - */ - detectMacOSAppearance(): string | null; - /** - * Internal constructor — use `startMacAppearanceObserver()` instead. - * @internal - */ - MacAppearanceObserver: { - start(callback: (err: Error | null, appearance: string) => void): MacAppearanceObserver; - }; - } -} diff --git a/packages/natives/src/ast/index.ts b/packages/natives/src/ast/index.ts deleted file mode 100644 index b058a581e..000000000 --- a/packages/natives/src/ast/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Native AST structural search and rewrite wrappers. - */ - -import { native } from "../native"; -import type { AstFindOptions, AstFindResult, AstReplaceOptions, AstReplaceResult } from "./types"; - -export type { - AstFindMatch, - AstFindOptions, - AstFindResult, - AstReplaceChange, - AstReplaceFileChange, - AstReplaceOptions, - AstReplaceResult, - AstStrictness, -} from "./types"; - -export async function astGrep(options: AstFindOptions): Promise { - return native.astGrep(options); -} - -export async function astEdit(options: AstReplaceOptions): Promise { - return native.astEdit(options); -} diff --git a/packages/natives/src/ast/types.ts b/packages/natives/src/ast/types.ts deleted file mode 100644 index 356fc0686..000000000 --- a/packages/natives/src/ast/types.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Types for native ast-grep operations. - */ - -import type { Cancellable } from "../bindings"; - -export type AstStrictness = "cst" | "smart" | "ast" | "relaxed" | "signature"; - -export interface AstFindOptions extends Cancellable { - patterns?: string[]; - lang?: string; - path?: string; - glob?: string; - selector?: string; - strictness?: AstStrictness; - limit?: number; - offset?: number; - includeMeta?: boolean; - context?: number; -} - -export interface AstFindMatch { - path: string; - text: string; - byteStart: number; - byteEnd: number; - startLine: number; - startColumn: number; - endLine: number; - endColumn: number; - metaVariables?: Record; -} - -export interface AstFindResult { - matches: AstFindMatch[]; - totalMatches: number; - filesWithMatches: number; - filesSearched: number; - limitReached: boolean; - parseErrors?: string[]; -} - -export interface AstReplaceOptions extends Cancellable { - rewrites?: Record; - lang?: string; - path?: string; - glob?: string; - selector?: string; - strictness?: AstStrictness; - dryRun?: boolean; - maxReplacements?: number; - maxFiles?: number; - failOnParseError?: boolean; -} - -export interface AstReplaceChange { - path: string; - before: string; - after: string; - byteStart: number; - byteEnd: number; - deletedLength: number; - startLine: number; - startColumn: number; - endLine: number; - endColumn: number; -} - -export interface AstReplaceFileChange { - path: string; - count: number; -} - -export interface AstReplaceResult { - changes: AstReplaceChange[]; - fileChanges: AstReplaceFileChange[]; - totalReplacements: number; - filesTouched: number; - filesSearched: number; - applied: boolean; - limitReached: boolean; - parseErrors?: string[]; -} - -declare module "../bindings" { - interface NativeBindings { - astGrep(options: AstFindOptions): Promise; - astEdit(options: AstReplaceOptions): Promise; - } -} diff --git a/packages/natives/src/bindings.ts b/packages/natives/src/bindings.ts deleted file mode 100644 index 12903781a..000000000 --- a/packages/natives/src/bindings.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Base types for native bindings. - * Modules extend this interface via declaration merging. - */ - -/** Callback type for threadsafe functions from N-API. */ -export type TsFunc = (error: Error | null, value: T) => void; - -/** Options for cancellable operations. */ -export interface Cancellable { - /** Timeout in milliseconds for the operation. */ - timeoutMs?: number; - /** Abort signal for cancelling the operation. */ - signal?: AbortSignal; -} - -/** - * Native bindings interface. - * Extended by each module via declaration merging. - */ -export interface NativeBindings { - cancelWork(id: number): void; -} diff --git a/packages/natives/src/chunk/index.ts b/packages/natives/src/chunk/index.ts deleted file mode 100644 index 7ec24c2ce..000000000 --- a/packages/natives/src/chunk/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { native } from "../native"; -import type { ChunkNode, ChunkTree, RenderChunkTreeParams } from "./types"; - -export type { ChunkAnchorStyle, ChunkNode, ChunkTree, RenderChunkTreeParams, VisibleLineRange } from "./types"; - -export function parseChunkTree(source: string, language: string): ChunkTree { - return native.parseChunkTree(source, language); -} - -export function resolveChunkPath(tree: ChunkTree, chunkPath: string): ChunkNode | undefined { - return native.resolveChunkPath(tree, chunkPath); -} - -export function lineToChunkPath(tree: ChunkTree, line: number): string | undefined { - return native.lineToChunkPath(tree, line); -} - -export function lineToContainingChunkPath(tree: ChunkTree, line: number): string | undefined { - return native.lineToContainingChunkPath(tree, line); -} - -export function renderChunkTree(params: RenderChunkTreeParams): string { - return native.renderChunkTree(params); -} diff --git a/packages/natives/src/chunk/types.ts b/packages/natives/src/chunk/types.ts deleted file mode 100644 index e207bd72e..000000000 --- a/packages/natives/src/chunk/types.ts +++ /dev/null @@ -1,60 +0,0 @@ -export interface ChunkNode { - path: string; - name: string; - kind: "branch" | "leaf"; - parentPath?: string; - children: string[]; - signature?: string; - startLine: number; - endLine: number; - lineCount: number; - startByte: number; - endByte: number; - checksum: string; - error: boolean; - indent: number; - indentChar: string; -} - -export interface ChunkTree { - language: string; - checksum: string; - lineCount: number; - parseErrors: number; - fallback: boolean; - rootPath: string; - rootChildren: string[]; - chunks: ChunkNode[]; -} - -export interface VisibleLineRange { - startLine: number; - endLine: number; -} - -export type ChunkAnchorStyle = "full" | "kind" | "bare"; - -export interface RenderChunkTreeParams { - tree: ChunkTree; - chunkPath?: string; - source: string; - title: string; - languageTag?: string; - checksum: string; - visibleRange?: VisibleLineRange; - renderChildrenOnly: boolean; - omitChecksum: boolean; - anchorStyle?: ChunkAnchorStyle; - showLeafPreview: boolean; - tabReplacement?: string; -} - -declare module "../bindings" { - interface NativeBindings { - parseChunkTree(source: string, language: string): ChunkTree; - resolveChunkPath(tree: ChunkTree, chunkPath: string): ChunkNode | undefined; - lineToChunkPath(tree: ChunkTree, line: number): string | undefined; - lineToContainingChunkPath(tree: ChunkTree, line: number): string | undefined; - renderChunkTree(params: RenderChunkTreeParams): string; - } -} diff --git a/packages/natives/src/clipboard/types.ts b/packages/natives/src/clipboard/types.ts deleted file mode 100644 index 7cadfd264..000000000 --- a/packages/natives/src/clipboard/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Types for clipboard operations. - */ - -/** PNG-encoded clipboard image payload. */ -export interface ClipboardImage { - /** PNG image bytes. */ - data: Uint8Array; - /** MIME type for the PNG payload. */ - mimeType: string; -} - -declare module "../bindings" { - /** Native clipboard operations exposed by the bindings layer. */ - interface NativeBindings { - /** - * Copy text to the system clipboard. - * @param text - UTF-8 text to place on the clipboard. - */ - copyToClipboard(text: string): Promise; - /** - * Read an image from the clipboard. - * @returns PNG payload or null when no image is available. - */ - readImageFromClipboard(): Promise; - } -} diff --git a/packages/natives/src/embedded-addon.ts b/packages/natives/src/embedded-addon.ts deleted file mode 100644 index b2efc4c48..000000000 --- a/packages/natives/src/embedded-addon.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type EmbeddedAddonVariant = "modern" | "baseline" | "default"; - -export interface EmbeddedAddonFile { - variant: EmbeddedAddonVariant; - filename: string; - filePath: string; -} - -export interface EmbeddedAddon { - platformTag: string; - version: string; - files: EmbeddedAddonFile[]; -} - -export const embeddedAddon: EmbeddedAddon | null = null; diff --git a/packages/natives/src/glob/index.ts b/packages/natives/src/glob/index.ts deleted file mode 100644 index fcc8d215d..000000000 --- a/packages/natives/src/glob/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * File discovery API powered by globset + ignore crate. - */ - -import * as path from "node:path"; - -import { native } from "../native"; -import type { SearchDb } from "../search-db"; -import type { GlobMatch, GlobOptions, GlobResult } from "./types"; - -export type { GlobMatch, GlobOptions, GlobResult } from "./types"; -export { FileType } from "./types"; - -/** - * Find files matching a glob pattern. - * Respects .gitignore by default. - */ -export async function glob( - options: GlobOptions, - onMatch?: (match: GlobMatch) => void, - db?: SearchDb, -): Promise { - const searchPath = path.resolve(options.path); - const pattern = options.pattern || "*"; - // napi-rs ThreadsafeFunction passes (error, value) - skip callback on error - const cb = onMatch ? (err: Error | null, m: GlobMatch) => !err && onMatch(m) : undefined; - - return native.glob( - { - ...options, - path: searchPath, - pattern, - hidden: options.hidden ?? false, - gitignore: options.gitignore ?? true, - recursive: options.recursive ?? true, - }, - cb, - db, - ); -} - -/** - * Invalidate the filesystem scan cache. - * - * When called with a path, removes entries for roots containing that path. - * When called without a path, clears the entire cache. - */ -export function invalidateFsScanCache(path?: string): void { - native.invalidateFsScanCache(path); -} diff --git a/packages/natives/src/glob/types.ts b/packages/natives/src/glob/types.ts deleted file mode 100644 index 88d5c4226..000000000 --- a/packages/natives/src/glob/types.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Types for native find API. - */ - -import type { Cancellable, TsFunc } from "../bindings"; -import type { SearchDb } from "../search-db"; - -export enum FileType { - /** A regular file. */ - File = 1, - /** A directory. */ - Dir = 2, - /** A symlink. */ - Symlink = 3, -} - -/** Options for discovering files and directories. */ -export interface GlobOptions extends Cancellable { - /** Glob pattern to match (e.g., `*.ts`). */ - pattern: string; - /** Directory to search. */ - path: string; - /** Filter by file type: "file", "dir", or "symlink". Symlinks match file/dir filters when their target type matches. */ - fileType?: FileType; - /** Match simple patterns recursively by default (example: *.ts -> recursive match). Set false to keep patterns relative to the search root only. */ - recursive?: boolean; - /** Include hidden files (default: false). */ - hidden?: boolean; - /** Maximum number of results to return. */ - maxResults?: number; - /** Respect .gitignore files (default: true). */ - gitignore?: boolean; - /** Enable shared filesystem scan cache (default: false). */ - cache?: boolean; - /** Sort results by mtime (most recent first) before applying limit. */ - sortByMtime?: boolean; - /** Include node_modules entries even when pattern does not mention node_modules. */ - includeNodeModules?: boolean; -} - -/** A single filesystem match. */ -export interface GlobMatch { - /** Relative path from the search root. */ - path: string; - /** Resolved filesystem type for the match (for fileType=file/dir filters, symlink targets are reported as file/dir). */ - fileType: FileType; - /** Modification time in milliseconds since epoch, if available. */ - mtime?: number; -} - -/** Result of a find operation. */ -export interface GlobResult { - /** Matched filesystem entries. */ - matches: GlobMatch[]; - /** Number of matches returned after limits are applied. */ - totalMatches: number; -} - -declare module "../bindings" { - interface NativeBindings { - /** - * Find filesystem entries matching a glob pattern. - * @param options Search options that control globbing and filters. - * @param onMatch Optional callback for streaming matches as they are found. - */ - glob(options: GlobOptions, onMatch?: TsFunc, db?: SearchDb): Promise; - /** Invalidate the filesystem scan cache for the given path (or all caches if omitted). */ - invalidateFsScanCache(path?: string): void; - } -} diff --git a/packages/natives/src/grep/index.ts b/packages/natives/src/grep/index.ts deleted file mode 100644 index 30b089255..000000000 --- a/packages/natives/src/grep/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Native ripgrep wrapper using N-API. - */ - -import { native } from "../native"; -import type { SearchDb } from "../search-db"; -import type { - ContextLine, - FuzzyFindMatch, - FuzzyFindOptions, - FuzzyFindResult, - GrepMatch, - GrepOptions, - GrepResult, - GrepSummary, - SearchOptions, - SearchResult, -} from "./types"; - -export type { - ContextLine, - FuzzyFindMatch, - FuzzyFindOptions, - FuzzyFindResult, - GrepMatch, - GrepOptions, - GrepResult, - GrepSummary, - SearchOptions, - SearchResult, -}; - -/** - * Search files for a regex pattern with optional streaming callback. - */ -export async function grep( - options: GrepOptions, - onMatch?: (match: GrepMatch) => void, - db?: SearchDb, -): Promise { - // napi-rs ThreadsafeFunction passes (error, value) - skip callback on error - const cb = onMatch ? (err: Error | null, m: GrepMatch) => !err && onMatch(m) : undefined; - return native.grep(options, cb, db); -} - -/** - * Search a single file's content for a pattern. - * Lower-level API for when you already have file content. - * - * Accepts `Uint8Array`/`Buffer` for zero-copy when content is already UTF-8 encoded. - */ -export function searchContent(content: string | Uint8Array, options: SearchOptions): SearchResult { - return native.search(content, options); -} - -/** - * Quick check if content contains a pattern match. - * - * Accepts `Uint8Array`/`Buffer` for zero-copy when content/pattern are already UTF-8 encoded. - */ -export function hasMatch( - content: string | Uint8Array, - pattern: string | Uint8Array, - options?: { ignoreCase?: boolean; multiline?: boolean }, -): boolean { - return native.hasMatch(content, pattern, options?.ignoreCase ?? false, options?.multiline ?? false); -} - -/** - * Fuzzy file path search for autocomplete. - * - * Searches for files and directories whose paths contain the query substring - * (case-insensitive). Respects .gitignore by default. - */ -export async function fuzzyFind(options: FuzzyFindOptions, db?: SearchDb): Promise { - return native.fuzzyFind(options, db); -} diff --git a/packages/natives/src/grep/types.ts b/packages/natives/src/grep/types.ts deleted file mode 100644 index 8965d2e7a..000000000 --- a/packages/natives/src/grep/types.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Types for grep/search operations. - */ - -import type { Cancellable, TsFunc } from "../bindings"; -import type { SearchDb } from "../search-db"; - -/** Options for searching files. */ -export interface GrepOptions extends Cancellable { - /** Regex pattern to search for */ - pattern: string; - /** Directory or file to search */ - path: string; - /** Glob filter for filenames (e.g., "*.ts") */ - glob?: string; - /** Filter by file type (e.g., "js", "py", "rust") */ - type?: string; - /** Case-insensitive search */ - ignoreCase?: boolean; - /** Enable multiline matching */ - multiline?: boolean; - /** Include hidden files (default: true) */ - hidden?: boolean; - /** Respect .gitignore files (default: true) */ - gitignore?: boolean; - /** Enable shared filesystem scan cache (default: false). */ - cache?: boolean; - /** Maximum number of matches to return */ - maxCount?: number; - /** Skip first N matches */ - offset?: number; - /** Lines of context before matches */ - contextBefore?: number; - /** Lines of context after matches */ - contextAfter?: number; - /** Lines of context before/after matches (legacy) */ - context?: number; - /** Truncate lines longer than this (characters) */ - maxColumns?: number; - /** Output mode */ - mode?: "content" | "filesWithMatches" | "count"; -} - -/** A context line returned around a match. */ -export interface ContextLine { - /** 1-indexed line number. */ - lineNumber: number; - /** Line content (trimmed line ending). */ - line: string; -} - -/** A single grep match or per-file count entry. */ -export interface GrepMatch { - /** File path for the match (relative for directory searches). */ - path: string; - /** 1-indexed line number (0 for count-only entries). */ - lineNumber: number; - /** Matched line content (empty for count-only entries). */ - line: string; - /** Context lines before the match. */ - contextBefore?: ContextLine[]; - /** Context lines after the match. */ - contextAfter?: ContextLine[]; - /** Whether the line was truncated. */ - truncated?: boolean; - /** Per-file match count (count mode only). */ - matchCount?: number; -} - -/** Summary stats for a grep run. */ -export interface GrepSummary { - /** Total matches across all files. */ - totalMatches: number; - /** Number of files with at least one match. */ - filesWithMatches: number; - /** Number of files searched. */ - filesSearched: number; - /** Whether the limit/offset stopped the search early. */ - limitReached?: boolean; -} - -/** Full grep result including matches and summary counts. */ -export interface GrepResult extends GrepSummary { - /** Matches or per-file counts, depending on mode. */ - matches: GrepMatch[]; -} - -/** Options for searching in-memory content. */ -export interface SearchOptions { - /** Regex pattern to search for */ - pattern: string; - /** Case-insensitive search */ - ignoreCase?: boolean; - /** Enable multiline matching */ - multiline?: boolean; - /** Maximum number of matches to return */ - maxCount?: number; - /** Skip first N matches */ - offset?: number; - /** Lines of context before matches */ - contextBefore?: number; - /** Lines of context after matches */ - contextAfter?: number; - /** Lines of context before/after matches (legacy) */ - context?: number; - /** Truncate lines longer than this (characters) */ - maxColumns?: number; - /** Output mode */ - mode?: "content" | "count"; -} - -/** A single content match. */ -export interface SearchMatch { - /** 1-indexed line number. */ - lineNumber: number; - /** Matched line content. */ - line: string; - /** Context lines before the match. */ - contextBefore?: ContextLine[]; - /** Context lines after the match. */ - contextAfter?: ContextLine[]; - /** Whether the line was truncated. */ - truncated?: boolean; -} - -/** Result of searching in-memory content. */ -export interface SearchResult { - /** All matches found. */ - matches: SearchMatch[]; - /** Total number of matches (may exceed `matches.length`). */ - matchCount: number; - /** Whether the limit was reached. */ - limitReached: boolean; - /** Error message, if any. */ - error?: string; -} - -/** Options for fuzzy file path search. */ -export interface FuzzyFindOptions extends Cancellable { - /** Fuzzy query to match against file paths (case-insensitive). */ - query: string; - /** Directory to search. */ - path: string; - /** Include hidden files (default: false). */ - hidden?: boolean; - /** Respect .gitignore (default: true). */ - gitignore?: boolean; - /** Enable shared filesystem scan cache (default: false). */ - cache?: boolean; - /** Maximum number of matches to return (default: 100). */ - maxResults?: number; -} - -/** A single match in fuzzy find results. */ -export interface FuzzyFindMatch { - /** Relative path from the search root (uses `/` separators). */ - path: string; - /** Whether this entry is a directory. */ - isDirectory: boolean; - /** Match quality score (higher is better). */ - score: number; -} - -/** Result of fuzzy file path search. */ -export interface FuzzyFindResult { - /** Matched entries (up to `maxResults`). */ - matches: FuzzyFindMatch[]; - /** Total number of matches found (may exceed `matches.length`). */ - totalMatches: number; -} - -declare module "../bindings" { - interface NativeBindings { - /** Fuzzy file path search for autocomplete. */ - fuzzyFind(options: FuzzyFindOptions, db?: SearchDb): Promise; - /** Search files for a regex pattern. */ - grep(options: GrepOptions, onMatch?: TsFunc, db?: SearchDb): Promise; - /** Search in-memory content for a regex pattern. */ - search(content: string | Uint8Array, options: SearchOptions): SearchResult; - /** Quick check if content matches a pattern. */ - hasMatch( - content: string | Uint8Array, - pattern: string | Uint8Array, - ignoreCase: boolean, - multiline: boolean, - ): boolean; - } -} diff --git a/packages/natives/src/highlight/index.ts b/packages/natives/src/highlight/index.ts deleted file mode 100644 index 0a0f99dbf..000000000 --- a/packages/natives/src/highlight/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Syntax highlighting powered by native syntect bindings. - */ - -import { native } from "../native"; - -export type { HighlightColors } from "./types"; - -export const { highlightCode, supportsLanguage, getSupportedLanguages } = native; diff --git a/packages/natives/src/highlight/types.ts b/packages/natives/src/highlight/types.ts deleted file mode 100644 index ad7ac6593..000000000 --- a/packages/natives/src/highlight/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Types for syntax highlighting. - */ - -/** - * Theme colors for syntax highlighting. - * Each color should be an ANSI escape sequence (e.g., "\x1b[38;2;255;0;0m"). - */ -export interface HighlightColors { - /** ANSI color for comments. */ - comment: string; - /** ANSI color for keywords. */ - keyword: string; - /** ANSI color for function names. */ - function: string; - /** ANSI color for variables and identifiers. */ - variable: string; - /** ANSI color for string literals. */ - string: string; - /** ANSI color for numeric literals. */ - number: string; - /** ANSI color for type identifiers. */ - type: string; - /** ANSI color for operators. */ - operator: string; - /** ANSI color for punctuation tokens. */ - punctuation: string; - /** Color for diff inserted lines (+). */ - inserted?: string; - /** Color for diff deleted lines (-). */ - deleted?: string; -} - -declare module "../bindings" { - interface NativeBindings { - /** - * Highlight code with syntax coloring. - * @param code Source code to highlight. - * @param lang Language name, extension, or null for plain text. - * @param colors ANSI color palette for semantic scopes. - * @returns Highlighted code with ANSI color codes. - */ - highlightCode(code: string, lang: string | null | undefined, colors: HighlightColors): string; - /** - * Check if a language is supported for highlighting. - * @param lang Language name or extension to test. - * @returns True when highlighting is available. - */ - supportsLanguage(lang: string): boolean; - /** - * Get list of all supported languages. - * @returns Syntect language names supported by the native highlighter. - */ - getSupportedLanguages(): string[]; - } -} diff --git a/packages/natives/src/html/index.ts b/packages/natives/src/html/index.ts deleted file mode 100644 index a10a69bb6..000000000 --- a/packages/natives/src/html/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * HTML to Markdown conversion powered by native bindings. - */ - -import { native } from "../native"; -import type { HtmlToMarkdownOptions } from "./types"; - -export type { HtmlToMarkdownOptions } from "./types"; - -/** - * Convert HTML to Markdown. - * - * @param html - HTML content to convert - * @param options - Conversion options - * @returns Markdown text - */ -export async function htmlToMarkdown(html: string, options?: HtmlToMarkdownOptions): Promise { - return native.htmlToMarkdown(html, options); -} diff --git a/packages/natives/src/html/types.ts b/packages/natives/src/html/types.ts deleted file mode 100644 index f3c69677a..000000000 --- a/packages/natives/src/html/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Types for HTML to Markdown conversion. - */ - -/** Options controlling HTML preprocessing and output. */ -export interface HtmlToMarkdownOptions { - /** Remove navigation elements, forms, headers, and footers. */ - cleanContent?: boolean; - /** Skip images during conversion. */ - skipImages?: boolean; -} - -declare module "../bindings" { - /** Native HTML utilities exposed by the Rust bindings. */ - interface NativeBindings { - /** - * Convert HTML to Markdown. - * @param html HTML source to convert. - * @param options Optional conversion settings. - * @returns Markdown output. - */ - htmlToMarkdown(html: string, options?: HtmlToMarkdownOptions | null): Promise; - } -} diff --git a/packages/natives/src/image/index.ts b/packages/natives/src/image/index.ts deleted file mode 100644 index 4b5a35f9a..000000000 --- a/packages/natives/src/image/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Image processing via native bindings. - */ - -import { native } from "../native"; - -export { ImageFormat, type PhotonImageConstructor, SamplingFilter } from "./types"; - -/** PhotonImage class for image manipulation. Use PhotonImage.parse() to create instances. */ -export const PhotonImage = native.PhotonImage; - -/** Encode image bytes into a SIXEL escape sequence at target pixel size. */ -export const encodeSixel = native.encodeSixel; - -/** PhotonImage instance type. */ -export type PhotonImage = import("./types").PhotonImage; diff --git a/packages/natives/src/image/types.ts b/packages/natives/src/image/types.ts deleted file mode 100644 index dba08aa8c..000000000 --- a/packages/natives/src/image/types.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Types for image processing. - */ - -/** Output format for image encoding. */ -export const enum ImageFormat { - /** PNG encoded bytes. */ - PNG = 0, - /** JPEG encoded bytes. */ - JPEG = 1, - /** WebP encoded bytes. */ - WEBP = 2, - /** GIF encoded bytes. */ - GIF = 3, -} - -/** Sampling filter for resize operations. */ -export const enum SamplingFilter { - /** Nearest-neighbor sampling (fast, low quality). */ - Nearest = 1, - /** Triangle filter (linear interpolation). */ - Triangle = 2, - /** Catmull-Rom filter with sharper edges. */ - CatmullRom = 3, - /** Gaussian filter for smoother results. */ - Gaussian = 4, - /** Lanczos3 filter for high-quality downscaling. */ - Lanczos3 = 5, -} - -/** Image container for native image operations. */ -export interface PhotonImage { - /** Image width in pixels. */ - get width(): number; - /** Image height in pixels. */ - get height(): number; - /** - * Encode the image using the requested format and quality. - * Returns the encoded image bytes. - */ - encode(format: ImageFormat, quality: number): Promise; - /** - * Resize the image to the requested dimensions with the filter. - * Returns a new image instance. - */ - resize(width: number, height: number, filter: SamplingFilter): Promise; -} - -/** Static entrypoints for creating `PhotonImage` instances. */ -export interface PhotonImageConstructor { - /** Parse image bytes (PNG, JPEG, WebP, GIF) into a native image. */ - parse(bytes: Uint8Array): Promise; - /** Instance prototype reference. */ - prototype: PhotonImage; -} - -declare module "../bindings" { - /** Native bindings for image operations. */ - interface NativeBindings { - /** Sampling filters exposed by the native module. */ - SamplingFilter: typeof SamplingFilter; - /** Photon image constructor exposed by the native module. */ - PhotonImage: PhotonImageConstructor; - /** Encode image bytes to SIXEL escape sequence at target pixel size. */ - encodeSixel(bytes: Uint8Array, targetWidthPx: number, targetHeightPx: number): string; - } -} diff --git a/packages/natives/src/index.ts b/packages/natives/src/index.ts deleted file mode 100644 index fc3c15b7d..000000000 --- a/packages/natives/src/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export * from "./appearance"; -export * from "./ast"; -export * from "./chunk"; -export * from "./clipboard"; -export * from "./glob"; -export * from "./grep"; -export * from "./highlight"; -export * from "./html"; -export * from "./image"; -export * from "./keys"; -export * from "./projfs"; -export * from "./ps"; -export * from "./pty"; -export * from "./search-db"; -export * from "./shell"; -export * from "./text"; -export * from "./work"; diff --git a/packages/natives/src/keys/index.ts b/packages/natives/src/keys/index.ts deleted file mode 100644 index cc96254fb..000000000 --- a/packages/natives/src/keys/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Keyboard sequence utilities powered by native bindings. - */ - -import { native } from "../native"; - -export type { KeyEventType, ParsedKittyResult } from "./types"; - -export const { matchesKittySequence, parseKey, matchesLegacySequence, parseKittySequence, matchesKey } = native; diff --git a/packages/natives/src/keys/types.ts b/packages/natives/src/keys/types.ts deleted file mode 100644 index 8accd2cf4..000000000 --- a/packages/natives/src/keys/types.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Types for keyboard sequence handling. - */ - -/** - * Event types from Kitty keyboard protocol (flag 2). - * 1 = key press, 2 = key repeat, 3 = key release. - */ -export const enum KeyEventType { - /** Key press event. */ - Press = 1, - /** Key repeat event. */ - Repeat = 2, - /** Key release event. */ - Release = 3, -} - -/** Parsed Kitty keyboard protocol sequence result. */ -export interface ParsedKittyResult { - /** Primary codepoint associated with the key. */ - codepoint: number; - /** Optional shifted key codepoint from the sequence. */ - shiftedKey?: number; - /** Optional base layout key codepoint from the sequence. */ - baseLayoutKey?: number; - /** Modifier bitmask (shift/alt/ctrl), excluding lock bits. */ - modifier: number; - /** Optional event type from the sequence. */ - eventType?: KeyEventType; -} - -declare module "../bindings" { - interface NativeBindings { - /** - * Match Kitty protocol sequences for a codepoint and modifier mask. - * @param data Raw terminal input data. - * @param expectedCodepoint Codepoint to compare against the parsed sequence. - * @param expectedModifier Modifier mask (shift/alt/ctrl). - * @returns True when the sequence matches the expected codepoint and modifiers. - */ - matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean; - /** - * Parse terminal input and return a normalized key identifier. - * Returns key names like "escape", "ctrl+c", "shift+tab", "alt+enter". - * Returns null if the input is not a recognized key sequence. - * @param data Raw terminal input data. - * @param kittyProtocolActive Whether Kitty disambiguation is enabled. - * @returns The normalized key id or null when unrecognized. - */ - parseKey(data: string, kittyProtocolActive: boolean): string | null; - /** - * Check if input matches a legacy escape sequence for a specific key. - * @param data Raw terminal input data. - * @param keyName Key identifier to match (e.g. "home"). - * @returns True when the sequence maps to the given key name. - */ - matchesLegacySequence(data: string, keyName: string): boolean; - /** - * Parse a Kitty keyboard protocol sequence. - * @param data Raw terminal input data. - * @returns Parsed sequence info or null if not a Kitty sequence. - */ - parseKittySequence(data: string): ParsedKittyResult | null; - /** - * Match input data against a key identifier string. - * Supports: escape, tab, enter, backspace, delete, home, end, space, - * arrows (up/down/left/right), ctrl+X, shift+X, alt+X, combined modifiers. - * @param data Raw terminal input data. - * @param keyId Key identifier string to match (e.g. "ctrl+c"). - * @param kittyProtocolActive Whether Kitty disambiguation is enabled. - * @returns True when the input matches the key identifier. - */ - matchesKey(data: string, keyId: string, kittyProtocolActive: boolean): boolean; - } -} diff --git a/packages/natives/src/projfs/index.ts b/packages/natives/src/projfs/index.ts deleted file mode 100644 index e271a778f..000000000 --- a/packages/natives/src/projfs/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Windows ProjFS-backed overlay lifecycle bindings. - */ - -import { native } from "../native"; - -export type { ProjfsOverlayProbeResult } from "./types"; -export const { projfsOverlayProbe, projfsOverlayStart, projfsOverlayStop } = native; diff --git a/packages/natives/src/projfs/types.ts b/packages/natives/src/projfs/types.ts deleted file mode 100644 index 8db17e1e9..000000000 --- a/packages/natives/src/projfs/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Types for Windows ProjFS-backed overlay lifecycle. - */ - -/** Result for probing whether ProjFS can be used on this machine. */ -export interface ProjfsOverlayProbeResult { - available: boolean; - reason?: string; -} - -declare module "../bindings" { - interface NativeBindings { - /** - * Probe whether ProjFS APIs are available and loadable on the current machine. - */ - projfsOverlayProbe(): ProjfsOverlayProbeResult; - - /** - * Start a ProjFS-backed projection at `projectionRoot` serving files from `lowerRoot`. - */ - projfsOverlayStart(lowerRoot: string, projectionRoot: string): void; - - /** - * Stop a ProjFS-backed projection previously started at `projectionRoot`. - */ - projfsOverlayStop(projectionRoot: string): void; - } -} diff --git a/packages/natives/src/ps/index.ts b/packages/natives/src/ps/index.ts deleted file mode 100644 index 88544b7b7..000000000 --- a/packages/natives/src/ps/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Process management utilities. - */ - -import { setNativeKillTree } from "@oh-my-pi/pi-utils"; -import { native } from "../native"; - -setNativeKillTree(native.killTree); - -export const { killTree, listDescendants } = native; diff --git a/packages/natives/src/ps/types.ts b/packages/natives/src/ps/types.ts deleted file mode 100644 index a4b9af0b3..000000000 --- a/packages/natives/src/ps/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Types for process management. - */ - -export {}; - -declare module "../bindings" { - /** Native process-management bindings implemented in pi-natives. */ - interface NativeBindings { - /** - * Kill a process and all its descendants using platform-native APIs. - * @param pid Root process id. - * @param signal Signal number (ignored on Windows). - * @returns Number of processes successfully killed. - */ - killTree(pid: number, signal: number): number; - /** - * List all descendant PIDs of a process (children, grandchildren, etc.). - * @param pid Root process id. - * @returns Empty array when the process has no children or doesn't exist. - */ - listDescendants(pid: number): number[]; - } -} diff --git a/packages/natives/src/pty/index.ts b/packages/natives/src/pty/index.ts deleted file mode 100644 index a18a67ebc..000000000 --- a/packages/natives/src/pty/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * PTY-backed interactive execution. - */ - -import { native } from "../native"; - -export type { PtyRunResult, PtySessionConstructor, PtyStartOptions } from "./types"; - -export const { PtySession } = native; -export type PtySession = import("./types").PtySession; diff --git a/packages/natives/src/pty/types.ts b/packages/natives/src/pty/types.ts deleted file mode 100644 index cacfc5870..000000000 --- a/packages/natives/src/pty/types.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Types for PTY-backed interactive execution. - */ - -import type { Cancellable, TsFunc } from "../bindings"; - -/** - * Options for starting a command in a pseudo-terminal session. - */ -export interface PtyStartOptions extends Cancellable { - /** Command to execute. */ - command: string; - /** Working directory for command execution. */ - cwd?: string; - /** Environment variables for this command. */ - env?: Record; - /** PTY column count. */ - cols?: number; - /** PTY row count. */ - rows?: number; -} - -/** - * Result of a PTY command run. - */ -export interface PtyRunResult { - /** Exit code of the command, if available. */ - exitCode?: number; - /** Whether the command was cancelled by abort signal or kill request. */ - cancelled: boolean; - /** Whether the command timed out. */ - timedOut: boolean; -} - -/** Stateful PTY session instance. */ -export interface PtySession { - /** Start command execution and stream output while it runs. */ - start(options: PtyStartOptions, onChunk?: TsFunc): Promise; - /** Write raw input bytes to PTY stdin. */ - write(data: string): void; - /** Resize active PTY. */ - resize(cols: number, rows: number): void; - /** Force-kill active command. */ - kill(): void; -} - -/** Native PTY session constructor. */ -export interface PtySessionConstructor { - new (): PtySession; -} - -declare module "../bindings" { - interface NativeBindings { - /** Stateful PTY session constructor for interactive terminal passthrough. */ - PtySession: PtySessionConstructor; - } -} diff --git a/packages/natives/src/search-db-types.ts b/packages/natives/src/search-db-types.ts deleted file mode 100644 index efc1fc164..000000000 --- a/packages/natives/src/search-db-types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { NativeBindings } from "./bindings"; - -/** Shared native search database instance. */ -export interface SearchDb {} - -/** Native SearchDb constructor. */ -export interface SearchDbConstructor { - new (path: string): SearchDb; -} - -declare module "./bindings" { - interface NativeBindings { - /** Stateful shared search DB constructor. */ - SearchDb: SearchDbConstructor; - } -} - -export type NativeBindingsWithSearchDb = NativeBindings & { - SearchDb: SearchDbConstructor; -}; diff --git a/packages/natives/src/search-db.ts b/packages/natives/src/search-db.ts deleted file mode 100644 index 2c5599e2e..000000000 --- a/packages/natives/src/search-db.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { native } from "./native"; -import type { SearchDb as NativeSearchDb, SearchDbConstructor } from "./search-db-types"; - -export const SearchDb = native.SearchDb as SearchDbConstructor; - -export type { SearchDbConstructor }; -export type SearchDb = NativeSearchDb; diff --git a/packages/natives/src/shell/index.ts b/packages/natives/src/shell/index.ts deleted file mode 100644 index e34767518..000000000 --- a/packages/natives/src/shell/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Native shell execution via brush-core. - */ - -import { native } from "../native"; -import type { ShellExecuteOptions, ShellExecuteResult } from "./types"; - -export type { ShellExecuteOptions, ShellExecuteResult, ShellOptions, ShellRunOptions, ShellRunResult } from "./types"; - -export const { Shell } = native; -export type Shell = import("./types").Shell; - -/** - * Execute a shell command using brush-core. - * - * @param options - Execution options including command, cwd, env, timeout - * @param onChunk - Optional callback for streaming output chunks - * @returns Promise resolving to execution result with exit code and status - */ -export async function executeShell( - options: ShellExecuteOptions, - onChunk?: (chunk: string) => void, -): Promise { - const wrappedCallback = onChunk ? (err: Error | null, chunk: string) => !err && onChunk(chunk) : undefined; - return native.executeShell(options, wrappedCallback); -} diff --git a/packages/natives/src/shell/types.ts b/packages/natives/src/shell/types.ts deleted file mode 100644 index 9c0942571..000000000 --- a/packages/natives/src/shell/types.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Types for shell execution via brush-core. - */ - -import type { Cancellable, TsFunc } from "../bindings"; - -/** - * Configuration for a persistent brush-core shell session. - */ -export interface ShellOptions { - /** Environment variables to set once per session. */ - sessionEnv?: Record; - /** Optional snapshot path to source for bash sessions. */ - snapshotPath?: string; -} - -/** - * Options for running a single shell command. - */ -export interface ShellRunOptions extends Cancellable { - /** The command to execute. */ - command: string; - /** Working directory for command execution. */ - cwd?: string; - /** Environment variables to apply for this command. */ - env?: Record; -} - -/** - * Result of running a shell command via brush-core. - */ -export interface ShellRunResult { - /** Exit code of the command (undefined if cancelled or timed out). */ - exitCode?: number; - /** Whether the command was cancelled via abort. */ - cancelled: boolean; - /** Whether the command timed out. */ - timedOut: boolean; -} - -/** - * Internal options for the native brush-core binding. - */ -export interface ShellExecuteOptions extends Cancellable { - /** The command to execute. */ - command: string; - /** Working directory for command execution. */ - cwd?: string; - /** Environment variables to apply for this command. */ - env?: Record; - /** Environment variables to set once per session. */ - sessionEnv?: Record; - /** Optional snapshot path to source for bash sessions. */ - snapshotPath?: string; -} - -/** -/** Internal result from the native brush-core binding. */ -export interface ShellExecuteResult extends ShellRunResult {} - -/** Native Shell class instance. */ -export interface Shell { - /** - * Run a command in the shell. - * @param options Command execution options. - * @param onChunk Optional callback for streamed output. - * @returns Promise resolving to the command result. - */ - run(options: ShellRunOptions, onChunk?: TsFunc): Promise; - /** - * Abort all running commands in this session. - */ - abort(): Promise; -} - -/** Native Shell class constructor. */ -export interface ShellConstructor { - /** - * Create a new shell session. - * @param options Optional session configuration. - */ - new (options?: ShellOptions): Shell; -} - -declare module "../bindings" { - /** Native bindings exposed by the shell module. */ - interface NativeBindings { - /** - * Execute a shell command with explicit session metadata. - * @param options Execution options including session identifiers. - * @param onChunk Optional callback for streamed output. - * @returns Promise resolving to the command result. - */ - executeShell(options: ShellExecuteOptions, onChunk?: TsFunc): Promise; - - /** Shell class constructor for creating sessions. */ - Shell: ShellConstructor; - } -} diff --git a/packages/natives/src/text/index.ts b/packages/natives/src/text/index.ts deleted file mode 100644 index 4c78a4f0f..000000000 --- a/packages/natives/src/text/index.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * ANSI-aware text utilities powered by native bindings. - */ - -import { Ellipsis, type ExtractSegmentsResult, type SliceWithWidthResult } from "@oh-my-pi/pi-natives"; -import { getDefaultTabWidth } from "@oh-my-pi/pi-utils"; -import { native } from "../native"; - -export type { ExtractSegmentsResult, SliceWithWidthResult } from "./types"; -export { Ellipsis } from "./types"; - -/** - * Truncate text to fit within a maximum visible width, adding ellipsis if needed. - * Optionally pad with spaces to reach exactly maxWidth. - * Properly handles ANSI escape codes (they don't count toward width). - * - * @param text - Text to truncate (may contain ANSI codes) - * @param maxWidth - Maximum visible width - * @param ellipsis - Ellipsis kind to append when truncating (default: Unicode "…") - * @param pad - If true, pad result with spaces to exactly maxWidth (default: false) - * @returns Truncated text, optionally padded to exactly maxWidth - */ -export function truncateToWidth( - text: string, - maxWidth: number, - ellipsis: Ellipsis = Ellipsis.Unicode, - pad = false, - tabWidth = getDefaultTabWidth(), -): string { - return native.truncateToWidth(text, maxWidth, ellipsis, pad, tabWidth); -} - -/** - * Slice a range of visible columns from a line. - * @param line - The line to slice - * @param startCol - The starting column - * @param length - The length of the slice - * @param strict - Whether to strictly enforce the length - * @returns The sliced line - */ -export function sliceWithWidth( - line: string, - startCol: number, - length: number, - strict = false, - tabWidth = getDefaultTabWidth(), -): SliceWithWidthResult { - if (length <= 0) return { text: "", width: 0 }; - return native.sliceWithWidth(line, startCol, length, strict, tabWidth); -} - -/** - * Wrap text to a visible width while preserving ANSI color/style sequences. - * - * @param text - Input text, optionally containing ANSI escape codes - * @param width - Maximum visible width per output line - * @param tabWidth - Width used when measuring tab characters (default: configured tab width) - * @returns Wrapped lines with ANSI state preserved across breaks - */ -export function wrapTextWithAnsi(text: string, width: number, tabWidth = getDefaultTabWidth()): string[] { - return native.wrapTextWithAnsi(text, width, tabWidth); -} - -/** - * Measure visible terminal width of text, excluding ANSI escape sequences. - * - * @param text - Input text, optionally containing ANSI escape codes - * @param tabWidth - Width used when measuring tab characters (default: configured tab width) - * @returns Visible width in terminal cells - */ -export function visibleWidth(text: string, tabWidth = getDefaultTabWidth()): number { - return native.visibleWidth(text, tabWidth); -} - -/** - * Extract before/after segments around an overlay range using visible-column boundaries. - * - * @param line - Input line, optionally containing ANSI escape codes - * @param beforeEnd - Visible column where the `before` segment ends - * @param afterStart - Visible column where the `after` segment starts - * @param afterLen - Visible width to include in the `after` segment - * @param strictAfter - When true, graphemes that overflow `afterLen` are dropped - * @param tabWidth - Width used when measuring tab characters (default: configured tab width) - * @returns Visible-width-aware before/after segments - */ -export function extractSegments( - line: string, - beforeEnd: number, - afterStart: number, - afterLen: number, - strictAfter: boolean, - tabWidth = getDefaultTabWidth(), -): ExtractSegmentsResult { - return native.extractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, tabWidth); -} - -export const { sanitizeText } = native; diff --git a/packages/natives/src/text/types.ts b/packages/natives/src/text/types.ts deleted file mode 100644 index d4a63f315..000000000 --- a/packages/natives/src/text/types.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Types for ANSI-aware text utilities. - */ - -/** Result of slicing a line by visible columns. */ -export interface SliceWithWidthResult { - /** UTF-16 slice containing the selected text. */ - text: string; - /** Visible width of the slice in terminal cells. */ - width: number; -} - -/** Result of extracting before/after overlay segments. */ -export interface ExtractSegmentsResult { - /** UTF-16 content before the overlay region. */ - before: string; - /** Visible width of the `before` segment. */ - beforeWidth: number; - /** UTF-16 content after the overlay region. */ - after: string; - /** Visible width of the `after` segment. */ - afterWidth: number; -} - -/** Ellipsis strategy for truncation. */ -export const enum Ellipsis { - /** Use a single Unicode ellipsis character ("…"). */ - Unicode = 0, - /** Use three ASCII dots ("..."). */ - Ascii = 1, - /** Omit ellipsis entirely. */ - Omit = 2, -} - -declare module "../bindings" { - interface NativeBindings { - /** - * Truncate text to a visible width, optionally padding with spaces. - * @param text UTF-16 input text. - * @param maxWidth Maximum visible width in terminal cells. - * @param ellipsisKind Ellipsis strategy (see {@link Ellipsis}). - * @param pad Whether to pad the output to `maxWidth`. - */ - truncateToWidth(text: string, maxWidth: number, ellipsisKind: number, pad: boolean, tabWidth?: number): string; - /** - * Sanitize text output: strip ANSI codes, remove binary garbage, normalize line endings. - */ - sanitizeText(text: string): string; - - /** - * Wrap text to a visible width, preserving ANSI codes across line breaks. - * @param text UTF-16 input text with optional ANSI escapes. - * @param width Maximum visible width per line. - */ - wrapTextWithAnsi(text: string, width: number, tabWidth?: number): string[]; - /** - * Slice a range of visible columns from a line. - * @param line UTF-16 input line with optional ANSI escapes. - * @param startCol Starting column in terminal cells. - * @param length Number of visible cells to include. - * @param strict Whether to drop graphemes that overflow the range. - */ - sliceWithWidth( - line: string, - startCol: number, - length: number, - strict: boolean, - tabWidth?: number, - ): SliceWithWidthResult; - /** - * Measure the visible width of text (excluding ANSI codes). - * @param text UTF-16 input text with optional ANSI escapes. - */ - visibleWidth(text: string, tabWidth?: number): number; - /** Extract before/after segments around an overlay region. - * @param line UTF-16 input line with optional ANSI escapes. - * @param beforeEnd Column where the "before" segment ends. - * @param afterStart Column where the "after" segment starts. - * @param afterLen Visible width of the "after" segment. - * @param strictAfter Whether to drop graphemes that overflow `afterLen`. - */ - extractSegments( - line: string, - beforeEnd: number, - afterStart: number, - afterLen: number, - strictAfter: boolean, - tabWidth?: number, - ): ExtractSegmentsResult; - } -} diff --git a/packages/natives/src/work/index.ts b/packages/natives/src/work/index.ts deleted file mode 100644 index 242d1853c..000000000 --- a/packages/natives/src/work/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Work scheduling profiling via native instrumentation. - * - * Always-on profiling - samples are collected into a circular buffer. - * Call `getWorkProfile()` to retrieve recent activity. - */ - -import { native } from "../native"; - -export type { WorkProfile } from "./types"; -export const { getWorkProfile } = native; diff --git a/packages/natives/src/work/types.ts b/packages/natives/src/work/types.ts deleted file mode 100644 index 9161543d5..000000000 --- a/packages/natives/src/work/types.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Types for work scheduling profiling. - */ - -/** - * Profiling results from work scheduling instrumentation. - */ -export interface WorkProfile { - /** Folded stack format for flamegraph tools. */ - folded: string; - /** Markdown summary of profiling results. */ - summary: string; - /** SVG flamegraph (if generation succeeded). */ - svg: string | null; - /** Total work time in milliseconds. */ - totalMs: number; - /** Number of samples collected. */ - sampleCount: number; -} - -declare module "../bindings" { - interface NativeBindings { - /** - * Get work profile data from the last N seconds. - * - * Always-on profiling - samples are collected into a circular buffer. - * Call this to retrieve recent activity. - */ - getWorkProfile(lastSeconds: number): WorkProfile; - } -} diff --git a/packages/natives/test/native.test.ts b/packages/natives/test/native.test.ts index e9305183d..ae509357a 100644 --- a/packages/natives/test/native.test.ts +++ b/packages/natives/test/native.test.ts @@ -7,6 +7,7 @@ import { FileType, fuzzyFind, type GlobMatch, + GrepOutputMode, glob, grep, htmlToMarkdown, @@ -16,7 +17,7 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, -} from "../src/index"; +} from "../native/index"; let testDir: string; @@ -119,7 +120,7 @@ describe("pi-natives", () => { const result = await grep({ pattern: "return", path: testDir, - mode: "filesWithMatches", + mode: GrepOutputMode.FilesWithMatches, }); expect(result.filesWithMatches).toBeGreaterThan(0); diff --git a/packages/natives/tsconfig.json b/packages/natives/tsconfig.json index d423b3376..2dce4fdad 100644 --- a/packages/natives/tsconfig.json +++ b/packages/natives/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "noEmit": true }, - "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "bench/**/*.ts"], + "include": ["native/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 286d4e20b..55f8f05cd 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -18,8 +18,8 @@ * - isKittyProtocolActive() - Query global Kitty protocol state */ +import type { KeyEventType } from "@oh-my-pi/pi-natives"; import { - type KeyEventType, matchesKey as matchesKeyNative, parseKey as parseKeyNative, parseKittySequence as parseKittySequenceNative, diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index b2f38e5e5..9dfc5f06e 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -1,5 +1,4 @@ -import { sliceWithWidth } from "@oh-my-pi/pi-natives"; -import { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils"; +import { getDefaultTabWidth, getIndentation, sliceWithWidth } from "@oh-my-pi/pi-natives"; export { Ellipsis, extractSegments, sliceWithWidth, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-natives"; @@ -9,8 +8,8 @@ const SPACE_BUFFER = " ".repeat(512); /* * Replace tabs with configured spacing for consistent rendering. */ -export function replaceTabs(text: string): string { - return text.replaceAll("\t", getIndentation()); +export function replaceTabs(text: string, file?: string): string { + return text.replaceAll("\t", " ".repeat(getIndentation(file))); } /** diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index b7c81f3dc..b336eba65 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, it } from "bun:test"; import { stripVTControlCharacters } from "node:util"; +import { setDefaultTabWidth } from "@oh-my-pi/pi-natives"; import { CURSOR_MARKER } from "@oh-my-pi/pi-tui"; import { CombinedAutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete"; import { Editor } from "@oh-my-pi/pi-tui/components/editor"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; -import { setDefaultTabWidth } from "@oh-my-pi/pi-utils"; import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings"; import { defaultEditorTheme } from "./test-themes"; diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index 6b7a4c9c4..b723d2411 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "bun:test"; +import { getIndentation } from "@oh-my-pi/pi-natives"; import { CURSOR_MARKER } from "@oh-my-pi/pi-tui"; import { Input } from "@oh-my-pi/pi-tui/components/input"; import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; -import { getIndentation } from "@oh-my-pi/pi-utils"; function renderedWidth(input: Input, width: number): number { const [line] = input.render(width); @@ -163,7 +163,7 @@ describe("Input component", () => { expect(input.getValue()).toBe(""); input.handleInput("c\x1b[201~"); - expect(input.getValue()).toBe(`a${getIndentation()}bc`); + expect(input.getValue()).toBe(`a${" ".repeat(getIndentation())}bc`); }); it("never renders a line wider than the terminal width (wide chars)", () => { diff --git a/packages/utils/src/indent.ts b/packages/utils/src/indent.ts deleted file mode 100644 index da3bfe53b..000000000 --- a/packages/utils/src/indent.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * Shared tab indentation resolution utilities. - * - * Resolves tab width from a configurable default and optional per-file `.editorconfig` rules. - * This module intentionally has no dependency on higher-level settings systems. - */ - -import * as fs from "node:fs"; -import * as path from "node:path"; -import { getProjectDir } from "./dirs"; - -const DEFAULT_TAB_WIDTH = 3; -const MIN_TAB_WIDTH = 1; -const MAX_TAB_WIDTH = 16; -const EDITORCONFIG_NAME = ".editorconfig"; - -/** Parsed `.editorconfig` section `[pattern]` with normalized key/value properties */ -interface EditorConfigSection { - pattern: string; - properties: Record; -} - -/** Parsed `.editorconfig` document with top-level `root` flag and ordered sections */ -interface ParsedEditorConfig { - root: boolean; - sections: EditorConfigSection[]; -} - -/** Effective editorconfig indent-related properties merged for one target file */ -interface EditorConfigMatch { - indentStyle?: "space" | "tab"; - indentSize?: number | "tab"; - tabWidth?: number; -} - -const editorConfigCache = new Map(); -const editorConfigChainCache = new Map>(); -const indentationCache = new Map(); -let defaultTabWidth = DEFAULT_TAB_WIDTH; - -function clampTabWidth(value: number): number { - if (!Number.isFinite(value)) return DEFAULT_TAB_WIDTH; - return Math.min(MAX_TAB_WIDTH, Math.max(MIN_TAB_WIDTH, Math.round(value))); -} - -function parsePositiveInteger(value: string | undefined): number | undefined { - if (!value) return undefined; - if (!/^\d+$/.test(value)) return undefined; - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed <= 0) return undefined; - return clampTabWidth(parsed); -} - -function parseEditorConfigFile(content: string): ParsedEditorConfig { - const parsed: ParsedEditorConfig = { root: false, sections: [] }; - let currentSection: EditorConfigSection | null = null; - - for (const rawLine of content.split(/\r?\n/)) { - const line = rawLine.trim(); - if (line.length === 0) continue; - if (line.startsWith("#") || line.startsWith(";")) continue; - - const sectionMatch = line.match(/^\[(.+)\]$/); - if (sectionMatch) { - const pattern = sectionMatch[1].trim(); - if (pattern.length === 0) { - currentSection = null; - continue; - } - currentSection = { pattern, properties: {} }; - parsed.sections.push(currentSection); - continue; - } - - const equalsIndex = line.indexOf("="); - if (equalsIndex === -1) continue; - - const key = line.slice(0, equalsIndex).trim().toLowerCase(); - const value = line - .slice(equalsIndex + 1) - .trim() - .toLowerCase(); - if (key.length === 0) continue; - - if (currentSection === null) { - if (key === "root") parsed.root = value === "true"; - continue; - } - - currentSection.properties[key] = value; - } - - return parsed; -} - -function parseCachedEditorConfig(configPath: string): ParsedEditorConfig | null { - const cached = editorConfigCache.get(configPath); - if (cached) return cached; - - let content: string; - try { - content = fs.readFileSync(configPath, "utf8"); - } catch { - return null; - } - - const parsed = parseEditorConfigFile(content); - editorConfigCache.set(configPath, parsed); - return parsed; -} - -function matchesEditorConfigPattern(pattern: string, relativePath: string): boolean { - const normalizedPattern = pattern.replace(/^\//, ""); - if (normalizedPattern.length === 0) return false; - - const candidates = new Set(); - candidates.add(normalizedPattern); - if (!normalizedPattern.includes("/")) { - candidates.add(`**/${normalizedPattern}`); - } - - for (const candidate of candidates) { - try { - if (new Bun.Glob(candidate).match(relativePath)) { - return true; - } - } catch {} - } - - return false; -} - -function resolveFilePath(file: string): string { - if (path.isAbsolute(file)) return path.normalize(file); - return path.normalize(path.resolve(getProjectDir(), file)); -} - -function collectEditorConfigChain(startDir: string): Array<{ dir: string; parsed: ParsedEditorConfig }> { - const cached = editorConfigChainCache.get(startDir); - if (cached) return cached; - - const chain: Array<{ dir: string; parsed: ParsedEditorConfig }> = []; - let cursor = path.resolve(startDir); - - while (true) { - const configPath = path.join(cursor, EDITORCONFIG_NAME); - const parsed = parseCachedEditorConfig(configPath); - if (parsed) { - chain.push({ dir: cursor, parsed }); - if (parsed.root) break; - } - - const parent = path.dirname(cursor); - if (parent === cursor) break; - cursor = parent; - } - - const result = chain.reverse(); - editorConfigChainCache.set(startDir, result); - return result; -} - -function resolveEditorConfigMatch(absoluteFile: string): EditorConfigMatch | null { - const fileDir = path.dirname(absoluteFile); - const chain = collectEditorConfigChain(fileDir); - if (chain.length === 0) return null; - - const match: EditorConfigMatch = {}; - - for (const { dir, parsed } of chain) { - const relativePath = path.relative(dir, absoluteFile).split(path.sep).join("/"); - for (const section of parsed.sections) { - if (!matchesEditorConfigPattern(section.pattern, relativePath)) continue; - const indentStyle = section.properties.indent_style; - if (indentStyle === "space" || indentStyle === "tab") { - match.indentStyle = indentStyle; - } - - const indentSizeRaw = section.properties.indent_size; - if (indentSizeRaw === "tab") { - match.indentSize = "tab"; - } else { - const indentSize = parsePositiveInteger(indentSizeRaw); - if (indentSize !== undefined) { - match.indentSize = indentSize; - } - } - - const tabWidth = parsePositiveInteger(section.properties.tab_width); - if (tabWidth !== undefined) { - match.tabWidth = tabWidth; - } - } - } - - if (match.indentStyle || match.indentSize !== undefined || match.tabWidth !== undefined) { - return match; - } - return null; -} - -function resolveEditorConfigTabWidth(match: EditorConfigMatch | null, fallbackWidth: number): number | null { - if (!match) return null; - - if (typeof match.indentSize === "number") { - return match.indentSize; - } - - if (match.indentSize === "tab") { - if (typeof match.tabWidth === "number") return match.tabWidth; - return fallbackWidth; - } - - if (typeof match.tabWidth === "number") { - return match.tabWidth; - } - - if (match.indentStyle === "tab") { - return fallbackWidth; - } - - return null; -} - -/** - * Sets the process-wide default tab width used when no file-specific override applies. - * - * @param width Desired tab width in spaces. Values are clamped to a safe range. - */ -export function setDefaultTabWidth(width: number): void { - defaultTabWidth = clampTabWidth(width); - indentationCache.clear(); -} - -/** - * Gets the current process-wide default tab width. - */ -export function getDefaultTabWidth(): number { - return defaultTabWidth; -} - -/** - * Returns indentation used to replace a tab character. - * - * If `file` is provided, `.editorconfig` rules are resolved for that file path and applied. - * Otherwise, the configured default tab width is used. - * - * @param file Optional absolute or project-relative file path for editorconfig resolution - * @returns A string containing N spaces representing one tab - */ -export function getIndentation(file?: string): string { - if (!file) return " ".repeat(getDefaultTabWidth()); - - const absoluteFile = resolveFilePath(file); - const cached = indentationCache.get(absoluteFile); - if (cached) return cached; - - const fallbackWidth = getDefaultTabWidth(); - const editorConfigMatch = resolveEditorConfigMatch(absoluteFile); - const resolvedWidth = resolveEditorConfigTabWidth(editorConfigMatch, fallbackWidth) ?? fallbackWidth; - const result = " ".repeat(clampTabWidth(resolvedWidth)); - indentationCache.set(absoluteFile, result); - return result; -} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index cf1d05c88..96bed972f 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -7,7 +7,6 @@ export * from "./format"; export * from "./fs-error"; export * from "./glob"; export * from "./hook-fetch"; -export * from "./indent"; export * from "./json"; export * as logger from "./logger"; export * from "./mermaid-ascii"; diff --git a/packages/utils/test/spacing.test.ts b/packages/utils/test/spacing.test.ts index cd06eea27..21423ec93 100644 --- a/packages/utils/test/spacing.test.ts +++ b/packages/utils/test/spacing.test.ts @@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { getDefaultTabWidth, getIndentation, setDefaultTabWidth } from "@oh-my-pi/pi-natives"; import { getProjectDir, setProjectDir } from "../src/dirs"; -import { getDefaultTabWidth, getIndentation, setDefaultTabWidth } from "../src/indent"; import { Snowflake } from "../src/snowflake"; describe("spacing", () => { @@ -26,11 +26,11 @@ describe("spacing", () => { it("uses configurable default tab width", () => { expect(getDefaultTabWidth()).toBe(3); - expect(getIndentation()).toBe(" "); + expect(" ".repeat(getIndentation())).toBe(" "); setDefaultTabWidth(5); expect(getDefaultTabWidth()).toBe(5); - expect(getIndentation()).toBe(" "); + expect(" ".repeat(getIndentation())).toBe(" "); }); it("resolves editorconfig rules for file path and falls back to default", async () => { @@ -41,9 +41,9 @@ describe("spacing", () => { ["root = true", "", "[*]", "indent_size = 2", "", "[*.md]", "indent_size = 4"].join("\n"), ); - expect(getIndentation(filePath)).toBe(" "); - expect(getIndentation(path.join(tempDir, "README.md"))).toBe(" "); - expect(getIndentation(path.join(tempDir, "missing.txt"))).toBe(" "); + expect(" ".repeat(getIndentation(filePath))).toBe(" "); + expect(" ".repeat(getIndentation(path.join(tempDir, "README.md")))).toBe(" "); + expect(" ".repeat(getIndentation(path.join(tempDir, "missing.txt")))).toBe(" "); }); it("merges nested editorconfig files from root to leaf", async () => { @@ -54,6 +54,6 @@ describe("spacing", () => { await fs.writeFile(path.join(tempDir, ".editorconfig"), ["root = true", "", "[*]", "indent_size = 2"].join("\n")); await fs.writeFile(path.join(tempDir, "packages", ".editorconfig"), ["[*.ts]", "indent_size = 6"].join("\n")); - expect(getIndentation(filePath)).toBe(" "); + expect(" ".repeat(getIndentation(filePath))).toBe(" "); }); }); diff --git a/tsconfig.base.json b/tsconfig.base.json index 1726417c6..cf8affebd 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,8 +31,7 @@ "@oh-my-pi/pi-coding-agent": ["./packages/coding-agent/src/index.ts"], "@oh-my-pi/pi-coding-agent/hooks": ["./packages/coding-agent/src/extensibility/hooks/index.ts"], "@oh-my-pi/pi-coding-agent/*": ["./packages/coding-agent/src/*"], - "@oh-my-pi/pi-natives": ["./packages/natives/src/index.ts"], - "@oh-my-pi/pi-natives/*": ["./packages/natives/src/*"], + "@oh-my-pi/pi-natives": ["./packages/natives/native"], "@oh-my-pi/omp-stats": ["./packages/stats/src/index.ts"], "@oh-my-pi/omp-stats/*": ["./packages/stats/src/*"], "@oh-my-pi/pi-tui": ["./packages/tui/src/index.ts"],