Skip to main content

reference_query/lang/
mod.rs

1//! Language plugins — the only seam languages plug into.
2//!
3//! A plugin maps source text to the common [`Symbol`](crate::core::Symbol)
4//! model. The core stays language-agnostic; adding a language is a new plugin,
5//! not a core change.
6
7use tree_sitter::{Language, Node, Parser};
8
9use crate::core::{Kind, Symbol};
10
11pub mod go;
12pub mod python;
13pub mod ruby;
14pub mod rust;
15
16/// Per-file extraction context shared by every plugin: the source bytes, the
17/// repo-relative path, and the language tag stamped on each emitted symbol.
18pub(crate) struct Ctx<'a> {
19    src: &'a [u8],
20    file: &'a str,
21    language: &'static str,
22}
23
24impl Ctx<'_> {
25    /// The text of `node`'s named field, if present.
26    pub(crate) fn field_text(&self, node: Node, field: &str) -> Option<String> {
27        node.child_by_field_name(field)
28            .and_then(|n| n.utf8_text(self.src).ok())
29            .map(str::to_string)
30    }
31
32    /// The text of `node` itself.
33    pub(crate) fn node_text(&self, node: Node) -> Option<String> {
34        node.utf8_text(self.src).ok().map(str::to_string)
35    }
36
37    /// Build a [`Symbol`] for `node` (1-based line span).
38    pub(crate) fn symbol(
39        &self,
40        name: &str,
41        kind: Kind,
42        node: Node,
43        parent: Option<&str>,
44    ) -> Symbol {
45        Symbol {
46            name: name.to_string(),
47            kind,
48            language: self.language.to_string(),
49            file: self.file.to_string(),
50            line: node.start_position().row as u32 + 1,
51            end_line: node.end_position().row as u32 + 1,
52            parent: parent.map(str::to_string),
53            visibility: None, // plugins that know it set it on the result
54        }
55    }
56}
57
58/// Join a name onto its enclosing qualified name with the language's separator.
59pub(crate) fn qualify(parent: Option<&str>, name: &str, sep: &str) -> String {
60    match parent {
61        Some(p) => format!("{p}{sep}{name}"),
62        None => name.to_string(),
63    }
64}
65
66thread_local! {
67    /// One parser per language per thread. `set_language` (grammar table
68    /// loading) is the expensive step of parser setup, and the indexer calls
69    /// `extract` once per file — reuse makes that a one-time cost per worker.
70    static PARSERS: std::cell::RefCell<std::collections::HashMap<&'static str, Parser>> =
71        std::cell::RefCell::new(std::collections::HashMap::new());
72}
73
74/// Parse `source` with `grammar` and hand the tree's root (plus a [`Ctx`]) to
75/// the plugin's `walk`. All the per-file plumbing lives here; a plugin is just
76/// its walk. (The parser cache is borrowed across the walk, so a walk must
77/// never recurse into another `extract` — none does.)
78pub(crate) fn extract_with(
79    language: &'static str,
80    grammar: Language,
81    file: &str,
82    source: &str,
83    walk: impl FnOnce(&Ctx, Node, &mut Vec<Symbol>),
84) -> Vec<Symbol> {
85    PARSERS.with(|cell| {
86        let mut parsers = cell.borrow_mut();
87        let parser = match parsers.entry(language) {
88            std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
89            std::collections::hash_map::Entry::Vacant(v) => {
90                let mut p = Parser::new();
91                if p.set_language(&grammar).is_err() {
92                    return Vec::new();
93                }
94                v.insert(p)
95            }
96        };
97        let Some(tree) = parser.parse(source, None) else {
98            return Vec::new();
99        };
100        let mut out = Vec::new();
101        let ctx = Ctx {
102            src: source.as_bytes(),
103            file,
104            language,
105        };
106        walk(&ctx, tree.root_node(), &mut out);
107        out
108    })
109}
110
111/// Extracts definitions from a single source file.
112pub trait LanguagePlugin {
113    /// The language tag emitted on every [`Symbol`] (e.g. `"ruby"`). Also the
114    /// canonical name `--lang` matches against.
115    fn language(&self) -> &'static str;
116
117    /// File extensions this plugin handles, without the dot (e.g. `["rb"]`).
118    fn extensions(&self) -> &[&str];
119
120    /// Extract definitions from `source`. `file` is the repo-relative path,
121    /// recorded on each emitted [`Symbol`].
122    fn extract(&self, file: &str, source: &str) -> Vec<Symbol>;
123}
124
125/// The registered language plugins. Adding a language is one line here.
126static REGISTRY: [&(dyn LanguagePlugin + Sync); 4] =
127    [&ruby::Ruby, &rust::Rust, &go::Go, &python::Python];
128
129/// The tags of all registered languages — the set `--lang` matches against, so
130/// it can't drift from the registry.
131pub fn languages() -> Vec<&'static str> {
132    registry().iter().map(|p| p.language()).collect()
133}
134
135/// The registered language plugins.
136pub fn registry() -> &'static [&'static (dyn LanguagePlugin + Sync)] {
137    &REGISTRY
138}
139
140/// The plugin handling files with the given extension (without the dot), if any.
141pub fn plugin_for_extension(ext: &str) -> Option<&'static (dyn LanguagePlugin + Sync)> {
142    REGISTRY
143        .iter()
144        .copied()
145        .find(|p| p.extensions().contains(&ext))
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn languages_are_registered_by_extension() {
154        for ext in ["rb", "rs", "go", "py"] {
155            assert!(plugin_for_extension(ext).is_some(), "{ext} should resolve");
156        }
157        assert!(plugin_for_extension("java").is_none());
158    }
159}