use tree_sitter::{Language, Node};
use crate::core::{Kind, Symbol};
use crate::lang::{Ctx, LanguagePlugin, extract_with_key, qualify};
const TYPESCRIPT: &str = "typescript";
const JAVASCRIPT: &str = "javascript";
type Grammar = (&'static str, Language);
pub(crate) struct TypeScript;
pub(crate) struct JavaScript;
impl LanguagePlugin for TypeScript {
fn language(&self) -> &'static str {
TYPESCRIPT
}
fn extensions(&self) -> &[&str] {
&["ts", "mts", "cts", "tsx"]
}
fn constructor(&self) -> Option<&'static str> {
Some("constructor")
}
fn extract(&self, file: &str, source: &str) -> Vec<Symbol> {
let grammar = if is_tsx(file) { tsx() } else { ts() };
run(TYPESCRIPT, grammar, file, source)
}
}
impl LanguagePlugin for JavaScript {
fn language(&self) -> &'static str {
JAVASCRIPT
}
fn extensions(&self) -> &[&str] {
&["js", "mjs", "cjs", "jsx"]
}
fn constructor(&self) -> Option<&'static str> {
Some("constructor")
}
fn extract(&self, file: &str, source: &str) -> Vec<Symbol> {
run(JAVASCRIPT, tsx(), file, source)
}
}
fn ts() -> Grammar {
("ts", tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into())
}
fn tsx() -> Grammar {
("tsx", tree_sitter_typescript::LANGUAGE_TSX.into())
}
fn run(language: &'static str, (key, grammar): Grammar, file: &str, source: &str) -> Vec<Symbol> {
let scope = Scope {
parent: None,
exported: false,
ambient: is_declaration_file(file),
};
extract_with_key(key, language, grammar, file, source, |ctx, root, out| {
walk(ctx, root, scope, out)
})
}
fn is_declaration_file(file: &str) -> bool {
let name = file.rsplit('/').next().unwrap_or(file);
[".d.ts", ".d.mts", ".d.cts"]
.iter()
.any(|ext| name.len() > ext.len() && name.to_ascii_lowercase().ends_with(ext))
}
#[derive(Clone, Copy)]
struct Scope<'a> {
parent: Option<&'a str>,
exported: bool,
ambient: bool,
}
impl<'a> Scope<'a> {
fn within(self, parent: Option<&'a str>) -> Self {
Scope {
parent,
exported: false,
..self
}
}
fn visibility(self) -> &'static str {
if self.exported || self.ambient {
"public"
} else {
"private"
}
}
}
fn is_tsx(file: &str) -> bool {
std::path::Path::new(file)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("tsx"))
}
fn walk(ctx: &Ctx, node: Node, scope: Scope, out: &mut Vec<Symbol>) {
let parent = scope.parent;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"export_statement" => walk(
ctx,
child,
Scope {
exported: true,
..scope
},
out,
),
"ambient_declaration" => {
let ambient = Scope {
ambient: true,
..scope
};
let global = has_token(child, "global");
walk(
ctx,
child,
if global {
ambient.within(None)
} else {
ambient
},
out,
);
}
"module" => {
let name = ctx.field_text(child, "name");
let name = name
.as_deref()
.map(|n| n.trim_matches(|c| c == '"' || c == '\''));
if let Some(name) = name.filter(|n| !n.contains('*'))
&& child.child_by_field_name("body").is_some()
{
let vis = scope.visibility();
push(
ctx,
out,
name,
Kind::Module,
child,
parent,
vis,
scope.ambient,
);
let qualified = qualify(parent, name, ".");
walk(ctx, child, scope.within(Some(&qualified)), out);
}
}
"class_declaration"
| "abstract_class_declaration"
| "interface_declaration"
| "type_alias_declaration"
| "enum_declaration"
| "internal_module" => {
if let Some(name) = ctx.field_text(child, "name") {
let kind = match child.kind() {
"interface_declaration" => Kind::Trait,
"type_alias_declaration" => Kind::Type,
"enum_declaration" => Kind::Enum,
"internal_module" => Kind::Module,
_ => Kind::Class,
};
let vis = scope.visibility();
let is_type = matches!(kind, Kind::Trait | Kind::Type);
let stub = scope.ambient && !is_type;
if !(kind == Kind::Module && merges_into(out, &name, parent)) {
push(ctx, out, &name, kind, child, parent, vis, stub);
}
let qualified = qualify(parent, &name, ".");
if kind == Kind::Enum {
members(ctx, child, &qualified, vis, scope.ambient, out);
continue;
}
let inner = Scope {
ambient: stub,
..scope.within(Some(&qualified))
};
walk(ctx, child, inner, out);
}
}
"function_declaration" | "generator_function_declaration" | "function_signature" => {
if let Some(name) = ctx.field_text(child, "name") {
let stub = scope.ambient || child.kind() == "function_signature";
let vis = scope.visibility();
push(ctx, out, &name, Kind::Function, child, parent, vis, stub);
}
}
"lexical_declaration" | "variable_declaration" => {
declarations(ctx, child, scope, out);
}
"method_definition" | "abstract_method_signature" | "method_signature" => {
let overload = child.kind() == "method_signature" && node.kind() == "class_body";
push_member(ctx, out, child, Kind::Method, scope, overload);
}
"public_field_definition" | "field_definition" => {
if is_function(child.child_by_field_name("value")) {
push_member(ctx, out, child, Kind::Method, scope, false);
} else if has_token(child, "static") && has_token(child, "readonly") {
push_member(ctx, out, child, Kind::Constant, scope, false);
}
}
"arrow_function" | "function_expression" | "function" => {}
"statement_block" if is_global_block(ctx, child) => {
walk(ctx, child, scope.within(None), out)
}
_ => walk(ctx, child, scope, out),
}
}
}
fn merges_into(out: &[Symbol], name: &str, parent: Option<&str>) -> bool {
out.iter().rev().any(|s| {
s.name == name
&& s.parent.as_deref() == parent
&& matches!(s.kind, Kind::Function | Kind::Class | Kind::Enum)
})
}
fn is_global_block(ctx: &Ctx, block: Node) -> bool {
block
.prev_sibling()
.filter(|p| matches!(p.kind(), "ERROR" | "expression_statement"))
.and_then(|p| ctx.node_text(p))
.is_some_and(|t| t.trim() == "global")
}
fn members(
ctx: &Ctx,
node: Node,
qualified: &str,
vis: &'static str,
stub: bool,
out: &mut Vec<Symbol>,
) {
let Some(body) = node.child_by_field_name("body") else {
return;
};
let mut cursor = body.walk();
for m in body.named_children(&mut cursor) {
let name = match m.kind() {
"enum_assignment" => m.child_by_field_name("name"),
"property_identifier" | "string" => Some(m),
_ => None, };
if let Some(name) = name.and_then(|n| ctx.node_text(n)) {
let name = name.trim_matches(|c| c == '"' || c == '\'');
push(ctx, out, name, Kind::Variant, m, Some(qualified), vis, stub);
}
}
}
fn push_member(
ctx: &Ctx,
out: &mut Vec<Symbol>,
node: Node,
kind: Kind,
scope: Scope,
overload: bool,
) {
if let Some(raw) = ctx.field_text(node, "name") {
let vis = member_visibility(ctx, node, &raw);
let name = raw.trim_start_matches('#');
let stub = scope.ambient || overload;
push(ctx, out, name, kind, node, scope.parent, vis, stub);
}
}
fn declarations(ctx: &Ctx, node: Node, scope: Scope, out: &mut Vec<Symbol>) {
let binding = scope.ambient || node.child(0).is_some_and(|k| k.kind() == "const");
let constants = binding && at_module_level(ctx, node);
let mut cursor = node.walk();
for d in node.children(&mut cursor) {
if d.kind() != "variable_declarator" {
continue;
}
let value = d.child_by_field_name("value");
let kind = if is_function(value) {
Kind::Function
} else if constants && !is_require(ctx, value) {
Kind::Constant
} else {
continue;
};
if let Some(name) = d.child_by_field_name("name")
&& name.kind() == "identifier"
&& let Some(name) = ctx.node_text(name)
{
let vis = scope.visibility();
push(
ctx,
out,
&name,
kind,
node,
scope.parent,
vis,
scope.ambient,
);
}
}
}
fn at_module_level(ctx: &Ctx, stmt: Node) -> bool {
let mut up = stmt.parent();
while let Some(n) =
up.filter(|n| matches!(n.kind(), "export_statement" | "ambient_declaration"))
{
up = n.parent();
}
up.is_some_and(|n| match n.kind() {
"program" => true,
"statement_block" => {
is_global_block(ctx, n)
|| n.parent().is_some_and(|m| {
matches!(
m.kind(),
"internal_module" | "module" | "ambient_declaration"
)
})
}
_ => false,
})
}
fn is_require(ctx: &Ctx, mut value: Option<Node>) -> bool {
while let Some(v) = value {
match v.kind() {
"member_expression" => value = v.child_by_field_name("object"),
"call_expression" => {
return v
.child_by_field_name("function")
.and_then(|f| ctx.node_text(f))
.is_some_and(|f| f == "require");
}
_ => return false,
}
}
false
}
fn has_token(node: Node, kw: &str) -> bool {
let mut cursor = node.walk();
node.children(&mut cursor).any(|c| c.kind() == kw)
}
fn is_function(value: Option<Node>) -> bool {
match value.map(|v| (v, v.kind())) {
Some((_, "arrow_function" | "function_expression" | "function")) => true,
Some((call, "call_expression")) => is_function(
call.child_by_field_name("arguments")
.and_then(|args| args.named_child(0)),
),
_ => false,
}
}
#[allow(clippy::too_many_arguments)] fn push(
ctx: &Ctx,
out: &mut Vec<Symbol>,
name: &str,
kind: Kind,
node: Node,
parent: Option<&str>,
visibility: &'static str,
stub: bool,
) {
let mut s = ctx.symbol(name, kind, node, parent);
s.visibility = Some(visibility);
s.stub = stub;
out.push(s);
}
fn member_visibility(ctx: &Ctx, node: Node, name: &str) -> &'static str {
if name.starts_with('#') {
return "private";
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "accessibility_modifier" {
return match ctx.node_text(child).as_deref() {
Some("private") => "private",
Some("protected") => "protected",
_ => "public",
};
}
}
"public"
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lang::testing::find;
fn extract(source: &str) -> Vec<Symbol> {
TypeScript.extract("test.ts", source)
}
#[test]
fn extracts_types_functions_and_members() {
let src = r#"
export interface Renderer {
render(): string;
}
export type Size = { width: number };
export enum Color {
Red,
}
export class Widget implements Renderer {
render(): string {
return "";
}
private resize(n: number) {}
}
export function buildWidget(): Widget {
return new Widget();
}
export const makeWidget = () => new Widget();
"#;
let syms = extract(src);
assert_eq!(find(&syms, "Renderer").kind, Kind::Trait);
assert_eq!(find(&syms, "Size").kind, Kind::Type);
assert_eq!(find(&syms, "Color").kind, Kind::Enum);
let widget = find(&syms, "Widget");
assert_eq!(widget.kind, Kind::Class);
assert_eq!(widget.parent, None);
assert_eq!(widget.language, "typescript");
let render = find(&syms, "render");
assert_eq!(render.kind, Kind::Method);
let renders: Vec<_> = syms.iter().filter(|s| s.name == "render").collect();
assert_eq!(renders.len(), 2, "{syms:?}");
assert!(
renders
.iter()
.any(|s| s.parent.as_deref() == Some("Widget"))
);
assert!(
renders
.iter()
.any(|s| s.parent.as_deref() == Some("Renderer"))
);
assert_eq!(find(&syms, "buildWidget").kind, Kind::Function);
assert_eq!(find(&syms, "makeWidget").kind, Kind::Function);
}
#[test]
fn an_object_type_declares_methods_like_an_interface() {
let src = "type Renderer = {\n render(): string;\n};\n";
let syms = extract(src);
assert_eq!(find(&syms, "Renderer").kind, Kind::Type);
let render = find(&syms, "render");
assert_eq!(render.kind, Kind::Method);
assert_eq!(render.parent.as_deref(), Some("Renderer"));
}
#[test]
fn qualifies_through_namespaces() {
let src = "namespace Outer {\n export class Store {\n get() {}\n }\n}\n";
let syms = extract(src);
assert_eq!(find(&syms, "Outer").kind, Kind::Module);
assert_eq!(find(&syms, "Store").parent.as_deref(), Some("Outer"));
assert_eq!(find(&syms, "get").parent.as_deref(), Some("Outer.Store"));
}
#[test]
fn callback_locals_are_not_definitions() {
let src = "describe('widget', () => {\n const helper = () => 1;\n});\n";
assert!(extract(src).is_empty(), "{:?}", extract(src));
}
#[test]
fn empty_and_unparseable_yield_no_symbols() {
assert!(extract("").is_empty());
assert!(extract("// just a comment\n").is_empty());
}
#[test]
fn visibility_reflects_exports_and_member_modifiers() {
let src = r#"
export function open() {}
function helper() {}
export class Account {
deposit() {}
private audit() {}
protected hook() {}
#secret() {}
}
"#;
let syms = extract(src);
assert_eq!(find(&syms, "open").visibility, Some("public"));
assert_eq!(find(&syms, "helper").visibility, Some("private"));
assert_eq!(find(&syms, "deposit").visibility, Some("public"));
assert_eq!(find(&syms, "audit").visibility, Some("private"));
assert_eq!(find(&syms, "hook").visibility, Some("protected"));
assert_eq!(find(&syms, "secret").visibility, Some("private"));
}
#[test]
fn tsx_and_jsx_parse_as_their_own_languages() {
let component = "export const Widget = () => <div>hi</div>;\n";
let tsx = TypeScript.extract("Widget.tsx", component);
assert_eq!(find(&tsx, "Widget").kind, Kind::Function);
assert_eq!(find(&tsx, "Widget").language, "typescript");
let jsx = JavaScript.extract("Widget.jsx", component);
assert_eq!(find(&jsx, "Widget").language, "javascript");
let generic = TypeScript.extract("id.ts", "export const id = <T>(x: T): T => x;\n");
assert_eq!(find(&generic, "id").kind, Kind::Function);
}
#[test]
fn module_level_consts_are_constants() {
let src = r#"
export const MAX_RETRIES = 3;
const router = createRouter();
export const handler = () => 1;
const fs = require("fs");
const { Widget } = require("./widget");
const Gadget = require("./gadget").Gadget;
const { width, height } = defaults;
let counter = 0;
var legacy = 1;
namespace Limits {
export const CEILING = 9;
}
function build() {
const localLimit = 5;
}
if (ready) {
const inBlock = 1;
}
describe("widget", () => {
const inCallback = 1;
});
"#;
let syms = extract(src);
let max = find(&syms, "MAX_RETRIES");
assert_eq!(max.kind, Kind::Constant);
assert_eq!(max.parent, None);
assert_eq!(max.visibility, Some("public"));
let router = find(&syms, "router");
assert_eq!(router.kind, Kind::Constant);
assert_eq!(router.visibility, Some("private"));
let ceiling = find(&syms, "CEILING");
assert_eq!(ceiling.kind, Kind::Constant);
assert_eq!(ceiling.parent.as_deref(), Some("Limits"));
let handlers: Vec<_> = syms.iter().filter(|s| s.name == "handler").collect();
assert_eq!(handlers.len(), 1, "{syms:?}");
assert_eq!(handlers[0].kind, Kind::Function);
for absent in [
"fs",
"Widget",
"Gadget",
"width",
"counter",
"legacy",
"localLimit",
"inBlock",
"inCallback",
] {
assert!(!syms.iter().any(|s| s.name == absent), "{absent}: {syms:?}");
}
}
#[test]
fn static_readonly_fields_are_class_constants() {
let src = r#"
enum Color {
Red,
}
class Widget {
static readonly DEFAULT_SIZE = 3;
private static readonly SECRET = "x";
static count = 0;
readonly id = 1;
}
"#;
let syms = extract(src);
let size = find(&syms, "DEFAULT_SIZE");
assert_eq!(size.kind, Kind::Constant);
assert_eq!(size.parent.as_deref(), Some("Widget"));
assert_eq!(size.visibility, Some("public"));
assert_eq!(find(&syms, "SECRET").visibility, Some("private"));
for absent in ["count", "id"] {
assert!(!syms.iter().any(|s| s.name == absent), "{absent}: {syms:?}");
}
}
#[test]
fn enum_members_are_variants_of_their_enum() {
let src = r#"
export enum EVENT {
MOUSE_MOVE = "mousemove",
// a comment between members
"key-down" = "keydown",
Wheel,
}
const enum Hidden {
Inner = 1,
}
"#;
let syms = extract(src);
for name in ["MOUSE_MOVE", "key-down", "Wheel"] {
let m = find(&syms, name);
assert_eq!(m.kind, Kind::Variant, "{name}");
assert_eq!(m.parent.as_deref(), Some("EVENT"), "{name}");
assert_eq!(m.visibility, Some("public"), "{name}");
}
assert_eq!(find(&syms, "Inner").visibility, Some("private"));
assert_eq!(syms.len(), 6, "{syms:?}");
}
#[test]
fn a_function_passed_to_a_wrapping_call_is_a_function() {
let src = r#"
export const Badge = memo((props: Props) => <div />);
export const Field = React.forwardRef<Ref, Props>(function Field(props, ref) {
return <input ref={ref} />;
});
export const Nested = memo(forwardRef((props, ref) => null));
export const Aliased = memo(BadgeBase, areEqual);
export const store = createStore({ size: 1 });
"#;
let syms = TypeScript.extract("badge.tsx", src);
for name in ["Badge", "Field", "Nested"] {
assert_eq!(find(&syms, name).kind, Kind::Function, "{name}");
}
assert_eq!(find(&syms, "Aliased").kind, Kind::Constant);
assert_eq!(find(&syms, "store").kind, Kind::Constant);
}
#[test]
fn ambient_declarations_are_public_stubs() {
let src = r#"
declare function setup(name: string): void;
export declare function teardown(): void;
declare const VERSION: string;
declare let counter: number;
declare var process: Process;
declare class Store {
get(key: string): string;
}
declare namespace Widgets {
function build(): Widget;
const LIMIT: number;
}
declare module "widget-store" {
export function open(path: string): Store;
export namespace open {
function sync(): void;
}
global {
function reset(): void;
var registry: Store;
}
}
declare module "*.svg";
declare module "side-effect";
declare global {
interface Window {
app: App;
focusApp(): void;
}
function track(event: string): void;
var DEBUG: boolean;
}
"#;
let syms = extract(src);
let at = |name: &str| {
let s = find(&syms, name);
(s.kind, s.parent.as_deref(), s.visibility, s.stub)
};
let top = |kind| (kind, None, Some("public"), true);
assert_eq!(at("setup"), top(Kind::Function));
assert_eq!(at("teardown"), top(Kind::Function));
for name in ["VERSION", "counter", "process", "DEBUG"] {
assert_eq!(at(name), top(Kind::Constant), "{name}");
}
assert_eq!(at("Store"), top(Kind::Class));
assert_eq!(
at("get"),
(Kind::Method, Some("Store"), Some("public"), true)
);
assert_eq!(at("Widgets"), top(Kind::Module));
assert_eq!(
at("build"),
(Kind::Function, Some("Widgets"), Some("public"), true)
);
assert_eq!(
at("LIMIT"),
(Kind::Constant, Some("Widgets"), Some("public"), true)
);
assert_eq!(at("widget-store"), top(Kind::Module));
let open: Vec<_> = syms.iter().filter(|s| s.name == "open").collect();
assert_eq!(open.len(), 1, "{open:?}");
assert_eq!(open[0].kind, Kind::Function);
assert_eq!(at("sync").1, Some("widget-store.open"));
assert_eq!(at("reset"), top(Kind::Function));
assert_eq!(at("registry"), top(Kind::Constant));
assert_eq!(at("Window"), (Kind::Trait, None, Some("public"), false));
assert_eq!(
at("focusApp"),
(Kind::Method, Some("Window"), Some("public"), false)
);
assert_eq!(at("track"), top(Kind::Function));
assert!(!syms.iter().any(|s| s.name.contains("svg")), "{syms:?}");
assert!(!syms.iter().any(|s| s.name == "side-effect"), "{syms:?}");
}
#[test]
fn a_declaration_file_is_ambient_without_declare() {
let src = "export default function isReady(): boolean;\nexport class Pool {\n size(): number;\n}\n";
let syms = TypeScript.extract("types/index.d.ts", src);
for name in ["isReady", "Pool", "size"] {
let s = find(&syms, name);
assert!(s.stub, "{name}");
assert_eq!(s.visibility, Some("public"), "{name}");
}
let src = "export class Pool {\n size(): number { return 1; }\n}\n";
assert!(!find(&extract(src), "Pool").stub);
}
#[test]
fn overload_signatures_are_stubs_of_the_implementation() {
let src = r#"
/** Parses a widget. */
export function parse(input: string): Widget;
export function parse(input: Buffer): Widget;
export function parse(input: string | Buffer): Widget {
return build(input);
}
class Codec {
encode(value: string): string;
encode(value: number): string;
encode(value: unknown): string {
return String(value);
}
}
"#;
let syms = extract(src);
for name in ["parse", "encode"] {
let all: Vec<_> = syms.iter().filter(|s| s.name == name).collect();
let stubs: Vec<_> = all.iter().map(|s| s.stub).collect();
assert_eq!(stubs, [true, true, false], "{name}: {all:?}");
assert!(
all.iter()
.all(|s| (s.kind, &s.parent) == (all[2].kind, &all[2].parent))
);
}
assert_eq!(find(&syms, "parse").visibility, Some("public"));
}
#[test]
fn class_properties_holding_arrows_are_methods() {
let src = "class Widget {\n handleClick = () => {};\n size = 3;\n}\n";
let syms = extract(src);
let click = find(&syms, "handleClick");
assert_eq!(click.kind, Kind::Method);
assert_eq!(click.parent.as_deref(), Some("Widget"));
assert!(!syms.iter().any(|s| s.name == "size"), "{syms:?}");
}
}