ghtui 0.2.0

A keyboard-driven terminal client for GitHub, with a pull request diff viewer that goes further than GitHub's
//! Named scopes (functions, methods, types, impls, modules) and the lines
//! they span, from the syntax tree, so a hunk can say where it is:
//! `impl Doc › fn offset`.

use std::ops::RangeInclusive;

use tree_sitter::{Node, Tree};

use crate::diff::highlight::{Language, parse};
use crate::diff::sat_u32;
use crate::diff::text::Text;

/// A named scope and the lines it spans (1-based, inclusive).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Scope {
    pub lines: RangeInclusive<u32>,
    pub name: String,
    /// The name it defines (`offset` for `fn offset`); an impl defines none.
    pub defines: Option<String>,
}

/// Every named scope in `text`, outermost first (so the scopes containing
/// a line, in order, are its path). Empty for languages without scope
/// names, files too big to highlight, and anything that won't parse in
/// time. A syntax error only loses the scopes it breaks.
pub fn scopes(lang: Option<Language>, text: &Text) -> Vec<Scope> {
    let tree = lang.and_then(|lang| parse(lang, text));
    scopes_in(lang, tree.as_ref(), text)
}

/// [`scopes`] of `text` already parsed as `tree`.
pub fn scopes_in(lang: Option<Language>, tree: Option<&Tree>, text: &Text) -> Vec<Scope> {
    let (Some(lang), Some(tree)) = (lang, tree) else {
        return Vec::new();
    };
    let source = text.source.as_bytes();
    // Pre-order walk: parents before children.
    let mut out = Vec::new();
    let mut cursor = tree.walk();
    'walk: loop {
        let node = cursor.node();
        if let Some((name, defines)) = name(lang, node, source) {
            let row = |p: tree_sitter::Point| sat_u32(p.row).saturating_add(1);
            out.push(Scope {
                lines: row(node.start_position())..=row(node.end_position()),
                name,
                defines,
            });
        }
        if cursor.goto_first_child() {
            continue;
        }
        loop {
            if cursor.goto_next_sibling() {
                continue 'walk;
            }
            if !cursor.goto_parent() {
                break 'walk;
            }
        }
    }
    out
}

/// How `node` is named in a scope path, if it's a scope at all, and the
/// name it defines.
fn name(lang: Language, node: Node<'_>, source: &[u8]) -> Option<(String, Option<String>)> {
    use Language::{Go, JavaScript, Python, Rust, Tsx, TypeScript};
    let field = |f: &str| {
        let text = node.child_by_field_name(f)?.utf8_text(source).ok()?;
        // Generic parameters and the like can span lines.
        Some(text.split_whitespace().collect::<Vec<_>>().join(" "))
    };
    let js = matches!(lang, JavaScript | TypeScript | Tsx);
    let keyword = match (lang, node.kind()) {
        (Rust, "impl_item") => {
            let name = match field("trait") {
                Some(t) => format!("impl {t} for {}", field("type")?),
                None => format!("impl {}", field("type")?),
            };
            return Some((name, None));
        }
        // Methods, and `const handler = () => {...}`: just the name.
        (_, "method_definition") if js => return field("name").map(|n| (n.clone(), Some(n))),
        (_, "variable_declarator")
            if js
                && node.child_by_field_name("value").is_some_and(|v| {
                    matches!(
                        v.kind(),
                        "arrow_function" | "function_expression" | "function"
                    )
                }) =>
        {
            return field("name").map(|n| (n.clone(), Some(n)));
        }
        (Rust, "function_item" | "function_signature_item") => "fn",
        (Rust, "struct_item") => "struct",
        (Rust, "enum_item") | (TypeScript | Tsx, "enum_declaration") => "enum",
        (Rust, "union_item") => "union",
        (Rust, "trait_item") => "trait",
        (Rust, "mod_item") => "mod",
        (Rust, "macro_definition") => "macro_rules!",
        (Rust, "const_item") => "const",
        (Rust, "static_item") => "static",
        (Python, "function_definition") => "def",
        (Python, "class_definition") => "class",
        (_, "class_declaration" | "abstract_class_declaration") if js => "class",
        (Go, "function_declaration" | "method_declaration") => "func",
        (Go, "type_spec") | (Rust, "type_item") | (TypeScript | Tsx, "type_alias_declaration") => {
            "type"
        }
        (_, "function_declaration" | "generator_function_declaration") if js => "function",
        (TypeScript | Tsx, "interface_declaration") => "interface",
        (TypeScript | Tsx, "internal_module") => "namespace",
        _ => return None,
    };
    let name = field("name")?;
    Some((format!("{keyword} {name}"), Some(name)))
}

/// The path of scopes containing `line`, outermost first.
pub fn path(scopes: &[Scope], line: u32) -> Vec<&str> {
    scopes
        .iter()
        .filter(|s| s.lines.contains(&line))
        .map(|s| s.name.as_str())
        .collect()
}

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

    fn at(lang: Language, source: &str, line: u32) -> Vec<String> {
        let text = Text::new(source.as_bytes());
        path(&scopes(Some(lang), &text), line)
            .into_iter()
            .map(str::to_owned)
            .collect()
    }

    #[test]
    fn rust_methods_in_impls() {
        let src = "struct Doc;\n\nimpl Doc {\n    fn offset(&self) -> u32 {\n        1\n    }\n}\n\nimpl Display for Doc {\n    fn fmt(&self) {}\n}\n";
        assert_eq!(at(Language::Rust, src, 5), ["impl Doc", "fn offset"]);
        assert_eq!(
            at(Language::Rust, src, 10),
            ["impl Display for Doc", "fn fmt"]
        );
        assert_eq!(at(Language::Rust, src, 2), Vec::<String>::new());
    }

    #[test]
    fn nested_functions_nest() {
        let src = "mod a {\n    fn outer() {\n        fn inner() {\n            x();\n        }\n    }\n}\n";
        assert_eq!(
            at(Language::Rust, src, 4),
            ["mod a", "fn outer", "fn inner"]
        );
    }

    #[test]
    fn other_languages() {
        let py = "class Shape:\n    def area(self):\n        return 1\n";
        assert_eq!(at(Language::Python, py, 3), ["class Shape", "def area"]);
        let go = "package p\n\nfunc (s *Shape) Area() int {\n\treturn 1\n}\n";
        assert_eq!(at(Language::Go, go, 4), ["func Area"]);
        let ts = "class Shape {\n  area(): number {\n    return 1;\n  }\n}\nconst run = () => {\n  go();\n};\n";
        assert_eq!(at(Language::TypeScript, ts, 3), ["class Shape", "area"]);
        assert_eq!(at(Language::TypeScript, ts, 7), ["run"]);
    }

    /// A syntax error loses what it breaks, not everything.
    #[test]
    fn broken_code_still_names_what_parses() {
        let src = "fn fine() {\n    ok();\n}\n\nfn broken( {\n";
        assert_eq!(at(Language::Rust, src, 2), ["fn fine"]);
    }

    #[test]
    fn plain_text_has_no_scopes() {
        assert!(scopes(None, &Text::new(b"fn x() {}\n")).is_empty());
    }
}