Skip to content

Commit cf24fbd

Browse files
tawsifkamaltkucar
authored andcommitted
CG-10731: Add ChainedAttribute.attribute_chain (#383)
- Adds ChainedAttribute.attribute_chain to be able view all members of a lengthy chained attribute in a list - Can easily now traverse a lengthy chained attribute - i.e calling attribute_chain on one of the functionCalls or one of the ChainedAttributes of `a().b().c.d.e()` -> `[FunctionCall(name=a), FunctionCall(name=b), Name(source=c), Name(source=d), FunctionCall(name=e)]` --------- Signed-off-by: dependabot[bot] <[email protected]>
1 parent 4408ae8 commit cf24fbd

File tree

8 files changed

+567
-17
lines changed

8 files changed

+567
-17
lines changed

src/codegen/sdk/core/detached_symbols/argument.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ def __init__(self, node: TSNode, positional_idx: int, parent: FunctionCall) -> N
5252
self._name_node = self._parse_expression(name_node, default=Name)
5353
self._value_node = self._parse_expression(_value_node)
5454

55+
def __repr__(self) -> str:
56+
keyword = f"keyword={self.name}, " if self.name else ""
57+
value = f"value='{self.value}', " if self.value else ""
58+
type = f"type={self.type}" if self.type else ""
59+
60+
return f"Argument({keyword}{value}{type})"
61+
5562
@noapidoc
5663
@classmethod
5764
def from_argument_list(cls, node: TSNode, file_node_id: NodeId, G: CodebaseGraph, parent: FunctionCall) -> MultiExpression[Parent, Argument]:

src/codegen/sdk/core/detached_symbols/function_call.py

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,28 @@ def __init__(self, node: TSNode, file_node_id: NodeId, G: CodebaseGraph, parent:
6262
args = [Argument(x, i, self) for i, x in enumerate(arg_list_node.named_children) if x.type != "comment"]
6363
self._arg_list = Collection(arg_list_node, self.file_node_id, self.G, self, children=args)
6464

65+
def __repr__(self) -> str:
66+
"""Custom string representation showing the function call chain structure.
67+
68+
Format: FunctionCall(name=current, pred=pred_name, succ=succ_name, base=base_name)
69+
70+
It will only print out predecessor, successor, and base that are of type FunctionCall. If it's a property, it will not be logged
71+
"""
72+
# Helper to safely get name
73+
74+
# Get names for each part
75+
parts = [f"name='{self.name}'"]
76+
77+
if self.predecessor and isinstance(self.predecessor, FunctionCall):
78+
parts.append(f"predecessor=FunctionCall(name='{self.predecessor.name}')")
79+
80+
if self.successor and isinstance(self.successor, FunctionCall):
81+
parts.append(f"successor=FunctionCall(name='{self.successor.name}')")
82+
83+
parts.append(f"filepath='{self.file.filepath}'")
84+
85+
return f"FunctionCall({', '.join(parts)})"
86+
6587
@classmethod
6688
def from_usage(cls, node: Editable[Parent], parent: Parent | None = None) -> Self | None:
6789
"""Creates a FunctionCall object from an Editable instance that represents a function call.
@@ -210,9 +232,33 @@ def predecessor(self) -> FunctionCall[Parent] | None:
210232
or if the predecessor is not a function call.
211233
"""
212234
# Recursively travel down the tree to find the previous function call (child nodes are previous calls)
213-
return self.call_chain[-2] if len(self.call_chain) > 1 else None
235+
name = self.get_name()
236+
while name:
237+
if isinstance(name, FunctionCall):
238+
return name
239+
elif isinstance(name, ChainedAttribute):
240+
name = name.object
241+
else:
242+
break
243+
return None
244+
245+
@property
246+
@reader
247+
def successor(self) -> FunctionCall[Parent] | None:
248+
"""Returns the next function call in a function call chain.
249+
250+
Returns the next function call in a function call chain. This method is useful for traversing function call chains
251+
to analyze or modify sequences of chained function calls.
252+
253+
Returns:
254+
FunctionCall[Parent] | None: The next function call in the chain, or None if there is no successor
255+
or if the successor is not a function call.
256+
"""
257+
# this will avoid parent function calls in tree-sitter that are NOT part of the chained calls
258+
if not isinstance(self.parent, ChainedAttribute):
259+
return None
214260

215-
# TODO: also define a successor?
261+
return self.parent_of_type(FunctionCall)
216262

217263
@property
218264
@noapidoc
@@ -581,6 +627,26 @@ def function_calls(self) -> list[FunctionCall]:
581627
# calls.append(call)
582628
return sort_editables(calls, dedupe=False)
583629

630+
@property
631+
@reader
632+
def attribute_chain(self) -> list[FunctionCall | Name]:
633+
"""Returns a list of elements in the chainedAttribute that the function call belongs in.
634+
635+
Breaks down chained expressions into individual components in order of appearance.
636+
For example: `a.b.c().d` -> [Name("a"), Name("b"), FunctionCall("c"), Name("d")]
637+
638+
Returns:
639+
list[FunctionCall | Name]: List of Name nodes (property access) and FunctionCall nodes (method calls)
640+
"""
641+
if isinstance(self.get_name(), ChainedAttribute): # child is chainedAttribute. MEANING that this is likely in the middle or the last function call of a chained function call chain.
642+
return self.get_name().attribute_chain
643+
elif isinstance(
644+
self.parent, ChainedAttribute
645+
): # does not have child chainedAttribute, but parent is chainedAttribute. MEANING that this is likely the TOP function call of a chained function call chain.
646+
return self.parent.attribute_chain
647+
else: # this is a standalone function call
648+
return [self]
649+
584650
@property
585651
@noapidoc
586652
def descendant_symbols(self) -> list[Importable]:
@@ -603,24 +669,35 @@ def register_api_call(self, url: str):
603669
@property
604670
@reader
605671
def call_chain(self) -> list[FunctionCall]:
606-
"""Returns a list of all function calls in this function call chain, including this call. Does not include calls made after this one."""
672+
"""Returns a list of all function calls in this function call chain, including this call. Does not include calls made after this one."""
607673
ret = []
608-
name = self.get_name()
609-
while name:
610-
if isinstance(name, FunctionCall):
611-
ret.extend(name.call_chain)
612-
break
613-
elif isinstance(name, ChainedAttribute):
614-
name = name.object
615-
else:
616-
break
674+
675+
# backward traversal
676+
curr = self
677+
pred = curr.predecessor
678+
while pred is not None and isinstance(pred, FunctionCall):
679+
ret.insert(0, pred)
680+
pred = pred.predecessor
681+
617682
ret.append(self)
683+
684+
# forward traversal
685+
curr = self
686+
succ = curr.successor
687+
while succ is not None and isinstance(succ, FunctionCall):
688+
ret.append(succ)
689+
succ = succ.successor
690+
618691
return ret
619692

620693
@property
621694
@reader
622695
def base(self) -> Editable | None:
623-
"""Returns the base object of this function call chain."""
696+
"""Returns the base object of this function call chain.
697+
698+
Args:
699+
Editable | None: The base object of this function call chain.
700+
"""
624701
name = self.get_name()
625702
while isinstance(name, ChainedAttribute):
626703
if isinstance(name.object, FunctionCall):

src/codegen/sdk/core/expressions/chained_attribute.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
from codegen.shared.decorators.docs import apidoc, noapidoc
1616

1717
if TYPE_CHECKING:
18+
from codegen.sdk.core.detached_symbols.function_call import FunctionCall
1819
from codegen.sdk.core.interfaces.has_name import HasName
1920
from codegen.sdk.core.interfaces.importable import Importable
2021

22+
2123
Object = TypeVar("Object", bound="Chainable")
2224
Attribute = TypeVar("Attribute", bound="Resolvable")
2325
Parent = TypeVar("Parent", bound="Expression")
@@ -74,6 +76,49 @@ def attribute(self) -> Attribute:
7476
"""
7577
return self._attribute
7678

79+
@property
80+
@reader
81+
def attribute_chain(self) -> list["FunctionCall | Name"]:
82+
"""Returns a list of elements in a chained attribute expression.
83+
84+
Breaks down chained expressions into individual components in order of appearance.
85+
For example: `a.b.c().d` -> [Name("a"), Name("b"), FunctionCall("c"), Name("d")]
86+
87+
Returns:
88+
list[FunctionCall | Name]: List of Name nodes (property access) and FunctionCall nodes (method calls)
89+
"""
90+
from codegen.sdk.core.detached_symbols.function_call import FunctionCall
91+
92+
ret = []
93+
curr = self
94+
95+
# Traverse backwards in code (children of tree node)
96+
while isinstance(curr, ChainedAttribute):
97+
curr = curr.object
98+
99+
if isinstance(curr, FunctionCall):
100+
ret.insert(0, curr)
101+
curr = curr.get_name()
102+
elif isinstance(curr, ChainedAttribute):
103+
ret.insert(0, curr.attribute)
104+
105+
# This means that we have reached the base of the chain and the first item was an attribute (i.e a.b.c.func())
106+
if isinstance(curr, Name) and not isinstance(curr.parent, FunctionCall):
107+
ret.insert(0, curr)
108+
109+
curr = self
110+
111+
# Traversing forward in code (parents of tree node). Will add the current node as well
112+
while isinstance(curr, ChainedAttribute) or isinstance(curr, FunctionCall):
113+
if isinstance(curr, FunctionCall):
114+
ret.append(curr)
115+
elif isinstance(curr, ChainedAttribute) and not isinstance(curr.parent, FunctionCall):
116+
ret.append(curr.attribute)
117+
118+
curr = curr.parent
119+
120+
return ret
121+
77122
@property
78123
def object(self) -> Object:
79124
"""Returns the object that contains the attribute being looked up.

src/codegen/sdk/core/interfaces/editable.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ def _is_empty_container(text: str) -> bool:
7575
"resolved_types",
7676
"valid_symbol_names",
7777
"valid_import_names",
78+
"predecessor",
79+
"successor",
80+
"base",
81+
"call_chain",
7882
"code_block",
7983
"parent_statement",
8084
"symbol_usages",

src/codegen/sdk/core/symbol_group.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ def __init__(self, file_node_id: NodeId, G: CodebaseGraph, parent: Parent, node:
3737
node = children[0].ts_node
3838
super().__init__(node, file_node_id, G, parent)
3939

40+
def __repr__(self) -> str:
41+
return f"Collection({self.symbols})" if self.symbols is not None else super().__repr__()
42+
4043
def _init_children(self): ...
4144

4245
@repr_func # HACK

tests/unit/codegen/sdk/python/detached_symbols/function_call/test_function_call.py

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -502,8 +502,8 @@ def baz():
502502

503503
# Check call chain
504504
assert c.call_chain == [a, b, c]
505-
assert b.call_chain == [a, b]
506-
assert a.call_chain == [a]
505+
assert b.call_chain == [a, b, c]
506+
assert a.call_chain == [a, b, c]
507507

508508
# Check base
509509
assert c.base == a.get_name()
@@ -530,15 +530,103 @@ def baz():
530530

531531
# Check call chain
532532
assert c.call_chain == [a, b, c]
533-
assert b.call_chain == [a, b]
534-
assert a.call_chain == [a]
533+
assert b.call_chain == [a, b, c]
534+
assert a.call_chain == [a, b, c]
535535

536536
# Check base
537537
assert c.base.source == "x"
538538
assert b.base.source == "x"
539539
assert a.base.source == "x"
540540

541541

542+
def test_function_call_chain_nested(tmpdir) -> None:
543+
# language=python
544+
content = """
545+
def foo():
546+
# Nested function calls - each call should be independent
547+
a(b(c()))
548+
"""
549+
with get_codebase_session(tmpdir=tmpdir, files={"test.py": content}) as codebase:
550+
file = codebase.get_file("test.py")
551+
foo = file.get_function("foo")
552+
calls = foo.function_calls
553+
assert len(calls) == 3
554+
a = calls[0]
555+
b = calls[1]
556+
c = calls[2]
557+
558+
# Each call should be independent - no predecessors
559+
assert a.predecessor is None
560+
assert b.predecessor is None
561+
assert c.predecessor is None
562+
563+
# No successors since they're nested, not chained
564+
assert a.successor is None
565+
assert b.successor is None
566+
assert c.successor is None
567+
568+
# Call chain for each should only include itself
569+
assert a.call_chain == [a]
570+
assert b.call_chain == [b]
571+
assert c.call_chain == [c]
572+
573+
# Verify source strings are correct
574+
assert a.source == "a(b(c()))"
575+
assert b.source == "b(c())"
576+
assert c.source == "c()"
577+
578+
579+
def test_function_call_chain_successor(tmpdir) -> None:
580+
# language=python
581+
content = """
582+
def foo():
583+
a().b().c()
584+
585+
def bat():
586+
x.y.z.func()
587+
588+
def baz():
589+
x.a().y.b().z.c()
590+
"""
591+
with get_codebase_session(tmpdir=tmpdir, files={"test.py": content}) as codebase:
592+
file = codebase.get_file("test.py")
593+
594+
# Check foo
595+
foo = file.get_function("foo")
596+
calls = foo.function_calls
597+
assert len(calls) == 3
598+
c = calls[0]
599+
b = calls[1]
600+
a = calls[2]
601+
602+
# Check successors
603+
assert a.successor == b
604+
assert b.successor == c
605+
assert c.successor is None
606+
607+
# Check bat
608+
bat = file.get_function("bat")
609+
calls = bat.function_calls
610+
assert len(calls) == 1
611+
func = calls[0]
612+
613+
# No successor since it's a single function call
614+
assert func.successor is None
615+
616+
# Check baz
617+
baz = file.get_function("baz")
618+
calls = baz.function_calls
619+
assert len(calls) == 3
620+
c = calls[0]
621+
b = calls[1]
622+
a = calls[2]
623+
624+
# Check successors
625+
assert a.successor == b
626+
assert b.successor == c
627+
assert c.successor is None
628+
629+
542630
def test_function_call_chain_hard(tmpdir) -> None:
543631
# language=python
544632
content = """

0 commit comments

Comments
 (0)