magi-code 0.96.1

Repository-aware CLI coding agent for terminal work
Documentation
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())
}