From e33b728b865800fa21d292e69c0bcf9fa633d22d Mon Sep 17 00:00:00 2001 From: Kamil Kozik Date: Sun, 22 Feb 2026 20:17:55 +0100 Subject: [PATCH 1/2] increase coverage failure threshold --- .coveragerc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.coveragerc b/.coveragerc index 30e6dc8c..1a959f1c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,8 +5,8 @@ omit = hcl2/lark_parser.py hcl2/version.py hcl2/__init__.py - hcl2/rules/__init__.py + hcl2/rules/__init__.py [report] show_missing = true -fail_under = 80 +fail_under = 95 From 0df1ccf404b6bc00ced2e8aa6e24a462bea8373c Mon Sep 17 00:00:00 2001 From: Kamil Kozik Date: Sun, 22 Feb 2026 20:33:58 +0100 Subject: [PATCH 2/2] add WIP rules processing feature --- .../memory/tree-walking-investigation.md | 54 ++++ hcl2/editor.py | 77 ++++++ hcl2/processor.py | 258 ++++++++++++++++++ 3 files changed, 389 insertions(+) create mode 100644 .claude/projects/-Users-kkozik-Documents-dev-astrotools-python-hcl2/memory/tree-walking-investigation.md create mode 100644 hcl2/editor.py create mode 100644 hcl2/processor.py diff --git a/.claude/projects/-Users-kkozik-Documents-dev-astrotools-python-hcl2/memory/tree-walking-investigation.md b/.claude/projects/-Users-kkozik-Documents-dev-astrotools-python-hcl2/memory/tree-walking-investigation.md new file mode 100644 index 00000000..055e5448 --- /dev/null +++ b/.claude/projects/-Users-kkozik-Documents-dev-astrotools-python-hcl2/memory/tree-walking-investigation.md @@ -0,0 +1,54 @@ +# Tree Walking & Editing — Investigation Notes + +## Context + +Investigating "power-user" features for walking/editing the parsed LarkElement tree. + +## Two Uncommitted Approaches Found + +### 1. `hcl2/processor.py` — `RulesProcessor` (Selenium-like wrapper) + +Already fleshed out. Wraps a `LarkRule` node, returns new `RulesProcessor` instances (chainable). + +**Capabilities:** + +- **Search:** `find_blocks()`, `find_attributes()`, `find_rules()`, `find_by_predicate()` +- **Walk:** `walk()` generator yielding `(processor, children)` tuples +- **Navigate:** `next()`, `previous()`, `siblings`, `next_siblings`, `previous_siblings` +- **Mutate:** `replace(new_node)`, `append_child(new_node)` +- Commented out: `insert_before()` + +### 2. `hcl2/editor.py` — `Editor` with `TreePath` (XPath-like navigation) + +Embryonic. `TreePath` = list of `(rule_name, index)` steps. Has debug `print()` statements. `visit()` method sketched but commented out. + +## Recommendation: Selenium-style (`RulesProcessor`) wins + +**Why it fits:** + +- Tree is heterogeneous — chainable typed wrappers that "find, then act" match well +- Users want semantic searches (find blocks by label, attributes by name) — Selenium's `find_element_by_*` pattern +- Fluent chaining: `proc.find_block(["resource", "aws_instance"]).attribute("ami").replace(new_value)` +- Already handles skipping whitespace/comment nodes during navigation + +**Why XPath-style (`Editor`) doesn't fit:** + +- Paths are fragile — depend on exact tree structure and child indices +- Users shouldn't need to know `attribute → expr_term → expression → literal` path structure +- Doesn't compose with searches — purely positional + +## Gaps in `RulesProcessor` Before It's a Complete Power-User API + +1. **Missing mutations:** `insert_before()` / `insert_after()` / `remove()` / `delete()` +1. **Not exposed in `api.py`** — no public entry point +1. **No `__repr__`** — hard to inspect interactively +1. **No Builder integration** — no easy way to construct replacement nodes inline +1. **`walk()` quirk** — wraps all children including `None` and tokens, can be awkward +1. **Naming** — could become `HCLNavigator` or `TreeCursor` for public surface + +## Key Architecture Context + +- `LarkElement` (ABC) → `LarkToken` (terminals) and `LarkRule` (non-terminals) +- Bidirectional parent-child refs auto-set in `LarkRule.__init__` +- ~41 rule classes across 7 domain files +- Primitive mutation: `LarkToken.set_value()`, direct `_children` list mutation diff --git a/hcl2/editor.py b/hcl2/editor.py new file mode 100644 index 00000000..9efce08f --- /dev/null +++ b/hcl2/editor.py @@ -0,0 +1,77 @@ +import dataclasses +from copy import copy, deepcopy +from typing import List, Optional, Set, Tuple + +from hcl2.rule_transformer.rules.abstract import LarkRule +from hcl2.rule_transformer.rules.base import BlockRule, StartRule + + +@dataclasses.dataclass +class TreePathElement: + + name: str + index: int = 0 + + +@dataclasses.dataclass +class TreePath: + + elements: List[TreePathElement] = dataclasses.field(default_factory=list) + + @classmethod + def build(cls, elements: List[Tuple[str, Optional[int]] | str]): + results = [] + for element in elements: + if isinstance(element, tuple): + if len(element) == 1: + result = TreePathElement(element[0], 0) + else: + result = TreePathElement(*element) + else: + result = TreePathElement(element, 0) + + results.append(result) + + return cls(results) + + def __iter__(self): + return self.elements.__iter__() + + def __len__(self): + return self.elements.__len__() + + +class Editor: + def __init__(self, rules_tree: LarkRule): + self.rules_tree = rules_tree + + @classmethod + def _find_one(cls, rules_tree: LarkRule, path_element: TreePathElement) -> LarkRule: + return cls._find_all(rules_tree, path_element.name)[path_element.index] + + @classmethod + def _find_all(cls, rules_tree: LarkRule, rule_name: str) -> List[LarkRule]: + children = [] + print("rule", rules_tree) + print("rule children", rules_tree.children) + for child in rules_tree.children: + if isinstance(child, LarkRule) and child.lark_name() == rule_name: + children.append(child) + + return children + + def find_by_path(self, path: TreePath, rule_name: str) -> List[LarkRule]: + path = deepcopy(path.elements) + + current_rule = self.rules_tree + while len(path) > 0: + current_path, *path = path + print(current_path, path) + current_rule = self._find_one(current_rule, current_path) + + return self._find_all(current_rule, rule_name) + + # def visit(self, path: TreePath) -> "Editor": + # + # while len(path) > 1: + # current = diff --git a/hcl2/processor.py b/hcl2/processor.py new file mode 100644 index 00000000..b854aff5 --- /dev/null +++ b/hcl2/processor.py @@ -0,0 +1,258 @@ +from copy import copy, deepcopy +from typing import ( + List, + Optional, + Union, + Callable, + Any, + Tuple, + Generic, + TypeVar, + cast, + Generator, +) + +from hcl2.rule_transformer.rules.abstract import LarkRule, LarkElement +from hcl2.rule_transformer.rules.base import BlockRule, AttributeRule +from hcl2.rule_transformer.rules.whitespace import NewLineOrCommentRule + +T = TypeVar("T", bound=LarkRule) + + +class RulesProcessor(Generic[T]): + """""" + + @classmethod + def _traverse( + cls, + node: T, + predicate: Callable[[T], bool], + current_depth: int = 0, + max_depth: Optional[int] = None, + ) -> List["RulesProcessor"]: + + results = [] + + if predicate(node): + results.append(cls(node)) + + if max_depth is not None and current_depth >= max_depth: + return results + + for child in node.children: + if child is None or not isinstance(child, LarkRule): + continue + + child_results = cls._traverse( + child, + predicate, + current_depth + 1, + max_depth, + ) + results.extend(child_results) + + return results + + def __init__(self, node: LarkRule): + self.node = node + + @property + def siblings(self): + if self.node.parent is None: + return None + return self.node.parent.children + + @property + def next_siblings(self): + if self.node.parent is None: + return None + return self.node.parent.children[self.node.index + 1 :] + + @property + def previous_siblings(self): + if self.node.parent is None: + return None + return self.node.parent.children[: self.node.index - 1] + + def walk(self) -> Generator[Tuple["RulesProcessor", List["RulesProcessor"]]]: + child_processors = [self.__class__(child) for child in self.node.children] + yield self, child_processors + for processor in child_processors: + if isinstance(processor.node, LarkRule): + for result in processor.walk(): + yield result + + def find_block( + self, + labels: List[str], + exact_match: bool = True, + max_depth: Optional[int] = None, + ) -> "RulesProcessor[BlockRule]": + return self.find_blocks(labels, exact_match, max_depth)[0] + + def find_blocks( + self, + labels: List[str], + exact_match: bool = True, + max_depth: Optional[int] = None, + ) -> List["RulesProcessor[BlockRule]"]: + """ + Find blocks by their labels. + + Args: + labels: List of label strings to match + exact_match: If True, all labels must match exactly. If False, labels can be a subset. + max_depth: Maximum depth to search + + Returns: + ... + """ + + def block_predicate(node: LarkRule) -> bool: + if not isinstance(node, BlockRule): + return False + + node_labels = [label.serialize() for label in node.labels] + + if exact_match: + return node_labels == labels + else: + # Check if labels is a prefix of node_labels + if len(labels) > len(node_labels): + return False + return node_labels[: len(labels)] == labels + + return cast( + List[RulesProcessor[BlockRule]], + self._traverse(self.node, block_predicate, max_depth=max_depth), + ) + + def attribute( + self, name: str, max_depth: Optional[int] = None + ) -> "RulesProcessor[AttributeRule]": + return self.find_attributes(name, max_depth)[0] + + def find_attributes( + self, name: str, max_depth: Optional[int] = None + ) -> List["RulesProcessor[AttributeRule]"]: + """ + Find attributes by their identifier name. + + Args: + name: Attribute name to search for + max_depth: Maximum depth to search + + Returns: + List of TreePath objects for matching attributes + """ + + def attribute_predicate(node: LarkRule) -> bool: + if not isinstance(node, AttributeRule): + return False + return node.identifier.serialize() == name + + return self._traverse(self.node, attribute_predicate, max_depth=max_depth) + + def rule(self, rule_name: str, max_depth: Optional[int] = None): + return self.find_rules(rule_name, max_depth)[0] + + def find_rules( + self, rule_name: str, max_depth: Optional[int] = None + ) -> List["RulesProcessor"]: + """ + Find all rules of a specific type. + + Args: + rule_name: Name of the rule type to find + max_depth: Maximum depth to search + + Returns: + List of TreePath objects for matching rules + """ + + def rule_predicate(node: LarkRule) -> bool: + return node.lark_name() == rule_name + + return self._traverse(self.node, rule_predicate, max_depth=max_depth) + + def find_by_predicate( + self, predicate: Callable[[LarkRule], bool], max_depth: Optional[int] = None + ) -> List["RulesProcessor"]: + """ + Find all rules matching a custom predicate. + + Args: + predicate: Function that returns True for nodes to collect + max_depth: Maximum depth to search + + Returns: + List of TreePath objects for matching rules + """ + return self._traverse(self.node, predicate, max_depth) + + # Convenience methods + def get_all_blocks(self, max_depth: Optional[int] = None) -> List: + """Get all blocks in the tree.""" + return self.find_rules("block", max_depth) + + def get_all_attributes( + self, max_depth: Optional[int] = None + ) -> List["RulesProcessor"]: + """Get all attributes in the tree.""" + return self.find_rules("attribute", max_depth) + + def previous(self, skip_new_line: bool = True) -> Optional["RulesProcessor"]: + """Get the next sibling node.""" + if self.node.parent is None: + return None + + for sibling in reversed(self.previous_siblings): + if sibling is not None and isinstance(sibling, LarkRule): + if skip_new_line and isinstance(sibling, NewLineOrCommentRule): + continue + return self.__class__(sibling) + + def next(self, skip_new_line: bool = True) -> Optional["RulesProcessor"]: + """Get the next sibling node.""" + if self.node.parent is None: + return None + + for sibling in self.next_siblings: + if sibling is not None and isinstance(sibling, LarkRule): + if skip_new_line and isinstance(sibling, NewLineOrCommentRule): + continue + return self.__class__(sibling) + + def append_child( + self, new_node: LarkRule, indentation: bool = True + ) -> "RulesProcessor": + children = self.node.children + if indentation: + if isinstance(children[-1], NewLineOrCommentRule): + children.pop() + children.append(NewLineOrCommentRule.from_string("\n ")) + + new_node = deepcopy(new_node) + new_node.set_parent(self.node) + new_node.set_index(len(children)) + children.append(new_node) + return self.__class__(new_node) + + def replace(self, new_node: LarkRule) -> "RulesProcessor": + new_node = deepcopy(new_node) + + self.node.parent.children.pop(self.node.index) + self.node.parent.children.insert(self.node.index, new_node) + new_node.set_parent(self.node.parent) + new_node.set_index(self.node.index) + return self.__class__(new_node) + + # def insert_before(self, new_node: LarkRule) -> bool: + # """Insert a new node before this one.""" + # if self.parent is None or self.parent_index < 0: + # return False + # + # try: + # self.parent.children.insert(self.parent_index, new_node) + # except (IndexError, AttributeError): + # return False