Skip to main content

fallow_engine/
flags.rs

1//! Feature flag analysis owned by the engine boundary.
2
3use std::path::Path;
4
5use fallow_config::ResolvedConfig;
6use fallow_types::discover::DiscoveredFile;
7use fallow_types::extract::{FlagUse, FlagUseKind, ModuleInfo};
8use fallow_types::results::{AnalysisResults, FeatureFlag, FlagConfidence, FlagKind};
9use rustc_hash::FxHashMap;
10
11use crate::session::AnalysisSession;
12use crate::suppress::{IssueKind, is_file_suppressed, is_suppressed};
13
14/// Typed result from running feature flag analysis.
15#[derive(Debug, Clone)]
16pub struct FeatureFlagsAnalysis {
17    pub flags: Vec<FeatureFlag>,
18    pub files_scanned: usize,
19}
20
21/// Run feature flag analysis with a reusable analysis session.
22#[must_use]
23pub fn analyze_feature_flags_with_session(session: &AnalysisSession) -> FeatureFlagsAnalysis {
24    let parsed = session.parsed_parts(false);
25    let flags = collect_flags_for_modules(session, &parsed.files, &parsed.modules);
26    FeatureFlagsAnalysis {
27        flags,
28        files_scanned: parsed.files.len(),
29    }
30}
31
32/// Built-in environment variable prefixes treated as feature flags.
33#[must_use]
34pub fn builtin_env_prefixes() -> &'static [&'static str] {
35    crate::feature_flags::builtin_env_prefixes()
36}
37
38/// Distinct built-in SDK provider labels, in declaration order.
39#[must_use]
40pub fn builtin_sdk_providers() -> Vec<&'static str> {
41    crate::feature_flags::builtin_sdk_providers()
42}
43
44fn collect_flags_for_modules(
45    session: &AnalysisSession,
46    files: &[DiscoveredFile],
47    modules: &[ModuleInfo],
48) -> Vec<FeatureFlag> {
49    let mut flags = collect_flags_from_modules(session.config(), files, modules);
50    correlate_flags_with_dead_code(&mut flags, session, modules);
51    flags
52}
53
54fn correlate_flags_with_dead_code(
55    flags: &mut [FeatureFlag],
56    session: &AnalysisSession,
57    modules: &[ModuleInfo],
58) {
59    if let Ok(analysis_output) = session.analyze_dead_code_with_parsed_modules(modules) {
60        correlate_with_dead_code(flags, &analysis_output.results);
61    }
62}
63
64fn correlate_with_dead_code(flags: &mut [FeatureFlag], results: &AnalysisResults) {
65    if results.unused_exports.is_empty() && results.unused_types.is_empty() {
66        return;
67    }
68
69    for flag in flags.iter_mut() {
70        let (Some(guard_start), Some(guard_end)) = (flag.guard_line_start, flag.guard_line_end)
71        else {
72            continue;
73        };
74
75        for export in &results.unused_exports {
76            if export.export.path == flag.path
77                && export.export.line >= guard_start
78                && export.export.line <= guard_end
79            {
80                flag.guarded_dead_exports
81                    .push(export.export.export_name.clone());
82            }
83        }
84
85        for export in &results.unused_types {
86            if export.export.path == flag.path
87                && export.export.line >= guard_start
88                && export.export.line <= guard_end
89            {
90                flag.guarded_dead_exports
91                    .push(export.export.export_name.clone());
92            }
93        }
94    }
95}
96
97fn collect_flags_from_modules(
98    config: &ResolvedConfig,
99    files: &[DiscoveredFile],
100    modules: &[ModuleInfo],
101) -> Vec<FeatureFlag> {
102    let file_paths: FxHashMap<_, _> = files.iter().map(|file| (file.id, &file.path)).collect();
103
104    let extra_sdk: Vec<(String, usize, String)> = config
105        .flags
106        .sdk_patterns
107        .iter()
108        .map(|pattern| {
109            (
110                pattern.function.clone(),
111                pattern.name_arg,
112                pattern.provider.clone().unwrap_or_default(),
113            )
114        })
115        .collect();
116    let has_custom_config = !extra_sdk.is_empty()
117        || !config.flags.env_prefixes.is_empty()
118        || config.flags.config_object_heuristics;
119
120    let mut flags = Vec::new();
121    for module in modules {
122        let Some(path) = file_paths.get(&module.file_id) else {
123            continue;
124        };
125
126        collect_builtin_flags(&mut flags, module, path);
127        if has_custom_config {
128            collect_custom_flags(&mut flags, config, module, path, &extra_sdk);
129        }
130    }
131    flags
132}
133
134fn collect_builtin_flags(flags: &mut Vec<FeatureFlag>, module: &ModuleInfo, path: &Path) {
135    let file_suppressed = is_file_suppressed(&module.suppressions, IssueKind::FeatureFlag);
136    for flag_use in &module.flag_uses {
137        if file_suppressed
138            || is_suppressed(&module.suppressions, flag_use.line, IssueKind::FeatureFlag)
139        {
140            continue;
141        }
142        flags.push(flag_use_to_feature_flag(flag_use, module, path));
143    }
144}
145
146fn collect_custom_flags(
147    flags: &mut Vec<FeatureFlag>,
148    config: &ResolvedConfig,
149    module: &ModuleInfo,
150    path: &Path,
151    extra_sdk: &[(String, usize, String)],
152) {
153    let Ok(source) = std::fs::read_to_string(path) else {
154        return;
155    };
156
157    let custom_flags = crate::feature_flags::extract_flags_from_source(
158        &source,
159        path,
160        extra_sdk,
161        &config.flags.env_prefixes,
162        config.flags.config_object_heuristics,
163    );
164    for flag_use in &custom_flags {
165        let already_found = module.flag_uses.iter().any(|existing| {
166            existing.line == flag_use.line && existing.flag_name == flag_use.flag_name
167        });
168        if !already_found
169            && !is_suppressed(&module.suppressions, flag_use.line, IssueKind::FeatureFlag)
170        {
171            flags.push(flag_use_to_feature_flag(flag_use, module, path));
172        }
173    }
174}
175
176fn flag_use_to_feature_flag(flag_use: &FlagUse, module: &ModuleInfo, path: &Path) -> FeatureFlag {
177    let (kind, confidence) = match flag_use.kind {
178        FlagUseKind::EnvVar => (FlagKind::EnvironmentVariable, FlagConfidence::High),
179        FlagUseKind::SdkCall => (FlagKind::SdkCall, FlagConfidence::High),
180        FlagUseKind::ConfigObject => (FlagKind::ConfigObject, FlagConfidence::Low),
181    };
182
183    let (guard_line_start, guard_line_end) = if let (Some(start), Some(end)) =
184        (flag_use.guard_span_start, flag_use.guard_span_end)
185        && !module.line_offsets.is_empty()
186    {
187        let (start_line, _) =
188            fallow_types::extract::byte_offset_to_line_col(&module.line_offsets, start);
189        let (end_line, _) =
190            fallow_types::extract::byte_offset_to_line_col(&module.line_offsets, end);
191        (Some(start_line), Some(end_line))
192    } else {
193        (None, None)
194    };
195
196    FeatureFlag {
197        path: path.to_path_buf(),
198        flag_name: flag_use.flag_name.clone(),
199        kind,
200        confidence,
201        line: flag_use.line,
202        col: flag_use.col,
203        guard_span_start: flag_use.guard_span_start,
204        guard_span_end: flag_use.guard_span_end,
205        sdk_name: flag_use.sdk_name.clone(),
206        guard_line_start,
207        guard_line_end,
208        guarded_dead_exports: Vec::new(),
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn session_runner_uses_session_discovery_instead_of_rediscovering() {
218        let project = tempfile::tempdir().expect("temp dir");
219        let root = project.path();
220        std::fs::create_dir(root.join("src")).expect("src dir");
221        std::fs::write(
222            root.join("package.json"),
223            r#"{"name":"flags-session","main":"src/index.ts"}"#,
224        )
225        .expect("package json");
226        std::fs::write(
227            root.join("src/index.ts"),
228            "if (process.env.FEATURE_EXISTING) {}\n",
229        )
230        .expect("initial source");
231
232        let session = AnalysisSession::load(root, None).expect("session loads");
233
234        std::fs::write(
235            root.join("src/late.ts"),
236            "if (process.env.FEATURE_LATE) {}\n",
237        )
238        .expect("late source");
239
240        let session_flags = analyze_feature_flags_with_session(&session);
241        let session_names: Vec<_> = session_flags
242            .flags
243            .iter()
244            .map(|flag| flag.flag_name.as_str())
245            .collect();
246        assert_eq!(session_names, vec!["FEATURE_EXISTING"]);
247
248        let second_session_flags = analyze_feature_flags_with_session(&session);
249        let second_session_names: Vec<_> = second_session_flags
250            .flags
251            .iter()
252            .map(|flag| flag.flag_name.as_str())
253            .collect();
254        assert_eq!(second_session_names, vec!["FEATURE_EXISTING"]);
255    }
256}