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