pw_tool: Create a basic CLI tool framework

Adds a basic framework for a CLI tool for future features. At the time
of this commit, this only includes basic "proof of concept" style
commands, but can and will be extended to more functionality.

Change-Id: I0445384bae7b763d1f1301e427dbb6316faf9ec2
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/29880
Commit-Queue: Jason Graffius <jgraff@google.com>
Reviewed-by: Alexei Frolov <frolv@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
diff --git a/BUILD.gn b/BUILD.gn
index 1d1e617..7cd01b9 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -185,6 +185,12 @@
 
     # Add target-specific images.
     deps += pw_TARGET_APPLICATIONS
+
+    # Add the pw_tool target to be built on host.
+    if (defined(pw_toolchain_SCOPE.is_host_toolchain) &&
+        pw_toolchain_SCOPE.is_host_toolchain) {
+      deps += [ "$dir_pw_tool" ]
+    }
   }
 
   group("host_tools") {
@@ -218,6 +224,7 @@
       "$dir_pw_sync",
       "$dir_pw_sys_io",
       "$dir_pw_thread",
+      "$dir_pw_tool",
       "$dir_pw_trace",
       "$dir_pw_unit_test",
       "$dir_pw_varint",
diff --git a/modules.gni b/modules.gni
index 54c92a3..341dbb1 100644
--- a/modules.gni
+++ b/modules.gni
@@ -97,6 +97,7 @@
   dir_pw_thread_threadx = get_path_info("pw_thread_threadx", "abspath")
   dir_pw_third_party = get_path_info("third_party", "abspath")
   dir_pw_tokenizer = get_path_info("pw_tokenizer", "abspath")
+  dir_pw_tool = get_path_info("pw_tool", "abspath")
   dir_pw_toolchain = get_path_info("pw_toolchain", "abspath")
   dir_pw_trace = get_path_info("pw_trace", "abspath")
   dir_pw_trace_tokenized = get_path_info("pw_trace_tokenized", "abspath")
diff --git a/pw_tool/BUILD b/pw_tool/BUILD
new file mode 100644
index 0000000..304ec5b
--- /dev/null
+++ b/pw_tool/BUILD
@@ -0,0 +1,30 @@
+# 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
+# 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.
+
+load(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_binary",
+)
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_binary(
+    name = "pw_tool",
+    srcs = [ "main.cc" ],
+    deps = [
+        "//pw_log",
+        "//pw_polyfill",
+    ]
+)
+
diff --git a/pw_tool/BUILD.gn b/pw_tool/BUILD.gn
new file mode 100644
index 0000000..522ae3a
--- /dev/null
+++ b/pw_tool/BUILD.gn
@@ -0,0 +1,25 @@
+# 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
+# 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.
+
+import("//build_overrides/pigweed.gni")
+import("$dir_pw_build/target_types.gni")
+
+pw_executable("pw_tool") {
+  output_name = "pw_tool"
+  deps = [
+    "$dir_pw_log",
+    "$dir_pw_polyfill",
+  ]
+  sources = [ "main.cc" ]
+}
diff --git a/pw_tool/main.cc b/pw_tool/main.cc
new file mode 100644
index 0000000..b716276
--- /dev/null
+++ b/pw_tool/main.cc
@@ -0,0 +1,176 @@
+// 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
+// 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.
+
+#include <algorithm>
+#include <cctype>
+#include <functional>
+#include <iostream>
+#include <span>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+#include <vector>
+
+#include "pw_log/log.h"
+
+namespace {
+
+// String used to prompt for user input in the CLI loop.
+constexpr char kPrompt[] = ">";
+
+// Convert the provided string to a lowercase equivalent.
+std::string ToLower(std::string_view view) {
+  std::string str{view};
+  std::transform(str.begin(), str.end(), str.begin(), [](char c) {
+    return std::tolower(c);
+  });
+  return str;
+}
+
+// Scan an input line for tokens, returning a vector containing each token.
+// Tokens are either whitespace delimited strings or a quoted string which may
+// contain spaces and is terminated by another quote. When delimiting by
+// whitespace any consecutive sequence of whitespace is treated as a single
+// delimiter.
+//
+// For example, the tokenization of the following line:
+//
+//   The duck said "quack, quack" before   eating   its bread
+//
+// Would result in the following tokens:
+//
+//   ["The", "duck", "said", "quack, quack", "before", "eating", "its", "bread"]
+//
+std::vector<std::string_view> TokenizeLine(std::string_view line) {
+  size_t token_start = 0;
+  size_t index = 0;
+  bool in_quote = false;
+  std::vector<std::string_view> tokens;
+
+  while (index < line.size()) {
+    // Trim leading/trailing whitespace for each token.
+    while (index < line.size() && std::isspace(line[index])) {
+      ++index;
+    }
+
+    if (index >= line.size()) {
+      // Have reached the end and no further tokens remain.
+      break;
+    }
+
+    token_start = index++;
+    if (line[token_start] == '"') {
+      in_quote = true;
+      // Don't include the quote character.
+      ++token_start;
+    }
+
+    // In a token, scan for the end of the token.
+    while (index < line.size()) {
+      if ((in_quote && line[index] == '"') ||
+          (!in_quote && std::isspace(line[index]))) {
+        break;
+      }
+      ++index;
+    }
+
+    if (index >= line.size() && in_quote) {
+      PW_LOG_WARN("Assuming closing quote at EOL.");
+    }
+
+    tokens.push_back(line.substr(token_start, index - token_start));
+    in_quote = false;
+    ++index;
+  }
+
+  return tokens;
+}
+
+// Context supplied to (and mutable by) each command.
+struct CommandContext {
+  // When set to `true`, the CLI will exit once the active command returns.
+  bool quit = false;
+};
+
+// Commands are given mutable CommandContext and a span tokens in the line of
+// the command.
+using Command =
+    std::function<bool(CommandContext*, std::span<std::string_view>)>;
+
+// Echoes all arguments provided to cout.
+bool CommandEcho(CommandContext* /*context*/,
+                 std::span<std::string_view> tokens) {
+  bool first = true;
+  for (const auto& token : tokens.subspan(1)) {
+    if (!first) {
+      std::cout << ' ';
+    }
+
+    std::cout << token;
+    first = false;
+  }
+  std::cout << std::endl;
+
+  return true;
+}
+
+// Quit the CLI.
+bool CommandQuit(CommandContext* context,
+                 std::span<std::string_view> /*tokens*/) {
+  context->quit = true;
+  return true;
+}
+
+}  // namespace
+
+int main(int /*argc*/, char* /*argv*/[]) {
+  CommandContext context;
+  std::unordered_map<std::string, Command> commands{
+      {"echo", CommandEcho},
+      {"exit", CommandQuit},
+      {"quit", CommandQuit},
+  };
+
+  // Enter CLI loop.
+  while (true) {
+    // Prompt for input.
+    std::string line;
+    std::cout << kPrompt << ' ' << std::flush;
+    std::getline(std::cin, line);
+
+    // Tokenize provided line.
+    auto tokens = TokenizeLine(line);
+    if (tokens.empty()) {
+      continue;
+    }
+
+    // Search for provided command.
+    auto it = commands.find(ToLower(tokens[0]));
+    if (it == commands.end()) {
+      PW_LOG_ERROR("Unrecognized command \"%.*s\".",
+                   static_cast<int>(tokens[0].size()),
+                   tokens[0].data());
+      continue;
+    }
+
+    // Invoke the command.
+    Command command = it->second;
+    command(&context, tokens);
+    if (context.quit) {
+      break;
+    }
+  }
+
+  return EXIT_SUCCESS;
+}