//! Function boundaries on file pages (hub spec §6.6): tree-sitter finds //! the functions and methods of a Rust, TypeScript, Python or shell file, //! with the lines they span, so a function's history (Oxygen spec §27) is //! one click away. /// A function or method and the lines it spans (1-based, inclusive). #[derive(Clone, Debug, PartialEq, Eq)] pub struct Function { /// Qualified by its enclosing type or class where there is one: `Repo::log`. pub name: String, pub start: usize, pub end: usize, } /// The grammar for a path, by its extension, and the node kinds that are /// functions in it. fn grammar(path: &str) -> Option<(tree_sitter::Language, &'static [&'static str])> { let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?; Some(match ext.as_str() { "rs" => (tree_sitter_rust::LANGUAGE.into(), &["function_item"][..]), "ts" | "mts" | "cts" => ( tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), &["function_declaration", "method_definition", "generator_function_declaration"][..], ), "tsx" => ( tree_sitter_typescript::LANGUAGE_TSX.into(), &["function_declaration", "method_definition", "generator_function_declaration"][..], ), "py" => (tree_sitter_python::LANGUAGE.into(), &["function_definition"][..]), "sh" | "bash" => (tree_sitter_bash::LANGUAGE.into(), &["function_definition"][..]), _ => return None, }) } /// Node kinds whose name qualifies the functions inside them. const CONTAINERS: &[&str] = &["impl_item", "trait_item", "class_declaration", "class_definition", "mod_item"]; /// The functions of `text`, in file order; empty for a language without a /// grammar here or text that does not parse. pub fn functions(path: &str, text: &str) -> Vec { let Some((language, kinds)) = grammar(path) else { return vec![] }; let mut parser = tree_sitter::Parser::new(); if parser.set_language(&language).is_err() { return vec![]; } let Some(tree) = parser.parse(text, None) else { return vec![] }; let mut out = Vec::new(); walk(tree.root_node(), text.as_bytes(), kinds, &mut Vec::new(), &mut out); out } fn name_of(node: tree_sitter::Node, src: &[u8]) -> Option { let n = node.child_by_field_name("name").or_else(|| node.child_by_field_name("type"))?; n.utf8_text(src).ok().map(|s| s.split_whitespace().collect::>().join(" ")) } fn walk(node: tree_sitter::Node, src: &[u8], kinds: &[&str], scope: &mut Vec, out: &mut Vec) { let kind = node.kind(); let mut pushed = false; if kinds.contains(&kind) { if let Some(name) = name_of(node, src) { let mut full = scope.clone(); full.push(name); out.push(Function { name: full.join("::"), start: node.start_position().row + 1, end: node.end_position().row + 1 }); } } else if CONTAINERS.contains(&kind) { if let Some(name) = name_of(node, src) { scope.push(name); pushed = true; } } let mut cursor = node.walk(); for child in node.children(&mut cursor) { walk(child, src, kinds, scope, out); } if pushed { scope.pop(); } } #[cfg(test)] mod tests { use super::*; fn names(path: &str, text: &str) -> Vec<(String, usize, usize)> { functions(path, text).into_iter().map(|f| (f.name, f.start, f.end)).collect() } #[test] fn rust_functions_and_methods() { let src = "use x;\n\nfn free() {\n a();\n}\n\nimpl Repo {\n pub fn log(&self) -> u8 {\n 1\n }\n}\n\ntrait T {\n fn t(&self);\n}\n"; assert_eq!(names("src/lib.rs", src), [("free".to_string(), 3, 5), ("Repo::log".to_string(), 8, 10)]); let generic = "impl Wrap {\n fn get(&self) {}\n}\n"; assert_eq!(names("a.rs", generic), [("Wrap::get".to_string(), 2, 2)]); } #[test] fn typescript_python_and_shell() { let ts = "export function activate(ctx: X): void {\n go();\n}\nclass Repo {\n refresh(): void {\n x();\n }\n}\n"; assert_eq!(names("src/extension.ts", ts), [("activate".to_string(), 1, 3), ("Repo::refresh".to_string(), 5, 7)]); let py = "def top():\n pass\n\nclass Recipe:\n def main(self):\n return 1\n"; assert_eq!(names("images/rust/recipe.py", py), [("top".to_string(), 1, 2), ("Recipe::main".to_string(), 5, 6)]); let sh = "#!/bin/sh\nbuild() {\n cargo build\n}\n"; assert_eq!(names("build.sh", sh), [("build".to_string(), 2, 4)]); } #[test] fn other_files_have_none() { assert!(functions("README.md", "# fn main() {}\n").is_empty()); assert!(functions("Makefile", "all:\n").is_empty()); } }