#[derive(Debug, Clone, Default)]
pub struct ReactApiUsage {
pub uses_portal: bool,
pub portal_target: Option<String>,
pub consumed_contexts: Vec<String>,
pub is_forward_ref: bool,
pub is_memo: bool,
}
pub fn detect_react_api_usage(source: &str) -> ReactApiUsage {
let mut usage = ReactApiUsage::default();
if source.contains("createPortal(") || source.contains("createPortal (") {
usage.uses_portal = true;
if let Some(target) = extract_portal_target(source) {
usage.portal_target = Some(target);
}
}
extract_use_context_calls(source, &mut usage.consumed_contexts);
if source.contains("forwardRef(") || source.contains("forwardRef<") {
usage.is_forward_ref = true;
}
for (i, _) in source.match_indices("memo(") {
if is_memo_call(source, i) {
usage.is_memo = true;
break;
}
}
usage
}
fn extract_portal_target(source: &str) -> Option<String> {
let portal_idx = source.find("createPortal(")?;
let after = &source[portal_idx + "createPortal(".len()..];
let mut depth = 1;
let mut comma_pos = None;
for (i, ch) in after.char_indices() {
match ch {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => {
depth -= 1;
if depth == 0 {
break;
}
}
',' if depth == 1 => {
comma_pos = Some(i);
break;
}
_ => {}
}
}
let comma = comma_pos?;
let target_start = comma + 1;
let rest = &after[target_start..];
let mut depth = 1;
let mut end = 0;
for (i, ch) in rest.char_indices() {
match ch {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => {
depth -= 1;
if depth == 0 {
end = i;
break;
}
}
',' if depth == 1 => {
end = i;
break;
}
_ => {}
}
}
let target = rest[..end].trim().to_string();
if target.is_empty() {
None
} else {
Some(target)
}
}
fn extract_use_context_calls(source: &str, contexts: &mut Vec<String>) {
let pattern = "useContext(";
for (idx, _) in source.match_indices(pattern) {
let after = &source[idx + pattern.len()..];
let trimmed = after.trim_start();
let name_end = trimmed
.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '$')
.unwrap_or(trimmed.len());
if name_end > 0 {
let name = trimmed[..name_end].to_string();
if !contexts.contains(&name) {
contexts.push(name);
}
}
}
}
fn is_memo_call(source: &str, idx: usize) -> bool {
if idx == 0 {
return true;
}
let prev = source.as_bytes()[idx - 1];
!prev.is_ascii_alphanumeric() && prev != b'_' && prev != b'$'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_portal() {
let source = r#"
return ReactDOM.createPortal(
<ModalContent>{children}</ModalContent>,
this.getElement(appendTo)
);
"#;
let usage = detect_react_api_usage(source);
assert!(usage.uses_portal);
assert_eq!(
usage.portal_target,
Some("this.getElement(appendTo)".into())
);
}
#[test]
fn test_detect_use_context() {
let source = r#"
const { isExpanded } = useContext(AccordionItemContext);
const theme = useContext(ThemeContext);
"#;
let usage = detect_react_api_usage(source);
assert_eq!(
usage.consumed_contexts,
vec!["AccordionItemContext", "ThemeContext"]
);
}
#[test]
fn test_detect_forward_ref() {
let source = r#"
export const Dropdown = forwardRef((props: DropdownProps, ref: React.Ref<any>) => (
<DropdownBase innerRef={ref} {...props} />
));
"#;
let usage = detect_react_api_usage(source);
assert!(usage.is_forward_ref);
}
#[test]
fn test_detect_memo() {
let source = r#"
export const MyComponent = React.memo(function MyComponent(props) {
return <div>{props.children}</div>;
});
"#;
let usage = detect_react_api_usage(source);
assert!(usage.is_memo);
}
#[test]
fn test_no_false_memo_match() {
let source = r#"
const memoize = (fn) => { /* ... */ };
const memorize = () => {};
"#;
let usage = detect_react_api_usage(source);
assert!(!usage.is_memo);
}
#[test]
fn test_no_portal() {
let source = r#"
return <div>{children}</div>;
"#;
let usage = detect_react_api_usage(source);
assert!(!usage.uses_portal);
assert!(usage.portal_target.is_none());
}
}