[llvm] [Dexter] Add support for nested state nodes (PR #201395)

Stephen Tozer via llvm-commits llvm-commits at lists.llvm.org
Wed Jun 10 05:13:34 PDT 2026


https://github.com/SLTozer updated https://github.com/llvm/llvm-project/pull/201395

>From 44021de1a657b743811520b7c7f099a55527f18e Mon Sep 17 00:00:00 2001
From: Stephen Tozer <stephen.tozer at sony.com>
Date: Fri, 22 May 2026 11:13:19 +0100
Subject: [PATCH 1/3] [Dexter] Add support for nested state nodes

This patch adds support for nested state nodes, !where and a new !and node.
Nested state nodes are evaluated only at a frame relative to the frame that
their parent matched to:

- `!where` can only match the frame immediately called from/leafwards from
  their parent frame.
- `!and` can only match the same frame as their parent frame.
---
 .../ScriptDebuggerController.py               | 46 ++++++++++++++----
 .../dexter/dex/dextIR/StepIR.py               |  1 +
 .../dexter/dex/evaluation/RunMatch.py         | 12 ++---
 .../dexter/dex/evaluation/StateMatch.py       | 47 +++++++++++++++----
 .../dexter/dex/test_script/Nodes.py           | 19 +++++---
 .../feature_tests/scripts/nested_wheres.cpp   | 40 ++++++++++++++++
 .../scripts/parser/valid-parse.test           |  6 ++-
 7 files changed, 140 insertions(+), 31 deletions(-)
 create mode 100644 cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/nested_wheres.cpp

diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerControllers/ScriptDebuggerController.py b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerControllers/ScriptDebuggerController.py
index b5af73f385fb6..94483eaaa002d 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerControllers/ScriptDebuggerController.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerControllers/ScriptDebuggerController.py
@@ -8,16 +8,18 @@
 recording debugger output."""
 
 
+from collections import defaultdict
+from dataclasses import dataclass
 from enum import Enum
 import time
-from typing import Optional
+from typing import Dict, List, Optional
 
 from dex.debugger.DebuggerControllers.DebuggerControllerBase import (
     DebuggerControllerBase,
 )
 from dex.debugger.DebuggerBase import DebuggerBase
 from dex.debugger.DAP import DAP
-from dex.evaluation.StateMatch import get_active_where_expects
+from dex.evaluation.StateMatch import get_active_where_matches
 from dex.test_script.Nodes import Where
 from dex.test_script.Script import DexterScript, Scope
 from dex.tools import Context
@@ -31,7 +33,6 @@ class DebuggerAction(Enum):
     CONTINUE = 2
     EXIT = 3
 
-
 class ScriptDebuggerController(DebuggerControllerBase):
     """Uses a Dexter Script to drive a debugger session, using "where" nodes to make breakpoint/stepping decisions, and
     "expect" nodes to evaluate variables."""
@@ -49,9 +50,12 @@ def __init__(self, context: Context, step_collection: DextIR):
         # and will be set back to None after we finish running the debugger.
         self.debugger: DebuggerBase = None  # type: ignore
 
+        # Breakpoint IDs currently set for each !where node.
+        self._where_bps: Dict[Where, List[int]] = defaultdict(list)
+
     def add_where_entry_bp(self, where: Where, default_file: Optional[str] = None):
         """Adds a breakpoint to catch when we enter the given !where node."""
-        added_ids = []
+        added_ids: List[int] = []
         if where.function:
             if where.lines:
                 raise NotImplementedError(
@@ -66,6 +70,7 @@ def add_where_entry_bp(self, where: Where, default_file: Optional[str] = None):
             # If this Where covers a range of lines, we breakpoint each of them to ensure that we don't miss any lines.
             for line in where.get_lines():
                 added_ids.append(self.debugger.add_breakpoint(file, line))
+        self._where_bps[where] = added_ids
         for id in added_ids:
             self.context.logger.note(f"Added Entry BP {id} for {where}")
 
@@ -115,12 +120,12 @@ def _run_debugger_custom(self, cmdline):
             ## Fetch frame information and breakpoint information from the debugger.
             step_info: StepIR = self.debugger.get_stack_frames(self._step_index)
 
-            active_where_expects = get_active_where_expects(script, step_info)
+            active_where_matches = get_active_where_matches(script, step_info)
 
             watches = [
                 watch
-                for frame_idx, expects in active_where_expects.values()
-                for expect in expects
+                for where_match in active_where_matches.values()
+                for expect in where_match.active_expects
                 if (watch := expect.get_watched_expr())
             ]
             self.debugger.collect_watches(step_info, watches)
@@ -130,14 +135,35 @@ def _run_debugger_custom(self, cmdline):
             # - Otherwise, if any !where matches any non-current stack frame, we step out.
             # - Otherwise, we continue.
             if any(
-                frame_idx == 0 for frame_idx, expects in active_where_expects.values()
+                where_match.frame_idx == 0
+                for where_match in active_where_matches.values()
             ):
                 next_action = DebuggerAction.STEP_OVER
-            elif active_where_expects:
+            elif active_where_matches:
                 next_action = DebuggerAction.STEP_OUT
             else:
                 next_action = DebuggerAction.CONTINUE
 
+            # Update breakpoints: first remove unneeded breakpoints, then set newly desired breakpoints.
+            bp_to_delete = []
+            pending_wheres = set(
+                where
+                for where_match in active_where_matches.values()
+                for where in where_match.pending_wheres
+            )
+            for where, bp_ids in self._where_bps.items():
+                if (
+                    bp_ids
+                    and where not in script.root_wheres
+                    and where not in pending_wheres
+                ):
+                    bp_to_delete.extend(bp_ids)
+                    bp_ids.clear()
+            self.debugger.delete_breakpoints(bp_to_delete)
+            for where in pending_wheres:
+                if not self._where_bps[where]:
+                    self.add_where_entry_bp(where)
+
             if step_info.current_frame:
                 self._step_index += 1
                 # Record the step in step_collection.
@@ -147,7 +173,7 @@ def _run_debugger_custom(self, cmdline):
 
             # If we have --trace enabled, report a short overview of this step.
             self.context.logger.note(
-                f"Stopped at {step_info.current_function} {step_info.current_location.short_str()}, {len(active_where_expects)} !wheres on the stack, next_action={next_action}"
+                f"Stopped at {step_info.current_function} {step_info.current_location.short_str()}, {len(active_where_matches)} !wheres on the stack, next_action={next_action}"
             )
 
             if next_action == DebuggerAction.EXIT:
diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/dextIR/StepIR.py b/cross-project-tests/debuginfo-tests/dexter/dex/dextIR/StepIR.py
index 34c10ea36162d..92e66fc525388 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/dextIR/StepIR.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/dextIR/StepIR.py
@@ -64,6 +64,7 @@ def __init__(
         if watches is None:
             watches = {}
         self.watches = watches
+        self.hit_fn_bps: List[str] = []
 
     def __str__(self):
         try:
diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/RunMatch.py b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/RunMatch.py
index adb710dddacde..1be5157997222 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/RunMatch.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/RunMatch.py
@@ -20,7 +20,7 @@
     get_variable_metrics,
     serialize_metric_to_json,
 )
-from dex.evaluation.StateMatch import get_active_where_expects
+from dex.evaluation.StateMatch import get_active_where_matches
 from dex.test_script import DexterScript, Scope
 from dex.test_script.Nodes import Expect, Value
 
@@ -33,11 +33,11 @@ class DebuggerStepMatch:
     def __init__(self, step: StepIR, script: DexterScript):
         self.step = step
         self.script = script
-        self.state_match = get_active_where_expects(script, step)
+        self.state_match = get_active_where_matches(script, step)
         expects_to_match = {
             expect
-            for frame_idx, expects in self.state_match.values()
-            for expect in expects
+            for where_match in self.state_match.values()
+            for expect in where_match.active_expects
         }
         self.expect_matches: Dict[Expect, DebuggerExpectMatch] = {}
 
@@ -111,8 +111,8 @@ def dump_step_results(self) -> str:
             result += f"Step {step_match.step.step_index}:\n"
             result += f"  {step_match.step.current_location}\n"
             frame_active_wheres = defaultdict(list)
-            for where, (frame_idx, expects) in step_match.state_match.items():
-                frame_active_wheres[frame_idx].append(str(where))
+            for where, where_match in step_match.state_match.items():
+                frame_active_wheres[where_match.frame_idx].append(str(where))
             if not frame_active_wheres:
                 result += f"  No active !where nodes.\n"
                 continue
diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
index 9ae45ff2b6658..5d40220263997 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
@@ -7,6 +7,7 @@
 """Utilities for matching debugger state, such as the call stack, conditions, or historical state (e.g. breakpoint
 hitcounts) to descriptions of expected state in a DexterScript."""
 
+from dataclasses import dataclass, field
 import os
 from typing import Dict, List, Tuple
 
@@ -51,20 +52,50 @@ def match_where_to_frame(
     return True
 
 
-def get_active_where_expects(
+class StepMatchResult:
+    """Represents the result of matching the given DexterScript against the given StepIR, used both for running a
+    debugger session and for evaluating the result of a test.
+    """
+
+
+ at dataclass
+class WhereMatchResult:
+    """Class storing the result of a single !where matched against a stack frame. The primary information stored is just
+    the frame index that the !where matched against; for convenience, the children of the !where are also included.
+    """
+
+    frame_idx: int
+    active_expects: List[Value] = field(default_factory=list)
+    pending_wheres: List[Where] = field(default_factory=list)
+
+
+def get_active_where_matches(
     script: DexterScript, step_info: StepIR
-) -> Dict[Where, Tuple[int, List[Value]]]:
+) -> Dict[Where, WhereMatchResult]:
     """Match the script against the step_info, producing a dict that maps each !where that matches a stack frame to the
     index of the (rootmost) stack frame that it matches, and if the frame that it matches is the current stack frame
     (i.e. the frame index is 0), also includes a list of every direct child !expect node for that !where.
     """
-    active_where_expects: Dict[Where, Tuple[int, List[Value]]] = {}
+    active_where_expects: Dict[Where, WhereMatchResult] = {}
 
     def get_active_wheres(where: Where, scope: Scope):
+        # For nested !wheres, we must match a specific frame relative to the parent !where.
         if scope.where:
-            raise NotImplementedError(
-                "Support for nested !where nodes currently unimplemented."
+            if scope.where not in active_where_expects:
+                # If the parent !where doesn't match any frame, then this !where cannot match any either.
+                return
+            parent_frame_idx = active_where_expects[scope.where].frame_idx
+            # !and must match the same frame as its parent; !where must match the next leafmost frame.
+            target_frame_idx = (
+                parent_frame_idx if where.is_and else parent_frame_idx - 1
             )
+            if target_frame_idx < 0:
+                # If the target frame is -1, we can't match the !where yet, but we should prepare to step into it.
+                active_where_expects[scope.where].pending_wheres.append(where)
+                return
+            if match_where_to_frame(where, step_info.frames[target_frame_idx]):
+                active_where_expects[where] = WhereMatchResult(target_frame_idx)
+            return
         # For this !where, search for the rootmost stack frame that matches it.
         matching_frame_idx = next(
             (
@@ -75,7 +106,7 @@ def get_active_wheres(where: Where, scope: Scope):
             None,
         )
         if matching_frame_idx is not None:
-            active_where_expects[where] = (matching_frame_idx, [])
+            active_where_expects[where] = WhereMatchResult(matching_frame_idx)
 
     # As we visit the script nodes in pre-order traversal, we can always assume that an expect's parent !where
     # has already been visited, and thus should have an entry in active_where_expects if it is active.
@@ -85,9 +116,9 @@ def get_active_expects(expect: Expect, expected_value, scope: Scope):
         ), "Values should be the only type of expect possible!"
         if (
             scope.where in active_where_expects
-            and active_where_expects[scope.where][0] == 0
+            and active_where_expects[scope.where].frame_idx == 0
         ):
-            active_where_expects[scope.where][1].append(expect)
+            active_where_expects[scope.where].active_expects.append(expect)
 
     script.visit_script(visit_where=get_active_wheres, visit_expect=get_active_expects)
 
diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/test_script/Nodes.py b/cross-project-tests/debuginfo-tests/dexter/dex/test_script/Nodes.py
index 875fe949bf2a2..932475dff3493 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/test_script/Nodes.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/test_script/Nodes.py
@@ -49,13 +49,14 @@ class Where:
     within scope of a "Where" will only be evaluated for the steps where the Where applies.
     """
 
-    def __init__(self, attributes: dict):
+    def __init__(self, attributes: dict, is_and: bool):
         self.file: Optional[str] = attributes.pop("file", None)
         self.function: Union[list[str], str, None] = attributes.pop("function", None)
         self.lines: Union[int, DexRange, None] = attributes.pop("lines", None)
         self.after_hit_count: Optional[int] = attributes.pop("after_hit_count", None)
         self.for_hit_count: Optional[int] = attributes.pop("for_hit_count", None)
         self.conditions: dict = attributes.pop("conditions", None)
+        self.is_and = is_and
         if attributes:
             raise DexterNodeError(
                 self, f"unexpected attributes {', '.join(attributes)}"
@@ -75,7 +76,8 @@ def __repr__(self):
             for name, value in self.get_attrs().items()
             if value is not None
         ]
-        return f"Where(" + ", ".join(elts) + ")"
+        name = "And" if self.is_and else "Where"
+        return f"{name}(" + ", ".join(elts) + ")"
 
     def get_attrs(self) -> Dict[str, Any]:
         return {
@@ -88,19 +90,24 @@ def get_attrs(self) -> Dict[str, Any]:
         }
 
     @staticmethod
-    def constructor(loader: yaml.Loader, node):
-        return Where(loader.construct_mapping(node))
+    def get_constructor(is_and: bool):
+        def constructor(loader, node):
+            return Where(loader.construct_mapping(node), is_and)
+
+        return constructor
 
     @staticmethod
     def representer(dumper: yaml.Dumper, data: "Where"):
         mapping = {
             name: value for name, value in data.get_attrs().items() if value is not None
         }
-        return dumper.represent_mapping("!where", mapping, flow_style=True)
+        tag = "!and" if data.is_and else "!where"
+        return dumper.represent_mapping(tag, mapping, flow_style=True)
 
     @staticmethod
     def register_yaml(loader):
-        yaml.add_constructor("!where", Where.constructor, loader)
+        yaml.add_constructor("!where", Where.get_constructor(False), loader)
+        yaml.add_constructor("!and", Where.get_constructor(True), loader)
         yaml.add_representer(Where, Where.representer)
 
     def get_lines(self) -> range:
diff --git a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/nested_wheres.cpp b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/nested_wheres.cpp
new file mode 100644
index 0000000000000..44130741d846d
--- /dev/null
+++ b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/nested_wheres.cpp
@@ -0,0 +1,40 @@
+// RUN: %dexter_regression_test_cxx_build %s -o %t
+// RUN: %dexter_regression_test_run --use-script --binary %t -- %s | FileCheck %s
+
+/// Test that we correctly interpret nested !where+!and nodes during debugging
+/// and evaluation.
+
+int fib(int n) {
+    if (n <= 1) {
+        return 1;
+    }
+    int first = fib(n - 2);
+    int second = fib(n - 1);
+    return first + second;
+}
+
+int main() {
+  return fib(4);
+}
+
+
+// CHECK: correct_steps: 9
+// CHECK: missing_values: 0
+
+/*
+---
+!where {function: main}:
+    !where {function: fib}:
+        !and {lines: 8}:
+            !value n: 4
+        !where {function: fib}:
+            !and {lines: 8}:
+                !value n: [2, 3]
+            !where {function: fib}:
+                !and {lines: 8}:
+                    !value n: [0, 1, 1, 2]
+                !where {function: fib}:
+                    !and {lines: 8}:
+                        !value n: [0, 1]
+...
+*/
diff --git a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/parser/valid-parse.test b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/parser/valid-parse.test
index a639fadf576fa..9a57df7c90175 100644
--- a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/parser/valid-parse.test
+++ b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/parser/valid-parse.test
@@ -7,7 +7,9 @@ CHECK-NEXT: : !value 'x': 5
 CHECK-NEXT:   ? !where {file: lib.cpp, lines: !range [10, 20]}
 CHECK-NEXT:   : !value 'y': 10
 CHECK-NEXT: ? !where {lines: 5}
-CHECK-NEXT: : !value 'z': bees
+CHECK-NEXT: : ? !and {lines: 5}
+CHECK-NEXT:   : !value 'y': 7
+CHECK-NEXT:   !value 'z': bees
 
 
 ---
@@ -16,5 +18,7 @@ CHECK-NEXT: : !value 'z': bees
     !where {file: "lib.cpp", lines: !range [10, 20]}:
         !value y: 10
 !where {lines: 5}:
+    !and {lines: 5}:
+        !value y: 7
     !value z: bees
 ...

>From 5c96694de3647fe10403ceee138c5f6cfaaf661a Mon Sep 17 00:00:00 2001
From: Stephen Tozer <stephen.tozer at sony.com>
Date: Wed, 3 Jun 2026 17:12:49 +0100
Subject: [PATCH 2/3] Remove unused class

---
 .../debuginfo-tests/dexter/dex/evaluation/StateMatch.py     | 6 ------
 1 file changed, 6 deletions(-)

diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
index 5d40220263997..2b4363cff38af 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
@@ -52,12 +52,6 @@ def match_where_to_frame(
     return True
 
 
-class StepMatchResult:
-    """Represents the result of matching the given DexterScript against the given StepIR, used both for running a
-    debugger session and for evaluating the result of a test.
-    """
-
-
 @dataclass
 class WhereMatchResult:
     """Class storing the result of a single !where matched against a stack frame. The primary information stored is just

>From a6c42004604af465509b68cd5fff3bbd5c8b7f74 Mon Sep 17 00:00:00 2001
From: Stephen Tozer <stephen.tozer at sony.com>
Date: Wed, 10 Jun 2026 13:13:18 +0100
Subject: [PATCH 3/3] clang-format

---
 .../feature_tests/scripts/nested_wheres.cpp   | 20 +++++++++----------
 1 file changed, 9 insertions(+), 11 deletions(-)

diff --git a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/nested_wheres.cpp b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/nested_wheres.cpp
index 44130741d846d..cee0f593ff9d4 100644
--- a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/nested_wheres.cpp
+++ b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/nested_wheres.cpp
@@ -1,22 +1,20 @@
 // RUN: %dexter_regression_test_cxx_build %s -o %t
-// RUN: %dexter_regression_test_run --use-script --binary %t -- %s | FileCheck %s
+// RUN: %dexter_regression_test_run --use-script --binary %t -- %s \
+// RUN:   | FileCheck %s
 
 /// Test that we correctly interpret nested !where+!and nodes during debugging
 /// and evaluation.
 
 int fib(int n) {
-    if (n <= 1) {
-        return 1;
-    }
-    int first = fib(n - 2);
-    int second = fib(n - 1);
-    return first + second;
-}
-
-int main() {
-  return fib(4);
+  if (n <= 1) {
+    return 1;
+  }
+  int first = fib(n - 2);
+  int second = fib(n - 1);
+  return first + second;
 }
 
+int main() { return fib(4); }
 
 // CHECK: correct_steps: 9
 // CHECK: missing_values: 0



More information about the llvm-commits mailing list