1#[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
19const BUILTIN_SDK_PATTERNS: &[(&str, usize, &str)] = &[
21 ("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 ("useGate", 0, "Statsig"),
32 ("checkGate", 0, "Statsig"),
33 ("useExperiment", 0, "Statsig"),
34 ("useConfig", 0, "Statsig"),
35 ("isEnabled", 0, "Unleash"),
37 ("getVariant", 0, "Unleash"),
38 ("isOn", 0, "GrowthBook"),
40 ("isOff", 0, "GrowthBook"),
41 ("getFeatureValue", 0, "GrowthBook"),
42 ("getTreatment", 0, "Split"),
44 ("getValueAsync", 0, "ConfigCat"),
46 ("hasFeature", 0, "Flagsmith"),
48 ("getValue", 0, ""),
51 ("useFeature", 0, ""),
53 ("getFeatureFlag", 0, ""),
54];
55
56const 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
72const CONFIG_OBJECT_KEYWORDS: &[&str] = &[
74 "feature",
75 "features",
76 "featureFlags",
77 "featureFlag",
78 "flag",
79 "flags",
80 "toggle",
81 "toggles",
82];
83
84struct FlagVisitor<'a> {
86 results: Vec<FlagUse>,
87 line_offsets: &'a [u32],
88 extra_sdk_patterns: &'a [(String, usize, String)],
90 extra_env_prefixes: &'a [String],
92 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 fn check_env_var(&mut self, expr: &MemberExpression<'_>, guard: Option<(u32, u32)>) {
114 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 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 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 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 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 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_expression_for_flags(self, &stmt.test, guard);
242
243 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 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
273fn 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
298fn 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
320fn extract_process_env_name(expr: &StaticMemberExpression<'_>) -> Option<String> {
322 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
336fn 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
347fn 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 Some((inner.property.name.to_string(), prop_name))
357 } else {
358 None
359 }
360 }
361 _ => None,
362 }
363}
364
365pub 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
385pub 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 #[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 #[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 #[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 #[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 #[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 #[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}