From a82645969b7e1d41724904e7754b6393a6232996 Mon Sep 17 00:00:00 2001 From: Juan Posadas Date: Fri, 15 May 2026 14:46:22 -0600 Subject: [PATCH 01/14] hrw4u: rename ASTVisitor to ASTBuilder The class constructs an AST from an ANTLR parse tree; it does not visit one. Rename the module and class to match what they do. --- tools/hrw4u/Makefile | 2 +- tools/hrw4u/src/{ast_visitor.py => ast_builder.py} | 2 +- .../hrw4u/tests/{test_ast_visitor.py => test_ast_builder.py} | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename tools/hrw4u/src/{ast_visitor.py => ast_builder.py} (99%) rename tools/hrw4u/tests/{test_ast_visitor.py => test_ast_builder.py} (99%) diff --git a/tools/hrw4u/Makefile b/tools/hrw4u/Makefile index 24714279b7e..512130021c2 100644 --- a/tools/hrw4u/Makefile +++ b/tools/hrw4u/Makefile @@ -56,7 +56,7 @@ SRC_FILES_HRW4U=src/visitor.py \ src/sandbox.py \ src/kg_visitor.py \ src/ast_nodes.py \ - src/ast_visitor.py + src/ast_builder.py ALL_HRW4U_FILES=$(SHARED_FILES) $(UTILS_FILES) $(SRC_FILES_HRW4U) diff --git a/tools/hrw4u/src/ast_visitor.py b/tools/hrw4u/src/ast_builder.py similarity index 99% rename from tools/hrw4u/src/ast_visitor.py rename to tools/hrw4u/src/ast_builder.py index 4a66ec0a710..0e525efbff4 100644 --- a/tools/hrw4u/src/ast_visitor.py +++ b/tools/hrw4u/src/ast_builder.py @@ -21,7 +21,7 @@ from hrw4u.ast_nodes import * -class ASTVisitor(hrw4uVisitor): +class ASTBuilder(hrw4uVisitor): """ANTLR visitor that walks an HRW4U parse tree and produces an AST for HRW4U.""" # Only visitProgram is overridden from the ANTLR visitor interface; diff --git a/tools/hrw4u/tests/test_ast_visitor.py b/tools/hrw4u/tests/test_ast_builder.py similarity index 99% rename from tools/hrw4u/tests/test_ast_visitor.py rename to tools/hrw4u/tests/test_ast_builder.py index ec919d1f060..48fe1150b0e 100644 --- a/tools/hrw4u/tests/test_ast_visitor.py +++ b/tools/hrw4u/tests/test_ast_builder.py @@ -17,12 +17,12 @@ from hrw4u.ast_nodes import * from utils import parse_input_text -from hrw4u.ast_visitor import ASTVisitor +from hrw4u.ast_builder import ASTBuilder def _build(source: str) -> HRW4UAST: _, tree = parse_input_text(source) - return ASTVisitor().visit(tree) + return ASTBuilder().visit(tree) class TestAssignments: From f339bc8897aeeba4d6b9c722d6adf45b2883ed30 Mon Sep 17 00:00:00 2001 From: Juan Posadas Date: Fri, 15 May 2026 14:54:20 -0600 Subject: [PATCH 02/14] hrw4u: introduce thin AST enums for operators and var-section kind Replace raw-string fields on Assignment, Comparison, LogicalOp, and VarSection with four AST-local enums: AssignOp, CmpOp, BoolOp, and VarSectionKind. These carry identity only (no codegen metadata), so AST consumers pattern-match against semantic names and stay decoupled from grammar spellings; changing '==' to 'eq' in the grammar would only touch the builder, not downstream code. VarSection.scope moves from a raw string ("txn" / "session") to the thin VarSectionKind, keeping the AST free of semantic/codegen concerns. The builder and tests still pass raw strings; this commit changes only the contract. Builder and test updates follow. --- tools/hrw4u/src/ast_nodes.py | 43 ++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/tools/hrw4u/src/ast_nodes.py b/tools/hrw4u/src/ast_nodes.py index acf5bacccb3..1eee451511b 100644 --- a/tools/hrw4u/src/ast_nodes.py +++ b/tools/hrw4u/src/ast_nodes.py @@ -18,9 +18,14 @@ from __future__ import annotations from dataclasses import dataclass +from enum import Enum, auto from typing import Union __all__ = [ + "AssignOp", + "CmpOp", + "BoolOp", + "VarSectionKind", "LiteralStringValue", "IdentValue", "IPValue", @@ -52,6 +57,36 @@ ] +class AssignOp(Enum): + ASSIGN = auto() + PLUS_ASSIGN = auto() + __repr__ = Enum.__str__ + + +class CmpOp(Enum): + EQ = auto() + NEQ = auto() + GT = auto() + LT = auto() + MATCH = auto() + NOT_MATCH = auto() + IN = auto() + NOT_IN = auto() + __repr__ = Enum.__str__ + + +class BoolOp(Enum): + AND = auto() + OR = auto() + __repr__ = Enum.__str__ + + +class VarSectionKind(Enum): + TXN = auto() + SESSION = auto() + __repr__ = Enum.__str__ + + @dataclass(frozen=True, kw_only=True) class LiteralStringValue: raw: str @@ -104,7 +139,7 @@ def from_dotted(name: str) -> Target: @dataclass(frozen=True, kw_only=True) class Assignment(Node): target: Target - operator: str # "=" or "+=" + operator: AssignOp value: ValueExpr @@ -122,14 +157,14 @@ class Break(Node): @dataclass(frozen=True, kw_only=True) class Comparison(Node): left: IdentValue | FunctionCall - operator: str # "==", "!=", ">", "<", "~", "!~", "in", "!in" + operator: CmpOp right: ValueExpr | RegexValue | tuple[ValueExpr, ...] modifiers: tuple[str, ...] @dataclass(frozen=True, kw_only=True) class LogicalOp(Node): - operator: str # "&&" or "||" + operator: BoolOp left: ConditionExpr right: ConditionExpr @@ -184,7 +219,7 @@ class VarDecl(Node): @dataclass(frozen=True, kw_only=True) class VarSection(Node): - scope: str + scope: VarSectionKind declarations: tuple[VarDecl, ...] From 07651bf0226f2fded2ab0e46c4c05a16a51fd17a Mon Sep 17 00:00:00 2001 From: Juan Posadas Date: Mon, 18 May 2026 10:55:15 -0600 Subject: [PATCH 03/14] hrw4u: add hrw4u-ir tool for inspecting intermediate representations Adds a small CLI that takes an HRW4U source file and emits a chosen intermediate representation: the ANTLR concrete syntax tree (cst) or the dataclass AST built by ASTBuilder (ast). Stages are registered in a dict so adding future stages (ast-resolved, ast-validated, ...) is a one-line change. --stage is required so the tool stays explicit about which IR is being inspected as the pipeline grows. Wired into pyproject.toml script-files and the Makefile build target alongside the other scripts. --- tools/hrw4u/Makefile | 2 + tools/hrw4u/pyproject.toml | 2 +- tools/hrw4u/scripts/hrw4u-ir | 88 ++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100755 tools/hrw4u/scripts/hrw4u-ir diff --git a/tools/hrw4u/Makefile b/tools/hrw4u/Makefile index 512130021c2..d66127597ee 100644 --- a/tools/hrw4u/Makefile +++ b/tools/hrw4u/Makefile @@ -31,6 +31,7 @@ SCRIPT_HRW4U=scripts/hrw4u SCRIPT_U4WRH=scripts/u4wrh SCRIPT_LSP=scripts/hrw4u-lsp SCRIPT_KG=scripts/hrw4u-kg +SCRIPT_IR=scripts/hrw4u-ir # Shared source files (will go in hrw4u package) SHARED_FILES=src/common.py \ @@ -197,6 +198,7 @@ build: gen uv run pyinstaller --onedir --name u4wrh --strip $(SCRIPT_U4WRH) uv run pyinstaller --onedir --name hrw4u-lsp --strip $(SCRIPT_LSP) uv run pyinstaller --onedir --name hrw4u-kg --strip $(SCRIPT_KG) + uv run pyinstaller --onedir --name hrw4u-ir --strip $(SCRIPT_IR) # Wheel packaging (adjust pyproject to include both packages if desired) package: gen diff --git a/tools/hrw4u/pyproject.toml b/tools/hrw4u/pyproject.toml index d30dd7491f5..16545b1c44d 100644 --- a/tools/hrw4u/pyproject.toml +++ b/tools/hrw4u/pyproject.toml @@ -60,7 +60,7 @@ u4wrh = "u4wrh.__main__:main" hrw4u-lsp = "hrw4u_lsp.__main__:main" [tool.setuptools] -script-files = ["scripts/hrw4u", "scripts/u4wrh", "scripts/hrw4u-lsp", "scripts/hrw4u-kg"] +script-files = ["scripts/hrw4u", "scripts/u4wrh", "scripts/hrw4u-lsp", "scripts/hrw4u-kg", "scripts/hrw4u-ir"] [tool.setuptools.packages.find] where = ["build"] diff --git a/tools/hrw4u/scripts/hrw4u-ir b/tools/hrw4u/scripts/hrw4u-ir new file mode 100755 index 00000000000..cdf1aef70ae --- /dev/null +++ b/tools/hrw4u/scripts/hrw4u-ir @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 +# +# http://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. +"""hrw4u-ir - Inspect HRW4U intermediate representations (CST, AST, ...).""" + +from __future__ import annotations + +import argparse +import pprint +import sys +from typing import Any, Callable + +from antlr4 import CommonTokenStream, InputStream + +from hrw4u.ast_builder import ASTBuilder +from hrw4u.hrw4uLexer import hrw4uLexer +from hrw4u.hrw4uParser import hrw4uParser + + +def emit_cst(tree: Any, parser: hrw4uParser) -> None: + print(tree.toStringTree(recog=parser)) + + +def emit_ast(tree: Any, parser: hrw4uParser) -> None: + ast = ASTBuilder().visit(tree) + pprint.pp(ast, sort_dicts=False) + + +# Stage registry. Adding a new stage (ast-resolved, ast-validated, ...) +# is a one-line addition here plus its emit_* function above. +STAGES: dict[str, Callable[[Any, hrw4uParser], None]] = { + "cst": emit_cst, + "ast": emit_ast, +} + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Inspect HRW4U intermediate representations.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="Stages:\n" + " cst ANTLR concrete syntax tree (raw parse tree)\n" + " ast dataclass AST built by ASTBuilder\n") + parser.add_argument( + "input_file", + nargs="?", + type=argparse.FileType("r"), + default=sys.stdin, + help="HRW4U source file (default: stdin)") + parser.add_argument( + "--stage", + choices=sorted(STAGES.keys()), + required=True, + help="Which IR stage to emit (required)") + args = parser.parse_args() + + content = args.input_file.read() + if args.input_file is not sys.stdin: + args.input_file.close() + + token_stream = CommonTokenStream(hrw4uLexer(InputStream(content))) + antlr_parser = hrw4uParser(token_stream) + tree = antlr_parser.program() + + if antlr_parser.getNumberOfSyntaxErrors() > 0: + print("Parse failed: syntax errors above.", file=sys.stderr) + return 1 + + STAGES[args.stage](tree, antlr_parser) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 73a49b8c05f2bd83836676d61bdec671359f0840 Mon Sep 17 00:00:00 2001 From: Juan Posadas Date: Mon, 18 May 2026 11:25:47 -0600 Subject: [PATCH 04/14] hrw4u: wire ASTBuilder operators to the new enums The thin AST enums (AssignOp, CmpOp, BoolOp, VarSectionKind) were defined and referenced in the dataclass annotations but the builder still emitted raw strings, so the type annotations were lying and the enums were unreachable. Switch the builder to emit the enum values, update the existing assertions, and fill in coverage gaps surfaced along the way: IdentValue assignment RHS, paramRef as a function argument, multiple use directives, top-level comments, both VARS and SESSION_VARS in one program, multi-param procedures, and empty if/elif/else bodies. --- tools/hrw4u/src/ast_builder.py | 38 ++++---- tools/hrw4u/tests/test_ast_builder.py | 129 ++++++++++++++++++++------ 2 files changed, 121 insertions(+), 46 deletions(-) diff --git a/tools/hrw4u/src/ast_builder.py b/tools/hrw4u/src/ast_builder.py index 0e525efbff4..54904c3c76f 100644 --- a/tools/hrw4u/src/ast_builder.py +++ b/tools/hrw4u/src/ast_builder.py @@ -62,14 +62,14 @@ def _visit_proc_param(self, ctx) -> ProcParam: def _visit_section(self, ctx) -> VarSection | Section: if ctx.varSection() is not None: - return self._visit_var_section(ctx.varSection(), "txn") + return self._visit_var_section(ctx.varSection(), VarSectionKind.TXN) if ctx.sessionVarSection() is not None: - return self._visit_var_section(ctx.sessionVarSection(), "session") + return self._visit_var_section(ctx.sessionVarSection(), VarSectionKind.SESSION) name = ctx.name.text body = self._visit_body(ctx.sectionBody()) return Section(type=name, body=tuple(body), line=ctx.start.line) - def _visit_var_section(self, ctx, scope) -> VarSection: + def _visit_var_section(self, ctx, scope: VarSectionKind) -> VarSection: decls = [] for var_item in ctx.variables().variablesItem(): if var_item.variableDecl() is not None: @@ -107,11 +107,11 @@ def _visit_statement(self, ctx) -> BodyNode: if ctx.EQUAL(): target = Target.from_dotted(ctx.lhs.text) value = self._extract_value(ctx.value()) - return Assignment(target=target, operator="=", value=value, line=line) + return Assignment(target=target, operator=AssignOp.ASSIGN, value=value, line=line) if ctx.PLUSEQUAL(): target = Target.from_dotted(ctx.lhs.text) value = self._extract_value(ctx.value()) - return Assignment(target=target, operator="+=", value=value, line=line) + return Assignment(target=target, operator=AssignOp.PLUS_ASSIGN, value=value, line=line) if ctx.op: return FunctionCall(name=ctx.op.text, args=(), line=line) raise ValueError(f"Unhandled statement alternative at line {line}") @@ -170,14 +170,14 @@ def _visit_expression(self, ctx) -> ConditionExpr: if ctx.OR(): left = self._visit_expression(ctx.expression()) right = self._visit_term(ctx.term()) - return LogicalOp(operator="||", left=left, right=right, line=ctx.start.line) + return LogicalOp(operator=BoolOp.OR, left=left, right=right, line=ctx.start.line) return self._visit_term(ctx.term()) def _visit_term(self, ctx) -> ConditionExpr: if ctx.AND(): left = self._visit_term(ctx.term()) right = self._visit_factor(ctx.factor()) - return LogicalOp(operator="&&", left=left, right=right, line=ctx.start.line) + return LogicalOp(operator=BoolOp.AND, left=left, right=right, line=ctx.start.line) return self._visit_factor(ctx.factor()) def _visit_factor(self, ctx) -> ConditionExpr: @@ -211,30 +211,30 @@ def _visit_comparison(self, ctx) -> Comparison: return Comparison(left=left, operator=operator, right=right, modifiers=modifiers, line=line) - def _detect_comparison_operator(self, ctx) -> str: + def _detect_comparison_operator(self, ctx) -> CmpOp: if ctx.EQUALS(): - return "==" + return CmpOp.EQ if ctx.NEQ(): - return "!=" + return CmpOp.NEQ if ctx.GT(): - return ">" + return CmpOp.GT if ctx.LT(): - return "<" + return CmpOp.LT if ctx.TILDE(): - return "~" + return CmpOp.MATCH if ctx.NOT_TILDE(): - return "!~" + return CmpOp.NOT_MATCH if ctx.IN(): for child in ctx.children: if hasattr(child, "getText") and child.getText() == "!": - return "!in" - return "in" + return CmpOp.NOT_IN + return CmpOp.IN raise ValueError(f"Unhandled comparison operator at line {ctx.start.line}") - def _extract_comparison_rhs(self, ctx, operator) -> ValueExpr | RegexValue | tuple[ValueExpr, ...]: - if operator in ("~", "!~"): + def _extract_comparison_rhs(self, ctx, operator: CmpOp) -> ValueExpr | RegexValue | tuple[ValueExpr, ...]: + if operator in (CmpOp.MATCH, CmpOp.NOT_MATCH): return RegexValue(raw=ctx.regex().getText()[1:-1]) - if operator in ("in", "!in"): + if operator in (CmpOp.IN, CmpOp.NOT_IN): if ctx.set_(): return tuple(self._extract_value(v) for v in ctx.set_().value()) if ctx.iprange(): diff --git a/tools/hrw4u/tests/test_ast_builder.py b/tools/hrw4u/tests/test_ast_builder.py index 48fe1150b0e..f5e385c5c30 100644 --- a/tools/hrw4u/tests/test_ast_builder.py +++ b/tools/hrw4u/tests/test_ast_builder.py @@ -32,7 +32,7 @@ def test_simple_assignment(self): a = ast.body[0].body[0] assert isinstance(a, Assignment) assert a.target == Target.from_dotted("inbound.req.X-Foo") - assert a.operator == "=" + assert a.operator == AssignOp.ASSIGN assert a.value == LiteralStringValue(raw="test") def test_bool_value(self): @@ -49,7 +49,7 @@ def test_int_value(self): def test_plus_equals(self): ast = _build('REMAP {\n inbound.req.X-Foo += "extra";\n}') a = ast.body[0].body[0] - assert a.operator == "+=" + assert a.operator == AssignOp.PLUS_ASSIGN def test_ip_value(self): ast = _build('REMAP {\n inbound.req.X-IP = 10.0.0.1;\n}') @@ -64,6 +64,13 @@ def test_param_ref_value(self): assert isinstance(a, Assignment) assert a.value == ParamRef(raw="tag") + def test_ident_value(self): + src = 'VARS {\n flag: bool;\n}\nREMAP {\n inbound.req.X-Flag = flag;\n}' + ast = _build(src) + a = ast.body[1].body[0] + assert isinstance(a, Assignment) + assert a.value == IdentValue(raw="flag") + class TestFunctionCalls: @@ -80,6 +87,14 @@ def test_with_args(self): assert fc.name == "set-header" assert fc.args == (LiteralStringValue(raw="X-Foo"), LiteralStringValue(raw="bar")) + def test_param_ref_arg(self): + src = 'procedure local::stamp($tag) {\n set-header("X-Stamp", $tag);\n}\nREMAP {\n set-debug();\n}' + ast = _build(src) + fc = ast.body[0].body[0] + assert isinstance(fc, FunctionCall) + assert fc.name == "set-header" + assert fc.args == (LiteralStringValue(raw="X-Stamp"), ParamRef(raw="tag")) + def test_standalone_operator(self): ast = _build('REMAP {\n skip-remap;\n}') fc = ast.body[0].body[0] @@ -127,6 +142,27 @@ def test_use_directive(self): assert isinstance(u, UseDirective) assert u.spec == "test::add-debug-header" + def test_multiple_use_directives(self): + src = ('use test::add-debug-header\n' + 'use test::stamp-request\n' + 'REMAP {\n test::add-debug-header("tag");\n}') + ast = _build(src) + directives = [i for i in ast.body if isinstance(i, UseDirective)] + assert len(directives) == 2 + assert directives[0].spec == "test::add-debug-header" + assert directives[1].spec == "test::stamp-request" + + def test_top_level_comments_skipped(self): + src = ('# leading comment\n' + 'use test::helper\n' + '# between use and section\n' + 'REMAP {\n set-debug();\n}\n' + '# trailing comment\n') + ast = _build(src) + assert len(ast.body) == 2 + assert isinstance(ast.body[0], UseDirective) + assert isinstance(ast.body[1], Section) + def test_item_ordering(self): src = 'VARS {\n x: bool;\n}\nREMAP {\n set-debug();\n}\nSEND_RESPONSE {\n set-debug();\n}' ast = _build(src) @@ -150,7 +186,7 @@ def test_txn_scope(self): ast = _build(src) vs = ast.body[0] assert isinstance(vs, VarSection) - assert vs.scope == "txn" + assert vs.scope == VarSectionKind.TXN assert len(vs.declarations) == 1 assert vs.declarations[0].name == "flag" assert vs.declarations[0].type_name == "bool" @@ -161,7 +197,7 @@ def test_session_scope(self): ast = _build(src) vs = ast.body[0] assert isinstance(vs, VarSection) - assert vs.scope == "session" + assert vs.scope == VarSectionKind.SESSION assert vs.declarations[0].name == "counter" def test_slot(self): @@ -181,6 +217,18 @@ def test_multiple_declarations(self): assert vs.declarations[1].name == "b" assert vs.declarations[2].name == "c" + def test_txn_and_session_in_same_program(self): + src = ('VARS {\n flag: bool;\n}\n' + 'SESSION_VARS {\n counter: int;\n}\n' + 'REMAP {\n set-debug();\n}') + ast = _build(src) + var_sections = [i for i in ast.body if isinstance(i, VarSection)] + assert len(var_sections) == 2 + assert var_sections[0].scope == VarSectionKind.TXN + assert var_sections[0].declarations[0].name == "flag" + assert var_sections[1].scope == VarSectionKind.SESSION + assert var_sections[1].declarations[0].name == "counter" + class TestProcedures: @@ -202,6 +250,20 @@ def test_default_param(self): assert pd.params[0].name == "ttl" assert pd.params[0].default == 300 + def test_multiple_params(self): + src = ('procedure local::tag($key, $value="x", $count=1) {\n set-debug();\n}\n' + 'REMAP {\n set-debug();\n}') + ast = _build(src) + pd = ast.body[0] + assert isinstance(pd, ProcedureDecl) + assert len(pd.params) == 3 + assert pd.params[0].name == "key" + assert pd.params[0].default is None + assert pd.params[1].name == "value" + assert pd.params[1].default == LiteralStringValue(raw="x") + assert pd.params[2].name == "count" + assert pd.params[2].default == 1 + def test_body(self): src = ('procedure local::multi() {\n inbound.req.X = "a";\n' ' set-debug();\n}\nREMAP {\n set-debug();\n}') @@ -223,31 +285,31 @@ def test_equality_comparison(self): cond = self._first_condition('REMAP {\n if inbound.req.X-Foo == "bar" {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) assert cond.left == IdentValue(raw="inbound.req.X-Foo") - assert cond.operator == "==" + assert cond.operator == CmpOp.EQ assert cond.right == LiteralStringValue(raw="bar") assert cond.modifiers == () def test_regex_comparison(self): cond = self._first_condition('REMAP {\n if inbound.url.path ~ /\\.php$/ {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) - assert cond.operator == "~" + assert cond.operator == CmpOp.MATCH assert isinstance(cond.right, RegexValue) def test_in_set(self): cond = self._first_condition('REMAP {\n if inbound.url.path in ["a", "b"] {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) - assert cond.operator == "in" + assert cond.operator == CmpOp.IN assert cond.right == (LiteralStringValue(raw="a"), LiteralStringValue(raw="b")) def test_not_in_set(self): cond = self._first_condition('REMAP {\n if inbound.url.path !in ["a"] {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) - assert cond.operator == "!in" + assert cond.operator == CmpOp.NOT_IN def test_in_iprange(self): cond = self._first_condition('REMAP {\n if inbound.ip in {10.0.0.0/8} {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) - assert cond.operator == "in" + assert cond.operator == CmpOp.IN assert cond.right == (IPValue(raw="10.0.0.0/8"),) def test_modifiers(self): @@ -286,7 +348,7 @@ def test_and_condition(self): cond = self._first_condition( 'REMAP {\n if inbound.req.X-A == "a" && inbound.req.X-B == "b" {\n set-debug();\n }\n}') assert isinstance(cond, LogicalOp) - assert cond.operator == "&&" + assert cond.operator == BoolOp.AND assert isinstance(cond.left, Comparison) assert isinstance(cond.right, Comparison) @@ -294,7 +356,7 @@ def test_or_condition(self): cond = self._first_condition( 'REMAP {\n if inbound.req.X-A == "a" || inbound.req.X-B == "b" {\n set-debug();\n }\n}') assert isinstance(cond, LogicalOp) - assert cond.operator == "||" + assert cond.operator == BoolOp.OR def test_function_call_in_condition(self): cond = self._first_condition('REMAP {\n if access("/tmp/bar") {\n set-debug();\n }\n}') @@ -305,31 +367,31 @@ def test_function_call_in_condition(self): def test_not_tilde_comparison(self): cond = self._first_condition('REMAP {\n if inbound.url.path !~ /\\.jpg$/ {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) - assert cond.operator == "!~" + assert cond.operator == CmpOp.NOT_MATCH assert isinstance(cond.right, RegexValue) def test_greater_than_comparison(self): cond = self._first_condition('REMAP {\n if inbound.req.Content-Length > 1000 {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) - assert cond.operator == ">" + assert cond.operator == CmpOp.GT assert cond.right == 1000 def test_less_than_comparison(self): cond = self._first_condition('REMAP {\n if inbound.req.Content-Length < 500 {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) - assert cond.operator == "<" + assert cond.operator == CmpOp.LT assert cond.right == 500 def test_neq_comparison(self): cond = self._first_condition('REMAP {\n if inbound.req.X-Foo != "bar" {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) - assert cond.operator == "!=" + assert cond.operator == CmpOp.NEQ assert cond.right == LiteralStringValue(raw="bar") def test_parenthesized_condition(self): cond = self._first_condition('REMAP {\n if (inbound.req.X-Foo == "bar") {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) - assert cond.operator == "==" + assert cond.operator == CmpOp.EQ assert cond.right == LiteralStringValue(raw="bar") def test_and_binds_tighter_than_or(self): @@ -339,11 +401,11 @@ def test_and_binds_tighter_than_or(self): ' if inbound.req.X-A == "a" || inbound.req.X-B == "b" && inbound.req.X-C == "c" {\n' ' set-debug();\n }\n}') assert isinstance(cond, LogicalOp) - assert cond.operator == "||" + assert cond.operator == BoolOp.OR assert isinstance(cond.left, Comparison) assert cond.left.left == IdentValue(raw="inbound.req.X-A") assert isinstance(cond.right, LogicalOp) - assert cond.right.operator == "&&" + assert cond.right.operator == BoolOp.AND assert cond.right.left.left == IdentValue(raw="inbound.req.X-B") assert cond.right.right.left == IdentValue(raw="inbound.req.X-C") @@ -354,7 +416,7 @@ def test_not_with_and(self): ' if !inbound.resp.All-Cache && inbound.req.X-B == "b" {\n' ' set-debug();\n }\n}') assert isinstance(cond, LogicalOp) - assert cond.operator == "&&" + assert cond.operator == BoolOp.AND assert isinstance(cond.left, NotOp) assert isinstance(cond.left.operand, IdentCondition) assert cond.left.operand.name == "inbound.resp.All-Cache" @@ -368,7 +430,7 @@ def test_not_comparison_with_or(self): ' if !(inbound.req.X-A == "x") || inbound.req.X-B == "y" {\n' ' set-debug();\n }\n}') assert isinstance(cond, LogicalOp) - assert cond.operator == "||" + assert cond.operator == BoolOp.OR assert isinstance(cond.left, NotOp) assert isinstance(cond.left.operand, Comparison) assert cond.left.operand.left == IdentValue(raw="inbound.req.X-A") @@ -396,9 +458,9 @@ def test_parens_override_precedence(self): ' if (inbound.req.X-A == "a" || inbound.req.X-B == "b") && inbound.req.X-C == "c" {\n' ' set-debug();\n }\n}') assert isinstance(cond, LogicalOp) - assert cond.operator == "&&" + assert cond.operator == BoolOp.AND assert isinstance(cond.left, LogicalOp) - assert cond.left.operator == "||" + assert cond.left.operator == BoolOp.OR assert cond.left.left.left == IdentValue(raw="inbound.req.X-A") assert cond.left.right.left == IdentValue(raw="inbound.req.X-B") assert isinstance(cond.right, Comparison) @@ -411,10 +473,10 @@ def test_nested_parens_with_not(self): ' if !(inbound.req.X-A == "x" || inbound.req.X-B == "y") && inbound.req.X-C == "z" {\n' ' set-debug();\n }\n}') assert isinstance(cond, LogicalOp) - assert cond.operator == "&&" + assert cond.operator == BoolOp.AND assert isinstance(cond.left, NotOp) assert isinstance(cond.left.operand, LogicalOp) - assert cond.left.operand.operator == "||" + assert cond.left.operand.operator == BoolOp.OR assert isinstance(cond.right, Comparison) assert cond.right.left == IdentValue(raw="inbound.req.X-C") @@ -481,6 +543,19 @@ def test_mixed_body(self): assert isinstance(body[1], IfBlock) assert isinstance(body[2], Assignment) + def test_empty_blocks(self): + # Grammar permits LBRACE blockItem* RBRACE — i.e. empty if/elif/else bodies. + src = ('REMAP {\n' + ' if inbound.req.X-A == "a" {\n } elif inbound.req.X-B == "b" {\n' + ' } else {\n }\n}') + ast = _build(src) + ib = ast.body[0].body[0] + assert isinstance(ib, IfBlock) + assert ib.body == () + assert len(ib.elif_branches) == 1 + assert ib.elif_branches[0].body == () + assert ib.else_body == () + class TestLineNumbers: SRC = ( @@ -670,7 +745,7 @@ def test_nested_ifs_from_test_data(self): inner = middle.body[1] assert isinstance(inner, IfBlock) assert isinstance(inner.condition, LogicalOp) - assert inner.condition.operator == "||" + assert inner.condition.operator == BoolOp.OR # Outer elif has modifiers assert len(outer.elif_branches) == 1 @@ -699,7 +774,7 @@ def test_ip_range_condition(self): ast = _build(src) cond = ast.body[0].body[0].condition assert isinstance(cond, Comparison) - assert cond.operator == "in" + assert cond.operator == CmpOp.IN assert len(cond.right) == 2 def test_set_membership_with_modifier(self): @@ -712,7 +787,7 @@ def test_set_membership_with_modifier(self): ast = _build(src) cond = ast.body[0].body[0].condition assert isinstance(cond, Comparison) - assert cond.operator == "in" + assert cond.operator == CmpOp.IN assert cond.right == (LiteralStringValue(raw="php"), LiteralStringValue(raw="php3"), LiteralStringValue(raw="php4")) assert cond.modifiers == ("EXT",) From 030d1019b348552aad6b9b2d6f1704d5c6f8767e Mon Sep 17 00:00:00 2001 From: Juan Posadas Date: Mon, 18 May 2026 16:05:48 -0600 Subject: [PATCH 05/14] hrw4u: cover remaining AST builder gaps in test_ast_builder Add tests for paths that the existing suite parses but never asserts on, so future refactors don't silently regress them: - Qualified function-call names (procedure invocation). - !in with an iprange RHS (the only IN/NOT_IN x set/iprange pair that was untested). - if false as a top-level BoolLiteral condition. - false (lowercase) as an assigned value. - Empty string literal "" as an assigned value. - Target.from_dotted with and without a namespace, including the no-dot branch that the grammar doesn't currently exercise. --- tools/hrw4u/tests/test_ast_builder.py | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tools/hrw4u/tests/test_ast_builder.py b/tools/hrw4u/tests/test_ast_builder.py index f5e385c5c30..ffd954fbfdc 100644 --- a/tools/hrw4u/tests/test_ast_builder.py +++ b/tools/hrw4u/tests/test_ast_builder.py @@ -41,6 +41,18 @@ def test_bool_value(self): assert isinstance(a, Assignment) assert a.value is True + def test_false_value(self): + ast = _build('SEND_RESPONSE {\n http.cntl.TXN_DEBUG = false;\n}') + a = ast.body[0].body[0] + assert isinstance(a, Assignment) + assert a.value is False + + def test_empty_string_value(self): + ast = _build('REMAP {\n inbound.req.X-Foo = "";\n}') + a = ast.body[0].body[0] + assert isinstance(a, Assignment) + assert a.value == LiteralStringValue(raw="") + def test_int_value(self): ast = _build('REMAP {\n http.cntl.INTERCEPT_RETRY = 1;\n}') a = ast.body[0].body[0] @@ -72,6 +84,19 @@ def test_ident_value(self): assert a.value == IdentValue(raw="flag") +class TestTarget: + + def test_from_dotted(self): + t = Target.from_dotted("inbound.req.X-Foo") + assert t.namespace == "inbound.req" + assert t.field == "X-Foo" + + def test_from_dotted_no_namespace(self): + t = Target.from_dotted("flag") + assert t.namespace is None + assert t.field == "flag" + + class TestFunctionCalls: def test_no_args(self): @@ -87,6 +112,14 @@ def test_with_args(self): assert fc.name == "set-header" assert fc.args == (LiteralStringValue(raw="X-Foo"), LiteralStringValue(raw="bar")) + def test_qualified_name(self): + src = 'use test::helper\nREMAP {\n test::helper("tag");\n}' + ast = _build(src) + fc = ast.body[1].body[0] + assert isinstance(fc, FunctionCall) + assert fc.name == "test::helper" + assert fc.args == (LiteralStringValue(raw="tag"),) + def test_param_ref_arg(self): src = 'procedure local::stamp($tag) {\n set-header("X-Stamp", $tag);\n}\nREMAP {\n set-debug();\n}' ast = _build(src) @@ -312,6 +345,12 @@ def test_in_iprange(self): assert cond.operator == CmpOp.IN assert cond.right == (IPValue(raw="10.0.0.0/8"),) + def test_not_in_iprange(self): + cond = self._first_condition('REMAP {\n if inbound.ip !in {10.0.0.0/8} {\n set-debug();\n }\n}') + assert isinstance(cond, Comparison) + assert cond.operator == CmpOp.NOT_IN + assert cond.right == (IPValue(raw="10.0.0.0/8"),) + def test_modifiers(self): cond = self._first_condition('REMAP {\n if inbound.req.X-Foo == "bar" with NOCASE {\n set-debug();\n }\n}') assert isinstance(cond, Comparison) @@ -334,6 +373,11 @@ def test_bool_literal_true(self): assert isinstance(cond, BoolLiteral) assert cond.value is True + def test_bool_literal_false(self): + cond = self._first_condition('REMAP {\n if false {\n set-debug();\n }\n}') + assert isinstance(cond, BoolLiteral) + assert cond.value is False + def test_ident_condition(self): cond = self._first_condition('REMAP {\n if inbound.resp.All-Cache {\n set-debug();\n }\n}') assert isinstance(cond, IdentCondition) From bfc6dc0e9a04277a751dec2d34a11a131d32c197 Mon Sep 17 00:00:00 2001 From: Juan Posadas Date: Mon, 18 May 2026 17:35:26 -0600 Subject: [PATCH 06/14] hrw4u: rename hrw4u-ir to hrw4u-ast and default to ast stage The tool's main use is AST inspection; CST (and future resolved / validated stages) are precursors and refinements around it, so naming after the AST makes intent clearer than the generic "ir". Defaulting --stage to ast removes friction from the common case, and a smoke test covers the CLI surface. --- tools/hrw4u/Makefile | 4 +- tools/hrw4u/pyproject.toml | 2 +- tools/hrw4u/scripts/{hrw4u-ir => hrw4u-ast} | 22 +++---- tools/hrw4u/tests/test_hrw4u_ast.py | 72 +++++++++++++++++++++ 4 files changed, 86 insertions(+), 14 deletions(-) rename tools/hrw4u/scripts/{hrw4u-ir => hrw4u-ast} (78%) create mode 100644 tools/hrw4u/tests/test_hrw4u_ast.py diff --git a/tools/hrw4u/Makefile b/tools/hrw4u/Makefile index d66127597ee..1ddd5c3a96f 100644 --- a/tools/hrw4u/Makefile +++ b/tools/hrw4u/Makefile @@ -31,7 +31,7 @@ SCRIPT_HRW4U=scripts/hrw4u SCRIPT_U4WRH=scripts/u4wrh SCRIPT_LSP=scripts/hrw4u-lsp SCRIPT_KG=scripts/hrw4u-kg -SCRIPT_IR=scripts/hrw4u-ir +SCRIPT_AST=scripts/hrw4u-ast # Shared source files (will go in hrw4u package) SHARED_FILES=src/common.py \ @@ -198,7 +198,7 @@ build: gen uv run pyinstaller --onedir --name u4wrh --strip $(SCRIPT_U4WRH) uv run pyinstaller --onedir --name hrw4u-lsp --strip $(SCRIPT_LSP) uv run pyinstaller --onedir --name hrw4u-kg --strip $(SCRIPT_KG) - uv run pyinstaller --onedir --name hrw4u-ir --strip $(SCRIPT_IR) + uv run pyinstaller --onedir --name hrw4u-ast --strip $(SCRIPT_AST) # Wheel packaging (adjust pyproject to include both packages if desired) package: gen diff --git a/tools/hrw4u/pyproject.toml b/tools/hrw4u/pyproject.toml index 16545b1c44d..16a72f4b9e2 100644 --- a/tools/hrw4u/pyproject.toml +++ b/tools/hrw4u/pyproject.toml @@ -60,7 +60,7 @@ u4wrh = "u4wrh.__main__:main" hrw4u-lsp = "hrw4u_lsp.__main__:main" [tool.setuptools] -script-files = ["scripts/hrw4u", "scripts/u4wrh", "scripts/hrw4u-lsp", "scripts/hrw4u-kg", "scripts/hrw4u-ir"] +script-files = ["scripts/hrw4u", "scripts/u4wrh", "scripts/hrw4u-lsp", "scripts/hrw4u-kg", "scripts/hrw4u-ast"] [tool.setuptools.packages.find] where = ["build"] diff --git a/tools/hrw4u/scripts/hrw4u-ir b/tools/hrw4u/scripts/hrw4u-ast similarity index 78% rename from tools/hrw4u/scripts/hrw4u-ir rename to tools/hrw4u/scripts/hrw4u-ast index cdf1aef70ae..da22112a0fc 100755 --- a/tools/hrw4u/scripts/hrw4u-ir +++ b/tools/hrw4u/scripts/hrw4u-ast @@ -15,7 +15,7 @@ # 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. -"""hrw4u-ir - Inspect HRW4U intermediate representations (CST, AST, ...).""" +"""hrw4u-ast - Inspect the HRW4U AST and the stages around it (CST, ...).""" from __future__ import annotations @@ -35,26 +35,29 @@ def emit_cst(tree: Any, parser: hrw4uParser) -> None: print(tree.toStringTree(recog=parser)) -def emit_ast(tree: Any, parser: hrw4uParser) -> None: +def emit_ast(tree: Any, _parser: hrw4uParser) -> None: ast = ASTBuilder().visit(tree) - pprint.pp(ast, sort_dicts=False) + pprint.pp(ast) -# Stage registry. Adding a new stage (ast-resolved, ast-validated, ...) -# is a one-line addition here plus its emit_* function above. +# Stage registry. Adding a new stage (resolved, validated, ...) is a one-line +# addition here plus its emit_* function above. Each emitter takes the parse +# tree and the parser (some stages need the parser for token/rule names). STAGES: dict[str, Callable[[Any, hrw4uParser], None]] = { "cst": emit_cst, "ast": emit_ast, } +DEFAULT_STAGE = "ast" + def main() -> int: parser = argparse.ArgumentParser( - description="Inspect HRW4U intermediate representations.", + description="Inspect the HRW4U AST and surrounding stages.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="Stages:\n" " cst ANTLR concrete syntax tree (raw parse tree)\n" - " ast dataclass AST built by ASTBuilder\n") + " ast dataclass AST built by ASTBuilder (default)\n") parser.add_argument( "input_file", nargs="?", @@ -62,10 +65,7 @@ def main() -> int: default=sys.stdin, help="HRW4U source file (default: stdin)") parser.add_argument( - "--stage", - choices=sorted(STAGES.keys()), - required=True, - help="Which IR stage to emit (required)") + "--stage", choices=sorted(STAGES.keys()), default=DEFAULT_STAGE, help=f"Which stage to emit (default: {DEFAULT_STAGE})") args = parser.parse_args() content = args.input_file.read() diff --git a/tools/hrw4u/tests/test_hrw4u_ast.py b/tools/hrw4u/tests/test_hrw4u_ast.py new file mode 100644 index 00000000000..451c9848ba3 --- /dev/null +++ b/tools/hrw4u/tests/test_hrw4u_ast.py @@ -0,0 +1,72 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 +# +# http://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. +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +SAMPLE = 'REMAP {\n inbound.req.X-Foo = "bar";\n set-debug();\n}\n' + + +def run_hrw4u_ast(args: list[str], stdin: str | None = None) -> subprocess.CompletedProcess: + script = Path("scripts/hrw4u-ast").resolve() + cmd = [sys.executable, str(script)] + args + return subprocess.run(cmd, capture_output=True, text=True, input=stdin) + + +def test_default_stage_emits_ast() -> None: + result = run_hrw4u_ast([], stdin=SAMPLE) + assert result.returncode == 0, result.stderr + assert "HRW4UAST" in result.stdout + assert "Section" in result.stdout + assert "set-debug" in result.stdout + + +def test_explicit_ast_stage() -> None: + result = run_hrw4u_ast(["--stage", "ast"], stdin=SAMPLE) + assert result.returncode == 0, result.stderr + assert "HRW4UAST" in result.stdout + + +def test_cst_stage() -> None: + result = run_hrw4u_ast(["--stage", "cst"], stdin=SAMPLE) + assert result.returncode == 0, result.stderr + # toStringTree produces parenthesized rule names; "program" is the start rule. + assert "program" in result.stdout + assert "HRW4UAST" not in result.stdout + + +def test_unknown_stage_errors() -> None: + result = run_hrw4u_ast(["--stage", "bogus"], stdin=SAMPLE) + assert result.returncode != 0 + assert "invalid choice" in result.stderr + + +def test_syntax_error_returns_nonzero() -> None: + result = run_hrw4u_ast([], stdin="REMAP { this is not valid ;") + assert result.returncode == 1 + assert "Parse failed" in result.stderr + + +def test_reads_from_file_argument(tmp_path: Path) -> None: + src = tmp_path / "sample.hrw4u" + src.write_text(SAMPLE) + result = run_hrw4u_ast([str(src)]) + assert result.returncode == 0, result.stderr + assert "HRW4UAST" in result.stdout From c914e5443abe0dce38013ba54125aa23863cdf03 Mon Sep 17 00:00:00 2001 From: Juan Posadas Date: Mon, 18 May 2026 18:04:02 -0600 Subject: [PATCH 07/14] hrw4u: name the '!' token in the grammar to retire textual scans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both ASTBuilder and the existing visitor previously discriminated the negated forms ('!factor', '!in') by walking children and matching on text, which is fragile and depends on knowing that no other grammar production can produce a '!' child. Promoting '!' to a named BANG token lets the visitors use ctx.BANG() — explicit, accessor-driven, and impossible to misread. --- tools/hrw4u/grammar/hrw4u.g4 | 7 ++++--- tools/hrw4u/src/ast_builder.py | 7 ++----- tools/hrw4u/src/visitor.py | 8 ++++---- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/tools/hrw4u/grammar/hrw4u.g4 b/tools/hrw4u/grammar/hrw4u.g4 index 335a3817b30..ba728e7c638 100644 --- a/tools/hrw4u/grammar/hrw4u.g4 +++ b/tools/hrw4u/grammar/hrw4u.g4 @@ -88,6 +88,7 @@ AND : '&&'; OR : '||'; TILDE : '~'; NOT_TILDE : '!~'; +BANG : '!'; COLON : ':'; COMMA : ','; SEMICOLON : ';'; @@ -217,7 +218,7 @@ term ; factor - : '!' factor + : BANG factor | LPAREN expression RPAREN | functionCall | comparison @@ -230,9 +231,9 @@ comparison : comparable (EQUALS | NEQ | GT | LT) value modifier? | comparable (TILDE | NOT_TILDE) regex modifier? | comparable IN set modifier? - | comparable '!' IN set modifier? + | comparable BANG IN set modifier? | comparable IN iprange - | comparable '!' IN iprange + | comparable BANG IN iprange ; modifier diff --git a/tools/hrw4u/src/ast_builder.py b/tools/hrw4u/src/ast_builder.py index 54904c3c76f..2242be4dc89 100644 --- a/tools/hrw4u/src/ast_builder.py +++ b/tools/hrw4u/src/ast_builder.py @@ -181,7 +181,7 @@ def _visit_term(self, ctx) -> ConditionExpr: return self._visit_factor(ctx.factor()) def _visit_factor(self, ctx) -> ConditionExpr: - if ctx.getChildCount() == 2 and ctx.getChild(0).getText() == "!": + if ctx.BANG(): return NotOp(operand=self._visit_factor(ctx.factor()), line=ctx.start.line) if ctx.LPAREN(): return self._visit_expression(ctx.expression()) @@ -225,10 +225,7 @@ def _detect_comparison_operator(self, ctx) -> CmpOp: if ctx.NOT_TILDE(): return CmpOp.NOT_MATCH if ctx.IN(): - for child in ctx.children: - if hasattr(child, "getText") and child.getText() == "!": - return CmpOp.NOT_IN - return CmpOp.IN + return CmpOp.NOT_IN if ctx.BANG() else CmpOp.IN raise ValueError(f"Unhandled comparison operator at line {ctx.start.line}") def _extract_comparison_rhs(self, ctx, operator: CmpOp) -> ValueExpr | RegexValue | tuple[ValueExpr, ...]: diff --git a/tools/hrw4u/src/visitor.py b/tools/hrw4u/src/visitor.py index f3fb38c3a39..b379ea501ec 100644 --- a/tools/hrw4u/src/visitor.py +++ b/tools/hrw4u/src/visitor.py @@ -1012,9 +1012,9 @@ def visitComparison(self, ctx, *, last: bool = False) -> None: return operator = ctx.getChild(1) - # Detect negation: '!=' and '!~' are single tokens (NEQ, NOT_TILDE), - # but '!in' is two separate tokens ('!' + IN). - if operator.getText() == '!': + # Detect negation: '!=' and '!~' are single tokens (NEQ, NOT_TILDE); + # 'in' is single, but '!in' is BANG followed by IN as separate tokens. + if ctx.BANG(): negate = True else: negate = operator.symbol.type in (hrw4uParser.NEQ, hrw4uParser.NOT_TILDE) @@ -1136,7 +1136,7 @@ def emit_term(self, ctx, *, last: bool = False) -> None: def emit_factor(self, ctx, *, last: bool = False) -> None: with self.debug_context("emit_factor"), self.trap(ctx): match ctx: - case _ if ctx.getChildCount() == 2 and ctx.getChild(0).getText() == "!": + case _ if ctx.BANG(): self._dbg("`NOT' detected") child = ctx.getChild(1) if child.LPAREN(): From a68b405ab819694680fdf824dc982d212cfa2c9c Mon Sep 17 00:00:00 2001 From: Juan Posadas Date: Mon, 18 May 2026 18:04:10 -0600 Subject: [PATCH 08/14] =?UTF-8?q?hrw4u:=20tidy=20ast=5Fnodes.py=20?= =?UTF-8?q?=E2=80=94=20explain=20the=20enum=20repr=20trick=20and=20drop=20?= =?UTF-8?q?Union?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repr alias on every operator enum exists so that pprint output from hrw4u-ast renders as "AssignOp.ASSIGN" rather than the noisy default ""; promote that to a comment so the next reader doesn't take it for dead code. While here, switch the remaining Union[...] aliases to the equivalent X | Y syntax to match the rest of the file (and drop the now-unused typing.Union import). --- tools/hrw4u/src/ast_nodes.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tools/hrw4u/src/ast_nodes.py b/tools/hrw4u/src/ast_nodes.py index 1eee451511b..54a5321cf7a 100644 --- a/tools/hrw4u/src/ast_nodes.py +++ b/tools/hrw4u/src/ast_nodes.py @@ -19,7 +19,6 @@ from dataclasses import dataclass from enum import Enum, auto -from typing import Union __all__ = [ "AssignOp", @@ -57,6 +56,11 @@ ] +# Enum.__str__ yields "AssignOp.ASSIGN" while the default Enum.__repr__ yields +# ""; we alias __repr__ to __str__ on every operator enum +# so that pprint output (used by hrw4u-ast) is concise and readable. + + class AssignOp(Enum): ASSIGN = auto() PLUS_ASSIGN = auto() @@ -112,7 +116,7 @@ class RegexValue: raw: str -ValueExpr = Union[LiteralStringValue, IdentValue, IPValue, ParamRef, int, bool, tuple[IPValue, ...]] +ValueExpr = LiteralStringValue | IdentValue | IPValue | ParamRef | int | bool | tuple[IPValue, ...] @dataclass(frozen=True, kw_only=True) @@ -241,6 +245,6 @@ class HRW4UAST: # Type aliases: must follow all class definitions (evaluated at runtime). -ConditionExpr = Union[Comparison, LogicalOp, NotOp, BoolLiteral, IdentCondition, FunctionCall] -BodyNode = Union[Assignment, FunctionCall, IfBlock, Break] -TopLevelNode = Union[UseDirective, VarSection, ProcedureDecl, Section] +ConditionExpr = Comparison | LogicalOp | NotOp | BoolLiteral | IdentCondition | FunctionCall +BodyNode = Assignment | FunctionCall | IfBlock | Break +TopLevelNode = UseDirective | VarSection | ProcedureDecl | Section From 0bf32817abacf701b85fbfb52f1f5b3f3564f4df Mon Sep 17 00:00:00 2001 From: Juan Posadas Date: Tue, 19 May 2026 09:40:44 -0600 Subject: [PATCH 09/14] hrw4u: note that Target is a value class, not an AST node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive-by clarification — Target deliberately doesn't inherit from Node and doesn't carry a line, because the grammar has no Target production: it's destructured from an Assignment's IDENT lhs, so the source position already lives on the Assignment. --- tools/hrw4u/src/ast_nodes.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/hrw4u/src/ast_nodes.py b/tools/hrw4u/src/ast_nodes.py index 54a5321cf7a..912d1b5c063 100644 --- a/tools/hrw4u/src/ast_nodes.py +++ b/tools/hrw4u/src/ast_nodes.py @@ -126,6 +126,8 @@ class Node: @dataclass(frozen=True) class Target: + # Value class, not an AST node — destructured from an Assignment's IDENT + # lhs, so source position lives on the enclosing Assignment. namespace: str | None field: str From 315e34be9a341e6d4b137f4affffa7cc9365382a Mon Sep 17 00:00:00 2001 From: Juan Posadas Date: Tue, 19 May 2026 10:10:47 -0600 Subject: [PATCH 10/14] hrw4u: namespace ast_nodes imports in the builder Replace `from hrw4u.ast_nodes import *` with `from hrw4u import ast_nodes as nodes`. The builder constructs ~25 distinct node types, and a `nodes.Foo(...)` prefix at every construction site reinforces "this is an AST node" without polluting the module namespace through a wildcard. Tests keep the wildcard since they read more naturally as the module's public API consumer. --- tools/hrw4u/src/ast_builder.py | 125 +++++++++++++++++---------------- 1 file changed, 63 insertions(+), 62 deletions(-) diff --git a/tools/hrw4u/src/ast_builder.py b/tools/hrw4u/src/ast_builder.py index 2242be4dc89..f66dbe198da 100644 --- a/tools/hrw4u/src/ast_builder.py +++ b/tools/hrw4u/src/ast_builder.py @@ -18,7 +18,7 @@ from __future__ import annotations from hrw4u.hrw4uVisitor import hrw4uVisitor -from hrw4u.ast_nodes import * +from hrw4u import ast_nodes as nodes class ASTBuilder(hrw4uVisitor): @@ -29,7 +29,7 @@ class ASTBuilder(hrw4uVisitor): # method has an explicit return type and full control over how # child results are assembled into parent AST nodes. - def visitProgram(self, ctx) -> HRW4UAST: + def visitProgram(self, ctx) -> nodes.HRW4UAST: items = [] for item in ctx.programItem(): if item.useDirective() is not None: @@ -42,34 +42,34 @@ def visitProgram(self, ctx) -> HRW4UAST: pass else: raise ValueError(f"Unhandled programItem alternative at line {item.start.line}") - return HRW4UAST(body=tuple(items)) + return nodes.HRW4UAST(body=tuple(items)) - def _visit_use_directive(self, ctx) -> UseDirective: - return UseDirective(spec=ctx.QUALIFIED_IDENT().getText(), line=ctx.start.line) + def _visit_use_directive(self, ctx) -> nodes.UseDirective: + return nodes.UseDirective(spec=ctx.QUALIFIED_IDENT().getText(), line=ctx.start.line) - def _visit_procedure_decl(self, ctx) -> ProcedureDecl: + def _visit_procedure_decl(self, ctx) -> nodes.ProcedureDecl: name = ctx.QUALIFIED_IDENT().getText() params = () if ctx.paramList(): params = tuple(self._visit_proc_param(p) for p in ctx.paramList().param()) body = tuple(self._visit_body(ctx.block().blockItem())) - return ProcedureDecl(name=name, params=params, body=body, line=ctx.start.line) + return nodes.ProcedureDecl(name=name, params=params, body=body, line=ctx.start.line) - def _visit_proc_param(self, ctx) -> ProcParam: + def _visit_proc_param(self, ctx) -> nodes.ProcParam: name = ctx.IDENT().getText() default = self._extract_value(ctx.value()) if ctx.value() else None - return ProcParam(name=name, default=default, line=ctx.start.line) + return nodes.ProcParam(name=name, default=default, line=ctx.start.line) - def _visit_section(self, ctx) -> VarSection | Section: + def _visit_section(self, ctx) -> nodes.VarSection | nodes.Section: if ctx.varSection() is not None: - return self._visit_var_section(ctx.varSection(), VarSectionKind.TXN) + return self._visit_var_section(ctx.varSection(), nodes.VarSectionKind.TXN) if ctx.sessionVarSection() is not None: - return self._visit_var_section(ctx.sessionVarSection(), VarSectionKind.SESSION) + return self._visit_var_section(ctx.sessionVarSection(), nodes.VarSectionKind.SESSION) name = ctx.name.text body = self._visit_body(ctx.sectionBody()) - return Section(type=name, body=tuple(body), line=ctx.start.line) + return nodes.Section(type=name, body=tuple(body), line=ctx.start.line) - def _visit_var_section(self, ctx, scope: VarSectionKind) -> VarSection: + def _visit_var_section(self, ctx, scope: nodes.VarSectionKind) -> nodes.VarSection: decls = [] for var_item in ctx.variables().variablesItem(): if var_item.variableDecl() is not None: @@ -78,13 +78,13 @@ def _visit_var_section(self, ctx, scope: VarSectionKind) -> VarSection: pass else: raise ValueError(f"Unhandled variablesItem alternative at line {var_item.start.line}") - return VarSection(scope=scope, declarations=tuple(decls), line=ctx.start.line) + return nodes.VarSection(scope=scope, declarations=tuple(decls), line=ctx.start.line) - def _visit_var_decl(self, ctx) -> VarDecl: - return VarDecl( + def _visit_var_decl(self, ctx) -> nodes.VarDecl: + return nodes.VarDecl( name=ctx.name.text, type_name=ctx.typeName.text, slot=int(ctx.slot.text) if ctx.slot else None, line=ctx.start.line) - def _visit_body(self, items) -> list[BodyNode]: + def _visit_body(self, items) -> list[nodes.BodyNode]: """Shared helper for sectionBody and blockItem lists.""" result = [] for item in items: @@ -98,51 +98,51 @@ def _visit_body(self, items) -> list[BodyNode]: raise ValueError(f"Unhandled body item alternative at line {item.start.line}") return result - def _visit_statement(self, ctx) -> BodyNode: + def _visit_statement(self, ctx) -> nodes.BodyNode: line = ctx.start.line if ctx.BREAK(): - return Break(line=line) + return nodes.Break(line=line) if ctx.functionCall(): return self._visit_function_call(ctx.functionCall()) if ctx.EQUAL(): - target = Target.from_dotted(ctx.lhs.text) + target = nodes.Target.from_dotted(ctx.lhs.text) value = self._extract_value(ctx.value()) - return Assignment(target=target, operator=AssignOp.ASSIGN, value=value, line=line) + return nodes.Assignment(target=target, operator=nodes.AssignOp.ASSIGN, value=value, line=line) if ctx.PLUSEQUAL(): - target = Target.from_dotted(ctx.lhs.text) + target = nodes.Target.from_dotted(ctx.lhs.text) value = self._extract_value(ctx.value()) - return Assignment(target=target, operator=AssignOp.PLUS_ASSIGN, value=value, line=line) + return nodes.Assignment(target=target, operator=nodes.AssignOp.PLUS_ASSIGN, value=value, line=line) if ctx.op: - return FunctionCall(name=ctx.op.text, args=(), line=line) + return nodes.FunctionCall(name=ctx.op.text, args=(), line=line) raise ValueError(f"Unhandled statement alternative at line {line}") - def _visit_function_call(self, ctx) -> FunctionCall: + def _visit_function_call(self, ctx) -> nodes.FunctionCall: name = ctx.funcName.text args = () if ctx.argumentList(): args = tuple(self._extract_value(v) for v in ctx.argumentList().value()) - return FunctionCall(name=name, args=args, line=ctx.start.line) + return nodes.FunctionCall(name=name, args=args, line=ctx.start.line) - def _extract_value(self, ctx) -> ValueExpr: + def _extract_value(self, ctx) -> nodes.ValueExpr: if ctx.number is not None: return int(ctx.number.text) if ctx.str_ is not None: - return LiteralStringValue(raw=ctx.str_.text[1:-1]) + return nodes.LiteralStringValue(raw=ctx.str_.text[1:-1]) if ctx.TRUE(): return True if ctx.FALSE(): return False if ctx.ident is not None: - return IdentValue(raw=ctx.ident.text) + return nodes.IdentValue(raw=ctx.ident.text) if ctx.ip(): - return IPValue(raw=ctx.ip().getText()) + return nodes.IPValue(raw=ctx.ip().getText()) if ctx.iprange(): - return tuple(IPValue(raw=ip.getText()) for ip in ctx.iprange().ip()) + return tuple(nodes.IPValue(raw=ip.getText()) for ip in ctx.iprange().ip()) if ctx.paramRef(): - return ParamRef(raw=ctx.paramRef().IDENT().getText()) + return nodes.ParamRef(raw=ctx.paramRef().IDENT().getText()) raise ValueError(f"Unhandled value alternative at line {ctx.start.line}") - def _visit_conditional(self, ctx) -> IfBlock: + def _visit_conditional(self, ctx) -> nodes.IfBlock: if_stmt = ctx.ifStatement() condition = self._visit_condition(if_stmt.condition()) block = if_stmt.block() @@ -153,7 +153,7 @@ def _visit_conditional(self, ctx) -> IfBlock: elif_cond = self._visit_condition(elif_ctx.condition()) elif_block = elif_ctx.block() elif_body = tuple(self._visit_body(elif_block.blockItem())) if elif_block else () - elif_branches.append(ElifBranch(condition=elif_cond, body=elif_body, line=elif_ctx.start.line)) + elif_branches.append(nodes.ElifBranch(condition=elif_cond, body=elif_body, line=elif_ctx.start.line)) else_body = () if ctx.elseClause(): @@ -161,28 +161,29 @@ def _visit_conditional(self, ctx) -> IfBlock: if else_block: else_body = tuple(self._visit_body(else_block.blockItem())) - return IfBlock(condition=condition, body=body, elif_branches=tuple(elif_branches), else_body=else_body, line=ctx.start.line) + return nodes.IfBlock( + condition=condition, body=body, elif_branches=tuple(elif_branches), else_body=else_body, line=ctx.start.line) - def _visit_condition(self, ctx) -> ConditionExpr: + def _visit_condition(self, ctx) -> nodes.ConditionExpr: return self._visit_expression(ctx.expression()) - def _visit_expression(self, ctx) -> ConditionExpr: + def _visit_expression(self, ctx) -> nodes.ConditionExpr: if ctx.OR(): left = self._visit_expression(ctx.expression()) right = self._visit_term(ctx.term()) - return LogicalOp(operator=BoolOp.OR, left=left, right=right, line=ctx.start.line) + return nodes.LogicalOp(operator=nodes.BoolOp.OR, left=left, right=right, line=ctx.start.line) return self._visit_term(ctx.term()) - def _visit_term(self, ctx) -> ConditionExpr: + def _visit_term(self, ctx) -> nodes.ConditionExpr: if ctx.AND(): left = self._visit_term(ctx.term()) right = self._visit_factor(ctx.factor()) - return LogicalOp(operator=BoolOp.AND, left=left, right=right, line=ctx.start.line) + return nodes.LogicalOp(operator=nodes.BoolOp.AND, left=left, right=right, line=ctx.start.line) return self._visit_factor(ctx.factor()) - def _visit_factor(self, ctx) -> ConditionExpr: + def _visit_factor(self, ctx) -> nodes.ConditionExpr: if ctx.BANG(): - return NotOp(operand=self._visit_factor(ctx.factor()), line=ctx.start.line) + return nodes.NotOp(operand=self._visit_factor(ctx.factor()), line=ctx.start.line) if ctx.LPAREN(): return self._visit_expression(ctx.expression()) if ctx.functionCall(): @@ -190,18 +191,18 @@ def _visit_factor(self, ctx) -> ConditionExpr: if ctx.comparison(): return self._visit_comparison(ctx.comparison()) if ctx.ident is not None: - return IdentCondition(name=ctx.ident.text, line=ctx.start.line) + return nodes.IdentCondition(name=ctx.ident.text, line=ctx.start.line) if ctx.TRUE(): - return BoolLiteral(value=True, line=ctx.start.line) + return nodes.BoolLiteral(value=True, line=ctx.start.line) if ctx.FALSE(): - return BoolLiteral(value=False, line=ctx.start.line) + return nodes.BoolLiteral(value=False, line=ctx.start.line) raise ValueError(f"Unhandled factor alternative at line {ctx.start.line}") - def _visit_comparison(self, ctx) -> Comparison: + def _visit_comparison(self, ctx) -> nodes.Comparison: line = ctx.start.line comp = ctx.comparable() if comp.ident is not None: - left = IdentValue(raw=comp.ident.text) + left = nodes.IdentValue(raw=comp.ident.text) else: left = self._visit_function_call(comp.functionCall()) @@ -209,33 +210,33 @@ def _visit_comparison(self, ctx) -> Comparison: right = self._extract_comparison_rhs(ctx, operator) modifiers = self._extract_modifiers(ctx) - return Comparison(left=left, operator=operator, right=right, modifiers=modifiers, line=line) + return nodes.Comparison(left=left, operator=operator, right=right, modifiers=modifiers, line=line) - def _detect_comparison_operator(self, ctx) -> CmpOp: + def _detect_comparison_operator(self, ctx) -> nodes.CmpOp: if ctx.EQUALS(): - return CmpOp.EQ + return nodes.CmpOp.EQ if ctx.NEQ(): - return CmpOp.NEQ + return nodes.CmpOp.NEQ if ctx.GT(): - return CmpOp.GT + return nodes.CmpOp.GT if ctx.LT(): - return CmpOp.LT + return nodes.CmpOp.LT if ctx.TILDE(): - return CmpOp.MATCH + return nodes.CmpOp.MATCH if ctx.NOT_TILDE(): - return CmpOp.NOT_MATCH + return nodes.CmpOp.NOT_MATCH if ctx.IN(): - return CmpOp.NOT_IN if ctx.BANG() else CmpOp.IN + return nodes.CmpOp.NOT_IN if ctx.BANG() else nodes.CmpOp.IN raise ValueError(f"Unhandled comparison operator at line {ctx.start.line}") - def _extract_comparison_rhs(self, ctx, operator: CmpOp) -> ValueExpr | RegexValue | tuple[ValueExpr, ...]: - if operator in (CmpOp.MATCH, CmpOp.NOT_MATCH): - return RegexValue(raw=ctx.regex().getText()[1:-1]) - if operator in (CmpOp.IN, CmpOp.NOT_IN): + def _extract_comparison_rhs(self, ctx, operator: nodes.CmpOp) -> nodes.ValueExpr | nodes.RegexValue | tuple[nodes.ValueExpr, ...]: + if operator in (nodes.CmpOp.MATCH, nodes.CmpOp.NOT_MATCH): + return nodes.RegexValue(raw=ctx.regex().getText()[1:-1]) + if operator in (nodes.CmpOp.IN, nodes.CmpOp.NOT_IN): if ctx.set_(): return tuple(self._extract_value(v) for v in ctx.set_().value()) if ctx.iprange(): - return tuple(IPValue(raw=ip.getText()) for ip in ctx.iprange().ip()) + return tuple(nodes.IPValue(raw=ip.getText()) for ip in ctx.iprange().ip()) if ctx.value(): return self._extract_value(ctx.value()) raise ValueError(f"Unhandled comparison RHS at line {ctx.start.line}") From c362a9053b42befa652cac49cb0111b2f80952f1 Mon Sep 17 00:00:00 2001 From: Juan Posadas Date: Tue, 19 May 2026 10:21:21 -0600 Subject: [PATCH 11/14] hrw4u: namespace ast_nodes imports in test_ast_builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same change as the builder — replace `from hrw4u.ast_nodes import *` with `from hrw4u import ast_nodes as nodes`. Keeps the import style consistent across producer and consumer, and avoids the wildcard silently shadowing any local name added later in the test file. --- tools/hrw4u/tests/test_ast_builder.py | 376 +++++++++++++------------- 1 file changed, 188 insertions(+), 188 deletions(-) diff --git a/tools/hrw4u/tests/test_ast_builder.py b/tools/hrw4u/tests/test_ast_builder.py index ffd954fbfdc..a442cd9b846 100644 --- a/tools/hrw4u/tests/test_ast_builder.py +++ b/tools/hrw4u/tests/test_ast_builder.py @@ -15,12 +15,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from hrw4u.ast_nodes import * +from hrw4u import ast_nodes as nodes from utils import parse_input_text from hrw4u.ast_builder import ASTBuilder -def _build(source: str) -> HRW4UAST: +def _build(source: str) -> nodes.HRW4UAST: _, tree = parse_input_text(source) return ASTBuilder().visit(tree) @@ -30,28 +30,28 @@ class TestAssignments: def test_simple_assignment(self): ast = _build('REMAP {\n inbound.req.X-Foo = "test";\n}') a = ast.body[0].body[0] - assert isinstance(a, Assignment) - assert a.target == Target.from_dotted("inbound.req.X-Foo") - assert a.operator == AssignOp.ASSIGN - assert a.value == LiteralStringValue(raw="test") + assert isinstance(a, nodes.Assignment) + assert a.target == nodes.Target.from_dotted("inbound.req.X-Foo") + assert a.operator == nodes.AssignOp.ASSIGN + assert a.value == nodes.LiteralStringValue(raw="test") def test_bool_value(self): ast = _build('SEND_RESPONSE {\n http.cntl.TXN_DEBUG = true;\n}') a = ast.body[0].body[0] - assert isinstance(a, Assignment) + assert isinstance(a, nodes.Assignment) assert a.value is True def test_false_value(self): ast = _build('SEND_RESPONSE {\n http.cntl.TXN_DEBUG = false;\n}') a = ast.body[0].body[0] - assert isinstance(a, Assignment) + assert isinstance(a, nodes.Assignment) assert a.value is False def test_empty_string_value(self): ast = _build('REMAP {\n inbound.req.X-Foo = "";\n}') a = ast.body[0].body[0] - assert isinstance(a, Assignment) - assert a.value == LiteralStringValue(raw="") + assert isinstance(a, nodes.Assignment) + assert a.value == nodes.LiteralStringValue(raw="") def test_int_value(self): ast = _build('REMAP {\n http.cntl.INTERCEPT_RETRY = 1;\n}') @@ -61,38 +61,38 @@ def test_int_value(self): def test_plus_equals(self): ast = _build('REMAP {\n inbound.req.X-Foo += "extra";\n}') a = ast.body[0].body[0] - assert a.operator == AssignOp.PLUS_ASSIGN + assert a.operator == nodes.AssignOp.PLUS_ASSIGN def test_ip_value(self): ast = _build('REMAP {\n inbound.req.X-IP = 10.0.0.1;\n}') a = ast.body[0].body[0] - assert isinstance(a, Assignment) - assert a.value == IPValue(raw="10.0.0.1") + assert isinstance(a, nodes.Assignment) + assert a.value == nodes.IPValue(raw="10.0.0.1") def test_param_ref_value(self): src = 'procedure local::stamp($tag) {\n inbound.req.X-Stamp = $tag;\n}\nREMAP {\n set-debug();\n}' ast = _build(src) a = ast.body[0].body[0] - assert isinstance(a, Assignment) - assert a.value == ParamRef(raw="tag") + assert isinstance(a, nodes.Assignment) + assert a.value == nodes.ParamRef(raw="tag") def test_ident_value(self): src = 'VARS {\n flag: bool;\n}\nREMAP {\n inbound.req.X-Flag = flag;\n}' ast = _build(src) a = ast.body[1].body[0] - assert isinstance(a, Assignment) - assert a.value == IdentValue(raw="flag") + assert isinstance(a, nodes.Assignment) + assert a.value == nodes.IdentValue(raw="flag") class TestTarget: def test_from_dotted(self): - t = Target.from_dotted("inbound.req.X-Foo") + t = nodes.Target.from_dotted("inbound.req.X-Foo") assert t.namespace == "inbound.req" assert t.field == "X-Foo" def test_from_dotted_no_namespace(self): - t = Target.from_dotted("flag") + t = nodes.Target.from_dotted("flag") assert t.namespace is None assert t.field == "flag" @@ -102,7 +102,7 @@ class TestFunctionCalls: def test_no_args(self): ast = _build('REMAP {\n set-debug();\n}') fc = ast.body[0].body[0] - assert isinstance(fc, FunctionCall) + assert isinstance(fc, nodes.FunctionCall) assert fc.name == "set-debug" assert fc.args == () @@ -110,35 +110,35 @@ def test_with_args(self): ast = _build('REMAP {\n set-header("X-Foo", "bar");\n}') fc = ast.body[0].body[0] assert fc.name == "set-header" - assert fc.args == (LiteralStringValue(raw="X-Foo"), LiteralStringValue(raw="bar")) + assert fc.args == (nodes.LiteralStringValue(raw="X-Foo"), nodes.LiteralStringValue(raw="bar")) def test_qualified_name(self): src = 'use test::helper\nREMAP {\n test::helper("tag");\n}' ast = _build(src) fc = ast.body[1].body[0] - assert isinstance(fc, FunctionCall) + assert isinstance(fc, nodes.FunctionCall) assert fc.name == "test::helper" - assert fc.args == (LiteralStringValue(raw="tag"),) + assert fc.args == (nodes.LiteralStringValue(raw="tag"),) def test_param_ref_arg(self): src = 'procedure local::stamp($tag) {\n set-header("X-Stamp", $tag);\n}\nREMAP {\n set-debug();\n}' ast = _build(src) fc = ast.body[0].body[0] - assert isinstance(fc, FunctionCall) + assert isinstance(fc, nodes.FunctionCall) assert fc.name == "set-header" - assert fc.args == (LiteralStringValue(raw="X-Stamp"), ParamRef(raw="tag")) + assert fc.args == (nodes.LiteralStringValue(raw="X-Stamp"), nodes.ParamRef(raw="tag")) def test_standalone_operator(self): ast = _build('REMAP {\n skip-remap;\n}') fc = ast.body[0].body[0] - assert isinstance(fc, FunctionCall) + assert isinstance(fc, nodes.FunctionCall) assert fc.name == "skip-remap" assert fc.args == () def test_break(self): ast = _build('REMAP {\n if true {\n break;\n }\n}') body = ast.body[0].body[0].body - assert isinstance(body[0], Break) + assert isinstance(body[0], nodes.Break) class TestSections: @@ -156,13 +156,13 @@ def test_comments_in_block_skipped(self): def test_section_type(self): ast = _build('REMAP {\n set-debug();\n}') s = ast.body[0] - assert isinstance(s, Section) + assert isinstance(s, nodes.Section) assert s.type == "REMAP" def test_multiple_sections(self): src = 'REMAP {\n set-debug();\n}\nSEND_RESPONSE {\n set-debug();\n}' ast = _build(src) - sections = [i for i in ast.body if isinstance(i, Section)] + sections = [i for i in ast.body if isinstance(i, nodes.Section)] assert len(sections) == 2 assert sections[0].type == "REMAP" assert sections[1].type == "SEND_RESPONSE" @@ -172,7 +172,7 @@ def test_use_directive(self): ast = _build(src) assert len(ast.body) == 2 u = ast.body[0] - assert isinstance(u, UseDirective) + assert isinstance(u, nodes.UseDirective) assert u.spec == "test::add-debug-header" def test_multiple_use_directives(self): @@ -180,7 +180,7 @@ def test_multiple_use_directives(self): 'use test::stamp-request\n' 'REMAP {\n test::add-debug-header("tag");\n}') ast = _build(src) - directives = [i for i in ast.body if isinstance(i, UseDirective)] + directives = [i for i in ast.body if isinstance(i, nodes.UseDirective)] assert len(directives) == 2 assert directives[0].spec == "test::add-debug-header" assert directives[1].spec == "test::stamp-request" @@ -193,16 +193,16 @@ def test_top_level_comments_skipped(self): '# trailing comment\n') ast = _build(src) assert len(ast.body) == 2 - assert isinstance(ast.body[0], UseDirective) - assert isinstance(ast.body[1], Section) + assert isinstance(ast.body[0], nodes.UseDirective) + assert isinstance(ast.body[1], nodes.Section) def test_item_ordering(self): src = 'VARS {\n x: bool;\n}\nREMAP {\n set-debug();\n}\nSEND_RESPONSE {\n set-debug();\n}' ast = _build(src) assert len(ast.body) == 3 - assert isinstance(ast.body[0], VarSection) - assert isinstance(ast.body[1], Section) - assert isinstance(ast.body[2], Section) + assert isinstance(ast.body[0], nodes.VarSection) + assert isinstance(ast.body[1], nodes.Section) + assert isinstance(ast.body[2], nodes.Section) class TestVarSections: @@ -211,15 +211,15 @@ def test_comments_in_var_section_skipped(self): src = 'VARS {\n # comment\n x: bool;\n # another\n y: int;\n}\nREMAP {\n set-debug();\n}' ast = _build(src) vs = ast.body[0] - assert isinstance(vs, VarSection) + assert isinstance(vs, nodes.VarSection) assert len(vs.declarations) == 2 def test_txn_scope(self): src = 'VARS {\n flag: bool;\n}\nREMAP {\n set-debug();\n}' ast = _build(src) vs = ast.body[0] - assert isinstance(vs, VarSection) - assert vs.scope == VarSectionKind.TXN + assert isinstance(vs, nodes.VarSection) + assert vs.scope == nodes.VarSectionKind.TXN assert len(vs.declarations) == 1 assert vs.declarations[0].name == "flag" assert vs.declarations[0].type_name == "bool" @@ -229,22 +229,22 @@ def test_session_scope(self): src = 'SESSION_VARS {\n counter: int;\n}\nREMAP {\n set-debug();\n}' ast = _build(src) vs = ast.body[0] - assert isinstance(vs, VarSection) - assert vs.scope == VarSectionKind.SESSION + assert isinstance(vs, nodes.VarSection) + assert vs.scope == nodes.VarSectionKind.SESSION assert vs.declarations[0].name == "counter" def test_slot(self): src = 'VARS {\n x: int @3;\n}\nREMAP {\n set-debug();\n}' ast = _build(src) vs = ast.body[0] - assert isinstance(vs, VarSection) + assert isinstance(vs, nodes.VarSection) assert vs.declarations[0].slot == 3 def test_multiple_declarations(self): src = 'VARS {\n a: bool;\n b: int;\n c: string;\n}\nREMAP {\n set-debug();\n}' ast = _build(src) vs = ast.body[0] - assert isinstance(vs, VarSection) + assert isinstance(vs, nodes.VarSection) assert len(vs.declarations) == 3 assert vs.declarations[0].name == "a" assert vs.declarations[1].name == "b" @@ -255,11 +255,11 @@ def test_txn_and_session_in_same_program(self): 'SESSION_VARS {\n counter: int;\n}\n' 'REMAP {\n set-debug();\n}') ast = _build(src) - var_sections = [i for i in ast.body if isinstance(i, VarSection)] + var_sections = [i for i in ast.body if isinstance(i, nodes.VarSection)] assert len(var_sections) == 2 - assert var_sections[0].scope == VarSectionKind.TXN + assert var_sections[0].scope == nodes.VarSectionKind.TXN assert var_sections[0].declarations[0].name == "flag" - assert var_sections[1].scope == VarSectionKind.SESSION + assert var_sections[1].scope == nodes.VarSectionKind.SESSION assert var_sections[1].declarations[0].name == "counter" @@ -269,7 +269,7 @@ def test_basic_decl(self): src = 'procedure local::stamp($tag) {\n inbound.req.X-Stamp = "$tag";\n}\nREMAP {\n set-debug();\n}' ast = _build(src) pd = ast.body[0] - assert isinstance(pd, ProcedureDecl) + assert isinstance(pd, nodes.ProcedureDecl) assert pd.name == "local::stamp" assert len(pd.params) == 1 assert pd.params[0].name == "tag" @@ -279,7 +279,7 @@ def test_default_param(self): src = 'procedure local::cache($ttl=300) {\n set-debug();\n}\nREMAP {\n set-debug();\n}' ast = _build(src) pd = ast.body[0] - assert isinstance(pd, ProcedureDecl) + assert isinstance(pd, nodes.ProcedureDecl) assert pd.params[0].name == "ttl" assert pd.params[0].default == 300 @@ -288,12 +288,12 @@ def test_multiple_params(self): 'REMAP {\n set-debug();\n}') ast = _build(src) pd = ast.body[0] - assert isinstance(pd, ProcedureDecl) + assert isinstance(pd, nodes.ProcedureDecl) assert len(pd.params) == 3 assert pd.params[0].name == "key" assert pd.params[0].default is None assert pd.params[1].name == "value" - assert pd.params[1].default == LiteralStringValue(raw="x") + assert pd.params[1].default == nodes.LiteralStringValue(raw="x") assert pd.params[2].name == "count" assert pd.params[2].default == 1 @@ -302,10 +302,10 @@ def test_body(self): ' set-debug();\n}\nREMAP {\n set-debug();\n}') ast = _build(src) pd = ast.body[0] - assert isinstance(pd, ProcedureDecl) + assert isinstance(pd, nodes.ProcedureDecl) assert len(pd.body) == 2 - assert isinstance(pd.body[0], Assignment) - assert isinstance(pd.body[1], FunctionCall) + assert isinstance(pd.body[0], nodes.Assignment) + assert isinstance(pd.body[1], nodes.FunctionCall) class TestConditionExpressions: @@ -316,127 +316,127 @@ def _first_condition(self, source: str): def test_equality_comparison(self): cond = self._first_condition('REMAP {\n if inbound.req.X-Foo == "bar" {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) - assert cond.left == IdentValue(raw="inbound.req.X-Foo") - assert cond.operator == CmpOp.EQ - assert cond.right == LiteralStringValue(raw="bar") + assert isinstance(cond, nodes.Comparison) + assert cond.left == nodes.IdentValue(raw="inbound.req.X-Foo") + assert cond.operator == nodes.CmpOp.EQ + assert cond.right == nodes.LiteralStringValue(raw="bar") assert cond.modifiers == () def test_regex_comparison(self): cond = self._first_condition('REMAP {\n if inbound.url.path ~ /\\.php$/ {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) - assert cond.operator == CmpOp.MATCH - assert isinstance(cond.right, RegexValue) + assert isinstance(cond, nodes.Comparison) + assert cond.operator == nodes.CmpOp.MATCH + assert isinstance(cond.right, nodes.RegexValue) def test_in_set(self): cond = self._first_condition('REMAP {\n if inbound.url.path in ["a", "b"] {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) - assert cond.operator == CmpOp.IN - assert cond.right == (LiteralStringValue(raw="a"), LiteralStringValue(raw="b")) + assert isinstance(cond, nodes.Comparison) + assert cond.operator == nodes.CmpOp.IN + assert cond.right == (nodes.LiteralStringValue(raw="a"), nodes.LiteralStringValue(raw="b")) def test_not_in_set(self): cond = self._first_condition('REMAP {\n if inbound.url.path !in ["a"] {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) - assert cond.operator == CmpOp.NOT_IN + assert isinstance(cond, nodes.Comparison) + assert cond.operator == nodes.CmpOp.NOT_IN def test_in_iprange(self): cond = self._first_condition('REMAP {\n if inbound.ip in {10.0.0.0/8} {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) - assert cond.operator == CmpOp.IN - assert cond.right == (IPValue(raw="10.0.0.0/8"),) + assert isinstance(cond, nodes.Comparison) + assert cond.operator == nodes.CmpOp.IN + assert cond.right == (nodes.IPValue(raw="10.0.0.0/8"),) def test_not_in_iprange(self): cond = self._first_condition('REMAP {\n if inbound.ip !in {10.0.0.0/8} {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) - assert cond.operator == CmpOp.NOT_IN - assert cond.right == (IPValue(raw="10.0.0.0/8"),) + assert isinstance(cond, nodes.Comparison) + assert cond.operator == nodes.CmpOp.NOT_IN + assert cond.right == (nodes.IPValue(raw="10.0.0.0/8"),) def test_modifiers(self): cond = self._first_condition('REMAP {\n if inbound.req.X-Foo == "bar" with NOCASE {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) + assert isinstance(cond, nodes.Comparison) assert cond.modifiers == ("NOCASE",) def test_modifiers_preserve_source_casing(self): cond = self._first_condition('REMAP {\n if inbound.req.X-Foo == "bar" with nocase,Pre {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) + assert isinstance(cond, nodes.Comparison) assert cond.modifiers == ("nocase", "Pre") def test_function_call_comparable(self): cond = self._first_condition('REMAP {\n if url(true) ~ /pat/ {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) - assert isinstance(cond.left, FunctionCall) + assert isinstance(cond, nodes.Comparison) + assert isinstance(cond.left, nodes.FunctionCall) assert cond.left.name == "url" assert cond.left.args == (True,) def test_bool_literal_true(self): cond = self._first_condition('REMAP {\n if true {\n set-debug();\n }\n}') - assert isinstance(cond, BoolLiteral) + assert isinstance(cond, nodes.BoolLiteral) assert cond.value is True def test_bool_literal_false(self): cond = self._first_condition('REMAP {\n if false {\n set-debug();\n }\n}') - assert isinstance(cond, BoolLiteral) + assert isinstance(cond, nodes.BoolLiteral) assert cond.value is False def test_ident_condition(self): cond = self._first_condition('REMAP {\n if inbound.resp.All-Cache {\n set-debug();\n }\n}') - assert isinstance(cond, IdentCondition) + assert isinstance(cond, nodes.IdentCondition) assert cond.name == "inbound.resp.All-Cache" def test_not_condition(self): cond = self._first_condition('REMAP {\n if !inbound.resp.All-Cache {\n set-debug();\n }\n}') - assert isinstance(cond, NotOp) - assert isinstance(cond.operand, IdentCondition) + assert isinstance(cond, nodes.NotOp) + assert isinstance(cond.operand, nodes.IdentCondition) def test_and_condition(self): cond = self._first_condition( 'REMAP {\n if inbound.req.X-A == "a" && inbound.req.X-B == "b" {\n set-debug();\n }\n}') - assert isinstance(cond, LogicalOp) - assert cond.operator == BoolOp.AND - assert isinstance(cond.left, Comparison) - assert isinstance(cond.right, Comparison) + assert isinstance(cond, nodes.LogicalOp) + assert cond.operator == nodes.BoolOp.AND + assert isinstance(cond.left, nodes.Comparison) + assert isinstance(cond.right, nodes.Comparison) def test_or_condition(self): cond = self._first_condition( 'REMAP {\n if inbound.req.X-A == "a" || inbound.req.X-B == "b" {\n set-debug();\n }\n}') - assert isinstance(cond, LogicalOp) - assert cond.operator == BoolOp.OR + assert isinstance(cond, nodes.LogicalOp) + assert cond.operator == nodes.BoolOp.OR def test_function_call_in_condition(self): cond = self._first_condition('REMAP {\n if access("/tmp/bar") {\n set-debug();\n }\n}') - assert isinstance(cond, FunctionCall) + assert isinstance(cond, nodes.FunctionCall) assert cond.name == "access" - assert cond.args == (LiteralStringValue(raw="/tmp/bar"),) + assert cond.args == (nodes.LiteralStringValue(raw="/tmp/bar"),) def test_not_tilde_comparison(self): cond = self._first_condition('REMAP {\n if inbound.url.path !~ /\\.jpg$/ {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) - assert cond.operator == CmpOp.NOT_MATCH - assert isinstance(cond.right, RegexValue) + assert isinstance(cond, nodes.Comparison) + assert cond.operator == nodes.CmpOp.NOT_MATCH + assert isinstance(cond.right, nodes.RegexValue) def test_greater_than_comparison(self): cond = self._first_condition('REMAP {\n if inbound.req.Content-Length > 1000 {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) - assert cond.operator == CmpOp.GT + assert isinstance(cond, nodes.Comparison) + assert cond.operator == nodes.CmpOp.GT assert cond.right == 1000 def test_less_than_comparison(self): cond = self._first_condition('REMAP {\n if inbound.req.Content-Length < 500 {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) - assert cond.operator == CmpOp.LT + assert isinstance(cond, nodes.Comparison) + assert cond.operator == nodes.CmpOp.LT assert cond.right == 500 def test_neq_comparison(self): cond = self._first_condition('REMAP {\n if inbound.req.X-Foo != "bar" {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) - assert cond.operator == CmpOp.NEQ - assert cond.right == LiteralStringValue(raw="bar") + assert isinstance(cond, nodes.Comparison) + assert cond.operator == nodes.CmpOp.NEQ + assert cond.right == nodes.LiteralStringValue(raw="bar") def test_parenthesized_condition(self): cond = self._first_condition('REMAP {\n if (inbound.req.X-Foo == "bar") {\n set-debug();\n }\n}') - assert isinstance(cond, Comparison) - assert cond.operator == CmpOp.EQ - assert cond.right == LiteralStringValue(raw="bar") + assert isinstance(cond, nodes.Comparison) + assert cond.operator == nodes.CmpOp.EQ + assert cond.right == nodes.LiteralStringValue(raw="bar") def test_and_binds_tighter_than_or(self): # a || b && c should parse as a || (b && c) @@ -444,14 +444,14 @@ def test_and_binds_tighter_than_or(self): 'REMAP {\n' ' if inbound.req.X-A == "a" || inbound.req.X-B == "b" && inbound.req.X-C == "c" {\n' ' set-debug();\n }\n}') - assert isinstance(cond, LogicalOp) - assert cond.operator == BoolOp.OR - assert isinstance(cond.left, Comparison) - assert cond.left.left == IdentValue(raw="inbound.req.X-A") - assert isinstance(cond.right, LogicalOp) - assert cond.right.operator == BoolOp.AND - assert cond.right.left.left == IdentValue(raw="inbound.req.X-B") - assert cond.right.right.left == IdentValue(raw="inbound.req.X-C") + assert isinstance(cond, nodes.LogicalOp) + assert cond.operator == nodes.BoolOp.OR + assert isinstance(cond.left, nodes.Comparison) + assert cond.left.left == nodes.IdentValue(raw="inbound.req.X-A") + assert isinstance(cond.right, nodes.LogicalOp) + assert cond.right.operator == nodes.BoolOp.AND + assert cond.right.left.left == nodes.IdentValue(raw="inbound.req.X-B") + assert cond.right.right.left == nodes.IdentValue(raw="inbound.req.X-C") def test_not_with_and(self): # !ident && comparison should parse as (!ident) && comparison @@ -459,13 +459,13 @@ def test_not_with_and(self): 'REMAP {\n' ' if !inbound.resp.All-Cache && inbound.req.X-B == "b" {\n' ' set-debug();\n }\n}') - assert isinstance(cond, LogicalOp) - assert cond.operator == BoolOp.AND - assert isinstance(cond.left, NotOp) - assert isinstance(cond.left.operand, IdentCondition) + assert isinstance(cond, nodes.LogicalOp) + assert cond.operator == nodes.BoolOp.AND + assert isinstance(cond.left, nodes.NotOp) + assert isinstance(cond.left.operand, nodes.IdentCondition) assert cond.left.operand.name == "inbound.resp.All-Cache" - assert isinstance(cond.right, Comparison) - assert cond.right.left == IdentValue(raw="inbound.req.X-B") + assert isinstance(cond.right, nodes.Comparison) + assert cond.right.left == nodes.IdentValue(raw="inbound.req.X-B") def test_not_comparison_with_or(self): # !(a == "x") || b == "y" should parse as (!(a == "x")) || (b == "y") @@ -473,26 +473,26 @@ def test_not_comparison_with_or(self): 'REMAP {\n' ' if !(inbound.req.X-A == "x") || inbound.req.X-B == "y" {\n' ' set-debug();\n }\n}') - assert isinstance(cond, LogicalOp) - assert cond.operator == BoolOp.OR - assert isinstance(cond.left, NotOp) - assert isinstance(cond.left.operand, Comparison) - assert cond.left.operand.left == IdentValue(raw="inbound.req.X-A") - assert cond.left.operand.right == LiteralStringValue(raw="x") - assert isinstance(cond.right, Comparison) - assert cond.right.left == IdentValue(raw="inbound.req.X-B") + assert isinstance(cond, nodes.LogicalOp) + assert cond.operator == nodes.BoolOp.OR + assert isinstance(cond.left, nodes.NotOp) + assert isinstance(cond.left.operand, nodes.Comparison) + assert cond.left.operand.left == nodes.IdentValue(raw="inbound.req.X-A") + assert cond.left.operand.right == nodes.LiteralStringValue(raw="x") + assert isinstance(cond.right, nodes.Comparison) + assert cond.right.left == nodes.IdentValue(raw="inbound.req.X-B") def test_double_negation(self): cond = self._first_condition('REMAP {\n if !!inbound.resp.All-Cache {\n set-debug();\n }\n}') - assert isinstance(cond, NotOp) - assert isinstance(cond.operand, NotOp) - assert isinstance(cond.operand.operand, IdentCondition) + assert isinstance(cond, nodes.NotOp) + assert isinstance(cond.operand, nodes.NotOp) + assert isinstance(cond.operand.operand, nodes.IdentCondition) assert cond.operand.operand.name == "inbound.resp.All-Cache" def test_not_bool_literal(self): cond = self._first_condition('REMAP {\n if !false {\n set-debug();\n }\n}') - assert isinstance(cond, NotOp) - assert isinstance(cond.operand, BoolLiteral) + assert isinstance(cond, nodes.NotOp) + assert isinstance(cond.operand, nodes.BoolLiteral) assert cond.operand.value is False def test_parens_override_precedence(self): @@ -501,14 +501,14 @@ def test_parens_override_precedence(self): 'REMAP {\n' ' if (inbound.req.X-A == "a" || inbound.req.X-B == "b") && inbound.req.X-C == "c" {\n' ' set-debug();\n }\n}') - assert isinstance(cond, LogicalOp) - assert cond.operator == BoolOp.AND - assert isinstance(cond.left, LogicalOp) - assert cond.left.operator == BoolOp.OR - assert cond.left.left.left == IdentValue(raw="inbound.req.X-A") - assert cond.left.right.left == IdentValue(raw="inbound.req.X-B") - assert isinstance(cond.right, Comparison) - assert cond.right.left == IdentValue(raw="inbound.req.X-C") + assert isinstance(cond, nodes.LogicalOp) + assert cond.operator == nodes.BoolOp.AND + assert isinstance(cond.left, nodes.LogicalOp) + assert cond.left.operator == nodes.BoolOp.OR + assert cond.left.left.left == nodes.IdentValue(raw="inbound.req.X-A") + assert cond.left.right.left == nodes.IdentValue(raw="inbound.req.X-B") + assert isinstance(cond.right, nodes.Comparison) + assert cond.right.left == nodes.IdentValue(raw="inbound.req.X-C") def test_nested_parens_with_not(self): # !(a == "x" || b == "y") && c == "z" @@ -516,13 +516,13 @@ def test_nested_parens_with_not(self): 'REMAP {\n' ' if !(inbound.req.X-A == "x" || inbound.req.X-B == "y") && inbound.req.X-C == "z" {\n' ' set-debug();\n }\n}') - assert isinstance(cond, LogicalOp) - assert cond.operator == BoolOp.AND - assert isinstance(cond.left, NotOp) - assert isinstance(cond.left.operand, LogicalOp) - assert cond.left.operand.operator == BoolOp.OR - assert isinstance(cond.right, Comparison) - assert cond.right.left == IdentValue(raw="inbound.req.X-C") + assert isinstance(cond, nodes.LogicalOp) + assert cond.operator == nodes.BoolOp.AND + assert isinstance(cond.left, nodes.NotOp) + assert isinstance(cond.left.operand, nodes.LogicalOp) + assert cond.left.operand.operator == nodes.BoolOp.OR + assert isinstance(cond.right, nodes.Comparison) + assert cond.right.left == nodes.IdentValue(raw="inbound.req.X-C") class TestIfBlocks: @@ -530,7 +530,7 @@ class TestIfBlocks: def test_simple_if(self): ast = _build('REMAP {\n if true {\n inbound.req.X = "y";\n }\n}') ib = ast.body[0].body[0] - assert isinstance(ib, IfBlock) + assert isinstance(ib, nodes.IfBlock) assert len(ib.body) == 1 assert ib.elif_branches == () assert ib.else_body == () @@ -549,9 +549,9 @@ def test_if_elif_else(self): ' inbound.resp.X = "other";\n }\n}') ast = _build(src) ib = ast.body[0].body[0] - assert isinstance(ib, IfBlock) + assert isinstance(ib, nodes.IfBlock) assert len(ib.elif_branches) == 1 - assert isinstance(ib.elif_branches[0], ElifBranch) + assert isinstance(ib.elif_branches[0], nodes.ElifBranch) assert len(ib.elif_branches[0].body) == 1 assert len(ib.else_body) == 1 @@ -571,9 +571,9 @@ def test_nested_if(self): ' if inbound.req.Y == "b" {\n set-debug();\n }\n }\n}') ast = _build(src) outer = ast.body[0].body[0] - assert isinstance(outer, IfBlock) + assert isinstance(outer, nodes.IfBlock) inner = outer.body[0] - assert isinstance(inner, IfBlock) + assert isinstance(inner, nodes.IfBlock) def test_mixed_body(self): src = ( @@ -583,9 +583,9 @@ def test_mixed_body(self): ast = _build(src) body = ast.body[0].body assert len(body) == 3 - assert isinstance(body[0], Assignment) - assert isinstance(body[1], IfBlock) - assert isinstance(body[2], Assignment) + assert isinstance(body[0], nodes.Assignment) + assert isinstance(body[1], nodes.IfBlock) + assert isinstance(body[2], nodes.Assignment) def test_empty_blocks(self): # Grammar permits LBRACE blockItem* RBRACE — i.e. empty if/elif/else bodies. @@ -594,7 +594,7 @@ def test_empty_blocks(self): ' } else {\n }\n}') ast = _build(src) ib = ast.body[0].body[0] - assert isinstance(ib, IfBlock) + assert isinstance(ib, nodes.IfBlock) assert ib.body == () assert len(ib.elif_branches) == 1 assert ib.elif_branches[0].body == () @@ -641,97 +641,97 @@ def setup_method(self): def test_use_directive(self): u = self.ast.body[0] - assert isinstance(u, UseDirective) + assert isinstance(u, nodes.UseDirective) assert u.line == 1 def test_var_section(self): vs = self.ast.body[1] - assert isinstance(vs, VarSection) + assert isinstance(vs, nodes.VarSection) assert vs.line == 2 def test_var_decl(self): vd = self.ast.body[1].declarations[0] - assert isinstance(vd, VarDecl) + assert isinstance(vd, nodes.VarDecl) assert vd.line == 3 def test_procedure_decl(self): pd = self.ast.body[2] - assert isinstance(pd, ProcedureDecl) + assert isinstance(pd, nodes.ProcedureDecl) assert pd.line == 5 def test_proc_param(self): pp = self.ast.body[2].params[0] - assert isinstance(pp, ProcParam) + assert isinstance(pp, nodes.ProcParam) assert pp.line == 5 def test_procedure_body_assignment(self): a = self.ast.body[2].body[0] - assert isinstance(a, Assignment) + assert isinstance(a, nodes.Assignment) assert a.line == 6 def test_section(self): s = self.ast.body[3] - assert isinstance(s, Section) + assert isinstance(s, nodes.Section) assert s.line == 8 def test_assignment(self): a = self.ast.body[3].body[0] - assert isinstance(a, Assignment) + assert isinstance(a, nodes.Assignment) assert a.line == 9 def test_function_call(self): fc = self.ast.body[3].body[1] - assert isinstance(fc, FunctionCall) + assert isinstance(fc, nodes.FunctionCall) assert fc.line == 10 def test_standalone_operator(self): fc = self.ast.body[3].body[2] - assert isinstance(fc, FunctionCall) + assert isinstance(fc, nodes.FunctionCall) assert fc.line == 11 def test_if_block(self): ib = self.ast.body[3].body[3] - assert isinstance(ib, IfBlock) + assert isinstance(ib, nodes.IfBlock) assert ib.line == 12 def test_comparison_in_condition(self): cond = self.ast.body[3].body[3].condition - assert isinstance(cond, Comparison) + assert isinstance(cond, nodes.Comparison) assert cond.line == 12 def test_break(self): brk = self.ast.body[3].body[3].body[0] - assert isinstance(brk, Break) + assert isinstance(brk, nodes.Break) assert brk.line == 13 def test_elif_branch(self): eb = self.ast.body[3].body[3].elif_branches[0] - assert isinstance(eb, ElifBranch) + assert isinstance(eb, nodes.ElifBranch) assert eb.line == 14 def test_elif_condition(self): cond = self.ast.body[3].body[3].elif_branches[0].condition - assert isinstance(cond, Comparison) + assert isinstance(cond, nodes.Comparison) assert cond.line == 14 def test_logical_op(self): cond = self.ast.body[3].body[4].condition - assert isinstance(cond, LogicalOp) + assert isinstance(cond, nodes.LogicalOp) assert cond.line == 19 def test_not_op(self): cond = self.ast.body[3].body[5].condition - assert isinstance(cond, NotOp) + assert isinstance(cond, nodes.NotOp) assert cond.line == 22 def test_bool_literal(self): cond = self.ast.body[3].body[6].condition - assert isinstance(cond, BoolLiteral) + assert isinstance(cond, nodes.BoolLiteral) assert cond.line == 25 def test_ident_condition(self): cond = self.ast.body[3].body[7].condition - assert isinstance(cond, IdentCondition) + assert isinstance(cond, nodes.IdentCondition) assert cond.line == 28 @@ -767,18 +767,18 @@ def test_nested_ifs_from_test_data(self): } }''' ast = _build(src) - sections = [i for i in ast.body if isinstance(i, Section)] + sections = [i for i in ast.body if isinstance(i, nodes.Section)] assert len(sections) == 1 s = sections[0] assert s.type == "REMAP" # Top-level if block outer = s.body[0] - assert isinstance(outer, IfBlock) + assert isinstance(outer, nodes.IfBlock) # Body: assignment + nested if - assert isinstance(outer.body[0], Assignment) - assert isinstance(outer.body[1], IfBlock) + assert isinstance(outer.body[0], nodes.Assignment) + assert isinstance(outer.body[1], nodes.IfBlock) middle = outer.body[1] # Middle if has elif and else @@ -787,14 +787,14 @@ def test_nested_ifs_from_test_data(self): # Deepest nested if (3 levels) inner = middle.body[1] - assert isinstance(inner, IfBlock) - assert isinstance(inner.condition, LogicalOp) - assert inner.condition.operator == BoolOp.OR + assert isinstance(inner, nodes.IfBlock) + assert isinstance(inner.condition, nodes.LogicalOp) + assert inner.condition.operator == nodes.BoolOp.OR # Outer elif has modifiers assert len(outer.elif_branches) == 1 elif_cond = outer.elif_branches[0].condition - assert isinstance(elif_cond, Comparison) + assert isinstance(elif_cond, nodes.Comparison) assert elif_cond.modifiers == ("NOCASE", "PRE") def test_http_cntl_booleans(self): @@ -817,8 +817,8 @@ def test_ip_range_condition(self): }''' ast = _build(src) cond = ast.body[0].body[0].condition - assert isinstance(cond, Comparison) - assert cond.operator == CmpOp.IN + assert isinstance(cond, nodes.Comparison) + assert cond.operator == nodes.CmpOp.IN assert len(cond.right) == 2 def test_set_membership_with_modifier(self): @@ -830,9 +830,9 @@ def test_set_membership_with_modifier(self): }''' ast = _build(src) cond = ast.body[0].body[0].condition - assert isinstance(cond, Comparison) - assert cond.operator == CmpOp.IN - assert cond.right == (LiteralStringValue(raw="php"), LiteralStringValue(raw="php3"), LiteralStringValue(raw="php4")) + assert isinstance(cond, nodes.Comparison) + assert cond.operator == nodes.CmpOp.IN + assert cond.right == (nodes.LiteralStringValue(raw="php"), nodes.LiteralStringValue(raw="php3"), nodes.LiteralStringValue(raw="php4")) assert cond.modifiers == ("EXT",) def test_debug_pattern_for_lint_rules(self): @@ -846,14 +846,14 @@ def test_debug_pattern_for_lint_rules(self): body = ast.body[0].body # set-debug() function call - assert isinstance(body[0], FunctionCall) + assert isinstance(body[0], nodes.FunctionCall) assert body[0].name == "set-debug" # TXN_DEBUG assignment with True - assert isinstance(body[1], Assignment) - assert body[1].target == Target.from_dotted("http.cntl.TXN_DEBUG") + assert isinstance(body[1], nodes.Assignment) + assert body[1].target == nodes.Target.from_dotted("http.cntl.TXN_DEBUG") assert body[1].value is True # Regular assignment (not flagged) - assert isinstance(body[2], Assignment) + assert isinstance(body[2], nodes.Assignment) assert body[2].target.namespace == "inbound.req" From c959262a672f53740ec246dbca7d6d2cf8e00e38 Mon Sep 17 00:00:00 2001 From: Juan Posadas Date: Tue, 19 May 2026 10:41:25 -0600 Subject: [PATCH 12/14] hrw4u: name AST value fields after what they actually contain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared `raw` field name was misleading on the three node types where construction strips delimiters or sigils: LiteralStringValue drops the surrounding quotes, ParamRef drops the leading '\$', and RegexValue drops the surrounding '/'. Rename to `text`, `name`, and `pattern` respectively, and document on each what is and isn't preserved (escapes are kept as written). IdentValue and IPValue keep `raw` because nothing is stripped — the field really is the source text. The asymmetry now encodes a real distinction. --- tools/hrw4u/src/ast_builder.py | 6 +++--- tools/hrw4u/src/ast_nodes.py | 12 +++++++++--- tools/hrw4u/tests/test_ast_builder.py | 28 +++++++++++++-------------- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/tools/hrw4u/src/ast_builder.py b/tools/hrw4u/src/ast_builder.py index f66dbe198da..b58f9fc3506 100644 --- a/tools/hrw4u/src/ast_builder.py +++ b/tools/hrw4u/src/ast_builder.py @@ -127,7 +127,7 @@ def _extract_value(self, ctx) -> nodes.ValueExpr: if ctx.number is not None: return int(ctx.number.text) if ctx.str_ is not None: - return nodes.LiteralStringValue(raw=ctx.str_.text[1:-1]) + return nodes.LiteralStringValue(text=ctx.str_.text[1:-1]) if ctx.TRUE(): return True if ctx.FALSE(): @@ -139,7 +139,7 @@ def _extract_value(self, ctx) -> nodes.ValueExpr: if ctx.iprange(): return tuple(nodes.IPValue(raw=ip.getText()) for ip in ctx.iprange().ip()) if ctx.paramRef(): - return nodes.ParamRef(raw=ctx.paramRef().IDENT().getText()) + return nodes.ParamRef(name=ctx.paramRef().IDENT().getText()) raise ValueError(f"Unhandled value alternative at line {ctx.start.line}") def _visit_conditional(self, ctx) -> nodes.IfBlock: @@ -231,7 +231,7 @@ def _detect_comparison_operator(self, ctx) -> nodes.CmpOp: def _extract_comparison_rhs(self, ctx, operator: nodes.CmpOp) -> nodes.ValueExpr | nodes.RegexValue | tuple[nodes.ValueExpr, ...]: if operator in (nodes.CmpOp.MATCH, nodes.CmpOp.NOT_MATCH): - return nodes.RegexValue(raw=ctx.regex().getText()[1:-1]) + return nodes.RegexValue(pattern=ctx.regex().getText()[1:-1]) if operator in (nodes.CmpOp.IN, nodes.CmpOp.NOT_IN): if ctx.set_(): return tuple(self._extract_value(v) for v in ctx.set_().value()) diff --git a/tools/hrw4u/src/ast_nodes.py b/tools/hrw4u/src/ast_nodes.py index 912d1b5c063..ce2bd1b683f 100644 --- a/tools/hrw4u/src/ast_nodes.py +++ b/tools/hrw4u/src/ast_nodes.py @@ -93,7 +93,10 @@ class VarSectionKind(Enum): @dataclass(frozen=True, kw_only=True) class LiteralStringValue: - raw: str + # The string body with surrounding quotes stripped. Escape sequences + # (e.g. '\n', '\"') are preserved as written; consumers needing the + # decoded value must do their own decoding. + text: str @dataclass(frozen=True, kw_only=True) @@ -108,12 +111,15 @@ class IPValue: @dataclass(frozen=True, kw_only=True) class ParamRef: - raw: str + # Parameter name without the '$' sigil (source `$tag` -> name='tag'). + name: str @dataclass(frozen=True, kw_only=True) class RegexValue: - raw: str + # The regex body with surrounding '/' delimiters stripped. Backslash + # escapes inside the pattern are preserved as written. + pattern: str ValueExpr = LiteralStringValue | IdentValue | IPValue | ParamRef | int | bool | tuple[IPValue, ...] diff --git a/tools/hrw4u/tests/test_ast_builder.py b/tools/hrw4u/tests/test_ast_builder.py index a442cd9b846..6bfc52799f2 100644 --- a/tools/hrw4u/tests/test_ast_builder.py +++ b/tools/hrw4u/tests/test_ast_builder.py @@ -33,7 +33,7 @@ def test_simple_assignment(self): assert isinstance(a, nodes.Assignment) assert a.target == nodes.Target.from_dotted("inbound.req.X-Foo") assert a.operator == nodes.AssignOp.ASSIGN - assert a.value == nodes.LiteralStringValue(raw="test") + assert a.value == nodes.LiteralStringValue(text="test") def test_bool_value(self): ast = _build('SEND_RESPONSE {\n http.cntl.TXN_DEBUG = true;\n}') @@ -51,7 +51,7 @@ def test_empty_string_value(self): ast = _build('REMAP {\n inbound.req.X-Foo = "";\n}') a = ast.body[0].body[0] assert isinstance(a, nodes.Assignment) - assert a.value == nodes.LiteralStringValue(raw="") + assert a.value == nodes.LiteralStringValue(text="") def test_int_value(self): ast = _build('REMAP {\n http.cntl.INTERCEPT_RETRY = 1;\n}') @@ -74,7 +74,7 @@ def test_param_ref_value(self): ast = _build(src) a = ast.body[0].body[0] assert isinstance(a, nodes.Assignment) - assert a.value == nodes.ParamRef(raw="tag") + assert a.value == nodes.ParamRef(name="tag") def test_ident_value(self): src = 'VARS {\n flag: bool;\n}\nREMAP {\n inbound.req.X-Flag = flag;\n}' @@ -110,7 +110,7 @@ def test_with_args(self): ast = _build('REMAP {\n set-header("X-Foo", "bar");\n}') fc = ast.body[0].body[0] assert fc.name == "set-header" - assert fc.args == (nodes.LiteralStringValue(raw="X-Foo"), nodes.LiteralStringValue(raw="bar")) + assert fc.args == (nodes.LiteralStringValue(text="X-Foo"), nodes.LiteralStringValue(text="bar")) def test_qualified_name(self): src = 'use test::helper\nREMAP {\n test::helper("tag");\n}' @@ -118,7 +118,7 @@ def test_qualified_name(self): fc = ast.body[1].body[0] assert isinstance(fc, nodes.FunctionCall) assert fc.name == "test::helper" - assert fc.args == (nodes.LiteralStringValue(raw="tag"),) + assert fc.args == (nodes.LiteralStringValue(text="tag"),) def test_param_ref_arg(self): src = 'procedure local::stamp($tag) {\n set-header("X-Stamp", $tag);\n}\nREMAP {\n set-debug();\n}' @@ -126,7 +126,7 @@ def test_param_ref_arg(self): fc = ast.body[0].body[0] assert isinstance(fc, nodes.FunctionCall) assert fc.name == "set-header" - assert fc.args == (nodes.LiteralStringValue(raw="X-Stamp"), nodes.ParamRef(raw="tag")) + assert fc.args == (nodes.LiteralStringValue(text="X-Stamp"), nodes.ParamRef(name="tag")) def test_standalone_operator(self): ast = _build('REMAP {\n skip-remap;\n}') @@ -293,7 +293,7 @@ def test_multiple_params(self): assert pd.params[0].name == "key" assert pd.params[0].default is None assert pd.params[1].name == "value" - assert pd.params[1].default == nodes.LiteralStringValue(raw="x") + assert pd.params[1].default == nodes.LiteralStringValue(text="x") assert pd.params[2].name == "count" assert pd.params[2].default == 1 @@ -319,7 +319,7 @@ def test_equality_comparison(self): assert isinstance(cond, nodes.Comparison) assert cond.left == nodes.IdentValue(raw="inbound.req.X-Foo") assert cond.operator == nodes.CmpOp.EQ - assert cond.right == nodes.LiteralStringValue(raw="bar") + assert cond.right == nodes.LiteralStringValue(text="bar") assert cond.modifiers == () def test_regex_comparison(self): @@ -332,7 +332,7 @@ def test_in_set(self): cond = self._first_condition('REMAP {\n if inbound.url.path in ["a", "b"] {\n set-debug();\n }\n}') assert isinstance(cond, nodes.Comparison) assert cond.operator == nodes.CmpOp.IN - assert cond.right == (nodes.LiteralStringValue(raw="a"), nodes.LiteralStringValue(raw="b")) + assert cond.right == (nodes.LiteralStringValue(text="a"), nodes.LiteralStringValue(text="b")) def test_not_in_set(self): cond = self._first_condition('REMAP {\n if inbound.url.path !in ["a"] {\n set-debug();\n }\n}') @@ -406,7 +406,7 @@ def test_function_call_in_condition(self): cond = self._first_condition('REMAP {\n if access("/tmp/bar") {\n set-debug();\n }\n}') assert isinstance(cond, nodes.FunctionCall) assert cond.name == "access" - assert cond.args == (nodes.LiteralStringValue(raw="/tmp/bar"),) + assert cond.args == (nodes.LiteralStringValue(text="/tmp/bar"),) def test_not_tilde_comparison(self): cond = self._first_condition('REMAP {\n if inbound.url.path !~ /\\.jpg$/ {\n set-debug();\n }\n}') @@ -430,13 +430,13 @@ def test_neq_comparison(self): cond = self._first_condition('REMAP {\n if inbound.req.X-Foo != "bar" {\n set-debug();\n }\n}') assert isinstance(cond, nodes.Comparison) assert cond.operator == nodes.CmpOp.NEQ - assert cond.right == nodes.LiteralStringValue(raw="bar") + assert cond.right == nodes.LiteralStringValue(text="bar") def test_parenthesized_condition(self): cond = self._first_condition('REMAP {\n if (inbound.req.X-Foo == "bar") {\n set-debug();\n }\n}') assert isinstance(cond, nodes.Comparison) assert cond.operator == nodes.CmpOp.EQ - assert cond.right == nodes.LiteralStringValue(raw="bar") + assert cond.right == nodes.LiteralStringValue(text="bar") def test_and_binds_tighter_than_or(self): # a || b && c should parse as a || (b && c) @@ -478,7 +478,7 @@ def test_not_comparison_with_or(self): assert isinstance(cond.left, nodes.NotOp) assert isinstance(cond.left.operand, nodes.Comparison) assert cond.left.operand.left == nodes.IdentValue(raw="inbound.req.X-A") - assert cond.left.operand.right == nodes.LiteralStringValue(raw="x") + assert cond.left.operand.right == nodes.LiteralStringValue(text="x") assert isinstance(cond.right, nodes.Comparison) assert cond.right.left == nodes.IdentValue(raw="inbound.req.X-B") @@ -832,7 +832,7 @@ def test_set_membership_with_modifier(self): cond = ast.body[0].body[0].condition assert isinstance(cond, nodes.Comparison) assert cond.operator == nodes.CmpOp.IN - assert cond.right == (nodes.LiteralStringValue(raw="php"), nodes.LiteralStringValue(raw="php3"), nodes.LiteralStringValue(raw="php4")) + assert cond.right == (nodes.LiteralStringValue(text="php"), nodes.LiteralStringValue(text="php3"), nodes.LiteralStringValue(text="php4")) assert cond.modifiers == ("EXT",) def test_debug_pattern_for_lint_rules(self): From 10acb79d61200415f9834c6e366dd0e5ca1eec19 Mon Sep 17 00:00:00 2001 From: Juan Posadas Date: Tue, 19 May 2026 10:51:15 -0600 Subject: [PATCH 13/14] hrw4u: don't package or build a binary for hrw4u-ast hrw4u-ast is a developer-only inspection tool, not something we ship to end users. Drop it from setuptools' script-files (no `pip install` exposure) and from the PyInstaller build target (each --onedir bundle adds ~30-50 MB to release artifacts). Devs continue to run it from the source tree via `uv run scripts/hrw4u-ast`. --- tools/hrw4u/Makefile | 8 +++++--- tools/hrw4u/pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tools/hrw4u/Makefile b/tools/hrw4u/Makefile index 1ddd5c3a96f..d370bf4a009 100644 --- a/tools/hrw4u/Makefile +++ b/tools/hrw4u/Makefile @@ -31,7 +31,9 @@ SCRIPT_HRW4U=scripts/hrw4u SCRIPT_U4WRH=scripts/u4wrh SCRIPT_LSP=scripts/hrw4u-lsp SCRIPT_KG=scripts/hrw4u-kg -SCRIPT_AST=scripts/hrw4u-ast + +# scripts/hrw4u-ast is dev-only — not packaged or shipped. Run it from the +# source tree via `uv run scripts/hrw4u-ast` or `python scripts/hrw4u-ast`. # Shared source files (will go in hrw4u package) SHARED_FILES=src/common.py \ @@ -192,13 +194,13 @@ coverage: gen coverage-open: coverage uv run python -m webbrowser "file://$(shell pwd)/htmlcov/index.html" -# Build standalone binaries (optional) +# Build standalone binaries (optional). hrw4u-ast is intentionally +# excluded — it's a dev-only inspection tool, not a shipped artifact. build: gen uv run pyinstaller --onedir --name hrw4u --strip $(SCRIPT_HRW4U) uv run pyinstaller --onedir --name u4wrh --strip $(SCRIPT_U4WRH) uv run pyinstaller --onedir --name hrw4u-lsp --strip $(SCRIPT_LSP) uv run pyinstaller --onedir --name hrw4u-kg --strip $(SCRIPT_KG) - uv run pyinstaller --onedir --name hrw4u-ast --strip $(SCRIPT_AST) # Wheel packaging (adjust pyproject to include both packages if desired) package: gen diff --git a/tools/hrw4u/pyproject.toml b/tools/hrw4u/pyproject.toml index 16a72f4b9e2..d30dd7491f5 100644 --- a/tools/hrw4u/pyproject.toml +++ b/tools/hrw4u/pyproject.toml @@ -60,7 +60,7 @@ u4wrh = "u4wrh.__main__:main" hrw4u-lsp = "hrw4u_lsp.__main__:main" [tool.setuptools] -script-files = ["scripts/hrw4u", "scripts/u4wrh", "scripts/hrw4u-lsp", "scripts/hrw4u-kg", "scripts/hrw4u-ast"] +script-files = ["scripts/hrw4u", "scripts/u4wrh", "scripts/hrw4u-lsp", "scripts/hrw4u-kg"] [tool.setuptools.packages.find] where = ["build"] From e32c1abb591b9a845e2ae28bc1a62d80b7bf64ee Mon Sep 17 00:00:00 2001 From: Juan Posadas Date: Tue, 19 May 2026 17:05:24 -0600 Subject: [PATCH 14/14] hrw4u: Run format --- tools/hrw4u/scripts/hrw4u-ast | 10 ++-------- tools/hrw4u/src/ast_builder.py | 3 ++- tools/hrw4u/src/ast_nodes.py | 1 - tools/hrw4u/tests/test_ast_builder.py | 14 ++++++++------ tools/hrw4u/tests/test_hrw4u_ast.py | 1 - 5 files changed, 12 insertions(+), 17 deletions(-) diff --git a/tools/hrw4u/scripts/hrw4u-ast b/tools/hrw4u/scripts/hrw4u-ast index da22112a0fc..ab17579d919 100755 --- a/tools/hrw4u/scripts/hrw4u-ast +++ b/tools/hrw4u/scripts/hrw4u-ast @@ -55,15 +55,9 @@ def main() -> int: parser = argparse.ArgumentParser( description="Inspect the HRW4U AST and surrounding stages.", formatter_class=argparse.RawDescriptionHelpFormatter, - epilog="Stages:\n" - " cst ANTLR concrete syntax tree (raw parse tree)\n" - " ast dataclass AST built by ASTBuilder (default)\n") + epilog="Stages:\n cst ANTLR concrete syntax tree (raw parse tree)\n ast dataclass AST built by ASTBuilder (default)\n") parser.add_argument( - "input_file", - nargs="?", - type=argparse.FileType("r"), - default=sys.stdin, - help="HRW4U source file (default: stdin)") + "input_file", nargs="?", type=argparse.FileType("r"), default=sys.stdin, help="HRW4U source file (default: stdin)") parser.add_argument( "--stage", choices=sorted(STAGES.keys()), default=DEFAULT_STAGE, help=f"Which stage to emit (default: {DEFAULT_STAGE})") args = parser.parse_args() diff --git a/tools/hrw4u/src/ast_builder.py b/tools/hrw4u/src/ast_builder.py index b58f9fc3506..a91945e792e 100644 --- a/tools/hrw4u/src/ast_builder.py +++ b/tools/hrw4u/src/ast_builder.py @@ -229,7 +229,8 @@ def _detect_comparison_operator(self, ctx) -> nodes.CmpOp: return nodes.CmpOp.NOT_IN if ctx.BANG() else nodes.CmpOp.IN raise ValueError(f"Unhandled comparison operator at line {ctx.start.line}") - def _extract_comparison_rhs(self, ctx, operator: nodes.CmpOp) -> nodes.ValueExpr | nodes.RegexValue | tuple[nodes.ValueExpr, ...]: + def _extract_comparison_rhs(self, ctx, + operator: nodes.CmpOp) -> nodes.ValueExpr | nodes.RegexValue | tuple[nodes.ValueExpr, ...]: if operator in (nodes.CmpOp.MATCH, nodes.CmpOp.NOT_MATCH): return nodes.RegexValue(pattern=ctx.regex().getText()[1:-1]) if operator in (nodes.CmpOp.IN, nodes.CmpOp.NOT_IN): diff --git a/tools/hrw4u/src/ast_nodes.py b/tools/hrw4u/src/ast_nodes.py index ce2bd1b683f..c86c234202f 100644 --- a/tools/hrw4u/src/ast_nodes.py +++ b/tools/hrw4u/src/ast_nodes.py @@ -55,7 +55,6 @@ "TopLevelNode", ] - # Enum.__str__ yields "AssignOp.ASSIGN" while the default Enum.__repr__ yields # ""; we alias __repr__ to __str__ on every operator enum # so that pprint output (used by hrw4u-ast) is concise and readable. diff --git a/tools/hrw4u/tests/test_ast_builder.py b/tools/hrw4u/tests/test_ast_builder.py index 6bfc52799f2..178bafa902a 100644 --- a/tools/hrw4u/tests/test_ast_builder.py +++ b/tools/hrw4u/tests/test_ast_builder.py @@ -186,11 +186,12 @@ def test_multiple_use_directives(self): assert directives[1].spec == "test::stamp-request" def test_top_level_comments_skipped(self): - src = ('# leading comment\n' - 'use test::helper\n' - '# between use and section\n' - 'REMAP {\n set-debug();\n}\n' - '# trailing comment\n') + src = ( + '# leading comment\n' + 'use test::helper\n' + '# between use and section\n' + 'REMAP {\n set-debug();\n}\n' + '# trailing comment\n') ast = _build(src) assert len(ast.body) == 2 assert isinstance(ast.body[0], nodes.UseDirective) @@ -832,7 +833,8 @@ def test_set_membership_with_modifier(self): cond = ast.body[0].body[0].condition assert isinstance(cond, nodes.Comparison) assert cond.operator == nodes.CmpOp.IN - assert cond.right == (nodes.LiteralStringValue(text="php"), nodes.LiteralStringValue(text="php3"), nodes.LiteralStringValue(text="php4")) + assert cond.right == ( + nodes.LiteralStringValue(text="php"), nodes.LiteralStringValue(text="php3"), nodes.LiteralStringValue(text="php4")) assert cond.modifiers == ("EXT",) def test_debug_pattern_for_lint_rules(self): diff --git a/tools/hrw4u/tests/test_hrw4u_ast.py b/tools/hrw4u/tests/test_hrw4u_ast.py index 451c9848ba3..726576ab744 100644 --- a/tools/hrw4u/tests/test_hrw4u_ast.py +++ b/tools/hrw4u/tests/test_hrw4u_ast.py @@ -20,7 +20,6 @@ import sys from pathlib import Path - SAMPLE = 'REMAP {\n inbound.req.X-Foo = "bar";\n set-debug();\n}\n'