Skip to main content

reference_query/lang/typescript/
mod.rs

1//! TypeScript / JavaScript plugin. One grammar family, two language tags:
2//! JavaScript is TypeScript with the types taken out, so both share a walk and
3//! `-x ts` / `-x js` still mean what you'd expect.
4//!
5//! Extracts `class` → class, `interface` → trait (a named contract, like Go's),
6//! `type` → struct (a named shape), `enum` → enum, `namespace` → module,
7//! `function` → function, and the members a class, interface, or object type
8//! declares → method. A
9//! `const f = () => …` is a function too — in modern JS that *is* how functions
10//! are declared. `parent` is `.`-joined, so a method renders as `deposit ·
11//! Account`.
12//!
13//! Visibility: a class member takes its `private`/`protected` modifier (or `#`
14//! prefix); anything module-level reads public when `export`ed and private when
15//! not. That last convention is ESM's — a CommonJS file (`module.exports = …`)
16//! exports nothing the grammar can see, so its definitions all read private.
17//! Visibility is only ever a small ranking nudge, so the mislabel costs little.
18
19use tree_sitter::{Language, Node};
20
21use crate::core::{Kind, Symbol};
22use crate::lang::{Ctx, LanguagePlugin, extract_with_key, qualify};
23
24const TYPESCRIPT: &str = "typescript";
25const JAVASCRIPT: &str = "javascript";
26
27/// A grammar paired with the parser-cache key naming it. The key identifies the
28/// *grammar*, not the language tag, so the two are never named apart.
29type Grammar = (&'static str, Language);
30
31pub struct TypeScript;
32pub struct JavaScript;
33
34impl LanguagePlugin for TypeScript {
35    fn language(&self) -> &'static str {
36        TYPESCRIPT
37    }
38
39    fn extensions(&self) -> &[&str] {
40        &["ts", "mts", "cts", "tsx"]
41    }
42
43    fn extract(&self, file: &str, source: &str) -> Vec<Symbol> {
44        // The two grammars disagree on `<T>`: TSX reads it as a JSX tag, TS as a
45        // type parameter. Give each file the one it means.
46        let grammar = if is_tsx(file) { tsx() } else { ts() };
47        run(TYPESCRIPT, grammar, file, source)
48    }
49}
50
51impl LanguagePlugin for JavaScript {
52    fn language(&self) -> &'static str {
53        JAVASCRIPT
54    }
55
56    fn extensions(&self) -> &[&str] {
57        &["js", "mjs", "cjs", "jsx"]
58    }
59
60    fn extract(&self, file: &str, source: &str) -> Vec<Symbol> {
61        // TSX is the JSX-aware superset — it parses plain JS, and `.js` holding
62        // JSX is routine in React projects.
63        run(JAVASCRIPT, tsx(), file, source)
64    }
65}
66
67fn ts() -> Grammar {
68    ("ts", tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into())
69}
70
71fn tsx() -> Grammar {
72    ("tsx", tree_sitter_typescript::LANGUAGE_TSX.into())
73}
74
75fn run(language: &'static str, (key, grammar): Grammar, file: &str, source: &str) -> Vec<Symbol> {
76    extract_with_key(key, language, grammar, file, source, |ctx, root, out| {
77        walk(ctx, root, None, false, out)
78    })
79}
80
81/// Whether `file` is a `.tsx` — the JSX-bearing dialect of TypeScript.
82fn is_tsx(file: &str) -> bool {
83    std::path::Path::new(file)
84        .extension()
85        .is_some_and(|e| e.eq_ignore_ascii_case("tsx"))
86}
87
88/// Recursively collect definitions. `parent` is the enclosing qualified name;
89/// `exported` is set while walking under an `export`.
90fn walk(ctx: &Ctx, node: Node, parent: Option<&str>, exported: bool, out: &mut Vec<Symbol>) {
91    let mut cursor = node.walk();
92    for child in node.children(&mut cursor) {
93        match child.kind() {
94            // `export …` isn't a definition; it marks the one that follows public
95            "export_statement" => walk(ctx, child, parent, true, out),
96
97            // a named type (or namespace): emit it, then descend so whatever
98            // members it declares are qualified by it. `type Foo = { run(): … }`
99            // holds methods exactly like `interface Foo` does, so it's the same
100            // arm — an enum body simply has nothing we extract.
101            "class_declaration"
102            | "abstract_class_declaration"
103            | "interface_declaration"
104            | "type_alias_declaration"
105            | "enum_declaration"
106            | "internal_module" => {
107                if let Some(name) = ctx.field_text(child, "name") {
108                    let kind = match child.kind() {
109                        "interface_declaration" => Kind::Trait,
110                        "type_alias_declaration" => Kind::Struct,
111                        "enum_declaration" => Kind::Enum,
112                        "internal_module" => Kind::Module,
113                        _ => Kind::Class,
114                    };
115                    let vis = module_visibility(exported);
116                    push(ctx, out, &name, kind, child, parent, vis);
117                    let qualified = qualify(parent, &name, ".");
118                    // members carry their own visibility; a namespace body
119                    // re-declares `export` for what it re-exports
120                    walk(ctx, child, Some(&qualified), false, out);
121                }
122            }
123
124            "function_declaration" | "generator_function_declaration" => {
125                if let Some(name) = ctx.field_text(child, "name") {
126                    let vis = module_visibility(exported);
127                    push(ctx, out, &name, Kind::Function, child, parent, vis);
128                }
129                // bodies hold locals and callbacks, not navigation targets
130            }
131
132            // `const handler = () => …` — the modern function declaration
133            "lexical_declaration" | "variable_declaration" => {
134                declared_functions(ctx, child, parent, module_visibility(exported), out);
135            }
136
137            // class and interface members
138            "method_definition" | "abstract_method_signature" | "method_signature" => {
139                push_member(ctx, out, child, parent);
140            }
141
142            // `handleClick = () => …` in a class body: a method but for syntax
143            "public_field_definition" | "field_definition" => {
144                if is_function(child.child_by_field_name("value")) {
145                    push_member(ctx, out, child, parent);
146                }
147            }
148
149            // never descend into a function body reached some other way (a
150            // callback argument, an IIFE) — its locals aren't definitions
151            "arrow_function" | "function_expression" | "function" => {}
152
153            _ => walk(ctx, child, parent, exported, out),
154        }
155    }
156}
157
158/// Emit a member of a type as a method. An ES private name (`#tally`) is
159/// indexed without its `#`, so it's found by the name you'd think to search.
160fn push_member(ctx: &Ctx, out: &mut Vec<Symbol>, node: Node, parent: Option<&str>) {
161    if let Some(raw) = ctx.field_text(node, "name") {
162        let vis = member_visibility(ctx, node, &raw);
163        let name = raw.trim_start_matches('#');
164        push(ctx, out, name, Kind::Method, node, parent, vis);
165    }
166}
167
168/// Emit the function-valued declarators of a `const`/`let`/`var` statement.
169fn declared_functions(
170    ctx: &Ctx,
171    node: Node,
172    parent: Option<&str>,
173    visibility: &'static str,
174    out: &mut Vec<Symbol>,
175) {
176    let mut cursor = node.walk();
177    for d in node.children(&mut cursor) {
178        if d.kind() != "variable_declarator" || !is_function(d.child_by_field_name("value")) {
179            continue;
180        }
181        if let Some(name) = ctx.field_text(d, "name") {
182            // span the whole statement, so `end_line` covers the closing brace
183            push(ctx, out, &name, Kind::Function, node, parent, visibility);
184        }
185    }
186}
187
188/// Whether a declarator's value is a function in some spelling.
189fn is_function(value: Option<Node>) -> bool {
190    matches!(
191        value.map(|v| v.kind()),
192        Some("arrow_function" | "function_expression" | "function")
193    )
194}
195
196fn push(
197    ctx: &Ctx,
198    out: &mut Vec<Symbol>,
199    name: &str,
200    kind: Kind,
201    node: Node,
202    parent: Option<&str>,
203    visibility: &'static str,
204) {
205    let mut s = ctx.symbol(name, kind, node, parent);
206    s.visibility = Some(visibility);
207    out.push(s);
208}
209
210/// ESM's convention: what a module exports is its public API.
211fn module_visibility(exported: bool) -> &'static str {
212    if exported { "public" } else { "private" }
213}
214
215/// A member's declared access: the TypeScript modifier if it has one, else the
216/// `#` prefix of an ES private name, else public (both languages' default).
217fn member_visibility(ctx: &Ctx, node: Node, name: &str) -> &'static str {
218    if name.starts_with('#') {
219        return "private";
220    }
221    let mut cursor = node.walk();
222    for child in node.children(&mut cursor) {
223        if child.kind() == "accessibility_modifier" {
224            return match ctx.node_text(child).as_deref() {
225                Some("private") => "private",
226                Some("protected") => "protected",
227                _ => "public",
228            };
229        }
230    }
231    "public"
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn extract(source: &str) -> Vec<Symbol> {
239        TypeScript.extract("test.ts", source)
240    }
241
242    fn find<'a>(syms: &'a [Symbol], name: &str) -> &'a Symbol {
243        syms.iter()
244            .find(|s| s.name == name)
245            .unwrap_or_else(|| panic!("no symbol named {name} in {syms:?}"))
246    }
247
248    #[test]
249    fn extracts_types_functions_and_members() {
250        let src = r#"
251export interface Renderer {
252  render(): string;
253}
254
255export type Size = { width: number };
256
257export enum Color {
258  Red,
259}
260
261export class Widget implements Renderer {
262  render(): string {
263    return "";
264  }
265
266  private resize(n: number) {}
267}
268
269export function buildWidget(): Widget {
270  return new Widget();
271}
272
273export const makeWidget = () => new Widget();
274"#;
275        let syms = extract(src);
276
277        assert_eq!(find(&syms, "Renderer").kind, Kind::Trait);
278        assert_eq!(find(&syms, "Size").kind, Kind::Struct);
279        assert_eq!(find(&syms, "Color").kind, Kind::Enum);
280
281        let widget = find(&syms, "Widget");
282        assert_eq!(widget.kind, Kind::Class);
283        assert_eq!(widget.parent, None);
284        assert_eq!(widget.language, "typescript");
285
286        // members are methods qualified by the type that holds them
287        let render = find(&syms, "render");
288        assert_eq!(render.kind, Kind::Method);
289        // both the class method and the interface signature are recorded
290        let renders: Vec<_> = syms.iter().filter(|s| s.name == "render").collect();
291        assert_eq!(renders.len(), 2, "{syms:?}");
292        assert!(
293            renders
294                .iter()
295                .any(|s| s.parent.as_deref() == Some("Widget"))
296        );
297        assert!(
298            renders
299                .iter()
300                .any(|s| s.parent.as_deref() == Some("Renderer"))
301        );
302
303        assert_eq!(find(&syms, "buildWidget").kind, Kind::Function);
304        // an arrow assigned to a const is a function, not a mystery
305        assert_eq!(find(&syms, "makeWidget").kind, Kind::Function);
306    }
307
308    #[test]
309    fn an_object_type_declares_methods_like_an_interface() {
310        // the two spellings are interchangeable in TypeScript, so a method is
311        // just as navigable through either
312        let src = "type Renderer = {\n  render(): string;\n};\n";
313        let syms = extract(src);
314        assert_eq!(find(&syms, "Renderer").kind, Kind::Struct);
315        let render = find(&syms, "render");
316        assert_eq!(render.kind, Kind::Method);
317        assert_eq!(render.parent.as_deref(), Some("Renderer"));
318    }
319
320    #[test]
321    fn qualifies_through_namespaces() {
322        let src = "namespace Outer {\n  export class Store {\n    get() {}\n  }\n}\n";
323        let syms = extract(src);
324        assert_eq!(find(&syms, "Outer").kind, Kind::Module);
325        assert_eq!(find(&syms, "Store").parent.as_deref(), Some("Outer"));
326        assert_eq!(find(&syms, "get").parent.as_deref(), Some("Outer.Store"));
327    }
328
329    #[test]
330    fn callback_locals_are_not_definitions() {
331        // a helper defined inside a test callback isn't a navigation target
332        let src = "describe('widget', () => {\n  const helper = () => 1;\n});\n";
333        assert!(extract(src).is_empty(), "{:?}", extract(src));
334    }
335
336    #[test]
337    fn empty_and_unparseable_yield_no_symbols() {
338        assert!(extract("").is_empty());
339        assert!(extract("// just a comment\n").is_empty());
340    }
341
342    #[test]
343    fn visibility_reflects_exports_and_member_modifiers() {
344        let src = r#"
345export function open() {}
346function helper() {}
347
348export class Account {
349  deposit() {}
350  private audit() {}
351  protected hook() {}
352  #secret() {}
353}
354"#;
355        let syms = extract(src);
356        assert_eq!(find(&syms, "open").visibility, Some("public"));
357        assert_eq!(find(&syms, "helper").visibility, Some("private"));
358        assert_eq!(find(&syms, "deposit").visibility, Some("public"));
359        assert_eq!(find(&syms, "audit").visibility, Some("private"));
360        assert_eq!(find(&syms, "hook").visibility, Some("protected"));
361        // an ES private name is private, and navigable without the `#`
362        assert_eq!(find(&syms, "secret").visibility, Some("private"));
363    }
364
365    #[test]
366    fn tsx_and_jsx_parse_as_their_own_languages() {
367        let component = "export const Widget = () => <div>hi</div>;\n";
368
369        let tsx = TypeScript.extract("Widget.tsx", component);
370        assert_eq!(find(&tsx, "Widget").kind, Kind::Function);
371        assert_eq!(find(&tsx, "Widget").language, "typescript");
372
373        let jsx = JavaScript.extract("Widget.jsx", component);
374        assert_eq!(find(&jsx, "Widget").language, "javascript");
375
376        // a `.ts` file reads `<T>` as a type parameter, not a JSX tag
377        let generic = TypeScript.extract("id.ts", "export const id = <T>(x: T): T => x;\n");
378        assert_eq!(find(&generic, "id").kind, Kind::Function);
379    }
380
381    #[test]
382    fn class_properties_holding_arrows_are_methods() {
383        let src = "class Widget {\n  handleClick = () => {};\n  size = 3;\n}\n";
384        let syms = extract(src);
385        let click = find(&syms, "handleClick");
386        assert_eq!(click.kind, Kind::Method);
387        assert_eq!(click.parent.as_deref(), Some("Widget"));
388        // a plain data field isn't a definition worth navigating to
389        assert!(!syms.iter().any(|s| s.name == "size"), "{syms:?}");
390    }
391}