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,
JavaScript,
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("js" | "jsx") => Some(Self::JavaScript),
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::JavaScript => tree_sitter_javascript::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::JavaScript => "JavaScript",
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, .js, .jsx, .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())
}