Skip to main content

devscout_rs/extract/
ts_fragment.rs

1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use tree_sitter::Node;
5
6use super::text::{named_children, text, truncate};
7use super::ts_fragment_types::{
8    TsBinding, TsFragment, TsFragmentDef, TsImport, TsReexport, TsReexportName, TsRef,
9};
10use super::ts_purpose::{
11    common_js_export_target, compose_hybrid_ts_purpose, is_default_export_statement,
12    ts_declared_name, ts_pattern_names, ts_purpose_segments, CjsTarget,
13};
14
15// The dispatching call names, framework-specific BY NAME on purpose.
16// `dispatch(x)` covers redux
17// and NgRx `Store.dispatch`; `ofType(x)` is how an NgRx effect names the
18// actions it reacts to. A member call ending in `.dispatch(...)` counts too,
19// by the property name alone -- the receiver is never resolved, so this can
20// never claim more than the call site literally spells.
21const TS_DISPATCH_CALLEES: &[&str] = &["dispatch", "ofType"];
22
23// The four node kinds a reference can be spelled as. The traversal below is
24// cursor-driven and materialises a `Node` ONLY for these.
25const TS_REFERENCE_NODES: &[&str] = &[
26    "call_expression",
27    "new_expression",
28    "jsx_opening_element",
29    "jsx_self_closing_element",
30];
31
32// `None` for anything that is not a `string` node, and the empty string for
33// a string with no `string_fragment` child.
34fn ts_string_literal(node: Option<Node>, src: &[u8]) -> Option<String> {
35    let node = node?;
36    if node.kind() != "string" {
37        return None;
38    }
39    Some(
40        match named_children(node)
41            .into_iter()
42            .find(|c| c.kind() == "string_fragment")
43        {
44            Some(frag) => text(frag, src),
45            None => String::new(),
46        },
47    )
48}
49
50fn ts_line(node: Node) -> usize {
51    node.start_position().row + 1
52}
53
54// A JSX tag names a component (rather than an intrinsic HTML element) exactly
55// when its first character is an ASCII uppercase letter -- React's own rule,
56// applied literally rather than guessed at. ASCII-only, so the classification
57// of a non-ASCII first character is stable (never a component).
58fn is_component_tag_name(name: &str) -> bool {
59    matches!(name.chars().next(), Some(c) if c.is_ascii_uppercase())
60}
61
62fn ts_import_bindings(stmt: Node, src: &[u8]) -> Vec<TsBinding> {
63    let Some(clause) = named_children(stmt)
64        .into_iter()
65        .find(|c| c.kind() == "import_clause")
66    else {
67        return Vec::new();
68    };
69    let mut out = Vec::new();
70    for c in named_children(clause) {
71        match c.kind() {
72            "identifier" => out.push(TsBinding {
73                local: text(c, src),
74                imported: "default".to_string(),
75            }),
76            "namespace_import" => {
77                if let Some(id) = named_children(c)
78                    .into_iter()
79                    .find(|n| n.kind() == "identifier")
80                {
81                    out.push(TsBinding {
82                        local: text(id, src),
83                        imported: "*".to_string(),
84                    });
85                }
86            }
87            "named_imports" => {
88                for spec in named_children(c) {
89                    if spec.kind() != "import_specifier" {
90                        continue;
91                    }
92                    let Some(name) = spec.child_by_field_name("name") else {
93                        continue;
94                    };
95                    let alias = spec.child_by_field_name("alias");
96                    out.push(TsBinding {
97                        local: text(alias.unwrap_or(name), src),
98                        imported: text(name, src),
99                    });
100                }
101            }
102            _ => {}
103        }
104    }
105    out
106}
107
108// A bare `export * from 'm'` carries no names and is marked `star`. `export *
109// as NS from 'm'` binds a namespace OBJECT under NS -- resolving `NS.member`
110// through it would be a second hop past the one level the resolver follows,
111// so it contributes its import edge and no name mapping (neither `star` nor
112// any `names` entry).
113fn ts_reexport_entry(stmt: Node, src: &[u8]) -> Option<TsReexport> {
114    let spec = ts_string_literal(stmt.child_by_field_name("source"), src)?;
115    let line = ts_line(stmt);
116    if named_children(stmt)
117        .iter()
118        .any(|c| c.kind() == "namespace_export")
119    {
120        return Some(TsReexport {
121            spec,
122            line,
123            star: false,
124            names: Vec::new(),
125        });
126    }
127    let Some(clause) = named_children(stmt)
128        .into_iter()
129        .find(|c| c.kind() == "export_clause")
130    else {
131        return Some(TsReexport {
132            spec,
133            line,
134            star: true,
135            names: Vec::new(),
136        });
137    };
138    let mut names = Vec::new();
139    for s in named_children(clause) {
140        if s.kind() != "export_specifier" {
141            continue;
142        }
143        let Some(name) = s.child_by_field_name("name") else {
144            continue;
145        };
146        let alias = s.child_by_field_name("alias");
147        names.push(TsReexportName {
148            exported: text(alias.unwrap_or(name), src),
149            imported: text(name, src),
150        });
151    }
152    Some(TsReexport {
153        spec,
154        line,
155        star: false,
156        names,
157    })
158}
159
160// The kind vocabulary is `TS_BUCKET_ORDER` so a TS def row reads
161// the same way a TS purpose segment does. `default` is not among them: a
162// default export keeps the kind of what it declares, and the file records the
163// local name it exported by default separately.
164fn ts_decl_kind(node: Node) -> Option<&'static str> {
165    match node.kind() {
166        "class_declaration" | "abstract_class_declaration" => Some("class"),
167        "function_declaration" | "generator_function_declaration" => Some("function"),
168        "interface_declaration" => Some("interface"),
169        "type_alias_declaration" => Some("type"),
170        "enum_declaration" => Some("enum"),
171        "lexical_declaration" | "variable_declaration" => Some("const"),
172        _ => None,
173    }
174}
175
176#[derive(Debug, Clone)]
177struct TsDecl {
178    kind: &'static str,
179    line: usize,
180    end_line: usize,
181}
182
183// Every top-level declaration with the line its NAME is declared on, whether
184// exported or not: `export { A }` names a local declaration that was written
185// without the keyword, and a CommonJS file exports by name only.
186fn ts_top_level_decls(program_node: Node, src: &[u8]) -> HashMap<String, TsDecl> {
187    let mut decls: HashMap<String, TsDecl> = HashMap::new();
188    fn add(
189        decls: &mut HashMap<String, TsDecl>,
190        name: String,
191        kind: &'static str,
192        line: usize,
193        end_line: usize,
194    ) {
195        if name.is_empty() {
196            return;
197        }
198        decls.entry(name).or_insert(TsDecl {
199            kind,
200            line,
201            end_line,
202        });
203    }
204    fn from_declaration(node: Node, src: &[u8], decls: &mut HashMap<String, TsDecl>) {
205        let Some(kind) = ts_decl_kind(node) else {
206            return;
207        };
208        if kind == "const" {
209            for d in named_children(node) {
210                if d.kind() != "variable_declarator" {
211                    continue;
212                }
213                let name_node = d.child_by_field_name("name");
214                let line = ts_line(name_node.unwrap_or(d));
215                for n in ts_pattern_names(name_node, src) {
216                    add(decls, n, "const", line, d.end_position().row + 1);
217                }
218            }
219            return;
220        }
221        add(
222            decls,
223            ts_declared_name(node, src),
224            kind,
225            ts_line(node),
226            node.end_position().row + 1,
227        );
228    }
229    for c in named_children(program_node) {
230        if c.kind() == "export_statement" {
231            if let Some(decl) = c.child_by_field_name("declaration") {
232                from_declaration(decl, src, &mut decls);
233            }
234            continue;
235        }
236        from_declaration(c, src, &mut decls);
237    }
238    decls
239}
240
241fn ts_default_export_name(program_node: Node, src: &[u8]) -> Option<String> {
242    for stmt in named_children(program_node) {
243        if stmt.kind() != "export_statement" || !is_default_export_statement(stmt) {
244            continue;
245        }
246        if let Some(decl) = stmt.child_by_field_name("declaration") {
247            let name = ts_declared_name(decl, src);
248            if !name.is_empty() {
249                return Some(name);
250            }
251            continue;
252        }
253        if let Some(value) = stmt.child_by_field_name("value") {
254            if value.kind() == "identifier" {
255                return Some(text(value, src));
256            }
257        }
258    }
259    None
260}
261
262// The names this file makes importable, each mapped to the top-level
263// declaration it names. A name with no matching declaration in this file
264// contributes nothing: there is no line to point a caller at, and inventing
265// one is a guess.
266#[allow(
267    clippy::cognitive_complexity,
268    reason = "one flat dispatch over every export shape a top-level declaration can take; the shapes only make sense enumerated together"
269)]
270fn ts_exported_names(
271    program_node: Node,
272    decls: &HashMap<String, TsDecl>,
273    src: &[u8],
274) -> Vec<String> {
275    let mut names: Vec<String> = Vec::new();
276    let mut seen: HashSet<String> = HashSet::new();
277    macro_rules! add {
278        ($name:expr) => {{
279            let name: String = $name;
280            if !name.is_empty() && decls.contains_key(&name) && seen.insert(name.clone()) {
281                names.push(name);
282            }
283        }};
284    }
285    let export_stmts: Vec<Node> = named_children(program_node)
286        .into_iter()
287        .filter(|c| c.kind() == "export_statement")
288        .collect();
289    if !export_stmts.is_empty() {
290        for stmt in export_stmts {
291            if stmt.child_by_field_name("source").is_some() {
292                continue; // re-export, handled separately
293            }
294            if let Some(decl) = stmt.child_by_field_name("declaration") {
295                if ts_decl_kind(decl) == Some("const") {
296                    for d in named_children(decl) {
297                        if d.kind() != "variable_declarator" {
298                            continue;
299                        }
300                        for n in ts_pattern_names(d.child_by_field_name("name"), src) {
301                            add!(n);
302                        }
303                    }
304                } else {
305                    add!(ts_declared_name(decl, src));
306                }
307                continue;
308            }
309            if let Some(clause) = named_children(stmt)
310                .into_iter()
311                .find(|c| c.kind() == "export_clause")
312            {
313                for s in named_children(clause) {
314                    if s.kind() != "export_specifier" {
315                        continue;
316                    }
317                    // A local `export { A as B }` publishes B, but the
318                    // DECLARATION it points at is A -- only a name this file
319                    // actually declares earns a def, so the alias is recorded
320                    // against A's own line.
321                    if let Some(name) = s.child_by_field_name("name") {
322                        add!(text(name, src));
323                    }
324                }
325            }
326        }
327        return names;
328    }
329    // CommonJS -- no `export` keyword anywhere in the file.
330    for stmt in named_children(program_node) {
331        if stmt.kind() != "expression_statement" {
332            continue;
333        }
334        let Some(assign) = named_children(stmt)
335            .into_iter()
336            .find(|n| n.kind() == "assignment_expression")
337        else {
338            continue;
339        };
340        let Some(target) = common_js_export_target(assign.child_by_field_name("left"), src) else {
341            continue;
342        };
343        let right = assign.child_by_field_name("right");
344        match target {
345            CjsTarget::Prop(prop) => {
346                add!(prop);
347            }
348            CjsTarget::Whole => match right {
349                Some(r) if r.kind() == "object" => {
350                    for c in named_children(r) {
351                        if c.kind() == "shorthand_property_identifier" {
352                            add!(text(c, src));
353                        } else if c.kind() == "pair" {
354                            if let Some(value) = c.child_by_field_name("value") {
355                                if value.kind() == "identifier" {
356                                    add!(text(value, src));
357                                }
358                            }
359                        }
360                    }
361                }
362                Some(r) if r.kind() == "identifier" => {
363                    add!(text(r, src));
364                }
365                _ => {}
366            },
367        }
368    }
369    names
370}
371
372// `const x = require('m')` / `const { a, b } = require('m')` -- the CommonJS
373// counterpart of an import clause, recorded in the same shape. A whole-module
374// binding is `"*"` (the same marker a `* as ns` import uses) because that is
375// what `require` returns; a destructured one names each property directly.
376fn ts_require_import(decl_node: Node, src: &[u8]) -> Vec<TsImport> {
377    let mut out = Vec::new();
378    for d in named_children(decl_node) {
379        if d.kind() != "variable_declarator" {
380            continue;
381        }
382        let Some(value) = d.child_by_field_name("value") else {
383            continue;
384        };
385        if value.kind() != "call_expression" {
386            continue;
387        }
388        let Some(fnode) = value.child_by_field_name("function") else {
389            continue;
390        };
391        if fnode.kind() != "identifier" || text(fnode, src) != "require" {
392            continue;
393        }
394        let args = value.child_by_field_name("arguments");
395        let first = args.and_then(|a| named_children(a).into_iter().next());
396        let Some(spec) = ts_string_literal(first, src) else {
397            continue;
398        };
399        let name_node = d.child_by_field_name("name");
400        let bindings = match name_node {
401            Some(n) if n.kind() == "identifier" => {
402                vec![TsBinding {
403                    local: text(n, src),
404                    imported: "*".to_string(),
405                }]
406            }
407            _ => ts_pattern_names(name_node, src)
408                .into_iter()
409                .map(|n| TsBinding {
410                    local: n.clone(),
411                    imported: n,
412                })
413                .collect(),
414        };
415        out.push(TsImport {
416            spec,
417            line: ts_line(d),
418            bindings,
419        });
420    }
421    out
422}
423
424#[allow(
425    clippy::too_many_lines,
426    clippy::cognitive_complexity,
427    reason = "one flat dispatch over every reference shape a TS node can carry; splitting it would separate branches that share the same consumed-set bookkeeping"
428)]
429fn ts_record_ref(
430    node: Node,
431    src: &[u8],
432    refs: &mut Vec<TsRef>,
433    consumed: &mut HashSet<usize>,
434    known: &HashSet<String>,
435) {
436    match node.kind() {
437        "call_expression" => {
438            let fnode = node.child_by_field_name("function");
439            let callee = match fnode {
440                Some(f) if f.kind() == "identifier" => Some(text(f, src)),
441                Some(f) if f.kind() == "member_expression" => {
442                    f.child_by_field_name("property").map(|p| text(p, src))
443                }
444                _ => None,
445            };
446            let dispatching = callee
447                .as_deref()
448                .is_some_and(|c| TS_DISPATCH_CALLEES.contains(&c));
449            if dispatching {
450                // `dispatch(loadThings())` names loadThings, not the inner
451                // call's own callee: the argument IS the action creator or
452                // thunk, and marking it consumed is what stops the walk from
453                // ALSO recording it as a plain call. `dispatch(clearCart)` (an
454                // already-built action object) names it directly.
455                let args = node
456                    .child_by_field_name("arguments")
457                    .map(named_children)
458                    .unwrap_or_default();
459                for arg in args {
460                    if arg.kind() == "identifier" {
461                        let name = text(arg, src);
462                        if known.contains(&name) {
463                            refs.push(TsRef {
464                                kind: "dispatch".to_string(),
465                                name,
466                                member: None,
467                                line: ts_line(arg),
468                            });
469                        }
470                        consumed.insert(arg.id());
471                    } else if arg.kind() == "call_expression" {
472                        if let Some(inner) = arg.child_by_field_name("function") {
473                            if inner.kind() == "identifier" {
474                                let name = text(inner, src);
475                                if known.contains(&name) {
476                                    refs.push(TsRef {
477                                        kind: "dispatch".to_string(),
478                                        name,
479                                        member: None,
480                                        line: ts_line(inner),
481                                    });
482                                }
483                                consumed.insert(arg.id());
484                            }
485                        }
486                    }
487                }
488            } else if !consumed.contains(&node.id()) {
489                match fnode {
490                    Some(f) if f.kind() == "identifier" => {
491                        let name = text(f, src);
492                        if known.contains(&name) {
493                            refs.push(TsRef {
494                                kind: "call".to_string(),
495                                name,
496                                member: None,
497                                line: ts_line(f),
498                            });
499                        }
500                    }
501                    Some(f) if f.kind() == "member_expression" => {
502                        let obj = f.child_by_field_name("object");
503                        let prop = f.child_by_field_name("property");
504                        // The QUALIFIER only, never the chain's tail method --
505                        // the same line the C# side draws: `uses-member`
506                        // resolves to a type, never to a chained call's
507                        // method def.
508                        if let (Some(obj), Some(prop)) = (obj, prop) {
509                            let name = text(obj, src);
510                            if obj.kind() == "identifier" && known.contains(&name) {
511                                refs.push(TsRef {
512                                    kind: "call".to_string(),
513                                    name,
514                                    member: Some(text(prop, src)),
515                                    line: ts_line(obj),
516                                });
517                            }
518                        }
519                    }
520                    _ => {}
521                }
522            }
523        }
524        "new_expression" => {
525            if let Some(ctor) = node.child_by_field_name("constructor") {
526                let name = text(ctor, src);
527                if ctor.kind() == "identifier" && known.contains(&name) {
528                    refs.push(TsRef {
529                        kind: "call".to_string(),
530                        name,
531                        member: None,
532                        line: ts_line(ctor),
533                    });
534                }
535            }
536        }
537        "jsx_opening_element" | "jsx_self_closing_element" => {
538            let name_node = node.child_by_field_name("name");
539            match name_node {
540                Some(n) if n.kind() == "identifier" => {
541                    let name = text(n, src);
542                    if is_component_tag_name(&name) && known.contains(&name) {
543                        refs.push(TsRef {
544                            kind: "jsx-use".to_string(),
545                            name,
546                            member: None,
547                            line: ts_line(n),
548                        });
549                    }
550                }
551                Some(n) if n.kind() == "member_expression" || n.kind() == "nested_identifier" => {
552                    let obj = n.child_by_field_name("object");
553                    let prop = n.child_by_field_name("property");
554                    if let (Some(obj), Some(prop)) = (obj, prop) {
555                        let name = text(obj, src);
556                        if obj.kind() == "identifier"
557                            && is_component_tag_name(&name)
558                            && known.contains(&name)
559                        {
560                            refs.push(TsRef {
561                                kind: "jsx-use".to_string(),
562                                name,
563                                member: Some(text(prop, src)),
564                                line: ts_line(obj),
565                            });
566                        }
567                    }
568                }
569                _ => {}
570            }
571        }
572        _ => {}
573    }
574}
575
576// Pre-order, the same order the recursive form visits in: a dispatching call
577// is always seen before the argument it consumes, which is what lets the
578// consumed set suppress a duplicate plain-call ref for that argument.
579// Cursor-driven, materialising a `Node` only for the four reference kinds.
580fn ts_ref_walk(program_node: Node, src: &[u8], refs: &mut Vec<TsRef>, known: &HashSet<String>) {
581    let mut consumed: HashSet<usize> = HashSet::new();
582    let mut cursor = program_node.walk();
583    loop {
584        if TS_REFERENCE_NODES.contains(&cursor.node().kind()) {
585            ts_record_ref(cursor.node(), src, refs, &mut consumed, known);
586        }
587        if cursor.goto_first_child() {
588            continue;
589        }
590        let mut advanced = false;
591        while cursor.depth() > 0 {
592            if cursor.goto_next_sibling() {
593                advanced = true;
594                break;
595            }
596            if !cursor.goto_parent() {
597                break;
598            }
599        }
600        if !advanced && !cursor.goto_next_sibling() {
601            break;
602        }
603    }
604}
605
606/// Extract the reference fragment (imports, exports, refs) from a parsed TS/JS
607/// program node.
608pub fn extract_ts_fragment(program_node: Node, src: &[u8]) -> TsFragment {
609    let decls = ts_top_level_decls(program_node, src);
610    let mut imports: Vec<TsImport> = Vec::new();
611    let mut reexports: Vec<TsReexport> = Vec::new();
612    for stmt in named_children(program_node) {
613        if stmt.kind() == "import_statement" {
614            let Some(spec) = ts_string_literal(stmt.child_by_field_name("source"), src) else {
615                continue;
616            };
617            imports.push(TsImport {
618                spec,
619                line: ts_line(stmt),
620                bindings: ts_import_bindings(stmt, src),
621            });
622        } else if stmt.kind() == "export_statement" && stmt.child_by_field_name("source").is_some()
623        {
624            if let Some(entry) = ts_reexport_entry(stmt, src) {
625                reexports.push(entry);
626            }
627        } else if stmt.kind() == "lexical_declaration" || stmt.kind() == "variable_declaration" {
628            imports.extend(ts_require_import(stmt, src));
629        }
630    }
631    let defs: Vec<TsFragmentDef> = ts_exported_names(program_node, &decls, src)
632        .into_iter()
633        .map(|name| {
634            let d = &decls[&name];
635            TsFragmentDef {
636                name,
637                kind: d.kind.to_string(),
638                line: d.line,
639                end_line: d.end_line,
640            }
641        })
642        .collect();
643    // The only local names a cross-file resolver could ever land on: something
644    // this file imported, or something this file itself exports. A reference
645    // to anything else is unresolvable BY THE RESOLVER'S OWN RULE, so
646    // recording it would put a fact in the fragment cache that exists only to
647    // be discarded -- and it changes no edge the resolver would have emitted.
648    let mut known: HashSet<String> = defs.iter().map(|d| d.name.clone()).collect();
649    for imp in &imports {
650        for b in &imp.bindings {
651            known.insert(b.local.clone());
652        }
653    }
654    let mut refs: Vec<TsRef> = Vec::new();
655    ts_ref_walk(program_node, src, &mut refs, &known);
656    // Appended LAST and only when the file has one, the house rule for every
657    // added fact -- a file with no default export keeps the shorter shape.
658    let default = ts_default_export_name(program_node, src).filter(|d| decls.contains_key(d));
659    TsFragment {
660        ts: 1,
661        defs,
662        imports,
663        reexports,
664        refs,
665        default,
666    }
667}
668
669/// One TS/JS file's whole contribution, off ONE parse: its purpose (when the
670/// file exports anything) and its reference fragment (always), from the same
671/// tree. A parse failure yields `None`, leaving the file out of BOTH outputs.
672pub struct TsFileExtraction {
673    /// The purpose value.
674    pub purpose: Option<String>,
675    /// The fragment value.
676    pub fragment: TsFragment,
677}
678
679/// Parses TypeScript-family source into its optional purpose and graph fragment.
680///
681/// Returns `None` when the selected grammar cannot parse the source.
682pub fn extract_ts_file(
683    root: &Path,
684    rel: &str,
685    source: &str,
686    grammar: crate::parse::TsGrammar,
687) -> Option<TsFileExtraction> {
688    let units = crate::parse::utf16_units(source);
689    let tree = crate::parse::parse_ts_js(&units, grammar)?;
690    let src = crate::parse::utf16_bytes(&units);
691    let root_node = tree.root_node();
692    let purpose = ts_purpose_segments(root_node, &src).map(|raw| {
693        let detail = crate::walk::default_purpose_detailed(root, rel);
694        if detail.is_comment {
695            compose_hybrid_ts_purpose(&detail.text, &raw)
696        } else {
697            truncate(&raw)
698        }
699    });
700    Some(TsFileExtraction {
701        purpose,
702        fragment: extract_ts_fragment(root_node, &src),
703    })
704}