fallow-engine 3.1.0

Typed analysis engine facade for fallow consumers
Documentation
//! Feature flag analysis owned by the engine boundary.

use std::path::Path;

use fallow_config::ResolvedConfig;
use fallow_types::discover::DiscoveredFile;
use fallow_types::extract::{FlagUse, FlagUseKind, ModuleInfo};
use fallow_types::results::{AnalysisResults, FeatureFlag, FlagConfidence, FlagKind};
use rustc_hash::FxHashMap;

use crate::session::AnalysisSession;
use crate::suppress::{IssueKind, is_file_suppressed, is_suppressed};

/// Typed result from running feature flag analysis.
#[derive(Debug, Clone)]
pub struct FeatureFlagsAnalysis {
    pub flags: Vec<FeatureFlag>,
    pub files_scanned: usize,
}

/// Run feature flag analysis with a reusable analysis session.
#[must_use]
pub fn analyze_feature_flags_with_session(session: &AnalysisSession) -> FeatureFlagsAnalysis {
    let parsed = session.parsed_parts(false);
    let flags = collect_flags_for_modules(session, &parsed.files, &parsed.modules);
    FeatureFlagsAnalysis {
        flags,
        files_scanned: parsed.files.len(),
    }
}

/// Built-in environment variable prefixes treated as feature flags.
#[must_use]
pub fn builtin_env_prefixes() -> &'static [&'static str] {
    crate::feature_flags::builtin_env_prefixes()
}

/// Distinct built-in SDK provider labels, in declaration order.
#[must_use]
pub fn builtin_sdk_providers() -> Vec<&'static str> {
    crate::feature_flags::builtin_sdk_providers()
}

fn collect_flags_for_modules(
    session: &AnalysisSession,
    files: &[DiscoveredFile],
    modules: &[ModuleInfo],
) -> Vec<FeatureFlag> {
    let mut flags = collect_flags_from_modules(session.config(), files, modules);
    correlate_flags_with_dead_code(&mut flags, session, modules);
    flags
}

fn correlate_flags_with_dead_code(
    flags: &mut [FeatureFlag],
    session: &AnalysisSession,
    modules: &[ModuleInfo],
) {
    if let Ok(analysis_output) = session.analyze_dead_code_with_parsed_modules(modules) {
        correlate_with_dead_code(flags, &analysis_output.results);
    }
}

fn correlate_with_dead_code(flags: &mut [FeatureFlag], results: &AnalysisResults) {
    if results.unused_exports.is_empty() && results.unused_types.is_empty() {
        return;
    }

    for flag in flags.iter_mut() {
        let (Some(guard_start), Some(guard_end)) = (flag.guard_line_start, flag.guard_line_end)
        else {
            continue;
        };

        for export in &results.unused_exports {
            if export.export.path == flag.path
                && export.export.line >= guard_start
                && export.export.line <= guard_end
            {
                flag.guarded_dead_exports
                    .push(export.export.export_name.clone());
            }
        }

        for export in &results.unused_types {
            if export.export.path == flag.path
                && export.export.line >= guard_start
                && export.export.line <= guard_end
            {
                flag.guarded_dead_exports
                    .push(export.export.export_name.clone());
            }
        }
    }
}

fn collect_flags_from_modules(
    config: &ResolvedConfig,
    files: &[DiscoveredFile],
    modules: &[ModuleInfo],
) -> Vec<FeatureFlag> {
    let file_paths: FxHashMap<_, _> = files.iter().map(|file| (file.id, &file.path)).collect();

    let extra_sdk: Vec<(String, usize, String)> = config
        .flags
        .sdk_patterns
        .iter()
        .map(|pattern| {
            (
                pattern.function.clone(),
                pattern.name_arg,
                pattern.provider.clone().unwrap_or_default(),
            )
        })
        .collect();
    let has_custom_config = !extra_sdk.is_empty()
        || !config.flags.env_prefixes.is_empty()
        || config.flags.config_object_heuristics;

    let mut flags = Vec::new();
    for module in modules {
        let Some(path) = file_paths.get(&module.file_id) else {
            continue;
        };

        collect_builtin_flags(&mut flags, module, path);
        if has_custom_config {
            collect_custom_flags(&mut flags, config, module, path, &extra_sdk);
        }
    }
    flags
}

fn collect_builtin_flags(flags: &mut Vec<FeatureFlag>, module: &ModuleInfo, path: &Path) {
    let file_suppressed = is_file_suppressed(&module.suppressions, IssueKind::FeatureFlag);
    for flag_use in &module.flag_uses {
        if file_suppressed
            || is_suppressed(&module.suppressions, flag_use.line, IssueKind::FeatureFlag)
        {
            continue;
        }
        flags.push(flag_use_to_feature_flag(flag_use, module, path));
    }
}

fn collect_custom_flags(
    flags: &mut Vec<FeatureFlag>,
    config: &ResolvedConfig,
    module: &ModuleInfo,
    path: &Path,
    extra_sdk: &[(String, usize, String)],
) {
    let Ok(source) = std::fs::read_to_string(path) else {
        return;
    };

    let custom_flags = crate::feature_flags::extract_flags_from_source(
        &source,
        path,
        extra_sdk,
        &config.flags.env_prefixes,
        config.flags.config_object_heuristics,
    );
    for flag_use in &custom_flags {
        let already_found = module.flag_uses.iter().any(|existing| {
            existing.line == flag_use.line && existing.flag_name == flag_use.flag_name
        });
        if !already_found
            && !is_suppressed(&module.suppressions, flag_use.line, IssueKind::FeatureFlag)
        {
            flags.push(flag_use_to_feature_flag(flag_use, module, path));
        }
    }
}

fn flag_use_to_feature_flag(flag_use: &FlagUse, module: &ModuleInfo, path: &Path) -> FeatureFlag {
    let (kind, confidence) = match flag_use.kind {
        FlagUseKind::EnvVar => (FlagKind::EnvironmentVariable, FlagConfidence::High),
        FlagUseKind::SdkCall => (FlagKind::SdkCall, FlagConfidence::High),
        FlagUseKind::ConfigObject => (FlagKind::ConfigObject, FlagConfidence::Low),
    };

    let (guard_line_start, guard_line_end) = if let (Some(start), Some(end)) =
        (flag_use.guard_span_start, flag_use.guard_span_end)
        && !module.line_offsets.is_empty()
    {
        let (start_line, _) =
            fallow_types::extract::byte_offset_to_line_col(&module.line_offsets, start);
        let (end_line, _) =
            fallow_types::extract::byte_offset_to_line_col(&module.line_offsets, end);
        (Some(start_line), Some(end_line))
    } else {
        (None, None)
    };

    FeatureFlag {
        path: path.to_path_buf(),
        flag_name: flag_use.flag_name.clone(),
        kind,
        confidence,
        line: flag_use.line,
        col: flag_use.col,
        guard_span_start: flag_use.guard_span_start,
        guard_span_end: flag_use.guard_span_end,
        sdk_name: flag_use.sdk_name.clone(),
        guard_line_start,
        guard_line_end,
        guarded_dead_exports: Vec::new(),
    }
}

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

    #[test]
    fn session_runner_uses_session_discovery_instead_of_rediscovering() {
        let project = tempfile::tempdir().expect("temp dir");
        let root = project.path();
        std::fs::create_dir(root.join("src")).expect("src dir");
        std::fs::write(
            root.join("package.json"),
            r#"{"name":"flags-session","main":"src/index.ts"}"#,
        )
        .expect("package json");
        std::fs::write(
            root.join("src/index.ts"),
            "if (process.env.FEATURE_EXISTING) {}\n",
        )
        .expect("initial source");

        let session = AnalysisSession::load(root, None).expect("session loads");

        std::fs::write(
            root.join("src/late.ts"),
            "if (process.env.FEATURE_LATE) {}\n",
        )
        .expect("late source");

        let session_flags = analyze_feature_flags_with_session(&session);
        let session_names: Vec<_> = session_flags
            .flags
            .iter()
            .map(|flag| flag.flag_name.as_str())
            .collect();
        assert_eq!(session_names, vec!["FEATURE_EXISTING"]);

        let second_session_flags = analyze_feature_flags_with_session(&session);
        let second_session_names: Vec<_> = second_session_flags
            .flags
            .iter()
            .map(|flag| flag.flag_name.as_str())
            .collect();
        assert_eq!(second_session_names, vec!["FEATURE_EXISTING"]);
    }
}