diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index acf7370d6..e0e00364a 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3457,6 +3457,66 @@ def _lua_is_require_call(node, source: bytes) -> bool: return False return _read_text(name_node, source) == "require" +_PHP_ROUTING_VERBS = frozenset({"get", "post", "put", "patch", "delete", "options", "any", "match", "map"}) + +def _php_get_route_name(closure_node, src: bytes) -> str | None: + """Walk up the AST to extract grouped routing prefixes (#3409).""" + prefixes = [] + verb = None + + curr = closure_node.parent + while curr is not None: + if curr.type in ("function_definition", "method_declaration", "class_declaration"): + break + + if curr.type == "argument": + arg_list = curr.parent + if arg_list is not None and arg_list.type == "argument_list": + call = arg_list.parent + if call is not None and call.type in ("member_call_expression", "function_call_expression", "scoped_call_expression"): + name_node = call.child_by_field_name("name") + if name_node is None: + name_node = call.child_by_field_name("function") + + raw_method = (_read_text(name_node, src) if name_node else "").lower() + path_text = None + + for sibling in arg_list.children: + if sibling is curr: + break + if sibling.type in ("string", "encapsed_string"): + path_text = _read_text(sibling, src).strip("'\"") + break + + if verb is None: + # The innermost call must be a routing verb with a path starting with '/' + if path_text is not None and path_text.startswith("/") and raw_method in _PHP_ROUTING_VERBS: + verb = raw_method.upper() + prefixes.append(path_text) + else: + return None # Not a valid route closure + else: + # Outer calls (e.g. group(), prefix()) just contribute their prefix if it starts with '/' + if path_text is not None and path_text.startswith("/"): + prefixes.append(path_text) + + curr = call.parent + continue + + elif curr.type in ("anonymous_function_creation_expression", "arrow_function"): + # Jump across the closure boundary to its containing argument + curr = curr.parent + continue + + curr = curr.parent + + if verb and prefixes: + # prefixes are inside-out (innermost path is first) + # e.g. ['/users/{id}', '/api/v1'] -> '/api/v1/users/{id}' + full_path = "/" + "/".join(p.strip("/") for p in reversed(prefixes) if p.strip("/")) + return f"{verb} {full_path}" + + return None def _extract_generic( path: Path, config: LanguageConfig, *, source_override: bytes | None = None, @@ -3559,66 +3619,6 @@ def _extract_generic( php_closure_counts: dict[str, int] = {} pending_listen_edges: list[tuple[str, str, int]] = [] - def _php_get_route_name(closure_node, src: bytes) -> str | None: - """Walk up the AST to extract grouped routing prefixes (#3409).""" - _routing_verbs = frozenset({"get", "post", "put", "patch", "delete", "options", "any", "match", "map"}) - prefixes = [] - verb = None - - curr = closure_node.parent - while curr is not None: - if curr.type in ("function_definition", "method_declaration", "class_declaration"): - break - - if curr.type == "argument": - arg_list = curr.parent - if arg_list is not None and arg_list.type == "argument_list": - call = arg_list.parent - if call is not None and call.type in ("member_call_expression", "function_call_expression", "scoped_call_expression"): - name_node = call.child_by_field_name("name") - if name_node is None: - name_node = call.child_by_field_name("function") - - raw_method = (_read_text(name_node, src) if name_node else "").lower() - path_text = None - - for sibling in arg_list.children: - if sibling is curr: - break - if sibling.type in ("string", "encapsed_string"): - path_text = _read_text(sibling, src).strip("'\"") - break - - if verb is None: - # The innermost call must be a routing verb with a path starting with '/' - if path_text is not None and path_text.startswith("/") and raw_method in _routing_verbs: - verb = raw_method.upper() - prefixes.append(path_text) - else: - return None # Not a valid route closure - else: - # Outer calls (e.g. group(), prefix()) just contribute their prefix if it starts with '/' - if path_text is not None and path_text.startswith("/"): - prefixes.append(path_text) - - curr = call.parent - continue - - elif curr.type in ("anonymous_function_creation_expression", "arrow_function"): - # Jump across the closure boundary to its containing argument - curr = curr.parent - continue - - curr = curr.parent - - if verb and prefixes: - # prefixes are inside-out (innermost path is first) - # e.g. ['/users/{id}', '/api/v1'] -> '/api/v1/users/{id}' - full_path = "/" + "/".join(p.strip("/") for p in reversed(prefixes) if p.strip("/")) - return f"{verb} {full_path}" - - return None - # tree-sitter-swift parses both `class Foo` and `extension Foo` as # `class_declaration`. Same-file pairs collapse via seen_ids, but cross-file # extensions don't (file stem is part of the id), so they're collected here