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    /// Detected feature flags with their usage sites and confidence.
18    pub flags: Vec<FeatureFlag>,
19    /// Number of files the flag scan covered.
20    pub files_scanned: usize,
21}
22
23/// Run feature flag analysis with a reusable analysis session.
24///
25/// # Errors
26///
27/// Returns [`crate::EngineError::cancelled`] when the session's caller
28/// cancelled the run. The scan spends its time in the parse loop and in the
29/// dead-code correlation behind it, and both observe the token. A session
30/// without a cancellation token can never return this error.
31pub fn analyze_feature_flags_with_session(
32    session: &AnalysisSession,
33) -> crate::EngineResult<FeatureFlagsAnalysis> {
34    let modules = session.shared_parsed_modules_cancellable(false, "the feature-flag scan")?;
35    let flags = collect_flags_for_modules(session, session.files(), &modules)?;
36    Ok(FeatureFlagsAnalysis {
37        flags,
38        files_scanned: session.files().len(),
39    })
40}
41
42/// Run feature flag analysis while reusing dead-code results from the same
43/// session.
44///
45/// Compound surfaces such as `fallow viz` use this path to avoid rebuilding
46/// the module graph solely to correlate guarded dead exports.
47#[must_use]
48pub fn analyze_feature_flags_with_session_and_results(
49    session: &AnalysisSession,
50    results: &AnalysisResults,
51) -> FeatureFlagsAnalysis {
52    let modules = session.shared_parsed_modules(false);
53    let mut flags = collect_flags_from_modules(session.config(), session.files(), &modules);
54    correlate_with_dead_code(&mut flags, results);
55    FeatureFlagsAnalysis {
56        flags,
57        files_scanned: session.files().len(),
58    }
59}
60
61/// Built-in environment variable prefixes treated as feature flags.
62#[must_use]
63pub fn builtin_env_prefixes() -> &'static [&'static str] {
64    crate::feature_flags::builtin_env_prefixes()
65}
66
67/// Distinct built-in SDK provider labels, in declaration order.
68#[must_use]
69pub fn builtin_sdk_providers() -> Vec<&'static str> {
70    crate::feature_flags::builtin_sdk_providers()
71}
72
73fn collect_flags_for_modules(
74    session: &AnalysisSession,
75    files: &[DiscoveredFile],
76    modules: &Arc<[ModuleInfo]>,
77) -> crate::EngineResult<Vec<FeatureFlag>> {
78    let mut flags = collect_flags_from_modules(session.config(), files, modules);
79    correlate_flags_with_dead_code(&mut flags, session, modules)?;
80    Ok(flags)
81}
82
83fn correlate_flags_with_dead_code(
84    flags: &mut [FeatureFlag],
85    session: &AnalysisSession,
86    modules: &Arc<[ModuleInfo]>,
87) -> crate::EngineResult<()> {
88    match session.analyze_dead_code_with_shared_modules(Arc::clone(modules)) {
89        Ok(analysis_output) => correlate_with_dead_code(flags, &analysis_output.results),
90        // Correlation only enriches the flags, so a broken dead-code pass
91        // leaves them uncorrelated rather than failing the scan. A cancelled
92        // one is not a failure to enrich, it is the caller asking to stop.
93        Err(err) if err.is_cancelled() => return Err(err),
94        Err(_) => {}
95    }
96    Ok(())
97}
98
99fn correlate_with_dead_code(flags: &mut [FeatureFlag], results: &AnalysisResults) {
100    if results.unused_exports.is_empty() && results.unused_types.is_empty() {
101        return;
102    }
103
104    for flag in flags.iter_mut() {
105        let (Some(guard_start), Some(guard_end)) = (flag.guard_line_start, flag.guard_line_end)
106        else {
107            continue;
108        };
109
110        for export in &results.unused_exports {
111            if export.export.path == flag.path
112                && export.export.line >= guard_start
113                && export.export.line <= guard_end
114            {
115                flag.guarded_dead_exports
116                    .push(export.export.export_name.clone());
117            }
118        }
119
120        for export in &results.unused_types {
121            if export.export.path == flag.path
122                && export.export.line >= guard_start
123                && export.export.line <= guard_end
124            {
125                flag.guarded_dead_exports
126                    .push(export.export.export_name.clone());
127            }
128        }
129    }
130}
131
132fn collect_flags_from_modules(
133    config: &ResolvedConfig,
134    files: &[DiscoveredFile],
135    modules: &[ModuleInfo],
136) -> Vec<FeatureFlag> {
137    let file_paths: FxHashMap<_, _> = files.iter().map(|file| (file.id, &file.path)).collect();
138
139    let extra_sdk: Vec<(String, usize, String)> = config
140        .flags
141        .sdk_patterns
142        .iter()
143        .map(|pattern| {
144            (
145                pattern.function.clone(),
146                pattern.name_arg,
147                pattern.provider.clone().unwrap_or_default(),
148            )
149        })
150        .collect();
151    let has_custom_config = !extra_sdk.is_empty()
152        || !config.flags.env_prefixes.is_empty()
153        || config.flags.config_object_heuristics;
154
155    let mut flags = Vec::new();
156    for module in modules {
157        let Some(path) = file_paths.get(&module.file_id) else {
158            continue;
159        };
160
161        collect_builtin_flags(&mut flags, module, path);
162        if has_custom_config {
163            collect_custom_flags(&mut flags, config, module, path, &extra_sdk);
164        }
165    }
166    flags
167}
168
169fn collect_builtin_flags(flags: &mut Vec<FeatureFlag>, module: &ModuleInfo, path: &Path) {
170    let file_suppressed = is_file_suppressed(&module.suppressions, IssueKind::FeatureFlag);
171    for flag_use in &module.flag_uses {
172        if file_suppressed
173            || is_suppressed(&module.suppressions, flag_use.line, IssueKind::FeatureFlag)
174        {
175            continue;
176        }
177        flags.push(flag_use_to_feature_flag(flag_use, module, path));
178    }
179}
180
181fn collect_custom_flags(
182    flags: &mut Vec<FeatureFlag>,
183    config: &ResolvedConfig,
184    module: &ModuleInfo,
185    path: &Path,
186    extra_sdk: &[(String, usize, String)],
187) {
188    let Ok(source) = std::fs::read_to_string(path) else {
189        return;
190    };
191
192    let custom_flags = crate::feature_flags::extract_flags_from_source(
193        &source,
194        path,
195        extra_sdk,
196        &config.flags.env_prefixes,
197        config.flags.config_object_heuristics,
198    );
199    for flag_use in &custom_flags {
200        let already_found = module.flag_uses.iter().any(|existing| {
201            existing.line == flag_use.line && existing.flag_name == flag_use.flag_name
202        });
203        if !already_found
204            && !is_suppressed(&module.suppressions, flag_use.line, IssueKind::FeatureFlag)
205        {
206            flags.push(flag_use_to_feature_flag(flag_use, module, path));
207        }
208    }
209}
210
211fn flag_use_to_feature_flag(flag_use: &FlagUse, module: &ModuleInfo, path: &Path) -> FeatureFlag {
212    let (kind, confidence) = match flag_use.kind {
213        FlagUseKind::EnvVar => (FlagKind::EnvironmentVariable, FlagConfidence::High),
214        FlagUseKind::SdkCall => (FlagKind::SdkCall, FlagConfidence::High),
215        FlagUseKind::ConfigObject => (FlagKind::ConfigObject, FlagConfidence::Low),
216    };
217
218    let (guard_line_start, guard_line_end) = if let (Some(start), Some(end)) =
219        (flag_use.guard_span_start, flag_use.guard_span_end)
220        && !module.line_offsets.is_empty()
221    {
222        let (start_line, _) =
223            fallow_types::extract::byte_offset_to_line_col(&module.line_offsets, start);
224        let (end_line, _) =
225            fallow_types::extract::byte_offset_to_line_col(&module.line_offsets, end);
226        (Some(start_line), Some(end_line))
227    } else {
228        (None, None)
229    };
230
231    FeatureFlag {
232        path: path.to_path_buf(),
233        flag_name: flag_use.flag_name.clone(),
234        kind,
235        confidence,
236        line: flag_use.line,
237        col: flag_use.col,
238        guard_span_start: flag_use.guard_span_start,
239        guard_span_end: flag_use.guard_span_end,
240        sdk_name: flag_use.sdk_name.clone(),
241        guard_line_start,
242        guard_line_end,
243        guarded_dead_exports: Vec::new(),
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn session_runner_uses_session_discovery_instead_of_rediscovering() {
253        let project = tempfile::tempdir().expect("temp dir");
254        let root = project.path();
255        std::fs::create_dir(root.join("src")).expect("src dir");
256        std::fs::write(
257            root.join("package.json"),
258            r#"{"name":"flags-session","main":"src/index.ts"}"#,
259        )
260        .expect("package json");
261        std::fs::write(
262            root.join("src/index.ts"),
263            "if (process.env.FEATURE_EXISTING) {}\n",
264        )
265        .expect("initial source");
266
267        let session = AnalysisSession::load(root, None).expect("session loads");
268
269        std::fs::write(
270            root.join("src/late.ts"),
271            "if (process.env.FEATURE_LATE) {}\n",
272        )
273        .expect("late source");
274
275        let session_flags =
276            analyze_feature_flags_with_session(&session).expect("session flag scan");
277        let session_names: Vec<_> = session_flags
278            .flags
279            .iter()
280            .map(|flag| flag.flag_name.as_str())
281            .collect();
282        assert_eq!(session_names, vec!["FEATURE_EXISTING"]);
283
284        let second_session_flags =
285            analyze_feature_flags_with_session(&session).expect("second session flag scan");
286        let second_session_names: Vec<_> = second_session_flags
287            .flags
288            .iter()
289            .map(|flag| flag.flag_name.as_str())
290            .collect();
291        assert_eq!(second_session_names, vec!["FEATURE_EXISTING"]);
292    }
293}