feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API
Introduce Expo Router integration with a new Screens view and API to surface screens and transitions, plus a new Steps API and UI to depict typed steps from anchors or symbols. Extend codegraph’s extraction and resolution to handle namespace objects (export default NAME, two-statement forms, and default bindings) and React hook bindings for handlers, improving accuracy of flows across JS ↔ native boundaries. Add Swift/React Native bridge receiver evidence (RCT_EXTERN_MODULE, RCT_EXTERN_METHOD) and related resolution logic, with tests covering namespace-object resolution, useCallback-driven handlers, and inline RN event listeners. Update UI to include a Steps tab and associated components (StepsView, StepNode, ScreenEdge) and wire navigation to expose steps-based exploration via /api/steps and UI routes. Documentation and changelog reflect the new Expo Router integration and steps surface capabilities.
This commit is contained in:
@@ -293,6 +293,29 @@ impl<'t> Walker<'t> {
|
||||
|
||||
// --- extractVariable (TS/JS branch) ------------------------------------------------
|
||||
|
||||
/// A top-level binding exported by a LATER statement rather than at its
|
||||
/// declaration: `export default NAME`, `export { NAME }`, `export { NAME as
|
||||
/// default }`. The declaration's own `is_exported` (an `export_statement`
|
||||
/// ancestor) cannot see these. One anchored regex over the file source.
|
||||
/// Mirrors TreeSitterExtractor.isExportedLater.
|
||||
pub(super) fn is_exported_later(&self, name: &str) -> bool {
|
||||
if name.is_empty()
|
||||
|| !name.chars().next().map(|c| c.is_ascii_alphabetic() || c == '_' || c == '$').unwrap_or(false)
|
||||
|| !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let n = regex::escape(name);
|
||||
let pattern = format!(
|
||||
r"(?m)^[ \t]*export\s+(?:default\s+{n}\s*;?[ \t]*$|\{{[^}}]*\b{n}\b[^}}]*\}})",
|
||||
n = n
|
||||
);
|
||||
match regex::Regex::new(&pattern) {
|
||||
Ok(re) => re.is_match(self.src),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn extract_variable(&mut self, node: Node<'t>) {
|
||||
let is_const = self.is_const_decl(node);
|
||||
let kind: &'static str = if is_const { "constant" } else { "variable" };
|
||||
@@ -373,7 +396,12 @@ impl<'t> Walker<'t> {
|
||||
let has_inline_fns = object_of_fns
|
||||
.map(|o| self.object_has_inline_functions(o))
|
||||
.unwrap_or(false);
|
||||
let extract_object_methods = is_exported && object_of_fns.is_some() && has_inline_fns;
|
||||
// "Exported" includes the two-statement form `const useStore =
|
||||
// create(…)` … `export default useStore` (is_exported_later), the
|
||||
// shape most React Native stores are written in. Mirrors
|
||||
// TreeSitterExtractor.isExportedLater.
|
||||
let extract_object_methods =
|
||||
(is_exported || self.is_exported_later(&name)) && object_of_fns.is_some() && has_inline_fns;
|
||||
|
||||
let rtk_endpoints = match value {
|
||||
Some(v) if v.kind() == "call_expression" => self.find_rtk_endpoints_object(v),
|
||||
|
||||
@@ -39,6 +39,11 @@ pub fn dispatch(kind: &str) -> Option<Mode> {
|
||||
"variable_declarator" => Some(Mode::VarInit),
|
||||
"pair" => Some(Mode::Value),
|
||||
"array" => Some(Mode::List),
|
||||
// A JSX attribute value or child (`onPress={handleSubmit}`): the
|
||||
// expression's one named child is the value. Mirrors TS_JS_SPEC.
|
||||
"jsx_expression" => Some(Mode::List),
|
||||
// An object literal's shorthand members (`return { handleApprove }`).
|
||||
"object" => Some(Mode::List),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -117,7 +122,7 @@ pub fn capture(container: Node, mode: Mode, src: &str) -> Vec<(Candidate, Mode)>
|
||||
/// `this.<member>` member_expression special form (object EXACTLY `this`).
|
||||
fn normalize<'t>(node: Node<'t>, src: &str) -> Vec<(String, Node<'t>)> {
|
||||
match node.kind() {
|
||||
"identifier" => vec![(src[node.byte_range()].to_string(), node)],
|
||||
"identifier" | "shorthand_property_identifier" => vec![(src[node.byte_range()].to_string(), node)],
|
||||
"member_expression" => {
|
||||
let obj = node.child_by_field_name("object");
|
||||
let prop = node.child_by_field_name("property");
|
||||
|
||||
@@ -704,13 +704,20 @@ impl<'t> Walker<'t> {
|
||||
self.extract_variable_type_annotation(node, owner);
|
||||
}
|
||||
|
||||
// Nested NAMED functions become their own nodes.
|
||||
// Nested NAMED functions become their own nodes — and so does the
|
||||
// function a React handler hook binds a name to (`const onPress =
|
||||
// useCallback(() => {…}, [])`). Mirrors TreeSitterExtractor's
|
||||
// reactHookBoundName.
|
||||
if is_function_type(kind) {
|
||||
let name = self.extract_name(node);
|
||||
if name != "<anonymous>" {
|
||||
self.extract_function(node, None);
|
||||
return;
|
||||
}
|
||||
if let Some(bound) = self.react_hook_bound_name(node) {
|
||||
self.extract_function(node, Some(bound));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if is_class_type(self.variant, kind) {
|
||||
@@ -735,6 +742,44 @@ impl<'t> Walker<'t> {
|
||||
|
||||
// --- name / signature / modifier helpers ------------------------------------
|
||||
|
||||
/// The declarator name a React handler hook binds an anonymous function
|
||||
/// to — `const NAME = useCallback(<node>, [...])` (also `React.useCallback`,
|
||||
/// `useEffectEvent`, `useEvent`) — or None for any other shape. The node
|
||||
/// must be the call's FIRST argument and the call's value must be bound
|
||||
/// directly by a `variable_declarator`.
|
||||
fn react_hook_bound_name(&self, node: Node<'t>) -> Option<String> {
|
||||
if !matches!(node.kind(), "arrow_function" | "function_expression") {
|
||||
return None;
|
||||
}
|
||||
let args = node.parent()?;
|
||||
if args.kind() != "arguments" {
|
||||
return None;
|
||||
}
|
||||
let first = args.named_child(0)?;
|
||||
if first.start_byte() != node.start_byte() || first.end_byte() != node.end_byte() {
|
||||
return None;
|
||||
}
|
||||
let call = args.parent()?;
|
||||
if call.kind() != "call_expression" {
|
||||
return None;
|
||||
}
|
||||
let callee = call.child_by_field_name("function")?;
|
||||
let callee_text = self.text(callee);
|
||||
let hook = callee_text.strip_prefix("React.").unwrap_or(callee_text);
|
||||
if !matches!(hook, "useCallback" | "useEffectEvent" | "useEvent") {
|
||||
return None;
|
||||
}
|
||||
let declarator = call.parent()?;
|
||||
if declarator.kind() != "variable_declarator" {
|
||||
return None;
|
||||
}
|
||||
let name_node = declarator.child_by_field_name("name")?;
|
||||
if name_node.kind() != "identifier" {
|
||||
return None;
|
||||
}
|
||||
Some(self.text(name_node).to_string())
|
||||
}
|
||||
|
||||
/// extractName / extractNameRaw for the TS/JS configs.
|
||||
fn extract_name(&self, node: Node) -> String {
|
||||
// javascriptExtractor.resolveName: field_definition names its key the
|
||||
|
||||
Reference in New Issue
Block a user