Merge "Add --vscode-launch-props to gdbclient.py"
diff --git a/python-packages/gdbrunner/Android.bp b/python-packages/gdbrunner/Android.bp
new file mode 100644
index 0000000..0e48065
--- /dev/null
+++ b/python-packages/gdbrunner/Android.bp
@@ -0,0 +1,24 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+python_library_host {
+    name: "gdbrunner",
+    srcs: [
+        "gdbrunner/__init__.py",
+    ],
+}
diff --git a/scripts/Android.bp b/scripts/Android.bp
index 1fc4151..45011d6 100644
--- a/scripts/Android.bp
+++ b/scripts/Android.bp
@@ -59,3 +59,23 @@
         "pyfakefs",
     ]
 }
+
+python_test_host {
+    name: "gdbclient_test",
+    srcs: [
+        "gdbclient.py",
+        "gdbclient_test.py",
+    ],
+    libs: [
+        "adb_py",
+        "gdbrunner",
+    ],
+    test_options: {
+        unit_test: true,
+    },
+    version: {
+        py3: {
+            embedded_launcher: true,
+        },
+    },
+}
diff --git a/scripts/gdbclient.py b/scripts/gdbclient.py
index 9c602cc..ef32bf0 100755
--- a/scripts/gdbclient.py
+++ b/scripts/gdbclient.py
@@ -28,7 +28,7 @@
 import tempfile
 import textwrap
 
-from typing import BinaryIO
+from typing import BinaryIO, Any
 
 # Shared functions across gdbclient.py and ndk-gdb.py.
 import gdbrunner
@@ -98,8 +98,13 @@
         choices=["lldb", "vscode-lldb"],
         help=("Set up lldb-server and port forwarding. Prints commands or " +
               ".vscode/launch.json configuration needed to connect the debugging " +
-              "client to the server. 'vscode' with llbd and 'vscode-lldb' both " +
+              "client to the server. 'vscode' with lldb and 'vscode-lldb' both " +
               "require the 'vadimcn.vscode-lldb' extension."))
+    parser.add_argument(
+        "--vscode-launch-props", default=None,
+        dest="vscode_launch_props",
+        help=("JSON with extra properties to add to launch parameters when using " +
+              "vscode-lldb forwarding."))
 
     parser.add_argument(
         "--env", nargs=1, action="append", metavar="VAR=VALUE",
@@ -244,7 +249,49 @@
 
     return (binary_file, pid, run_cmd)
 
-def generate_vscode_lldb_script(root: str, sysroot: str, binary_name: str, port: str | int, solib_search_path: list[str]) -> str:
+def merge_launch_dict(base: dict[str, Any], to_add:  dict[str, Any] | None) -> None:
+    """Merges two dicts describing VSCode launch.json properties: base and
+    to_add. Base is modified in-place with items from to_add.
+    Items from to_add that are not present in base are inserted. Items that are
+    present are merged following these rules:
+        - Lists are merged with to_add elements appended to the end of base
+          list. Only a list can be merged with a list.
+        - dicts are merged recursively. Only a dict can be merged with a dict.
+        - Other present values in base get overwritten with values from to_add.
+
+    The reason for these rules is that merging in new values should prefer to
+    expand the existing set instead of overwriting where possible.
+    """
+    if to_add is None:
+        return
+
+    for key, val in to_add.items():
+        if key not in base:
+            base[key] = val
+        else:
+            if isinstance(base[key], list) and not isinstance(val, list):
+                raise ValueError(f'Cannot merge non-list into list at key={key}. ' +
+                'You probably need to wrap your value into a list.')
+            if not isinstance(base[key], list) and isinstance(val, list):
+                raise ValueError(f'Cannot merge list into non-list at key={key}.')
+            if isinstance(base[key], dict) != isinstance(val, dict):
+                raise ValueError(f'Cannot merge dict and non-dict at key={key}')
+
+            # We don't allow the user to overwrite or interleave lists and don't allow
+            # to delete dict entries.
+            # It can be done but would make the implementation a bit more complicated
+            # and provides less value than adding elements.
+            # We expect that the config generated by gdbclient doesn't contain anything
+            # the user would want to remove.
+            if isinstance(base[key], list):
+                base[key] += val
+            elif isinstance(base[key], dict):
+                merge_launch_dict(base[key], val)
+            else:
+                base[key] = val
+
+
+def generate_vscode_lldb_script(root: str, sysroot: str, binary_name: str, port: str | int, solib_search_path: list[str], extra_props: dict[str, Any] | None) -> str:
     # TODO It would be nice if we didn't need to copy this or run the
     #      lldbclient.py program manually. Doing this would probably require
     #      writing a vscode extension or modifying an existing one.
@@ -262,6 +309,7 @@
                                  "target modules search-paths add / {}/".format(sysroot)],
         "processCreateCommands": ["gdb-remote {}".format(str(port))]
     }
+    merge_launch_dict(res, extra_props)
     return json.dumps(res, indent=4)
 
 def generate_lldb_script(root: str, sysroot: str, binary_name: str, port: str | int, solib_search_path: list[str]) -> str:
@@ -278,7 +326,7 @@
     return '\n'.join(commands)
 
 
-def generate_setup_script(sysroot: str, linker_search_dir: str | None, binary_name: str, is64bit: bool, port: str | int, debugger: str) -> str:
+def generate_setup_script(sysroot: str, linker_search_dir: str | None, binary_name: str, is64bit: bool, port: str | int, debugger: str, vscode_launch_props: dict[str, Any] | None) -> str:
     # Generate a setup script.
     root = os.environ["ANDROID_BUILD_TOP"]
     symbols_dir = os.path.join(sysroot, "system", "lib64" if is64bit else "lib")
@@ -294,7 +342,7 @@
 
     if debugger == "vscode-lldb":
         return generate_vscode_lldb_script(
-            root, sysroot, binary_name, port, solib_search_path)
+            root, sysroot, binary_name, port, solib_search_path, vscode_launch_props)
     elif debugger == 'lldb':
         return generate_lldb_script(
             root, sysroot, binary_name, port, solib_search_path)
@@ -332,6 +380,12 @@
     # Fetch binary for -p, -n.
     binary_file, pid, run_cmd = handle_switches(args, sysroot)
 
+    vscode_launch_props = None
+    if args.vscode_launch_props:
+        if args.setup_forwarding != "vscode-lldb":
+            raise ValueError('vscode_launch_props requires --setup-forwarding=vscode-lldb')
+        vscode_launch_props = json.loads(args.vscode_launch_props)
+
     with binary_file:
         if sys.platform.startswith("linux"):
             platform_name = "linux-x86"
@@ -381,7 +435,8 @@
                                                binary_name=binary_file.name,
                                                is64bit=is64bit,
                                                port=args.port,
-                                               debugger=debugger)
+                                               debugger=debugger,
+                                               vscode_launch_props=vscode_launch_props)
 
         if not args.setup_forwarding:
             # Print a newline to separate our messages from the GDB session.
diff --git a/scripts/gdbclient_test.py b/scripts/gdbclient_test.py
new file mode 100644
index 0000000..ef61bc9
--- /dev/null
+++ b/scripts/gdbclient_test.py
@@ -0,0 +1,165 @@
+#
+# Copyright (C) 2023 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+import gdbclient
+import unittest
+import copy
+import json
+
+from typing import Any
+
+class LaunchConfigMergeTest(unittest.TestCase):
+    def merge_compare(self, base: dict[str, Any], to_add: dict[str, Any] | None, expected: dict[str, Any]) -> None:
+        actual = copy.deepcopy(base)
+        gdbclient.merge_launch_dict(actual, to_add)
+        self.assertEqual(actual, expected, f'base={base}, to_add={to_add}')
+
+    def test_add_none(self) -> None:
+        base = { 'foo' : 1 }
+        to_add = None
+        expected = { 'foo' : 1 }
+        self.merge_compare(base, to_add, expected)
+
+    def test_add_val(self) -> None:
+        base = { 'foo' : 1 }
+        to_add = { 'bar' : 2}
+        expected = { 'foo' : 1, 'bar' : 2 }
+        self.merge_compare(base, to_add, expected)
+
+    def test_overwrite_val(self) -> None:
+        base = { 'foo' : 1 }
+        to_add = { 'foo' : 2}
+        expected = { 'foo' : 2 }
+        self.merge_compare(base, to_add, expected)
+
+    def test_lists_get_appended(self) -> None:
+        base = { 'foo' : [1, 2] }
+        to_add = { 'foo' : [3, 4]}
+        expected = { 'foo' : [1, 2, 3, 4] }
+        self.merge_compare(base, to_add, expected)
+
+    def test_add_elem_to_dict(self) -> None:
+        base = { 'foo' : { 'bar' : 1 } }
+        to_add = { 'foo' : { 'baz' : 2 } }
+        expected = { 'foo' : { 'bar' :  1, 'baz' : 2 } }
+        self.merge_compare(base, to_add, expected)
+
+    def test_overwrite_elem_in_dict(self) -> None:
+        base = { 'foo' : { 'bar' : 1 } }
+        to_add = { 'foo' : { 'bar' : 2 } }
+        expected = { 'foo' : { 'bar' : 2 } }
+        self.merge_compare(base, to_add, expected)
+
+    def test_merging_dict_and_value_raises(self) -> None:
+        base = { 'foo' : { 'bar' : 1 } }
+        to_add = { 'foo' : 2 }
+        with self.assertRaises(ValueError):
+            gdbclient.merge_launch_dict(base, to_add)
+
+    def test_merging_value_and_dict_raises(self) -> None:
+        base = { 'foo' : 2 }
+        to_add = { 'foo' : { 'bar' : 1 } }
+        with self.assertRaises(ValueError):
+            gdbclient.merge_launch_dict(base, to_add)
+
+    def test_merging_dict_and_list_raises(self) -> None:
+        base = { 'foo' : { 'bar' : 1 } }
+        to_add = { 'foo' : [1] }
+        with self.assertRaises(ValueError):
+            gdbclient.merge_launch_dict(base, to_add)
+
+    def test_merging_list_and_dict_raises(self) -> None:
+        base = { 'foo' : [1] }
+        to_add = { 'foo' : { 'bar' : 1 } }
+        with self.assertRaises(ValueError):
+            gdbclient.merge_launch_dict(base, to_add)
+
+    def test_adding_elem_to_list_raises(self) -> None:
+        base = { 'foo' : [1] }
+        to_add = { 'foo' : 2}
+        with self.assertRaises(ValueError):
+            gdbclient.merge_launch_dict(base, to_add)
+
+    def test_adding_list_to_elem_raises(self) -> None:
+        base = { 'foo' : 1 }
+        to_add = { 'foo' : [2]}
+        with self.assertRaises(ValueError):
+            gdbclient.merge_launch_dict(base, to_add)
+
+
+class VsCodeLaunchGeneratorTest(unittest.TestCase):
+    def setUp(self) -> None:
+        # These tests can generate long diffs, so we remove the limit
+        self.maxDiff = None
+
+    def test_generate_script(self) -> None:
+        self.assertEqual(json.loads(gdbclient.generate_vscode_lldb_script(root='/root',
+                                                            sysroot='/sysroot',
+                                                            binary_name='test',
+                                                            port=123,
+                                                            solib_search_path=['/path1',
+                                                                               '/path2'],
+                                                            extra_props=None)),
+        {
+             'name': '(lldbclient.py) Attach test (port: 123)',
+             'type': 'lldb',
+             'request': 'custom',
+             'relativePathBase': '/root',
+             'sourceMap': { '/b/f/w' : '/root', '': '/root', '.': '/root' },
+             'initCommands': ['settings append target.exec-search-paths /path1 /path2'],
+             'targetCreateCommands': ['target create test',
+                                      'target modules search-paths add / /sysroot/'],
+             'processCreateCommands': ['gdb-remote 123']
+         })
+
+    def test_generate_script_with_extra_props(self) -> None:
+        extra_props = {
+            'initCommands' : ['settings append target.exec-search-paths /path3'],
+            'processCreateCommands' : ['break main', 'continue'],
+            'sourceMap' : { '/test/' : '/root/test'},
+            'preLaunchTask' : 'Build'
+        }
+        self.assertEqual(json.loads(gdbclient.generate_vscode_lldb_script(root='/root',
+                                                            sysroot='/sysroot',
+                                                            binary_name='test',
+                                                            port=123,
+                                                            solib_search_path=['/path1',
+                                                                               '/path2'],
+                                                            extra_props=extra_props)),
+        {
+             'name': '(lldbclient.py) Attach test (port: 123)',
+             'type': 'lldb',
+             'request': 'custom',
+             'relativePathBase': '/root',
+             'sourceMap': { '/b/f/w' : '/root',
+                           '': '/root',
+                           '.': '/root',
+                           '/test/' : '/root/test' },
+             'initCommands': [
+                 'settings append target.exec-search-paths /path1 /path2',
+                 'settings append target.exec-search-paths /path3',
+             ],
+             'targetCreateCommands': ['target create test',
+                                      'target modules search-paths add / /sysroot/'],
+             'processCreateCommands': ['gdb-remote 123',
+                                       'break main',
+                                       'continue'],
+             'preLaunchTask' : 'Build'
+         })
+
+
+if __name__ == '__main__':
+    unittest.main(verbosity=2)