pw_build: Update pw_error template

- Display full target names, including the toolchain, for build errors.
- Print the dependency path that caused the error using gn path.
- Add message_lines argument to simplify multi-line messages.
- Replace null_backend.py with pw_error.
- Ensure that pw_error works without the Pigweed Python packages
  installed.

Example error if pw_log_BACKEND is unset:

9:28:27 ERR
9:28:27 ERR Build error for //pw_log:pw_log.NO_BACKEND_SET(//targets/host:host_clang_debug):
9:28:27 ERR
9:28:27 ERR   Attempted to build the //pw_log:pw_log facade with no backend.
9:28:27 ERR
9:28:27 ERR   If you are using this facade, ensure you have configured a backend
9:28:27 ERR   properly. The build arg for the facade must be set to a valid
9:28:27 ERR   backend in the toolchain. For example, you may need to add a line
9:28:27 ERR   like the following to the toolchain's .gni file:
9:28:27 ERR
9:28:27 ERR     pw_log_BACKEND = "//path/to/the:backend"
9:28:27 ERR
9:28:27 ERR   If you are NOT using this facade, this error may have been triggered
9:28:27 ERR   by trying to build all targets.
9:28:27 ERR
9:28:28 ERR Dependency path to this target:
9:28:28 ERR
9:28:28 ERR   gn path out //:default "//pw_log:pw_log.NO_BACKEND_SET(//targets/host:host_clang_debug)"
//:default --[private]-->
//:host --[private]-->
//:pigweed_default(//targets/host:host_clang_debug) --[private]-->
//pw_trace:trace_example_basic(//targets/host:host_clang_debug) --[private]-->
//pw_log:pw_log(//targets/host:host_clang_debug) --[public]-->
//pw_log:pw_log.NO_BACKEND_SET(//targets/host:host_clang_debug)

Showing one of 75 "interesting" non-data paths. 0 of them are public.
Use --all to print all paths.

Fixed: 336
Change-Id: Ida252e1ae9956822ec0efbbfc82bad60f58de5e8
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/36240
Pigweed-Auto-Submit: Wyatt Hepler <hepler@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
diff --git a/pw_build/error.gni b/pw_build/error.gni
index 6e7994a..a7c0e69 100644
--- a/pw_build/error.gni
+++ b/pw_build/error.gni
@@ -14,23 +14,39 @@
 
 import("python_action.gni")
 
-# Prints an error message and exits the build unsuccessfully.
+# Prints an error message and exits the build unsuccessfully. Either 'message'
+# or 'message_lines' must be specified, but not both.
 #
 # Args:
-#   message: The message to print.
+#   message: The message to print. Use \n for newlines.
+#   message_lines: List of lines to use for the message.
 #
 template("pw_error") {
-  assert(defined(invoker.message) && invoker.message != "",
-         "pw_error requires an error message")
+  assert(
+      defined(invoker.message) != defined(invoker.message_lines),
+      "pw_error requires either a 'message' string or a 'message_lines' list")
 
-  pw_python_action(target_name) {
+  if (defined(invoker.message_lines)) {
+    _message = string_join("\n", invoker.message_lines)
+  } else {
+    _message = invoker.message
+  }
+  assert(_message != "", "The message cannot be empty")
+
+  action(target_name) {
     script = "$dir_pw_build/py/pw_build/error.py"
     args = [
       "--target",
-      get_label_info(target_name, "label_no_toolchain"),
+      get_label_info(":$target_name", "label_with_toolchain"),
       "--message",
-      invoker.message,
+      _message,
+      "--root",
+      rebase_path("//"),
+      "--out",
+      rebase_path(root_build_dir),
     ]
-    stamp = true
+
+    # This output file is never created.
+    outputs = [ "$target_gen_dir/$target_name.build_error" ]
   }
 }
diff --git a/pw_build/facade.gni b/pw_build/facade.gni
index 4beee92..517762b 100644
--- a/pw_build/facade.gni
+++ b/pw_build/facade.gni
@@ -14,7 +14,7 @@
 
 import("//build_overrides/pigweed.gni")
 
-import("$dir_pw_build/python_action.gni")
+import("$dir_pw_build/error.gni")
 import("$dir_pw_build/target_types.gni")
 
 # Declare a facade.
@@ -100,17 +100,35 @@
   }
 
   if (invoker.backend == "") {
+    # Try to guess the name of the facade's backend variable.
+    _dir = get_path_info(get_label_info(":$target_name", "dir"), "name")
+    if (target_name == _dir) {
+      _varname = target_name + "_BACKEND"
+    } else {
+      # There is no way to capitalize this string in GN, so use <FACADE_NAME>
+      # instead of the lowercase target name.
+      _varname = _dir + "_<FACADE_NAME>_BACKEND"
+    }
+
     # If backend is not set to anything, create a script that emits an error.
     # This will be added as a data dependency to the actual target, so that
     # attempting to build the facade without a backend fails with a relevant
     # error message.
-    _main_target_name = target_name
-
-    pw_python_action(_main_target_name + ".NO_BACKEND_SET") {
-      stamp = true
-      script = "$dir_pw_build/py/pw_build/null_backend.py"
-      args = [ _main_target_name ]
-      not_needed(invoker, "*")
+    pw_error(target_name + ".NO_BACKEND_SET") {
+      _label = get_label_info(":${invoker.target_name}", "label_no_toolchain")
+      message_lines = [
+        "Attempted to build the $_label facade with no backend.",
+        "",
+        "If you are using this facade, ensure you have configured a backend ",
+        "properly. The build arg for the facade must be set to a valid ",
+        "backend in the toolchain. For example, you may need to add a line ",
+        "like the following to the toolchain's .gni file:",
+        "",
+        "  $_varname = \"//path/to/the:backend\"",
+        "",
+        "If you are NOT using this facade, this error may have been triggered ",
+        "by trying to build all targets.",
+      ]
     }
   }
 
@@ -129,7 +147,7 @@
       public_deps += [ invoker.backend ]
     } else {
       # If the backend is not set, depend on the *.NO_BACKEND_SET target.
-      public_deps += [ ":$_main_target_name" + ".NO_BACKEND_SET" ]
+      public_deps += [ ":$target_name.NO_BACKEND_SET" ]
     }
   }
 }
diff --git a/pw_build/py/BUILD.gn b/pw_build/py/BUILD.gn
index 2355db7..5287fb5 100644
--- a/pw_build/py/BUILD.gn
+++ b/pw_build/py/BUILD.gn
@@ -28,7 +28,6 @@
     "pw_build/host_tool.py",
     "pw_build/mirror_tree.py",
     "pw_build/nop.py",
-    "pw_build/null_backend.py",
     "pw_build/python_runner.py",
     "pw_build/python_wheels.py",
     "pw_build/zip.py",
diff --git a/pw_build/py/pw_build/error.py b/pw_build/py/pw_build/error.py
index d98d55d..9d0f41e 100644
--- a/pw_build/py/pw_build/error.py
+++ b/pw_build/py/pw_build/error.py
@@ -1,4 +1,4 @@
-# Copyright 2020 The Pigweed Authors
+# Copyright 2021 The Pigweed Authors
 #
 # 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
@@ -15,32 +15,70 @@
 
 import argparse
 import logging
+import os
+from pathlib import Path
+import subprocess
 import sys
 
-import pw_cli.log
+try:
+    from pw_cli.log import install as setup_logging
+except ImportError:
+    from logging import basicConfig as setup_logging  # type: ignore
 
 _LOG = logging.getLogger(__name__)
 
 
-def _parse_args():
+def _parse_args() -> argparse.Namespace:
     parser = argparse.ArgumentParser(description=__doc__)
-    parser.add_argument('--message', help='Error message to print')
-    parser.add_argument('--target', help='GN target in which error occurred')
+    parser.add_argument('--message',
+                        required=True,
+                        help='Error message to print')
+    parser.add_argument('--target',
+                        required=True,
+                        help='GN target in which the error occurred')
+    parser.add_argument('--root', required=True, type=Path, help='GN root')
+    parser.add_argument('--out', required=True, type=Path, help='GN out dir')
     return parser.parse_args()
 
 
-def main(message: str, target: str) -> int:
+def main(message: str, target: str, root: Path, out: Path) -> int:
+    """Logs the error message and returns 1."""
+
     _LOG.error('')
-    _LOG.error('Build error:')
+    _LOG.error('Build error for %s:', target)
     _LOG.error('')
+
     for line in message.split('\\n'):
         _LOG.error('  %s', line)
+
     _LOG.error('')
-    _LOG.error('(in %s)', target)
-    _LOG.error('')
+
+    gn_cmd = subprocess.run(
+        ['gn', 'path', f'--root={root}', out, '//:default', target],
+        capture_output=True)
+    path_info = gn_cmd.stdout.decode(errors='replace').rstrip()
+
+    relative_out = os.path.relpath(out, root)
+
+    if gn_cmd.returncode == 0 and 'No non-data paths found' not in path_info:
+        _LOG.error('Dependency path to this target:')
+        _LOG.error('')
+        _LOG.error('  gn path %s //:default "%s"\n%s', relative_out, target,
+                   path_info)
+        _LOG.error('')
+    else:
+        _LOG.error(
+            'Run this command to see the build dependency path to this target:'
+        )
+        _LOG.error('')
+        _LOG.error('  gn path %s <target> "%s"', relative_out, target)
+        _LOG.error('')
+        _LOG.error('where <target> is the GN target you are building.')
+        _LOG.error('')
+
     return 1
 
 
 if __name__ == '__main__':
-    pw_cli.log.install()
+    setup_logging()
     sys.exit(main(**vars(_parse_args())))
diff --git a/pw_build/py/pw_build/null_backend.py b/pw_build/py/pw_build/null_backend.py
deleted file mode 100644
index adc61e7..0000000
--- a/pw_build/py/pw_build/null_backend.py
+++ /dev/null
@@ -1,39 +0,0 @@
-# Copyright 2019 The Pigweed Authors
-#
-# 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
-#
-#     https://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.
-"""Script that emits a helpful error when a facade is used without a backend."""
-
-import argparse
-import sys
-
-
-def parse_args():
-    """Parses command-line arguments."""
-
-    parser = argparse.ArgumentParser(
-        description='Emits an error when a facade has a null backend')
-    parser.add_argument('facade_name', help='The facade with a null backend')
-    return parser.parse_args()
-
-
-def main():
-    args = parse_args()
-    print(f'ERROR: {args.facade_name} tried to build without a backend.')
-    print('If you are using this module, ensure you have configured a backend '
-          'properly. If you are NOT using this module, this error may have '
-          'been triggered by trying to build all targets.')
-    sys.exit(1)
-
-
-if __name__ == '__main__':
-    main()