use anyhow::Result;
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use tree_sitter::{Node, Parser};
use crate::indexer::{languages, path_utils};
#[derive(Debug, Serialize, Clone)]
pub struct FileSignature {
pub path: String,
pub language: String,
pub file_comment: Option<String>,
pub signatures: Vec<SignatureItem>,
}
#[derive(Debug, Serialize, Clone)]
pub struct SignatureItem {
pub kind: String, pub name: String, pub signature: String, pub description: Option<String>, pub start_line: usize, pub end_line: usize, }
pub fn extract_file_signatures(files: &[PathBuf]) -> Result<Vec<FileSignature>> {
let mut all_signatures = Vec::new();
let mut parser = Parser::new();
let current_dir = std::env::current_dir()?;
for file_path in files {
if let Some(language) = detect_language(file_path) {
if let Ok(contents) = fs::read_to_string(file_path) {
let display_path = path_utils::PathUtils::for_display(file_path, ¤t_dir);
if language == "markdown" {
let signatures = extract_markdown_signatures(&contents);
let file_comment = extract_markdown_file_comment(&contents);
all_signatures.push(FileSignature {
path: display_path,
language: "markdown".to_string(),
file_comment,
signatures,
});
} else {
let lang_impl = match languages::get_language(language) {
Some(impl_) => impl_,
None => continue, };
parser.set_language(&lang_impl.get_ts_language())?;
let tree = parser
.parse(&contents, None)
.unwrap_or_else(|| parser.parse("", None).unwrap());
let signatures =
extract_signatures(tree.root_node(), &contents, lang_impl.as_ref());
let file_comment = extract_file_comment(tree.root_node(), &contents);
all_signatures.push(FileSignature {
path: display_path,
language: lang_impl.name().to_string(),
file_comment,
signatures,
});
}
}
}
}
Ok(all_signatures)
}
pub fn extract_signatures(
node: Node,
contents: &str,
lang_impl: &dyn languages::Language,
) -> Vec<SignatureItem> {
let mut signatures = Vec::new();
let meaningful_kinds = lang_impl.get_meaningful_kinds();
fn visit_node(
node: Node,
contents: &str,
lang_impl: &dyn languages::Language,
meaningful_kinds: &[&str],
signatures: &mut Vec<SignatureItem>,
) {
let node_kind = node.kind();
if meaningful_kinds.contains(&node_kind) && lang_impl.is_signature_node(node, contents) {
let start_line = node.start_position().row;
let end_line = node.end_position().row;
let name = extract_name(node, contents, lang_impl);
let description = extract_preceding_comment(node, contents);
if let Some(name) = name {
let sig_text = node_text(node, contents);
let kind = lang_impl
.extract_declaration_kind(node, contents)
.map(str::to_string)
.unwrap_or_else(|| map_node_kind_to_simple_with_context(node, contents));
signatures.push(SignatureItem {
kind,
name,
signature: sig_text,
description,
start_line,
end_line,
});
}
}
let mut cursor = node.walk();
if cursor.goto_first_child() {
loop {
visit_node(
cursor.node(),
contents,
lang_impl,
meaningful_kinds,
signatures,
);
if !cursor.goto_next_sibling() {
break;
}
}
}
}
visit_node(
node,
contents,
lang_impl,
&meaningful_kinds,
&mut signatures,
);
signatures.sort_by_key(|sig| sig.start_line);
signatures
}
fn extract_name(node: Node, contents: &str, lang_impl: &dyn languages::Language) -> Option<String> {
if let Some(name) = lang_impl.extract_signature_name(node, contents) {
return Some(name);
}
for child in node.children(&mut node.walk()) {
if child.kind() == "identifier"
|| child.kind().contains("name")
|| child.kind().contains("function_name")
{
if let Ok(name) = child.utf8_text(contents.as_bytes()) {
if !name.is_empty() {
return Some(name.to_string());
}
}
}
}
let symbols = lang_impl.extract_symbols(node, contents);
symbols.into_iter().next()
}
const MAX_BLANK_LINES_BETWEEN_COMMENTS: usize = 1;
fn clean_comment_text(comment: &str) -> String {
let trimmed = comment.trim();
if trimmed.starts_with("/*") {
let inner = trimmed
.trim_start_matches("/**")
.trim_start_matches("/*")
.trim_end_matches("*/");
inner
.lines()
.map(|line| {
let line = line.trim();
match line.strip_prefix('*') {
Some(rest) => rest.trim_start(),
None => line,
}
})
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string()
} else {
trimmed
.trim_start_matches('/')
.trim_start_matches('*')
.trim_start_matches('/')
.trim_end_matches("*/")
.trim()
.to_string()
}
}
fn extract_preceding_comment(node: Node, contents: &str) -> Option<String> {
let parent = node.parent()?;
let mut prev: Option<Node> = None;
let mut cursor = parent.walk();
if cursor.goto_first_child() {
loop {
let current = cursor.node();
if current.id() == node.id() {
break;
}
prev = Some(current);
if !cursor.goto_next_sibling() {
break;
}
}
}
let last = prev?;
if !last.kind().contains("comment") {
return None;
}
let mut comment_nodes = vec![last];
let mut current = last;
while let Some(sibling) = current.prev_sibling() {
if !sibling.kind().contains("comment") {
break;
}
let gap = current
.start_position()
.row
.saturating_sub(sibling.end_position().row + 1);
if gap > MAX_BLANK_LINES_BETWEEN_COMMENTS {
break;
}
comment_nodes.push(sibling);
current = sibling;
}
comment_nodes.reverse();
let lines: Vec<String> = comment_nodes
.into_iter()
.filter_map(|n| n.utf8_text(contents.as_bytes()).ok())
.map(clean_comment_text)
.collect();
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
fn extract_file_comment(root: Node, contents: &str) -> Option<String> {
let mut cursor = root.walk();
if !cursor.goto_first_child() {
return None;
}
let first = cursor.node();
if !first.kind().contains("comment") {
return None;
}
let mut comment_nodes = vec![first];
let mut current = first;
while let Some(sibling) = current.next_sibling() {
if !sibling.kind().contains("comment") {
break;
}
let gap = sibling
.start_position()
.row
.saturating_sub(current.end_position().row + 1);
if gap > MAX_BLANK_LINES_BETWEEN_COMMENTS {
break;
}
comment_nodes.push(sibling);
current = sibling;
}
let lines: Vec<String> = comment_nodes
.into_iter()
.filter_map(|n| n.utf8_text(contents.as_bytes()).ok())
.map(clean_comment_text)
.collect();
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
fn node_text(node: Node, contents: &str) -> String {
if let Ok(text) = node.utf8_text(contents.as_bytes()) {
text.to_string()
} else {
let start_byte = node.start_byte();
let end_byte = node.end_byte();
let content_bytes = contents.as_bytes();
if start_byte < end_byte && end_byte <= content_bytes.len() {
String::from_utf8_lossy(&content_bytes[start_byte..end_byte]).to_string()
} else {
String::new()
}
}
}
fn map_node_kind_to_simple(kind: &str) -> String {
match kind {
k if k.contains("function") => "function".to_string(),
k if k.contains("method") => "method".to_string(),
k if k.contains("class") => "class".to_string(),
k if k.contains("struct") => "struct".to_string(),
k if k.contains("enum") => "enum".to_string(),
k if k.contains("interface") => "interface".to_string(),
k if k.contains("trait") => "trait".to_string(),
k if k.contains("mod") || k.contains("module") => "module".to_string(),
k if k.contains("const") => "constant".to_string(),
k if k.contains("macro") => "macro".to_string(),
k if k.contains("type") => "type".to_string(),
_ => kind.to_string(), }
}
fn map_node_kind_to_simple_with_context(node: Node, _contents: &str) -> String {
let kind = node.kind();
if kind == "declaration" && contains_function_declarator(node) {
return "function".to_string();
}
if kind == "namespace_definition" {
return "namespace".to_string();
}
map_node_kind_to_simple(kind)
}
fn contains_function_declarator(node: Node) -> bool {
for child in node.children(&mut node.walk()) {
if child.kind() == "function_declarator" || contains_function_declarator(child) {
return true;
}
}
false
}
fn detect_language(path: &Path) -> Option<&str> {
use crate::indexer::file_utils::FileUtils;
FileUtils::detect_language(path)
}
fn extract_markdown_signatures(contents: &str) -> Vec<SignatureItem> {
use crate::indexer::markdown_processor::detect_code_fence;
let mut signatures = Vec::new();
let lines: Vec<&str> = contents.lines().collect();
let mut fence_marker: Option<String> = None;
for (line_idx, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if let Some(fence) = detect_code_fence(trimmed) {
match &fence_marker {
None => fence_marker = Some(fence),
Some(marker) if trimmed.starts_with(marker.as_str()) => fence_marker = None,
_ => {}
}
continue;
}
if fence_marker.is_some() {
continue;
}
if trimmed.starts_with('#') {
let heading_level = trimmed.chars().take_while(|&c| c == '#').count();
let heading_text = trimmed.trim_start_matches('#').trim();
if !heading_text.is_empty() {
let mut content_lines = vec![*line];
let mut end_line = line_idx;
for i in 1.. {
if line_idx + i >= lines.len() {
break;
}
let next_line = lines[line_idx + i];
let next_trimmed = next_line.trim();
if next_trimmed.starts_with('#') && !next_trimmed.starts_with("```") {
break;
}
content_lines.push(next_line);
end_line = line_idx + i;
if content_lines.len() >= 20 {
break;
}
}
while content_lines.len() > 1 && content_lines.last().unwrap().trim().is_empty() {
content_lines.pop();
end_line -= 1;
}
let signature_content = content_lines.join("\n");
signatures.push(SignatureItem {
kind: format!("heading{}", heading_level),
name: heading_text.to_string(),
signature: signature_content,
description: None,
start_line: line_idx,
end_line,
});
}
}
}
signatures
}
fn extract_markdown_file_comment(contents: &str) -> Option<String> {
let lines: Vec<&str> = contents.lines().collect();
if lines.is_empty() {
return None;
}
if lines[0].trim() == "---" {
let mut comment_lines = Vec::new();
for line in lines.iter().skip(1) {
if line.trim() == "---" {
break;
}
comment_lines.push(*line);
}
if !comment_lines.is_empty() {
return Some(comment_lines.join("\n"));
}
}
let mut comment_lines = Vec::new();
let mut found_content = false;
for line in &lines {
let trimmed = line.trim();
if trimmed.starts_with('#') {
if found_content {
break; }
continue;
}
if trimmed.is_empty() {
if found_content {
break; }
continue;
}
found_content = true;
comment_lines.push(*line);
if comment_lines.len() >= 3 {
break;
}
}
if comment_lines.is_empty() {
None
} else {
Some(comment_lines.join(" ").trim().to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(lang: &str, source: &str) -> (tree_sitter::Tree, Box<dyn languages::Language>) {
let lang_impl = languages::get_language(lang).unwrap();
let mut parser = Parser::new();
parser.set_language(&lang_impl.get_ts_language()).unwrap();
let tree = parser.parse(source, None).unwrap();
(tree, lang_impl)
}
#[test]
fn multiline_line_comment_block_captures_all_lines_in_order() {
let source = "// line1\n// line2\n// line3\nfn foo() {}\n";
let (tree, lang_impl) = parse("rust", source);
let sigs = extract_signatures(tree.root_node(), source, lang_impl.as_ref());
let foo = sigs.iter().find(|s| s.name == "foo").unwrap();
assert_eq!(foo.description.as_deref(), Some("line1\nline2\nline3"));
}
#[test]
fn distinct_comment_groups_are_not_merged() {
let source = "// unrelated comment\n\n\n// doc line1\n// doc line2\nfn bar() {}\n";
let (tree, lang_impl) = parse("rust", source);
let sigs = extract_signatures(tree.root_node(), source, lang_impl.as_ref());
let bar = sigs.iter().find(|s| s.name == "bar").unwrap();
assert_eq!(bar.description.as_deref(), Some("doc line1\ndoc line2"));
}
#[test]
fn clean_comment_text_strips_block_comment_markers_per_line() {
let input = "/**\n * This is a function.\n * It does X.\n */";
assert_eq!(clean_comment_text(input), "This is a function.\nIt does X.");
}
#[test]
fn clean_comment_text_leaves_line_comment_unchanged() {
assert_eq!(clean_comment_text("// comment"), "comment");
}
#[test]
fn block_comment_before_function_is_cleaned() {
let source = "/**\n * This is a function.\n * It does X.\n */\nfn baz() {}\n";
let (tree, lang_impl) = parse("rust", source);
let sigs = extract_signatures(tree.root_node(), source, lang_impl.as_ref());
let baz = sigs.iter().find(|s| s.name == "baz").unwrap();
assert_eq!(
baz.description.as_deref(),
Some("This is a function.\nIt does X.")
);
}
#[test]
fn file_comment_chains_consecutive_leading_lines() {
let source = "// header line1\n// header line2\nfn foo() {}\n";
let (tree, _lang_impl) = parse("rust", source);
let file_comment = extract_file_comment(tree.root_node(), source);
assert_eq!(file_comment.as_deref(), Some("header line1\nheader line2"));
}
#[test]
fn markdown_heading_inside_fenced_code_block_is_ignored() {
let markdown = "# Real Heading\n\nSome text.\n\n```bash\n#!/bin/bash\necho hi\n```\n\n## Another Heading\n";
let sigs = extract_markdown_signatures(markdown);
let names: Vec<&str> = sigs.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["Real Heading", "Another Heading"]);
}
#[test]
fn cpp_pointer_returning_declaration_is_classified_as_function() {
let source = "int* foo();\n";
let (tree, lang_impl) = parse("cpp", source);
let sigs = extract_signatures(tree.root_node(), source, lang_impl.as_ref());
let foo = sigs.iter().find(|s| s.name == "foo").unwrap();
assert_eq!(foo.kind, "function");
}
#[test]
fn cpp_plain_variable_declaration_is_unaffected() {
let source = "int x;\n";
let (tree, lang_impl) = parse("cpp", source);
let sigs = extract_signatures(tree.root_node(), source, lang_impl.as_ref());
let x = sigs.iter().find(|s| s.name == "x").unwrap();
assert_eq!(x.kind, "declaration");
}
}