[netdump] Netdump filter parser external interface

This CL is part of a series that adds packet filtering capability
to netdump using a filter language with similar syntax to the Berkeley
Packet Filter (BPF).

This CL defines the public interface of the filter string parser.
There is an interface to client code that wishes to use the parser,
as well as a builder interface that will interact with specific
implementations of packet filter objects. Internal structures in the
parser that track state during a parse are also defined.

Follow-up CL will include:
- Syntax logic for the filter language.
- Adapter of the parser interface to the checked-in filter tree
  nodes implementation.

Bug: CONN-99
Test: Build with `--with-base //garnet/packages/tests:zircon` as a
parameter to `fx set`, then `runtests -t netdump-test` on the target.

Change-Id: I2475206768c11f809be0e8b949d299aa773cfa92
diff --git a/zircon/system/uapp/netdump/BUILD.gn b/zircon/system/uapp/netdump/BUILD.gn
index 8794d06..aef2ba8 100644
--- a/zircon/system/uapp/netdump/BUILD.gn
+++ b/zircon/system/uapp/netdump/BUILD.gn
@@ -6,6 +6,7 @@
   sources = [
     "filter.cpp",
     "netdump.cpp",
+    "parser.cpp",
     "tokens.cpp",
   ]
   deps = [
@@ -31,12 +32,14 @@
   sources = [
     "filter.cpp",
     "test/filter_test.cpp",
+    "test/parser_test.cpp",
     "test/tokens_test.cpp",
     "tokens.cpp",
   ]
   include_dirs = [ "." ]
   deps = [
     ":common",
+    "$zx/system/ulib/mock-function",
     "$zx/system/ulib/zxtest",
   ]
 }
diff --git a/zircon/system/uapp/netdump/filter_builder.h b/zircon/system/uapp/netdump/filter_builder.h
new file mode 100644
index 0000000..6092dba
--- /dev/null
+++ b/zircon/system/uapp/netdump/filter_builder.h
@@ -0,0 +1,82 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// This file specifies a pure virtual interface, `FilterBuilder`, of filter operations the parser
+// may emit. This interface is intended to be reflective of the filter language the parser supports,
+// rather than be tied to any particular filter implementation. Therefore it should evolve,
+// for example becoming more abstract and/or flexible, as the filter language becomes more
+// expressive. During parse, the client must pass a concrete implementation of `FilterBuilder` to
+// the parser in order to construct filters.
+
+#pragma once
+
+#include <array>
+#include <type_traits>
+
+#include "tokens.h"
+
+namespace netdump::parser {
+
+// As a filter string is parsed, functions declared in this interface are called to emit filters of
+// type `T`. Parsed data is supplied to `FilterBuilder` in host byte order. It is the job of the
+// builder implementations to rearrange the data into the order required by filters. The internal
+// state of a `FilterBuilder` is also allowed to change during a parse. The filter operation methods
+// are therefore not marked `const`, and the correct way to pass a `FilterBuilder` is by pointer
+// rather than `const` reference.
+template <class T>
+class FilterBuilder {
+    // During parsing, filter objects will pass between the parser and `FilterBuilder`.
+    // In general the objects will be moved, and the parser requires `T` to be a move constructible
+    // and move assignable type. This does not prohibit a copyable `T`.
+    static_assert(std::is_move_constructible<T>::value && std::is_move_assignable<T>::value,
+                  "Parser will move construct and move assign filter objects.");
+
+public:
+    // Filter operation methods. Parameters to these methods are:
+    // - Literal data that was parsed from user input, such as a protocol number.
+    // - The qualifier keyword token encountered that specifies the type of data, such as `src`.
+
+    // Frame length.
+    virtual T frame_length(uint16_t length, TokenPtr comparator) = 0;
+
+    // Ethernet II ethertype.
+    virtual T ethertype(uint16_t type) = 0;
+    // MAC address expression.
+    virtual T mac(std::array<uint8_t, ETH_ALEN> address, TokenPtr addr_type) = 0;
+
+    // IP version.
+    virtual T ip_version(uint8_t version) = 0;
+    // IP packet length.
+    virtual T ip_pkt_length(uint8_t version, uint16_t length, TokenPtr comparator) = 0;
+    // IP protocol number.
+    virtual T ip_protocol(uint8_t version, uint8_t protocol) = 0;
+    // IPv4 address.
+    virtual T ipv4_address(uint32_t address, TokenPtr type) = 0;
+    // IPv6 address.
+    virtual T ipv6_address(std::array<uint8_t, IP6_ADDR_LEN> address, TokenPtr addr_type) = 0;
+
+    // Port ranges as pairs of (begin, end) ports.
+    virtual T ports(std::vector<std::pair<uint16_t, uint16_t>> ranges, TokenPtr port_type) = 0;
+
+    // Logical operations.
+    // In general, if a filter operation uses another `T` filter as input, like the case here,
+    // the input objects are passed by value and implementations of `FilterBuilder<T>` should
+    // move them as necessary.
+    virtual T negation(T filter) = 0;           // Logical `NOT`.
+    virtual T conjunction(T left, T right) = 0; // Logical `AND`.
+    virtual T disjunction(T left, T right) = 0; // Logical `OR`.
+
+    FilterBuilder(const FilterBuilder&) = delete;
+    FilterBuilder& operator=(const FilterBuilder&) = delete;
+
+protected:
+    // In order to facilitate keyword lookup, construction must be with the same `Tokenizer` used to
+    // lex the tokens passed into the filter operation methods.
+    explicit FilterBuilder<T>(const Tokenizer& tokenizer)
+        : tkz(tokenizer) {}
+
+    const Tokenizer& tkz;
+};
+
+} // namespace netdump::parser
diff --git a/zircon/system/uapp/netdump/parser.cpp b/zircon/system/uapp/netdump/parser.cpp
new file mode 100644
index 0000000..bb1e659
--- /dev/null
+++ b/zircon/system/uapp/netdump/parser.cpp
@@ -0,0 +1,13 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "parser.h"
+
+namespace netdump::parser {
+
+void parser_syntax(std::ostream output) {
+    // TODO(xianglong): Implement.
+}
+
+} // namespace netdump::parser
diff --git a/zircon/system/uapp/netdump/parser.h b/zircon/system/uapp/netdump/parser.h
new file mode 100644
index 0000000..9862c811
--- /dev/null
+++ b/zircon/system/uapp/netdump/parser.h
@@ -0,0 +1,118 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// This is the filter language parser. Given a filter specification string, the goal is to construct
+// an object that represents the filter described by the specification.
+//
+// The parser is composed of a public interface, described in this file, and internal syntax and
+// state implemented in the other parser files. The current design is that the parser is coupled
+// with the lexer, as the knowledge about reserved keywords in the filter language must be shared
+// between them. However, the parser is agnostic to the concrete form the filter object takes.
+// In the classes relating to the parser, the template argument `T` is the type of the filter object
+// that will be constructed.
+//
+// Key to the public parser interface is the `FilterBuilder<T>` class, declared in its own file. Due
+// to template classes, the body of parser code is organized as a series of header files.
+
+#pragma once
+
+#include <ostream>
+#include <sstream>
+#include <variant>
+
+#include "parser_internal.h"
+
+namespace netdump::parser {
+
+// ANSI escape sequences used for highlighting.
+constexpr auto ANSI_HIGHLIGHT = "\033[1;30;43m";
+constexpr auto ANSI_HIGHLIGHT_ERROR = "\033[4;31;43m";
+constexpr auto ANSI_RESET = "\033[0m";
+
+using ParseError = std::string;
+// Clients access the parsing functionality through this class.
+class Parser {
+public:
+    // Specifies the `Tokenizer` to use for parsing.
+    explicit Parser(const Tokenizer& tkz)
+        : tkz_(tkz) {}
+
+    // Attempt a parse of `filter_spec` and return a filter of type `T` built with `builder`.
+    // If the input is successfully parsed, a `T` (variant index 0) is returned.
+    // If parsing is unsuccessful, an `Error` (index 1) is returned that explains the syntax error.
+    template <class T>
+    std::variant<T, ParseError> parse(const std::string& filter_spec,
+                                      FilterBuilder<T>* builder) {
+        Environment env(tkz_.tokenize(filter_spec));
+
+        if (env.at_end()) {
+            return error_result<T>("Filter string expected.\n");
+        }
+
+        std::optional<T> filter = Syntax<T>().parse(false, tkz_, &env, builder);
+
+        if (filter != std::nullopt) {
+            // Parse success, return the filter.
+            return filter_result(*filter);
+        }
+
+        // On parse error, build the error message.
+        std::string msg = highlight_error(filter_spec, &env);
+        return error_result<T>(msg + "\nFilter syntax error: " + env.error_cause + "\n");
+    }
+
+    Parser(const Parser&) = delete;
+    Parser& operator=(const Parser&) = delete;
+
+protected: // Protected instead of private for testing.
+    const Tokenizer& tkz_;
+
+    // Insert some ANSI escape characters to highlight the syntax error in console.
+    // If the error is at the end location, highlight this by reproducing `filter_spec` and
+    // appending an error marker. Otherwise, the the tokens in `env` are reproduced, and the
+    // location where the error occurred is highlighted.
+    std::string highlight_error(const std::string& filter_spec, Environment* env) {
+        if (!env->error_loc.has_value()) {
+            return filter_spec;
+        }
+
+        std::stringstream msg;
+        auto error_loc = *(env->error_loc);
+        if (error_loc == env->end()) {
+            // The mistake is at the end of the input.
+            msg << filter_spec << ANSI_HIGHLIGHT_ERROR << "*" << ANSI_RESET;
+            return msg.str();
+        }
+        // Go to the mistake and highlight it.
+        env->reset();
+        while (!env->at_end()) {
+            if (error_loc == env->cur()) {
+                msg << ANSI_HIGHLIGHT_ERROR << (**env)->get_term() << ANSI_RESET;
+            } else {
+                msg << (**env)->get_term();
+            }
+            ++(*env);
+            if (!env->at_end()) {
+                msg << " ";
+            }
+        }
+        return msg.str();
+    }
+
+    template <class T>
+    inline std::variant<T, ParseError> filter_result(T filter) {
+        return std::variant<T, ParseError>(std::in_place_index_t<0>(), filter);
+    }
+
+    template <class T>
+    inline std::variant<T, ParseError> error_result(std::string msg) {
+        return std::variant<T, ParseError>(std::in_place_index_t<1>(), std::move(msg));
+    }
+};
+
+// Write a human-readable description of the parser syntax to `output`,
+// such as for display on screen. No significant logic should be performed.
+void parser_syntax(std::ostream output);
+
+} // namespace netdump::parser
diff --git a/zircon/system/uapp/netdump/parser_internal.h b/zircon/system/uapp/netdump/parser_internal.h
new file mode 100644
index 0000000..852cff8
--- /dev/null
+++ b/zircon/system/uapp/netdump/parser_internal.h
@@ -0,0 +1,31 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// This file implements the syntax logic for the packet filter language.
+
+#pragma once
+
+#include <arpa/inet.h>
+#include <netinet/if_ether.h>
+
+#include "filter_builder.h"
+#include "parser_state.h"
+
+namespace netdump::parser {
+
+template <class T>
+class Syntax {
+public:
+    // Attempt a parse by recursive descent. The parse state is tracked in `env`.
+    // `parens` should be true if the parse is under parenthesis, otherwise it should be false.
+    // Return null if the specification is invalid. On return, the `env` error data is updated if
+    // there was a syntax mistake.
+    std::optional<T> parse(bool parens, const Tokenizer& tkz, Environment* env,
+                           FilterBuilder<T>* builder) {
+        // TODO(xianglong): Implement.
+        return std::nullopt;
+    }
+};
+
+} // namespace netdump::parser
diff --git a/zircon/system/uapp/netdump/parser_state.h b/zircon/system/uapp/netdump/parser_state.h
new file mode 100644
index 0000000..b8a68ef
--- /dev/null
+++ b/zircon/system/uapp/netdump/parser_state.h
@@ -0,0 +1,110 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// This header defines internal structures used by the parser to help it manage state during
+// a parse.
+
+#pragma once
+
+#include <iterator>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <zircon/assert.h>
+
+#include "tokens.h"
+
+namespace netdump::parser {
+
+using TokenIterator = std::vector<TokenPtr>::iterator;
+
+// Parse state values for when a binary logical operator is encountered.
+enum class ParseOp {
+    NONE,
+    CONJ, // Logical `AND`.
+    DISJ, // Logical `OR`.
+};
+
+class ParseOpState {
+public:
+    // Track if the parse has encountered a binary operation.
+    ParseOp op = ParseOp::NONE;
+    // Track how many negations the current parse is under.
+    // The number of negations is tracked so e.g. `not not` can be differentiated from no negation.
+    size_t negations = 0;
+};
+
+// An `Environment` object represents the parse environment. It can be seen as a state machine that
+// keeps track of the parse cursor, i.e. the token location that the parser has reached.
+// The states are positions along the series of tokens that need to be parser. If an error has been
+// encountered within the parse, data relevant for reporting the error can be recorded here.
+// Exactly one instance of `Environment` should be created at the beginning of a parse attempt for
+// use until the end of the attempt. `Environment` is therefore movable but not copyable to help
+// enforce this.
+class Environment {
+public:
+    // An instance is constructed from a vector of tokens that needs to be parsed.
+    explicit Environment(std::vector<TokenPtr> tokens)
+        : tokens_(std::move(tokens)), begin_(tokens_.begin()), cur_(begin_), end_(tokens_.end()) {}
+
+    // Movable but not copyable.
+    Environment(Environment&& other) = default;
+    Environment& operator=(Environment&& other) = default;
+    Environment(const Environment&) = delete;
+    Environment& operator=(const Environment&) = delete;
+
+    // Return the first token location to be parsed.
+    [[nodiscard]] inline TokenIterator begin() const { return begin_; }
+
+    // Return the current token location under parse.
+    [[nodiscard]] inline TokenIterator cur() const { return cur_; }
+
+    // Return the location beyond the last token to be parsed.
+    [[nodiscard]] inline TokenIterator end() const { return end_; }
+
+    [[nodiscard]] inline bool at_end() const { return cur_ == end_; }
+
+    // Reset the token location under parse to the beginning.
+    // Does not clear the error data.
+    inline void reset() { cur_ = begin_; }
+
+    // Clear the error data.
+    inline void clear_error() {
+        error_cause.clear();
+        error_loc = std::nullopt;
+    }
+
+    inline bool has_error() { return (error_loc != std::nullopt); }
+
+    // Operators to get the token at the current location, and to change the location.
+    TokenPtr operator*() {
+        ZX_DEBUG_ASSERT_MSG(cur_ < end_, "Dereferencing end token location.");
+        return *cur_;
+    }
+    Environment& operator++() {
+        if (cur_ < end_) {
+            ++cur_;
+        }
+        return (*this);
+    }
+    Environment& operator--() {
+        if (cur_ > begin_) {
+            --cur_;
+        }
+        return (*this);
+    }
+
+    // Data for any error that was encountered.
+    std::string error_cause{};
+    std::optional<TokenIterator> error_loc{};
+
+private:
+    std::vector<TokenPtr> tokens_;
+    TokenIterator begin_;
+    TokenIterator cur_;
+    TokenIterator end_;
+};
+
+} // namespace netdump::parser
diff --git a/zircon/system/uapp/netdump/test/parser_test.cpp b/zircon/system/uapp/netdump/test/parser_test.cpp
new file mode 100644
index 0000000..cc118d0
--- /dev/null
+++ b/zircon/system/uapp/netdump/test/parser_test.cpp
@@ -0,0 +1,261 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "parser.h"
+
+#include <sstream>
+
+#include <lib/mock-function/mock-function.h>
+#include <zxtest/zxtest.h>
+
+namespace netdump::parser::test {
+
+// The `FilterBuilder` that builds nothing but calls a mock function for each of the operations.
+class MockFilterBuilder : public FilterBuilder<std::monostate> {
+public:
+    mock_function::MockFunction<std::monostate, uint16_t, TokenPtr> frame_length_mock;
+    std::monostate frame_length(uint16_t length, TokenPtr comparator) override {
+        return frame_length_mock.Call(length, comparator);
+    }
+
+    mock_function::MockFunction<std::monostate, uint16_t> ethertype_mock;
+    std::monostate ethertype(uint16_t type) override {
+        return ethertype_mock.Call(type);
+    }
+
+    mock_function::MockFunction<std::monostate, std::array<uint8_t, ETH_ALEN>, TokenPtr>
+        mac_mock;
+    std::monostate mac(std::array<uint8_t, ETH_ALEN> address, TokenPtr addr_type) override {
+        return mac_mock.Call(address, addr_type);
+    }
+
+    mock_function::MockFunction<std::monostate, uint8_t> ip_version_mock;
+    std::monostate ip_version(uint8_t version) override {
+        return ip_version_mock.Call(version);
+    }
+
+    mock_function::MockFunction<std::monostate, uint8_t, uint16_t, TokenPtr> ip_pkt_length_mock;
+    std::monostate ip_pkt_length(uint8_t version, uint16_t length, TokenPtr comparator) override {
+        return ip_pkt_length_mock.Call(version, length, comparator);
+    }
+
+    mock_function::MockFunction<std::monostate, uint8_t, uint8_t> ip_protocol_mock;
+    std::monostate ip_protocol(uint8_t version, uint8_t protocol) override {
+        return ip_protocol_mock.Call(version, protocol);
+    }
+
+    mock_function::MockFunction<std::monostate, uint32_t, TokenPtr> ipv4_address_mock;
+    std::monostate ipv4_address(uint32_t address, TokenPtr type) override {
+        return ipv4_address_mock.Call(address, type);
+    }
+
+    mock_function::MockFunction<std::monostate, std::array<uint8_t, IP6_ADDR_LEN>, TokenPtr>
+        ipv6_address_mock;
+    std::monostate ipv6_address(std::array<uint8_t, IP6_ADDR_LEN> address, TokenPtr addr_type)
+        override {
+        return ipv6_address_mock.Call(address, addr_type);
+    }
+
+    mock_function::MockFunction<std::monostate, std::vector<std::pair<uint16_t, uint16_t>>,
+                                TokenPtr>
+        ports_mock;
+    std::monostate ports(std::vector<std::pair<uint16_t, uint16_t>> ranges, TokenPtr port_type)
+        override {
+        return ports_mock.Call(ranges, port_type);
+    }
+
+    mock_function::MockFunction<std::monostate, std::monostate> negation_mock;
+    std::monostate negation(std::monostate filter) override {
+        return negation_mock.Call(filter);
+    }
+
+    mock_function::MockFunction<std::monostate, std::monostate, std::monostate> conjunction_mock;
+    std::monostate conjunction(std::monostate left, std::monostate right) override {
+        return conjunction_mock.Call(left, right);
+    }
+
+    mock_function::MockFunction<std::monostate, std::monostate, std::monostate> disjunction_mock;
+    std::monostate disjunction(std::monostate left, std::monostate right) override {
+        return disjunction_mock.Call(left, right);
+    }
+
+    explicit MockFilterBuilder(const Tokenizer& tokenizer)
+        : FilterBuilder<std::monostate>(tokenizer) {}
+
+#define NETDUMP_APPLY_TO_ALL_MOCKS(fn) \
+    do {                               \
+        frame_length_mock.fn();        \
+        ethertype_mock.fn();           \
+        mac_mock.fn();                 \
+        ip_version_mock.fn();          \
+        ip_pkt_length_mock.fn();       \
+        ip_protocol_mock.fn();         \
+        ipv4_address_mock.fn();        \
+        ipv6_address_mock.fn();        \
+        ports_mock.fn();               \
+        negation_mock.fn();            \
+        conjunction_mock.fn();         \
+        disjunction_mock.fn();         \
+    } while (false)
+
+    inline void verify_and_clear_all() {
+        NETDUMP_APPLY_TO_ALL_MOCKS(VerifyAndClear);
+    }
+
+    inline void expect_no_call_all() {
+        NETDUMP_APPLY_TO_ALL_MOCKS(ExpectNoCall);
+    }
+#undef NETDUMP_APPLY_TO_ALL_MOCKS
+};
+
+// Test token cursor transitions in the parse environment.
+TEST(NetdumpParserTests, EnvironmentPlusPlusTest) {
+    Tokenizer tkz{};
+    Environment env(std::vector<TokenPtr>{tkz.PORT, tkz.HOST});
+
+    EXPECT_EQ(tkz.PORT, *env);
+    ++env;
+    EXPECT_EQ(tkz.HOST, *env);
+}
+
+TEST(NetdumpParserTests, EnvironmentMinusMinusTest) {
+    Tokenizer tkz{};
+    Environment env(std::vector<TokenPtr>{tkz.TCP, tkz.IP6});
+
+    EXPECT_EQ(tkz.TCP, *env);
+    ++env;
+    EXPECT_EQ(tkz.IP6, *env);
+    --env;
+    EXPECT_EQ(tkz.TCP, *env);
+}
+
+TEST(NetdumpParserTests, EnvironmentGuardsTest) {
+    Tokenizer tkz{};
+    Environment env(std::vector<TokenPtr>{tkz.ICMP, tkz.ARP});
+
+    EXPECT_EQ(tkz.ICMP, *env);
+    --env;
+    EXPECT_EQ(tkz.ICMP, *env);
+    ++env;
+    ++env;
+    EXPECT_TRUE(env.at_end());
+    ++env;
+    EXPECT_TRUE(env.at_end());
+    --env;
+    EXPECT_EQ(tkz.ARP, *env);
+}
+
+TEST(NetdumpParserTests, EnvironmentEndDereferenceTest) {
+    Tokenizer tkz{};
+    Environment env(std::vector<TokenPtr>{tkz.ICMP});
+    ++env;
+
+    EXPECT_TRUE(env.at_end());
+    ASSERT_DEATH([&env]() { *env; });
+}
+
+TEST(NetdumpParserTests, EnvironmentFullWalkTest) {
+    Tokenizer tkz{};
+    Environment env(std::vector<TokenPtr>{tkz.AND, tkz.DNS, tkz.DHCP, tkz.SRC});
+
+    EXPECT_EQ(env.begin(), env.cur());
+    EXPECT_EQ(tkz.AND, *env);
+    ++env;
+    EXPECT_EQ(tkz.DNS, *env);
+    --env;
+    EXPECT_EQ(tkz.AND, *env);
+    --env;
+    EXPECT_EQ(tkz.AND, *env);
+    ++env;
+    ++env;
+    EXPECT_EQ(tkz.DHCP, *env);
+    ++env;
+    EXPECT_FALSE(env.at_end());
+    EXPECT_EQ(tkz.SRC, *env);
+    ++env;
+    EXPECT_EQ(env.end(), env.cur());
+    ++env;
+    EXPECT_TRUE(env.at_end());
+    --env;
+    EXPECT_EQ(tkz.SRC, *env);
+
+    EXPECT_FALSE(env.has_error());
+    env.error_loc = env.cur();
+    env.error_cause = "cause";
+
+    env.reset();
+    EXPECT_EQ(tkz.AND, *env);
+
+    EXPECT_TRUE(env.has_error());
+    env.clear_error();
+    EXPECT_EQ(std::nullopt, env.error_loc);
+    EXPECT_EQ("", env.error_cause);
+}
+
+// Totally invalid filter string should end up with no filter operation calls after parsing.
+TEST(NetdumpParserTests, ParseErrorTest) {
+    Tokenizer tkz{};
+    MockFilterBuilder builder(tkz);
+    Parser parser(tkz);
+    builder.expect_no_call_all();
+    std::variant<std::monostate, ParseError> filter = parser.parse("mumble jumble", &builder);
+    // Error state as expected if `filter` holds a `ParseError`.
+    EXPECT_TRUE(std::holds_alternative<ParseError>(filter));
+    builder.verify_and_clear_all();
+}
+
+class TestParser : public Parser {
+public:
+    void highlight_error_test() {
+        Environment env(std::vector<TokenPtr>{tkz_.AND, tkz_.DNS, tkz_.DHCP});
+
+        env.error_loc = std::nullopt;
+        // Just returns the string if no error location.
+        // The use of EXPECT_STR_EQ (which only allows C-strings) gives much nicer debug messages
+        // than EXPECT_EQ between `std::string`.
+        EXPECT_STR_EQ("spec", highlight_error("spec", &env).c_str());
+
+        std::stringstream expect_string1;
+        env.error_loc = env.end();
+        expect_string1 << "spec" << ANSI_HIGHLIGHT_ERROR << "*" << ANSI_RESET;
+        // Reproduce spec string and append error marker if error location is at end.
+        EXPECT_STR_EQ(expect_string1.str().c_str(), highlight_error("spec", &env).c_str());
+
+        std::stringstream expect_string2;
+        env.error_loc = env.begin();
+        expect_string2 << ANSI_HIGHLIGHT_ERROR << tkz_.AND->get_term() << ANSI_RESET << " "
+                       << tkz_.DNS->get_term() << " "
+                       << tkz_.DHCP->get_term();
+        EXPECT_STR_EQ(expect_string2.str().c_str(), highlight_error("spec", &env).c_str());
+
+        std::stringstream expect_string3;
+        env.reset();
+        ++env;
+        env.error_loc = env.cur();
+        --env; // This tests error is highlighted by error location, not `env` state.
+        expect_string3 << tkz_.AND->get_term() << " "
+                       << ANSI_HIGHLIGHT_ERROR << tkz_.DNS->get_term() << ANSI_RESET << " "
+                       << tkz_.DHCP->get_term();
+        EXPECT_STR_EQ(expect_string3.str().c_str(), highlight_error("spec", &env).c_str());
+
+        std::stringstream expect_string4;
+        env.reset();
+        ++env;
+        ++env;
+        env.error_loc = env.cur();
+        expect_string4 << tkz_.AND->get_term() << " "
+                       << tkz_.DNS->get_term() << " "
+                       << ANSI_HIGHLIGHT_ERROR << tkz_.DHCP->get_term() << ANSI_RESET;
+        EXPECT_STR_EQ(expect_string4.str().c_str(), highlight_error("spec", &env).c_str());
+    }
+
+    explicit TestParser(const Tokenizer& tokenizer)
+        : Parser(tokenizer) {}
+};
+
+TEST(NetdumpParserTests, HighlightErrorTest) {
+    TestParser(Tokenizer{}).highlight_error_test();
+}
+
+} // namespace netdump::parser::test