Skip to main content

winx_code_agent/utils/
symbols.rs

1//! Symbol/outline extraction via `tree-sitter-tags` and each grammar's embedded
2//! `TAGS_QUERY` — the same engine GitHub uses for code navigation. We surface
3//! only definitions (functions, types, methods, ...), not references.
4//!
5//! 11 languages have a tags query (rust, js/ts, go, c, c++, java, ruby, c#, php,
6//! lua); the others (bash/css/html/json) have no code symbols and return empty.
7
8use tree_sitter_tags::{TagsConfiguration, TagsContext};
9
10/// A code symbol occurrence (definition or reference) from a source file.
11#[derive(Debug, Clone)]
12pub struct Symbol {
13    /// Symbol name (e.g. `parse_config`).
14    pub name: String,
15    /// Symbol kind from the grammar's tags query (e.g. `function`, `struct`,
16    /// `method`, `class`, `call`).
17    pub kind: String,
18    /// 1-based line where the occurrence starts.
19    pub line: usize,
20    /// True for a definition, false for a reference (call / use site).
21    pub is_definition: bool,
22}
23
24/// Map a file extension to its tree-sitter language + embedded tags query.
25/// Returns `None` for unsupported / non-code extensions.
26fn lang_and_query(ext: &str) -> Option<(tree_sitter::Language, &'static str)> {
27    let pair = match ext {
28        "rs" => (tree_sitter_rust::LANGUAGE.into(), tree_sitter_rust::TAGS_QUERY),
29        "js" | "mjs" | "cjs" | "jsx" => {
30            (tree_sitter_javascript::LANGUAGE.into(), tree_sitter_javascript::TAGS_QUERY)
31        }
32        "ts" => {
33            (tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), tree_sitter_typescript::TAGS_QUERY)
34        }
35        "tsx" => (tree_sitter_typescript::LANGUAGE_TSX.into(), tree_sitter_typescript::TAGS_QUERY),
36        "go" => (tree_sitter_go::LANGUAGE.into(), tree_sitter_go::TAGS_QUERY),
37        "c" | "h" => (tree_sitter_c::LANGUAGE.into(), tree_sitter_c::TAGS_QUERY),
38        "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => {
39            (tree_sitter_cpp::LANGUAGE.into(), tree_sitter_cpp::TAGS_QUERY)
40        }
41        "java" => (tree_sitter_java::LANGUAGE.into(), tree_sitter_java::TAGS_QUERY),
42        "rb" => (tree_sitter_ruby::LANGUAGE.into(), tree_sitter_ruby::TAGS_QUERY),
43        "cs" => (tree_sitter_c_sharp::LANGUAGE.into(), tree_sitter_c_sharp::TAGS_QUERY),
44        "php" => (tree_sitter_php::LANGUAGE_PHP.into(), tree_sitter_php::TAGS_QUERY),
45        "lua" => (tree_sitter_lua::LANGUAGE.into(), tree_sitter_lua::TAGS_QUERY),
46        _ => return None,
47    };
48    Some(pair)
49}
50
51/// Whether `ext` has a symbol extractor (used to filter repo-map candidates).
52pub fn supports(ext: &str) -> bool {
53    lang_and_query(ext).is_some()
54}
55
56/// Compile the tags configuration for `ext`, or `None` if unsupported / the
57/// query fails to compile. Compiling is non-trivial, so callers cache per
58/// language across a repo-map pass.
59pub fn config_for(ext: &str) -> Option<TagsConfiguration> {
60    let (language, query) = lang_and_query(ext)?;
61    TagsConfiguration::new(language, query, "").ok()
62}
63
64/// Extract definition symbols from `text` using a pre-built `config`.
65/// `context` is reusable across files.
66pub fn extract(context: &mut TagsContext, config: &TagsConfiguration, text: &str) -> Vec<Symbol> {
67    collect(context, config, text, true)
68}
69
70/// Extract all occurrences — definitions AND references (call/use sites).
71pub fn extract_all(
72    context: &mut TagsContext,
73    config: &TagsConfiguration,
74    text: &str,
75) -> Vec<Symbol> {
76    collect(context, config, text, false)
77}
78
79/// Shared collector. Best-effort: a parse/iteration error yields whatever was
80/// gathered so far (possibly empty). `source[tag.name_range]` is safe because
81/// `text` is valid UTF-8 and the range comes from tree-sitter over that buffer.
82fn collect(
83    context: &mut TagsContext,
84    config: &TagsConfiguration,
85    text: &str,
86    definitions_only: bool,
87) -> Vec<Symbol> {
88    let source = text.as_bytes();
89    let mut out = Vec::new();
90    let Ok((tags, _)) = context.generate_tags(config, source, None) else {
91        return out;
92    };
93    for tag in tags.flatten() {
94        if definitions_only && !tag.is_definition {
95            continue;
96        }
97        let name = String::from_utf8_lossy(&source[tag.name_range.clone()]).into_owned();
98        if name.is_empty() {
99            continue;
100        }
101        out.push(Symbol {
102            name,
103            kind: config.syntax_type_name(tag.syntax_type_id).to_string(),
104            line: tag.span.start.row + 1,
105            is_definition: tag.is_definition,
106        });
107    }
108    out
109}
110
111#[cfg(test)]
112mod tests {
113    #![allow(clippy::expect_used)]
114    use super::*;
115
116    #[test]
117    fn extracts_rust_symbols() {
118        let mut ctx = TagsContext::new();
119        let cfg = config_for("rs").expect("rust config");
120        let src = "pub fn alpha() {}\nstruct Beta;\nimpl Beta { fn gamma(&self) {} }\n";
121        let syms = extract(&mut ctx, &cfg, src);
122        let names: Vec<&str> = syms.iter().map(|s| s.name.as_str()).collect();
123        assert!(names.contains(&"alpha"), "got {names:?}");
124        assert!(names.contains(&"Beta"), "got {names:?}");
125        assert!(names.contains(&"gamma"), "got {names:?}");
126        // alpha is defined on line 1.
127        assert_eq!(syms.iter().find(|s| s.name == "alpha").map(|s| s.line), Some(1));
128    }
129
130    #[test]
131    fn unsupported_extension_has_no_config() {
132        assert!(config_for("json").is_none());
133        assert!(!supports("css"));
134        assert!(supports("rs"));
135        assert!(supports("ts"));
136    }
137}