Skip to main content

fallow_extract/
flags.rs

1//! Feature flag detection via lightweight Oxc AST visitor.
2//!
3//! Detects three patterns:
4//! 1. **Environment variables**: `process.env.FEATURE_X`
5//! 2. **SDK calls**: `useFlag('name')`, `variation('name', false)`, etc.
6//! 3. **Config objects**: `config.features.x` (opt-in, heuristic)
7//!
8//! Always extracted during parse (lightweight pattern matching on `MemberExpression`
9//! and `CallExpression` nodes). Custom SDK patterns and config object heuristics
10//! are applied as a supplementary pass in the CLI when user config is present.
11
12#[allow(clippy::wildcard_imports, reason = "many AST types used")]
13use oxc_ast::ast::*;
14use oxc_ast_visit::Visit;
15use oxc_ast_visit::walk;
16
17use fallow_types::extract::{FlagUse, FlagUseKind, byte_offset_to_line_col};
18
19/// Built-in SDK function patterns: (function_name, name_arg_index, provider_label).
20const BUILTIN_SDK_PATTERNS: &[(&str, usize, &str)] = &[
21    // LaunchDarkly
22    ("useFlag", 0, "LaunchDarkly"),
23    ("useLDFlag", 0, "LaunchDarkly"),
24    ("useFeatureFlag", 0, "LaunchDarkly"),
25    ("variation", 0, "LaunchDarkly"),
26    ("boolVariation", 0, "LaunchDarkly"),
27    ("stringVariation", 0, "LaunchDarkly"),
28    ("numberVariation", 0, "LaunchDarkly"),
29    ("jsonVariation", 0, "LaunchDarkly"),
30    // Statsig
31    ("useGate", 0, "Statsig"),
32    ("checkGate", 0, "Statsig"),
33    ("useExperiment", 0, "Statsig"),
34    ("useConfig", 0, "Statsig"),
35    // Unleash
36    ("isEnabled", 0, "Unleash"),
37    ("getVariant", 0, "Unleash"),
38    // GrowthBook
39    ("isOn", 0, "GrowthBook"),
40    ("isOff", 0, "GrowthBook"),
41    ("getFeatureValue", 0, "GrowthBook"),
42    // Split
43    ("getTreatment", 0, "Split"),
44    // ConfigCat
45    ("getValueAsync", 0, "ConfigCat"),
46    // Flagsmith
47    ("hasFeature", 0, "Flagsmith"),
48    // Shared: getValue is used by both ConfigCat and Flagsmith.
49    // Attribution is best-effort when function names collide.
50    ("getValue", 0, ""),
51    // Generic
52    ("useFeature", 0, ""),
53    ("getFeatureFlag", 0, ""),
54];
55
56/// Built-in environment variable prefixes that indicate feature flags.
57const BUILTIN_ENV_PREFIXES: &[&str] = &[
58    "FEATURE_",
59    "NEXT_PUBLIC_FEATURE_",
60    "NEXT_PUBLIC_ENABLE_",
61    "REACT_APP_FEATURE_",
62    "REACT_APP_ENABLE_",
63    "VITE_FEATURE_",
64    "VITE_ENABLE_",
65    "NUXT_PUBLIC_FEATURE_",
66    "ENABLE_",
67    "FF_",
68    "FLAG_",
69    "TOGGLE_",
70];
71
72/// Config object names that heuristically indicate feature flag namespaces.
73const CONFIG_OBJECT_KEYWORDS: &[&str] = &[
74    "feature",
75    "features",
76    "featureFlags",
77    "featureFlag",
78    "flag",
79    "flags",
80    "toggle",
81    "toggles",
82];
83
84/// AST visitor that detects feature flag patterns.
85struct FlagVisitor<'a> {
86    results: Vec<FlagUse>,
87    line_offsets: &'a [u32],
88    /// Extra SDK patterns from user config.
89    extra_sdk_patterns: &'a [(String, usize, String)],
90    /// Extra env prefixes from user config.
91    extra_env_prefixes: &'a [String],
92    /// Whether to detect config object patterns (opt-in).
93    config_object_heuristics: bool,
94}
95
96impl<'a> FlagVisitor<'a> {
97    fn new(
98        line_offsets: &'a [u32],
99        extra_sdk_patterns: &'a [(String, usize, String)],
100        extra_env_prefixes: &'a [String],
101        config_object_heuristics: bool,
102    ) -> Self {
103        Self {
104            results: Vec::new(),
105            line_offsets,
106            extra_sdk_patterns,
107            extra_env_prefixes,
108            config_object_heuristics,
109        }
110    }
111
112    /// Check if a member expression matches `process.env.SOMETHING`.
113    fn check_env_var(&mut self, expr: &MemberExpression<'_>, guard: Option<(u32, u32)>) {
114        // Match: process.env.X (static member)
115        if let MemberExpression::StaticMemberExpression(static_expr) = expr
116            && let Some(env_name) = extract_process_env_name(static_expr)
117            && self.is_flag_env_name(&env_name)
118        {
119            let (line, col) = byte_offset_to_line_col(self.line_offsets, static_expr.span.start);
120            self.results.push(FlagUse {
121                flag_name: env_name,
122                kind: FlagUseKind::EnvVar,
123                line,
124                col,
125                guard_span_start: guard.map(|(s, _)| s),
126                guard_span_end: guard.map(|(_, e)| e),
127                sdk_name: None,
128            });
129        }
130    }
131
132    /// Check if a call expression matches an SDK pattern.
133    fn check_sdk_call(&mut self, call: &CallExpression<'_>, guard: Option<(u32, u32)>) {
134        let func_name = match &call.callee {
135            Expression::Identifier(id) => Some(id.name.as_str()),
136            Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()),
137            _ => None,
138        };
139
140        let Some(func_name) = func_name else {
141            return;
142        };
143
144        // Check built-in patterns
145        for &(pattern_name, name_arg_idx, provider) in BUILTIN_SDK_PATTERNS {
146            if func_name == pattern_name {
147                if let Some(flag_name) = extract_string_arg(&call.arguments, name_arg_idx) {
148                    let (line, col) = byte_offset_to_line_col(self.line_offsets, call.span.start);
149                    self.results.push(FlagUse {
150                        flag_name,
151                        kind: FlagUseKind::SdkCall,
152                        line,
153                        col,
154                        guard_span_start: guard.map(|(s, _)| s),
155                        guard_span_end: guard.map(|(_, e)| e),
156                        sdk_name: if provider.is_empty() {
157                            None
158                        } else {
159                            Some(provider.to_string())
160                        },
161                    });
162                }
163                return;
164            }
165        }
166
167        // Check user-configured extra patterns
168        for (pattern_name, name_arg_idx, provider) in self.extra_sdk_patterns {
169            if func_name == pattern_name {
170                if let Some(flag_name) = extract_string_arg(&call.arguments, *name_arg_idx) {
171                    let (line, col) = byte_offset_to_line_col(self.line_offsets, call.span.start);
172                    self.results.push(FlagUse {
173                        flag_name,
174                        kind: FlagUseKind::SdkCall,
175                        line,
176                        col,
177                        guard_span_start: guard.map(|(s, _)| s),
178                        guard_span_end: guard.map(|(_, e)| e),
179                        sdk_name: if provider.is_empty() {
180                            None
181                        } else {
182                            Some(provider.clone())
183                        },
184                    });
185                }
186                return;
187            }
188        }
189    }
190
191    /// Check if a member expression matches a config object pattern.
192    fn check_config_object(
193        &mut self,
194        expr: &StaticMemberExpression<'_>,
195        guard: Option<(u32, u32)>,
196    ) {
197        if !self.config_object_heuristics {
198            return;
199        }
200
201        // Look for patterns like config.features.x, flags.enableNewFeature
202        // The object must contain a keyword from CONFIG_OBJECT_KEYWORDS
203        if let Some((obj_name, prop_name)) = extract_config_object_access(expr)
204            && CONFIG_OBJECT_KEYWORDS
205                .iter()
206                .any(|kw| obj_name.eq_ignore_ascii_case(kw) || prop_name.eq_ignore_ascii_case(kw))
207        {
208            let (line, col) = byte_offset_to_line_col(self.line_offsets, expr.span.start);
209            self.results.push(FlagUse {
210                flag_name: format!("{obj_name}.{prop_name}"),
211                kind: FlagUseKind::ConfigObject,
212                line,
213                col,
214                guard_span_start: guard.map(|(s, _)| s),
215                guard_span_end: guard.map(|(_, e)| e),
216                sdk_name: None,
217            });
218        }
219    }
220
221    fn is_flag_env_name(&self, name: &str) -> bool {
222        for prefix in BUILTIN_ENV_PREFIXES {
223            if name.starts_with(prefix) {
224                return true;
225            }
226        }
227        for prefix in self.extra_env_prefixes {
228            if name.starts_with(prefix.as_str()) {
229                return true;
230            }
231        }
232        false
233    }
234}
235
236impl Visit<'_> for FlagVisitor<'_> {
237    fn visit_if_statement(&mut self, stmt: &IfStatement<'_>) {
238        let guard = Some((stmt.span.start, stmt.span.end));
239
240        // Check the test expression for flag patterns (with guard context)
241        check_expression_for_flags(self, &stmt.test, guard);
242
243        // Visit consequent and alternate, but NOT the test expression again
244        self.visit_statement(&stmt.consequent);
245        if let Some(alt) = &stmt.alternate {
246            self.visit_statement(alt);
247        }
248    }
249
250    fn visit_conditional_expression(&mut self, expr: &ConditionalExpression<'_>) {
251        let guard = Some((expr.span.start, expr.span.end));
252        check_expression_for_flags(self, &expr.test, guard);
253
254        // Visit consequent and alternate, but NOT the test expression again
255        self.visit_expression(&expr.consequent);
256        self.visit_expression(&expr.alternate);
257    }
258
259    fn visit_call_expression(&mut self, call: &CallExpression<'_>) {
260        self.check_sdk_call(call, None);
261        walk::walk_call_expression(self, call);
262    }
263
264    fn visit_member_expression(&mut self, expr: &MemberExpression<'_>) {
265        self.check_env_var(expr, None);
266        if let MemberExpression::StaticMemberExpression(static_expr) = expr {
267            self.check_config_object(static_expr, None);
268        }
269        walk::walk_member_expression(self, expr);
270    }
271}
272
273/// Check an expression (typically an if-test) for flag patterns.
274fn check_expression_for_flags(
275    visitor: &mut FlagVisitor<'_>,
276    expr: &Expression<'_>,
277    guard: Option<(u32, u32)>,
278) {
279    match expr {
280        Expression::CallExpression(call) => {
281            visitor.check_sdk_call(call, guard);
282        }
283        Expression::StaticMemberExpression(member) => {
284            check_static_member_for_env(visitor, member, guard);
285            visitor.check_config_object(member, guard);
286        }
287        Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::LogicalNot => {
288            check_expression_for_flags(visitor, &unary.argument, guard);
289        }
290        Expression::LogicalExpression(logical) => {
291            check_expression_for_flags(visitor, &logical.left, guard);
292            check_expression_for_flags(visitor, &logical.right, guard);
293        }
294        _ => {}
295    }
296}
297
298/// Check a static member expression directly for `process.env.X` pattern.
299fn check_static_member_for_env(
300    visitor: &mut FlagVisitor<'_>,
301    expr: &StaticMemberExpression<'_>,
302    guard: Option<(u32, u32)>,
303) {
304    if let Some(env_name) = extract_process_env_name(expr)
305        && visitor.is_flag_env_name(&env_name)
306    {
307        let (line, col) = byte_offset_to_line_col(visitor.line_offsets, expr.span.start);
308        visitor.results.push(FlagUse {
309            flag_name: env_name,
310            kind: FlagUseKind::EnvVar,
311            line,
312            col,
313            guard_span_start: guard.map(|(s, _)| s),
314            guard_span_end: guard.map(|(_, e)| e),
315            sdk_name: None,
316        });
317    }
318}
319
320/// Extract the environment variable name from `process.env.X`.
321fn extract_process_env_name(expr: &StaticMemberExpression<'_>) -> Option<String> {
322    // Match: process.env.SOMETHING
323    let prop_name = expr.property.name.as_str();
324
325    if let Expression::StaticMemberExpression(inner) = &expr.object
326        && inner.property.name.as_str() == "env"
327        && let Expression::Identifier(id) = &inner.object
328        && id.name.as_str() == "process"
329    {
330        return Some(prop_name.to_string());
331    }
332
333    None
334}
335
336/// Extract a string literal argument at the given index.
337fn extract_string_arg(args: &[Argument<'_>], index: usize) -> Option<String> {
338    args.get(index).and_then(|arg| {
339        if let Argument::StringLiteral(lit) = arg {
340            Some(lit.value.to_string())
341        } else {
342            None
343        }
344    })
345}
346
347/// Extract config object access pattern: `obj.prop` where either name is a flag keyword.
348fn extract_config_object_access(expr: &StaticMemberExpression<'_>) -> Option<(String, String)> {
349    let prop_name = expr.property.name.to_string();
350
351    match &expr.object {
352        Expression::Identifier(id) => Some((id.name.to_string(), prop_name)),
353        Expression::StaticMemberExpression(inner) => {
354            if matches!(&inner.object, Expression::Identifier(_)) {
355                // Two-level: config.features.x -> obj="features", prop="x"
356                Some((inner.property.name.to_string(), prop_name))
357            } else {
358                None
359            }
360        }
361        _ => None,
362    }
363}
364
365/// Entry point: extract feature flag use sites from a parsed program.
366///
367/// Called unconditionally from `parse_source_to_module` for all parsed files.
368pub fn extract_flags(
369    program: &Program<'_>,
370    line_offsets: &[u32],
371    extra_sdk_patterns: &[(String, usize, String)],
372    extra_env_prefixes: &[String],
373    config_object_heuristics: bool,
374) -> Vec<FlagUse> {
375    let mut visitor = FlagVisitor::new(
376        line_offsets,
377        extra_sdk_patterns,
378        extra_env_prefixes,
379        config_object_heuristics,
380    );
381    visitor.visit_program(program);
382    visitor.results
383}
384
385/// Extract feature flags from source text with custom configuration.
386///
387/// Higher-level convenience function that handles parsing internally.
388/// Used by the CLI flags command for supplementary extraction with
389/// user-configured patterns that aren't applied at parse/cache time.
390pub fn extract_flags_from_source(
391    source: &str,
392    path: &std::path::Path,
393    extra_sdk_patterns: &[(String, usize, String)],
394    extra_env_prefixes: &[String],
395    config_object_heuristics: bool,
396) -> Vec<FlagUse> {
397    let source_type = oxc_span::SourceType::from_path(path).unwrap_or_default();
398    let allocator = oxc_allocator::Allocator::default();
399    let parser_return = oxc_parser::Parser::new(&allocator, source, source_type).parse();
400    let line_offsets = fallow_types::extract::compute_line_offsets(source);
401    extract_flags(
402        &parser_return.program,
403        &line_offsets,
404        extra_sdk_patterns,
405        extra_env_prefixes,
406        config_object_heuristics,
407    )
408}
409
410#[cfg(all(test, not(miri)))]
411mod tests {
412    use super::*;
413    use oxc_allocator::Allocator;
414    use oxc_parser::Parser;
415    use oxc_span::SourceType;
416
417    fn extract_from_source(source: &str) -> Vec<FlagUse> {
418        let allocator = Allocator::default();
419        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
420        let line_offsets = fallow_types::extract::compute_line_offsets(source);
421        extract_flags(&parser_return.program, &line_offsets, &[], &[], false)
422    }
423
424    fn extract_with_config_objects(source: &str) -> Vec<FlagUse> {
425        let allocator = Allocator::default();
426        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
427        let line_offsets = fallow_types::extract::compute_line_offsets(source);
428        extract_flags(&parser_return.program, &line_offsets, &[], &[], true)
429    }
430
431    // ── Environment variable detection ──────────────────────────────
432
433    #[test]
434    fn detects_process_env_feature_flag() {
435        let flags = extract_from_source("if (process.env.FEATURE_NEW_CHECKOUT) { doStuff(); }");
436        assert_eq!(flags.len(), 1);
437        assert_eq!(flags[0].flag_name, "FEATURE_NEW_CHECKOUT");
438        assert_eq!(flags[0].kind, FlagUseKind::EnvVar);
439        assert!(flags[0].guard_span_start.is_some());
440    }
441
442    #[test]
443    fn detects_next_public_enable_prefix() {
444        let flags = extract_from_source("if (process.env.NEXT_PUBLIC_ENABLE_BETA) {}");
445        assert_eq!(flags.len(), 1);
446        assert_eq!(flags[0].flag_name, "NEXT_PUBLIC_ENABLE_BETA");
447    }
448
449    #[test]
450    fn ignores_non_flag_env_vars() {
451        let flags = extract_from_source("const url = process.env.DATABASE_URL;");
452        assert!(flags.is_empty());
453    }
454
455    #[test]
456    fn detects_negated_env_flag() {
457        let flags = extract_from_source("if (!process.env.FEATURE_X) { fallback(); }");
458        assert_eq!(flags.len(), 1);
459        assert_eq!(flags[0].flag_name, "FEATURE_X");
460    }
461
462    // ── SDK call detection ──────────────────────────────────────────
463
464    #[test]
465    fn detects_launchdarkly_use_flag() {
466        let flags = extract_from_source("const flag = useFlag('new-checkout');");
467        assert_eq!(flags.len(), 1);
468        assert_eq!(flags[0].flag_name, "new-checkout");
469        assert_eq!(flags[0].kind, FlagUseKind::SdkCall);
470        assert_eq!(flags[0].sdk_name.as_deref(), Some("LaunchDarkly"));
471    }
472
473    #[test]
474    fn detects_statsig_use_gate() {
475        let flags = extract_from_source("if (useGate('beta-feature')) {}");
476        assert_eq!(flags.len(), 1);
477        assert_eq!(flags[0].flag_name, "beta-feature");
478        assert_eq!(flags[0].sdk_name.as_deref(), Some("Statsig"));
479    }
480
481    #[test]
482    fn detects_unleash_is_enabled() {
483        let flags = extract_from_source("client.isEnabled('feature-x')");
484        assert_eq!(flags.len(), 1);
485        assert_eq!(flags[0].flag_name, "feature-x");
486    }
487
488    #[test]
489    fn detects_growthbook_get_feature_value() {
490        let flags = extract_from_source("const val = getFeatureValue('parser', false);");
491        assert_eq!(flags.len(), 1);
492        assert_eq!(flags[0].flag_name, "parser");
493        assert_eq!(flags[0].sdk_name.as_deref(), Some("GrowthBook"));
494    }
495
496    #[test]
497    fn ignores_sdk_call_without_string_arg() {
498        let flags = extract_from_source("useFlag(dynamicKey);");
499        assert!(flags.is_empty());
500    }
501
502    // ── Config object detection (opt-in) ────────────────────────────
503
504    #[test]
505    fn config_objects_off_by_default() {
506        let flags = extract_from_source("if (config.features.newCheckout) {}");
507        assert!(flags.is_empty());
508    }
509
510    #[test]
511    fn detects_config_features_when_enabled() {
512        let flags = extract_with_config_objects("if (config.features.newCheckout) {}");
513        assert_eq!(flags.len(), 1);
514        assert_eq!(flags[0].flag_name, "features.newCheckout");
515        assert_eq!(flags[0].kind, FlagUseKind::ConfigObject);
516    }
517
518    #[test]
519    fn detects_flags_object() {
520        let flags = extract_with_config_objects("if (flags.enableV2) {}");
521        assert_eq!(flags.len(), 1);
522        assert_eq!(flags[0].flag_name, "flags.enableV2");
523    }
524
525    #[test]
526    fn ignores_non_flag_config_object() {
527        let flags = extract_with_config_objects("const host = config.database.host;");
528        assert!(flags.is_empty());
529    }
530
531    // ── Guard span detection ────────────────────────────────────────
532
533    #[test]
534    fn captures_if_guard_span() {
535        let source = "if (process.env.FEATURE_X) {\n  doStuff();\n}";
536        let flags = extract_from_source(source);
537        assert_eq!(flags.len(), 1);
538        assert!(flags[0].guard_span_start.is_some());
539        assert!(flags[0].guard_span_end.is_some());
540    }
541
542    #[test]
543    fn captures_ternary_guard_span() {
544        let source = "const x = useFlag('beta') ? newFlow() : oldFlow();";
545        let flags = extract_from_source(source);
546        assert_eq!(flags.len(), 1);
547        assert!(flags[0].guard_span_start.is_some());
548    }
549
550    // ── Custom SDK patterns ─────────────────────────────────────────
551
552    #[test]
553    fn detects_custom_sdk_pattern() {
554        let allocator = Allocator::default();
555        let source = "isFeatureActive('my-flag');";
556        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
557        let line_offsets = fallow_types::extract::compute_line_offsets(source);
558        let custom = vec![("isFeatureActive".to_string(), 0, "Internal".to_string())];
559        let flags = extract_flags(&parser_return.program, &line_offsets, &custom, &[], false);
560        assert_eq!(flags.len(), 1);
561        assert_eq!(flags[0].flag_name, "my-flag");
562        assert_eq!(flags[0].sdk_name.as_deref(), Some("Internal"));
563    }
564
565    // ── Custom env prefixes ─────────────────────────────────────────
566
567    #[test]
568    fn detects_custom_env_prefix() {
569        let allocator = Allocator::default();
570        let source = "if (process.env.MYAPP_ENABLE_V2) {}";
571        let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
572        let line_offsets = fallow_types::extract::compute_line_offsets(source);
573        let custom_prefixes = vec!["MYAPP_ENABLE_".to_string()];
574        let flags = extract_flags(
575            &parser_return.program,
576            &line_offsets,
577            &[],
578            &custom_prefixes,
579            false,
580        );
581        assert_eq!(flags.len(), 1);
582        assert_eq!(flags[0].flag_name, "MYAPP_ENABLE_V2");
583    }
584}