From 7f64e5ef034721cbc5da120a5df5449ca92096e2 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 25 Apr 2026 17:42:24 +0700 Subject: [PATCH] feat(hierarchy): add Node tree + path query support Preserve parent-child relationships in a Node tree at parse time. Extend Find/FindAll with " > " path operator for scoped queries, e.g. id:LoginScreen > desc:EmailInput. Flat Elements slice and all public signatures unchanged. --- internal/hierarchy/hierarchy.go | 129 +++++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 10 deletions(-) diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index 60f5317..902b7f3 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -1,12 +1,18 @@ // Package hierarchy parses the TreeNode JSON produced by the Maestro sidecar // and resolves selectors against it. // -// Selector grammar (v0.1): +// Selector grammar (v1.0): // -// id: — match resource-id ending with ":id/" or equal to -// text: — exact match on the node's text -// desc: — exact match on the node's content-desc -// descPrefix: — startsWith match on the node's content-desc (e.g. Compose testTag + UUID) +// Single selectors (global scan): +// id: - resource-id == suffix or ends with ":id/" +// text: - exact text match +// desc: - exact content-desc match +// descPrefix: - content-desc starts with prefix +// +// Path queries (segments separated by " > "): +// > > ... - each segment is matched within the subtree of the +// previous match (any descendant, not just direct child) +// example: id:LoginScreen > desc:EmailInput package hierarchy import ( @@ -54,8 +60,15 @@ type Element struct { Bounds Bounds `json:"bounds"` } +// Node is one node in the hierarchy tree. +type Node struct { + Element + Children []*Node `json:"-"` +} + // Tree is a flat collection of every node in a hierarchy dump, in pre-order. type Tree struct { + Root *Node `json:"-"` Elements []*Element `json:"elements"` } @@ -81,16 +94,17 @@ func Parse(text string) (*Tree, error) { return nil, fmt.Errorf("hierarchy: %w", err) } tree := &Tree{} - walkNode(&root, tree) + tree.Root = walkNode(&root, tree) return tree, nil } -func walkNode(node *treeNodeJSON, tree *Tree) { - element := elementFromNode(node) - tree.Elements = append(tree.Elements, element) +func walkNode(node *treeNodeJSON, tree *Tree) *Node { + n := &Node{Element: *elementFromNode(node)} + tree.Elements = append(tree.Elements, &n.Element) for i := range node.Children { - walkNode(&node.Children[i], tree) + n.Children = append(n.Children, walkNode(&node.Children[i], tree)) } + return n } func elementFromNode(node *treeNodeJSON) *Element { @@ -138,6 +152,9 @@ func elementFromNode(node *treeNodeJSON) *Element { // Find returns the first element matching the selector, or nil. func (t *Tree) Find(selector string) *Element { + if strings.Contains(selector, " > ") { + return findPath(t.Root, strings.Split(selector, " > ")) + } kind, value, ok := parseSelector(selector) if !ok { return nil @@ -152,6 +169,9 @@ func (t *Tree) Find(selector string) *Element { // FindAll returns every element matching the selector. func (t *Tree) FindAll(selector string) []*Element { + if strings.Contains(selector, " > ") { + return findPathAll(t.Root, strings.Split(selector, " > ")) + } kind, value, ok := parseSelector(selector) if !ok { return nil @@ -165,6 +185,95 @@ func (t *Tree) FindAll(selector string) []*Element { return matches } +func findPath(root *Node, segments []string) *Element { + if root == nil || len(segments) == 0 { + return nil + } + kind, value, ok := parseSelector(segments[0]) + if !ok { + return nil + } + for _, node := range searchSubtree(root, kind, value) { + if len(segments) == 1 { + return &node.Element + } + if result := findPathDescendants(node, segments[1:]); result != nil { + return result + } + } + return nil +} + +func findPathDescendants(root *Node, segments []string) *Element { + kind, value, ok := parseSelector(segments[0]) + if !ok { + return nil + } + for _, child := range root.Children { + for _, node := range searchSubtree(child, kind, value) { + if len(segments) == 1 { + return &node.Element + } + if result := findPathDescendants(node, segments[1:]); result != nil { + return result + } + } + } + return nil +} + +func findPathAll(root *Node, segments []string) []*Element { + if root == nil || len(segments) == 0 { + return nil + } + kind, value, ok := parseSelector(segments[0]) + if !ok { + return nil + } + var result []*Element + for _, node := range searchSubtree(root, kind, value) { + if len(segments) == 1 { + result = append(result, &node.Element) + continue + } + result = append(result, findPathAllDescendants(node, segments[1:])...) + } + return result +} + +func findPathAllDescendants(root *Node, segments []string) []*Element { + kind, value, ok := parseSelector(segments[0]) + if !ok { + return nil + } + var result []*Element + for _, child := range root.Children { + for _, node := range searchSubtree(child, kind, value) { + if len(segments) == 1 { + result = append(result, &node.Element) + continue + } + result = append(result, findPathAllDescendants(node, segments[1:])...) + } + } + return result +} + +// searchSubtree returns all nodes under root (inclusive) matching kind:value. +func searchSubtree(root *Node, kind, value string) []*Node { + if root == nil { + return nil + } + var result []*Node + if match(&root.Element, kind, value) { + result = append(result, root) + } + for _, child := range root.Children { + result = append(result, searchSubtree(child, kind, value)...) + } + return result +} + func parseSelector(selector string) (string, string, bool) { index := strings.IndexByte(selector, ':') if index <= 0 {