use super::{
model::{BlockMode, BlockResolution, BlockResolutionOp, Cursor, Edit, InsertMode},
tokenizer::split_hashline_lines,
};
use std::path::Path;
use tree_sitter::{Language, Node, Parser};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BlockEditResolution {
pub(crate) edits: Vec<Edit>,
pub(crate) warnings: Vec<String>,
pub(crate) block_resolutions: Vec<BlockResolution>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ResolvedBlock {
pub(crate) start: usize,
pub(crate) end: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct BlockResolver;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LanguageKind {
Rust,
Python,
TypeScript,
Tsx,
Go,
}
impl LanguageKind {
fn from_path(path: &Path) -> Option<Self> {
match path.extension().and_then(|extension| extension.to_str()) {
Some("rs") => Some(Self::Rust),
Some("py") => Some(Self::Python),
Some("ts") => Some(Self::TypeScript),
Some("tsx") => Some(Self::Tsx),
Some("go") => Some(Self::Go),
_ => None,
}
}
fn language(self) -> Language {
match self {
Self::Rust => tree_sitter_rust::LANGUAGE.into(),
Self::Python => tree_sitter_python::LANGUAGE.into(),
Self::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
Self::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
Self::Go => tree_sitter_go::LANGUAGE.into(),
}
}
fn name(self) -> &'static str {
match self {
Self::Rust => "Rust",
Self::Python => "Python",
Self::TypeScript => "TypeScript",
Self::Tsx => "TSX",
Self::Go => "Go",
}
}
}
impl BlockResolver {
pub(crate) fn resolve(path: &Path, text: &str, line: usize) -> Result<ResolvedBlock, String> {
let language_kind = LanguageKind::from_path(path).ok_or_else(|| {
format!(
".BLK edits are only supported for .rs, .py, .ts, .tsx, and .go files; got {}",
path.display()
)
})?;
let lines = split_hashline_lines(text);
if line == 0 || line > lines.len() {
return Err(format!(
".BLK anchor line {line} is outside file line range 1..={}",
lines.len()
));
}
if lines[line - 1].trim().is_empty() {
return Err(format!(".BLK anchor line {line} is blank"));
}
let language = language_kind.language();
let mut parser = Parser::new();
parser
.set_language(&language)
.map_err(|_| format!("failed to initialize {} parser", language_kind.name()))?;
let tree = parser
.parse(text, None)
.ok_or_else(|| format!("failed to parse {} source", language_kind.name()))?;
let root = tree.root_node();
if root.has_error() {
return Err(format!(
"failed to parse {} source without errors",
language_kind.name()
));
}
let row = line - 1;
if let Some(node) = smallest_named_multiline_node_starting_at(root, row) {
return Ok(ResolvedBlock {
start: node.start_position().row + 1,
end: node.end_position().row + 1,
});
}
if is_attribute_or_decorator_line(&lines[line - 1])
&& let Some(node) = smallest_named_multiline_node_starting_at(
root,
first_non_attribute_row(&lines, row),
)
{
return Ok(ResolvedBlock {
start: line,
end: node.end_position().row + 1,
});
}
Err(format!("no multi-line syntax node begins on line {line}"))
}
}
pub(crate) fn resolve_block_edits(
edits: &[Edit],
text: &str,
path: &Path,
) -> Result<BlockEditResolution, String> {
if !edits.iter().any(|edit| matches!(edit, Edit::Block { .. })) {
return Ok(BlockEditResolution {
edits: edits.to_vec(),
warnings: Vec::new(),
block_resolutions: Vec::new(),
});
}
let mut lowered = Vec::new();
let mut warnings = Vec::new();
let mut block_resolutions = Vec::new();
let mut synth_index = 0usize;
for edit in edits {
let Edit::Block {
anchor,
payloads,
mode,
line_num,
..
} = edit
else {
lowered.push(edit.clone());
continue;
};
let op = if matches!(mode, Some(BlockMode::InsertAfter)) {
BlockResolutionOp::InsertAfter
} else if payloads.is_empty() {
BlockResolutionOp::Delete
} else {
BlockResolutionOp::Replace
};
let span = match BlockResolver::resolve(path, text, anchor.line) {
Ok(span) => span,
Err(error) if op == BlockResolutionOp::InsertAfter => {
warnings.push(format!(
"line {line_num}: INS.BLK.POST {} could not resolve ({error}); lowered to INS.POST {}. Verify insertion point.",
anchor.line, anchor.line
));
for payload in payloads {
lowered.push(Edit::Insert {
cursor: Cursor::AfterAnchor {
anchor: anchor.clone(),
},
text: payload.clone(),
line_num: *line_num,
index: synth_index,
mode: None,
block_start: None,
});
synth_index += 1;
}
continue;
}
Err(error) => {
return Err(format!("line {line_num}: {error}"));
}
};
if span.start == span.end {
return Err(format!(
"line {line_num}: .BLK anchor {} resolved to one line; use plain SWAP/DEL/INS.POST for single-line edits.",
anchor.line
));
}
block_resolutions.push(BlockResolution {
anchor_line: anchor.line,
start: span.start,
end: span.end,
op,
});
match op {
BlockResolutionOp::InsertAfter => {
for payload in payloads {
lowered.push(Edit::Insert {
cursor: Cursor::AfterAnchor {
anchor: super::model::Anchor { line: span.end },
},
text: payload.clone(),
line_num: *line_num,
index: synth_index,
mode: None,
block_start: Some(span.start),
});
synth_index += 1;
}
}
BlockResolutionOp::Replace | BlockResolutionOp::Delete => {
for payload in payloads {
lowered.push(Edit::Insert {
cursor: Cursor::BeforeAnchor {
anchor: super::model::Anchor { line: span.start },
},
text: payload.clone(),
line_num: *line_num,
index: synth_index,
mode: Some(InsertMode::Replacement),
block_start: None,
});
synth_index += 1;
}
for line in span.start..=span.end {
lowered.push(Edit::Delete {
anchor: super::model::Anchor { line },
line_num: *line_num,
index: synth_index,
old_assertion: None,
});
synth_index += 1;
}
}
}
}
Ok(BlockEditResolution {
edits: lowered,
warnings,
block_resolutions,
})
}
fn is_attribute_or_decorator_line(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("#[") || trimmed.starts_with('@')
}
fn first_non_attribute_row(lines: &[String], mut row: usize) -> usize {
while row < lines.len() && is_attribute_or_decorator_line(&lines[row]) {
row += 1;
}
row.min(lines.len().saturating_sub(1))
}
fn smallest_named_multiline_node_starting_at(node: Node<'_>, row: usize) -> Option<Node<'_>> {
if node.start_position().row > row || node.end_position().row < row {
return None;
}
let mut cursor = node.walk();
let mut best = None;
for child in node.named_children(&mut cursor) {
if let Some(candidate) = smallest_named_multiline_node_starting_at(child, row) {
best = Some(match best {
Some(current) if node_len(current) <= node_len(candidate) => current,
_ => candidate,
});
}
}
if best.is_some() {
return best;
}
let start = node.start_position();
let end = node.end_position();
(node.is_named() && start.row == row && end.row > start.row).then_some(node)
}
fn node_len(node: Node<'_>) -> usize {
node.end_byte().saturating_sub(node.start_byte())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::hash_edit::{model::Anchor, parser::parse_patch};
use std::path::Path;
fn resolve(path: &str, text: &str, line: usize) -> ResolvedBlock {
BlockResolver::resolve(Path::new(path), text, line).unwrap()
}
#[test]
fn resolves_rust_function() {
let text = "fn main() {\n println!(\"hi\");\n}\n";
assert_eq!(
resolve("main.rs", text, 1),
ResolvedBlock { start: 1, end: 3 }
);
}
#[test]
fn resolves_rust_impl_method_with_attribute() {
let text = "struct Agent;\nimpl Agent {\n #[test]\n fn runs() {\n assert!(true);\n }\n}\n";
assert_eq!(
resolve("lib.rs", text, 3),
ResolvedBlock { start: 3, end: 6 }
);
}
#[test]
fn resolves_python_decorator_def() {
let text = "@decorator\ndef run():\n return 1\n";
assert_eq!(
resolve("app.py", text, 1),
ResolvedBlock { start: 1, end: 3 }
);
}
#[test]
fn resolves_typescript_function_class_and_arrow_block() {
let text = "function run() {\n return 1;\n}\n\nclass Agent {\n method() {\n return 2;\n }\n}\n\nconst fn = () => {\n return 3;\n};\n";
assert_eq!(
resolve("app.ts", text, 1),
ResolvedBlock { start: 1, end: 3 }
);
assert_eq!(
resolve("app.ts", text, 5),
ResolvedBlock { start: 5, end: 9 }
);
assert_eq!(
resolve("app.ts", text, 11),
ResolvedBlock { start: 11, end: 13 }
);
}
#[test]
fn resolves_tsx_component() {
let text = "export function View() {\n return <div>{1}</div>;\n}\n";
assert_eq!(
resolve("view.tsx", text, 1),
ResolvedBlock { start: 1, end: 3 }
);
}
#[test]
fn resolves_go_function() {
let text = "package main\n\nfunc Run() {\n\tprintln(\"hi\")\n}\n";
assert_eq!(
resolve("main.go", text, 3),
ResolvedBlock { start: 3, end: 5 }
);
}
#[test]
fn resolves_nested_construct_smallest_node() {
let text = "fn outer() {\n if true {\n println!(\"hi\");\n }\n}\n";
assert_eq!(
resolve("main.rs", text, 2),
ResolvedBlock { start: 2, end: 4 }
);
}
#[test]
fn rejects_single_line_or_missing_block_anchor() {
let text = "fn main() {\n let x = 1;\n}\n";
let err = BlockResolver::resolve(Path::new("main.rs"), text, 2).unwrap_err();
assert!(err.contains("no multi-line syntax node begins"), "{err}");
}
#[test]
fn rejects_blank_out_of_range_parse_error_and_unsupported_extension() {
assert!(
BlockResolver::resolve(Path::new("a.txt"), "x\n", 1)
.unwrap_err()
.contains("only supported")
);
assert!(
BlockResolver::resolve(Path::new("a.rs"), "\n", 1)
.unwrap_err()
.contains("blank")
);
assert!(
BlockResolver::resolve(Path::new("a.rs"), "fn x() {}\n", 3)
.unwrap_err()
.contains("outside")
);
assert!(
BlockResolver::resolve(Path::new("a.rs"), "fn x( {\n", 1)
.unwrap_err()
.contains("parse")
);
}
#[test]
fn lowers_swap_block_to_replacement_edits_and_metadata() {
let parsed = parse_patch("SWAP.BLK 1:\n+fn changed() {\n+}\n").unwrap();
let text = "fn main() {\n println!(\"hi\");\n}\n";
let lowered = resolve_block_edits(&parsed.edits, text, Path::new("main.rs")).unwrap();
assert_eq!(
lowered.block_resolutions,
vec![BlockResolution {
anchor_line: 1,
start: 1,
end: 3,
op: BlockResolutionOp::Replace,
}]
);
assert!(matches!(
lowered.edits[0],
Edit::Insert {
cursor: Cursor::BeforeAnchor {
anchor: Anchor { line: 1 }
},
mode: Some(InsertMode::Replacement),
..
}
));
assert!(matches!(
lowered.edits[2],
Edit::Delete {
anchor: Anchor { line: 1 },
..
}
));
assert!(matches!(
lowered.edits[4],
Edit::Delete {
anchor: Anchor { line: 3 },
..
}
));
}
#[test]
fn lowers_delete_block_to_range_deletes() {
let parsed = parse_patch("DEL.BLK 1").unwrap();
let text = "fn main() {\n println!(\"hi\");\n}\n";
let lowered = resolve_block_edits(&parsed.edits, text, Path::new("main.rs")).unwrap();
assert_eq!(lowered.edits.len(), 3);
assert_eq!(lowered.block_resolutions[0].op, BlockResolutionOp::Delete);
assert!(matches!(
lowered.edits[2],
Edit::Delete {
anchor: Anchor { line: 3 },
..
}
));
}
#[test]
fn lowers_insert_after_block_to_insert_after_resolved_end() {
let parsed = parse_patch("INS.BLK.POST 1:\n+tail").unwrap();
let text = "fn main() {\n println!(\"hi\");\n}\n";
let lowered = resolve_block_edits(&parsed.edits, text, Path::new("main.rs")).unwrap();
assert!(matches!(
lowered.edits[0],
Edit::Insert {
cursor: Cursor::AfterAnchor {
anchor: Anchor { line: 3 }
},
block_start: Some(1),
..
}
));
assert_eq!(
lowered.block_resolutions[0].op,
BlockResolutionOp::InsertAfter
);
}
#[test]
fn insert_after_block_unresolved_falls_back_to_plain_insert_after() {
let parsed = parse_patch("INS.BLK.POST 1:\n+tail").unwrap();
let lowered = resolve_block_edits(&parsed.edits, "text\n", Path::new("notes.txt")).unwrap();
assert!(matches!(
lowered.edits[0],
Edit::Insert {
cursor: Cursor::AfterAnchor {
anchor: Anchor { line: 1 }
},
block_start: None,
..
}
));
assert!(lowered.warnings[0].contains("lowered to INS.POST 1"));
assert!(lowered.block_resolutions.is_empty());
}
#[test]
fn unsupported_replace_block_errors_explicitly() {
let parsed = parse_patch("SWAP.BLK 1:\n+x").unwrap();
let err = resolve_block_edits(&parsed.edits, "text\n", Path::new("notes.txt")).unwrap_err();
assert!(err.contains(".BLK edits are only supported"), "{err}");
}
}