Skip to content

Commit a87fc79

Browse files
committed
feat(robot): enable bottom-up navigation in the SemanticModel
Every SemanticNode now carries a `parent` back-pointer, so LSP features that start from a token or statement can walk up to the enclosing block, section, or definition without re-querying the model by line. Comes with helpers `enclosing_definition_block`, `enclosing_block_of_kind`, `enclosing_section`, and `path_from_root` on `SemanticModel`. Parent is typed as `SemanticNode` (not `SemanticBlock`) so it can also point at a Statement — `RunKeywordCallStatement.inner_calls` are parented to their outer Run-Keyword statement.
1 parent 782d83d commit a87fc79

4 files changed

Lines changed: 568 additions & 6 deletions

File tree

packages/robot/src/robotcode/robot/diagnostics/semantic_analyzer/analyzer.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,13 +427,15 @@ def _add_statement(self, stmt: SemanticStatement) -> None:
427427
428428
If the current block is a control-flow block (For/While/If/Try/Group)
429429
and has no header yet, and the statement is the matching header kind,
430-
it is also wired up as `block.header`.
430+
it is also wired up as `block.header`. The header's parent is the block
431+
it heads — matching "the block owns its header".
431432
"""
432433
self._semantic_model.statements.append(stmt)
433434
if not self._block_stack:
434435
return
435436
parent = self._block_stack[-1]
436437
parent.body.append(stmt)
438+
stmt.parent = parent
437439
if (
438440
parent.header is None
439441
and stmt.kind in self._CONTROL_FLOW_HEADER_KINDS
@@ -444,7 +446,9 @@ def _add_statement(self, stmt: SemanticStatement) -> None:
444446
def _add_block(self, block: SemanticBlock) -> None:
445447
"""Append a child block to the currently open parent block."""
446448
if self._block_stack:
447-
self._block_stack[-1].body.append(block)
449+
parent = self._block_stack[-1]
450+
parent.body.append(block)
451+
block.parent = parent
448452

449453
def _push_block(self, block: SemanticBlock) -> None:
450454
self._block_stack.append(block)
@@ -2388,7 +2392,10 @@ def visit_TestCase(self, node: TestCase) -> None: # noqa: N802
23882392
# Header goes into the flat list; the block goes into the parent's body.
23892393
# _add_statement would also add to the parent body — we don't want that
23902394
# for the header (it lives inside the block as `header`, not as a sibling).
2395+
# Wire the header's parent to its block manually (the block-owns-its-header
2396+
# invariant) since we bypass _add_statement here.
23912397
self._semantic_model.statements.append(defn)
2398+
defn.parent = defn_block
23922399
self._add_block(defn_block)
23932400
self._push_block(defn_block)
23942401

@@ -2483,7 +2490,10 @@ def visit_Keyword(self, node: Keyword) -> None: # noqa: N802
24832490
self._current_definition = defn
24842491
self._current_definition_block = defn_block
24852492

2493+
# See visit_TestCase: header bypasses _add_statement so its parent is
2494+
# set explicitly to the owning DefinitionBlock.
24862495
self._semantic_model.statements.append(defn)
2496+
defn.parent = defn_block
24872497
self._add_block(defn_block)
24882498
self._push_block(defn_block)
24892499

packages/robot/src/robotcode/robot/diagnostics/semantic_analyzer/model.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
11
from dataclasses import dataclass, field
2-
from typing import TYPE_CHECKING, Dict, List, Optional
2+
from typing import TYPE_CHECKING, Dict, FrozenSet, List, Optional
33

4+
from .enums import NodeKind
45
from .nodes import (
56
DefinitionBlock,
67
DefinitionStatement,
78
SemanticBlock,
9+
SemanticNode,
810
SemanticStatement,
911
SemanticToken,
1012
)
1113

14+
_SECTION_KINDS: FrozenSet[NodeKind] = frozenset(
15+
{
16+
NodeKind.SETTING_SECTION,
17+
NodeKind.TESTCASE_SECTION,
18+
NodeKind.KEYWORD_SECTION,
19+
NodeKind.VARIABLE_SECTION,
20+
NodeKind.COMMENT_SECTION,
21+
NodeKind.INVALID_SECTION,
22+
}
23+
)
24+
1225
if TYPE_CHECKING:
1326
from ...utils.variables import VariableMatcher
1427
from ..entities import VariableDefinition
@@ -25,8 +38,11 @@ class SemanticModel:
2538
queries (outline, folding, breadcrumbs, scoping).
2639
- Flat list (`statements`) provides O(1) indexed access via `statement_at()`.
2740
28-
Optimized for queries: statement_at(), token_at(), find_variable(),
29-
block_at(), enclosing_definition().
41+
Optimized for queries:
42+
- line/position based: statement_at(), token_at(), token_path_at(),
43+
block_at(), enclosing_definition(), find_variable(), get_variables_at()
44+
- node-based parent walks: enclosing_definition_block(),
45+
enclosing_block_of_kind(), enclosing_section(), path_from_root()
3046
3147
Replaces ScopeTree by integrating variable scope tracking:
3248
- File-level variables are in `file_scope` (VariableScope)
@@ -207,6 +223,66 @@ def block_at(self, line: int) -> Optional[SemanticBlock]:
207223
"""Get the most specific (smallest range) block at a given line. O(1)."""
208224
return self._block_line_index.get(line)
209225

226+
# --- Node-based parent walks (use SemanticNode.parent back-pointer) ---
227+
228+
@staticmethod
229+
def enclosing_block_of_kind(
230+
node: SemanticNode,
231+
kinds: FrozenSet[NodeKind],
232+
) -> Optional[SemanticBlock]:
233+
"""Walk parent chain from `node` upward and return the first
234+
SemanticBlock whose kind is in `kinds`, or None if no match exists
235+
before reaching the root.
236+
237+
Use this for control-flow / section lookups when you already have a
238+
node (e.g. from `statement_at()`) and don't want to round-trip
239+
through a line-based query.
240+
"""
241+
current = node.parent
242+
while current is not None:
243+
if isinstance(current, SemanticBlock) and current.kind in kinds:
244+
return current
245+
current = current.parent
246+
return None
247+
248+
@staticmethod
249+
def enclosing_definition_block(node: SemanticNode) -> Optional[DefinitionBlock]:
250+
"""Walk parent chain and return the enclosing DefinitionBlock
251+
(TestCase / Keyword), or None if `node` is at file level (e.g. an
252+
import statement or a section-header).
253+
254+
Cheaper than `enclosing_definition(line)` when you already have the
255+
node — no line-range scan, just parent pointer hops.
256+
"""
257+
current = node.parent
258+
while current is not None:
259+
if isinstance(current, DefinitionBlock):
260+
return current
261+
current = current.parent
262+
return None
263+
264+
@staticmethod
265+
def enclosing_section(node: SemanticNode) -> Optional[SemanticBlock]:
266+
"""Walk parent chain and return the enclosing section block
267+
(SETTING / TESTCASE / KEYWORD / VARIABLE / COMMENT / INVALID),
268+
or None if `node` is outside any section (i.e. on the FILE root)."""
269+
return SemanticModel.enclosing_block_of_kind(node, _SECTION_KINDS)
270+
271+
@staticmethod
272+
def path_from_root(node: SemanticNode) -> List[SemanticNode]:
273+
"""Return the chain `[root, ..., node]` by walking parents.
274+
275+
Useful for breadcrumb UI, debugging, and tests asserting structural
276+
placement without depending on line ranges.
277+
"""
278+
chain: List[SemanticNode] = [node]
279+
current = node.parent
280+
while current is not None:
281+
chain.append(current)
282+
current = current.parent
283+
chain.reverse()
284+
return chain
285+
210286
def find_variable(
211287
self,
212288
name: str,

packages/robot/src/robotcode/robot/diagnostics/semantic_analyzer/nodes.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,26 @@ class SemanticNode:
4242
"""Common base for all nodes in the SemanticModel.
4343
4444
Both statements (leaf nodes with tokens) and blocks (structural containers
45-
with children) share kind and position fields.
45+
with children) share kind, position fields, and a back-pointer to their
46+
direct parent in the tree.
4647
"""
4748

4849
kind: NodeKind
4950
line_start: int = 0 # 1-indexed
5051
line_end: int = 0 # 1-indexed, inclusive
5152

53+
# Back-pointer to the directly enclosing node (block OR statement).
54+
# `None` only for the root SemanticBlock(kind=FILE).
55+
#
56+
# Type is `SemanticNode` (not `SemanticBlock`) because some statements
57+
# logically contain other statements without a wrapping block:
58+
# `RunKeywordCallStatement.inner_calls` are `KeywordCallStatement`s whose
59+
# parent is the outer Run-Keyword statement, not a block.
60+
#
61+
# Excluded from `repr` / compare to avoid infinite recursion through the
62+
# cycle (block → body → child → parent → block → …).
63+
parent: Optional["SemanticNode"] = field(default=None, repr=False, compare=False)
64+
5265

5366
@dataclass(slots=True)
5467
class SemanticStatement(SemanticNode):
@@ -119,6 +132,13 @@ class RunKeywordCallStatement(KeywordCallStatement):
119132

120133
inner_calls: List["KeywordCallStatement"] = field(default_factory=list)
121134

135+
def __post_init__(self) -> None:
136+
# Wire up parent back-pointers for inner calls. Their direct parent is
137+
# this Run-Keyword statement, not the enclosing block — that's why
138+
# SemanticNode.parent is typed as SemanticNode (not SemanticBlock).
139+
for inner in self.inner_calls:
140+
inner.parent = self
141+
122142

123143
# --- Control flow statements ---
124144

0 commit comments

Comments
 (0)