1use 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
27type 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 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 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
81fn 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
88fn 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_statement" => walk(ctx, child, parent, true, out),
96
97 "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 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 }
131
132 "lexical_declaration" | "variable_declaration" => {
134 declared_functions(ctx, child, parent, module_visibility(exported), out);
135 }
136
137 "method_definition" | "abstract_method_signature" | "method_signature" => {
139 push_member(ctx, out, child, parent);
140 }
141
142 "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 "arrow_function" | "function_expression" | "function" => {}
152
153 _ => walk(ctx, child, parent, exported, out),
154 }
155 }
156}
157
158fn 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
168fn 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 push(ctx, out, &name, Kind::Function, node, parent, visibility);
184 }
185 }
186}
187
188fn 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
210fn module_visibility(exported: bool) -> &'static str {
212 if exported { "public" } else { "private" }
213}
214
215fn 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 let render = find(&syms, "render");
288 assert_eq!(render.kind, Kind::Method);
289 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 assert_eq!(find(&syms, "makeWidget").kind, Kind::Function);
306 }
307
308 #[test]
309 fn an_object_type_declares_methods_like_an_interface() {
310 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 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 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 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 assert!(!syms.iter().any(|s| s.name == "size"), "{syms:?}");
390 }
391}