Skip to main content

weavatrix_parse/
script.rs

1//! Structural extraction for JavaScript and TypeScript.
2//!
3//! The pass walks the token stream once, tracking brace depth to know which
4//! declaration owns what. It reads the forms a repository graph is built from:
5//! every import and re-export shape, declarations including class members,
6//! and call sites with their receiver and string arguments.
7//!
8//! What it does not do is parse expressions. A call is recognised by an
9//! identifier followed by `(`, not by building an expression tree, because no
10//! consumer of these facts asks about precedence.
11
12use crate::facts::{
13    Declaration, DeclarationKind, Facts, Import, ImportBinding, Reference, ReferenceKind, Span,
14};
15use crate::syntax::Language;
16use crate::token::{Mode, Token, TokenKind, Tokenizer};
17use std::collections::BTreeMap;
18
19/// Extracts structural facts from one JavaScript or TypeScript source.
20#[must_use]
21pub fn extract(source: &str, language: Language) -> Facts {
22    let tokens = Tokenizer::new(source, language)
23        .mode(Mode::Lite)
24        .collect::<Vec<_>>();
25    Extractor {
26        source,
27        tokens: &tokens,
28        language,
29        facts: Facts::default(),
30        scopes: Vec::new(),
31        import_bindings: BTreeMap::new(),
32        depth: 0,
33        paren_depth: 0,
34        bracket_depth: 0,
35    }
36    .run()
37}
38
39/// A declaration whose body the walk is currently inside.
40struct Scope {
41    name: String,
42    /// Depth of the body, once it opens. A declaration is recorded before its
43    /// `{` is seen, so until then the scope is waiting and must not be closed
44    /// by the very brace that opens it.
45    depth: Option<i32>,
46    /// Whether members declared directly inside are class or object members.
47    member_body: bool,
48    /// Classes declare fields; object literals only contribute named methods.
49    fields: bool,
50    /// Parenthesis/bracket nesting at the member body's opening brace.
51    paren_depth: i32,
52    bracket_depth: i32,
53}
54
55struct Extractor<'source, 'tokens> {
56    source: &'source str,
57    tokens: &'tokens [Token],
58    language: Language,
59    facts: Facts,
60    scopes: Vec<Scope>,
61    import_bindings: BTreeMap<String, (String, bool, String)>,
62    depth: i32,
63    paren_depth: i32,
64    bracket_depth: i32,
65}
66
67impl Extractor<'_, '_> {
68    fn run(mut self) -> Facts {
69        let mut index = 0;
70        while index < self.tokens.len() {
71            self.close_scopes();
72            index = self.step(index);
73        }
74        self.facts
75    }
76
77    fn text(&self, index: usize) -> &str {
78        self.tokens
79            .get(index)
80            .map_or("", |token| token.text(self.source))
81    }
82
83    fn kind(&self, index: usize) -> Option<TokenKind> {
84        self.tokens.get(index).map(|token| token.kind)
85    }
86
87    fn is(&self, index: usize, word: &str) -> bool {
88        self.kind(index) == Some(TokenKind::Identifier) && self.text(index) == word
89    }
90
91    fn punct(&self, index: usize, mark: &str) -> bool {
92        self.kind(index) == Some(TokenKind::Punctuation) && self.text(index) == mark
93    }
94
95    fn span(&self, start: usize, end: usize) -> Span {
96        let first = &self.tokens[start.min(self.tokens.len() - 1)];
97        let last = &self.tokens[end.min(self.tokens.len() - 1)];
98        Span {
99            start: first.start,
100            end: last.end,
101            line: first.line,
102            column: first.column,
103            end_line: last.line,
104            end_column: last.column,
105        }
106    }
107
108    fn owner(&self) -> Option<String> {
109        self.scopes.last().map(|scope| scope.name.clone())
110    }
111
112    fn close_scopes(&mut self) {
113        while self
114            .scopes
115            .last()
116            .is_some_and(|scope| scope.depth.is_some_and(|depth| self.depth < depth))
117        {
118            self.scopes.pop();
119        }
120    }
121
122    /// Binds the innermost waiting scope to the body that just opened.
123    fn open_body(&mut self) {
124        let depth = self.depth;
125        if let Some(scope) = self.scopes.last_mut()
126            && scope.depth.is_none()
127            && scope.paren_depth == self.paren_depth
128            && scope.bracket_depth == self.bracket_depth
129        {
130            scope.depth = Some(depth);
131            scope.paren_depth = self.paren_depth;
132            scope.bracket_depth = self.bracket_depth;
133        }
134    }
135
136    /// Consumes one construct starting at `index`, returning the next index.
137    fn step(&mut self, index: usize) -> usize {
138        if self.punct(index, "{") {
139            let object_owner = self.object_literal_owner(index);
140            let waiting_scope = self
141                .scopes
142                .last()
143                .is_some_and(|scope| scope.depth.is_none());
144            self.depth += 1;
145            self.open_body();
146            if !waiting_scope && let Some(name) = object_owner {
147                self.scopes.push(Scope {
148                    name,
149                    depth: Some(self.depth),
150                    member_body: true,
151                    fields: false,
152                    paren_depth: self.paren_depth,
153                    bracket_depth: self.bracket_depth,
154                });
155            }
156            return index + 1;
157        }
158        if self.punct(index, "}") {
159            self.depth -= 1;
160            return index + 1;
161        }
162        if self.punct(index, "(") {
163            self.paren_depth += 1;
164            return index + 1;
165        }
166        if self.punct(index, ")") {
167            self.paren_depth -= 1;
168            return index + 1;
169        }
170        if self.punct(index, "[") {
171            self.bracket_depth += 1;
172            return index + 1;
173        }
174        if self.punct(index, "]") {
175            self.bracket_depth -= 1;
176            return index + 1;
177        }
178        if (self.is(index, "import") || self.is(index, "export"))
179            && let Some(next) = self.module_statement(index)
180        {
181            return next;
182        }
183        if self.kind(index) == Some(TokenKind::String) {
184            self.template_references(index);
185            if let Some(next) = self.route_table(index) {
186                return next;
187            }
188        }
189        if self.kind(index) == Some(TokenKind::Identifier) {
190            if let Some(next) = self.declaration(index) {
191                return next;
192            }
193            if let Some(next) = self.call(index) {
194                return next;
195            }
196        }
197        index + 1
198    }
199
200    /// Calls inside `${...}` are program expressions even though the lossless
201    /// tokenizer deliberately keeps the complete template as one string
202    /// token. Extract each balanced expression separately and relocate its
203    /// references to the original file. Literal template text is never parsed
204    /// as code.
205    fn template_references(&mut self, index: usize) {
206        let token = &self.tokens[index];
207        let template = token.text(self.source);
208        if !template.starts_with('`') {
209            return;
210        }
211        let owner = self.owner();
212        for (start, end) in template_interpolation_ranges(template, self.language) {
213            let Some(expression) = template.get(start..end) else {
214                continue;
215            };
216            let base = token.start + start;
217            let (base_line, base_column) = position_at(self.source, base);
218            let mut references = extract(expression, self.language).references;
219            for reference in &mut references {
220                reference.span.start += base;
221                reference.span.end += base;
222                reference.span.line = base_line.saturating_add(reference.span.line - 1);
223                if reference.span.line == base_line {
224                    reference.span.column = base_column.saturating_add(reference.span.column - 1);
225                }
226                reference.span.end_line = base_line.saturating_add(reference.span.end_line - 1);
227                if reference.span.end_line == base_line {
228                    reference.span.end_column =
229                        base_column.saturating_add(reference.span.end_column - 1);
230                }
231                reference.owner.clone_from(&owner);
232            }
233            self.facts.references.extend(references);
234        }
235    }
236
237    /// `import ... from 'x'`, `import 'x'`, `import('x')`, `require('x')`,
238    /// `export ... from 'x'`. Multi-line forms work because the walk is over
239    /// tokens rather than lines.
240    fn module_statement(&mut self, index: usize) -> Option<usize> {
241        let exporting = self.is(index, "export");
242        let mut cursor = index + 1;
243        let mut type_only = self.is(cursor, "type");
244        if type_only {
245            cursor += 1;
246        }
247        // `export function|class|const ...` is a declaration, not a module
248        // statement; let the declaration path handle it.
249        if exporting && !self.leads_to_from(cursor) {
250            return self.local_reexport(index, cursor);
251        }
252        if !exporting && self.punct(cursor, "(") {
253            // Dynamic import: `import('x')`.
254            let specifier = self.string_at(cursor + 1)?;
255            self.facts.imports.push(Import {
256                specifier,
257                span: self.span(index, cursor + 1),
258                type_only: false,
259                reexport: false,
260                names: Vec::new(),
261                bindings: Vec::new(),
262            });
263            return Some(cursor + 2);
264        }
265        let mut scan = cursor;
266        let limit = (index + 512).min(self.tokens.len());
267        while scan < limit {
268            if self.kind(scan) == Some(TokenKind::String) {
269                let specifier = self.string_at(scan)?;
270                // `{ type X }` marks a single specifier as type-only too.
271                type_only = type_only || self.braced_types_only(cursor, scan);
272                let bindings = self.clause_bindings(cursor, scan);
273                let names = bindings
274                    .iter()
275                    .map(|binding| binding.local.clone())
276                    .collect::<Vec<_>>();
277                if !exporting {
278                    for binding in &bindings {
279                        self.import_bindings.insert(
280                            binding.local.clone(),
281                            (specifier.clone(), type_only, binding.imported.clone()),
282                        );
283                    }
284                }
285                self.facts.imports.push(Import {
286                    specifier,
287                    span: self.span(index, scan),
288                    type_only,
289                    reexport: exporting,
290                    names,
291                    bindings,
292                });
293                return Some(scan + 1);
294            }
295            // Between the keyword and the specifier, an import clause holds
296            // only names and the punctuation that groups them. Anything else
297            // means this was never an import statement, and the scan must stop
298            // rather than run on into the code beneath it.
299            //
300            // The previous rule broke at any `{` that was not the second
301            // token, which silently lost every `import Default, { named }
302            // from 'x'` - the one shape that has a name before the brace.
303            if self.punct(scan, ";") {
304                break;
305            }
306            if self.kind(scan) == Some(TokenKind::Punctuation)
307                && !matches!(self.text(scan), "{" | "}" | "," | "*")
308            {
309                break;
310            }
311            scan += 1;
312        }
313        None
314    }
315
316    /// `export { importedName }` forwards the module that originally bound the
317    /// local name even though this statement has no `from` clause of its own.
318    fn local_reexport(&mut self, start: usize, open: usize) -> Option<usize> {
319        if !self.punct(open, "{") {
320            return None;
321        }
322        let limit = (open + 512).min(self.tokens.len());
323        let mut cursor = open + 1;
324        let mut by_target = BTreeMap::<(String, bool), Vec<ImportBinding>>::new();
325        while cursor < limit && !self.punct(cursor, "}") {
326            if self.kind(cursor) == Some(TokenKind::Identifier)
327                && !matches!(self.text(cursor), "as" | "type")
328                && !self.is(cursor.wrapping_sub(1), "as")
329                && let Some((target, type_only, imported)) =
330                    self.import_bindings.get(self.text(cursor))
331            {
332                let local = if self.is(cursor + 1, "as")
333                    && self.kind(cursor + 2) == Some(TokenKind::Identifier)
334                {
335                    self.text(cursor + 2).to_owned()
336                } else {
337                    self.text(cursor).to_owned()
338                };
339                by_target
340                    .entry((target.clone(), *type_only))
341                    .or_default()
342                    .push(ImportBinding {
343                        imported: imported.clone(),
344                        local,
345                    });
346            }
347            cursor += 1;
348        }
349        if !self.punct(cursor, "}") {
350            return None;
351        }
352        for ((specifier, type_only), bindings) in by_target {
353            let names = bindings
354                .iter()
355                .map(|binding| binding.local.clone())
356                .collect();
357            self.facts.imports.push(Import {
358                specifier,
359                span: self.span(start, cursor),
360                type_only,
361                reexport: true,
362                names,
363                bindings,
364            });
365        }
366        Some(cursor + 1)
367    }
368
369    /// A route table written as an object: `{ '/items': { POST: handler } }`.
370    ///
371    /// The path is a key rather than an argument, so no call site mentions it
372    /// and the ordinary call path cannot see it - but it registers a route
373    /// exactly as `router.post('/items', handler)` does.
374    fn route_table(&mut self, index: usize) -> Option<usize> {
375        let path = self.string_at(index)?;
376        if !path.starts_with('/') || !self.punct(index + 1, ":") || !self.punct(index + 2, "{") {
377            return None;
378        }
379        let limit = (index + 128).min(self.tokens.len());
380        let mut cursor = index + 3;
381        let mut depth = 1_i32;
382        while cursor < limit && depth > 0 {
383            if self.punct(cursor, "{") {
384                depth += 1;
385            } else if self.punct(cursor, "}") {
386                depth -= 1;
387            } else if depth == 1
388                && self.kind(cursor) == Some(TokenKind::Identifier)
389                && self.punct(cursor + 1, ":")
390                && is_method(self.text(cursor))
391            {
392                self.facts.references.push(Reference {
393                    name: self.text(cursor).to_owned(),
394                    kind: ReferenceKind::Call,
395                    receiver: None,
396                    span: self.span(index, cursor),
397                    owner: self.owner(),
398                    string_arguments: vec![path.clone()],
399                    name_arguments: Vec::new(),
400                });
401            }
402            cursor += 1;
403        }
404        Some(cursor)
405    }
406
407    /// The exported and local names an import clause binds.
408    fn clause_bindings(&self, start: usize, end: usize) -> Vec<ImportBinding> {
409        let mut bindings = Vec::new();
410        let mut scan = start;
411        let mut braced = false;
412        let mut default_seen = false;
413        while scan < end {
414            if self.punct(scan, "{") {
415                braced = true;
416                scan += 1;
417                continue;
418            }
419            if self.punct(scan, "}") {
420                braced = false;
421                scan += 1;
422                continue;
423            }
424            if self.punct(scan, "*")
425                && self.is(scan + 1, "as")
426                && self.kind(scan + 2) == Some(TokenKind::Identifier)
427            {
428                bindings.push(ImportBinding {
429                    imported: "*".to_owned(),
430                    local: self.text(scan + 2).to_owned(),
431                });
432                scan += 3;
433                continue;
434            }
435            if self.kind(scan) == Some(TokenKind::Identifier) {
436                let text = self.text(scan);
437                if matches!(text, "from" | "type" | "as") || self.is(scan.wrapping_sub(1), "as") {
438                    scan += 1;
439                    continue;
440                }
441                if self.is(scan + 1, "as") && self.kind(scan + 2) == Some(TokenKind::Identifier) {
442                    bindings.push(ImportBinding {
443                        imported: text.to_owned(),
444                        local: self.text(scan + 2).to_owned(),
445                    });
446                    scan += 3;
447                    continue;
448                }
449                if braced {
450                    bindings.push(ImportBinding {
451                        imported: text.to_owned(),
452                        local: text.to_owned(),
453                    });
454                } else if !default_seen {
455                    bindings.push(ImportBinding {
456                        imported: "default".to_owned(),
457                        local: text.to_owned(),
458                    });
459                    default_seen = true;
460                }
461            }
462            scan += 1;
463        }
464        bindings
465    }
466
467    /// Whether a `export ...` statement reaches a `from` clause before its end.
468    fn leads_to_from(&self, index: usize) -> bool {
469        let limit = (index + 512).min(self.tokens.len());
470        let mut scan = index;
471        while scan < limit {
472            if self.is(scan, "from") {
473                return true;
474            }
475            if self.punct(scan, ";") || self.punct(scan, "=") {
476                return false;
477            }
478            scan += 1;
479        }
480        false
481    }
482
483    /// Whether every named specifier between `start` and `end` is type-only.
484    fn braced_types_only(&self, start: usize, end: usize) -> bool {
485        let mut scan = start;
486        let mut inside = false;
487        let mut names = 0_usize;
488        let mut typed = 0_usize;
489        while scan < end {
490            if self.punct(scan, "{") {
491                inside = true;
492            } else if self.punct(scan, "}") {
493                inside = false;
494            } else if inside && self.kind(scan) == Some(TokenKind::Identifier) {
495                if self.text(scan) == "type" {
496                    typed += 1;
497                } else if !self.is(scan, "as") {
498                    names += 1;
499                }
500            }
501            scan += 1;
502        }
503        names > 0 && typed >= names
504    }
505
506    fn string_at(&self, index: usize) -> Option<String> {
507        if self.kind(index) != Some(TokenKind::String) {
508            return None;
509        }
510        let raw = self.text(index);
511        let trimmed = raw
512            .strip_prefix(['"', '\'', '`'])
513            .and_then(|value| value.strip_suffix(['"', '\'', '`']))
514            .unwrap_or(raw);
515        Some(trimmed.to_owned())
516    }
517
518    /// Declarations in every form the language writes them.
519    fn declaration(&mut self, index: usize) -> Option<usize> {
520        let mut cursor = index;
521        let mut exported = false;
522        if self.is(cursor, "export") {
523            exported = true;
524            cursor += 1;
525            if self.is(cursor, "default") {
526                cursor += 1;
527            }
528        }
529        for keyword in [
530            "async", "declare", "abstract", "static", "public", "private",
531        ] {
532            if self.is(cursor, keyword) {
533                cursor += 1;
534            }
535        }
536        let kind = match self.text(cursor) {
537            "function" => DeclarationKind::Function,
538            "class" => DeclarationKind::Class,
539            "interface" => DeclarationKind::Interface,
540            "enum" => DeclarationKind::Enum,
541            "type" => DeclarationKind::TypeAlias,
542            "const" => DeclarationKind::Constant,
543            "let" | "var" => DeclarationKind::Variable,
544            // Not a keyword-introduced declaration: the name itself may still
545            // declare a class member, and `cursor` has already skipped the
546            // modifiers that preceded it.
547            _ => return self.class_member(cursor, exported),
548        };
549        if self.kind(cursor) != Some(TokenKind::Identifier) {
550            return None;
551        }
552        let name_index = if self.punct(cursor + 1, "*") {
553            cursor + 2
554        } else {
555            cursor + 1
556        };
557        if self.kind(name_index) != Some(TokenKind::Identifier) {
558            return None;
559        }
560        let name = self.text(name_index).to_owned();
561        // `const x = () => {}` declares a function, not a value.
562        let kind = if matches!(kind, DeclarationKind::Constant | DeclarationKind::Variable)
563            && self.is_arrow_function(name_index)
564        {
565            DeclarationKind::Function
566        } else {
567            kind
568        };
569        self.facts.declarations.push(Declaration {
570            name: name.clone(),
571            kind,
572            span: self.span(index, name_index),
573            owner: self.owner(),
574            exported,
575        });
576        if matches!(
577            kind,
578            DeclarationKind::Class | DeclarationKind::Interface | DeclarationKind::Enum
579        ) {
580            self.scopes.push(Scope {
581                name,
582                depth: None,
583                member_body: true,
584                fields: true,
585                paren_depth: self.paren_depth,
586                bracket_depth: self.bracket_depth,
587            });
588        } else if matches!(kind, DeclarationKind::Function) {
589            self.scopes.push(Scope {
590                name,
591                depth: None,
592                member_body: false,
593                fields: false,
594                paren_depth: self.paren_depth,
595                bracket_depth: self.bracket_depth,
596            });
597        }
598        Some(name_index + 1)
599    }
600
601    /// Whether the initializer is an arrow function. `=>` is two punctuation
602    /// tokens, so the pair is matched rather than the text.
603    fn is_arrow_function(&self, name_index: usize) -> bool {
604        let limit = (name_index + 64).min(self.tokens.len());
605        let mut scan = name_index + 1;
606        let mut nesting = 0_i32;
607        let mut assignment = false;
608        let mut value_started = false;
609        let declaration_line = self.tokens[name_index].line;
610        while scan < limit {
611            if nesting == 0 && self.punct(scan, "=") && self.punct(scan + 1, ">") {
612                return true;
613            }
614            if nesting == 0
615                && self.tokens[scan].line > declaration_line
616                && assignment
617                && value_started
618            {
619                return false;
620            }
621            if nesting == 0 && self.punct(scan, "=") {
622                assignment = true;
623                scan += 1;
624                continue;
625            }
626            if self.punct(scan, "(") || self.punct(scan, "[") {
627                nesting += 1;
628            } else if self.punct(scan, ")") || self.punct(scan, "]") {
629                nesting -= 1;
630            } else if self.punct(scan, ";") || self.punct(scan, "{") {
631                return false;
632            }
633            if assignment {
634                value_started = true;
635            }
636            scan += 1;
637        }
638        false
639    }
640
641    /// A method or field written directly inside a class body.
642    fn class_member(&mut self, index: usize, exported: bool) -> Option<usize> {
643        let inside_class = self.scopes.last().is_some_and(|scope| {
644            scope.member_body
645                && scope.depth.is_some_and(|depth| self.depth == depth)
646                && scope.paren_depth == self.paren_depth
647                && scope.bracket_depth == self.bracket_depth
648        });
649        if !inside_class || self.kind(index) != Some(TokenKind::Identifier) {
650            return None;
651        }
652        let previous = self.text(index.wrapping_sub(1));
653        let starts_member = matches!(
654            previous,
655            "{" | "}"
656                | ","
657                | ";"
658                | "public"
659                | "private"
660                | "protected"
661                | "static"
662                | "async"
663                | "get"
664                | "set"
665                | "readonly"
666                | "abstract"
667                | "declare"
668        );
669        if !starts_member {
670            return None;
671        }
672        let name = self.text(index).to_owned();
673        if matches!(name.as_str(), "return" | "if" | "for" | "while" | "switch") {
674            return None;
675        }
676        // A method is a name followed by a parameter list; anything else
677        // declared at class-body level is a field.
678        let kind = if self.punct(index + 1, "(") || self.punct(index + 1, "<") {
679            DeclarationKind::Method
680        } else if self.punct(index + 1, ":") || self.punct(index + 1, "=") {
681            if !self.scopes.last().is_some_and(|scope| scope.fields) {
682                return None;
683            }
684            DeclarationKind::Field
685        } else {
686            return None;
687        };
688        self.facts.declarations.push(Declaration {
689            name: name.clone(),
690            kind,
691            span: self.span(index, index),
692            owner: self.owner(),
693            exported,
694        });
695        if kind == DeclarationKind::Method {
696            self.scopes.push(Scope {
697                name,
698                depth: None,
699                member_body: false,
700                fields: false,
701                paren_depth: self.paren_depth,
702                bracket_depth: self.bracket_depth,
703            });
704            return Some(index + 1);
705        }
706        // A field initializer is still written at class-body depth, so
707        // stepping through it would read `new Map()` as another member.
708        Some(self.skip_initializer(index + 1))
709    }
710
711    fn object_literal_owner(&self, open: usize) -> Option<String> {
712        if self.is(open.wrapping_sub(1), "return") {
713            return self.owner();
714        }
715        if self.punct(open.wrapping_sub(1), "=")
716            && self.kind(open.wrapping_sub(2)) == Some(TokenKind::Identifier)
717        {
718            return Some(self.text(open - 2).to_owned());
719        }
720        if self.punct(open.wrapping_sub(1), ":")
721            && self.kind(open.wrapping_sub(2)) == Some(TokenKind::Identifier)
722        {
723            return Some(self.text(open - 2).to_owned());
724        }
725        // Object wrappers are commonly returned through `Object.freeze({...})`
726        // or another constructor-like call. Walk only the current expression;
727        // a preceding `return` keeps the methods owned by the enclosing
728        // factory, while an assignment gives the object its binding name.
729        if self.punct(open.wrapping_sub(1), "(") {
730            let boundary = open.saturating_sub(24);
731            let mut scan = open - 1;
732            while scan > boundary {
733                scan -= 1;
734                if self.is(scan, "return") {
735                    return self.owner();
736                }
737                if self.punct(scan, "=")
738                    && self.kind(scan.wrapping_sub(1)) == Some(TokenKind::Identifier)
739                {
740                    return Some(self.text(scan - 1).to_owned());
741                }
742                if self.punct(scan, ";") || self.punct(scan, "{") || self.punct(scan, "}") {
743                    break;
744                }
745            }
746        }
747        None
748    }
749
750    /// Advances past a field initializer, stopping at the statement end that
751    /// closes it. Nested braces and parens are stepped over as a unit.
752    fn skip_initializer(&self, start: usize) -> usize {
753        let mut scan = start;
754        let mut nesting = 0_i32;
755        let limit = (start + 512).min(self.tokens.len());
756        while scan < limit {
757            if self.punct(scan, "(") || self.punct(scan, "[") || self.punct(scan, "{") {
758                nesting += 1;
759            } else if self.punct(scan, ")") || self.punct(scan, "]") || self.punct(scan, "}") {
760                if nesting == 0 {
761                    return scan;
762                }
763                nesting -= 1;
764            } else if nesting == 0 && self.punct(scan, ";") {
765                return scan + 1;
766            }
767            scan += 1;
768        }
769        start
770    }
771
772    /// A call site, with the receiver it was written on.
773    fn call(&mut self, index: usize) -> Option<usize> {
774        let type_arguments = self.type_argument_span(index + 1);
775        let open = index + 1 + type_arguments;
776        if !self.punct(open, "(") {
777            return None;
778        }
779        let name = self.text(index).to_owned();
780        if matches!(
781            name.as_str(),
782            "if" | "for" | "while" | "switch" | "catch" | "function" | "return" | "typeof"
783        ) {
784            return None;
785        }
786        let receiver = (self.punct(index.wrapping_sub(1), ".")
787            && self.kind(index.wrapping_sub(2)) == Some(TokenKind::Identifier))
788        .then(|| self.text(index - 2).to_owned());
789        let mut arguments = Vec::new();
790        let mut names = Vec::new();
791        let mut scan = open + 1;
792        let mut depth = 1_i32;
793        let mut nested = 0_i32;
794        let limit = (index + 256).min(self.tokens.len());
795        while scan < limit && depth > 0 {
796            if self.punct(scan, "(") {
797                depth += 1;
798            } else if self.punct(scan, ")") {
799                depth -= 1;
800            } else if self.punct(scan, "{") || self.punct(scan, "[") {
801                nested += 1;
802            } else if self.punct(scan, "}") || self.punct(scan, "]") {
803                nested -= 1;
804            } else if depth == 1
805                && nested == 0
806                && self.kind(scan) == Some(TokenKind::String)
807                && let Some(value) = self.string_at(scan)
808            {
809                arguments.push(value);
810            } else if depth == 1 && nested == 0 && self.kind(scan) == Some(TokenKind::Identifier) {
811                // A bare name passed as an argument: the router in
812                // `app.use("/api", router)`, the handler in `app.get(path, h)`.
813                // A member access contributes only its root, because that is
814                // the binding an importer can resolve.
815                if !self.punct(scan.wrapping_sub(1), ".") {
816                    names.push(self.text(scan).to_owned());
817                }
818            }
819            scan += 1;
820        }
821        // `require('x')` is how CommonJS imports, so it is an import as well
822        // as a call; recording only the call would lose the dependency.
823        if name == "require"
824            && receiver.is_none()
825            && let Some(specifier) = arguments.first()
826        {
827            // `const router = require('./x')` binds the module to a name, and
828            // a mount written later refers to it by that name and nothing
829            // else.
830            let bound = if self.punct(index.wrapping_sub(1), "=")
831                && self.kind(index.wrapping_sub(2)) == Some(TokenKind::Identifier)
832            {
833                vec![self.text(index - 2).to_owned()]
834            } else {
835                Vec::new()
836            };
837            self.facts.imports.push(Import {
838                specifier: specifier.clone(),
839                span: self.span(index, index),
840                type_only: false,
841                reexport: false,
842                bindings: bound
843                    .iter()
844                    .map(|local| ImportBinding {
845                        imported: "*".to_owned(),
846                        local: local.clone(),
847                    })
848                    .collect(),
849                names: bound,
850            });
851        }
852        self.facts.references.push(Reference {
853            kind: ReferenceKind::Call,
854            name,
855            receiver,
856            span: self.span(index, index),
857            owner: self.owner(),
858            string_arguments: arguments,
859            name_arguments: names,
860        });
861        Some(index + 1)
862    }
863
864    /// Length of a balanced TypeScript type-argument list before a call.
865    fn type_argument_span(&self, index: usize) -> usize {
866        if !self.punct(index, "<") {
867            return 0;
868        }
869        let limit = (index + 64).min(self.tokens.len());
870        let mut cursor = index + 1;
871        let mut depth = 1_i32;
872        while cursor < limit && depth > 0 {
873            if self.punct(cursor, "<") {
874                depth += 1;
875            } else if self.punct(cursor, ">") {
876                depth -= 1;
877            } else if depth == 1 && matches!(self.text(cursor), ";" | "{" | "}") {
878                return 0;
879            }
880            cursor += 1;
881        }
882        if depth == 0 && self.punct(cursor, "(") {
883            cursor - index
884        } else {
885            0
886        }
887    }
888}
889
890/// Whether a name is an HTTP method written as a route-table key.
891fn is_method(name: &str) -> bool {
892    matches!(
893        name,
894        "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | "ALL"
895    )
896}
897
898/// Byte ranges of the expressions enclosed by `${...}` in one JavaScript
899/// template token. A nested template is one token while matching the outer
900/// expression, so braces in its text cannot close the expression early.
901fn template_interpolation_ranges(template: &str, language: Language) -> Vec<(usize, usize)> {
902    let bytes = template.as_bytes();
903    let mut ranges = Vec::new();
904    let mut cursor = usize::from(bytes.first() == Some(&b'`'));
905    while cursor + 1 < bytes.len() {
906        if bytes[cursor] == b'\\' {
907            cursor = (cursor + 2).min(bytes.len());
908            continue;
909        }
910        if bytes[cursor] == b'`' {
911            break;
912        }
913        if bytes[cursor] != b'$' || bytes[cursor + 1] != b'{' {
914            cursor += 1;
915            continue;
916        }
917        let expression_start = cursor + 2;
918        let tail = &template[expression_start..];
919        let tokens = Tokenizer::new(tail, language)
920            .mode(Mode::Lite)
921            .collect::<Vec<_>>();
922        let mut depth = 1_i32;
923        let mut expression_end = None;
924        for token in tokens {
925            if token.kind != TokenKind::Punctuation {
926                continue;
927            }
928            match token.text(tail) {
929                "{" => depth += 1,
930                "}" => {
931                    depth -= 1;
932                    if depth == 0 {
933                        expression_end = Some(expression_start + token.start);
934                        cursor = expression_start + token.end;
935                        break;
936                    }
937                }
938                _ => {}
939            }
940        }
941        let Some(expression_end) = expression_end else {
942            break;
943        };
944        ranges.push((expression_start, expression_end));
945    }
946    ranges
947}
948
949fn position_at(source: &str, offset: usize) -> (u32, u32) {
950    let prefix = source.get(..offset).unwrap_or(source);
951    let line = u32::try_from(prefix.bytes().filter(|byte| *byte == b'\n').count())
952        .unwrap_or(u32::MAX)
953        .saturating_add(1);
954    let column = u32::try_from(
955        prefix
956            .rsplit_once('\n')
957            .map_or(prefix, |(_, suffix)| suffix)
958            .chars()
959            .count(),
960    )
961    .unwrap_or(u32::MAX)
962    .saturating_add(1);
963    (line, column)
964}
965
966#[cfg(test)]
967mod tests {
968    #[test]
969    fn a_default_import_alongside_named_ones_is_still_an_import() {
970        use crate::syntax::Language;
971
972        let source = "import './sideEffect.js';\n\
973             import express from 'express';\n\
974             import logger, { logRequest, logAction } from './logger.js';\n\
975             import * as tty from 'node:tty';\n\
976             import type { Config } from './config';\n\
977             export { helper } from './helper.js';\n\
978             const meta = import.meta.url;\n";
979        let facts = super::extract(source, Language::TypeScript);
980        assert_eq!(
981            facts
982                .imports
983                .iter()
984                .map(|import| import.specifier.as_str())
985                .collect::<Vec<_>>(),
986            [
987                "./sideEffect.js",
988                "express",
989                "./logger.js",
990                "node:tty",
991                "./config",
992                "./helper.js",
993            ],
994            "a name before the brace must not end the clause, and import.meta \
995             is not a module statement"
996        );
997    }
998
999    /// React files are the ones where the lexer's assumptions are most
1000    /// fragile: `<` and `>` surround markup rather than comparing, and a
1001    /// slash inside JSX text must stay a division rather than opening a
1002    /// regular expression that would swallow the rest of the file.
1003    #[test]
1004    fn jsx_does_not_derail_the_token_stream() {
1005        use crate::syntax::Language;
1006        use crate::token::{TokenKind, tokenize};
1007
1008        let source = "import React from 'react';\n\
1009             import { Button } from './Button';\n\
1010             export function Panel({ items, onPick }) {\n\
1011             \x20 const ratio = items.length / 2;\n\
1012             \x20 return (\n\
1013             \x20   <div className=\"panel\" data-count={items.length}>\n\
1014             \x20     {items.map((item) => (\n\
1015             \x20       <Button key={item.id} onClick={() => onPick(item)}>\n\
1016             \x20         {item.label} / {ratio}\n\
1017             \x20       </Button>\n\
1018             \x20     ))}\n\
1019             \x20   </div>\n\
1020             \x20 );\n\
1021             }\n\
1022             export const Footer = () => <footer>done</footer>;\n";
1023        let tokens = tokenize(source, Language::TypeScript);
1024        assert_eq!(
1025            tokens
1026                .iter()
1027                .map(|token| token.text(source))
1028                .collect::<String>(),
1029            source,
1030            "the stream must still reproduce the file"
1031        );
1032        assert!(
1033            !tokens
1034                .iter()
1035                .any(|token| matches!(token.kind, TokenKind::Regex | TokenKind::Unterminated)),
1036            "no division in JSX may be read as a regular expression"
1037        );
1038        let facts = super::extract(source, Language::TypeScript);
1039        assert_eq!(
1040            facts
1041                .imports
1042                .iter()
1043                .map(|import| import.specifier.as_str())
1044                .collect::<Vec<_>>(),
1045            ["react", "./Button"]
1046        );
1047        for name in ["Panel", "Footer"] {
1048            assert!(
1049                facts.declarations.iter().any(|item| item.name == name),
1050                "{name} must survive the markup, got {:?}",
1051                facts
1052                    .declarations
1053                    .iter()
1054                    .map(|item| item.name.as_str())
1055                    .collect::<Vec<_>>()
1056            );
1057        }
1058    }
1059
1060    use super::extract;
1061    use crate::facts::{DeclarationKind, ImportBinding, ReferenceKind};
1062    use crate::syntax::Language;
1063
1064    fn specifiers(source: &str) -> Vec<(String, bool, bool)> {
1065        extract(source, Language::TypeScript)
1066            .imports
1067            .into_iter()
1068            .map(|import| (import.specifier, import.type_only, import.reexport))
1069            .collect()
1070    }
1071
1072    #[test]
1073    fn reads_every_module_form_including_multi_line() {
1074        let source = "import defaultExport from './a';\n\
1075             import {\n  first,\n  second,\n} from './b';\n\
1076             import type { Shape } from './c';\n\
1077             import { type Only } from './d';\n\
1078             import * as everything from './e';\n\
1079             import './f';\n\
1080             const legacy = require('./g');\n\
1081             const lazy = await import('./h');\n\
1082             export { thing } from './i';\n\
1083             export * from './j';\n";
1084        assert_eq!(
1085            specifiers(source),
1086            [
1087                ("./a".to_owned(), false, false),
1088                ("./b".to_owned(), false, false),
1089                ("./c".to_owned(), true, false),
1090                ("./d".to_owned(), true, false),
1091                ("./e".to_owned(), false, false),
1092                ("./f".to_owned(), false, false),
1093                ("./g".to_owned(), false, false),
1094                ("./h".to_owned(), false, false),
1095                ("./i".to_owned(), false, true),
1096                ("./j".to_owned(), false, true),
1097            ],
1098            "a multi-line import is one fact, and type-only is distinguished"
1099        );
1100    }
1101
1102    #[test]
1103    fn a_comment_or_string_never_becomes_a_fact() {
1104        let source = "// import { fake } from './nope';\n\
1105             const text = \"import { alsoFake } from './nope2'\";\n\
1106             /* app.get('/commented-route', handler); */\n\
1107             import { real } from './yes';\n\
1108             app.get('/real-route', handler);\n";
1109        assert_eq!(specifiers(source), [("./yes".to_owned(), false, false)]);
1110        let facts = extract(source, Language::TypeScript);
1111        let routes = facts
1112            .references
1113            .iter()
1114            .flat_map(|call| call.string_arguments.clone())
1115            .collect::<Vec<_>>();
1116        assert_eq!(
1117            routes,
1118            ["/real-route"],
1119            "the commented route is not a call argument"
1120        );
1121    }
1122
1123    #[test]
1124    fn class_bodies_yield_methods_and_fields_with_their_owner() {
1125        let source = "export class Service {\n\
1126             \x20 private cache = new Map();\n\
1127             \x20 readonly limit: number = 10;\n\
1128             \x20 async run(input: string) {\n\
1129             \x20   return this.helper(input);\n\
1130             \x20 }\n\
1131             \x20 helper(value: string) { return value; }\n\
1132             }\n";
1133        let facts = extract(source, Language::TypeScript);
1134        let declared = facts
1135            .declarations
1136            .iter()
1137            .map(|item| (item.name.as_str(), item.kind, item.owner.as_deref()))
1138            .collect::<Vec<_>>();
1139        assert!(
1140            declared.contains(&("Service", DeclarationKind::Class, None)),
1141            "got {declared:?}"
1142        );
1143        assert!(
1144            declared.contains(&("run", DeclarationKind::Method, Some("Service"))),
1145            "a class method is a declaration owned by its class, got {declared:?}"
1146        );
1147        assert!(
1148            declared.contains(&("helper", DeclarationKind::Method, Some("Service"))),
1149            "got {declared:?}"
1150        );
1151        assert!(
1152            declared.contains(&("cache", DeclarationKind::Field, Some("Service"))),
1153            "got {declared:?}"
1154        );
1155        let call = facts
1156            .references
1157            .iter()
1158            .find(|call| call.name == "helper")
1159            .expect("the call inside run is recorded");
1160        assert_eq!(call.receiver.as_deref(), Some("this"));
1161        assert_eq!(call.owner.as_deref(), Some("run"));
1162    }
1163
1164    #[test]
1165    fn arrow_constants_are_functions_and_plain_constants_are_not() {
1166        let source = "export const load = async () => { return 1; };\n\
1167             const multiline =\n(value) => value;\n\
1168             const limit = 10;\n";
1169        let facts = extract(source, Language::TypeScript);
1170        let kinds = facts
1171            .declarations
1172            .iter()
1173            .map(|item| (item.name.as_str(), item.kind, item.exported))
1174            .collect::<Vec<_>>();
1175        assert!(
1176            kinds.contains(&("load", DeclarationKind::Function, true)),
1177            "got {kinds:?}"
1178        );
1179        assert!(
1180            kinds.contains(&("multiline", DeclarationKind::Function, false)),
1181            "got {kinds:?}"
1182        );
1183        assert!(
1184            kinds.contains(&("limit", DeclarationKind::Constant, false)),
1185            "got {kinds:?}"
1186        );
1187    }
1188
1189    #[test]
1190    fn regexes_and_collection_initializers_are_not_arrow_functions() {
1191        let source = "const SAFE_SCRIPT = /^(?:test(?::|$)|[^:]+:(?:test|check)(?::|$))/i\n\
1192             const UNSAFE_SHELL_ARG = /[\\0\\r\\n&|<>^%!`\\\"]/ \n\
1193             const byId = new Map((graph.nodes || []).map((node) => [String(node.id), node]))\n\
1194             const files = new Set((graph.nodes || []).filter((node) => node.id))\n\
1195             const adjacency = new Map([...files].map((file) => [file, new Set()]))\n";
1196        let facts = extract(source, Language::JavaScript);
1197        for name in [
1198            "SAFE_SCRIPT",
1199            "UNSAFE_SHELL_ARG",
1200            "byId",
1201            "files",
1202            "adjacency",
1203        ] {
1204            let declaration = facts
1205                .declarations
1206                .iter()
1207                .find(|item| item.name == name)
1208                .unwrap_or_else(|| panic!("missing {name}: {facts:?}"));
1209            assert_eq!(
1210                declaration.kind,
1211                DeclarationKind::Constant,
1212                "{name} is a value initializer"
1213            );
1214        }
1215    }
1216
1217    #[test]
1218    fn exported_functions_and_returned_object_methods_are_declarations() {
1219        let source = "export function runCommand(command, args = [], options = {}) {}\n\
1220             export function createGate() {\n\
1221             \x20 return {\n\
1222             \x20   shouldShow({ force = false } = {}) { return force },\n\
1223             \x20   reset() {},\n\
1224             \x20 }\n\
1225             }\n\
1226             export function createClassifier() {\n\
1227             \x20 return { explain(path, options = {}) { return path } }\n\
1228             }\n";
1229        let facts = extract(source, Language::JavaScript);
1230        let declared = facts
1231            .declarations
1232            .iter()
1233            .map(|item| (item.name.as_str(), item.kind, item.owner.as_deref()))
1234            .collect::<Vec<_>>();
1235        assert!(
1236            declared.contains(&("runCommand", DeclarationKind::Function, None)),
1237            "got {declared:?}"
1238        );
1239        for (name, owner) in [
1240            ("shouldShow", "createGate"),
1241            ("reset", "createGate"),
1242            ("explain", "createClassifier"),
1243        ] {
1244            assert!(
1245                declared.contains(&(name, DeclarationKind::Method, Some(owner))),
1246                "missing {owner}.{name}; got {declared:?}"
1247            );
1248        }
1249    }
1250
1251    #[test]
1252    fn exporting_an_imported_binding_keeps_its_origin() {
1253        let source =
1254            "import { safeRead, MAX_FILE_BYTES } from '../util.js';\nexport { safeRead };\n";
1255        let facts = extract(source, Language::JavaScript);
1256        let forwarded = facts
1257            .imports
1258            .iter()
1259            .find(|item| item.reexport)
1260            .expect("local export of imported binding");
1261        assert_eq!(forwarded.specifier, "../util.js");
1262        assert_eq!(forwarded.names, ["safeRead"]);
1263        assert_eq!(
1264            forwarded.bindings,
1265            [ImportBinding {
1266                imported: "safeRead".to_owned(),
1267                local: "safeRead".to_owned(),
1268            }]
1269        );
1270    }
1271
1272    #[test]
1273    fn aliased_imports_preserve_original_and_local_names() {
1274        let facts = extract(
1275            "import Default, {\n\
1276             \x20 architectureViolation as violation,\n\
1277             \x20 matchComponentSelector as matches,\n\
1278             } from './architecture.js';\n\
1279             import * as catalog from './catalog.js';\n",
1280            Language::JavaScript,
1281        );
1282        let architecture = facts
1283            .imports
1284            .iter()
1285            .find(|item| item.specifier == "./architecture.js")
1286            .expect("architecture import");
1287        assert_eq!(architecture.names, ["Default", "violation", "matches"]);
1288        assert_eq!(
1289            architecture.bindings,
1290            [
1291                ImportBinding {
1292                    imported: "default".to_owned(),
1293                    local: "Default".to_owned(),
1294                },
1295                ImportBinding {
1296                    imported: "architectureViolation".to_owned(),
1297                    local: "violation".to_owned(),
1298                },
1299                ImportBinding {
1300                    imported: "matchComponentSelector".to_owned(),
1301                    local: "matches".to_owned(),
1302                },
1303            ]
1304        );
1305        let catalog = facts
1306            .imports
1307            .iter()
1308            .find(|item| item.specifier == "./catalog.js")
1309            .expect("namespace import");
1310        assert_eq!(
1311            catalog.bindings,
1312            [ImportBinding {
1313                imported: "*".to_owned(),
1314                local: "catalog".to_owned(),
1315            }]
1316        );
1317    }
1318
1319    #[test]
1320    fn nested_template_text_never_becomes_a_call() {
1321        let source = r"function exactUsage(files) {
1322  return `${files ? ` in ${plural(files)} file(s)` : ''}`
1323}
1324";
1325        let facts = extract(source, Language::JavaScript);
1326        assert!(
1327            !facts
1328                .references
1329                .iter()
1330                .any(|reference| reference.name == "file"),
1331            "`file(s)` is literal template text, got {:?}",
1332            facts.references
1333        );
1334        let plural = facts
1335            .references
1336            .iter()
1337            .find(|reference| reference.name == "plural")
1338            .expect("call in a nested interpolation");
1339        assert_eq!(plural.kind, ReferenceKind::Call);
1340        assert_eq!(plural.owner.as_deref(), Some("exactUsage"));
1341        assert_eq!(plural.span.line, 2);
1342    }
1343
1344    #[test]
1345    fn template_interpolations_keep_all_calls_and_exact_spans() {
1346        let source = r"function describe(blob, name, edge, graph) {
1347  const mentioned = new RegExp(`x${escRe(name)}y`).test(blob)
1348  return `${compileKind(edge) ? labelOf(graph, edge.id) : ''}`
1349}
1350";
1351        let facts = extract(source, Language::JavaScript);
1352        let calls = facts
1353            .references
1354            .iter()
1355            .filter(|reference| reference.kind == ReferenceKind::Call)
1356            .collect::<Vec<_>>();
1357        for (name, line) in [
1358            ("RegExp", 2),
1359            ("escRe", 2),
1360            ("test", 2),
1361            ("compileKind", 3),
1362            ("labelOf", 3),
1363        ] {
1364            let reference = calls
1365                .iter()
1366                .find(|reference| reference.name == name)
1367                .unwrap_or_else(|| panic!("missing {name}, got {calls:?}"));
1368            assert_eq!(reference.span.line, line);
1369            assert_eq!(
1370                &source[reference.span.start..reference.span.end],
1371                name,
1372                "relocated span must name the original call"
1373            );
1374            assert_eq!(reference.owner.as_deref(), Some("describe"));
1375        }
1376    }
1377
1378    #[test]
1379    fn nested_call_arguments_are_not_object_methods() {
1380        let source = r"function build(makeClient, session) {
1381  return {
1382    make: () => makeClient({ timeoutMs: Math.max(100, remaining(session)) }),
1383  }
1384}
1385";
1386        let facts = extract(source, Language::JavaScript);
1387        let remaining = facts
1388            .references
1389            .iter()
1390            .filter(|reference| {
1391                reference.name == "remaining" && reference.kind == ReferenceKind::Call
1392            })
1393            .collect::<Vec<_>>();
1394        assert_eq!(remaining.len(), 1, "got {:?}", facts.references);
1395        assert_eq!(remaining[0].span.line, 3);
1396        assert!(
1397            !facts.declarations.iter().any(|declaration| {
1398                declaration.name == "remaining" && declaration.kind == DeclarationKind::Method
1399            }),
1400            "a nested argument is not an object method: {:?}",
1401            facts.declarations
1402        );
1403    }
1404
1405    #[test]
1406    fn default_object_parameter_is_not_the_function_body() {
1407        let source = r"export function runCommand(command, args = [], options = {}) {
1408  return spawn(command, args, { env: childProcessEnv(options.env || {}) })
1409}
1410";
1411        let facts = extract(source, Language::JavaScript);
1412        let declaration = facts
1413            .declarations
1414            .iter()
1415            .find(|declaration| declaration.name == "runCommand")
1416            .expect("exported function declaration");
1417        assert_eq!(declaration.kind, DeclarationKind::Function);
1418        assert!(declaration.exported);
1419        let environment = facts
1420            .references
1421            .iter()
1422            .find(|reference| reference.name == "childProcessEnv")
1423            .expect("call inside function body");
1424        assert_eq!(environment.owner.as_deref(), Some("runCommand"));
1425    }
1426
1427    #[test]
1428    fn call_in_returned_object_property_is_retained() {
1429        let source = r"function withGraph(graph) {
1430  const root = mkdtempSync('prefix')
1431  const graphPath = join(root, 'graph.json')
1432  return {root, graphPath, graph: loadGraph(graphPath)}
1433}
1434";
1435        let facts = extract(source, Language::JavaScript);
1436        let load = facts
1437            .references
1438            .iter()
1439            .find(|reference| {
1440                reference.name == "loadGraph" && reference.kind == ReferenceKind::Call
1441            })
1442            .unwrap_or_else(|| panic!("missing loadGraph, got {facts:?}"));
1443        assert_eq!(load.owner.as_deref(), Some("withGraph"));
1444        assert_eq!(load.span.line, 4);
1445    }
1446
1447    #[test]
1448    fn object_method_names_are_not_calls_but_their_bodies_are() {
1449        let source = r"function wrap(client) {
1450  return Object.freeze({
1451    fromUri(uri) { return client.normalizer.fromUri(uri) },
1452    kill() { client.kill() },
1453  })
1454}
1455";
1456        let facts = extract(source, Language::JavaScript);
1457        let calls = facts
1458            .references
1459            .iter()
1460            .filter(|reference| reference.kind == ReferenceKind::Call)
1461            .collect::<Vec<_>>();
1462        assert_eq!(
1463            calls
1464                .iter()
1465                .filter(|reference| reference.name == "fromUri")
1466                .count(),
1467            1,
1468            "only the body call is a reference, got {calls:?}"
1469        );
1470        let from_uri = calls
1471            .iter()
1472            .find(|reference| reference.name == "fromUri")
1473            .expect("body call");
1474        assert_eq!(from_uri.receiver.as_deref(), Some("normalizer"));
1475        assert_eq!(from_uri.span.line, 3);
1476        assert!(
1477            from_uri.span.column > 30,
1478            "the call must point inside the body, got {:?}",
1479            from_uri.span
1480        );
1481        assert_eq!(
1482            calls
1483                .iter()
1484                .filter(|reference| reference.name == "kill")
1485                .count(),
1486            1,
1487            "only client.kill() is a call, got {calls:?}"
1488        );
1489    }
1490
1491    #[test]
1492    fn typescript_generic_calls_keep_their_call_fact() {
1493        let facts = extract(
1494            "export async function loadUser() { return get<User>('/users/1'); }\n",
1495            Language::TypeScript,
1496        );
1497        let call = facts
1498            .references
1499            .iter()
1500            .find(|reference| reference.name == "get" && reference.kind == ReferenceKind::Call)
1501            .expect("generic call");
1502        assert_eq!(call.string_arguments, ["/users/1"]);
1503    }
1504
1505    #[test]
1506    fn calls_inside_object_fields_and_nested_arguments_are_all_retained() {
1507        let source = "function score(entry, count, total) {\n\
1508             \x20 return {...entry, hotspotScore: round(Math.sqrt(entry.value))}\n\
1509             }\n\
1510             function pair(pairs, count, total) {\n\
1511             \x20 pairs.push({jaccard: round(count / total), lift: round(Math.max(count, total))})\n\
1512             }\n";
1513        let facts = extract(source, Language::JavaScript);
1514        let calls = facts
1515            .references
1516            .iter()
1517            .filter(|reference| reference.kind == ReferenceKind::Call)
1518            .map(|reference| (reference.name.as_str(), reference.span.line))
1519            .collect::<Vec<_>>();
1520        assert_eq!(
1521            calls.iter().filter(|(name, _)| *name == "round").count(),
1522            3,
1523            "every aliased round call must survive, got {calls:?}"
1524        );
1525        for expected in [
1526            ("round", 2),
1527            ("sqrt", 2),
1528            ("push", 5),
1529            ("round", 5),
1530            ("max", 5),
1531        ] {
1532            assert!(
1533                calls.contains(&expected),
1534                "missing {expected:?}; got {calls:?}"
1535            );
1536        }
1537        for false_method in ["round", "sqrt", "max"] {
1538            assert!(
1539                !facts.declarations.iter().any(|declaration| {
1540                    declaration.name == false_method && declaration.kind == DeclarationKind::Method
1541                }),
1542                "a call used as an object value is not a method declaration"
1543            );
1544        }
1545    }
1546}