Add --vscode-launch-file to gdbclient.py

The new argument makes the generator write the VSCode launch.json config
into a file instead of stdout.

The generator uses marker lines to insert the config. This way the user
can control where in the file the launch config is written.

Test: atest gdbclient_test
Test: lldbclient.py --setup-forwarding vscode-lldb \
  --vscode-launch-props= \
  '{"internalConsoleOptions" : "openOnSessionStart"}' \
  --vscode-launch-file=.vscode/launch.json -r test

Change-Id: I92b3f479b5ebcb722933938f52d0f23ff098beac
diff --git a/scripts/gdbclient.py b/scripts/gdbclient.py
index b053a48..f994278 100755
--- a/scripts/gdbclient.py
+++ b/scripts/gdbclient.py
@@ -15,11 +15,11 @@
 # limitations under the License.
 #
 
-import adb
 import argparse
 import json
 import logging
 import os
+import pathlib
 import posixpath
 import re
 import shutil
@@ -27,14 +27,17 @@
 import sys
 import tempfile
 import textwrap
+from typing import Any, BinaryIO
 
-from typing import BinaryIO, Any
-
+import adb
 # Shared functions across gdbclient.py and ndk-gdb.py.
 import gdbrunner
 
 g_temp_dirs = []
 
+g_vscode_config_marker_begin = '// #lldbclient-generated-begin'
+g_vscode_config_marker_end = '// #lldbclient-generated-end'
+
 
 def read_toolchain_config(root: str) -> str:
     """Finds out current toolchain version."""
@@ -106,6 +109,14 @@
         dest="vscode_launch_props",
         help=("JSON with extra properties to add to launch parameters when using " +
               "vscode-lldb forwarding."))
+    parser.add_argument(
+        "--vscode-launch-file", default=None,
+        dest="vscode_launch_file",
+        help=textwrap.dedent(f"""Path to .vscode/launch.json file for the generated launch
+                     config when using vscode-lldb forwarding. The file needs to
+                     contain two marker lines: '{g_vscode_config_marker_begin}'
+                     and '{g_vscode_config_marker_end}'. The config will be written inline
+                     between these lines, replacing any text that is already there."""))
 
     parser.add_argument(
         "--env", nargs=1, action="append", metavar="VAR=VALUE",
@@ -351,6 +362,83 @@
         raise Exception("Unknown debugger type " + debugger)
 
 
+def insert_commands_into_vscode_config(dst_launch_config: str, setup_commands: str) -> str:
+    """Inserts setup commands into launch config between two marker lines.
+    Marker lines are set in global variables g_vscode_config_marker_end and g_vscode_config_marker_end.
+    The commands are inserted with the same indentation as the first marker line.
+
+    Args:
+        dst_launch_config: Config to insert commands into.
+        setup_commands: Commands to insert.
+    Returns:
+        Config with inserted commands.
+    Raises:
+        ValueError if the begin marker is not found or not terminated with an end marker.
+    """
+
+    # We expect the files to be small (~10s KB), so we use simple string concatenation
+    # for simplicity and readability even if it is slower.
+    output = ""
+    found_at_least_one_begin = False
+    unterminated_begin_line = None
+
+    # It might be tempting to rewrite this using find() or even regexes,
+    # but keeping track of line numbers, preserving whitespace, and detecting indent
+    # becomes tricky enough that this simple loop is more clear.
+    for linenum, line in enumerate(dst_launch_config.splitlines(keepends=True), start=1):
+       if unterminated_begin_line != None:
+           if line.strip() == g_vscode_config_marker_end:
+               unterminated_begin_line = None
+           else:
+               continue
+       output += line
+       if line.strip() == g_vscode_config_marker_begin:
+           found_at_least_one_begin = True
+           unterminated_begin_line = linenum
+           marker_indent = line[:line.find(g_vscode_config_marker_begin)]
+           output += textwrap.indent(setup_commands, marker_indent) + '\n'
+
+    if not found_at_least_one_begin:
+       raise ValueError(f"Did not find begin marker line '{g_vscode_config_marker_begin}' " +
+                        "in the VSCode launch file")
+
+    if unterminated_begin_line is not None:
+       raise ValueError(f"Unterminated begin marker at line {unterminated_begin_line} " +
+                        f"in the VSCode launch file. Add end marker line to file: '{g_vscode_config_marker_end}'")
+
+    return output
+
+
+def replace_file_contents(dst_path: os.PathLike, contents: str) -> None:
+    """Replaces the contents of the file pointed to by dst_path.
+
+    This function writes the new contents into a temporary file, then atomically swaps it with
+    the target file. This way if a write fails, the original file is not overwritten.
+
+    Args:
+        dst_path: The path to the file to be replaced.
+        contents: The new contents of the file.
+    Raises:
+        Forwards exceptions from underlying filesystem methods.
+    """
+    tempf = tempfile.NamedTemporaryFile('w', delete=False)
+    try:
+        tempf.write(contents)
+        os.replace(tempf.name, dst_path)
+    except:
+        os.remove(tempf.name)
+        raise
+
+
+def write_vscode_config(vscode_launch_file: pathlib.Path, setup_commands: str) -> None:
+    """Writes setup_commands into the file pointed by vscode_launch_file.
+
+    See insert_commands_into_vscode_config for the description of how the setup commands are written.
+    """
+    contents = insert_commands_into_vscode_config(vscode_launch_file.read_text(), setup_commands)
+    replace_file_contents(vscode_launch_file, contents)
+
+
 def do_main() -> None:
     required_env = ["ANDROID_BUILD_TOP",
                     "ANDROID_PRODUCT_OUT", "TARGET_PRODUCT"]
@@ -384,9 +472,17 @@
     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')
+            raise ValueError(
+                'vscode-launch-props requires --setup-forwarding=vscode-lldb')
         vscode_launch_props = json.loads(args.vscode_launch_props)
 
+    vscode_launch_file = None
+    if args.vscode_launch_file:
+        if args.setup_forwarding != "vscode-lldb":
+            raise ValueError(
+                'vscode-launch-file requires --setup-forwarding=vscode-lldb')
+        vscode_launch_file = args.vscode_launch_file
+
     with binary_file:
         if sys.platform.startswith("linux"):
             platform_name = "linux-x86"
@@ -446,19 +542,25 @@
             # Start lldb.
             gdbrunner.start_gdb(debugger_path, setup_commands, lldb=True)
         else:
-            print("")
-            print(setup_commands)
-            print("")
-            if args.setup_forwarding == "vscode-lldb":
-                print(textwrap.dedent("""
-                        Paste the above json into .vscode/launch.json and start the debugger as
-                        normal. Press enter in this terminal once debugging is finished to shut
-                        lldb-server down and close all the ports."""))
+            if args.setup_forwarding == "vscode-lldb" and vscode_launch_file:
+                write_vscode_config(pathlib.Path(vscode_launch_file) , setup_commands)
+                print(f"Generated config written to '{vscode_launch_file}'")
             else:
-                print(textwrap.dedent("""
-                        Paste the lldb commands above into the lldb frontend to set up the
-                        lldb-server connection. Press enter in this terminal once debugging is
-                        finished to shut lldb-server down and close all the ports."""))
+                print("")
+                print(setup_commands)
+                print("")
+                if args.setup_forwarding == "vscode-lldb":
+                    print(textwrap.dedent("""
+                            Paste the above json into .vscode/launch.json and start the debugger as
+                            normal."""))
+                else:
+                    print(textwrap.dedent("""
+                            Paste the lldb commands above into the lldb frontend to set up the
+                            lldb-server connection."""))
+
+            print(textwrap.dedent("""
+                        Press enter in this terminal once debugging is finished to shut lldb-server
+                        down and close all the ports."""))
             print("")
             input("Press enter to shut down lldb-server")
 
diff --git a/scripts/gdbclient_test.py b/scripts/gdbclient_test.py
index ef61bc9..999bf74 100644
--- a/scripts/gdbclient_test.py
+++ b/scripts/gdbclient_test.py
@@ -14,13 +14,15 @@
 # limitations under the License.
 #
 
-import gdbclient
-import unittest
 import copy
 import json
-
+import textwrap
+import unittest
 from typing import Any
 
+import gdbclient
+
+
 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)
@@ -161,5 +163,221 @@
          })
 
 
+class LaunchConfigInsertTest(unittest.TestCase):
+    def setUp(self) -> None:
+        # These tests can generate long diffs, so we remove the limit
+        self.maxDiff = None
+
+    def test_insert_config(self) -> None:
+        dst = textwrap.dedent("""\
+            // #lldbclient-generated-begin
+            // #lldbclient-generated-end""")
+        to_insert = textwrap.dedent("""\
+                                    foo
+                                    bar""")
+        self.assertEqual(gdbclient.insert_commands_into_vscode_config(dst,
+                                                                      to_insert),
+                         textwrap.dedent("""\
+                            // #lldbclient-generated-begin
+                            foo
+                            bar
+                            // #lldbclient-generated-end"""))
+
+    def test_insert_into_start(self) -> None:
+        dst = textwrap.dedent("""\
+            // #lldbclient-generated-begin
+            // #lldbclient-generated-end
+            more content""")
+        to_insert = textwrap.dedent("""\
+            foo
+            bar""")
+        self.assertEqual(gdbclient.insert_commands_into_vscode_config(dst,
+                                                                      to_insert),
+                         textwrap.dedent("""\
+                            // #lldbclient-generated-begin
+                            foo
+                            bar
+                            // #lldbclient-generated-end
+                            more content"""))
+
+    def test_insert_into_mid(self) -> None:
+        dst = textwrap.dedent("""\
+            start content
+            // #lldbclient-generated-begin
+            // #lldbclient-generated-end
+            more content""")
+        to_insert = textwrap.dedent("""\
+            foo
+            bar""")
+        self.assertEqual(gdbclient.insert_commands_into_vscode_config(dst,
+                                                                      to_insert),
+                         textwrap.dedent("""\
+                            start content
+                            // #lldbclient-generated-begin
+                            foo
+                            bar
+                            // #lldbclient-generated-end
+                            more content"""))
+
+    def test_insert_into_end(self) -> None:
+        dst = textwrap.dedent("""\
+            start content
+            // #lldbclient-generated-begin
+            // #lldbclient-generated-end""")
+        to_insert = textwrap.dedent("""\
+            foo
+            bar""")
+        self.assertEqual(gdbclient.insert_commands_into_vscode_config(dst,
+                                                                      to_insert),
+                         textwrap.dedent("""\
+                            start content
+                            // #lldbclient-generated-begin
+                            foo
+                            bar
+                            // #lldbclient-generated-end"""))
+
+    def test_insert_twice(self) -> None:
+        dst = textwrap.dedent("""\
+            // #lldbclient-generated-begin
+            // #lldbclient-generated-end
+            // #lldbclient-generated-begin
+            // #lldbclient-generated-end
+            """)
+        to_insert = 'foo'
+        self.assertEqual(gdbclient.insert_commands_into_vscode_config(dst,
+                                                                      to_insert),
+                         textwrap.dedent("""\
+                            // #lldbclient-generated-begin
+                            foo
+                            // #lldbclient-generated-end
+                            // #lldbclient-generated-begin
+                            foo
+                            // #lldbclient-generated-end
+                         """))
+
+    def test_preserve_space_indent(self) -> None:
+        dst = textwrap.dedent("""\
+            {
+              "version": "0.2.0",
+              "configurations": [
+                // #lldbclient-generated-begin
+                // #lldbclient-generated-end
+              ]
+            }
+        """)
+        to_insert = textwrap.dedent("""\
+            {
+                "name": "(lldbclient.py) Attach test",
+                "type": "lldb",
+                "processCreateCommands": [
+                    "gdb-remote 123",
+                    "test"
+                ]
+            }""")
+        self.assertEqual(gdbclient.insert_commands_into_vscode_config(dst,
+                                                                      to_insert),
+                         textwrap.dedent("""\
+                             {
+                               "version": "0.2.0",
+                               "configurations": [
+                                 // #lldbclient-generated-begin
+                                 {
+                                     "name": "(lldbclient.py) Attach test",
+                                     "type": "lldb",
+                                     "processCreateCommands": [
+                                         "gdb-remote 123",
+                                         "test"
+                                     ]
+                                 }
+                                 // #lldbclient-generated-end
+                               ]
+                             }
+                         """))
+
+    def test_preserve_tab_indent(self) -> None:
+        dst = textwrap.dedent("""\
+            {
+            \t"version": "0.2.0",
+            \t"configurations": [
+            \t\t// #lldbclient-generated-begin
+            \t\t// #lldbclient-generated-end
+            \t]
+            }
+        """)
+        to_insert = textwrap.dedent("""\
+            {
+            \t"name": "(lldbclient.py) Attach test",
+            \t"type": "lldb",
+            \t"processCreateCommands": [
+            \t\t"gdb-remote 123",
+            \t\t"test"
+            \t]
+            }""")
+        self.assertEqual(gdbclient.insert_commands_into_vscode_config(dst,
+                                                                      to_insert),
+                         textwrap.dedent("""\
+                            {
+                            \t"version": "0.2.0",
+                            \t"configurations": [
+                            \t\t// #lldbclient-generated-begin
+                            \t\t{
+                            \t\t\t"name": "(lldbclient.py) Attach test",
+                            \t\t\t"type": "lldb",
+                            \t\t\t"processCreateCommands": [
+                            \t\t\t\t"gdb-remote 123",
+                            \t\t\t\t"test"
+                            \t\t\t]
+                            \t\t}
+                            \t\t// #lldbclient-generated-end
+                            \t]
+                            }
+                         """))
+
+    def test_preserve_trailing_whitespace(self) -> None:
+        dst = textwrap.dedent("""\
+            // #lldbclient-generated-begin \t
+            // #lldbclient-generated-end\t """)
+        to_insert = 'foo'
+        self.assertEqual(gdbclient.insert_commands_into_vscode_config(dst,
+                                                                      to_insert),
+                         textwrap.dedent("""\
+                            // #lldbclient-generated-begin \t
+                            foo
+                            // #lldbclient-generated-end\t """))
+
+    def test_fail_if_no_begin(self) -> None:
+        dst = textwrap.dedent("""\
+            // #lldbclient-generated-end""")
+        with self.assertRaisesRegex(ValueError, 'Did not find begin marker line'):
+            gdbclient.insert_commands_into_vscode_config(dst, 'foo')
+
+    def test_fail_if_no_end(self) -> None:
+        dst = textwrap.dedent("""\
+            // #lldbclient-generated-begin""")
+        with self.assertRaisesRegex(ValueError, 'Unterminated begin marker at line 1'):
+            gdbclient.insert_commands_into_vscode_config(dst, 'foo')
+
+    def test_fail_if_begin_has_extra_text(self) -> None:
+        dst = textwrap.dedent("""\
+            // #lldbclient-generated-begin text
+            // #lldbclient-generated-end""")
+        with self.assertRaisesRegex(ValueError, 'Did not find begin marker line'):
+            gdbclient.insert_commands_into_vscode_config(dst, 'foo')
+
+    def test_fail_if_end_has_extra_text(self) -> None:
+        dst = textwrap.dedent("""\
+            // #lldbclient-generated-begin
+            // #lldbclient-generated-end text""")
+        with self.assertRaisesRegex(ValueError, 'Unterminated begin marker at line 1'):
+            gdbclient.insert_commands_into_vscode_config(dst, 'foo')
+
+    def test_fail_if_begin_end_swapped(self) -> None:
+        dst = textwrap.dedent("""\
+            // #lldbclient-generated-end
+            // #lldbclient-generated-begin""")
+        with self.assertRaisesRegex(ValueError, 'Unterminated begin marker at line 2'):
+            gdbclient.insert_commands_into_vscode_config(dst, 'foo')
+
+
 if __name__ == '__main__':
     unittest.main(verbosity=2)