From 9527eae3ffd9b376ca509bf25ce363eeb32a1bc5 Mon Sep 17 00:00:00 2001 From: Thien Trung Vuong Date: Mon, 24 Aug 2026 20:42:10 +0000 Subject: [PATCH 1/2] refactor(spec): add lossless structural parser Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/rpm/spec/tree.go | 898 ++++++++++++++++++++++ internal/rpm/spec/tree_raw_braces_test.go | 35 + internal/rpm/spec/tree_test.go | 192 +++++ 3 files changed, 1125 insertions(+) create mode 100644 internal/rpm/spec/tree.go create mode 100644 internal/rpm/spec/tree_raw_braces_test.go create mode 100644 internal/rpm/spec/tree_test.go diff --git a/internal/rpm/spec/tree.go b/internal/rpm/spec/tree.go new file mode 100644 index 000000000..291b3e709 --- /dev/null +++ b/internal/rpm/spec/tree.go @@ -0,0 +1,898 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package spec + +import ( + "fmt" + "strings" +) + +// blockKind classifies what a [block] represents in the spec tree. +type blockKind int + +const ( + // rootBlock is the top-level container for the entire spec. + rootBlock blockKind = iota + // sectionBlock is a named section (e.g., %build, %package -n foo). + // The implicit preamble (before any section header) is also a [sectionBlock] + // with an empty [block.Name]. + sectionBlock + // conditionalBlock is a %if/%endif block. May wrap sections (at top level) + // or appear as content inside a section. + conditionalBlock + // textBlock is a contiguous run of raw text lines (leaf node). + textBlock + // macroDefBlock is a %define/%global directive, optionally spanning + // multiple lines via backslash continuation. + macroDefBlock +) + +// block is a recursive node in the spec's structural tree. +// +// The tree is built by [parseTree] and serialized back to lines by [serializeTree]. +// Operations find and manipulate blocks, then serialize to update [Spec.rawLines]. +type block struct { + // Kind classifies this block. + Kind blockKind + // Header is the opening line: section header, conditional directive, or macro + // definition line. Empty for [rootBlock] and [textBlock]. + Header string + // Name is the section keyword (e.g., "%build") or macro name (e.g., "buildflags"). + // Empty for [rootBlock], [conditionalBlock], and [textBlock]. + Name string + // Package is the sub-package name for section blocks (e.g., "devel", "foo"). + // Empty for sections that target the main package. + Package string + // Endif is the %endif line text for [conditionalBlock] nodes. + Endif string + // Lines holds raw text for [textBlock] and [macroDefBlock] leaf nodes + // (including continuation lines for multi-line macros). + Lines []string + // Children holds nested blocks. For [sectionBlock], these are the section's + // content. For [conditionalBlock], these are the "then" branch. For [rootBlock], + // these are top-level sections and conditional wrappers. + Children []*block + // Else holds the "else" branch blocks for [conditionalBlock] nodes. + // nil when there is no %else/%elif branch. + Else []*block + // ElseDirective is the %else/%elif directive line, if present. + ElseDirective string +} + +// treeConditionalPair represents a matched `%if`/`%endif` pair by their line numbers. +type treeConditionalPair struct { + ifLine int + endifLine int +} + +// parseTree parses raw spec lines into a [block] tree. +// +// The parser runs in two passes: +// 1. Collect conditional pairs (%if/%endif) and section header positions. +// 2. Build the tree, classifying each conditional as a wrapper (spans sections) +// or content block (fully inside a section) based on whether its body contains +// section headers. +// +// Only '%define' and '%global' continuation bodies are opaque. Ordinary script +// backslashes do not suppress RPM section headers or conditional directives. +func parseTree(rawLines []string) (*block, error) { + pairs, err := collectTreeConditionalPairs(rawLines) + if err != nil { + return nil, fmt.Errorf("parsing conditional structure:\n%w", err) + } + + pairByIf := make(map[int]treeConditionalPair, len(pairs)) + for _, p := range pairs { + pairByIf[p.ifLine] = p + } + + sectionHeaders := findSectionHeaderLines(rawLines) + + sectionHeaderSet := make(map[int]bool, len(sectionHeaders)) + for _, h := range sectionHeaders { + sectionHeaderSet[h] = true + } + + root := &block{Kind: rootBlock} + + err = buildBlockChildren(rawLines, 0, len(rawLines), pairByIf, sectionHeaderSet, root, true) + if err != nil { + return nil, fmt.Errorf("building spec tree:\n%w", err) + } + + // Wrap leading non-section children (preamble content) into an implicit + // preamble sectionBlock with empty Name, matching how Visit treats lines + // before the first section header. This allows findSectionBlock(root, "", "") + // to locate the preamble. + wrapPreamble(root) + + return root, nil +} + +// collectTreeConditionalPairs matches conditionals while treating macro bodies as +// opaque. Unlike the line-oriented editor helper, the structural parser must not +// interpret directive-shaped macro content. +func collectTreeConditionalPairs(rawLines []string) ([]treeConditionalPair, error) { + var ( + pairs []treeConditionalPair + stack []int + inMacroBody bool + parseState macroState + ) + + for lineNum, line := range rawLines { + if inMacroBody { + parseState, inMacroBody = macroBodyStateAfter(line, parseState) + + continue + } + + if _, isMacro := isMacroDefLine(line); isMacro { + parseState, inMacroBody = macroBodyStateAfter(line, macroState{}) + + continue + } + + switch conditionalDepthChange(line) { + case 1: + stack = append(stack, lineNum) + case -1: + if len(stack) == 0 { + return nil, fmt.Errorf("unmatched %%endif at line %d", lineNum+1) + } + + ifLine := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + pairs = append(pairs, treeConditionalPair{ifLine: ifLine, endifLine: lineNum}) + } + } + + if len(stack) > 0 { + return nil, fmt.Errorf("unmatched %%if at line %d", stack[0]+1) + } + + return pairs, nil +} + +// wrapPreamble wraps the leading non-section children of root into a preamble +// [sectionBlock] with empty Name and Package. If the root already starts with +// a [sectionBlock], no wrapping is needed. +func wrapPreamble(root *block) { + // Find the index of the first sectionBlock or section-wrapping conditionalBlock. + firstSectionIdx := -1 + + for childIdx, child := range root.Children { + if child.Kind == sectionBlock { + firstSectionIdx = childIdx + + break + } + + if child.Kind == conditionalBlock && containsSectionBlocks(child) { + firstSectionIdx = childIdx + + break + } + } + + // If everything is preamble (no sections) or nothing precedes the first section, + // still wrap in a preamble block for uniform access. + preambleEnd := firstSectionIdx + if preambleEnd < 0 { + preambleEnd = len(root.Children) + } + + if preambleEnd == 0 { + // Nothing to wrap, but insert an empty preamble for uniform lookup. + preamble := &block{Kind: sectionBlock, Name: "", Package: ""} + root.Children = append([]*block{preamble}, root.Children...) + + return + } + + preamble := &block{ + Kind: sectionBlock, + Name: "", + Package: "", + Children: root.Children[:preambleEnd], + } + + root.Children = append([]*block{preamble}, root.Children[preambleEnd:]...) +} + +// containsSectionBlocks checks if a block (typically a conditionalBlock) contains +// any sectionBlock children in any branch, recursing through %elif chains. +func containsSectionBlocks(block *block) bool { + for _, child := range block.Children { + if child.Kind == sectionBlock { + return true + } + + if child.Kind == conditionalBlock && containsSectionBlocks(child) { + return true + } + } + + for _, child := range block.Else { + if child.Kind == sectionBlock { + return true + } + + if child.Kind == conditionalBlock && containsSectionBlocks(child) { + return true + } + } + + return false +} + +// findSectionHeaderLines returns the 0-indexed line numbers of all section headers, +// respecting line continuations (backslash-terminated lines suppress the next line). +func findSectionHeaderLines(rawLines []string) []int { + var headers []int + + inMacroBody := false + macroParseState := macroState{} + + for lineIdx, line := range rawLines { + if inMacroBody { + macroParseState, inMacroBody = macroBodyStateAfter(line, macroParseState) + + continue + } + + if isSectionHeaderLine(line) { + headers = append(headers, lineIdx) + } + + if _, isMacro := isMacroDefLine(line); isMacro { + macroParseState, inMacroBody = macroBodyStateAfter(line, macroState{}) + } + } + + return headers +} + +// isSectionHeaderLine returns true if the line starts a new RPM spec section. +func isSectionHeaderLine(rawLine string) bool { + tokens := strings.Fields(strings.TrimSpace(rawLine)) + if len(tokens) == 0 { + return false + } + + _, known := sectionTypesByName[strings.ToLower(tokens[0])] + + return known +} + +// hasSectionHeaderInRange checks whether any line in [start, end) is a section header. +func hasSectionHeaderInRange(start, end int, sectionHeaderSet map[int]bool) bool { + for lineNum := start; lineNum < end; lineNum++ { + if sectionHeaderSet[lineNum] { + return true + } + } + + return false +} + +// buildBlockChildren parses lines in [start, end) and appends resulting blocks +// to parent.Children. topLevel indicates whether sections can appear (true at +// root level and inside conditional wrappers). +// +//nolint:funlen // Recursive parser with multiple block types. +func buildBlockChildren( + rawLines []string, + start, end int, + pairByIf map[int]treeConditionalPair, + sectionHeaderSet map[int]bool, + parent *block, + topLevel bool, +) error { + lineIdx := start + + var textBuf []string + + flushText := func() { + if len(textBuf) > 0 { + parent.Children = append(parent.Children, &block{ + Kind: textBlock, + Lines: textBuf, + }) + + textBuf = nil + } + } + + for lineIdx < end { + line := rawLines[lineIdx] + + // Section headers (only at top level). + if topLevel && sectionHeaderSet[lineIdx] { + flushText() + + name, pkg := getSectionNameAndPackageFromHeader(line) + sectionBlock := &block{ + Kind: sectionBlock, + Header: line, + Name: name, + Package: pkg, + } + + sectionEnd := findTreeSectionEnd(lineIdx+1, end, pairByIf, sectionHeaderSet) + + err := buildBlockChildren(rawLines, lineIdx+1, sectionEnd, pairByIf, sectionHeaderSet, sectionBlock, false) + if err != nil { + return err + } + + parent.Children = append(parent.Children, sectionBlock) + lineIdx = sectionEnd + + continue + } + + // Conditional directives. + if conditionalDepthChange(line) == 1 { + flushText() + + pair, ok := pairByIf[lineIdx] + if !ok { + return fmt.Errorf("%%if at line %d has no matching pair", lineIdx+1) + } + + condBlock := &block{ + Kind: conditionalBlock, + Header: line, + Endif: rawLines[pair.endifLine], + } + + bodyStart := lineIdx + 1 + bodyEnd := pair.endifLine + + elseLine := findElseDirectiveLine(rawLines, bodyStart, bodyEnd) + + thenEnd := bodyEnd + if elseLine >= 0 { + thenEnd = elseLine + } + + isWrapper := hasSectionHeaderInRange(bodyStart, bodyEnd, sectionHeaderSet) + + if err := buildConditionalBranches( + rawLines, bodyStart, thenEnd, elseLine, bodyEnd, + pairByIf, sectionHeaderSet, condBlock, isWrapper, + ); err != nil { + return err + } + + parent.Children = append(parent.Children, condBlock) + lineIdx = pair.endifLine + 1 + + continue + } + + // Macro definitions. + if name, ok := isMacroDefLine(line); ok { + flushText() + + macroBlock, nextLineIdx, err := parseMacroDefBlock(rawLines, lineIdx, end, name) + if err != nil { + return err + } + + parent.Children = append(parent.Children, macroBlock) + lineIdx = nextLineIdx + + continue + } + + // Plain text line. + textBuf = append(textBuf, line) + lineIdx++ + } + + flushText() + + return nil +} + +func parseMacroDefBlock(rawLines []string, start, end int, name string) (*block, int, error) { + macroBlock := &block{ + Kind: macroDefBlock, + Header: rawLines[start], + Name: name, + Lines: []string{rawLines[start]}, + } + + state, continues := macroBodyStateAfter(rawLines[start], macroState{}) + + lineIdx := start + 1 + if !continues { + return macroBlock, lineIdx, nil + } + + for lineIdx < end { + line := rawLines[lineIdx] + macroBlock.Lines = append(macroBlock.Lines, line) + state, continues = macroBodyStateAfter(line, state) + lineIdx++ + + if !continues { + return macroBlock, lineIdx, nil + } + } + + return nil, 0, fmt.Errorf("unterminated macro construct at line %d", start+1) +} + +// buildConditionalBranches parses the then and optional else/elif branches of a +// conditional block. For %elif chains, the else branch contains a single nested +// [conditionalBlock] whose Header is the %elif directive, forming a linked list. +func buildConditionalBranches( + rawLines []string, + bodyStart, thenEnd, elseLine, bodyEnd int, + pairByIf map[int]treeConditionalPair, + sectionHeaderSet map[int]bool, + condBlock *block, + isWrapper bool, +) error { + err := buildBlockChildren(rawLines, bodyStart, thenEnd, pairByIf, sectionHeaderSet, condBlock, isWrapper) + if err != nil { + return err + } + + if elseLine < 0 { + return nil + } + + if isElifDirective(rawLines[elseLine]) { + // %elif: create a nested conditionalBlock forming a linked list. + // The inner block has no Endif — only the outermost block owns %endif. + inner := &block{ + Kind: conditionalBlock, + Header: rawLines[elseLine], + } + + // Find the next branch directive (%elif/%else) within the remaining body. + nextElse := findElseDirectiveLine(rawLines, elseLine+1, bodyEnd) + + nextThenEnd := bodyEnd + if nextElse >= 0 { + nextThenEnd = nextElse + } + + if err := buildConditionalBranches( + rawLines, elseLine+1, nextThenEnd, nextElse, bodyEnd, + pairByIf, sectionHeaderSet, inner, isWrapper, + ); err != nil { + return err + } + + condBlock.Else = []*block{inner} + } else { + // %else: terminal branch — store directive and parse content directly. + condBlock.ElseDirective = rawLines[elseLine] + elseContainer := &block{Kind: rootBlock} + + err := buildBlockChildren(rawLines, elseLine+1, bodyEnd, pairByIf, sectionHeaderSet, elseContainer, isWrapper) + if err != nil { + return err + } + + condBlock.Else = elseContainer.Children + } + + return nil +} + +// isElifDirective returns true if the line is a %elif/%elifarch/%elifnarch/%elifos/%elifnos +// directive (as opposed to a plain %else which is a terminal branch). +func isElifDirective(rawLine string) bool { + tokens := strings.Fields(strings.TrimSpace(rawLine)) + if len(tokens) == 0 { + return false + } + + lower := strings.ToLower(tokens[0]) + + return lower != "%else" && isConditionalBranchDirective(rawLine) +} + +// findTreeSectionEnd finds where a section ends: at the next section header at the +// same nesting level, or at a conditional that wraps sections. +func findTreeSectionEnd(start, end int, pairByIf map[int]treeConditionalPair, sectionHeaderSet map[int]bool) int { + lineIdx := start + + for lineIdx < end { + if sectionHeaderSet[lineIdx] { + return lineIdx + } + + if pair, ok := pairByIf[lineIdx]; ok { + if hasSectionHeaderInRange(lineIdx+1, pair.endifLine, sectionHeaderSet) { + return lineIdx + } + + lineIdx = pair.endifLine + 1 + + continue + } + + lineIdx++ + } + + return end +} + +// findElseDirectiveLine finds the %else/%elif line within [start, end) at +// conditional depth 0. +func findElseDirectiveLine(rawLines []string, start, end int) int { + depth := 0 + inMacroBody := false + macroParseState := macroState{} + + for lineIdx := start; lineIdx < end; lineIdx++ { + line := rawLines[lineIdx] + if inMacroBody { + macroParseState, inMacroBody = macroBodyStateAfter(line, macroParseState) + + continue + } + + if _, isMacro := isMacroDefLine(line); isMacro { + macroParseState, inMacroBody = macroBodyStateAfter(line, macroState{}) + + continue + } + + d := conditionalDepthChange(line) + + switch { + case d == 1: + depth++ + case d == -1: + depth-- + case depth == 0 && isConditionalBranchDirective(line): + return lineIdx + } + } + + return -1 +} + +// isMacroDefLine returns the macro name if the line is a %define or %global directive. +func isMacroDefLine(rawLine string) (string, bool) { + trimmed := strings.TrimSpace(rawLine) + tokens := strings.Fields(trimmed) + + const minMacroDefTokens = 2 + + if len(tokens) < minMacroDefTokens { + return "", false + } + + lower := strings.ToLower(tokens[0]) + if lower == "%define" || lower == "%global" { + // Strip trailing parentheses from macro names with parameters, + // e.g. "%define foo(x)" → "foo". + name := tokens[1] + if idx := strings.IndexByte(name, '('); idx >= 0 { + name = name[:idx] + } + + return name, true + } + + return "", false +} + +type macroState struct { + depth int + escapedBraces int + shellBraces int + rawBraces int + lua *luaState +} + +type luaState struct { + braces int + nestedRPM int + quote byte + escaped bool + longClose string +} + +func (state macroState) open() bool { + return state.depth > 0 || state.escapedBraces > 0 || state.lua != nil +} + +// percentRunOpensBracedMacro reports whether the percent run at start ends in +// an active '%{' opener. RPM escapes percent pairs, leaving only odd runs live. +func percentRunOpensBracedMacro(content string, start int) bool { + if start >= len(content) || content[start] != '%' { + return false + } + + end := start + for end < len(content) && content[end] == '%' { + end++ + } + + return end < len(content) && content[end] == '{' && (end-start)%2 != 0 +} + +// macroBodyStateAfter advances the parser state for one physical macro-body +// line and reports whether the body continues onto another line. +func macroBodyStateAfter(line string, state macroState) (macroState, bool) { + state = macroStateAfter(line, state) + + return state, strings.HasSuffix(line, "\\") || state.open() +} + +// macroStateAfter tracks RPM macro constructs in a '%define'/'%global' body. +// Lua has its own syntax, so raw Lua braces, strings, comments, and nested RPM +// expansions are accounted for before deciding that the outer '%{lua:...}' +// expansion has ended. +// +//nolint:cyclop // Macro and shell delimiters require independent lexical states. +func macroStateAfter(line string, state macroState) macroState { + for idx := 0; idx < len(line); { + if state.lua != nil { + consumed, closed := state.lua.consume(line[idx:]) + idx += consumed + + if closed { + state.lua = nil + state.depth-- + } + + continue + } + + if state.escapedBraces > 0 { + state, idx = consumeEscapedBracedMacro(line, idx, state) + + continue + } + + switch { + case line[idx] == '%' && (idx == 0 || line[idx-1] != '%'): + state, idx = macroStateAfterPercentRun(line, idx, state) + case line[idx] == '$' && idx+1 < len(line) && line[idx+1] == '{': + state.shellBraces++ + idx += 2 + case line[idx] == '}' && state.shellBraces > 0: + state.shellBraces-- + idx++ + case line[idx] == '{' && state.depth > 0: + state.rawBraces++ + idx++ + case line[idx] == '}' && state.rawBraces > 0: + state.rawBraces-- + idx++ + case line[idx] == '}' && state.depth > 0: + state.depth-- + idx++ + default: + idx++ + } + } + + // Lua treats a backslash followed by a physical newline as one escaped + // newline. The next line starts with a fresh escape state. + if state.lua != nil { + state.lua.escaped = false + } + + return state +} + +func consumeEscapedBracedMacro(line string, idx int, state macroState) (macroState, int) { + switch line[idx] { + case '{': + state.escapedBraces++ + case '}': + state.escapedBraces-- + } + + return state, idx + 1 +} + +func macroStateAfterPercentRun(line string, idx int, state macroState) (macroState, int) { + runEnd := idx + for runEnd < len(line) && line[runEnd] == '%' { + runEnd++ + } + + if runEnd >= len(line) || line[runEnd] != '{' { + return state, runEnd + } + + if (runEnd-idx)%2 == 0 { + state.escapedBraces++ + + return state, runEnd + 1 + } + + state.depth++ + if strings.HasPrefix(line[runEnd-1:], "%{lua:") { + state.lua = &luaState{} + + return state, runEnd - 1 + len("%{lua:") + } + + return state, runEnd + 1 +} + +// consume scans one Lua body fragment. It returns whether the outer RPM Lua +// expansion closes. Lua line comments naturally end at the next physical line. +// +//nolint:cyclop,funlen // Lua lexical states must be recognized before structural braces. +func (state *luaState) consume(text string) (int, bool) { + const ( + longOpenLength = 2 + longCommentLength = 4 + ) + + for idx := 0; idx < len(text); { + if state.longClose != "" { + if strings.HasPrefix(text[idx:], state.longClose) { + idx += len(state.longClose) + state.longClose = "" + } else { + idx++ + } + + continue + } + + if state.quote != 0 { + switch { + case state.escaped: + state.escaped = false + case text[idx] == '\\': + state.escaped = true + case text[idx] == state.quote: + state.quote = 0 + } + + idx++ + + continue + } + + if delimiter, ok := luaLongDelimiter(text[idx:]); ok { + state.longClose = "]" + delimiter + "]" + idx += len(delimiter) + longOpenLength + + continue + } + + if strings.HasPrefix(text[idx:], "--") { + if delimiter, ok := luaLongDelimiter(text[idx+2:]); ok { + state.longClose = "]" + delimiter + "]" + idx += len(delimiter) + longCommentLength + + continue + } + + return len(text), false + } + + switch { + case text[idx] == '\'', text[idx] == '"': + state.quote = text[idx] + case text[idx] == '%' && idx+1 < len(text) && text[idx+1] == '{': + state.nestedRPM++ + idx++ + case text[idx] == '}': + switch { + case state.nestedRPM > 0: + state.nestedRPM-- + case state.braces > 0: + state.braces-- + default: + return idx + 1, true + } + case text[idx] == '{': + state.braces++ + } + + idx++ + } + + return len(text), false +} + +// luaLongDelimiter recognizes the '=' run in a Lua long-bracket opener. +func luaLongDelimiter(text string) (string, bool) { + if len(text) == 0 || text[0] != '[' { + return "", false + } + + idx := 1 + for idx < len(text) && text[idx] == '=' { + idx++ + } + + if idx >= len(text) || text[idx] != '[' { + return "", false + } + + return text[1:idx], true +} + +// getSectionNameAndPackageFromHeader extracts the section keyword and package name +// from a section header line. Uses the existing [GetPackageNameFromSectionHeader] +// for package name extraction. +func getSectionNameAndPackageFromHeader(rawLine string) (string, string) { + tokens := strings.Fields(strings.TrimSpace(rawLine)) + if len(tokens) == 0 { + return "", "" + } + + sectName := tokens[0] + + sectType, ok := sectionTypesByName[strings.ToLower(sectName)] + if !ok { + return sectName, "" + } + + pkg := getPackageNameForSection(sectType, tokens) + + return sectName, pkg +} + +// serializeTree flattens a [block] tree back into raw spec lines. +// The result preserves all original whitespace, comments, and blank lines. +func serializeTree(block *block) []string { + var lines []string + + switch block.Kind { + case rootBlock: + for _, child := range block.Children { + lines = append(lines, serializeTree(child)...) + } + + case sectionBlock: + if block.Header != "" { + lines = append(lines, block.Header) + } + + for _, child := range block.Children { + lines = append(lines, serializeTree(child)...) + } + + case conditionalBlock: + lines = append(lines, block.Header) + + for _, child := range block.Children { + lines = append(lines, serializeTree(child)...) + } + + if block.ElseDirective != "" { + lines = append(lines, block.ElseDirective) + } + + if block.Else != nil { + for _, child := range block.Else { + lines = append(lines, serializeTree(child)...) + } + } + + if block.Endif != "" { + lines = append(lines, block.Endif) + } + + case textBlock: + lines = append(lines, block.Lines...) + + case macroDefBlock: + lines = append(lines, block.Lines...) + } + + return lines +} diff --git a/internal/rpm/spec/tree_raw_braces_test.go b/internal/rpm/spec/tree_raw_braces_test.go new file mode 100644 index 000000000..25dd16d92 --- /dev/null +++ b/internal/rpm/spec/tree_raw_braces_test.go @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package spec //nolint:testpackage // Tests access unexported parser tree types. + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTreeKeepsRawBracesInsideExpandBody(t *testing.T) { + lines := []string{ + "%global date_corpus %{expand:", + "for date in 2024-02-29 2025-02-28; do", + ` if { test "${date#????-??-??}" = "$date"; }; then`, + " %if 0", + " printf '%s\\n' %{date}", + " %endif", + " fi", + "done", + "}", + "%build", + "echo %{date_corpus}", + } + + tree, err := parseTree(lines) + + require.NoError(t, err) + assert.Equal(t, lines, serializeTree(tree)) + require.Len(t, tree.Children, 2) + require.Len(t, tree.Children[0].Children, 1) + assert.Equal(t, lines[:9], tree.Children[0].Children[0].Lines) +} diff --git a/internal/rpm/spec/tree_test.go b/internal/rpm/spec/tree_test.go new file mode 100644 index 000000000..2c88d51d2 --- /dev/null +++ b/internal/rpm/spec/tree_test.go @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package spec //nolint:testpackage // Tests access unexported parser tree types. + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTreeRoundTrip(t *testing.T) { + tests := []struct { + name string + input string + }{ + {name: "empty", input: ""}, + {name: "whitespace", input: " \t \n\t"}, + {name: "comment-only conditional", input: "%if 1\n# then\n%else\n# else\n%endif"}, + {name: "empty else", input: "%if 1\n%else\n%endif"}, + {name: "terminal elif", input: "%if 1\n%elif 0\n%endif"}, + {name: "nested wrappers", input: strings.Join([]string{ + "%ifarch x86_64", "%package x", "%ifnos linux", "%description x", "ignored", + "%endif", "%else", "%package y", "%endif", + }, "\n")}, + {name: "elif with sections", input: strings.Join([]string{ + "%if 1", "%package first", "%elifarch x86_64", "%package second", "%else", + "%package third", "%endif", + }, "\n")}, + {name: "macro continuation with directives", input: "%if 1\n%define flags \\\n%else \\\n%if 0 \\\nbody\n%endif"}, + {name: "ordinary continuation followed by structure", input: strings.Join([]string{ + "%build", `configure \`, "%if 1", "make", "%endif", "%files", "/bin/example", + }, "\n")}, + {name: "parameterized macro", input: strings.Join([]string{ + `%define configure(name:) %{name} \`, " --enabled", "%build", "echo %{configure test}", + }, "\n")}, + {name: "lua raw braces strings and expansions", input: `%global helper %{lua: +local value = { nested = %{version}, literal = "}", escaped = "\}" } +print(value.nested) +} +%build +echo %{helper}`}, + {name: "macro expand body with shell parameter expansion", input: strings.Join([]string{ + "%define gobuild(o:) %{expand:", + " %if 0", + ` go build -tags="${BUILDTAGS:-}" %{?**}`, + " %else", + " go build %{?**}", + " %endif", + "}", + "Release: 7%{?dist}", + }, "\n")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lines := splitLines(tt.input) + tree, err := parseTree(lines) + require.NoError(t, err) + assert.Equal(t, lines, serializeTree(tree)) + }) + } +} + +func TestParseTreeRejectsMalformedInput(t *testing.T) { + tests := []struct { + name string + input string + }{ + {name: "unterminated conditional", input: "%if 1\n%build"}, + {name: "unterminated macro continuation", input: "%global flags \\\nbody \\"}, + {name: "unterminated lua macro", input: "%global helper %{lua:\nlocal value = {}\n%build"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseTree(splitLines(tt.input)) + require.Error(t, err) + }) + } +} + +func TestIsElifDirectiveIgnoresWhitespace(t *testing.T) { + assert.False(t, isElifDirective(" \t ")) + assert.True(t, isElifDirective("%elif 0")) +} + +func TestPercentRunOpensBracedMacro(t *testing.T) { + tests := []struct { + run string + opens bool + }{ + {run: "%%", opens: false}, + {run: "%%%", opens: true}, + {run: "%%%%", opens: false}, + {run: "%%%%%", opens: true}, + } + + for _, test := range tests { + t.Run(test.run, func(t *testing.T) { + assert.Equal(t, test.opens, percentRunOpensBracedMacro(test.run+"{macro}", 0)) + }) + } +} + +func TestParseTreeTreatsLivePercentRunMacroBodiesAsAtomic(t *testing.T) { + lines := []string{ + "%global helper %%%{", + "%endif", + "}", + "%build", + "echo %{helper}", + } + + tree, err := parseTree(lines) + + require.NoError(t, err) + assert.Equal(t, lines, serializeTree(tree)) +} + +func TestParseTreeTreatsTrailingPercentRunsAsLiteralMacroContent(t *testing.T) { + tests := []struct { + name string + lines []string + }{ + { + name: "define single trailing percent", + lines: []string{"%define helper %", "%build", "echo %{helper}"}, + }, + { + name: "global even trailing percent run", + lines: []string{"%global helper %%", "%build", "echo %{helper}"}, + }, + { + name: "define odd trailing percent run", + lines: []string{"%define helper %%%", "%build", "echo %{helper}"}, + }, + { + name: "global even multiple trailing percent run", + lines: []string{"%global helper %%%%", "%build", "echo %{helper}"}, + }, + { + name: "continued intermediate line", + lines: []string{ + "%define helper \\", + "value %\\", + "final", + "%build", + "echo %{helper}", + }, + }, + { + name: "continued final line", + lines: []string{ + "%global helper \\", + "value %%%%", + "%build", + "echo %{helper}", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tree, err := parseTree(test.lines) + require.NoError(t, err) + assert.Equal(t, test.lines, serializeTree(tree)) + }) + } +} + +func TestParseTreeKeepsEscapedBracedMacrosOpaqueInsideExpandBody(t *testing.T) { + lines := []string{ + "%global helper %{expand:", + "%%{literal}", + "%if 0", + "ignored", + "}", + "%build", + "echo %{helper}", + } + + tree, err := parseTree(lines) + require.NoError(t, err) + assert.Equal(t, lines, serializeTree(tree)) +} + +func splitLines(input string) []string { + return strings.Split(input, "\n") +} From 811e2f86926f3132463233525d44484942af4bf9 Mon Sep 17 00:00:00 2001 From: Thien Trung Vuong Date: Wed, 2 Sep 2026 20:38:30 +0000 Subject: [PATCH 2/2] refactor(spec): add structural tree API --- internal/rpm/spec/structural_spec.go | 9 + internal/rpm/spec/structural_tree_api.go | 300 +++++++++++++ .../spec/structural_tree_api_internal_test.go | 421 ++++++++++++++++++ 3 files changed, 730 insertions(+) create mode 100644 internal/rpm/spec/structural_spec.go create mode 100644 internal/rpm/spec/structural_tree_api.go create mode 100644 internal/rpm/spec/structural_tree_api_internal_test.go diff --git a/internal/rpm/spec/structural_spec.go b/internal/rpm/spec/structural_spec.go new file mode 100644 index 000000000..a7bccbf03 --- /dev/null +++ b/internal/rpm/spec/structural_spec.go @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package spec + +// structuralSpec encapsulates the raw contents used by structural operations. +type structuralSpec struct { + rawLines []string +} diff --git a/internal/rpm/spec/structural_tree_api.go b/internal/rpm/spec/structural_tree_api.go new file mode 100644 index 000000000..0dfed7434 --- /dev/null +++ b/internal/rpm/spec/structural_tree_api.go @@ -0,0 +1,300 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package spec + +import ( + "errors" + "fmt" + "strings" +) + +// specTree is an opaque handle for a parsed spec structure. +type specTree struct { + root *block +} + +// sectionHandle refers to one section in a [specTree]. +type sectionHandle struct { + block *block + tree *specTree +} + +// mutateTree parses the spec, applies mutate, and validates the resulting tree +// before replacing [structuralSpec.rawLines]. Errors leave the spec unchanged. +func (s *structuralSpec) mutateTree(mutate func(*specTree) error) error { + root, err := parseTree(s.rawLines) + if err != nil { + return fmt.Errorf("parsing spec tree:\n%w", err) + } + + tree := &specTree{root: root} + if err := mutate(tree); err != nil { + return err + } + + lines := serializeTree(root) + if _, err := parseTree(lines); err != nil { + return fmt.Errorf("validating mutated spec tree:\n%w", err) + } + + s.rawLines = lines + + return nil +} + +// inspectTree parses the spec and passes its structure to inspect without +// modifying [structuralSpec.rawLines]. +func (s *structuralSpec) inspectTree(inspect func(*specTree) error) error { + root, err := parseTree(s.rawLines) + if err != nil { + return fmt.Errorf("parsing spec tree:\n%w", err) + } + + return inspect(&specTree{root: root}) +} + +// Section returns the first section with name and pkg, or nil if it is absent. +func (t *specTree) Section(name, pkg string) *sectionHandle { + for _, section := range t.Sections(name, pkg) { + return section + } + + return nil +} + +// HasSection reports whether a section with name is present for any package. +func (t *specTree) HasSection(name string) bool { + found := false + + walkBlocks(t.root, func(blk *block) bool { + if blk.Kind == sectionBlock && blk.Name == name { + found = true + } + + return !found + }) + + return found +} + +// Sections returns all matching sections in document order. +func (t *specTree) Sections(name, pkg string) []*sectionHandle { + var matches []*sectionHandle + + walkBlocks(t.root, func(blk *block) bool { + if blk.Kind == sectionBlock && blk.Name == name && blk.Package == pkg { + matches = append(matches, §ionHandle{block: blk, tree: t}) + } + + return true + }) + + return matches +} + +// SectionsByPackage returns every section associated with pkg in document order. +func (t *specTree) SectionsByPackage(pkg string) []*sectionHandle { + var matches []*sectionHandle + + walkBlocks(t.root, func(blk *block) bool { + if blk.Kind == sectionBlock && blk.Package == pkg { + matches = append(matches, §ionHandle{block: blk, tree: t}) + } + + return true + }) + + return matches +} + +// RemoveSections removes sections as one transaction. +func (t *specTree) RemoveSections(handles []*sectionHandle) error { + sections := make(map[*block]bool, len(handles)) + for _, handle := range handles { + if handle == nil || handle.tree != t || handle.block == nil { + return errors.New("section handle does not belong to this spec tree") + } + + sections[handle.block] = true + } + + if err := validateSectionRemoval(t.root, sections); err != nil { + return err + } + + removeSections(t.root, sections) + + return nil +} + +// Name returns the section keyword. The preamble has an empty name. +func (h *sectionHandle) Name() string { + return h.block.Name +} + +// Package returns the section package qualifier. +func (h *sectionHandle) Package() string { + return h.block.Package +} + +// AppendLines appends lines to the section's content. +func (h *sectionHandle) AppendLines(lines []string) { + if len(lines) == 0 { + return + } + + h.block.Children = append(h.block.Children, &block{Kind: textBlock, Lines: lines}) +} + +// PrependLines inserts lines immediately after the section header. +func (h *sectionHandle) PrependLines(lines []string) { + if len(lines) == 0 { + return + } + + child := &block{Kind: textBlock, Lines: lines} + h.block.Children = append([]*block{child}, h.block.Children...) +} + +func walkBlocks(blk *block, visit func(*block) bool) { + if !visit(blk) { + return + } + + for _, child := range blk.Children { + walkBlocks(child, visit) + } + + if blk.Kind == conditionalBlock { + for _, child := range blk.Else { + walkBlocks(child, visit) + } + } +} + +func removeSections(blk *block, removeSet map[*block]bool) { + blk.Children = removeSectionBlocks(blk.Children, removeSet) + if blk.Kind == conditionalBlock { + blk.Else = removeSectionBlocks(blk.Else, removeSet) + } + + for _, child := range blk.Children { + removeSections(child, removeSet) + } + + if blk.Kind == conditionalBlock { + for _, child := range blk.Else { + removeSections(child, removeSet) + } + } +} + +func removeSectionBlocks(blocks []*block, removeSet map[*block]bool) []*block { + result := make([]*block, 0, len(blocks)) + for _, blk := range blocks { + if !removeSet[blk] { + result = append(result, blk) + } + } + + return result +} + +func validateSectionRemoval(root *block, removeSet map[*block]bool) error { + return validateRemovalChildren(root.Children, removeSet, nil) +} + +func validateRemovalChildren(children []*block, removeSet map[*block]bool, preceding *block) error { + for index, child := range children { + if child.Kind == sectionBlock { + preceding = child + + continue + } + + if child.Kind != conditionalBlock { + continue + } + + if conditionalHasTextOrMacroContent(child) && containsSectionBlocks(child) { + if preceding != nil && removeSet[preceding] { + return fmt.Errorf("%%if block at %#q contains content belonging to the preceding section:\n%w", + child.Header, ErrConditionalSpansSections) + } + } + + if wouldEmptySectionWrapper(child, removeSet) && index+1 < len(children) { + next := children[index+1] + if next.Kind == conditionalBlock && !containsSectionBlocks(next) && conditionalHasTextOrMacroContent(next) { + return fmt.Errorf("content in %%if block at %#q would be orphaned after removing the preceding section:\n%w", + next.Header, ErrConditionalSpansSections) + } + } + + if err := validateRemovalChildren(child.Children, removeSet, preceding); err != nil { + return err + } + + if err := validateRemovalChildren(child.Else, removeSet, preceding); err != nil { + return err + } + } + + return nil +} + +func conditionalHasTextOrMacroContent(conditional *block) bool { + return hasTextOrMacroContent(conditional.Children) || hasTextOrMacroContent(conditional.Else) +} + +func hasTextOrMacroContent(blocks []*block) bool { + for _, blk := range blocks { + if blk.Kind == macroDefBlock { + return true + } + + if blk.Kind == textBlock { + for _, line := range blk.Lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") { + return true + } + } + } + + if blk.Kind == conditionalBlock && + (hasTextOrMacroContent(blk.Children) || hasTextOrMacroContent(blk.Else)) { + return true + } + } + + return false +} + +func wouldEmptySectionWrapper(conditional *block, removeSet map[*block]bool) bool { + if !containsSectionBlocks(conditional) { + return false + } + + return !hasRemainingSection(conditional.Children, removeSet) && + !hasRemainingSection(conditional.Else, removeSet) +} + +func hasRemainingSection(blocks []*block, removeSet map[*block]bool) bool { + for _, blk := range blocks { + if blk.Kind == sectionBlock && !removeSet[blk] { + return true + } + + if hasRemainingSection(blk.Children, removeSet) { + return true + } + + if blk.Kind == conditionalBlock && hasRemainingSection(blk.Else, removeSet) { + return true + } + } + + return false +} diff --git a/internal/rpm/spec/structural_tree_api_internal_test.go b/internal/rpm/spec/structural_tree_api_internal_test.go new file mode 100644 index 000000000..44a435908 --- /dev/null +++ b/internal/rpm/spec/structural_tree_api_internal_test.go @@ -0,0 +1,421 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package spec + +import ( + "errors" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInspectTreeQueriesSectionsInDocumentOrder(t *testing.T) { + specification := newTreeAPISpec([]string{ + "Name: example", + "%package -n example-devel", + "%description -n example-devel", + "%ifarch x86_64", + "%files -n example-devel", + "%else", + "%files -n example-devel", + "%endif", + }) + + err := specification.inspectTree(func(tree *specTree) error { + section := tree.Section("%description", "example-devel") + require.NotNil(t, section) + assert.Equal(t, "%description", section.Name()) + assert.Equal(t, "example-devel", section.Package()) + assert.True(t, tree.HasSection("%files")) + assert.False(t, tree.HasSection("%check")) + assert.Len(t, tree.Sections("%files", "example-devel"), 2) + assert.Len(t, tree.SectionsByPackage("example-devel"), 4) + assert.Empty(t, tree.Sections("%build", "")) + + return nil + }) + + require.NoError(t, err) + assert.Equal(t, []string{ + "Name: example", + "%package -n example-devel", + "%description -n example-devel", + "%ifarch x86_64", + "%files -n example-devel", + "%else", + "%files -n example-devel", + "%endif", + }, specification.rawLines) +} + +func TestHasSectionRemainsFoundAfterLaterNonMatchingBlocks(t *testing.T) { + specification := newTreeAPISpec([]string{ + "%build", + "make", + "%check", + "make check", + }) + + err := specification.inspectTree(func(tree *specTree) error { + assert.True(t, tree.HasSection("%build")) + + return nil + }) + + require.NoError(t, err) +} + +func TestTreeWrappersAreTransactional(t *testing.T) { + t.Run("callback error", func(t *testing.T) { + specification := newTreeAPISpec([]string{"%build", "make"}) + before := append([]string(nil), specification.rawLines...) + + err := specification.mutateTree(func(tree *specTree) error { + tree.Section("%build", "").AppendLines([]string{"make install"}) + + return errors.New("stop") + }) + + require.EqualError(t, err, "stop") + assert.Equal(t, before, specification.rawLines) + }) + + t.Run("validation error", func(t *testing.T) { + specification := newTreeAPISpec([]string{"%if 1", "%build", "make", "%endif"}) + before := append([]string(nil), specification.rawLines...) + + err := specification.mutateTree(func(tree *specTree) error { + tree.root.Children[1].Endif = "" + + return nil + }) + + require.Error(t, err) + assert.Equal(t, before, specification.rawLines) + }) + + t.Run("malformed source", func(t *testing.T) { + specification := newTreeAPISpec([]string{"%if 1", "%build"}) + before := append([]string(nil), specification.rawLines...) + + err := specification.inspectTree(func(*specTree) error { + t.Fatal("inspect callback must not run for malformed input") + + return nil + }) + + require.Error(t, err) + assert.Equal(t, before, specification.rawLines) + }) +} + +func TestSectionLinePrimitivesPreserveOrder(t *testing.T) { + specification := newTreeAPISpec([]string{"%build", "make"}) + + err := specification.mutateTree(func(tree *specTree) error { + section := tree.Section("%build", "") + require.NotNil(t, section) + section.PrependLines([]string{"setup"}) + section.AppendLines([]string{"make install"}) + + return nil + }) + + require.NoError(t, err) + assert.Equal(t, []string{"%build", "setup", "make", "make install"}, specification.rawLines) +} + +func TestSectionLinePrimitivesHandleEmptySections(t *testing.T) { + specification := newTreeAPISpec([]string{"%build", "%check"}) + + err := specification.mutateTree(func(tree *specTree) error { + section := tree.Section("%build", "") + require.NotNil(t, section) + section.AppendLines([]string{"make"}) + + return nil + }) + + require.NoError(t, err) + assert.Equal(t, []string{"%build", "make", "%check"}, specification.rawLines) +} + +func TestRemoveSectionsPreservesConditionalBalance(t *testing.T) { + specification := newTreeAPISpec([]string{ + "%if 1", + "%package one", + "%description one", + "one", + "%else", + "%package two", + "%description two", + "two", + "%endif", + }) + + err := specification.mutateTree(func(tree *specTree) error { + return tree.RemoveSections(tree.SectionsByPackage("one")) + }) + + require.NoError(t, err) + assert.Equal(t, []string{ + "%if 1", + "%else", + "%package two", + "%description two", + "two", + "%endif", + }, specification.rawLines) + _, err = parseTree(specification.rawLines) + require.NoError(t, err) +} + +func TestRemoveSectionsRejectsOrphanedConditionalContent(t *testing.T) { + specification := newTreeAPISpec([]string{ + "%package one", + "%if 1", + "shared", + "%package two", + "%endif", + }) + + err := specification.inspectTree(func(tree *specTree) error { + before := serializeTree(tree.root) + err := tree.RemoveSections(tree.Sections("%package", "one")) + require.ErrorIs(t, err, ErrConditionalSpansSections) + assert.Equal(t, before, serializeTree(tree.root)) + + return nil + }) + + require.NoError(t, err) +} + +func TestRemoveSectionsRejectsOrphanedConditionalBranchContent(t *testing.T) { + tests := []struct { + name string + lines []string + }{ + { + name: "then", + lines: []string{ + "%package one", + "%if 1", + "orphan", + "%package two", + "%endif", + }, + }, + { + name: "else", + lines: []string{ + "%package one", + "%if 1", + "%package two", + "%else", + "orphan", + "%package three", + "%endif", + }, + }, + { + name: "elif", + lines: []string{ + "%package one", + "%if 1", + "%package two", + "%elif 0", + "orphan", + "%package three", + "%endif", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + specification := newTreeAPISpec(test.lines) + before := append([]string(nil), specification.rawLines...) + + err := specification.mutateTree(func(tree *specTree) error { + return tree.RemoveSections(tree.Sections("%package", "one")) + }) + + require.ErrorIs(t, err, ErrConditionalSpansSections) + assert.Equal(t, before, specification.rawLines) + }) + } +} + +func TestRemoveSectionsRejectsOrphanedAdjacentConditionalBranchContent(t *testing.T) { + tests := []struct { + name string + lines []string + }{ + { + name: "else", + lines: []string{ + "%if 1", + "%package one", + "%endif", + "%if 1", + "%else", + "orphan", + "%endif", + }, + }, + { + name: "elif", + lines: []string{ + "%if 1", + "%package one", + "%endif", + "%if 1", + "%elif 0", + "orphan", + "%endif", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + specification := newTreeAPISpec(test.lines) + before := append([]string(nil), specification.rawLines...) + + err := specification.mutateTree(func(tree *specTree) error { + return tree.RemoveSections(tree.Sections("%package", "one")) + }) + + require.ErrorIs(t, err, ErrConditionalSpansSections) + assert.Equal(t, before, specification.rawLines) + }) + } +} + +func TestRemoveSectionsAllowsIndependentConditionalBranchRemoval(t *testing.T) { + specification := newTreeAPISpec([]string{ + "%if 1", + "%package one", + "%else", + "%package two", + "%endif", + }) + + err := specification.mutateTree(func(tree *specTree) error { + return tree.RemoveSections(tree.Sections("%package", "one")) + }) + + require.NoError(t, err) + assert.Equal(t, []string{ + "%if 1", + "%else", + "%package two", + "%endif", + }, specification.rawLines) +} + +func TestRemoveSectionsAllowsEmptyOrCommentOnlyAdjacentElse(t *testing.T) { + tests := []struct { + name string + lines []string + }{ + { + name: "empty", + lines: []string{ + "%if 1", + "%package one", + "%endif", + "%if 1", + "%else", + "%endif", + }, + }, + { + name: "comment only", + lines: []string{ + "%if 1", + "%package one", + "%endif", + "%if 1", + "%else", + "# not content", + "%endif", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + specification := newTreeAPISpec(test.lines) + + err := specification.mutateTree(func(tree *specTree) error { + return tree.RemoveSections(tree.Sections("%package", "one")) + }) + + require.NoError(t, err) + }) + } +} + +func TestHasTextOrMacroContentRecursesThroughConditionalBranches(t *testing.T) { + directivesOnly := []*block{{ + Kind: conditionalBlock, + Header: "%if 1", + Else: []*block{{ + Kind: conditionalBlock, + Header: "%else", + }}, + }} + assert.False(t, hasTextOrMacroContent(directivesOnly)) + + nestedContent := []*block{{ + Kind: conditionalBlock, + Header: "%if 1", + Else: []*block{{ + Kind: conditionalBlock, + Header: "%else", + Children: []*block{{ + Kind: macroDefBlock, + Lines: []string{"%global helper value"}, + }}, + }}, + }} + assert.True(t, hasTextOrMacroContent(nestedContent)) + + assert.False(t, hasTextOrMacroContent([]*block{{ + Kind: textBlock, + Lines: []string{"", " ", "# comment"}, + }})) +} + +func TestTreeEditKeepsEscapedBracedMacrosOpaqueInsideExpandBody(t *testing.T) { + lines := []string{ + "%global helper %{expand:", + "%%{literal}", + "%if 0", + "ignored", + "}", + "%build", + "echo %{helper}", + } + specification := newTreeAPISpec(lines) + + err := specification.mutateTree(func(tree *specTree) error { + section := tree.Section("%build", "") + require.NotNil(t, section) + section.AppendLines([]string{"make"}) + + return nil + }) + + require.NoError(t, err) + assert.Equal(t, append(lines, "make"), specification.rawLines) +} + +func newTreeAPISpec(lines []string) *structuralSpec { + return &structuralSpec{rawLines: slices.Clone(lines)} +}