kizu 0.7.0

Realtime diff monitor + inline scar review TUI for AI coding agents (Claude Code, etc.)
Documentation
use std::path::Path;

use anyhow::{Context, Result, anyhow, bail};
use tree_sitter::{Language, Node, Parser, Tree};
use tree_sitter_highlight::HighlightConfiguration;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum JsTsDialect {
    JavaScript,
    Jsx,
    TypeScript,
    Tsx,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum JsTsScarStyle {
    LineComment,
    JsxBlockComment,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct JsTsScarPlacement {
    pub insert_before_line_1indexed: usize,
    pub style: JsTsScarStyle,
}

pub(crate) fn dialect_for_path(path: &Path) -> Option<JsTsDialect> {
    let ext = path.extension().and_then(|e| e.to_str())?;
    match ext.to_ascii_lowercase().as_str() {
        "js" | "mjs" | "cjs" => Some(JsTsDialect::JavaScript),
        "jsx" => Some(JsTsDialect::Jsx),
        "ts" | "mts" | "cts" => Some(JsTsDialect::TypeScript),
        "tsx" => Some(JsTsDialect::Tsx),
        _ => None,
    }
}

pub(crate) fn scar_placement_for_line(
    dialect: JsTsDialect,
    source: &str,
    target_line_1indexed: usize,
) -> Result<JsTsScarPlacement> {
    let target = target_line_1indexed.max(1);
    if matches!(dialect, JsTsDialect::JavaScript | JsTsDialect::TypeScript) {
        return Ok(JsTsScarPlacement {
            insert_before_line_1indexed: target,
            style: JsTsScarStyle::LineComment,
        });
    }

    let tree = parse_source(dialect, source)?;
    let root = tree.root_node();
    if root.has_error() {
        bail!("could not safely place JSX/TSX scar: parse tree contains errors");
    }

    let row = target - 1;
    let lines = source.lines().collect::<Vec<_>>();
    if let Some(opening) = deepest_node_containing_row(
        root,
        row,
        &["jsx_opening_element", "jsx_self_closing_element"],
    ) {
        let opening_start_row = opening.start_position().row;
        if line_starts_like_jsx(lines.get(opening_start_row).copied().unwrap_or_default()) {
            return Ok(JsTsScarPlacement {
                insert_before_line_1indexed: jsx_container_start_line(opening)
                    .unwrap_or(opening_start_row + 1),
                style: JsTsScarStyle::JsxBlockComment,
            });
        }
    }

    if let Some(container) =
        deepest_node_containing_row(root, row, &["jsx_element", "jsx_fragment"])
    {
        let current_line = lines.get(row).copied().unwrap_or_default();
        let container_start = container.start_position().row;
        let container_start_line = lines.get(container_start).copied().unwrap_or_default();
        let inside_container_body = row > container_start && row < container.end_position().row;
        if line_starts_like_jsx(current_line)
            || current_line.trim_start().starts_with('{')
            || (inside_container_body && line_starts_like_jsx(container_start_line))
        {
            return Ok(JsTsScarPlacement {
                insert_before_line_1indexed: target,
                style: JsTsScarStyle::JsxBlockComment,
            });
        }
    }

    Ok(JsTsScarPlacement {
        insert_before_line_1indexed: target,
        style: JsTsScarStyle::LineComment,
    })
}

pub(crate) fn parse_source(dialect: JsTsDialect, source: &str) -> Result<Tree> {
    let mut parser = Parser::new();
    let language = language_for_dialect(dialect);
    parser
        .set_language(&language)
        .with_context(|| format!("loading tree-sitter language for {dialect:?}"))?;
    parser
        .parse(source, None)
        .ok_or_else(|| anyhow!("tree-sitter returned no parse tree for {dialect:?}"))
}

pub(crate) fn highlight_configuration(
    dialect: JsTsDialect,
) -> Result<HighlightConfiguration, tree_sitter::QueryError> {
    let language = language_for_dialect(dialect);
    let (name, highlights, injections, locals) = match dialect {
        JsTsDialect::JavaScript => (
            "javascript",
            tree_sitter_javascript::HIGHLIGHT_QUERY.to_string(),
            tree_sitter_javascript::INJECTIONS_QUERY,
            tree_sitter_javascript::LOCALS_QUERY,
        ),
        JsTsDialect::Jsx => (
            "jsx",
            format!(
                "{}\n{}",
                tree_sitter_javascript::HIGHLIGHT_QUERY,
                tree_sitter_javascript::JSX_HIGHLIGHT_QUERY
            ),
            tree_sitter_javascript::INJECTIONS_QUERY,
            tree_sitter_javascript::LOCALS_QUERY,
        ),
        JsTsDialect::TypeScript => (
            "typescript",
            tree_sitter_typescript::HIGHLIGHTS_QUERY.to_string(),
            "",
            tree_sitter_typescript::LOCALS_QUERY,
        ),
        JsTsDialect::Tsx => (
            "tsx",
            format!(
                "{}\n{}",
                tree_sitter_typescript::HIGHLIGHTS_QUERY,
                tree_sitter_javascript::JSX_HIGHLIGHT_QUERY
            ),
            "",
            tree_sitter_typescript::LOCALS_QUERY,
        ),
    };
    HighlightConfiguration::new(language, name, &highlights, injections, locals)
}

fn language_for_dialect(dialect: JsTsDialect) -> Language {
    match dialect {
        JsTsDialect::JavaScript | JsTsDialect::Jsx => tree_sitter_javascript::LANGUAGE.into(),
        JsTsDialect::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
        JsTsDialect::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
    }
}

fn deepest_node_containing_row<'tree>(
    node: Node<'tree>,
    row: usize,
    kinds: &[&str],
) -> Option<Node<'tree>> {
    if !node_contains_row(node, row) {
        return None;
    }
    let mut cursor = node.walk();
    let mut deepest = None;
    for child in node.children(&mut cursor) {
        if let Some(found) = deepest_node_containing_row(child, row, kinds) {
            deepest = Some(found);
        }
    }
    if deepest.is_some() {
        deepest
    } else {
        kinds.contains(&node.kind()).then_some(node)
    }
}

fn node_contains_row(node: Node<'_>, row: usize) -> bool {
    node.start_position().row <= row && row <= node.end_position().row
}

fn jsx_container_start_line(node: Node<'_>) -> Option<usize> {
    let mut current = Some(node);
    while let Some(n) = current {
        if matches!(
            n.kind(),
            "jsx_element" | "jsx_fragment" | "jsx_self_closing_element"
        ) {
            return Some(n.start_position().row + 1);
        }
        current = n.parent();
    }
    None
}

fn line_starts_like_jsx(line: &str) -> bool {
    line.trim_start().starts_with('<')
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn tsx_parser_accepts_react_component_fixture() {
        let source = r#"
type CounterProps = { count: number };

export function Counter({ count }: CounterProps) {
  return (
    <section className="counter">
      <p>Count: {count}</p>
    </section>
  );
}
"#;

        let tree = parse_source(JsTsDialect::Tsx, source).expect("tsx parses");
        assert!(
            !tree.root_node().has_error(),
            "TSX fixture should not contain parse errors: {:#?}",
            tree.root_node()
        );
    }

    #[test]
    fn tsx_highlight_configuration_builds() {
        let config =
            highlight_configuration(JsTsDialect::Tsx).expect("tsx highlight config builds");
        assert!(
            config.names().contains(&"tag"),
            "combined TSX query should include JSX tag captures"
        );
    }

    #[test]
    fn scar_placement_relocates_attribute_rows_to_jsx_element_start() {
        let source = "export function Panel() {\n  return (\n    <Button\n      kind=\"primary\"\n    >\n      Save\n    </Button>\n  );\n}\n";

        let placement = scar_placement_for_line(JsTsDialect::Tsx, source, 4).expect("placement");

        assert_eq!(
            placement,
            JsTsScarPlacement {
                insert_before_line_1indexed: 3,
                style: JsTsScarStyle::JsxBlockComment,
            }
        );
    }
}