pw_arduino_build: Arduino library searching support

- Adds commands to return arduino library source files and include dirs.
- Example GN rule in targets/arduino/target_docs.rst
- Show error if a .elf doesn't exist in the unit_test_runner

Change-Id: Ic317c5c061799a7e02fdad834c685d8cb37ba9ce
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/23460
Reviewed-by: Keir Mierle <keir@google.com>
Reviewed-by: David Rogers <davidrogers@google.com>
Commit-Queue: Anthony DiGirolamo <tonymd@google.com>
diff --git a/pw_arduino_build/py/pw_arduino_build/__main__.py b/pw_arduino_build/py/pw_arduino_build/__main__.py
index 503630a..4a17072 100644
--- a/pw_arduino_build/py/pw_arduino_build/__main__.py
+++ b/pw_arduino_build/py/pw_arduino_build/__main__.py
@@ -77,10 +77,9 @@
 
 
 def show_command_print_string_list(args, string_list: List[str]):
-    join_token = " "
-    if args.delimit_with_newlines:
-        join_token = "\n"
-    print(join_token.join(string_list))
+    if string_list:
+        join_token = "\n" if args.delimit_with_newlines else " "
+        print(join_token.join(string_list))
 
 
 def show_command_print_flag_string(args, flag_string):
@@ -241,12 +240,18 @@
         for tool_name in tools:
             print(tool_name)
 
+    elif args.library_include_dirs:
+        show_command_print_string_list(args, builder.library_include_dirs())
+
     elif args.library_includes:
         show_command_print_string_list(args, builder.library_includes())
 
     elif args.library_c_files:
         show_command_print_string_list(args, builder.library_c_files())
 
+    elif args.library_s_files:
+        show_command_print_string_list(args, builder.library_s_files())
+
     elif args.library_cpp_files:
         show_command_print_string_list(args, builder.library_cpp_files())
 
@@ -294,6 +299,9 @@
         "--project-source-path",
         default=project_source_path,
         help="Project directory. Default: '{}'".format(project_source_path))
+    parser.add_argument("--library-path",
+                        default="libraries",
+                        help="Path to Arduino Library directory.")
     parser.add_argument(
         "--build-project-name",
         default=build_project_name,
@@ -341,7 +349,7 @@
     return defaults
 
 
-def load_config_file(args, default_options):
+def load_config_file(args):
     """Load a config file and merge with command line options.
 
     Command line takes precedence over values loaded from a config file."""
@@ -353,6 +361,8 @@
     if not args.config_file:
         return
 
+    default_options = get_default_options()
+
     commandline_options = {
         # Global option
         "arduino_package_path": args.arduino_package_path,
@@ -397,11 +407,8 @@
             jfile.write(encoded_json)
 
 
-def main():
-    """Main command line function.
-
-    Parses command line args and dispatches to sub `*_command()` functions.
-    """
+def _parse_args() -> argparse.Namespace:
+    """Setup argparse and parse command line args."""
     def log_level(arg: str) -> int:
         try:
             return getattr(logging, arg.upper())
@@ -481,6 +488,7 @@
     show_parser.add_argument("--delimit-with-newlines",
                              help="Separate flag output with newlines.",
                              action="store_true")
+    show_parser.add_argument("--library-names", nargs="+", type=str)
 
     output_group = show_parser.add_mutually_exclusive_group(required=True)
     output_group.add_argument("--c-compile", action="store_true")
@@ -513,7 +521,9 @@
     output_group.add_argument("--upload-tools", action="store_true")
     output_group.add_argument("--upload-command")
     output_group.add_argument("--library-includes", action="store_true")
+    output_group.add_argument("--library-include-dirs", action="store_true")
     output_group.add_argument("--library-c-files", action="store_true")
+    output_group.add_argument("--library-s-files", action="store_true")
     output_group.add_argument("--library-cpp-files", action="store_true")
     output_group.add_argument("--core-c-files", action="store_true")
     output_group.add_argument("--core-s-files", action="store_true")
@@ -543,8 +553,16 @@
 
     run_parser.set_defaults(func=run_command)
 
+    return parser.parse_args()
+
+
+def main():
+    """Main command line function.
+
+    Dispatches command line invocations to sub `*_command()` functions.
+    """
     # Parse command line arguments.
-    args = parser.parse_args()
+    args = _parse_args()
     _LOG.debug(_pretty_format(args))
 
     log.install(args.loglevel)
@@ -557,7 +575,7 @@
                 args.compiler_path_override)))
         args.compiler_path_override = compiler_path_override
 
-    load_config_file(args, default_options)
+    load_config_file(args)
 
     if args.subcommand == "install-core":
         args.func(args)
@@ -567,7 +585,7 @@
                                  args.arduino_package_name)
         builder.load_board_definitions()
         args.func(args, builder)
-    else:
+    else:  # args.subcommand in ["run", "show"]
         check_for_missing_args(args)
         builder = ArduinoBuilder(
             args.arduino_package_path,
@@ -576,6 +594,8 @@
             build_project_name=args.build_project_name,
             project_path=args.project_path,
             project_source_path=args.project_source_path,
+            library_path=getattr(args, 'library_path', None),
+            library_names=getattr(args, 'library_names', None),
             compiler_path_override=args.compiler_path_override)
         builder.load_board_definitions()
         builder.select_board(args.board, args.menu_options)
diff --git a/pw_arduino_build/py/pw_arduino_build/builder.py b/pw_arduino_build/py/pw_arduino_build/builder.py
index 43b5ac3..fce58b6 100755
--- a/pw_arduino_build/py/pw_arduino_build/builder.py
+++ b/pw_arduino_build/py/pw_arduino_build/builder.py
@@ -75,6 +75,8 @@
                  build_path=None,
                  project_path=None,
                  project_source_path=None,
+                 library_path=None,
+                 library_names=None,
                  build_project_name=None,
                  compiler_path_override=False):
         self.arduino_path = arduino_path
@@ -87,6 +89,9 @@
         self.compiler_path_override = compiler_path_override
         self.variant_includes = ""
         self.build_variant_path = False
+        self.library_names = library_names
+        self.library_path = os.path.realpath(
+            os.path.expanduser(os.path.expandvars(library_path)))
 
         self.compiler_path_override_binaries = []
         if self.compiler_path_override:
@@ -958,9 +963,13 @@
         # - Else lib folder as root include -Ilibraries/libname
         #   (exclude source files in the examples folder in this case)
 
-        library_path = os.path.join(self.project_path, "libraries")
+        library_path = self.library_path
+        folder_patterns = ["*"]
+        if self.library_names:
+            folder_patterns = self.library_names
 
-        library_folders = file_operations.find_files(library_path, ["*"],
+        library_folders = file_operations.find_files(library_path,
+                                                     folder_patterns,
                                                      directories_only=True)
         library_source_root_folders = []
         for lib in library_folders:
@@ -973,6 +982,9 @@
 
         return library_source_root_folders
 
+    def library_include_dirs(self):
+        return [Path(lib).as_posix() for lib in self.library_folders()]
+
     def library_includes(self):
         include_args = []
         library_folders = self.library_folders()
@@ -986,13 +998,15 @@
         for lib_dir in library_folders:
             for file_path in file_operations.find_files(lib_dir, [pattern]):
                 if not file_path.startswith("examples"):
-                    sources.append(
-                        os.path.relpath(os.path.join(lib_dir, file_path)))
+                    sources.append((Path(lib_dir) / file_path).as_posix())
         return sources
 
     def library_c_files(self):
         return self.library_files("**/*.c")
 
+    def library_s_files(self):
+        return self.library_files("**/*.S")
+
     def library_cpp_files(self):
         return self.library_files("**/*.cpp")
 
diff --git a/pw_arduino_build/py/pw_arduino_build/unit_test_runner.py b/pw_arduino_build/py/pw_arduino_build/unit_test_runner.py
index 0ef5d8f..4fa6011 100755
--- a/pw_arduino_build/py/pw_arduino_build/unit_test_runner.py
+++ b/pw_arduino_build/py/pw_arduino_build/unit_test_runner.py
@@ -58,11 +58,20 @@
     """Exception raised when a given core does not support unit testing."""
 
 
+def valid_file_name(arg):
+    file_path = Path(os.path.expandvars(arg)).absolute()
+    if not file_path.is_file():
+        raise argparse.ArgumentTypeError(f"'{arg}' does not exist.")
+    return file_path
+
+
 def parse_args():
     """Parses command-line arguments."""
 
     parser = argparse.ArgumentParser(description=__doc__)
-    parser.add_argument('binary', help='The target test binary to run')
+    parser.add_argument('binary',
+                        help='The target test binary to run',
+                        type=valid_file_name)
     parser.add_argument('--port',
                         help='The name of the serial port to connect to when '
                         'running tests')
@@ -327,7 +336,7 @@
     ]
 
     # .elf file location args.
-    binary = Path(os.path.expandvars(args.binary)).absolute()
+    binary = args.binary
     build_path = binary.parent.as_posix()
     arduino_builder_args += ["--build-path", build_path]
     build_project_name = binary.name
diff --git a/targets/arduino/target_docs.rst b/targets/arduino/target_docs.rst
index 63f07b5..5db55e8 100644
--- a/targets/arduino/target_docs.rst
+++ b/targets/arduino/target_docs.rst
@@ -199,3 +199,61 @@
 
 - Requires a GUI (or X11 on Linux).
 - Can only flash one board at a time.
+
+GN Target Example
+=================
+
+Here is an example `pw_executable` gn rule that includes some Teensyduino
+libraries.
+
+.. code:: text
+
+  import("//build_overrides/pigweed.gni")
+  import("$dir_pw_arduino_build/arduino.gni")
+  import("$dir_pw_build/target_types.gni")
+
+  _library_args = [
+    "--library-path",
+    rebase_path(
+        "$dir_pw_third_party_arduino/cores/teensy/hardware/teensy/avr/libraries"
+    ),
+    "--library-names",
+    "Time",
+    "Wire",
+  ]
+
+  pw_executable("my_app") {
+    # All Library Sources
+    _library_c_files = exec_script(
+            arduino_builder_script,
+            arduino_show_command_args + _library_args + [
+              "--library-c-files"
+            ],
+            "list lines")
+    _library_cpp_files = exec_script(
+            arduino_builder_script,
+            arduino_show_command_args + _library_args + [
+              "--library-cpp-files"
+            ],
+            "list lines")
+
+    sources = [ "main.cc" ] + _library_c_files + _library_cpp_files
+
+    deps = [
+      "$dir_pw_hex_dump",
+      "$dir_pw_log",
+      "$dir_pw_string",
+    ]
+
+    include_dirs = exec_script(arduino_builder_script,
+                               arduino_show_command_args + _library_args +
+                                   [ "--library-include-dirs" ],
+                               "list lines")
+
+    # Required if using Arduino.h and any Arduino API functions
+    if (dir_pw_third_party_arduino != "") {
+      remove_configs = [ "$dir_pw_build:strict_warnings" ]
+      deps += [ "$dir_pw_third_party_arduino:arduino_core_sources" ]
+    }
+  }
+