[dexter] Change --source-root-dir and add --debugger-use-relative-paths

We want to use `DexDeclareFile` to specify paths relative to a project root
directory. The option `--source-root-dir`, prior to this patch, causes dexter
to strip the path prefix from commands before passing them to a debugger, and
appends the prefix to file paths returned from a debugger. This patch changes
the behviour of `--source-root-dir`. Relative paths in commands, made possible
with `DexDeclareFile(relative/path)`, are appended to the `--source-root-dir`
directory.

A new option, `--debugger-use-relative-paths`, can be used alongside
`--source-root-dir` to reproduce the old behaviour: all paths passed to the
debugger will be made relative to `--source-root-dir`.

I've added a regression test source_root_dir.dex for this new behaviour, and
modified the existing `--source-root-dir` regression and unit tests to use
`--debugger-use-relative-paths`.

Reviewed By: jmorse

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

GitOrigin-RevId: 4b55102aff29f5ce82b38a9e4a819b959e29ecd7
diff --git a/dexter/dex/command/ParseCommand.py b/dexter/dex/command/ParseCommand.py
index 81e5c6c..85e7a3f 100644
--- a/dexter/dex/command/ParseCommand.py
+++ b/dexter/dex/command/ParseCommand.py
@@ -209,7 +209,7 @@
     labels[label.eval()] = label.get_line()
 
 
-def _find_all_commands_in_file(path, file_lines, valid_commands):
+def _find_all_commands_in_file(path, file_lines, valid_commands, source_root_dir):
     labels = {} # dict of {name: line}.
     cmd_path = path
     declared_files = set()
@@ -278,7 +278,8 @@
                 elif type(command) is DexDeclareFile:
                     cmd_path = command.declared_file
                     if not os.path.isabs(cmd_path):
-                        source_dir = os.path.dirname(path)
+                        source_dir = (source_root_dir if source_root_dir else
+                                      os.path.dirname(path))
                         cmd_path = os.path.join(source_dir, cmd_path)
                     # TODO: keep stored paths as PurePaths for 'longer'.
                     cmd_path = str(PurePath(cmd_path))
@@ -295,25 +296,25 @@
         raise format_parse_err(msg, path, file_lines, err_point)
     return dict(commands), declared_files
 
-def _find_all_commands(test_files):
+def _find_all_commands(test_files, source_root_dir):
     commands = defaultdict(dict)
     valid_commands = _get_valid_commands()
     new_source_files = set()
     for test_file in test_files:
         with open(test_file) as fp:
             lines = fp.readlines()
-        file_commands, declared_files = _find_all_commands_in_file(test_file,
-                                                  lines, valid_commands)
+        file_commands, declared_files = _find_all_commands_in_file(
+            test_file, lines, valid_commands, source_root_dir)
         for command_name in file_commands:
             commands[command_name].update(file_commands[command_name])
         new_source_files |= declared_files
 
     return dict(commands), new_source_files
 
-def get_command_infos(test_files):
+def get_command_infos(test_files, source_root_dir):
   with Timer('parsing commands'):
       try:
-          commands, new_source_files = _find_all_commands(test_files)
+          commands, new_source_files = _find_all_commands(test_files, source_root_dir)
           command_infos = OrderedDict()
           for command_type in commands:
               for command in commands[command_type].values():
@@ -358,7 +359,7 @@
         Returns:
             { cmd_name: { (path, line): command_obj } }
         """
-        cmds, declared_files = _find_all_commands_in_file(__file__, lines, self.valid_commands)
+        cmds, declared_files = _find_all_commands_in_file(__file__, lines, self.valid_commands, None)
         return cmds
 
 
diff --git a/dexter/dex/debugger/DebuggerBase.py b/dexter/dex/debugger/DebuggerBase.py
index 5b97974..c31be77 100644
--- a/dexter/dex/debugger/DebuggerBase.py
+++ b/dexter/dex/debugger/DebuggerBase.py
@@ -206,6 +206,8 @@
         pass
 
     def _external_to_debug_path(self, path):
+        if not self.options.debugger_use_relative_paths:
+            return path
         root_dir = self.options.source_root_dir
         if not root_dir or not path:
             return path
@@ -213,6 +215,8 @@
         return path[len(root_dir):].lstrip(os.path.sep)
 
     def _debug_to_external_path(self, path):
+        if not self.options.debugger_use_relative_paths:
+            return path
         if not path or not self.options.source_root_dir:
             return path
         for file in self.options.source_files:
@@ -255,32 +259,38 @@
         return [frame.loc.path for frame in step.frames]
 
     def test_add_breakpoint_no_source_root_dir(self):
+        self.options.debugger_use_relative_paths = True
         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.debugger_use_relative_paths = True
         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.debugger_use_relative_paths = True
         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.options.debugger_use_relative_paths = True
         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.debugger_use_relative_paths = True
         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.debugger_use_relative_paths = True
         self.options.source_root_dir = '/my_root'
         self.options.source_files = ['/my_root/some_file']
         self.dbg.step_info = self._new_step(
diff --git a/dexter/dex/debugger/Debuggers.py b/dexter/dex/debugger/Debuggers.py
index 1fa433d..fbfa629 100644
--- a/dexter/dex/debugger/Debuggers.py
+++ b/dexter/dex/debugger/Debuggers.py
@@ -105,9 +105,15 @@
     defaults.source_root_dir = ''
     parser.add_argument(
         '--source-root-dir',
+        type=str,
+        metavar='<directory>',
         default=None,
-        help='prefix path to ignore when matching debug info and source files.')
-
+        help='source root directory')
+    parser.add_argument(
+        '--debugger-use-relative-paths',
+        action='store_true',
+        default=False,
+        help='pass the debugger paths relative to --source-root-dir')
 
 def handle_debugger_tool_base_options(context, defaults):  # noqa
     options = context.options
@@ -141,6 +147,15 @@
         if options.debugger == 'lldb':
             _warn_meaningless_option(context, '--show-debugger')
 
+    if options.source_root_dir != None:
+        if not os.path.isabs(options.source_root_dir):
+            raise ToolArgumentError(f'<d>--source-root-dir: expected absolute path, got</> <r>"{options.source_root_dir}"</>')
+        if not os.path.isdir(options.source_root_dir):
+            raise ToolArgumentError(f'<d>--source-root-dir: could not find directory</> <r>"{options.source_root_dir}"</>')
+
+    if options.debugger_use_relative_paths:
+        if not options.source_root_dir:
+            raise ToolArgumentError(f'<d>--debugger-relative-paths</> <r>requires --source-root-dir</>')
 
 def run_debugger_subprocess(debugger_controller, working_dir_path):
     with NamedTemporaryFile(
diff --git a/dexter/dex/tools/clang_opt_bisect/Tool.py b/dexter/dex/tools/clang_opt_bisect/Tool.py
index c910d9c..414f3d1 100644
--- a/dexter/dex/tools/clang_opt_bisect/Tool.py
+++ b/dexter/dex/tools/clang_opt_bisect/Tool.py
@@ -92,9 +92,11 @@
             executable_path=self.context.options.executable,
             source_paths=self.context.options.source_files,
             dexter_version=self.context.version)
+
         step_collection.commands, new_source_files = get_command_infos(
-            self.context.options.test_files)
+            self.context.options.source_files, self.context.options.source_root_dir)
         self.context.options.source_files.extend(list(new_source_files))
+
         debugger_controller = DefaultController(self.context, step_collection)
         return debugger_controller
 
diff --git a/dexter/dex/tools/test/Tool.py b/dexter/dex/tools/test/Tool.py
index 2d3ddce..1456d6e 100644
--- a/dexter/dex/tools/test/Tool.py
+++ b/dexter/dex/tools/test/Tool.py
@@ -139,7 +139,7 @@
             dexter_version=self.context.version)
 
         step_collection.commands, new_source_files = get_command_infos(
-            self.context.options.test_files)
+            self.context.options.test_files, self.context.options.source_root_dir)
 
         self.context.options.source_files.extend(list(new_source_files))
 
diff --git a/dexter/feature_tests/commands/perfect/dex_declare_file/precompiled_binary_different_dir/dex_commands/source_root_dir.dex b/dexter/feature_tests/commands/perfect/dex_declare_file/precompiled_binary_different_dir/dex_commands/source_root_dir.dex
new file mode 100644
index 0000000..466b827
--- /dev/null
+++ b/dexter/feature_tests/commands/perfect/dex_declare_file/precompiled_binary_different_dir/dex_commands/source_root_dir.dex
@@ -0,0 +1,23 @@
+## Purpose:
+##    Check that \DexDeclareFile's file declaration can be made relative to the
+##    --source-root-dir path.
+
+# REQUIRES: lldb
+# UNSUPPORTED: system-darwin
+
+# RUN: %clang %S/../source/test.cpp -O0 -g -o %t
+# RUN: %dexter_regression_test --binary %t \
+# RUN:   --source-root-dir="%S/../source" -- %s | FileCheck %s
+# RUN: rm %t
+
+# CHECK: source_root_dir.dex: (1.0000)
+
+## ../source/test.cpp
+## 1. int main() {
+## 2.   int result = 0;
+## 3.   return result;
+## 4. }
+
+## test.cpp is found in ../source, which we set as the source-root-dir.
+DexDeclareFile('test.cpp')
+DexExpectWatchValue('result', 0, on_line=3)
diff --git a/dexter/feature_tests/subtools/test/source-root-dir.cpp b/dexter/feature_tests/subtools/test/source-root-dir.cpp
index 41adf61..d589fbc 100644
--- a/dexter/feature_tests/subtools/test/source-root-dir.cpp
+++ b/dexter/feature_tests/subtools/test/source-root-dir.cpp
@@ -4,7 +4,7 @@
 // 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
+// RUN:     --source-root-dir=%S --debugger-use-relative-paths -- %s
 
 #include <stdio.h>
 int main() {