fallow-extract 2.31.0

Parsing and extraction engine for the fallow TypeScript/JavaScript codebase analyzer
Documentation
use oxc_allocator::Allocator;
use oxc_ast_visit::Visit;
use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::SourceType;
use rustc_hash::FxHashSet;

use crate::MemberAccess;
use crate::visitor::ModuleInfoExtractor;

#[derive(Debug, Default, Clone)]
pub struct TemplateUsage {
    pub(crate) used_bindings: FxHashSet<String>,
    pub(crate) member_accesses: Vec<MemberAccess>,
    pub(crate) whole_object_uses: Vec<String>,
}

impl TemplateUsage {
    pub(crate) fn merge(&mut self, other: Self) {
        self.used_bindings.extend(other.used_bindings);
        for access in other.member_accesses {
            let key = (&access.object, &access.member);
            let already_present = self
                .member_accesses
                .iter()
                .any(|existing| (&existing.object, &existing.member) == key);
            if !already_present {
                self.member_accesses.push(access);
            }
        }
        for whole in other.whole_object_uses {
            if !self
                .whole_object_uses
                .iter()
                .any(|existing| existing == &whole)
            {
                self.whole_object_uses.push(whole);
            }
        }
    }

    #[cfg(test)]
    pub(crate) fn is_empty(&self) -> bool {
        self.used_bindings.is_empty()
            && self.member_accesses.is_empty()
            && self.whole_object_uses.is_empty()
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TemplateSnippetKind {
    Expression,
    Statement,
}

pub fn analyze_template_snippet(
    snippet: &str,
    kind: TemplateSnippetKind,
    imported_bindings: &FxHashSet<String>,
    locals: &[String],
    allow_dollar_prefixed_refs: bool,
) -> TemplateUsage {
    let snippet = snippet.trim();
    if snippet.is_empty() || imported_bindings.is_empty() {
        return TemplateUsage::default();
    }

    let wrapped = wrap_snippet(snippet, kind, locals);
    let allocator = Allocator::default();
    let parser_return = Parser::new(&allocator, &wrapped, SourceType::ts()).parse();

    let semantic_ret = SemanticBuilder::new().build(&parser_return.program);
    let unresolved_names: FxHashSet<String> = semantic_ret
        .semantic
        .scoping()
        .root_unresolved_references()
        .keys()
        .filter_map(|name| {
            let name = name.as_str();
            if imported_bindings.contains(name) {
                return Some(name.to_string());
            }
            if allow_dollar_prefixed_refs
                && let Some(stripped) = name.strip_prefix('$')
                && imported_bindings.contains(stripped)
            {
                return Some(stripped.to_string());
            }
            None
        })
        .collect();

    if unresolved_names.is_empty() {
        return TemplateUsage::default();
    }

    let mut extractor = ModuleInfoExtractor::new();
    extractor.visit_program(&parser_return.program);

    TemplateUsage {
        used_bindings: unresolved_names.clone(),
        member_accesses: dedup_member_accesses(
            extractor
                .member_accesses
                .into_iter()
                .filter_map(|access| {
                    remap_object_name(
                        &access.object,
                        &unresolved_names,
                        allow_dollar_prefixed_refs,
                    )
                    .map(|object| MemberAccess {
                        object,
                        member: access.member,
                    })
                })
                .collect(),
        ),
        whole_object_uses: dedup_names(
            extractor
                .whole_object_uses
                .into_iter()
                .filter_map(|name| {
                    remap_object_name(&name, &unresolved_names, allow_dollar_prefixed_refs)
                })
                .collect(),
        ),
    }
}

/// Collect all unresolved identifier references from a template snippet.
///
/// Unlike [`analyze_template_snippet`], this does NOT filter against imported bindings.
/// Returns every identifier that is not locally scoped. Used for Angular external
/// templates where unresolved identifiers are potential component class member refs.
pub fn collect_unresolved_refs(
    snippet: &str,
    kind: TemplateSnippetKind,
    locals: &[String],
) -> FxHashSet<String> {
    let snippet = snippet.trim();
    if snippet.is_empty() {
        return FxHashSet::default();
    }

    let wrapped = wrap_snippet(snippet, kind, locals);
    let allocator = Allocator::default();
    let parser_return = Parser::new(&allocator, &wrapped, SourceType::ts()).parse();
    let semantic_ret = SemanticBuilder::new().build(&parser_return.program);

    semantic_ret
        .semantic
        .scoping()
        .root_unresolved_references()
        .keys()
        .map(|name| name.to_string())
        .collect()
}

fn remap_object_name(
    name: &str,
    unresolved_names: &FxHashSet<String>,
    allow_dollar_prefixed_refs: bool,
) -> Option<String> {
    if unresolved_names.contains(name) {
        return Some(name.to_string());
    }
    if allow_dollar_prefixed_refs
        && let Some(stripped) = name.strip_prefix('$')
        && unresolved_names.contains(stripped)
    {
        return Some(stripped.to_string());
    }
    None
}

fn wrap_snippet(snippet: &str, kind: TemplateSnippetKind, locals: &[String]) -> String {
    let mut wrapped = String::new();
    if !locals.is_empty() {
        wrapped.push_str("const __fallow_local = undefined;\n");
        for local in locals {
            wrapped.push_str("const ");
            wrapped.push_str(local);
            wrapped.push_str(" = __fallow_local;\n");
        }
    }

    match kind {
        TemplateSnippetKind::Expression => {
            wrapped.push_str("void (");
            wrapped.push_str(snippet);
            wrapped.push_str(");\n");
        }
        TemplateSnippetKind::Statement => {
            wrapped.push_str("(() => {\n");
            wrapped.push_str(snippet);
            wrapped.push_str("\n})();\n");
        }
    }

    wrapped
}

fn dedup_member_accesses(accesses: Vec<MemberAccess>) -> Vec<MemberAccess> {
    let mut seen: FxHashSet<(String, String)> = FxHashSet::default();
    let mut deduped = Vec::with_capacity(accesses.len());
    for access in accesses {
        let key = (access.object.clone(), access.member.clone());
        if seen.insert(key) {
            deduped.push(access);
        }
    }
    deduped
}

fn dedup_names(names: Vec<String>) -> Vec<String> {
    let mut seen: FxHashSet<String> = FxHashSet::default();
    let mut deduped = Vec::with_capacity(names.len());
    for name in names {
        if seen.insert(name.clone()) {
            deduped.push(name);
        }
    }
    deduped
}

#[cfg(test)]
mod tests {
    use super::*;

    fn bindings(names: &[&str]) -> FxHashSet<String> {
        names.iter().map(|name| (*name).to_string()).collect()
    }

    #[test]
    fn expression_usage_tracks_named_bindings() {
        let usage = analyze_template_snippet(
            "formatDate(user.createdAt)",
            TemplateSnippetKind::Expression,
            &bindings(&["formatDate"]),
            &[],
            false,
        );

        assert!(usage.used_bindings.contains("formatDate"));
        assert!(usage.member_accesses.is_empty());
        assert!(usage.whole_object_uses.is_empty());
    }

    #[test]
    fn expression_usage_tracks_namespace_members() {
        let usage = analyze_template_snippet(
            "utils.formatDate(user.createdAt)",
            TemplateSnippetKind::Expression,
            &bindings(&["utils"]),
            &[],
            false,
        );

        assert!(usage.used_bindings.contains("utils"));
        assert_eq!(usage.member_accesses.len(), 1);
        assert_eq!(usage.member_accesses[0].object, "utils");
        assert_eq!(usage.member_accesses[0].member, "formatDate");
    }

    #[test]
    fn locals_shadow_imported_names() {
        let usage = analyze_template_snippet(
            "item.name",
            TemplateSnippetKind::Expression,
            &bindings(&["item"]),
            &["item".to_string()],
            false,
        );

        assert!(usage.is_empty());
    }

    #[test]
    fn statement_usage_tracks_handler_references() {
        let usage = analyze_template_snippet(
            "count += increment(step);",
            TemplateSnippetKind::Statement,
            &bindings(&["increment"]),
            &[],
            false,
        );

        assert!(usage.used_bindings.contains("increment"));
    }

    #[test]
    fn dollar_prefixed_refs_can_map_to_imported_store_bindings() {
        let usage = analyze_template_snippet(
            "$page.url.pathname",
            TemplateSnippetKind::Expression,
            &bindings(&["page"]),
            &[],
            true,
        );

        assert!(usage.used_bindings.contains("page"));
        assert_eq!(usage.member_accesses.len(), 1);
        assert_eq!(usage.member_accesses[0].object, "page");
        assert_eq!(usage.member_accesses[0].member, "url");
    }
}