diff --git a/crates/pi-ast/src/block.rs b/crates/pi-ast/src/block.rs new file mode 100644 index 000000000..567e9f95f --- /dev/null +++ b/crates/pi-ast/src/block.rs @@ -0,0 +1,223 @@ +//! Resolve the syntactic block that begins on a given source line. +//! +//! Powers the hashline `replace block N:` operator: given a 1-indexed line, +//! parse the source with tree-sitter and return the line span of the outermost +//! named node that *begins* on that line (excluding the whole-file root). Brace +//! languages anchor a construct's block to its opening line, so pointing at the +//! line that opens an `if` / `function` / `struct` resolves to that construct's +//! full span; pointing at a continuation line or a lone closing delimiter +//! resolves to nothing. + +use anyhow::{Result, anyhow}; +use ast_grep_core::tree_sitter::LanguageExt; +use serde::{Deserialize, Serialize}; +use tree_sitter::{Parser, Point}; + +use crate::summary::{node_content_end_line, node_start_line, resolve_language}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockRangeOptions { + /// Source code to inspect. + pub code: String, + /// Language alias (e.g. "rust", "typescript") used before path inference. + pub lang: Option, + /// File path used to infer language by extension when `lang` is omitted. + pub path: Option, + /// 1-indexed source line the block must begin on. + pub line: u32, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct BlockRange { + /// 1-indexed inclusive first line of the resolved block. + pub start_line: u32, + /// 1-indexed inclusive last line of the resolved block. + pub end_line: u32, +} + +/// Count of leading space/tab bytes on `row` (0-indexed), i.e. the byte column +/// of the first content character. Returns `None` when `row` is out of range +/// or the line is blank / whitespace-only — there is no block to resolve there. +fn first_content_column(code: &str, row: usize) -> Option { + let line = code.split('\n').nth(row)?; + for (col, byte) in line.bytes().enumerate() { + if byte != b' ' && byte != b'\t' { + return Some(col); + } + } + None +} + +/// Resolve the block beginning on `options.line`. +/// +/// Returns `None` (a soft "no block here", surfaced as a hard error one layer +/// up) when the language is unrecognized, the line is out of range / blank, no +/// node begins on that line, or the resolved subtree contains a syntax error. +pub fn block_range_at(options: BlockRangeOptions) -> Result> { + let BlockRangeOptions { code, lang, path, line } = options; + if line == 0 || code.is_empty() { + return Ok(None); + } + let Some(language) = resolve_language(lang.as_deref(), path.as_deref()) else { + return Ok(None); + }; + let row = (line - 1) as usize; + let Some(col) = first_content_column(&code, row) else { + return Ok(None); + }; + + let mut parser = Parser::new(); + parser + .set_language(&language.get_ts_language()) + .map_err(|err| anyhow!("Failed to load tree-sitter language: {err}"))?; + let Some(tree) = parser.parse(&code, None) else { + return Ok(None); + }; + let root = tree.root_node(); + + let point = Point::new(row, col); + let Some(leaf) = root.named_descendant_for_point_range(point, point) else { + return Ok(None); + }; + // A leaf whose own start row is earlier than `row` means `point` landed on + // a continuation line or a closing delimiter of a block that opened earlier + // — there is no block *beginning* on line N. + if leaf.start_position().row != row { + return Ok(None); + } + // Climb to the outermost named ancestor that still begins on `row`, + // excluding the whole-file root. Ancestors can only begin on an earlier + // row, so the first parent that starts before `row` stops the climb. + let mut node = leaf; + while let Some(parent) = node.parent() { + if parent.id() == root.id() { + break; + } + if parent.start_position().row != row { + break; + } + node = parent; + } + // Refuse degenerate error-recovery spans: a missing brace can make + // tree-sitter wrap a huge region in an ERROR node. Checking only the + // resolved node's subtree (not the whole file) keeps an unrelated syntax + // error elsewhere from disabling the feature. + if node.has_error() { + return Ok(None); + } + Ok(Some(BlockRange { + start_line: node_start_line(node), + end_line: node_content_end_line(node), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resolve(code: &str, path: &str, line: u32) -> Option { + block_range_at(BlockRangeOptions { + code: code.to_string(), + lang: None, + path: Some(path.to_string()), + line, + }) + .expect("block resolution succeeds") + } + + const TS_EXAMPLE: &str = "function x() {\n if (y) {\n }\n}\n"; + + #[test] + fn resolves_inner_if_block() { + assert_eq!(resolve(TS_EXAMPLE, "x.ts", 2), Some(BlockRange { start_line: 2, end_line: 3 })); + } + + #[test] + fn resolves_enclosing_function_block() { + assert_eq!(resolve(TS_EXAMPLE, "x.ts", 1), Some(BlockRange { start_line: 1, end_line: 4 })); + } + + #[test] + fn lone_closing_brace_resolves_to_nothing() { + // Line 3 is ` }` — the closing delimiter of a block that opened on an + // earlier line, so no block *begins* there. + assert_eq!(resolve(TS_EXAMPLE, "x.ts", 3), None); + } + + #[test] + fn blank_line_resolves_to_nothing() { + let code = "function x() {\n\n return 1;\n}\n"; + assert_eq!(resolve(code, "x.ts", 2), None); + } + + #[test] + fn out_of_range_line_resolves_to_nothing() { + assert_eq!(resolve(TS_EXAMPLE, "x.ts", 99), None); + assert_eq!(resolve(TS_EXAMPLE, "x.ts", 0), None); + } + + #[test] + fn unrecognized_extension_resolves_to_nothing() { + assert_eq!(resolve(TS_EXAMPLE, "x.unknownext", 2), None); + } + + #[test] + fn resolves_top_level_python_def() { + let code = "x = 1\ndef greet():\n return 1\n"; + assert_eq!(resolve(code, "g.py", 2), Some(BlockRange { start_line: 2, end_line: 3 })); + } + + #[test] + fn resolves_inner_python_block() { + // Point at the `for` loop inside the function body. The suite's first + // statement is `total = 0` (line 2), so the `for` at line 3 is not the + // suite's first child and climbs only to the `for_statement`, not the + // whole function suite. + let code = + "def f(xs):\n total = 0\n for x in xs:\n total += x\n return total\n"; + assert_eq!(resolve(code, "f.py", 3), Some(BlockRange { start_line: 3, end_line: 4 })); + } + + #[test] + fn resolves_nested_block_to_outermost_on_line() { + // Point at the inner `if` line; it resolves the whole `if` block + // (header through its closing brace), not just the call inside it. + let code = "function f() {\n if (a) {\n g();\n }\n}\n"; + assert_eq!(resolve(code, "f.ts", 2), Some(BlockRange { start_line: 2, end_line: 4 })); + } + + #[test] + fn multi_statement_line_resolves_first_statement_node() { + // `let a = 1; let b = 2;` — pointing at the line resolves the first + // statement that begins at the line's first content column. + let code = "let a = 1; let b = 2;\n"; + let range = resolve(code, "m.ts", 1); + assert!(range.is_some(), "expected a block on a single-statement-bearing line"); + assert_eq!(range.unwrap().start_line, 1); + } + + #[test] + fn continuation_line_resolves_to_nothing() { + // A bare argument-continuation line whose first content does not open a + // new named node beginning on that row. + let code = "foo(\n a,\n b,\n);\n"; + // Line 2 (` a,`) is an argument — `a` is an identifier beginning on the + // row, so it DOES resolve. Use the closing `);` line instead, which is + // a continuation/closer of the call begun earlier. + assert_eq!(resolve(code, "c.ts", 4), None); + } + + #[test] + fn error_subtree_resolves_to_nothing() { + // Missing closing brace: the function's subtree carries an ERROR, so we + // refuse to resolve a degenerate recovery span. + let code = "function broken() {\n if (y) {\n}\n"; + assert_eq!(resolve(code, "b.ts", 1), None); + } + + #[test] + fn resolves_rust_struct_block() { + let code = "struct A;\nstruct B {\n x: u32,\n}\n"; + assert_eq!(resolve(code, "r.rs", 2), Some(BlockRange { start_line: 2, end_line: 4 })); + } +} diff --git a/crates/pi-ast/src/lib.rs b/crates/pi-ast/src/lib.rs index 51f21642f..971081275 100644 --- a/crates/pi-ast/src/lib.rs +++ b/crates/pi-ast/src/lib.rs @@ -1,3 +1,4 @@ +pub mod block; pub mod language; pub mod ops; pub mod summary; diff --git a/crates/pi-ast/src/summary.rs b/crates/pi-ast/src/summary.rs index a246d2b29..19cb7f061 100644 --- a/crates/pi-ast/src/summary.rs +++ b/crates/pi-ast/src/summary.rs @@ -208,7 +208,7 @@ pub fn summarize_code(options: SummaryOptions) -> Result { }) } -fn resolve_language(lang: Option<&str>, path: Option<&str>) -> Option { +pub(crate) fn resolve_language(lang: Option<&str>, path: Option<&str>) -> Option { if let Some(lang) = lang.map(str::trim).filter(|lang| !lang.is_empty()) { return SupportLang::from_alias(lang); } @@ -354,7 +354,7 @@ fn flush_groupable_run( } } -fn node_start_line(node: Node<'_>) -> u32 { +pub(crate) fn node_start_line(node: Node<'_>) -> u32 { node .start_position() .row @@ -376,7 +376,7 @@ fn node_end_line(node: Node<'_>) -> u32 { /// When that byte is a newline, the resulting position lands at column 0 of /// the next row, which makes the naive `row + 1` answer one greater than the /// row of the last visible content. This helper subtracts that off. -fn node_content_end_line(node: Node<'_>) -> u32 { +pub(crate) fn node_content_end_line(node: Node<'_>) -> u32 { let pos = node.end_position(); let row = if pos.column == 0 && pos.row > 0 { pos.row - 1 diff --git a/crates/pi-natives/src/block.rs b/crates/pi-natives/src/block.rs new file mode 100644 index 000000000..88d942693 --- /dev/null +++ b/crates/pi-natives/src/block.rs @@ -0,0 +1,47 @@ +//! Resolve the syntactic block beginning on a source line (tree-sitter). + +use napi::bindgen_prelude::*; +use napi_derive::napi; + +#[napi(object)] +pub struct BlockRangeOptions { + /// Source code to inspect. + pub code: String, + /// Language alias (e.g. "rust", "typescript") used before path inference. + pub lang: Option, + /// File path used to infer language by extension when `lang` is omitted. + pub path: Option, + /// 1-indexed source line the block must begin on. + pub line: u32, +} + +#[napi(object)] +pub struct BlockRange { + /// 1-indexed inclusive first line of the resolved block. + pub start_line: u32, + /// 1-indexed inclusive last line of the resolved block. + pub end_line: u32, +} + +impl From for BlockRange { + fn from(value: pi_ast::block::BlockRange) -> Self { + Self { start_line: value.start_line, end_line: value.end_line } + } +} + +/// Find the outermost named tree-sitter node that begins on `options.line`. +/// +/// Returns its 1-indexed inclusive line span, or `null` when the language is +/// unrecognized, the line is out of range / blank, no node begins on that line, +/// or the resolved subtree contains a syntax error. +#[napi] +pub fn block_range_at(options: BlockRangeOptions) -> Result> { + pi_ast::block::block_range_at(pi_ast::block::BlockRangeOptions { + code: options.code, + lang: options.lang, + path: options.path, + line: options.line, + }) + .map(|range| range.map(Into::into)) + .map_err(|error| Error::from_reason(error.to_string())) +} diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 26554cc99..3896b44b2 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -23,6 +23,7 @@ pub mod appearance; pub mod ast; +pub mod block; pub mod clipboard; pub mod fd; pub mod fs_cache; diff --git a/docs/tools/edit.md b/docs/tools/edit.md index 9ae6c5ae1..b0d85ec90 100644 --- a/docs/tools/edit.md +++ b/docs/tools/edit.md @@ -29,7 +29,9 @@ Patch language inside `input`: - **File header**: `¶PATH#TAG` (or `¶PATH` for new-file / head/tail-only inserts). `TAG` is three uppercase-hex chars minted by the session snapshot store. - **Operations**: - `replace N..M:` — replace original lines N..M with the body rows below. + - `replace block N:` — replace the whole tree-sitter block beginning on line N (its header line through its closing line) with the body rows. The line span is resolved at apply time from the file's parse tree; point N at the line that opens the construct. Errors (and steers to `replace N..M:`) when the language is unsupported, line N is blank or a closing delimiter, no node begins there, or the resolved block has a syntax error. - `delete N..M` — delete original lines N..M. No body. + - `delete block N` — delete the whole tree-sitter block beginning on line N (resolved like `replace block N`). No body. Same resolution failure modes and `delete N..M` fallback. - `insert before N:` — insert body rows immediately before line N. - `insert after N:` — insert body rows immediately after line N. - `insert head:` — insert body rows at the start of the file. @@ -56,9 +58,10 @@ The canonical grammar is strict, but the hand parser accepts a few non-dangerous - `*** Update File:` / `*** Add File:` / `*** Delete File:` / `*** Move to:` apply_patch sentinels throw an `apply_patch sentinel … is not valid in hashline` error. - `@@`-bracketed hunk headers are rejected with guidance to write a verb header. - Bare `N` and bare `N M` / `N..M` headers are rejected with guidance to write `replace` or `delete`. -- `delete N..M:` and any body rows under `delete` are rejected. -- Empty `replace` / `insert` hunks are rejected. +- `delete N..M:` and any body rows under `delete` / `delete block` are rejected. +- Empty `replace` / `insert` / `replace block` hunks are rejected. - `-` body rows are rejected with `MINUS_ROW_REJECTED`. +- `replace block N:` / `delete block N` require a wired tree-sitter resolver; `replace block` additionally needs at least one `+TEXT` body row, while `delete block` takes none. An unresolvable block (unsupported language, blank/closing-delimiter line, no node beginning on N, or a syntax error in the resolved block) is rejected on the apply/final-preview path; the streaming preview silently drops it instead. ## Outputs - Single-shot tool result; hashline mode does not use a `resolve` preview/apply handshake. @@ -165,8 +168,12 @@ delete 20 - Empty body-bearing hunk: - `line N: \`replace N..M:\` needs at least one \`+TEXT\` body row. To delete lines, use \`delete N..M\`.` - `line N: \`insert\` needs at least one \`+TEXT\` body row.` + - `line N: \`replace block N:\` needs at least one \`+TEXT\` body row. To delete a block, use \`delete N..M\` with the block's line range.` +- Unresolvable `replace block N:` (apply / final-preview path only): + - `line N: \`replace block X:\` could not resolve a syntactic block beginning on line X. The language may be unsupported, the line may be blank or a closing delimiter, or the block may not parse. Use \`replace X..M:\` with the block's explicit end line instead.` - Delete with body: - `line N: \`delete N..M\` does not take body rows. Remove the body, or use \`replace N..M:\`.` + - `line N: \`delete block N\` does not take body rows. Remove the body, or use \`replace block N:\` to replace the block.` - Range out of order: - `line N: range A..B ends before it starts.` - Overlapping hunks on the same anchor: diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 163a26920..d19e7a0cd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,12 +1,14 @@ # Changelog ## [Unreleased] + ### Added - Added a `Web search` setup tab that lets users choose the preferred `providers.webSearch` provider during onboarding - Added manual authorization-code/redirect URL prompts for OAuth providers that require non-callback login in the setup wizard - Added an `omp completions ` command that prints a shell completion script generated from the live command/flag metadata, so completions never drift from the actual CLI. Subcommands, flags, and enum values complete statically; `--model`/`--smol`/`--slow`/`--plan` resolve against the bundled model catalog and `--resume` against on-disk sessions via a hidden `__complete` helper. - Added a `/switch` slash command that opens the temporary model selector for the current session, mirroring the `alt+p` keybinding. +- Added `replace block N:` and `delete block N` operators to the `edit` tool: they resolve the syntactic block beginning on line N via tree-sitter (native `blockRangeAt`) and replace or delete its full line span, so a construct can be rewritten or removed without counting its closing line. Unresolvable blocks (unsupported language, blank/closing-delimiter line, or a parse error) are rejected with guidance to use an explicit `replace N..M:` / `delete N..M` range. ### Changed @@ -16,6 +18,7 @@ ### Fixed +- Used the native block resolver for hashline operations so `replace block` edits now derive block ranges from file-aware parsing - Fixed OAuth login handling to cancel cleanly when users press Esc or Ctrl+C during authentication - Fixed the `read` tool description advertising `inspect_image` ("for visual analysis, call `inspect_image`") even when the `inspect_image` tool was disabled, which left the model hunting for a tool absent from its function list. The image section is now gated on `inspect_image.enabled`: when disabled it instead states that reading an image path returns the decoded image inline. - Fixed session-title generation latching onto literal text inside fenced code blocks — a pasted UI mockup containing "Welcome to Claude Code v2.1.158" titled the session "Setup Screen for Claude Code v2.1.158" instead of capturing the actual request. The first user message now has fenced code blocks stripped before titling (both the online `pi/smol` and local CPU model paths share the same preprocessing), with a fallback to the original message when stripping would leave too little to title from (e.g. a message that is essentially just a code block). diff --git a/packages/coding-agent/src/edit/hashline/block-resolver.ts b/packages/coding-agent/src/edit/hashline/block-resolver.ts new file mode 100644 index 000000000..9529699bb --- /dev/null +++ b/packages/coding-agent/src/edit/hashline/block-resolver.ts @@ -0,0 +1,14 @@ +/** + * Tree-sitter-backed {@link BlockResolver} for the hashline `replace block N:` + * operator. Bridges the pure hashline seam to the native `blockRangeAt` + * primitive in `@oh-my-pi/pi-natives`, which infers the language from the file + * path and returns the 1-indexed line span of the syntactic block beginning on + * the requested line (or `null` when none can be resolved). + */ +import type { BlockResolver } from "@oh-my-pi/hashline"; +import { blockRangeAt } from "@oh-my-pi/pi-natives"; + +export const nativeBlockResolver: BlockResolver = ({ path, text, line }) => { + const range = blockRangeAt({ code: text, path, line }); + return range ? { start: range.startLine, end: range.endLine } : null; +}; diff --git a/packages/coding-agent/src/edit/hashline/diff.ts b/packages/coding-agent/src/edit/hashline/diff.ts index b354ca723..87d371b94 100644 --- a/packages/coding-agent/src/edit/hashline/diff.ts +++ b/packages/coding-agent/src/edit/hashline/diff.ts @@ -22,6 +22,7 @@ import { import { resolveToCwd } from "../../tools/path-utils"; import { generateDiffString } from "../diff"; import { readEditFileText } from "../read-file"; +import { nativeBlockResolver } from "./block-resolver"; export interface HashlineDiffOptions { /** @@ -74,7 +75,9 @@ export async function computeHashlineSectionDiff( const normalized = normalizeToLF(content); const hashError = validateSectionHash(section, absolutePath, normalized, snapshots); if (hashError) return { error: hashError }; - const result = options.streaming ? section.applyPartialTo(normalized) : section.applyTo(normalized); + const result = options.streaming + ? section.applyPartialTo(normalized, nativeBlockResolver) + : section.applyTo(normalized, nativeBlockResolver); if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` }; return generateDiffString(normalized, result.text); } catch (err) { diff --git a/packages/coding-agent/src/edit/hashline/execute.ts b/packages/coding-agent/src/edit/hashline/execute.ts index d7347bbd2..8a82ccd44 100644 --- a/packages/coding-agent/src/edit/hashline/execute.ts +++ b/packages/coding-agent/src/edit/hashline/execute.ts @@ -25,6 +25,7 @@ import { outputMeta } from "../../tools/output-meta"; import { generateDiffString } from "../diff"; import { getFileSnapshotStore } from "../file-snapshot-store"; import type { EditToolDetails, EditToolPerFileResult, LspBatchRequest } from "../renderer"; +import { nativeBlockResolver } from "./block-resolver"; import { HashlineFilesystem } from "./filesystem"; import { type HashlineParams, hashlineEditParamsSchema } from "./params"; @@ -133,7 +134,7 @@ export async function executeHashlineSingle( batchRequest: options.batchRequest, }); const snapshots = getFileSnapshotStore(options.session); - const patcher = new Patcher({ fs, snapshots }); + const patcher = new Patcher({ fs, snapshots, blockResolver: nativeBlockResolver }); // Single-section fast path: prepare, commit, render. if (patch.sections.length === 1) { diff --git a/packages/coding-agent/src/edit/hashline/index.ts b/packages/coding-agent/src/edit/hashline/index.ts index 7cc3f4569..c9f7e9382 100644 --- a/packages/coding-agent/src/edit/hashline/index.ts +++ b/packages/coding-agent/src/edit/hashline/index.ts @@ -1,3 +1,4 @@ +export * from "./block-resolver"; export * from "./diff"; export * from "./execute"; export * from "./filesystem"; diff --git a/packages/coding-agent/test/core/block-replace.test.ts b/packages/coding-agent/test/core/block-replace.test.ts new file mode 100644 index 000000000..694fc8b4c --- /dev/null +++ b/packages/coding-agent/test/core/block-replace.test.ts @@ -0,0 +1,144 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { formatHashlineHeader } from "@oh-my-pi/hashline"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { + type ExecuteHashlineSingleOptions, + executeHashlineSingle, + getFileSnapshotStore, +} from "@oh-my-pi/pi-coding-agent/edit"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; + +beforeAll(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true, cwd: process.cwd() }); +}); + +async function withTempDir(fn: (tempDir: string) => Promise): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "block-replace-")); + try { + await fn(tempDir); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +function makeSession(tempDir: string): ToolSession { + return { cwd: tempDir, settings: Settings.isolated() } as ToolSession; +} + +function executeOptions(_tempDir: string, input: string, session: ToolSession): ExecuteHashlineSingleOptions { + return { + session, + input, + writethrough: async (targetPath, content) => { + await Bun.write(targetPath, content); + return undefined; + }, + beginDeferredDiagnosticsForPath: () => ({ + onDeferredDiagnostics: () => {}, + signal: new AbortController().signal, + finalize: () => {}, + }), + }; +} + +/** + * Set up a file on disk + a recorded snapshot tag, returning the hashline + * section header bound to the current content. + */ +async function seedFile( + tempDir: string, + session: ToolSession, + name: string, + source: string, +): Promise<{ filePath: string; header: string }> { + const filePath = path.join(tempDir, name); + await Bun.write(filePath, source); + const tag = getFileSnapshotStore(session).record(filePath, source); + return { filePath, header: formatHashlineHeader(name, tag) }; +} + +const TS_SOURCE = "function x() {\n if (y) {\n }\n}\n"; + +describe("replace block — native tree-sitter resolution end-to-end", () => { + it("resolves the inner `if` block (line 2) and replaces its full span", async () => { + await withTempDir(async tempDir => { + const session = makeSession(tempDir); + const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); + const input = `${header}\nreplace block 2:\n+ if (y || z) {\n+ }`; + + await executeHashlineSingle(executeOptions(tempDir, input, session)); + + expect(await Bun.file(filePath).text()).toBe("function x() {\n if (y || z) {\n }\n}\n"); + }); + }); + + it("resolves the enclosing function block (line 1) and replaces the whole construct", async () => { + await withTempDir(async tempDir => { + const session = makeSession(tempDir); + const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); + const input = `${header}\nreplace block 1:\n+function x() {\n+ return 42;\n+}`; + + await executeHashlineSingle(executeOptions(tempDir, input, session)); + + expect(await Bun.file(filePath).text()).toBe("function x() {\n return 42;\n}\n"); + }); + }); + + it("deletes the resolved `if` block (line 2) end-to-end via `delete block`", async () => { + await withTempDir(async tempDir => { + const session = makeSession(tempDir); + const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); + const input = `${header}\ndelete block 2`; + + await executeHashlineSingle(executeOptions(tempDir, input, session)); + + expect(await Bun.file(filePath).text()).toBe("function x() {\n}\n"); + }); + }); + + it("reports the diff for a resolved block edit", async () => { + await withTempDir(async tempDir => { + const session = makeSession(tempDir); + const { header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); + const input = `${header}\nreplace block 2:\n+ if (y || z) {\n+ }`; + + const result = await executeHashlineSingle(executeOptions(tempDir, input, session)); + + const diff = result.details?.diff ?? ""; + expect(diff).toContain("if (y || z)"); + }); + }); + + it("rejects a lone closing delimiter (no block begins there) and steers to `replace N..M:`", async () => { + await withTempDir(async tempDir => { + const session = makeSession(tempDir); + const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); + // Line 3 is ` }` — a closing delimiter, not a block opener. + const input = `${header}\nreplace block 3:\n+ }`; + + await expect(executeHashlineSingle(executeOptions(tempDir, input, session))).rejects.toThrow( + /could not resolve a syntactic block beginning on line 3.*replace 3\.\.M:/s, + ); + // Disk untouched — refusal never leaves a partial write. + expect(await Bun.file(filePath).text()).toBe(TS_SOURCE); + }); + }); + + it("rejects a block edit on an unrecognized language", async () => { + await withTempDir(async tempDir => { + const session = makeSession(tempDir); + const source = "alpha\nbeta\ngamma\n"; + const { filePath, header } = await seedFile(tempDir, session, "data.unknownext", source); + const input = `${header}\nreplace block 1:\n+ALPHA`; + + await expect(executeHashlineSingle(executeOptions(tempDir, input, session))).rejects.toThrow( + /could not resolve a syntactic block/, + ); + expect(await Bun.file(filePath).text()).toBe(source); + }); + }); +}); diff --git a/packages/coding-agent/test/setup-wizard.test.ts b/packages/coding-agent/test/setup-wizard.test.ts index da492f52c..b48618473 100644 --- a/packages/coding-agent/test/setup-wizard.test.ts +++ b/packages/coding-agent/test/setup-wizard.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it } from "bun:test"; import { runOnboardingSetup } from "../src/commands/setup"; import { Settings } from "../src/config/settings"; import { SETTINGS_SCHEMA } from "../src/config/settings-schema"; -import { WebSearchTab } from "../src/modes/setup-wizard/scenes/web-search"; import { ALL_SCENES, CURRENT_SETUP_VERSION, @@ -11,6 +10,7 @@ import { type SetupSceneHost, selectSetupScenes, } from "../src/modes/setup-wizard"; +import { WebSearchTab } from "../src/modes/setup-wizard/scenes/web-search"; import { initTheme, theme } from "../src/modes/theme/theme"; import type { InteractiveModeContext } from "../src/modes/types"; @@ -139,6 +139,65 @@ describe("setup wizard theme previews", () => { }); }); +describe("setup wizard glyph scene", () => { + it("lists Nerd Font first and commits the chosen preset", async () => { + await initTheme(false, "unicode", false, "titanium", "light"); + const settings = Settings.isolated(); + const scene = ALL_SCENES.find(s => s.id === "glyph-mode"); + expect(scene).toBeDefined(); + + let finished = false; + const host = { + ctx: { + settings, + ui: { invalidate: () => {}, requestRender: () => {} }, + }, + requestRender: () => {}, + finish: () => { + finished = true; + }, + setFocus: () => {}, + restoreFocus: () => {}, + } as unknown as SetupSceneHost; + + const controller = scene!.mount(host); + // Row "1" is now Nerd Font (it must lead the list). + controller.handleInput?.("1"); + await Bun.sleep(20); + expect(theme.getSymbolPreset()).toBe("nerd"); + + controller.handleInput?.("\n"); + await Bun.sleep(20); + expect(settings.get("symbolPreset")).toBe("nerd"); + expect(finished).toBe(true); + }); +}); + +describe("setup wizard web search tab", () => { + it("persists the highlighted provider as the web search preference", async () => { + const settings = Settings.isolated(); + const host = { + ctx: { + settings, + session: { modelRegistry: { authStorage: { hasAuth: () => false } } }, + }, + requestRender: () => {}, + finish: () => {}, + setFocus: () => {}, + restoreFocus: () => {}, + } as unknown as SetupSceneHost; + + const tab = new WebSearchTab(host); + tab.handleInput("\x1b[B"); // move off "auto" to the next provider + tab.handleInput("\n"); // confirm the highlighted provider + await Bun.sleep(20); + + const expected = SETTINGS_SCHEMA["providers.webSearch"].ui.options[1].value; + expect(expected).not.toBe("auto"); + expect(settings.get("providers.webSearch")).toBe(expected); + }); +}); + describe("omp setup onboarding trigger", () => { it("starts the normal interactive command with forced setup wizard", async () => { let forceSetupWizard: boolean | undefined; diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 365ab07cc..8b3aa0664 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] +### Added + +- Added `replace block N:` and `delete block N` patch syntax to replace or delete the entire syntactic block that begins on line N using tree-sitter-resolved spans +- Added `BlockResolver` support in `Patcher` and `PatchSection.applyTo`/`applyPartialTo` to wire language-specific block-resolution at apply time +- Added `resolveBlockEdits` and block edit type definitions to the package API for resolving deferred `replace block` / `delete block` edits ## [15.5.13] - 2026-05-29 ### Breaking Changes diff --git a/packages/hashline/src/apply.ts b/packages/hashline/src/apply.ts index 164762bbe..35e69be61 100644 --- a/packages/hashline/src/apply.ts +++ b/packages/hashline/src/apply.ts @@ -7,6 +7,7 @@ * which fixes the common model mistake of a payload that duplicates or drops * the closing delimiter bordering the range (balance-validated; see below). */ +import { UNRESOLVED_BLOCK_INTERNAL } from "./messages"; import { cloneCursor } from "./tokenizer"; import type { Anchor, ApplyResult, Cursor, Edit } from "./types"; @@ -29,7 +30,7 @@ function getCursorAnchors(cursor: Cursor): Anchor[] { return cursor.kind === "before_anchor" || cursor.kind === "after_anchor" ? [cursor.anchor] : []; } -function getEditAnchors(edit: Edit): Anchor[] { +function getEditAnchors(edit: AppliedEdit): Anchor[] { if (edit.kind === "delete") return [edit.anchor]; return getCursorAnchors(edit.cursor); } @@ -407,9 +408,17 @@ function repairBoundaryBalance( * Returns the post-edit text and the first changed line number (1-indexed). * Throws if an anchor is out of bounds. */ -export function applyEdits(text: string, edits: Edit[]): ApplyResult { +export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult { if (edits.length === 0) return { text, firstChangedLine: undefined }; + // Block edits are deferred until `resolveBlockEdits` expands them into + // concrete inserts + deletes. Reaching the applier with one still present + // is an internal wiring bug, not authored-input error. + for (const edit of edits) { + if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL); + } + const appliedEdits = edits as readonly AppliedEdit[]; + const fileLines = text.split("\n"); const lineOrigins: LineOrigin[] = fileLines.map(() => "original"); @@ -418,7 +427,7 @@ export function applyEdits(text: string, edits: Edit[]): ApplyResult { if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line; }; - const targetEdits = edits.map((edit, index) => cloneAppliedEdit(edit, index)); + const targetEdits = appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)); validateLineBounds(targetEdits, fileLines); const { edits: repaired, warnings } = repairBoundaryBalance(targetEdits, fileLines); diff --git a/packages/hashline/src/block.ts b/packages/hashline/src/block.ts new file mode 100644 index 000000000..63333c310 --- /dev/null +++ b/packages/hashline/src/block.ts @@ -0,0 +1,84 @@ +/** + * Expand deferred `replace block N:` edits into concrete inserts + deletes. + * + * The hashline parser cannot expand a block edit on its own — the line span is + * unknown until file text + path (→ language) are available. This transform + * runs at every apply/preview boundary that has text: it calls the injected + * {@link BlockResolver} to resolve each block's `[start, end]` span, then emits + * the exact same `before_anchor` replacement inserts + range deletes that + * `replace start..end:` produces in the parser. After it runs, no `block` edits + * remain, so {@link applyEdits} (and recovery) only ever see resolved edits. + */ +import { BLOCK_RESOLVER_UNAVAILABLE, blockUnresolvedMessage } from "./messages"; +import type { BlockResolver, Cursor, Edit } from "./types"; + +export interface ResolveBlockEditsOptions { + /** + * How to handle a block edit that cannot be resolved (missing resolver or a + * `null` span). `"throw"` (default) raises a `blockUnresolvedMessage` error — + * used by the authoritative apply + final preview paths. `"drop"` silently + * skips the edit — used by the streaming preview, where a half-written file + * or transient parse error must not throw. + */ + onUnresolved?: "throw" | "drop"; +} + +/** True when at least one edit is an unresolved `replace block N:` edit. */ +export function hasBlockEdit(edits: readonly Edit[]): boolean { + return edits.some(edit => edit.kind === "block"); +} + +/** + * Resolve every `replace block N:` edit in `edits` against `text` (parsed as + * the language inferred from `path`). Non-block edits pass through untouched. + * Returns a fresh edit list with no `block` variants. The fast path returns the + * input unchanged when there is nothing to resolve. + * + * Synthesized inserts/deletes carry sequential `index` values for readability + * only — {@link applyEdits} re-derives every edit's index from array order, so + * the passthrough edits keeping their original indices is harmless. + */ +export function resolveBlockEdits( + edits: readonly Edit[], + text: string, + path: string, + resolver: BlockResolver | undefined, + options: ResolveBlockEditsOptions = {}, +): readonly Edit[] { + if (!hasBlockEdit(edits)) return edits; + const onUnresolved = options.onUnresolved ?? "throw"; + const resolved: Edit[] = []; + let synthIndex = 0; + for (const edit of edits) { + if (edit.kind !== "block") { + resolved.push(edit); + continue; + } + const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null; + if (span === null) { + if (onUnresolved === "drop") continue; + throw new Error( + `line ${edit.lineNum}: ${resolver ? blockUnresolvedMessage(edit.anchor.line) : BLOCK_RESOLVER_UNAVAILABLE}`, + ); + } + // Mirror the parser's `replace start..end:` expansion exactly: one + // `before_anchor` replacement insert per payload row at `span.start`, + // then one delete per line across `[span.start, span.end]`. An empty + // `payloads` (from `delete block N`) emits no inserts — a pure deletion. + for (const payload of edit.payloads) { + const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } }; + resolved.push({ + kind: "insert", + cursor, + text: payload, + lineNum: edit.lineNum, + index: synthIndex++, + mode: "replacement", + }); + } + for (let line = span.start; line <= span.end; line++) { + resolved.push({ kind: "delete", anchor: { line }, lineNum: edit.lineNum, index: synthIndex++ }); + } + } + return resolved; +} diff --git a/packages/hashline/src/format.ts b/packages/hashline/src/format.ts index 6867f9e0c..2f4df998e 100644 --- a/packages/hashline/src/format.ts +++ b/packages/hashline/src/format.ts @@ -14,6 +14,8 @@ export const HL_PAYLOAD_REPLACE = "+"; /** Hunk-header keyword for concrete line replacement. */ export const HL_REPLACE_KEYWORD = "replace"; +/** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */ +export const HL_BLOCK_KEYWORD = "block"; /** Hunk-header keyword for concrete line deletion. */ export const HL_DELETE_KEYWORD = "delete"; /** Hunk-header keyword for insertion operations. */ diff --git a/packages/hashline/src/grammar.lark b/packages/hashline/src/grammar.lark index 23d7c3e33..1743b88fa 100644 --- a/packages/hashline/src/grammar.lark +++ b/packages/hashline/src/grammar.lark @@ -7,11 +7,13 @@ file_header: "¶" filename "#" file_hash LF file_hash: /[0-9A-F]{4}/ filename: /[^\s#]+/ -hunk: body_hunk | delete_hunk +hunk: body_hunk | delete_hunk | delete_block_hunk body_hunk: body_header emit_op+ delete_hunk: "delete " header_range LF -body_header: (replace_anchor | insert_anchor) LF +delete_block_hunk: "delete block " LID LF +body_header: (replace_anchor | replace_block_anchor | insert_anchor) LF replace_anchor: "replace " header_range ":" +replace_block_anchor: "replace block " LID ":" insert_anchor: "insert " insert_pos ":" insert_pos: "before " LID | "after " LID | "head" | "tail" emit_op: "+" /(.*)/ LF diff --git a/packages/hashline/src/index.ts b/packages/hashline/src/index.ts index 96e7696c2..b7e61b17a 100644 --- a/packages/hashline/src/index.ts +++ b/packages/hashline/src/index.ts @@ -1,4 +1,5 @@ export * from "./apply"; +export * from "./block"; export * from "./diff-preview"; export * from "./format"; export * from "./fs"; diff --git a/packages/hashline/src/input.ts b/packages/hashline/src/input.ts index 3f8021314..9864d95b6 100644 --- a/packages/hashline/src/input.ts +++ b/packages/hashline/src/input.ts @@ -9,10 +9,11 @@ */ import * as path from "node:path"; import { applyEdits } from "./apply"; +import { resolveBlockEdits } from "./block"; import { HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format"; import { parsePatch, parsePatchStreaming } from "./parser"; import { Tokenizer } from "./tokenizer"; -import type { ApplyResult, Edit, SplitOptions } from "./types"; +import type { ApplyResult, BlockResolver, Edit, SplitOptions } from "./types"; // Pure classification — single shared tokenizer is safe. const TOKENIZER = new Tokenizer(); @@ -251,6 +252,8 @@ export class PatchSection { get hasAnchorScopedEdit(): boolean { return this.edits.some(edit => { if (edit.kind === "delete") return true; + // A `replace block N:` edit is anchored to concrete content on line N. + if (edit.kind === "block") return true; return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor"; }); } @@ -263,6 +266,10 @@ export class PatchSection { lines.add(edit.anchor.line); continue; } + if (edit.kind === "block") { + lines.add(edit.anchor.line); + continue; + } if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") { lines.add(edit.cursor.anchor.line); } @@ -276,10 +283,14 @@ export class PatchSection { * {@link Patcher} owns tag validation and recovery; reach for this * method directly when you've already validated the file content and * just want the result. + * + * `blockResolver` resolves any `replace block N:` edits against `text`; an + * unresolvable block throws (this is the final, authoritative preview path). */ - applyTo(text: string): ApplyResult { + applyTo(text: string, blockResolver?: BlockResolver): ApplyResult { const { edits, warnings } = this.parse(); - const result = applyEdits(text, [...edits]); + const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, { onUnresolved: "throw" }); + const result = applyEdits(text, resolved); // Preserve parse warnings so consumers don't need to call `parse()` // separately. const merged = warnings.length === 0 ? result.warnings : [...warnings, ...(result.warnings ?? [])]; @@ -294,10 +305,15 @@ export class PatchSection { * or a per-token parse error mid-stream) does not throw or emit a phantom * empty-payload edit. Intended for incremental diff previews; the writer * path should always use {@link applyTo}. + * + * `blockResolver` resolves any `replace block N:` edits against `text`; an + * unresolvable block is silently dropped so a half-written file does not + * throw mid-stream. */ - applyPartialTo(text: string): ApplyResult { + applyPartialTo(text: string, blockResolver?: BlockResolver): ApplyResult { const { edits, warnings } = parsePatchStreaming(this.diff); - const result = applyEdits(text, [...edits]); + const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, { onUnresolved: "drop" }); + const result = applyEdits(text, resolved); const merged = warnings.length === 0 ? result.warnings : [...warnings, ...(result.warnings ?? [])]; return merged && merged.length > 0 ? { ...result, warnings: merged } diff --git a/packages/hashline/src/messages.ts b/packages/hashline/src/messages.ts index d7490cd3b..5eff387b3 100644 --- a/packages/hashline/src/messages.ts +++ b/packages/hashline/src/messages.ts @@ -42,9 +42,48 @@ export const MINUS_ROW_REJECTED = /** Error text emitted when a replace hunk has no body. */ export const EMPTY_REPLACE = "`replace N..M:` needs at least one `+TEXT` body row. To delete lines, use `delete N..M`."; +/** Error text emitted when a `replace block N:` hunk has no body. */ +export const EMPTY_BLOCK = + "`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete N..M` with the block's line range."; + +/** + * Error text emitted when a `replace block N:` anchor cannot be resolved to a + * syntactic block (unrecognized language, blank/out-of-range line, no node + * begins on line N such as a lone closing delimiter, or the resolved block has + * a syntax error). Names the offending line and steers back to an explicit + * `replace N..M:` range. + */ +export function blockUnresolvedMessage(line: number): string { + return ( + `\`replace block ${line}:\` could not resolve a syntactic block beginning on line ${line}. ` + + `The language may be unsupported, the line may be blank or a closing delimiter, or the block may not parse. ` + + `Use \`replace ${line}..M:\` with the block's explicit end line instead.` + ); +} + +/** + * Error text emitted when a `replace block N:` edit reaches a code path that + * has no {@link BlockResolver} wired in. Indicates a host-configuration bug + * rather than authored-input error. + */ +export const BLOCK_RESOLVER_UNAVAILABLE = + "`replace block N:` is not available here (no tree-sitter block resolver is configured). Use `replace N..M:` with an explicit range."; + +/** + * Internal invariant error: `applyEdits` received an unresolved `replace block + * N:` edit. Block edits must be expanded by `resolveBlockEdits` before reaching + * the applier; hitting this is a wiring bug, not authored-input error. + */ +export const UNRESOLVED_BLOCK_INTERNAL = + "internal error: unresolved `replace block` edit reached the applier (resolveBlockEdits was not run)."; + /** Error text emitted when a delete hunk receives a body row. */ export const DELETE_TAKES_NO_BODY = "`delete N..M` does not take body rows. Remove the body, or use `replace N..M:`."; +/** Error text emitted when a `delete block N` hunk receives a body row. */ +export const DELETE_BLOCK_TAKES_NO_BODY = + "`delete block N` does not take body rows. Remove the body, or use `replace block N:` to replace the block."; + /** Error text emitted when an insert hunk has no body. */ export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row."; diff --git a/packages/hashline/src/parser.ts b/packages/hashline/src/parser.ts index ff5763ef5..6514ecd72 100644 --- a/packages/hashline/src/parser.ts +++ b/packages/hashline/src/parser.ts @@ -6,7 +6,9 @@ import { HL_PAYLOAD_REPLACE } from "./format"; import { BARE_BODY_AUTO_PIPED_WARNING, + DELETE_BLOCK_TAKES_NO_BODY, DELETE_TAKES_NO_BODY, + EMPTY_BLOCK, EMPTY_INSERT, EMPTY_REPLACE, MINUS_ROW_REJECTED, @@ -159,7 +161,8 @@ export class Executor { endStreaming(): { edits: Edit[]; warnings: string[] } { this.#consumePendingSkippableComments(); if (this.#pending && this.#pending.payloads.length > 0) this.#flushPending(); - else if (this.#pending?.target.kind === "delete") this.#flushPending(); + else if (this.#pending?.target.kind === "delete" || this.#pending?.target.kind === "delete_block") + this.#flushPending(); else this.#pending = undefined; this.#validateNoOverlappingDeletes(); return { edits: this.#edits, warnings: this.#warnings }; @@ -204,6 +207,7 @@ export class Executor { ); } if (pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`); + if (pending.target.kind === "delete_block") throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`); pending.payloads.push({ kind: "literal", text, lineNum }); } @@ -213,6 +217,8 @@ export class Executor { if (this.#pending) { if (text.trim().length === 0) return; if (this.#pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`); + if (this.#pending.target.kind === "delete_block") + throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`); if (text.trimStart().charCodeAt(0) === 45 /* - */) throw new Error(`line ${lineNum}: ${MINUS_ROW_REJECTED}`); if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING); this.#pending.payloads.push({ kind: "literal", text, lineNum }); @@ -240,6 +246,16 @@ export class Executor { this.#edits.push({ kind: "delete", anchor: { ...anchor }, lineNum, index: this.#editIndex++ }); } + #pushBlock(anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number): void { + this.#edits.push({ + kind: "block", + anchor: { ...anchor }, + payloads: payloads.map(payload => payload.text), + lineNum, + index: this.#editIndex++, + }); + } + #emitPayloadRows(cursor: Cursor, payloads: readonly PayloadRow[], lineNum: number, mode?: "replacement"): void { for (const payload of payloads) this.#pushInsert(cursor, payload.text, lineNum, mode); } @@ -253,6 +269,16 @@ export class Executor { for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum); return; } + if (target.kind === "delete_block") { + // A block edit with no payloads resolves to a pure block deletion. + this.#pushBlock(target.anchor, [], lineNum); + return; + } + if (target.kind === "block") { + if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_BLOCK}`); + this.#pushBlock(target.anchor, payloads, lineNum); + return; + } if (payloads.length === 0) { if (target.kind === "replace") throw new Error(`line ${lineNum}: ${EMPTY_REPLACE}`); throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`); diff --git a/packages/hashline/src/patcher.ts b/packages/hashline/src/patcher.ts index f7f9974c3..7204867a6 100644 --- a/packages/hashline/src/patcher.ts +++ b/packages/hashline/src/patcher.ts @@ -23,6 +23,7 @@ * filesystem configuration. */ import { applyEdits } from "./apply"; +import { hasBlockEdit, resolveBlockEdits } from "./block"; import { computeFileHash, formatHashlineHeader } from "./format"; import type { Filesystem, WriteResult } from "./fs"; import { isNotFound } from "./fs"; @@ -32,13 +33,19 @@ import { MismatchError } from "./mismatch"; import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize"; import { Recovery, type RecoveryResult } from "./recovery"; import type { SnapshotStore } from "./snapshots"; -import type { ApplyResult, Edit } from "./types"; +import type { ApplyResult, BlockResolver, Edit } from "./types"; export interface PatcherOptions { /** Storage backend used for all reads and writes. */ fs: Filesystem; /** Snapshot store that minted and resolves hashline section tags. Required. */ snapshots: SnapshotStore; + /** + * Resolves `replace block N:` anchors to concrete line spans via tree-sitter. + * Optional: when omitted, any `replace block N:` edit throws on apply (the + * host did not wire a resolver). Plain line-range ops never need it. + */ + blockResolver?: BlockResolver; } /** Per-section result returned by {@link Patcher.apply} / {@link Patcher.commit}. */ @@ -99,6 +106,8 @@ export class PreparedSection { function hasAnchorScopedEdit(edits: readonly Edit[]): boolean { return edits.some(edit => { if (edit.kind === "delete") return true; + // A `replace block N:` edit anchors to concrete content on line N. + if (edit.kind === "block") return true; return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor"; }); } @@ -147,6 +156,7 @@ export class Patcher { readonly fs: Filesystem; readonly snapshots: SnapshotStore; readonly recovery: Recovery; + readonly blockResolver: BlockResolver | undefined; constructor(options: PatcherOptions) { if (!options.snapshots) { @@ -155,6 +165,7 @@ export class Patcher { this.fs = options.fs; this.snapshots = options.snapshots; this.recovery = new Recovery(options.snapshots); + this.blockResolver = options.blockResolver; } /** @@ -306,6 +317,24 @@ export class Patcher { #recordFullSnapshot(canonicalPath: string, normalized: string): string { return this.snapshots.record(canonicalPath, normalized); } + #mismatchError( + section: PatchSection, + canonicalPath: string, + normalized: string, + expected: string, + hashRecognized: boolean, + ): MismatchError { + const actualFileHash = this.#recordFullSnapshot(canonicalPath, normalized); + return new MismatchError({ + path: section.path, + expectedFileHash: expected, + actualFileHash, + fileLines: normalized.split("\n"), + anchorLines: section.collectAnchorLines(), + hashRecognized, + }); + } + #applyWithRecovery(args: { section: PatchSection; canonicalPath: string; @@ -315,16 +344,37 @@ export class Patcher { }): ApplyResult { const { section, canonicalPath, exists, normalized, edits } = args; const expected = exists ? section.fileHash : undefined; - if (expected === undefined) return applyEdits(normalized, [...edits]); + const liveMatches = expected !== undefined && computeFileHash(normalized) === expected; + + // Resolve `replace block N:` edits to concrete ranges before recovery + // runs. Block anchors are expressed against the snapshot the section tag + // names, so resolve against that exact text: + // - live content matches the tag (or there is no tag) → resolve against + // the live, normalized content; + // - the file drifted → resolve against the tagged snapshot's text so the + // resulting ranges flow through the 3-way-merge recovery below. + // When a block edit needs the tagged snapshot but it is unavailable, the + // range cannot be placed safely — reject with a MismatchError (re-read). + let resolved: readonly Edit[] = edits; + if (hasBlockEdit(edits)) { + const baseText = + expected === undefined || liveMatches ? normalized : this.snapshots.byHash(canonicalPath, expected)?.text; + if (baseText === undefined) { + throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", false); + } + resolved = resolveBlockEdits(edits, baseText, section.path, this.blockResolver, { onUnresolved: "throw" }); + } + + if (expected === undefined) return applyEdits(normalized, resolved); // Whole-file unchanged → the tag still names the live content, so an // edit anchored at ANY line (displayed or not) is safe to apply. - if (computeFileHash(normalized) === expected) return applyEdits(normalized, [...edits]); + if (liveMatches) return applyEdits(normalized, resolved); // Head/tail-only inserts are position-stable: "start"/"end" cannot move // with content drift, so a stale tag is non-fatal. Apply onto the live // content and warn instead of hard-failing — unlike an anchored // mismatch, which cannot be safely relocated and must reject. - if (!hasAnchorScopedEdit(edits)) { - const result = applyEdits(normalized, [...edits]); + if (!hasAnchorScopedEdit(resolved)) { + const result = applyEdits(normalized, resolved); return { ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] }; } // File drifted: try to replay the edit against the version the tag @@ -333,18 +383,10 @@ export class Patcher { path: canonicalPath, currentText: normalized, fileHash: expected, - edits, + edits: resolved, }); if (recovered) return recoveryToApplyResult(recovered); const hashRecognized = this.snapshots.byHash(canonicalPath, expected) !== null; - const actualFileHash = this.#recordFullSnapshot(canonicalPath, normalized); - throw new MismatchError({ - path: section.path, - expectedFileHash: expected, - actualFileHash, - fileLines: normalized.split("\n"), - anchorLines: section.collectAnchorLines(), - hashRecognized, - }); + throw this.#mismatchError(section, canonicalPath, normalized, expected, hashRecognized); } } diff --git a/packages/hashline/src/prompt.md b/packages/hashline/src/prompt.md index cc6874a20..62524e473 100644 --- a/packages/hashline/src/prompt.md +++ b/packages/hashline/src/prompt.md @@ -6,7 +6,9 @@ Every file section starts with `¶PATH#TAG`. `TAG` is the 4-hex snapshot tag fro replace N..M: replace original lines N..M with the body rows below. +replace block N: replace the whole syntactic block that BEGINS on line N — its header line through its closing line — resolved with tree-sitter. Body rows below. Point N at the line that OPENS the construct (the `if`/`function`/`def`/`{`-bearing line), not a closing `}` or a blank line. delete N..M delete original lines N..M. No body. +delete block N delete the whole syntactic block that BEGINS on line N. insert before N: insert the body rows immediately before line N. insert after N: insert the body rows immediately after line N. insert head: insert the body rows at the very start of the file. @@ -70,6 +72,14 @@ insert head: insert tail: +greet("everyone") ``` + +Replace the whole `greet` function block — `replace block 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays: +``` +¶greet.py#A1B2 +replace block 1: ++def greet(name): ++ print(f"Hello, {name}") +``` diff --git a/packages/hashline/src/recovery.ts b/packages/hashline/src/recovery.ts index 197b967bc..9cbba063f 100644 --- a/packages/hashline/src/recovery.ts +++ b/packages/hashline/src/recovery.ts @@ -70,6 +70,9 @@ function collectAnchorLines(edits: readonly Edit[]): number[] { function getEditAnchors(edit: Edit): Anchor[] { if (edit.kind === "delete") return [edit.anchor]; + // Recovery only ever receives already-resolved edits (no `block`); this arm + // exists for type-exhaustiveness over the full `Edit` union. + if (edit.kind === "block") return [edit.anchor]; return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor" ? [edit.cursor.anchor] : []; } diff --git a/packages/hashline/src/tokenizer.ts b/packages/hashline/src/tokenizer.ts index 07c9674de..19ffe7164 100644 --- a/packages/hashline/src/tokenizer.ts +++ b/packages/hashline/src/tokenizer.ts @@ -10,6 +10,7 @@ */ import { describeAnchorExamples, + HL_BLOCK_KEYWORD, HL_DELETE_KEYWORD, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, @@ -196,7 +197,9 @@ function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allo export type BlockTarget = | { kind: "replace"; range: ParsedRange } + | { kind: "block"; anchor: Anchor } | { kind: "delete"; range: ParsedRange } + | { kind: "delete_block"; anchor: Anchor } | { kind: "insert_before"; anchor: Anchor } | { kind: "insert_after"; anchor: Anchor } | { kind: "bof" } @@ -249,6 +252,18 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan | const cursor = skipWhitespace(line, start, end); const replaceEnd = scanKeyword(line, cursor, end, HL_REPLACE_KEYWORD); if (replaceEnd !== null) { + // `replace block N:` — resolve N to a tree-sitter block range at apply + // time. Try the `block` sub-keyword before falling back to a literal + // `replace N..M:` range. + const blockEnd = scanKeyword(line, skipWhitespace(line, replaceEnd, end), end, HL_BLOCK_KEYWORD); + if (blockEnd !== null) { + const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end); + if (anchor === null) return null; + return { + target: { kind: "block", anchor: { line: anchor.line } }, + nextIndex: consumeOptionalColon(line, anchor.nextIndex, end), + }; + } const range = scanHeaderRange(line, replaceEnd, end, true); if (range === null) return null; return { @@ -258,6 +273,17 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan | } const deleteEnd = scanKeyword(line, cursor, end, HL_DELETE_KEYWORD); if (deleteEnd !== null) { + // `delete block N` — resolve N to a tree-sitter block range at apply + // time and delete its whole span. Like `delete N..M`, it takes no body + // and no trailing colon. + const blockEnd = scanKeyword(line, skipWhitespace(line, deleteEnd, end), end, HL_BLOCK_KEYWORD); + if (blockEnd !== null) { + const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end); + if (anchor === null) return null; + const next = skipWhitespace(line, anchor.nextIndex, end); + if (next < end && line.charCodeAt(next) === CHAR_COLON) return null; + return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next }; + } const range = scanHeaderRange(line, deleteEnd, end, true); if (range === null) return null; const next = skipWhitespace(line, range.nextIndex, end); diff --git a/packages/hashline/src/types.ts b/packages/hashline/src/types.ts index 582ca0498..55a9ca010 100644 --- a/packages/hashline/src/types.ts +++ b/packages/hashline/src/types.ts @@ -32,7 +32,24 @@ export type Edit = index: number; mode?: "replacement"; } - | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }; + | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string } + | { + /** + * Deferred block edit (`replace block N:` / `delete block N`). The exact + * line span is unknown at parse time — it is computed by + * {@link resolveBlockEdits} once file text + path (→ language) are + * available, then expanded into concrete edits: a non-empty `payloads` + * (from `replace block`) becomes the same `replacement` inserts + deletes + * that `replace start..end:` produces; an empty `payloads` (from `delete + * block`) becomes a pure range deletion. `applyEdits` never sees this + * variant. + */ + kind: "block"; + anchor: Anchor; + payloads: string[]; + lineNum: number; + index: number; + }; /** Result of applying a parsed set of edits to a text body. */ export interface ApplyResult { @@ -84,3 +101,32 @@ export interface CompactDiffOptions { /** Maximum entries kept on each side of an unchanged-context truncation (default 2). */ maxUnchangedRun?: number; } + +/** + * Resolved 1-indexed inclusive line span of a `replace block N:` target. + */ +export interface BlockSpan { + /** First line of the block (1-indexed, inclusive). */ + start: number; + /** Last line of the block (1-indexed, inclusive). */ + end: number; +} + +/** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */ +export interface BlockResolverRequest { + /** Target file path (used to infer language by extension). */ + path: string; + /** Full text the block must be resolved against (the snapshot the tag names). */ + text: string; + /** 1-indexed line the block must begin on. */ + line: number; +} + +/** + * Resolves a `replace block N:` anchor to the line span of the syntactic block + * that begins on line N. Returns `null` when no block can be resolved + * (unrecognized language, blank/out-of-range line, no node begins there, or the + * resolved subtree has a syntax error). Pure seam: the hashline core declares + * the contract; the host injects a tree-sitter-backed implementation. + */ +export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null; diff --git a/packages/hashline/test/block.test.ts b/packages/hashline/test/block.test.ts new file mode 100644 index 000000000..9ded37f47 --- /dev/null +++ b/packages/hashline/test/block.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from "bun:test"; +import { + type BlockResolver, + type BlockSpan, + computeFileHash, + type Edit, + InMemoryFilesystem, + InMemorySnapshotStore, + MismatchError, + Patch, + Patcher, + parsePatch, + resolveBlockEdits, +} from "@oh-my-pi/hashline"; + +const PATH = "x.ts"; + +// Deterministic stub: the block beginning on line N spans [N, N+1]. The exact +// shape does not matter — the unit tests only need a resolver that is not the +// real tree-sitter native (that is exercised by the coding-agent integration +// test). +const stubResolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 1 }); + +/** Strip parser/transform bookkeeping that `applyEdits` re-derives anyway. */ +function normalizeEdits(edits: readonly Edit[]): unknown[] { + return edits.map(edit => { + if (edit.kind === "insert") return { kind: edit.kind, cursor: edit.cursor, text: edit.text, mode: edit.mode }; + if (edit.kind === "delete") return { kind: edit.kind, anchor: edit.anchor }; + return edit; + }); +} + +describe("replace block parsing", () => { + it("parses `replace block N:` into a single deferred block edit", () => { + const { edits } = parsePatch("replace block 2:\n+A\n+B"); + + expect(edits).toHaveLength(1); + const edit = edits[0]; + expect(edit?.kind).toBe("block"); + if (edit?.kind !== "block") throw new Error("expected a block edit"); + expect(edit.anchor.line).toBe(2); + expect(edit.payloads).toEqual(["A", "B"]); + }); + + it("still parses a literal `replace N..M:` range (block sub-keyword is optional)", () => { + const { edits } = parsePatch("replace 2..3:\n+A"); + expect(edits.some(edit => edit.kind === "block")).toBe(false); + expect(edits.some(edit => edit.kind === "delete")).toBe(true); + }); + + it("rejects a `replace block N:` hunk with no body row", () => { + expect(() => parsePatch("replace block 2:")).toThrow("`replace block N:` needs at least one"); + }); +}); + +describe("resolveBlockEdits", () => { + it("expands a block edit exactly like the equivalent `replace start..end:`", () => { + const blockEdits = parsePatch("replace block 2:\n+A\n+B").edits; + const resolved = resolveBlockEdits(blockEdits, "ignored", PATH, stubResolver); + const replaceEdits = parsePatch("replace 2..3:\n+A\n+B").edits; + + expect(resolved.some(edit => edit.kind === "block")).toBe(false); + expect(normalizeEdits(resolved)).toEqual(normalizeEdits(replaceEdits)); + }); + + it("returns the input untouched when there are no block edits (fast path)", () => { + const edits = parsePatch("replace 1..1:\n+X").edits; + expect(resolveBlockEdits(edits, "ignored", PATH, stubResolver)).toBe(edits); + }); + + it("throws (default) when no resolver is wired", () => { + const edits = parsePatch("replace block 2:\n+X").edits; + expect(() => resolveBlockEdits(edits, "ignored", PATH, undefined)).toThrow("not available here"); + }); + + it("drops an unresolvable block edit in `drop` mode", () => { + const edits = parsePatch("replace block 2:\n+X").edits; + const resolved = resolveBlockEdits(edits, "ignored", PATH, () => null, { onUnresolved: "drop" }); + expect(resolved).toHaveLength(0); + }); + + it("throws a block-unresolved error in `throw` mode when the resolver returns null", () => { + const edits = parsePatch("replace block 7:\n+X").edits; + expect(() => resolveBlockEdits(edits, "ignored", PATH, () => null)).toThrow( + "could not resolve a syntactic block beginning on line 7", + ); + }); +}); + +describe("PatchSection.applyTo / applyPartialTo with block edits", () => { + const text = "function x() {\n if (y) {\n }\n}\n"; + + it("applyTo resolves a block edit and matches the equivalent `replace`", () => { + const blockSection = Patch.parseSingle(`¶${PATH}#1A2B\nreplace block 2:\n+ if (y || z) {\n+ }`); + const replaceSection = Patch.parseSingle(`¶${PATH}#1A2B\nreplace 2..3:\n+ if (y || z) {\n+ }`); + + const blockResult = blockSection.applyTo(text, stubResolver); + const replaceResult = replaceSection.applyTo(text); + + expect(blockResult.text).toBe("function x() {\n if (y || z) {\n }\n}\n"); + expect(blockResult.text).toBe(replaceResult.text); + }); + + it("applyTo throws when a block edit has no resolver", () => { + const section = Patch.parseSingle(`¶${PATH}#1A2B\nreplace block 2:\n+X`); + expect(() => section.applyTo(text)).toThrow("replace block"); + }); + + it("applyPartialTo drops an unresolvable block edit instead of throwing", () => { + const section = Patch.parseSingle(`¶${PATH}#1A2B\nreplace block 2:\n+X`); + // No resolver → drop. The lone block edit vanishes, so the text is unchanged. + const result = section.applyPartialTo(text); + expect(result.text).toBe(text); + }); +}); + +describe("Patcher with a block resolver", () => { + const text = "function x() {\n if (y) {\n }\n}\n"; + + it("applies a block edit on the hash-match path", async () => { + const fs = new InMemoryFilesystem([[PATH, text]]); + const snapshots = new InMemorySnapshotStore(); + const tag = snapshots.record(PATH, text); + const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); + + const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace block 2:\n+ if (y || z) {\n+ }`)); + + expect(result.sections[0]?.op).toBe("update"); + expect(fs.get(PATH)).toBe("function x() {\n if (y || z) {\n }\n}\n"); + }); + + it("resolves against the tagged snapshot and recovers onto drifted content", async () => { + const snapshotText = "line0\nline1\nline2\nline3\nline4\n"; + // The live file gained a trailing line after the read minted the tag. + const liveText = "line0\nline1\nline2\nline3\nline4\nline5\n"; + const fs = new InMemoryFilesystem([[PATH, liveText]]); + const snapshots = new InMemorySnapshotStore(); + const tag = snapshots.record(PATH, snapshotText); + const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); + + // `block 2` resolves against the SNAPSHOT → span [2,3] → replace + // "line1","line2"; recovery 3-way-merges the change onto the live file. + const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace block 2:\n+NEW`)); + + expect(result.sections[0]?.op).toBe("update"); + expect(fs.get(PATH)).toBe("line0\nNEW\nline3\nline4\nline5\n"); + expect(result.sections[0]?.warnings.some(w => /Recovered/.test(w))).toBe(true); + }); + + it("rejects a block edit whose tag was never recorded for this path", async () => { + const liveText = "line0\nline1\nline2\n"; + const fs = new InMemoryFilesystem([[PATH, liveText]]); + const snapshots = new InMemorySnapshotStore(); + const live = computeFileHash(liveText); + const bogus = live === "FFFF" ? "0000" : "FFFF"; + const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); + + await expect(patcher.apply(Patch.parse(`¶${PATH}#${bogus}\nreplace block 2:\n+NEW`))).rejects.toBeInstanceOf( + MismatchError, + ); + expect(fs.get(PATH)).toBe(liveText); + }); + + it("throws a block-unresolved error when the resolver returns null", async () => { + const fs = new InMemoryFilesystem([[PATH, text]]); + const snapshots = new InMemorySnapshotStore(); + const tag = snapshots.record(PATH, text); + const patcher = new Patcher({ fs, snapshots, blockResolver: () => null }); + + await expect(patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace block 2:\n+X`))).rejects.toThrow( + "could not resolve a syntactic block", + ); + expect(fs.get(PATH)).toBe(text); + }); +}); + +describe("delete block", () => { + const text = "function x() {\n if (y) {\n }\n}\n"; + + it("parses `delete block N` into a block edit with no payloads", () => { + const { edits } = parsePatch("delete block 2"); + + expect(edits).toHaveLength(1); + const edit = edits[0]; + expect(edit?.kind).toBe("block"); + if (edit?.kind !== "block") throw new Error("expected a block edit"); + expect(edit.anchor.line).toBe(2); + expect(edit.payloads).toEqual([]); + }); + + it("rejects body rows under `delete block N`", () => { + expect(() => parsePatch("delete block 2\n+X")).toThrow("`delete block N` does not take body rows"); + }); + + it("resolveBlockEdits expands a delete-block edit into pure deletes", () => { + const edits = parsePatch("delete block 2").edits; + const resolved = resolveBlockEdits(edits, "ignored", PATH, stubResolver); + + expect(resolved.every(edit => edit.kind === "delete")).toBe(true); + expect(resolved.map(edit => (edit.kind === "delete" ? edit.anchor.line : -1))).toEqual([2, 3]); + }); + + it("applyTo deletes the resolved block span", () => { + const section = Patch.parseSingle(`¶${PATH}#1A2B\ndelete block 2`); + // stub span [2,3] → drop " if (y) {" and " }". + expect(section.applyTo(text, stubResolver).text).toBe("function x() {\n}\n"); + }); + + it("applyPartialTo drops an unresolvable delete-block edit instead of throwing", () => { + const section = Patch.parseSingle(`¶${PATH}#1A2B\ndelete block 2`); + expect(section.applyPartialTo(text).text).toBe(text); + }); + + it("Patcher applies a delete-block edit on the hash-match path", async () => { + const fs = new InMemoryFilesystem([[PATH, text]]); + const snapshots = new InMemorySnapshotStore(); + const tag = snapshots.record(PATH, text); + const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); + + const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\ndelete block 2`)); + + expect(result.sections[0]?.op).toBe("update"); + expect(fs.get(PATH)).toBe("function x() {\n}\n"); + }); +}); diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 15cf957c9..067e440cd 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Added + +- Added `blockRangeAt` native API along with `BlockRange` and `BlockRangeOptions` types to return the 1-indexed line span of the outermost tree-sitter node beginning on a given line ### Fixed diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index c60785e17..3981b6d35 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -344,6 +344,33 @@ export interface BashFixupResult { stripped: Array } +export interface BlockRange { + /** 1-indexed inclusive first line of the resolved block. */ + startLine: number + /** 1-indexed inclusive last line of the resolved block. */ + endLine: number +} + +/** + * Find the outermost named tree-sitter node that begins on `options.line`. + * + * Returns its 1-indexed inclusive line span, or `null` when the language is + * unrecognized, the line is out of range / blank, no node begins on that line, + * or the resolved subtree contains a syntax error. + */ +export declare function blockRangeAt(options: BlockRangeOptions): BlockRange | null + +export interface BlockRangeOptions { + /** Source code to inspect. */ + code: string + /** Language alias (e.g. "rust", "typescript") used before path inference. */ + lang?: string + /** File path used to infer language by extension when `lang` is omitted. */ + path?: string + /** 1-indexed source line the block must begin on. */ + line: number +} + /** Clipboard image payload encoded as PNG bytes. */ export interface ClipboardImage { /** PNG-encoded image bytes. */ diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index 7d0944667..188e4d0cb 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -27,6 +27,7 @@ export const __piNativesV15_6_0 = nativeBindings.__piNativesV15_6_0; export const applyBashFixups = nativeBindings.applyBashFixups; export const astEdit = nativeBindings.astEdit; export const astGrep = nativeBindings.astGrep; +export const blockRangeAt = nativeBindings.blockRangeAt; export const copyToClipboard = nativeBindings.copyToClipboard; export const countTokens = nativeBindings.countTokens; export const detectMacOSAppearance = nativeBindings.detectMacOSAppearance;