Skip to main content

meta_ast/language/
mod.rs

1//! Language system: compile-time enum dispatch over 9 language packs.
2//!
3//! `LangId` enum selects a `LanguageSpec` via exhaustive `match`.
4//! Each `LanguageSpec` bundles a grammar constructor, tree-sitter query
5//! constructors (symbols, imports, references), and language-specific
6//! extraction heuristics.
7
8pub(crate) mod c;
9pub(crate) mod common;
10pub(crate) mod cpp;
11#[cfg(feature = "dataflow")]
12pub(crate) mod dataflow;
13pub(crate) mod go;
14pub mod import_resolver;
15pub(crate) mod javascript;
16pub(crate) mod python;
17pub(crate) mod ruby;
18pub(crate) mod rust;
19pub(crate) mod tsx;
20pub(crate) mod typescript;
21
22use serde::{Deserialize, Serialize};
23use tree_sitter::Query;
24
25use crate::model::Visibility;
26
27/// Configuration for extracting doc comments from source code.
28///
29/// Used for languages where doc comments are tree-sitter extras
30/// (siblings of declarations, not children) and cannot be captured
31/// inline in queries.
32#[derive(Debug, Clone)]
33pub struct DocCommentConfig {
34    /// Line comment prefixes that indicate doc comments (e.g., `["///", "//!"]` for Rust).
35    pub line_prefixes: &'static [&'static str],
36    /// Block comment opening (e.g., `Some("/**")` for doxygen/JSDoc).
37    pub block_open: Option<&'static str>,
38    /// Block comment closing (e.g., `"*/"`).
39    pub block_close: &'static str,
40    /// Whether to strip leading `*` continuation markers in block comments.
41    pub strip_continuation_marker: bool,
42}
43
44/// Default visibility assumed when a symbol declares no explicit modifier.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum DefaultVisibility {
48    /// When visibility is None, treat the symbol as public (Python, C functions).
49    PublicByDefault,
50    /// When visibility is None, treat the symbol as private (Rust, JS, TS, Go, C++).
51    PrivateByDefault,
52}
53
54/// Shared JSDoc/C-style doc comment config.
55///
56/// Used by JS, TS, TSX, C, C++ specs. Only Rust, Go, Ruby, Python differ.
57pub const C_LIKE_DOC_COMMENT: DocCommentConfig = DocCommentConfig {
58    line_prefixes: &["//"],
59    block_open: Some("/**"),
60    block_close: "*/",
61    strip_continuation_marker: true,
62};
63
64#[derive(
65    Debug,
66    Clone,
67    Copy,
68    PartialEq,
69    Eq,
70    Hash,
71    Serialize,
72    Deserialize,
73    strum::Display,
74    strum::AsRefStr,
75    strum::EnumString,
76)]
77#[non_exhaustive]
78#[serde(rename_all = "snake_case")]
79#[strum(serialize_all = "snake_case")]
80#[repr(usize)]
81pub enum LangId {
82    Python,
83    #[strum(serialize = "javascript")]
84    #[serde(rename = "javascript")]
85    JavaScript,
86    #[strum(serialize = "typescript")]
87    #[serde(rename = "typescript")]
88    TypeScript,
89    Tsx,
90    C,
91    Cpp,
92    Rust,
93    Go,
94    Ruby,
95}
96
97impl LangId {
98    pub const COUNT: usize = 9;
99
100    pub fn all() -> [LangId; Self::COUNT] {
101        [
102            LangId::Python,
103            LangId::JavaScript,
104            LangId::TypeScript,
105            LangId::Tsx,
106            LangId::C,
107            LangId::Cpp,
108            LangId::Rust,
109            LangId::Go,
110            LangId::Ruby,
111        ]
112    }
113
114    pub fn spec(self) -> &'static LanguageSpec {
115        spec_for(self)
116    }
117
118    #[cfg(feature = "metacall-deploy")]
119    pub fn metacall_tag(self) -> &'static str {
120        crate::deploy::tags::metacall_tag(self)
121    }
122}
123
124#[derive(Debug, Clone, Serialize)]
125pub struct RawSymbol<'a> {
126    pub name: std::borrow::Cow<'a, str>,
127    pub kind: crate::model::SymbolKind,
128    pub source_range: crate::model::SourceRange,
129    pub visibility: Option<crate::model::Visibility>,
130    pub signature: Option<std::borrow::Cow<'a, str>>,
131    pub docstring: Option<std::borrow::Cow<'a, str>>,
132    pub is_async: bool,
133}
134
135pub struct LanguageSpec {
136    pub extensions: &'static [&'static str],
137    pub grammar_fn: fn() -> tree_sitter::Language,
138    pub query_fn: fn() -> &'static Query,
139    pub import_path_resolver: fn(
140        raw: &str,
141        source_dir: &std::path::Path,
142        project_root: &std::path::Path,
143    ) -> Option<std::path::PathBuf>,
144    pub import_ref_query_fn: fn() -> &'static Query,
145    pub class_like_parents: &'static [&'static str],
146    pub ancestor_visibility_rules: &'static [(&'static str, Visibility)],
147    pub visibility_from_name: Option<fn(&str) -> Option<Visibility>>,
148    pub import_statement_kinds: &'static [&'static str],
149    pub default_visibility: DefaultVisibility,
150    pub doc_comment_config: Option<DocCommentConfig>,
151}
152
153pub fn spec_for(id: LangId) -> &'static LanguageSpec {
154    match id {
155        LangId::Python => &python::PYTHON_SPEC,
156        LangId::JavaScript => &javascript::JS_SPEC,
157        LangId::TypeScript => &typescript::TS_SPEC,
158        LangId::Tsx => &tsx::TSX_SPEC,
159        LangId::C => &c::C_SPEC,
160        LangId::Cpp => &cpp::CPP_SPEC,
161        LangId::Rust => &rust::RUST_SPEC,
162        LangId::Go => &go::GO_SPEC,
163        LangId::Ruby => &ruby::RUBY_SPEC,
164    }
165}
166
167pub fn grammar_for(id: LangId) -> tree_sitter::Language {
168    (spec_for(id).grammar_fn)()
169}
170
171/// Eagerly initialize all language query statics.
172/// Call at startup to fail fast on query compilation bugs.
173pub fn validate_queries() {
174    for id in LangId::all() {
175        let _ = (spec_for(id).query_fn)();
176        let _ = (spec_for(id).import_ref_query_fn)();
177    }
178}
179
180pub fn extract_symbols_for<'a>(
181    id: LangId,
182    tree: &'a tree_sitter::Tree,
183    source: &'a [u8],
184) -> Vec<RawSymbol<'a>> {
185    common::extract_with_spec(tree, source, spec_for(id))
186}
187
188pub fn extract_imports_and_references_for<'a>(
189    id: LangId,
190    tree: &'a tree_sitter::Tree,
191    source: &'a [u8],
192    file_path: &std::path::Path,
193) -> (
194    Vec<crate::model::UnresolvedImport>,
195    Vec<crate::model::UnresolvedReference>,
196) {
197    common::extract_imports_and_references_with_spec(tree, source, spec_for(id), file_path)
198}
199
200#[cfg(test)]
201mod tests {
202    use std::str::FromStr;
203
204    use super::*;
205
206    #[test]
207    fn lang_id_all_variants_exist() {
208        let variants = LangId::all();
209        for i in 0..variants.len() {
210            for j in (i + 1)..variants.len() {
211                assert_ne!(variants[i], variants[j]);
212            }
213        }
214    }
215
216    #[test]
217    fn lang_id_display() {
218        assert_eq!(format!("{}", LangId::Python), "python");
219        assert_eq!(format!("{}", LangId::Ruby), "ruby");
220        assert_eq!(format!("{}", LangId::JavaScript), "javascript");
221        assert_eq!(format!("{}", LangId::TypeScript), "typescript");
222    }
223
224    #[test]
225    fn lang_id_vocabulary_is_canonical() {
226        let expected = [
227            "python",
228            "javascript",
229            "typescript",
230            "tsx",
231            "c",
232            "cpp",
233            "rust",
234            "go",
235            "ruby",
236        ];
237        let all = LangId::all();
238        let actual: Vec<&str> = all.iter().map(|l| l.as_ref()).collect();
239        assert_eq!(actual, expected);
240    }
241
242    #[test]
243    fn lang_id_from_str_round_trip() {
244        for id in LangId::all() {
245            let name: &str = id.as_ref();
246            let parsed = LangId::from_str(name)
247                .unwrap_or_else(|e| panic!("from_str failed for documented name {name:?}: {e}"));
248            assert_eq!(parsed, id);
249        }
250        assert_eq!(LangId::from_str("typescript"), Ok(LangId::TypeScript));
251        assert_eq!(LangId::from_str("javascript"), Ok(LangId::JavaScript));
252    }
253
254    #[test]
255    fn lang_id_serde_snake_case() {
256        let json = serde_json::to_string(&LangId::Python).unwrap();
257        assert_eq!(json, "\"python\"");
258
259        let json = serde_json::to_string(&LangId::Ruby).unwrap();
260        assert_eq!(json, "\"ruby\"");
261
262        let json = serde_json::to_string(&LangId::JavaScript).unwrap();
263        assert_eq!(json, "\"javascript\"");
264
265        let json = serde_json::to_string(&LangId::TypeScript).unwrap();
266        assert_eq!(json, "\"typescript\"");
267    }
268
269    #[test]
270    fn grammar_for_all_variants() {
271        for id in LangId::all() {
272            let _lang = grammar_for(id);
273        }
274    }
275
276    #[test]
277    fn extract_symbols_for_python() {
278        let mut parser = tree_sitter::Parser::new();
279        parser.set_language(&grammar_for(LangId::Python)).unwrap();
280        let tree = parser.parse(b"def hello(): pass", None).unwrap();
281        let symbols = extract_symbols_for(LangId::Python, &tree, b"def hello(): pass");
282        assert!(!symbols.is_empty());
283    }
284
285    #[test]
286    fn spec_for_returns_spec_with_matching_extensions() {
287        let python_spec = spec_for(LangId::Python);
288        assert!(python_spec.extensions.contains(&"py"));
289        assert!(python_spec.extensions.contains(&"pyi"));
290
291        let js_spec = spec_for(LangId::JavaScript);
292        assert!(js_spec.extensions.contains(&"js"));
293    }
294
295    #[test]
296    fn lang_id_count_matches_variant_count() {
297        assert_eq!(LangId::COUNT, 9);
298        assert_eq!(LangId::all().len(), LangId::COUNT);
299    }
300
301    #[test]
302    fn all_specs_have_non_empty_extensions() {
303        for id in LangId::all() {
304            let spec = spec_for(id);
305            assert!(
306                !spec.extensions.is_empty(),
307                "{id:?} spec has empty extensions"
308            );
309        }
310    }
311
312    #[test]
313    fn no_duplicate_extensions_across_specs() {
314        use std::collections::HashSet;
315        let mut seen: HashSet<&str> = HashSet::new();
316        for id in LangId::all() {
317            let spec = spec_for(id);
318            for &ext in spec.extensions {
319                assert!(
320                    seen.insert(ext),
321                    "extension {ext:?} appears in more than one language spec"
322                );
323            }
324        }
325    }
326
327    #[test]
328    fn grammar_fn_smoke_test_all_variants() {
329        for id in LangId::all() {
330            let spec = spec_for(id);
331            let grammar = (spec.grammar_fn)();
332            let mut parser = tree_sitter::Parser::new();
333            assert!(
334                parser.set_language(&grammar).is_ok(),
335                "grammar_fn failed for {id:?}"
336            );
337        }
338    }
339
340    #[test]
341    fn query_fn_smoke_test_all_variants() {
342        for id in LangId::all() {
343            let spec = spec_for(id);
344            let _query = (spec.query_fn)();
345        }
346    }
347
348    #[test]
349    #[cfg(feature = "metacall-deploy")]
350    fn test_lang_id_metacall_tag() {
351        assert_eq!(LangId::Python.metacall_tag(), "py");
352        assert_eq!(LangId::JavaScript.metacall_tag(), "node");
353        assert_eq!(LangId::TypeScript.metacall_tag(), "ts");
354        assert_eq!(LangId::Tsx.metacall_tag(), "ts");
355        assert_eq!(LangId::C.metacall_tag(), "c");
356        assert_eq!(LangId::Cpp.metacall_tag(), "cpp");
357        assert_eq!(LangId::Rust.metacall_tag(), "rs");
358        assert_eq!(LangId::Go.metacall_tag(), "go");
359        assert_eq!(LangId::Ruby.metacall_tag(), "rb");
360    }
361}