Skip to main content

fallow_engine/
flags.rs

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