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.
This commit is contained in:
Generated
+31
@@ -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"
|
||||
|
||||
@@ -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<String>` — strip filename prefix, leading colon, trailing #XXXX
|
||||
- `resolve_exact(chunks, path) -> Option<usize>` — index lookup
|
||||
- `resolve_suffix(chunks, sel) -> Result<usize>` — suffix match ("fn_foo" → "class_Bar.fn_foo")
|
||||
- `resolve_prefix(chunks, sel) -> Result<usize>` — CHUNK_NAME_PREFIXES expansion
|
||||
- `resolve_kind_path(chunks, sel) -> Result<usize>` — "class.fn" → "class_Server.fn_start"
|
||||
- `resolve_by_crc(chunks, crc) -> Option<usize>` — checksum lookup
|
||||
- `resolve_selector(chunks, sel) -> Result<usize, ResolveError>` — orchestrates all of the above
|
||||
- `suggest_paths(chunks, query, limit) -> Vec<String>` — similarity-based suggestions for errors
|
||||
- `sanitize_crc(crc) -> Option<String>`
|
||||
|
||||
**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<InsertionPoint>`
|
||||
- `apply_edits(state: &ChunkStateInner, params: EditParams) -> Result<EditResult>`
|
||||
- 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>`
|
||||
- `ChunkStateInner` struct (source, language, checksum, line_count, parse_errors, chunks, chunk_index)
|
||||
- `#[napi(factory)] fn parse(source, language) -> Result<ChunkState>`
|
||||
- `#[napi(getter)]` for source, language, checksum, line_count, parse_errors
|
||||
- `#[napi] fn resolve(&self, path) -> Option<ChunkInfo>`
|
||||
- `#[napi] fn resolve_selector(&self, selector) -> Result<ChunkInfo>`
|
||||
- `#[napi] fn resolve_by_crc(&self, crc) -> Option<ChunkInfo>`
|
||||
- `#[napi] fn line_to_chunk_path(&self, line) -> Option<String>`
|
||||
- `#[napi] fn line_to_containing_chunk_path(&self, line) -> Option<String>`
|
||||
- `#[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<EditResult>` — 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
|
||||
@@ -44,6 +44,7 @@
|
||||
"packages/*/examples/**/*.ts",
|
||||
"packages/*/scripts/**/*.ts",
|
||||
"packages/*/*.ts",
|
||||
"!packages/natives/native/index.d.ts",
|
||||
"!**/vendor/**/*",
|
||||
"!**/node_modules/**/*",
|
||||
"!**/test-sessions.ts",
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String>,
|
||||
tsfn: ThreadsafeFunction<MacOSAppearance>,
|
||||
/// Last reported appearance — used for dedup so we never fire twice
|
||||
/// for the same value (notification + timer can race).
|
||||
last: Mutex<String>,
|
||||
last: Mutex<Option<MacOSAppearance>>,
|
||||
}
|
||||
|
||||
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<String>) -> Self {
|
||||
pub fn start(tsfn: ThreadsafeFunction<MacOSAppearance>) -> Self {
|
||||
let run_loop: Arc<Mutex<Option<SendableRunLoop>>> = 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<String> {
|
||||
pub fn detect_macos_appearance() -> Option<MacOSAppearance> {
|
||||
#[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<String>,
|
||||
#[napi(ts_arg_type = "(err: null | Error, appearance: MacOSAppearance) => void")]
|
||||
callback: napi::threadsafe_function::ThreadsafeFunction<MacOSAppearance>,
|
||||
) -> napi::Result<Self> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
|
||||
+121
-14
@@ -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<AstMatchStrictness> 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<AstMatchStrictness>) -> 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<Vec<String>>,
|
||||
/// Language override; otherwise inferred from file extension per candidate.
|
||||
pub lang: Option<String>,
|
||||
/// Single file or directory to scan (combined with `glob` when set).
|
||||
pub path: Option<String>,
|
||||
/// Optional glob filter relative to the search root.
|
||||
pub glob: Option<String>,
|
||||
/// Rule selector for multi-rule ast-grep configurations.
|
||||
pub selector: Option<String>,
|
||||
pub strictness: Option<String>,
|
||||
/// Pattern strictness; defaults to smart matching when omitted.
|
||||
pub strictness: Option<AstMatchStrictness>,
|
||||
/// Maximum matches to return after `offset` (default applies when omitted).
|
||||
pub limit: Option<u32>,
|
||||
/// Number of leading matches to skip before applying `limit`.
|
||||
pub offset: Option<u32>,
|
||||
/// When true, include meta-variable bindings per match.
|
||||
#[napi(js_name = "includeMeta")]
|
||||
pub include_meta: Option<bool>,
|
||||
/// Reserved for contextual snippets; not used by the current native find
|
||||
/// path.
|
||||
pub context: Option<u32>,
|
||||
/// Optional cancellation handle (library-specific).
|
||||
pub signal: Option<Unknown<'env>>,
|
||||
/// Wall-clock timeout for the worker task in milliseconds.
|
||||
#[napi(js_name = "timeoutMs")]
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// 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<AstFindMatch>,
|
||||
/// 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<Vec<String>>,
|
||||
}
|
||||
|
||||
/// 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<HashMap<String, String>>,
|
||||
/// Language override; otherwise inferred from discovered files.
|
||||
pub lang: Option<String>,
|
||||
/// Single file or directory to rewrite.
|
||||
pub path: Option<String>,
|
||||
/// Optional glob filter within the search root.
|
||||
pub glob: Option<String>,
|
||||
/// Rule selector for multi-rule configurations.
|
||||
pub selector: Option<String>,
|
||||
pub strictness: Option<String>,
|
||||
/// Pattern strictness for rewrites.
|
||||
pub strictness: Option<AstMatchStrictness>,
|
||||
/// When true (default), compute changes without writing files.
|
||||
#[napi(js_name = "dryRun")]
|
||||
pub dry_run: Option<bool>,
|
||||
/// Cap on replacement applications across all files.
|
||||
#[napi(js_name = "maxReplacements")]
|
||||
pub max_replacements: Option<u32>,
|
||||
/// Cap on distinct files that may be modified.
|
||||
#[napi(js_name = "maxFiles")]
|
||||
pub max_files: Option<u32>,
|
||||
/// Fail the operation when a file cannot be parsed for rewriting.
|
||||
#[napi(js_name = "failOnParseError")]
|
||||
pub fail_on_parse_error: Option<bool>,
|
||||
/// Optional cancellation handle.
|
||||
pub signal: Option<Unknown<'env>>,
|
||||
/// Wall-clock timeout for the worker task in milliseconds.
|
||||
#[napi(js_name = "timeoutMs")]
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<AstReplaceChange>,
|
||||
/// Replacement counts grouped by file.
|
||||
#[napi(js_name = "fileChanges")]
|
||||
pub file_changes: Vec<AstReplaceFileChange>,
|
||||
/// 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<Vec<String>>,
|
||||
}
|
||||
@@ -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<MatchStrictness> {
|
||||
let Some(raw) = value.map(str::trim).filter(|v| !v.is_empty()) else {
|
||||
return Ok(MatchStrictness::Smart);
|
||||
};
|
||||
raw.parse::<MatchStrictness>()
|
||||
.map_err(|err| Error::from_reason(format!("Invalid strictness '{raw}': {err}")))
|
||||
}
|
||||
|
||||
fn normalize_search_path(path: Option<String>) -> Result<PathBuf> {
|
||||
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<AstFindResult> {
|
||||
pub fn ast_grep(options: AstFindOptions<'_>) -> task::Promise<AstFindResult> {
|
||||
let AstFindOptions {
|
||||
patterns,
|
||||
lang,
|
||||
@@ -506,7 +611,7 @@ pub fn ast_grep(options: AstFindOptions<'_>) -> task::Async<AstFindResult> {
|
||||
|
||||
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<AstFindResult> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<AstReplaceResult> {
|
||||
pub fn ast_edit(options: AstReplaceOptions<'_>) -> task::Promise<AstReplaceResult> {
|
||||
let AstReplaceOptions {
|
||||
rewrites,
|
||||
lang,
|
||||
@@ -657,7 +764,7 @@ pub fn ast_edit(options: AstReplaceOptions<'_>) -> task::Async<AstReplaceResult>
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -744,6 +744,8 @@ pub fn is_trivia(kind: &str) -> bool {
|
||||
| "xml_decl"
|
||||
| "else_directive"
|
||||
| "elsif_directive"
|
||||
| "attribute_item"
|
||||
| "inner_attribute_item"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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<usize>,
|
||||
) -> 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::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub fn reindent_inserted_block(
|
||||
content: &str,
|
||||
target_indent: &str,
|
||||
file_indent_step: Option<usize>,
|
||||
) -> String {
|
||||
let lines = content.split('\n').collect::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>().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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
} else {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| (*line).to_owned())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
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<String> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<ChunkTree> {
|
||||
build_chunk_tree(source.as_str(), language.as_str())
|
||||
}
|
||||
|
||||
#[napi(js_name = "resolveChunkPath")]
|
||||
pub fn resolve_chunk_path(tree: ChunkTree, chunk_path: String) -> Option<ChunkNode> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
line_to_containing_chunk_path(&tree, line)
|
||||
}
|
||||
|
||||
#[napi(js_name = "renderChunkTree")]
|
||||
pub fn render_chunk_tree_napi(params: RenderChunkTreeParams) -> Result<String> {
|
||||
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<bool>,
|
||||
) -> 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<ChunkTree
|
||||
|
||||
classifier.post_process(&mut acc.chunks, &mut root_children, source);
|
||||
|
||||
insert_file_preamble_chunk(source, &mut acc.chunks, &mut root_children);
|
||||
insert_preamble_chunk(source, &mut acc.chunks, &mut root_children);
|
||||
|
||||
acc.chunks.insert(0, ChunkNode {
|
||||
path: String::new(),
|
||||
name: "<root>".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<ChunkTree
|
||||
})
|
||||
}
|
||||
|
||||
/// Smallest chunk path containing `line` (1-based file line), preferring the
|
||||
/// innermost leaf when multiple chunks overlap.
|
||||
pub(crate) fn line_to_chunk_path(tree: &ChunkTree, line: u32) -> Option<String> {
|
||||
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<Tree> {
|
||||
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: "<root>".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<ChunkNode>,
|
||||
root_children: &mut Vec<String>,
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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::<Vec<_>>();
|
||||
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() {
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
pub fn sanitize_chunk_selector(selector: Option<&str>) -> Option<String> {
|
||||
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<String> {
|
||||
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<String>,
|
||||
) -> 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<String>,
|
||||
) -> 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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<String>,
|
||||
) -> Result<ResolvedChunk<'a>, 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<String>,
|
||||
) -> Result<ResolvedChunk<'a>, 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::<Vec<_>>();
|
||||
|
||||
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() {
|
||||
"<root>"
|
||||
} else {
|
||||
chunk.path.as_str()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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<F>(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<String>,
|
||||
selector_label: &str,
|
||||
warning_label: &str,
|
||||
) -> Result<Option<&'a ChunkNode>, 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::<Vec<_>>()
|
||||
.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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<String>, Vec<String>, Option<String>) {
|
||||
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::<Vec<_>>();
|
||||
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<String> {
|
||||
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::<Vec<_>>();
|
||||
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::<BTreeSet<_>>();
|
||||
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<usize> {
|
||||
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)
|
||||
}
|
||||
@@ -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<Regex> =
|
||||
LazyLock::new(|| Regex::new(CHECKSUM_SUFFIX_RE).expect("checksum selector regex must compile"));
|
||||
static LINE_RANGE_SELECTOR_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(LINE_RANGE_SELECTOR_RE).expect("line range regex must compile"));
|
||||
static TLAPLUS_BEGIN_TRANSLATION_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(TLAPLUS_BEGIN_TRANSLATION_RE).expect("tlaplus begin regex must compile")
|
||||
});
|
||||
static TLAPLUS_END_TRANSLATION_REGEX: LazyLock<Regex> = 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<String, usize>,
|
||||
}
|
||||
|
||||
impl ChunkStateInner {
|
||||
pub(crate) fn parse(source: String, language: String) -> Result<Self> {
|
||||
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<ChunkInfo> {
|
||||
self.chunk(path).map(chunk_info)
|
||||
}
|
||||
|
||||
pub(crate) fn chunks(&self) -> impl Iterator<Item = &ChunkNode> {
|
||||
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<String> {
|
||||
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<ChunkStateInner>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<String> {
|
||||
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<ChunkInfo> {
|
||||
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<ChunkInfo> {
|
||||
self.inner.chunk_info(chunk_path.as_str())
|
||||
}
|
||||
|
||||
/// Every chunk node as a [`ChunkInfo`] list.
|
||||
#[napi]
|
||||
pub fn chunks(&self) -> Vec<ChunkInfo> {
|
||||
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<String>) -> Result<Vec<ChunkInfo>> {
|
||||
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<String> {
|
||||
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<ReadResult> {
|
||||
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<EditResult> {
|
||||
crate::chunk::edit::apply_edits(self, ¶ms).map_err(Error::from_reason)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ParsedChunkReadPath {
|
||||
selector: Option<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<usize> {
|
||||
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<VisibleLineRange> {
|
||||
let captures = LINE_RANGE_SELECTOR_REGEX.captures(selector)?;
|
||||
let start_line = captures.get(1)?.as_str().parse::<u32>().ok()?.max(1);
|
||||
let end_line = captures
|
||||
.get(2)
|
||||
.and_then(|m| m.as_str().parse::<u32>().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::<Vec<_>>();
|
||||
let mut masked = lines
|
||||
.iter()
|
||||
.map(|line| (*line).to_string())
|
||||
.collect::<Vec<_>>();
|
||||
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")
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub children: Vec<String>,
|
||||
pub signature: Option<String>,
|
||||
#[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<String>,
|
||||
pub chunks: Vec<ChunkNode>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
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<String>,
|
||||
pub checksum: String,
|
||||
/// Restrict output to an inclusive line range of the file.
|
||||
#[napi(js_name = "visibleRange")]
|
||||
pub visible_range: Option<VisibleLineRange>,
|
||||
/// 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<String>,
|
||||
pub anchor_style: Option<ChunkAnchorStyle>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Hide checksums in rendered anchors.
|
||||
#[napi(js_name = "omitChecksum")]
|
||||
pub omit_checksum: bool,
|
||||
/// Anchor formatting style.
|
||||
#[napi(js_name = "anchorStyle")]
|
||||
pub anchor_style: Option<ChunkAnchorStyle>,
|
||||
/// Optional absolute file line range to intersect with the resolved chunk.
|
||||
#[napi(js_name = "absoluteLineRange")]
|
||||
pub absolute_line_range: Option<VisibleLineRange>,
|
||||
/// Replace tabs in embedded previews.
|
||||
#[napi(js_name = "tabReplacement")]
|
||||
pub tab_replacement: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<ChunkReadTarget>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Optional checksum anchor; falls back to `EditParams.defaultCrc` when
|
||||
/// omitted.
|
||||
pub crc: Option<String>,
|
||||
/// Replacement or inserted text (meaning depends on `op`).
|
||||
pub content: Option<String>,
|
||||
/// For line-scoped `replace`, 1-based start line inside the target chunk.
|
||||
pub line: Option<u32>,
|
||||
/// For line-scoped `replace`, 1-based end line inside the chunk (defaults to
|
||||
/// `line`).
|
||||
#[napi(js_name = "endLine")]
|
||||
pub end_line: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<EditOperation>,
|
||||
/// Default chunk selector when an `EditOperation` omits `sel`.
|
||||
#[napi(js_name = "defaultSelector")]
|
||||
pub default_selector: Option<String>,
|
||||
/// Default checksum when an `EditOperation` omits `crc`.
|
||||
#[napi(js_name = "defaultCrc")]
|
||||
pub default_crc: Option<String>,
|
||||
/// Anchor formatting for rendered response text.
|
||||
#[napi(js_name = "anchorStyle")]
|
||||
pub anchor_style: Option<ChunkAnchorStyle>,
|
||||
/// 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<String>,
|
||||
/// Non-fatal issues (e.g. selector warnings) collected during apply.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -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<Option<ClipboardImage>> {
|
||||
pub fn read_image_from_clipboard() -> task::Promise<Option<ClipboardImage>> {
|
||||
task::blocking("clipboard.read_image", (), move |_| -> Result<Option<ClipboardImage>> {
|
||||
let mut clipboard = Clipboard::new()
|
||||
.map_err(|err| Error::from_reason(format!("Failed to access clipboard: {err}")))?;
|
||||
|
||||
@@ -329,7 +329,7 @@ fn fuzzy_find_sync(
|
||||
pub fn fuzzy_find(
|
||||
options: FuzzyFindOptions<'_>,
|
||||
db: Option<&SearchDb>,
|
||||
) -> task::Async<FuzzyFindResult> {
|
||||
) -> task::Promise<FuzzyFindResult> {
|
||||
let FuzzyFindOptions { query, path, hidden, gitignore, cache, max_results, timeout_ms, signal } =
|
||||
options;
|
||||
let ct = task::CancelToken::new(timeout_ms, signal);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<GlobMatch>,
|
||||
>,
|
||||
#[napi(ts_arg_type = "((error: Error | null, match: GlobMatch) => void) | undefined | null")]
|
||||
on_match: Option<ThreadsafeFunction<GlobMatch>>,
|
||||
db: Option<&SearchDb>,
|
||||
) -> task::Async<GlobResult> {
|
||||
) -> task::Promise<GlobResult> {
|
||||
let GlobOptions {
|
||||
pattern,
|
||||
path,
|
||||
|
||||
@@ -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<u32>,
|
||||
/// Output mode (content or count).
|
||||
pub mode: Option<String>,
|
||||
pub mode: Option<GrepOutputMode>,
|
||||
}
|
||||
|
||||
/// Options for searching files on disk.
|
||||
@@ -112,7 +128,7 @@ pub struct GrepOptions<'env> {
|
||||
#[napi(js_name = "maxColumns")]
|
||||
pub max_columns: Option<u32>,
|
||||
/// Output mode (content, filesWithMatches, or count).
|
||||
pub mode: Option<String>,
|
||||
pub mode: Option<GrepOutputMode>,
|
||||
/// Abort signal for cancelling the operation.
|
||||
pub signal: Option<Unknown<'env>>,
|
||||
/// 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<GrepOutputMode>) -> 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<SearchResultInternal> {
|
||||
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<u32>,
|
||||
context: Option<u32>,
|
||||
max_columns: Option<u32>,
|
||||
mode: Option<String>,
|
||||
mode: Option<GrepOutputMode>,
|
||||
}
|
||||
|
||||
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<JsString, Uint8Array>, options: SearchOptions) ->
|
||||
pub fn has_match(
|
||||
content: Either<JsString, Uint8Array>,
|
||||
pattern: Either<JsString, Uint8Array>,
|
||||
ignore_case: bool,
|
||||
multiline: bool,
|
||||
ignore_case: Option<bool>,
|
||||
multiline: Option<bool>,
|
||||
) -> Result<bool> {
|
||||
// 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<GrepMatch>,
|
||||
>,
|
||||
#[napi(ts_arg_type = "((error: Error | null, match: GrepMatch) => void) | undefined | null")]
|
||||
on_match: Option<ThreadsafeFunction<GrepMatch>>,
|
||||
db: Option<&crate::search_db::SearchDb>,
|
||||
) -> task::Async<GrepResult> {
|
||||
) -> task::Promise<GrepResult> {
|
||||
let GrepOptions {
|
||||
pattern,
|
||||
path,
|
||||
|
||||
@@ -26,7 +26,7 @@ pub struct HtmlToMarkdownOptions {
|
||||
pub fn html_to_markdown(
|
||||
html: String,
|
||||
options: Option<HtmlToMarkdownOptions>,
|
||||
) -> task::Async<String> {
|
||||
) -> task::Promise<String> {
|
||||
let options = options.unwrap_or_default();
|
||||
let clean_content = options.clean_content.unwrap_or(false);
|
||||
let skip_images = options.skip_images.unwrap_or(false);
|
||||
|
||||
@@ -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<DynamicImage>,
|
||||
}
|
||||
|
||||
type ImageTask = task::Async<PhotonImage>;
|
||||
type ImageTask = task::Promise<PhotonImage>;
|
||||
|
||||
#[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<Vec<u8>> {
|
||||
pub fn encode(&self, format: ImageFormat, quality: u8) -> task::Promise<Vec<u8>> {
|
||||
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<DynamicImage> {
|
||||
.decode()
|
||||
.map_err(|e| Error::from_reason(format!("Failed to decode image: {e}")))
|
||||
}
|
||||
fn encode_image(img: &DynamicImage, format: u8, quality: u8) -> Result<Vec<u8>> {
|
||||
fn encode_image(img: &DynamicImage, format: ImageFormat, quality: u8) -> Result<Vec<u8>> {
|
||||
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}"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DashMap<String, ParsedEditorConfig>> =
|
||||
LazyLock::new(DashMap::new);
|
||||
static EDITOR_CONFIG_CHAIN_CACHE: LazyLock<DashMap<String, Vec<(PathBuf, ParsedEditorConfig)>>> =
|
||||
LazyLock::new(DashMap::new);
|
||||
static INDENTATION_CACHE: LazyLock<DashMap<String, u32>> = LazyLock::new(DashMap::new);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct EditorConfigSection {
|
||||
pattern: String,
|
||||
properties: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ParsedEditorConfig {
|
||||
root: bool,
|
||||
sections: Vec<EditorConfigSection>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum IndentStyle {
|
||||
Space,
|
||||
Tab,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum IndentSize {
|
||||
Spaces(u32),
|
||||
Tab,
|
||||
}
|
||||
|
||||
struct EditorConfigMatch {
|
||||
indent_style: Option<IndentStyle>,
|
||||
indent_size: Option<IndentSize>,
|
||||
tab_width: Option<u32>,
|
||||
}
|
||||
|
||||
fn clamp_tab_width(value: u32) -> u32 {
|
||||
value.clamp(MIN_TAB_WIDTH, MAX_TAB_WIDTH)
|
||||
}
|
||||
|
||||
fn parse_positive_integer(value: Option<&str>) -> Option<u32> {
|
||||
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<usize> = 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<ParsedEditorConfig> {
|
||||
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<Result<GlobSet, _>> = 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<EditorConfigMatch> {
|
||||
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<u32> {
|
||||
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<String>, 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<String>,
|
||||
project_dir: Option<String>,
|
||||
) -> 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)
|
||||
}
|
||||
@@ -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<u32>) -> Option<KeyEventType> {
|
||||
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<i32> {
|
||||
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<u32>,
|
||||
pub event_type: Option<KeyEventType>,
|
||||
}
|
||||
|
||||
/// Perfect hash map for legacy sequences - O(1) lookup
|
||||
@@ -362,7 +383,7 @@ pub fn parse_kitty_sequence_napi(data: String) -> Option<ParsedKittyResult> {
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
@@ -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<String>,
|
||||
>,
|
||||
#[napi(ts_arg_type = "((error: Error | null, chunk: string) => void) | undefined | null")]
|
||||
on_chunk: Option<ThreadsafeFunction<String>>,
|
||||
) -> Result<PromiseRaw<'env, PtyRunResult>> {
|
||||
let run_config = PtyRunConfig {
|
||||
command: options.command,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<String>,
|
||||
>,
|
||||
#[napi(ts_arg_type = "((error: Error | null, chunk: string) => void) | undefined | null")]
|
||||
on_chunk: Option<ThreadsafeFunction<String>>,
|
||||
) -> Result<PromiseRaw<'e, ShellRunResult>> {
|
||||
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<String>,
|
||||
>,
|
||||
#[napi(ts_arg_type = "((error: Error | null, chunk: string) => void) | undefined | null")]
|
||||
on_chunk: Option<ThreadsafeFunction<String>>,
|
||||
) -> Result<PromiseRaw<'env, ShellExecuteResult>> {
|
||||
let config =
|
||||
ShellConfig { session_env: options.session_env, snapshot_path: options.snapshot_path };
|
||||
|
||||
@@ -270,7 +270,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub type Async<T> = AsyncTask<Blocking<T>>;
|
||||
pub type Promise<T> = AsyncTask<Blocking<T>>;
|
||||
|
||||
/// Create an `AsyncTask` that runs blocking work on libuv's thread pool.
|
||||
///
|
||||
|
||||
@@ -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<u32>) -> 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<u32> = 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::<EnvTabWidth>() {
|
||||
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<u32>) -> 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<u16>) -> Utf16String {
|
||||
@@ -48,6 +90,8 @@ fn build_utf16_string(mut data: Vec<u16>) -> 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<u32>,
|
||||
) -> Result<Vec<Utf16String>> {
|
||||
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<Ellipsis>,
|
||||
pad: Option<bool>,
|
||||
tab_width: Option<u32>,
|
||||
) -> Result<Either<JsString<'_>, Utf16String>> {
|
||||
) -> Result<Either<JsString<'a>, 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<bool>,
|
||||
tab_width: Option<u32>,
|
||||
) -> Result<SliceResult> {
|
||||
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<Either<JsString<'_>, Utf16Str
|
||||
///
|
||||
/// Tabs count as a fixed-width cell.
|
||||
#[napi(js_name = "visibleWidth")]
|
||||
pub fn visible_width_napi(text: JsString, tab_width: Option<u32>) -> Result<u32> {
|
||||
pub fn visible_width_napi(env: &napi::Env, text: JsString, tab_width: Option<u32>) -> Result<u32> {
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
- HTML export with syntax highlighting and collapsible sections
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
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));
|
||||
|
||||
@@ -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<void> {
|
||||
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)}`));
|
||||
|
||||
@@ -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<void> {
|
||||
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 ?? "",
|
||||
|
||||
@@ -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]*?\}\}/;
|
||||
|
||||
|
||||
@@ -55,8 +55,10 @@ async function executeCommand(commandConfig: string): Promise<string | undefined
|
||||
async function runShellCommand(command: string, timeoutMs: number): Promise<string | undefined> {
|
||||
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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<TInput> {
|
||||
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<TInput> {
|
||||
}
|
||||
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<string | undefined> => {
|
||||
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<TInput> {
|
||||
case "prepend_sibling": {
|
||||
const { content, sel } = details as unknown as Static<typeof pInsertDetails>;
|
||||
if (!content) {
|
||||
throw new Error(`Content required for ${op} on ${sel ?? "<root>"}`);
|
||||
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<TInput> {
|
||||
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<TInput> {
|
||||
}
|
||||
case "delete": {
|
||||
const { sel, crc } = details as unknown as Static<typeof pDeleteDetails>;
|
||||
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<TInput> {
|
||||
|
||||
const chunkResult = applyChunkEdits({
|
||||
source: rawContent,
|
||||
language: getLanguageFromPath(resolvedPath),
|
||||
language: chunkLanguage,
|
||||
cwd: this.session.cwd,
|
||||
filePath: resolvedPath,
|
||||
operations: normalizedOperations,
|
||||
|
||||
@@ -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.
|
||||
</operations>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
Searches files using regex and annotates each match with its containing chunk path.
|
||||
|
||||
<instruction>
|
||||
- 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.
|
||||
</instruction>
|
||||
|
||||
<critical>
|
||||
- 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.
|
||||
</critical>
|
||||
@@ -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}}
|
||||
</output>
|
||||
|
||||
<critical>
|
||||
|
||||
@@ -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| }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -234,8 +234,8 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
||||
});
|
||||
};
|
||||
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("/")) {
|
||||
|
||||
@@ -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<typeof grepSchema, GrepToolDetails> {
|
||||
|
||||
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<typeof grepSchema, GrepToolDetails> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -463,7 +463,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
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<typeof readSchema, ReadToolDetails> {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+3
-15
@@ -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<ClipboardImage | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
return native.readImageFromClipboard();
|
||||
return (await native.readImageFromClipboard()) ?? null;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
Vendored
+1331
File diff suppressed because it is too large
Load Diff
@@ -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 ---
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
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.<platform>-<arch>.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.");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<AstFindResult> {
|
||||
return native.astGrep(options);
|
||||
}
|
||||
|
||||
export async function astEdit(options: AstReplaceOptions): Promise<AstReplaceResult> {
|
||||
return native.astEdit(options);
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
export interface AstFindResult {
|
||||
matches: AstFindMatch[];
|
||||
totalMatches: number;
|
||||
filesWithMatches: number;
|
||||
filesSearched: number;
|
||||
limitReached: boolean;
|
||||
parseErrors?: string[];
|
||||
}
|
||||
|
||||
export interface AstReplaceOptions extends Cancellable {
|
||||
rewrites?: Record<string, string>;
|
||||
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<AstFindResult>;
|
||||
astEdit(options: AstReplaceOptions): Promise<AstReplaceResult>;
|
||||
}
|
||||
}
|
||||
@@ -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<T> = (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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
/**
|
||||
* Read an image from the clipboard.
|
||||
* @returns PNG payload or null when no image is available.
|
||||
*/
|
||||
readImageFromClipboard(): Promise<ClipboardImage | 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;
|
||||
@@ -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<GlobResult> {
|
||||
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);
|
||||
}
|
||||
@@ -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<GlobMatch>, db?: SearchDb): Promise<GlobResult>;
|
||||
/** Invalidate the filesystem scan cache for the given path (or all caches if omitted). */
|
||||
invalidateFsScanCache(path?: string): void;
|
||||
}
|
||||
}
|
||||
@@ -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<GrepResult> {
|
||||
// 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<FuzzyFindResult> {
|
||||
return native.fuzzyFind(options, db);
|
||||
}
|
||||
@@ -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<FuzzyFindResult>;
|
||||
/** Search files for a regex pattern. */
|
||||
grep(options: GrepOptions, onMatch?: TsFunc<GrepMatch>, db?: SearchDb): Promise<GrepResult>;
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
return native.htmlToMarkdown(html, options);
|
||||
}
|
||||
@@ -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<string>;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<Uint8Array>;
|
||||
/**
|
||||
* Resize the image to the requested dimensions with the filter.
|
||||
* Returns a new image instance.
|
||||
*/
|
||||
resize(width: number, height: number, filter: SamplingFilter): Promise<PhotonImage>;
|
||||
}
|
||||
|
||||
/** Static entrypoints for creating `PhotonImage` instances. */
|
||||
export interface PhotonImageConstructor {
|
||||
/** Parse image bytes (PNG, JPEG, WebP, GIF) into a native image. */
|
||||
parse(bytes: Uint8Array): Promise<PhotonImage>;
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user