[Dexter] Add --source-dir-root flag

Summary:
This allows to run dexter tests with separately compiled
binaries that are specified via --binary if the source file
location changed between compilation and dexter test run.

Reviewers: TWeaver, jmorse, probinson, #debug-info

Reviewed By: jmorse

Subscribers: #debug-info, cmtice, llvm-commits

Tags: #llvm, #debug-info

Differential Revision: https://reviews.llvm.org/D81319

GitOrigin-RevId: 53d6bfef32e371fb984a40b52208c6e34bd54e05
diff --git a/dexter/dex/debugger/DebuggerBase.py b/dexter/dex/debugger/DebuggerBase.py
index 12f4f4a..37aaffe 100644
--- a/dexter/dex/debugger/DebuggerBase.py
+++ b/dexter/dex/debugger/DebuggerBase.py
@@ -7,10 +7,13 @@
 """Base class for all debugger interface implementations."""
 
 import abc
+import os
 import sys
 import traceback
+import unittest
 
-from dex.dextIR import DebuggerIR, ValueIR
+from types import SimpleNamespace
+from dex.dextIR import DebuggerIR, FrameIR, LocIR, StepIR, ValueIR
 from dex.utils.Exceptions import DebuggerException
 from dex.utils.Exceptions import NotYetLoadedDebuggerException
 from dex.utils.ReturnCode import ReturnCode
@@ -19,6 +22,11 @@
 class DebuggerBase(object, metaclass=abc.ABCMeta):
     def __init__(self, context):
         self.context = context
+        # Note: We can't already read values from options
+        # as DebuggerBase is created before we initialize options
+        # to read potential_debuggers.
+        self.options = self.context.options
+
         self._interface = None
         self.has_loaded = False
         self._loading_error = NotYetLoadedDebuggerException()
@@ -116,16 +124,27 @@
     def clear_breakpoints(self):
         pass
 
-    @abc.abstractmethod
     def add_breakpoint(self, file_, line):
-        pass
+        return self._add_breakpoint(self._external_to_debug_path(file_), line)
 
     @abc.abstractmethod
+    def _add_breakpoint(self, file_, line):
+        pass
+
     def add_conditional_breakpoint(self, file_, line, condition):
-        pass
+        return self._add_conditional_breakpoint(
+            self._external_to_debug_path(file_), line, condition)
 
     @abc.abstractmethod
+    def _add_conditional_breakpoint(self, file_, line, condition):
+        pass
+
     def delete_conditional_breakpoint(self, file_, line, condition):
+        return self._delete_conditional_breakpoint(
+            self._external_to_debug_path(file_), line, condition)
+
+    @abc.abstractmethod
+    def _delete_conditional_breakpoint(self, file_, line, condition):
         pass
 
     @abc.abstractmethod
@@ -140,8 +159,14 @@
     def go(self) -> ReturnCode:
         pass
 
-    @abc.abstractmethod
     def get_step_info(self, watches, step_index):
+        step_info = self._get_step_info(watches, step_index)
+        for frame in step_info.frames:
+            frame.loc.path = self._debug_to_external_path(frame.loc.path)
+        return step_info
+
+    @abc.abstractmethod
+    def _get_step_info(self, watches, step_index):
         pass
 
     @abc.abstractproperty
@@ -159,3 +184,86 @@
     @abc.abstractmethod
     def evaluate_expression(self, expression, frame_idx=0) -> ValueIR:
         pass
+
+    def _external_to_debug_path(self, path):
+        root_dir = self.options.source_root_dir
+        if not root_dir or not path:
+            return path
+        assert path.startswith(root_dir)
+        return path[len(root_dir):].lstrip(os.path.sep)
+
+    def _debug_to_external_path(self, path):
+        if not path or not self.options.source_root_dir:
+            return path
+        for file in self.options.source_files:
+            if path.endswith(self._external_to_debug_path(file)):
+                return file
+        return path
+
+class TestDebuggerBase(unittest.TestCase):
+
+    class MockDebugger(DebuggerBase):
+
+        def __init__(self, context, *args):
+            super().__init__(context, *args)
+            self.step_info = None
+            self.breakpoint_file = None
+
+        def _add_breakpoint(self, file, line):
+            self.breakpoint_file = file
+
+        def _get_step_info(self, watches, step_index):
+            return self.step_info
+
+    def __init__(self, *args):
+        super().__init__(*args)
+        TestDebuggerBase.MockDebugger.__abstractmethods__ = set()
+        self.options = SimpleNamespace(source_root_dir = '', source_files = [])
+        context = SimpleNamespace(options = self.options)
+        self.dbg = TestDebuggerBase.MockDebugger(context)
+
+    def _new_step(self, paths):
+        frames = [
+            FrameIR(
+                function=None,
+                is_inlined=False,
+                loc=LocIR(path=path, lineno=0, column=0)) for path in paths
+        ]
+        return StepIR(step_index=0, stop_reason=None, frames=frames)
+
+    def _step_paths(self, step):
+        return [frame.loc.path for frame in step.frames]
+
+    def test_add_breakpoint_no_source_root_dir(self):
+        self.options.source_root_dir = ''
+        self.dbg.add_breakpoint('/root/some_file', 12)
+        self.assertEqual('/root/some_file', self.dbg.breakpoint_file)
+
+    def test_add_breakpoint_with_source_root_dir(self):
+        self.options.source_root_dir = '/my_root'
+        self.dbg.add_breakpoint('/my_root/some_file', 12)
+        self.assertEqual('some_file', self.dbg.breakpoint_file)
+
+    def test_add_breakpoint_with_source_root_dir_slash_suffix(self):
+        self.options.source_root_dir = '/my_root/'
+        self.dbg.add_breakpoint('/my_root/some_file', 12)
+        self.assertEqual('some_file', self.dbg.breakpoint_file)
+
+    def test_get_step_info_no_source_root_dir(self):
+        self.dbg.step_info = self._new_step(['/root/some_file'])
+        self.assertEqual(['/root/some_file'],
+            self._step_paths(self.dbg.get_step_info([], 0)))
+
+    def test_get_step_info_no_frames(self):
+        self.options.source_root_dir = '/my_root'
+        self.dbg.step_info = self._new_step([])
+        self.assertEqual([],
+            self._step_paths(self.dbg.get_step_info([], 0)))
+
+    def test_get_step_info(self):
+        self.options.source_root_dir = '/my_root'
+        self.options.source_files = ['/my_root/some_file']
+        self.dbg.step_info = self._new_step(
+            [None, '/other/file', '/dbg/some_file'])
+        self.assertEqual([None, '/other/file', '/my_root/some_file'],
+            self._step_paths(self.dbg.get_step_info([], 0)))
diff --git a/dexter/dex/debugger/Debuggers.py b/dexter/dex/debugger/Debuggers.py
index f69169f..06c115d 100644
--- a/dexter/dex/debugger/Debuggers.py
+++ b/dexter/dex/debugger/Debuggers.py
@@ -100,6 +100,11 @@
         default=None,
         display_default=defaults.arch,
         help='target architecture')
+    defaults.source_root_dir = ''
+    parser.add_argument(
+        '--source-root-dir',
+        default=None,
+        help='prefix path to ignore when matching debug info and source files.')
 
 
 def handle_debugger_tool_base_options(context, defaults):  # noqa
diff --git a/dexter/dex/debugger/dbgeng/dbgeng.py b/dexter/dex/debugger/dbgeng/dbgeng.py
index d812fd9..69e4b3b 100644
--- a/dexter/dex/debugger/dbgeng/dbgeng.py
+++ b/dexter/dex/debugger/dbgeng/dbgeng.py
@@ -76,18 +76,18 @@
             x.RemoveFlags(breakpoint.BreakpointFlags.DEBUG_BREAKPOINT_ENABLED)
             self.client.Control.RemoveBreakpoint(x)
 
-    def add_breakpoint(self, file_, line):
+    def _add_breakpoint(self, file_, line):
         # Breakpoint setting/deleting is not supported by dbgeng at this moment
         # but is something that should be considered in the future.
         # TODO: this method is called in the DefaultController but has no effect.
         pass
 
-    def add_conditional_breakpoint(self, file_, line, condition):
+    def _add_conditional_breakpoint(self, file_, line, condition):
         # breakpoint setting/deleting is not supported by dbgeng at this moment
         # but is something that should be considered in the future.
         raise NotImplementedError('add_conditional_breakpoint is not yet implemented by dbgeng')
 
-    def delete_conditional_breakpoint(self, file_, line, condition):
+    def _delete_conditional_breakpoint(self, file_, line, condition):
         # breakpoint setting/deleting is not supported by dbgeng at this moment
         # but is something that should be considered in the future.
         raise NotImplementedError('delete_conditional_breakpoint is not yet implemented by dbgeng')
@@ -106,7 +106,7 @@
         # We never go -- we always single step.
         pass
 
-    def get_step_info(self, watches, step_index):
+    def _get_step_info(self, watches, step_index):
         frames = self.step_info
         state_frames = []
 
diff --git a/dexter/dex/debugger/lldb/LLDB.py b/dexter/dex/debugger/lldb/LLDB.py
index c7bb746..5fc8fd3 100644
--- a/dexter/dex/debugger/lldb/LLDB.py
+++ b/dexter/dex/debugger/lldb/LLDB.py
@@ -103,12 +103,12 @@
     def clear_breakpoints(self):
         self._target.DeleteAllBreakpoints()
 
-    def add_breakpoint(self, file_, line):
+    def _add_breakpoint(self, file_, line):
         if not self._target.BreakpointCreateByLocation(file_, line):
             raise DebuggerException(
                 'could not add breakpoint [{}:{}]'.format(file_, line))
 
-    def add_conditional_breakpoint(self, file_, line, condition):
+    def _add_conditional_breakpoint(self, file_, line, condition):
         bp = self._target.BreakpointCreateByLocation(file_, line)
         if bp:
             bp.SetCondition(condition)
@@ -116,7 +116,7 @@
             raise DebuggerException(
                   'could not add breakpoint [{}:{}]'.format(file_, line))
 
-    def delete_conditional_breakpoint(self, file_, line, condition):
+    def _delete_conditional_breakpoint(self, file_, line, condition):
         bp_count = self._target.GetNumBreakpoints()
         bps = [self._target.GetBreakpointAtIndex(ix) for ix in range(0, bp_count)]
 
@@ -163,7 +163,7 @@
         self._process.Continue()
         return ReturnCode.OK
 
-    def get_step_info(self, watches, step_index):
+    def _get_step_info(self, watches, step_index):
         frames = []
         state_frames = []
 
diff --git a/dexter/dex/debugger/visualstudio/VisualStudio.py b/dexter/dex/debugger/visualstudio/VisualStudio.py
index 40a902b..6585a49 100644
--- a/dexter/dex/debugger/visualstudio/VisualStudio.py
+++ b/dexter/dex/debugger/visualstudio/VisualStudio.py
@@ -111,14 +111,14 @@
         for bp in self._debugger.Breakpoints:
             bp.Delete()
 
-    def add_breakpoint(self, file_, line):
+    def _add_breakpoint(self, file_, line):
         self._debugger.Breakpoints.Add('', file_, line)
 
-    def add_conditional_breakpoint(self, file_, line, condition):
+    def _add_conditional_breakpoint(self, file_, line, condition):
         column = 1
         self._debugger.Breakpoints.Add('', file_, line, column, condition)
 
-    def delete_conditional_breakpoint(self, file_, line, condition):
+    def _delete_conditional_breakpoint(self, file_, line, condition):
         for bp in self._debugger.Breakpoints:
             for bound_bp in bp.Children:
                 if (bound_bp.File == file_ and bound_bp.FileLine == line and
@@ -146,7 +146,7 @@
             raise Error('attempted to access stack frame {} out of {}'
                 .format(idx, len(stack_frames)))
 
-    def get_step_info(self, watches, step_index):
+    def _get_step_info(self, watches, step_index):
         thread = self._debugger.CurrentThread
         stackframes = thread.StackFrames
 
diff --git a/dexter/feature_tests/subtools/test/source-root-dir.cpp b/dexter/feature_tests/subtools/test/source-root-dir.cpp
new file mode 100644
index 0000000..cbea80a
--- /dev/null
+++ b/dexter/feature_tests/subtools/test/source-root-dir.cpp
@@ -0,0 +1,15 @@
+// REQUIRES: lldb
+// UNSUPPORTED: system-windows
+//
+// RUN: %dexter --fail-lt 1.0 -w \
+// RUN:     --builder 'clang' --debugger 'lldb' \
+// RUN:     --cflags "-O0 -glldb -fdebug-prefix-map=%S=/changed" \
+// RUN:     --source-root-dir=%S -- %s
+
+#include <stdio.h>
+int main() {
+  int x = 42;
+  printf("hello world: %d\n", x); // DexLabel('check')
+}
+
+// DexExpectWatchValue('x', 42, on_line='check')