feat: added block replace syntax with block edits in hashline parser
- Added `replace block N:` syntax in grammar/parser/tokenizer/types, emitting `kind: "block"` edits with empty-body checks. - Added block-resolution in hashline apply/recovery, wiring `BlockResolver` to resolve edits with throw/drop unresolved behavior. - Added native `blockRangeAt` support and exported block range/options types through pi-ast, pi-natives, and JS bindings. - Added coding-agent resolver wiring plus internal visibility/exports updates and new parser, patcher, and setup-wizard tests.
This commit is contained in:
@@ -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<String>,
|
||||
/// File path used to infer language by extension when `lang` is omitted.
|
||||
pub path: Option<String>,
|
||||
/// 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<usize> {
|
||||
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<Option<BlockRange>> {
|
||||
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<BlockRange> {
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod block;
|
||||
pub mod language;
|
||||
pub mod ops;
|
||||
pub mod summary;
|
||||
|
||||
@@ -208,7 +208,7 @@ pub fn summarize_code(options: SummaryOptions) -> Result<SummaryResult> {
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_language(lang: Option<&str>, path: Option<&str>) -> Option<SupportLang> {
|
||||
pub(crate) fn resolve_language(lang: Option<&str>, path: Option<&str>) -> Option<SupportLang> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user