#[allow(clippy::wildcard_imports, reason = "many AST types used")]
use oxc_ast::ast::*;
use oxc_ast_visit::Visit;
use oxc_ast_visit::walk;
use rustc_hash::{FxHashMap, FxHashSet};
use fallow_types::extract::{FlagUse, FlagUseKind, byte_offset_to_line_col};
const BUILTIN_SDK_PATTERNS: &[(&str, usize, &str)] = &[
("useFlag", 0, "LaunchDarkly"),
("useLDFlag", 0, "LaunchDarkly"),
("useFeatureFlag", 0, "LaunchDarkly"),
("variation", 0, "LaunchDarkly"),
("boolVariation", 0, "LaunchDarkly"),
("stringVariation", 0, "LaunchDarkly"),
("numberVariation", 0, "LaunchDarkly"),
("jsonVariation", 0, "LaunchDarkly"),
("useGate", 0, "Statsig"),
("checkGate", 0, "Statsig"),
("useExperiment", 0, "Statsig"),
("useConfig", 0, "Statsig"),
("isEnabled", 0, "Unleash"),
("getVariant", 0, "Unleash"),
("isOn", 0, "GrowthBook"),
("isOff", 0, "GrowthBook"),
("getFeatureValue", 0, "GrowthBook"),
("getTreatment", 0, "Split"),
("useFeatureFlagEnabled", 0, "PostHog"),
("useFeatureFlagPayload", 0, "PostHog"),
("useFeatureFlagVariantKey", 0, "PostHog"),
("getFeatureFlagPayload", 0, "PostHog"),
("getValueAsync", 0, "ConfigCat"),
("getValueDetailsAsync", 0, "ConfigCat"),
("hasFeature", 0, "Flagsmith"),
("useDecision", 0, "Optimizely"),
("getFeatureVariable", 0, "Optimizely"),
("getFeatureVariableBoolean", 0, "Optimizely"),
("getFeatureVariableString", 0, "Optimizely"),
("getFeatureVariableInteger", 0, "Optimizely"),
("getFeatureVariableDouble", 0, "Optimizely"),
("getFeatureVariableJson", 0, "Optimizely"),
("getFeatureVariableJSON", 0, "Optimizely"),
("getStringAssignment", 0, "Eppo"),
("getBooleanAssignment", 0, "Eppo"),
("getNumericAssignment", 0, "Eppo"),
("getIntegerAssignment", 0, "Eppo"),
("getJSONAssignment", 0, "Eppo"),
("getStringAssignmentDetails", 0, "Eppo"),
("getBooleanAssignmentDetails", 0, "Eppo"),
("getNumericAssignmentDetails", 0, "Eppo"),
("getIntegerAssignmentDetails", 0, "Eppo"),
("getJSONAssignmentDetails", 0, "Eppo"),
("getValue", 0, ""),
("useFeature", 0, ""),
("getFeatureFlag", 0, ""),
];
const VERCEL_FLAGS_PROVIDER: &str = "Vercel Flags";
const VERCEL_FLAGS_FUNCTIONS: &[&str] = &["flag", "evaluate"];
const BUILTIN_ENV_PREFIXES: &[&str] = &[
"FEATURE_",
"NEXT_PUBLIC_FEATURE_",
"NEXT_PUBLIC_ENABLE_",
"REACT_APP_FEATURE_",
"REACT_APP_ENABLE_",
"VITE_FEATURE_",
"VITE_ENABLE_",
"NUXT_PUBLIC_FEATURE_",
"ENABLE_",
"FF_",
"FLAG_",
"TOGGLE_",
];
const CONFIG_OBJECT_KEYWORDS: &[&str] = &[
"feature",
"features",
"featureFlags",
"featureFlag",
"flag",
"flags",
"toggle",
"toggles",
];
struct FlagVisitor<'a> {
results: Vec<FlagUse>,
line_offsets: &'a [u32],
extra_sdk_patterns: &'a [(String, usize, String)],
extra_env_prefixes: &'a [String],
config_object_heuristics: bool,
vercel_flags_imports: FxHashMap<String, String>,
vercel_flags_namespaces: FxHashSet<String>,
}
impl<'a> FlagVisitor<'a> {
fn new(
line_offsets: &'a [u32],
extra_sdk_patterns: &'a [(String, usize, String)],
extra_env_prefixes: &'a [String],
config_object_heuristics: bool,
) -> Self {
Self {
results: Vec::new(),
line_offsets,
extra_sdk_patterns,
extra_env_prefixes,
config_object_heuristics,
vercel_flags_imports: FxHashMap::default(),
vercel_flags_namespaces: FxHashSet::default(),
}
}
fn check_env_var(&mut self, expr: &MemberExpression<'_>, guard: Option<(u32, u32)>) {
if let MemberExpression::StaticMemberExpression(static_expr) = expr
&& let Some(env_name) = extract_process_env_name(static_expr)
&& self.is_flag_env_name(&env_name)
{
let (line, col) = byte_offset_to_line_col(self.line_offsets, static_expr.span.start);
self.results.push(FlagUse {
flag_name: env_name,
kind: FlagUseKind::EnvVar,
line,
col,
guard_span_start: guard.map(|(s, _)| s),
guard_span_end: guard.map(|(_, e)| e),
sdk_name: None,
});
}
}
fn check_sdk_call(&mut self, call: &CallExpression<'_>, guard: Option<(u32, u32)>) {
let func_name = match &call.callee {
Expression::Identifier(id) => Some(id.name.as_str()),
Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()),
_ => None,
};
let Some(func_name) = func_name else {
return;
};
if self.check_vercel_flags_call(call, guard) {
return;
}
for &(pattern_name, name_arg_idx, provider) in BUILTIN_SDK_PATTERNS {
if func_name == pattern_name {
if let Some(flag_name) = extract_string_arg(&call.arguments, name_arg_idx) {
let (line, col) = byte_offset_to_line_col(self.line_offsets, call.span.start);
self.results.push(FlagUse {
flag_name,
kind: FlagUseKind::SdkCall,
line,
col,
guard_span_start: guard.map(|(s, _)| s),
guard_span_end: guard.map(|(_, e)| e),
sdk_name: if provider.is_empty() {
None
} else {
Some(provider.to_string())
},
});
}
return;
}
}
for (pattern_name, name_arg_idx, provider) in self.extra_sdk_patterns {
if func_name == pattern_name {
if let Some(flag_name) = extract_string_arg(&call.arguments, *name_arg_idx) {
let (line, col) = byte_offset_to_line_col(self.line_offsets, call.span.start);
self.results.push(FlagUse {
flag_name,
kind: FlagUseKind::SdkCall,
line,
col,
guard_span_start: guard.map(|(s, _)| s),
guard_span_end: guard.map(|(_, e)| e),
sdk_name: if provider.is_empty() {
None
} else {
Some(provider.clone())
},
});
}
return;
}
}
}
fn check_vercel_flags_call(
&mut self,
call: &CallExpression<'_>,
guard: Option<(u32, u32)>,
) -> bool {
let Some(imported_name) = self.vercel_flags_imported_name(call) else {
return false;
};
let flag_name = match imported_name {
"flag" => extract_object_string_property_arg(&call.arguments, 0, "key"),
"evaluate" => extract_string_arg(&call.arguments, 0),
_ => None,
};
let Some(flag_name) = flag_name else {
return false;
};
let (line, col) = byte_offset_to_line_col(self.line_offsets, call.span.start);
self.results.push(FlagUse {
flag_name,
kind: FlagUseKind::SdkCall,
line,
col,
guard_span_start: guard.map(|(s, _)| s),
guard_span_end: guard.map(|(_, e)| e),
sdk_name: Some(VERCEL_FLAGS_PROVIDER.to_string()),
});
true
}
fn vercel_flags_imported_name<'b>(&'b self, call: &'b CallExpression<'_>) -> Option<&'b str> {
match &call.callee {
Expression::Identifier(id) => self
.vercel_flags_imports
.get(id.name.as_str())
.map(String::as_str),
Expression::StaticMemberExpression(member) => {
let Expression::Identifier(object) = &member.object else {
return None;
};
self.vercel_flags_namespaces
.contains(object.name.as_str())
.then_some(member.property.name.as_str())
}
_ => None,
}
}
fn collect_vercel_flags_imports(&mut self, program: &Program<'_>) {
for stmt in &program.body {
if let Statement::ImportDeclaration(decl) = stmt {
self.collect_vercel_flags_import(decl);
}
}
}
fn collect_vercel_flags_import(&mut self, decl: &ImportDeclaration<'_>) {
if !is_vercel_flags_source(decl.source.value.as_str()) || decl.import_kind.is_type() {
return;
}
let Some(specifiers) = &decl.specifiers else {
return;
};
for spec in specifiers {
match spec {
ImportDeclarationSpecifier::ImportSpecifier(specifier) => {
if specifier.import_kind.is_type() {
continue;
}
let imported_name = specifier.imported.name();
if VERCEL_FLAGS_FUNCTIONS.contains(&imported_name.as_str()) {
self.vercel_flags_imports
.insert(specifier.local.name.to_string(), imported_name.to_string());
}
}
ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => {
self.vercel_flags_namespaces
.insert(specifier.local.name.to_string());
}
ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => {}
}
}
}
fn check_config_object(
&mut self,
expr: &StaticMemberExpression<'_>,
guard: Option<(u32, u32)>,
) {
if !self.config_object_heuristics {
return;
}
if let Some((obj_name, prop_name)) = extract_config_object_access(expr)
&& CONFIG_OBJECT_KEYWORDS
.iter()
.any(|kw| obj_name.eq_ignore_ascii_case(kw) || prop_name.eq_ignore_ascii_case(kw))
{
let (line, col) = byte_offset_to_line_col(self.line_offsets, expr.span.start);
self.results.push(FlagUse {
flag_name: format!("{obj_name}.{prop_name}"),
kind: FlagUseKind::ConfigObject,
line,
col,
guard_span_start: guard.map(|(s, _)| s),
guard_span_end: guard.map(|(_, e)| e),
sdk_name: None,
});
}
}
fn is_flag_env_name(&self, name: &str) -> bool {
for prefix in BUILTIN_ENV_PREFIXES {
if name.starts_with(prefix) {
return true;
}
}
for prefix in self.extra_env_prefixes {
if name.starts_with(prefix.as_str()) {
return true;
}
}
false
}
}
impl Visit<'_> for FlagVisitor<'_> {
fn visit_program(&mut self, program: &Program<'_>) {
self.collect_vercel_flags_imports(program);
walk::walk_program(self, program);
}
fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'_>) {
self.collect_vercel_flags_import(decl);
}
fn visit_if_statement(&mut self, stmt: &IfStatement<'_>) {
let guard = Some((stmt.span.start, stmt.span.end));
check_expression_for_flags(self, &stmt.test, guard);
self.visit_statement(&stmt.consequent);
if let Some(alt) = &stmt.alternate {
self.visit_statement(alt);
}
}
fn visit_conditional_expression(&mut self, expr: &ConditionalExpression<'_>) {
let guard = Some((expr.span.start, expr.span.end));
check_expression_for_flags(self, &expr.test, guard);
self.visit_expression(&expr.consequent);
self.visit_expression(&expr.alternate);
}
fn visit_call_expression(&mut self, call: &CallExpression<'_>) {
self.check_sdk_call(call, None);
walk::walk_call_expression(self, call);
}
fn visit_member_expression(&mut self, expr: &MemberExpression<'_>) {
self.check_env_var(expr, None);
if let MemberExpression::StaticMemberExpression(static_expr) = expr {
self.check_config_object(static_expr, None);
}
walk::walk_member_expression(self, expr);
}
}
fn is_vercel_flags_source(source: &str) -> bool {
source == "flags"
|| source.starts_with("flags/")
|| source == "@vercel/flags"
|| source.starts_with("@vercel/flags/")
}
fn check_expression_for_flags(
visitor: &mut FlagVisitor<'_>,
expr: &Expression<'_>,
guard: Option<(u32, u32)>,
) {
match expr {
Expression::CallExpression(call) => {
visitor.check_sdk_call(call, guard);
}
Expression::StaticMemberExpression(member) => {
check_static_member_for_env(visitor, member, guard);
visitor.check_config_object(member, guard);
}
Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::LogicalNot => {
check_expression_for_flags(visitor, &unary.argument, guard);
}
Expression::LogicalExpression(logical) => {
check_expression_for_flags(visitor, &logical.left, guard);
check_expression_for_flags(visitor, &logical.right, guard);
}
_ => {}
}
}
fn check_static_member_for_env(
visitor: &mut FlagVisitor<'_>,
expr: &StaticMemberExpression<'_>,
guard: Option<(u32, u32)>,
) {
if let Some(env_name) = extract_process_env_name(expr)
&& visitor.is_flag_env_name(&env_name)
{
let (line, col) = byte_offset_to_line_col(visitor.line_offsets, expr.span.start);
visitor.results.push(FlagUse {
flag_name: env_name,
kind: FlagUseKind::EnvVar,
line,
col,
guard_span_start: guard.map(|(s, _)| s),
guard_span_end: guard.map(|(_, e)| e),
sdk_name: None,
});
}
}
fn extract_process_env_name(expr: &StaticMemberExpression<'_>) -> Option<String> {
let prop_name = expr.property.name.as_str();
if let Expression::StaticMemberExpression(inner) = &expr.object
&& inner.property.name.as_str() == "env"
&& let Expression::Identifier(id) = &inner.object
&& id.name.as_str() == "process"
{
return Some(prop_name.to_string());
}
None
}
fn extract_string_arg(args: &[Argument<'_>], index: usize) -> Option<String> {
args.get(index).and_then(|arg| {
if let Argument::StringLiteral(lit) = arg {
Some(lit.value.to_string())
} else {
None
}
})
}
fn extract_object_string_property_arg(
args: &[Argument<'_>],
index: usize,
property_name: &str,
) -> Option<String> {
let Some(Argument::ObjectExpression(obj)) = args.get(index) else {
return None;
};
for prop in &obj.properties {
let ObjectPropertyKind::ObjectProperty(prop) = prop else {
continue;
};
if prop
.key
.static_name()
.is_some_and(|key| key.as_ref() == property_name)
&& let Expression::StringLiteral(lit) = &prop.value
{
return Some(lit.value.to_string());
}
}
None
}
fn extract_config_object_access(expr: &StaticMemberExpression<'_>) -> Option<(String, String)> {
let prop_name = expr.property.name.to_string();
match &expr.object {
Expression::Identifier(id) => Some((id.name.to_string(), prop_name)),
Expression::StaticMemberExpression(inner) => {
if matches!(&inner.object, Expression::Identifier(_)) {
Some((inner.property.name.to_string(), prop_name))
} else {
None
}
}
_ => None,
}
}
pub fn extract_flags(
program: &Program<'_>,
line_offsets: &[u32],
extra_sdk_patterns: &[(String, usize, String)],
extra_env_prefixes: &[String],
config_object_heuristics: bool,
) -> Vec<FlagUse> {
let mut visitor = FlagVisitor::new(
line_offsets,
extra_sdk_patterns,
extra_env_prefixes,
config_object_heuristics,
);
visitor.visit_program(program);
visitor.results
}
pub fn extract_flags_from_source(
source: &str,
path: &std::path::Path,
extra_sdk_patterns: &[(String, usize, String)],
extra_env_prefixes: &[String],
config_object_heuristics: bool,
) -> Vec<FlagUse> {
let source_type = oxc_span::SourceType::from_path(path).unwrap_or_default();
let allocator = oxc_allocator::Allocator::default();
let parser_return = oxc_parser::Parser::new(&allocator, source, source_type).parse();
let line_offsets = fallow_types::extract::compute_line_offsets(source);
extract_flags(
&parser_return.program,
&line_offsets,
extra_sdk_patterns,
extra_env_prefixes,
config_object_heuristics,
)
}
#[cfg(all(test, not(miri)))]
mod tests {
use super::*;
use oxc_allocator::Allocator;
use oxc_parser::Parser;
use oxc_span::SourceType;
fn extract_from_source(source: &str) -> Vec<FlagUse> {
let allocator = Allocator::default();
let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
let line_offsets = fallow_types::extract::compute_line_offsets(source);
extract_flags(&parser_return.program, &line_offsets, &[], &[], false)
}
fn extract_with_config_objects(source: &str) -> Vec<FlagUse> {
let allocator = Allocator::default();
let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
let line_offsets = fallow_types::extract::compute_line_offsets(source);
extract_flags(&parser_return.program, &line_offsets, &[], &[], true)
}
#[test]
fn detects_process_env_feature_flag() {
let flags = extract_from_source("if (process.env.FEATURE_NEW_CHECKOUT) { doStuff(); }");
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].flag_name, "FEATURE_NEW_CHECKOUT");
assert_eq!(flags[0].kind, FlagUseKind::EnvVar);
assert!(flags[0].guard_span_start.is_some());
}
#[test]
fn detects_next_public_enable_prefix() {
let flags = extract_from_source("if (process.env.NEXT_PUBLIC_ENABLE_BETA) {}");
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].flag_name, "NEXT_PUBLIC_ENABLE_BETA");
}
#[test]
fn ignores_non_flag_env_vars() {
let flags = extract_from_source("const url = process.env.DATABASE_URL;");
assert!(flags.is_empty());
}
#[test]
fn detects_negated_env_flag() {
let flags = extract_from_source("if (!process.env.FEATURE_X) { fallback(); }");
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].flag_name, "FEATURE_X");
}
#[test]
fn detects_launchdarkly_use_flag() {
let flags = extract_from_source("const flag = useFlag('new-checkout');");
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].flag_name, "new-checkout");
assert_eq!(flags[0].kind, FlagUseKind::SdkCall);
assert_eq!(flags[0].sdk_name.as_deref(), Some("LaunchDarkly"));
}
#[test]
fn detects_statsig_use_gate() {
let flags = extract_from_source("if (useGate('beta-feature')) {}");
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].flag_name, "beta-feature");
assert_eq!(flags[0].sdk_name.as_deref(), Some("Statsig"));
}
#[test]
fn detects_unleash_is_enabled() {
let flags = extract_from_source("client.isEnabled('feature-x')");
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].flag_name, "feature-x");
}
#[test]
fn detects_growthbook_get_feature_value() {
let flags = extract_from_source("const val = getFeatureValue('parser', false);");
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].flag_name, "parser");
assert_eq!(flags[0].sdk_name.as_deref(), Some("GrowthBook"));
}
#[test]
fn detects_posthog_hooks() {
let flags = extract_from_source(
"const enabled = useFeatureFlagEnabled('new-checkout');\n\
const payload = useFeatureFlagPayload('checkout-copy');\n\
const variant = useFeatureFlagVariantKey('pricing-test');",
);
let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
assert_eq!(names, ["new-checkout", "checkout-copy", "pricing-test"]);
assert!(
flags
.iter()
.all(|flag| flag.sdk_name.as_deref() == Some("PostHog"))
);
}
#[test]
fn detects_vercel_flags_object_key_and_core_evaluate_from_imports() {
let flags = extract_from_source(
"import { flag, evaluate as evalFlag } from 'flags/next';\n\
export const showSale = flag({ key: 'summer-sale', decide: () => false });\n\
const value = await evalFlag('show-new-feature', false);",
);
let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
assert_eq!(names, ["summer-sale", "show-new-feature"]);
assert!(
flags
.iter()
.all(|flag| flag.sdk_name.as_deref() == Some("Vercel Flags"))
);
}
#[test]
fn detects_vercel_flags_namespace_imports() {
let flags = extract_from_source(
"import * as vercelFlags from '@vercel/flags';\n\
const value = await vercelFlags.evaluate('show-new-feature', false);\n\
export const showSale = vercelFlags.flag({ key: 'summer-sale', decide: () => false });",
);
let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
assert_eq!(names, ["show-new-feature", "summer-sale"]);
assert!(
flags
.iter()
.all(|flag| flag.sdk_name.as_deref() == Some("Vercel Flags"))
);
}
#[test]
fn detects_vercel_flags_calls_before_import_declaration() {
let flags = extract_from_source(
"export const showSale = flag({ key: 'summer-sale', decide: () => false });\n\
import { flag } from 'flags/next';",
);
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].flag_name, "summer-sale");
assert_eq!(flags[0].sdk_name.as_deref(), Some("Vercel Flags"));
}
#[test]
fn ignores_unimported_vercel_like_function_names() {
let flags = extract_from_source(
"function math() { return evaluate('2 + 2'); }\n\
function marker() { return flag({ key: 'ui-row' }); }",
);
assert!(flags.is_empty());
}
#[test]
fn detects_configcat_detail_evaluation() {
let flags = extract_from_source(
"const details = await client.getValueDetailsAsync('new-checkout', false);",
);
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].flag_name, "new-checkout");
assert_eq!(flags[0].sdk_name.as_deref(), Some("ConfigCat"));
}
#[test]
fn detects_optimizely_decisions_and_variables() {
let flags = extract_from_source(
"const [decision] = useDecision('checkout-flow');\n\
const copy = optimizelyClient.getFeatureVariableString('checkout-flow', 'copy', userId, attrs);\n\
const json = optimizelyClient.getFeatureVariableJson('checkout-flow', 'json', userId, attrs);",
);
assert_eq!(flags.len(), 3);
assert!(flags.iter().all(|flag| flag.flag_name == "checkout-flow"));
assert!(
flags
.iter()
.all(|flag| flag.sdk_name.as_deref() == Some("Optimizely"))
);
}
#[test]
fn detects_eppo_typed_assignments() {
let flags = extract_from_source(
"const value = client.getBooleanAssignment('new-onboarding', subject, {}, false);\n\
const details = client.getStringAssignmentDetails('copy-test', subject, {}, 'control');\n\
const payload = client.getJSONAssignmentDetails('payload-test', subject, {}, {});",
);
let names: Vec<_> = flags.iter().map(|flag| flag.flag_name.as_str()).collect();
assert_eq!(names, ["new-onboarding", "copy-test", "payload-test"]);
assert!(
flags
.iter()
.all(|flag| flag.sdk_name.as_deref() == Some("Eppo"))
);
}
#[test]
fn ignores_sdk_call_without_string_arg() {
let flags = extract_from_source("useFlag(dynamicKey);");
assert!(flags.is_empty());
}
#[test]
fn config_objects_off_by_default() {
let flags = extract_from_source("if (config.features.newCheckout) {}");
assert!(flags.is_empty());
}
#[test]
fn detects_config_features_when_enabled() {
let flags = extract_with_config_objects("if (config.features.newCheckout) {}");
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].flag_name, "features.newCheckout");
assert_eq!(flags[0].kind, FlagUseKind::ConfigObject);
}
#[test]
fn detects_flags_object() {
let flags = extract_with_config_objects("if (flags.enableV2) {}");
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].flag_name, "flags.enableV2");
}
#[test]
fn ignores_non_flag_config_object() {
let flags = extract_with_config_objects("const host = config.database.host;");
assert!(flags.is_empty());
}
#[test]
fn captures_if_guard_span() {
let source = "if (process.env.FEATURE_X) {\n doStuff();\n}";
let flags = extract_from_source(source);
assert_eq!(flags.len(), 1);
assert!(flags[0].guard_span_start.is_some());
assert!(flags[0].guard_span_end.is_some());
}
#[test]
fn captures_ternary_guard_span() {
let source = "const x = useFlag('beta') ? newFlow() : oldFlow();";
let flags = extract_from_source(source);
assert_eq!(flags.len(), 1);
assert!(flags[0].guard_span_start.is_some());
}
#[test]
fn detects_custom_sdk_pattern() {
let allocator = Allocator::default();
let source = "isFeatureActive('my-flag');";
let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
let line_offsets = fallow_types::extract::compute_line_offsets(source);
let custom = vec![("isFeatureActive".to_string(), 0, "Internal".to_string())];
let flags = extract_flags(&parser_return.program, &line_offsets, &custom, &[], false);
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].flag_name, "my-flag");
assert_eq!(flags[0].sdk_name.as_deref(), Some("Internal"));
}
#[test]
fn custom_sdk_pattern_can_use_vercel_object_function_name() {
let allocator = Allocator::default();
let source = "flag('internal-flag');";
let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
let line_offsets = fallow_types::extract::compute_line_offsets(source);
let custom = vec![("flag".to_string(), 0, "Internal".to_string())];
let flags = extract_flags(&parser_return.program, &line_offsets, &custom, &[], false);
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].flag_name, "internal-flag");
assert_eq!(flags[0].sdk_name.as_deref(), Some("Internal"));
}
#[test]
fn detects_custom_env_prefix() {
let allocator = Allocator::default();
let source = "if (process.env.MYAPP_ENABLE_V2) {}";
let parser_return = Parser::new(&allocator, source, SourceType::tsx()).parse();
let line_offsets = fallow_types::extract::compute_line_offsets(source);
let custom_prefixes = vec!["MYAPP_ENABLE_".to_string()];
let flags = extract_flags(
&parser_return.program,
&line_offsets,
&[],
&custom_prefixes,
false,
);
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].flag_name, "MYAPP_ENABLE_V2");
}
}