From e9dce20a4d341daf9fb79c9f4e808c6c88446661 Mon Sep 17 00:00:00 2001 From: reuben olinsky Date: Wed, 29 Apr 2026 20:27:54 +0000 Subject: [PATCH] feat(overlays): add spec-set-macros overlay Adds a new declarative overlay type for setting %global / %define macro values in RPM spec files, replacing ~25 fragile spec-search-replace regex toggles across azurelinux (gcc alone has 8 macro toggles). The overlay accepts a map of macro names to settings and updates each existing macro definition in place. The directive form (%global vs %define) is auto-detected from the spec; an optional `kind` field forces a specific form. The overlay fails if any named macro is not present in the spec, catching typos and upstream removals. Implementation notes / divergences from the design doc: - No inline-comment preservation. RPM does not treat `#` mid-line on %global/%define lines as a comment, so "preserving" trailing `#` text would silently truncate legitimate macro values. - Uses a new ErrNoSuchMacro in the spec package rather than wrapping ErrOverlayDidNotApply, matching the existing ErrNoSuchTag / ErrPatternNotFound convention in internal/rpm/spec/edit.go. - Stricter post-name match guard `(\s|$)` instead of `\b` so a request for `build_ada` does not match `%define build_ada()` (function-like macro) or `%global build_adapter` (shared prefix). - Multi-line definitions (lines ending in `\` continuation) are detected and rejected with ErrUnsupportedMacroDefinition rather than silently corrupting the spec by orphaning continuation lines. - Macro-name validation (no whitespace, `%`, or parentheses) and multi-line value rejection happen at config load time. - Multiple occurrences of the same macro are all updated; per-macro iteration order is sorted for deterministic rendering. - No section / sub-package scoping in v1 since macros are typically at top level in real specs; can be added later if a need arises. Tests: 14 SetMacro unit tests (incl. shared-prefix, function-like, multi-line, kind override, multiple occurrences), 10 new validation cases, 5 new integration cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/user/reference/config/overlays.md | 23 ++ internal/app/azldev/core/sources/overlays.go | 19 +- .../app/azldev/core/sources/overlays_test.go | 100 ++++++++ internal/projectconfig/overlay.go | 109 ++++++++- internal/projectconfig/overlay_test.go | 109 +++++++++ internal/rpm/spec/edit.go | 102 +++++++++ internal/rpm/spec/edit_test.go | 215 ++++++++++++++++++ ...ainer_config_generate-schema_stdout_1.snap | 32 +++ ...shots_config_generate-schema_stdout_1.snap | 32 +++ schemas/azldev.schema.json | 32 +++ 10 files changed, 771 insertions(+), 2 deletions(-) diff --git a/docs/user/reference/config/overlays.md b/docs/user/reference/config/overlays.md index da5ca9eb..e66ff370 100644 --- a/docs/user/reference/config/overlays.md +++ b/docs/user/reference/config/overlays.md @@ -19,6 +19,7 @@ These overlays modify `.spec` files using the structured spec parser, allowing p | `spec-set-tag` | Sets a tag value; replaces if exists, adds if not | `tag`, `value` | | `spec-update-tag` | Updates an existing tag; **fails if the tag doesn't exist** | `tag`, `value` | | `spec-remove-tag` | Removes a tag from the spec; **fails if the tag doesn't exist** | `tag` | +| `spec-set-macros` | Sets the value of one or more `%global` / `%define` macros; auto-detects which directive form is in the spec, or use `kind` to force one. **Fails if a target macro is not present.** | `macros` | | `spec-prepend-lines` | Prepends lines to the start of a section; **fails if section doesn't exist** | `lines` | | `spec-append-lines` | Appends lines to the end of a section; **fails if section doesn't exist** | `lines` | | `spec-search-replace` | Regex-based search and replace on spec content | `regex` | @@ -59,6 +60,7 @@ successfully makes a replacement to at least one matching file. | Regex | `regex` | Regular expression pattern to match | `spec-search-replace`, `file-search-replace` | | Replacement | `replacement` | Literal replacement text; capture group references like `$1` are **not** expanded. Omit or leave empty to delete matched text. | `spec-search-replace`, `file-search-replace`, `file-rename` | | Lines | `lines` | Array of text lines to insert | `spec-prepend-lines`, `spec-append-lines`, `file-prepend-lines` | +| Macros | `macros` | Map of macro name to settings (`{value = "...", kind = "global" \| "define"}`); `kind` is optional and forces the directive form | `spec-set-macros` | | File | `file` | The name of the non-spec file to modify or add | `file-prepend-lines`, `file-search-replace`, `file-add`, `file-remove`, `file-rename`, `patch-add` (optional), `patch-remove` | | Source | `source` | Path to source file for `file-add` and `patch-add`; relative paths are relative to the config file | `file-add`, `patch-add` | @@ -149,6 +151,27 @@ regex = "--enable-deprecated-feature\\s*" replacement = "" ``` +### Setting Spec Macros + +Use `spec-set-macros` to declaratively change the value of one or more `%global` / `%define` macros. The directive form (`%global` vs `%define`) is auto-detected from the existing spec, so you don't need to know which one upstream uses. The overlay **fails** if any named macro is not present in the spec — this catches typos and upstream removals. + +```toml +[[components.gcc.overlays]] +description = "Disable language frontends not supported on AZL toolchain" +type = "spec-set-macros" +macros = { build_ada = { value = "0" }, build_objc = { value = "0" }, build_go = { value = "0" } } +``` + +To force a specific directive form, use `kind`: + +```toml +[[components.mypackage.overlays]] +type = "spec-set-macros" +macros = { with_doc = { value = "1", kind = "global" } } +``` + +> **Note:** Macro names must be plain identifiers (no whitespace, `%`, or parentheses). Function-like macros (`%define name() ...`) are not supported. Multi-line macro definitions (using `\` line continuation) are also not supported. + ### Targeting a Sub-Package For multi-package specs, use the `package` field to target a specific sub-package: diff --git a/internal/app/azldev/core/sources/overlays.go b/internal/app/azldev/core/sources/overlays.go index f7a4584d..d0e8b6e4 100644 --- a/internal/app/azldev/core/sources/overlays.go +++ b/internal/app/azldev/core/sources/overlays.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "github.com/bmatcuk/doublestar/v4" @@ -116,7 +117,7 @@ func ApplySpecOverlayToFileInPlace(fs opctx.FS, overlay projectconfig.ComponentO // ApplySpecOverlay applies a spec-based overlay to an opened spec. An error is returned if a non-spec // overlay is provided. // -//nolint:cyclop,funlen // This function's complexity is inflated by the big switch over overlay types. +//nolint:cyclop,funlen,gocognit // This function's complexity is inherent to the big switch over overlay types. func ApplySpecOverlay(overlay projectconfig.ComponentOverlay, openedSpec *spec.Spec) error { //nolint:exhaustive // We intentionally ignore non-spec overlay types. switch overlay.Type { @@ -145,6 +146,22 @@ func ApplySpecOverlay(overlay projectconfig.ComponentOverlay, openedSpec *spec.S if err != nil { return fmt.Errorf("failed to remove tag %#q from spec:\n%w", overlay.Tag, err) } + case projectconfig.ComponentOverlaySetSpecMacros: + // Apply macros in stable (alphabetical) order so the resulting spec + // rendering is deterministic regardless of map iteration order. + names := make([]string, 0, len(overlay.Macros)) + for name := range overlay.Macros { + names = append(names, name) + } + + sort.Strings(names) + + for _, name := range names { + macroSpec := overlay.Macros[name] + if err := openedSpec.SetMacro(name, macroSpec.Value, macroSpec.Kind); err != nil { + return fmt.Errorf("failed to set macro %#q in spec:\n%w", name, err) + } + } case projectconfig.ComponentOverlayPrependSpecLines: err := openedSpec.PrependLinesToSection(overlay.SectionName, overlay.PackageName, overlay.Lines) if err != nil { diff --git a/internal/app/azldev/core/sources/overlays_test.go b/internal/app/azldev/core/sources/overlays_test.go index 2a8f63ff..5ae57509 100644 --- a/internal/app/azldev/core/sources/overlays_test.go +++ b/internal/app/azldev/core/sources/overlays_test.go @@ -1287,3 +1287,103 @@ make assert.Contains(t, err.Error(), "not found") }) } + +func TestApplySpecOverlay_SetMacros(t *testing.T) { + t.Run("sets a single macro", func(t *testing.T) { + specContent := `Name: test +%global build_ada 1 +` + overlay := projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + Macros: map[string]projectconfig.MacroSetSpec{ + "build_ada": {Value: "0"}, + }, + } + + result, err := applyOverlayToSpecContents(t, overlay, specContent) + require.NoError(t, err) + assert.Equal(t, `Name: test +%global build_ada 0 +`, result) + }) + + t.Run("sets multiple macros at once", func(t *testing.T) { + specContent := `Name: gcc +%global build_ada 1 +%global build_objc 1 +%global build_go 1 +` + overlay := projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + Macros: map[string]projectconfig.MacroSetSpec{ + "build_ada": {Value: "0"}, + "build_objc": {Value: "0"}, + "build_go": {Value: "0"}, + }, + } + + result, err := applyOverlayToSpecContents(t, overlay, specContent) + require.NoError(t, err) + assert.Equal(t, `Name: gcc +%global build_ada 0 +%global build_objc 0 +%global build_go 0 +`, result) + }) + + t.Run("kind override rewrites directive", func(t *testing.T) { + specContent := `Name: test +%define with_doc 0 +` + overlay := projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + Macros: map[string]projectconfig.MacroSetSpec{ + "with_doc": {Value: "1", Kind: projectconfig.MacroKindGlobal}, + }, + } + + result, err := applyOverlayToSpecContents(t, overlay, specContent) + require.NoError(t, err) + assert.Equal(t, `Name: test +%global with_doc 1 +`, result) + }) + + t.Run("fails when macro is missing", func(t *testing.T) { + specContent := `Name: test +%global other 1 +` + overlay := projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + Macros: map[string]projectconfig.MacroSetSpec{ + "build_ada": {Value: "0"}, + }, + } + + _, err := applyOverlayToSpecContents(t, overlay, specContent) + require.Error(t, err) + require.ErrorIs(t, err, spec.ErrNoSuchMacro) + assert.Contains(t, err.Error(), "build_ada") + }) + + t.Run("missing-macro stops at first error in deterministic order", func(t *testing.T) { + // Macros are applied in alphabetical order. Even though `build_objc` + // is present, the missing `build_ada` is processed first and triggers + // the failure. + specContent := `Name: test +%global build_objc 1 +` + overlay := projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + Macros: map[string]projectconfig.MacroSetSpec{ + "build_ada": {Value: "0"}, + "build_objc": {Value: "0"}, + }, + } + + _, err := applyOverlayToSpecContents(t, overlay, specContent) + require.Error(t, err) + require.ErrorIs(t, err, spec.ErrNoSuchMacro) + assert.Contains(t, err.Error(), "build_ada") + }) +} diff --git a/internal/projectconfig/overlay.go b/internal/projectconfig/overlay.go index e4f53f5f..3e8e31a8 100644 --- a/internal/projectconfig/overlay.go +++ b/internal/projectconfig/overlay.go @@ -7,6 +7,7 @@ import ( "fmt" "path/filepath" "regexp" + "strings" "github.com/bmatcuk/doublestar/v4" "github.com/brunoga/deep" @@ -17,7 +18,7 @@ import ( // ComponentOverlay represents an overlay that may be applied to a component's spec and/or its sources. type ComponentOverlay struct { // The type of overlay to apply. - Type ComponentOverlayType `toml:"type" json:"type" validate:"required" jsonschema:"enum=spec-add-tag,enum=spec-insert-tag,enum=spec-set-tag,enum=spec-update-tag,enum=spec-remove-tag,enum=spec-prepend-lines,enum=spec-append-lines,enum=spec-search-replace,enum=spec-remove-section,enum=patch-add,enum=patch-remove,enum=file-prepend-lines,enum=file-search-replace,enum=file-add,enum=file-remove,enum=file-rename,title=Overlay type,description=The type of overlay to apply"` + Type ComponentOverlayType `toml:"type" json:"type" validate:"required" jsonschema:"enum=spec-add-tag,enum=spec-insert-tag,enum=spec-set-tag,enum=spec-update-tag,enum=spec-remove-tag,enum=spec-set-macros,enum=spec-prepend-lines,enum=spec-append-lines,enum=spec-search-replace,enum=spec-remove-section,enum=patch-add,enum=patch-remove,enum=file-prepend-lines,enum=file-search-replace,enum=file-add,enum=file-remove,enum=file-rename,title=Overlay type,description=The type of overlay to apply"` // Human readable description of overlay; primarily present to document the need for the change. Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Human readable description of overlay" fingerprint:"-"` @@ -38,6 +39,11 @@ type ComponentOverlay struct { Replacement string `toml:"replacement,omitempty" json:"replacement,omitempty" jsonschema:"title=Replacement text,description=The replacement text to use in the spec"` // For overlays that reference lines of text, the lines of text to use. Lines []string `toml:"lines,omitempty" json:"lines,omitempty" jsonschema:"title=Lines,description=The lines of text to use"` + // For overlays that set one or more spec macros (spec-set-macros). Keys are + // macro names; values describe how each macro should be set. Object form + // (no scalar shorthand) keeps room for future per-entry flags without + // breaking existing configs. + Macros map[string]MacroSetSpec `toml:"macros,omitempty" json:"macros,omitempty" jsonschema:"title=Macros,description=Map of macro names to settings; used by spec-set-macros"` // For overlays that require a source file as input, indicates a path to that file; relative paths are relative to // the config file that defines the overlay. // Excluded from fingerprint because it contains an absolute path that varies by checkout @@ -110,6 +116,7 @@ func (c *ComponentOverlay) ModifiesSpec() bool { c.Type == ComponentOverlaySetSpecTag || c.Type == ComponentOverlayUpdateSpecTag || c.Type == ComponentOverlayRemoveSpecTag || + c.Type == ComponentOverlaySetSpecMacros || c.Type == ComponentOverlayPrependSpecLines || c.Type == ComponentOverlayAppendSpecLines || c.Type == ComponentOverlaySearchAndReplaceInSpec || @@ -134,6 +141,27 @@ func (c *ComponentOverlay) ModifiesNonSpecFiles() bool { // ComponentOverlayType is the type of a component overlay. type ComponentOverlayType string +// MacroKindGlobal forces a macro to be expressed as `%global` when set on a +// [MacroSetSpec.Kind] field. +const MacroKindGlobal = "global" + +// MacroKindDefine forces a macro to be expressed as `%define` when set on a +// [MacroSetSpec.Kind] field. +const MacroKindDefine = "define" + +// MacroSetSpec describes how to set a single spec macro for the +// `spec-set-macros` overlay. Object form is mandatory (no scalar shorthand) +// so additional fields can be added without breaking existing configs. +type MacroSetSpec struct { + // Value is the new value to assign to the macro. Required, non-empty, + // no newlines. + Value string `toml:"value" json:"value" validate:"required" jsonschema:"title=Value,description=The new value to assign to the macro"` + + // Kind optionally forces the macro to be expressed as `%global` or + // `%define`. If empty, the existing form in the spec is preserved. + Kind string `toml:"kind,omitempty" json:"kind,omitempty" jsonschema:"enum=global,enum=define,title=Kind,description=Force '%global' or '%define' form (defaults to whichever is already in the spec)"` +} + const ( // ComponentOverlayAddSpecTag is an overlay that adds a tag to the spec; fails if the tag already exists. ComponentOverlayAddSpecTag ComponentOverlayType = "spec-add-tag" @@ -148,6 +176,12 @@ const ( ComponentOverlayUpdateSpecTag ComponentOverlayType = "spec-update-tag" // ComponentOverlayRemoveSpecTag is an overlay that removes a tag from the spec; fails if the tag doesn't exist. ComponentOverlayRemoveSpecTag ComponentOverlayType = "spec-remove-tag" + // ComponentOverlaySetSpecMacros is an overlay that sets the value of one or more + // `%global` / `%define` macros in the spec. Auto-detects which form (global vs + // define) is currently used in the spec for each macro; an explicit `kind` may + // be provided to force a specific form. Fails if a target macro is not present + // in the spec. + ComponentOverlaySetSpecMacros ComponentOverlayType = "spec-set-macros" // ComponentOverlayPrependSpecLines is an overlay that prepends lines to a section in a spec; fails if the section // doesn't exist. ComponentOverlayPrependSpecLines ComponentOverlayType = "spec-prepend-lines" @@ -236,6 +270,20 @@ func (c *ComponentOverlay) Validate() error { if c.Tag == "" { return missingField("tag") } + case ComponentOverlaySetSpecMacros: + if len(c.Macros) == 0 { + return missingField("macros") + } + + for name, macroSpec := range c.Macros { + if err := validateMacroName(name, c.Type, desc); err != nil { + return err + } + + if err := validateMacroSetSpec(name, macroSpec, c.Type, desc); err != nil { + return err + } + } case ComponentOverlayPrependSpecLines, ComponentOverlayAppendSpecLines: if len(c.Lines) == 0 { return missingField("lines") @@ -328,6 +376,65 @@ func validateRegex(pattern, overlayDesc string) error { return nil } +// validateMacroName ensures the provided macro name is well-formed for the +// spec-set-macros overlay. Macro names must be non-empty, must not contain +// whitespace or `%`, and must not be function-like (parentheses). +func validateMacroName(name string, overlayType ComponentOverlayType, overlayDesc string) error { + if name == "" { + return fmt.Errorf("overlay type %#q has empty macro name: %s", overlayType, overlayDesc) + } + + if strings.ContainsAny(name, " \t\r\n") { + return fmt.Errorf( + "overlay type %#q has invalid macro name %#q (must not contain whitespace): %s", + overlayType, name, overlayDesc, + ) + } + + if strings.ContainsAny(name, "%()") { + return fmt.Errorf( + "overlay type %#q has invalid macro name %#q (must not contain '%%' or parentheses; "+ + "function-like macros are not supported): %s", + overlayType, name, overlayDesc, + ) + } + + return nil +} + +// validateMacroSetSpec checks per-entry constraints for a spec-set-macros entry. +func validateMacroSetSpec( + name string, macroSpec MacroSetSpec, + overlayType ComponentOverlayType, overlayDesc string, +) error { + if macroSpec.Value == "" { + return fmt.Errorf( + "overlay type %#q requires %#q for macro %#q: %s", + overlayType, "value", name, overlayDesc, + ) + } + + if strings.ContainsAny(macroSpec.Value, "\r\n") { + return fmt.Errorf( + "overlay type %#q value for macro %#q must not contain newlines "+ + "(multi-line macro values are not supported): %s", + overlayType, name, overlayDesc, + ) + } + + switch macroSpec.Kind { + case "", MacroKindGlobal, MacroKindDefine: + // OK. + default: + return fmt.Errorf( + "overlay type %#q has invalid %#q value %#q for macro %#q (expected %#q or %#q): %s", + overlayType, "kind", macroSpec.Kind, name, MacroKindGlobal, MacroKindDefine, overlayDesc, + ) + } + + return nil +} + // validateGlobPattern checks if the provided glob pattern is valid. func validateGlobPattern(pattern, overlayDesc string) error { if !doublestar.ValidatePattern(pattern) { diff --git a/internal/projectconfig/overlay_test.go b/internal/projectconfig/overlay_test.go index 4dd9f5a7..082dbb73 100644 --- a/internal/projectconfig/overlay_test.go +++ b/internal/projectconfig/overlay_test.go @@ -385,6 +385,114 @@ func TestComponentOverlay_Validate(t *testing.T) { errorExpected: true, errorContains: "section", }, + // spec-set-macros tests + { + name: "spec-set-macros valid single", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + Macros: map[string]projectconfig.MacroSetSpec{ + "build_ada": {Value: "0"}, + }, + }, + errorExpected: false, + }, + { + name: "spec-set-macros valid multiple", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + Macros: map[string]projectconfig.MacroSetSpec{ + "build_ada": {Value: "0"}, + "build_go": {Value: "0", Kind: projectconfig.MacroKindGlobal}, + "with_doc": {Value: "1", Kind: projectconfig.MacroKindDefine}, + }, + }, + errorExpected: false, + }, + { + name: "spec-set-macros missing macros", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + }, + errorExpected: true, + errorContains: "macros", + }, + { + name: "spec-set-macros empty value", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + Macros: map[string]projectconfig.MacroSetSpec{ + "build_ada": {Value: ""}, + }, + }, + errorExpected: true, + errorContains: "value", + }, + { + name: "spec-set-macros multi-line value", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + Macros: map[string]projectconfig.MacroSetSpec{ + "build_ada": {Value: "line1\nline2"}, + }, + }, + errorExpected: true, + errorContains: "newline", + }, + { + name: "spec-set-macros invalid kind", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + Macros: map[string]projectconfig.MacroSetSpec{ + "build_ada": {Value: "0", Kind: "bogus"}, + }, + }, + errorExpected: true, + errorContains: "kind", + }, + { + name: "spec-set-macros empty macro name", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + Macros: map[string]projectconfig.MacroSetSpec{ + "": {Value: "0"}, + }, + }, + errorExpected: true, + errorContains: "empty macro name", + }, + { + name: "spec-set-macros macro name with whitespace", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + Macros: map[string]projectconfig.MacroSetSpec{ + "build ada": {Value: "0"}, + }, + }, + errorExpected: true, + errorContains: "whitespace", + }, + { + name: "spec-set-macros function-like macro name rejected", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + Macros: map[string]projectconfig.MacroSetSpec{ + "build_ada()": {Value: "0"}, + }, + }, + errorExpected: true, + errorContains: "parentheses", + }, + { + name: "spec-set-macros macro name with percent", + overlay: projectconfig.ComponentOverlay{ + Type: projectconfig.ComponentOverlaySetSpecMacros, + Macros: map[string]projectconfig.MacroSetSpec{ + "%build_ada": {Value: "0"}, + }, + }, + errorExpected: true, + errorContains: "%", + }, } for _, testCase := range testCases { @@ -413,6 +521,7 @@ func TestComponentOverlay_ModifiesSpec(t *testing.T) { projectconfig.ComponentOverlaySetSpecTag, projectconfig.ComponentOverlayUpdateSpecTag, projectconfig.ComponentOverlayRemoveSpecTag, + projectconfig.ComponentOverlaySetSpecMacros, projectconfig.ComponentOverlayPrependSpecLines, projectconfig.ComponentOverlayAppendSpecLines, projectconfig.ComponentOverlaySearchAndReplaceInSpec, diff --git a/internal/rpm/spec/edit.go b/internal/rpm/spec/edit.go index f6cf8a59..859da2d9 100644 --- a/internal/rpm/spec/edit.go +++ b/internal/rpm/spec/edit.go @@ -24,6 +24,15 @@ var ErrSectionNotFound = errors.New("section not found") // ErrPatternNotFound is returned when a search pattern does not match any content in the spec. var ErrPatternNotFound = errors.New("pattern not found") +// ErrNoSuchMacro is returned when a requested `%global` / `%define` macro does +// not exist in the spec. +var ErrNoSuchMacro = errors.New("no such macro") + +// ErrUnsupportedMacroDefinition is returned when a macro is found in the spec +// in a form the editor cannot safely rewrite (e.g., a multi-line definition +// using `\` continuation). +var ErrUnsupportedMacroDefinition = errors.New("unsupported macro definition") + // SetTag sets the value of the given tag in the spec, under the specified package. It first // attempts to update the first instance of the tag found in the spec; if no such tag exists, // a new tag is added under the given package. @@ -516,6 +525,99 @@ func (s *Spec) SearchAndReplace(sectionName, packageName, regex, replacement str return err } +// SetMacro updates every existing `%global NAME ...` or `%define NAME ...` +// definition of the named macro to use the supplied value. If kind is empty, +// each occurrence retains its existing directive (`%global` or `%define`); if +// kind is "global" or "define", every occurrence is rewritten to that form. +// Any other value of kind returns an error rather than silently emitting an +// invalid directive. +// +// Indentation of the original line is preserved. Inter-token whitespace is +// normalized to single spaces. Returns ErrNoSuchMacro wrapped in an +// informative error if the macro is not found, or +// ErrUnsupportedMacroDefinition if a matched line uses `\` line continuation. +func (s *Spec) SetMacro(name, value, kind string) error { + slog.Debug("Setting spec macro", "name", name, "value", value, "kind", kind) + + switch kind { + case "", "global", "define": + // OK. + default: + return fmt.Errorf( + "invalid kind %#q for macro %#q (expected %#q, %#q, or empty)", + kind, name, "global", "define", + ) + } + + // Build a regex that matches a `%global` / `%define` line for the named + // macro. Use `(\s|$)` rather than `\b` so we don't match function-like + // macros (`%define name() ...`) or accidentally match a longer name with a + // shared prefix. + pattern, err := regexp.Compile( + `^(\s*)%(global|define)\s+` + regexp.QuoteMeta(name) + `(\s.*|)$`, + ) + if err != nil { + return fmt.Errorf("compiling macro-match regex for %#q:\n%w", name, err) + } + + var matches int + + visitErr := s.Visit(func(ctx *Context) error { + if ctx.Target.TargetType != SectionLineTarget { + return nil + } + + line := ctx.Target.Line.Text + + groups := pattern.FindStringSubmatch(line) + if groups == nil { + return nil + } + + // Refuse to rewrite a line that ends with a `\` continuation; the + // definition spans multiple physical lines and a single-line + // replacement would orphan the continuation body. + if endsWithLineContinuation(line) { + return fmt.Errorf( + "macro %#q has a multi-line definition (line ends with %#q); "+ + "this operation cannot safely rewrite it:\n%w", + name, `\`, ErrUnsupportedMacroDefinition, + ) + } + + indent := groups[1] + existingDirective := groups[2] + + directive := existingDirective + if kind != "" { + directive = kind + } + + ctx.ReplaceLine(fmt.Sprintf("%s%%%s %s %s", indent, directive, name, value)) + + matches++ + + return nil + }) + if visitErr != nil { + return visitErr + } + + if matches == 0 { + return fmt.Errorf("macro %#q not found in spec:\n%w", name, ErrNoSuchMacro) + } + + return nil +} + +// endsWithLineContinuation returns true if a single physical spec line ends +// with a `\` continuation (ignoring trailing whitespace). +func endsWithLineContinuation(line string) bool { + trimmed := strings.TrimRight(line, " \t\r\n") + + return strings.HasSuffix(trimmed, `\`) +} + // AddChangelogEntry adds a changelog entry to the spec's changelog section. An error is returned if // no %changelog section exists in the spec. func (s *Spec) AddChangelogEntry(user, email, version, release string, time time.Time, details []string) (err error) { diff --git a/internal/rpm/spec/edit_test.go b/internal/rpm/spec/edit_test.go index a9521c4f..2ec0b762 100644 --- a/internal/rpm/spec/edit_test.go +++ b/internal/rpm/spec/edit_test.go @@ -873,6 +873,221 @@ func TestSearchAndReplace(t *testing.T) { }) } +func TestSetMacro(t *testing.T) { + tests := []struct { + name string + input string + macroName string + value string + kind string + expectError bool + errorIs error + expectedOutput string + }{ + { + name: "flip global to 0", + input: `Name: test +%global build_ada 1 +`, + macroName: "build_ada", + value: "0", + expectedOutput: `Name: test +%global build_ada 0 +`, + }, + { + name: "flip define preserving form", + input: `Name: test +%define build_ada 1 +`, + macroName: "build_ada", + value: "0", + expectedOutput: `Name: test +%define build_ada 0 +`, + }, + { + name: "kind override define->global", + input: `Name: test +%define build_ada 1 +`, + macroName: "build_ada", + value: "0", + kind: "global", + expectedOutput: `Name: test +%global build_ada 0 +`, + }, + { + name: "kind override global->define", + input: `Name: test +%global build_ada 1 +`, + macroName: "build_ada", + value: "0", + kind: "define", + expectedOutput: `Name: test +%define build_ada 0 +`, + }, + { + name: "preserve indentation", + input: `Name: test +%if 1 + %global build_ada 1 +%endif +`, + macroName: "build_ada", + value: "0", + expectedOutput: `Name: test +%if 1 + %global build_ada 0 +%endif +`, + }, + { + name: "tolerate excess whitespace", + input: `Name: test +%global build_ada 1 +`, + macroName: "build_ada", + value: "0", + // Re-emitted with single-space normalization. + expectedOutput: `Name: test +%global build_ada 0 +`, + }, + { + name: "value with spaces", + input: `Name: test +%global tarfile_release 1.2.3 +`, + macroName: "tarfile_release", + value: `"1.2.3-rc1"`, + expectedOutput: `Name: test +%global tarfile_release "1.2.3-rc1" +`, + }, + { + name: "multiple occurrences updated", + input: `Name: test +%global build_ada 1 +%define build_ada 1 +`, + macroName: "build_ada", + value: "0", + // First retains %global, second retains %define. + expectedOutput: `Name: test +%global build_ada 0 +%define build_ada 0 +`, + }, + { + name: "multiple occurrences with kind override", + input: `Name: test +%global build_ada 1 +%define build_ada 1 +`, + macroName: "build_ada", + value: "0", + kind: "global", + expectedOutput: `Name: test +%global build_ada 0 +%global build_ada 0 +`, + }, + { + name: "macro not present errors", + input: `Name: test +%global other 1 +`, + macroName: "build_ada", + value: "0", + expectError: true, + errorIs: spec.ErrNoSuchMacro, + }, + { + name: "function-like macro is not matched", + input: `Name: test +%define build_ada() echo ada +`, + macroName: "build_ada", + value: "0", + expectError: true, + errorIs: spec.ErrNoSuchMacro, + }, + { + name: "shared-prefix macro is not matched", + input: `Name: test +%global build_adapter 1 +`, + macroName: "build_ada", + value: "0", + expectError: true, + errorIs: spec.ErrNoSuchMacro, + }, + { + name: "multi-line definition rejected", + input: `Name: test +%global big_macro foo \ + bar +`, + macroName: "big_macro", + value: "qux", + expectError: true, + errorIs: spec.ErrUnsupportedMacroDefinition, + }, + { + name: "definition with no value gets one", + // `%global name` (no value) — pattern's optional value group + // allows this; we should still rewrite to `%global name `. + input: `Name: test +%global build_ada +`, + macroName: "build_ada", + value: "0", + expectedOutput: `Name: test +%global build_ada 0 +`, + }, + { + name: "invalid kind rejected", + input: `Name: test +%global build_ada 1 +`, + macroName: "build_ada", + value: "0", + kind: "bogus", + expectError: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + specFile, err := spec.OpenSpec(strings.NewReader(test.input)) + require.NoError(t, err) + + err = specFile.SetMacro(test.macroName, test.value, test.kind) + + if test.expectError { + require.Error(t, err) + + if test.errorIs != nil { + require.ErrorIs(t, err, test.errorIs) + } + + return + } + + require.NoError(t, err) + + actual := new(bytes.Buffer) + require.NoError(t, specFile.Serialize(actual)) + assert.Equal(t, test.expectedOutput, actual.String()) + }) + } +} + func TestAddChangelogEntry(t *testing.T) { const ( testUser = "Test User" diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index 076dc148..21b67b1f 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -210,6 +210,7 @@ "spec-set-tag", "spec-update-tag", "spec-remove-tag", + "spec-set-macros", "spec-prepend-lines", "spec-append-lines", "spec-search-replace", @@ -273,6 +274,14 @@ "title": "Lines", "description": "The lines of text to use" }, + "macros": { + "additionalProperties": { + "$ref": "#/$defs/MacroSetSpec" + }, + "type": "object", + "title": "Macros", + "description": "Map of macro names to settings; used by spec-set-macros" + }, "source": { "type": "string", "title": "Source", @@ -637,6 +646,29 @@ "additionalProperties": false, "type": "object" }, + "MacroSetSpec": { + "properties": { + "value": { + "type": "string", + "title": "Value", + "description": "The new value to assign to the macro" + }, + "kind": { + "type": "string", + "enum": [ + "global", + "define" + ], + "title": "Kind", + "description": "Force '%global' or '%define' form (defaults to whichever is already in the spec)" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "value" + ] + }, "Origin": { "properties": { "type": { diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index 076dc148..21b67b1f 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -210,6 +210,7 @@ "spec-set-tag", "spec-update-tag", "spec-remove-tag", + "spec-set-macros", "spec-prepend-lines", "spec-append-lines", "spec-search-replace", @@ -273,6 +274,14 @@ "title": "Lines", "description": "The lines of text to use" }, + "macros": { + "additionalProperties": { + "$ref": "#/$defs/MacroSetSpec" + }, + "type": "object", + "title": "Macros", + "description": "Map of macro names to settings; used by spec-set-macros" + }, "source": { "type": "string", "title": "Source", @@ -637,6 +646,29 @@ "additionalProperties": false, "type": "object" }, + "MacroSetSpec": { + "properties": { + "value": { + "type": "string", + "title": "Value", + "description": "The new value to assign to the macro" + }, + "kind": { + "type": "string", + "enum": [ + "global", + "define" + ], + "title": "Kind", + "description": "Force '%global' or '%define' form (defaults to whichever is already in the spec)" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "value" + ] + }, "Origin": { "properties": { "type": { diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index 076dc148..21b67b1f 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -210,6 +210,7 @@ "spec-set-tag", "spec-update-tag", "spec-remove-tag", + "spec-set-macros", "spec-prepend-lines", "spec-append-lines", "spec-search-replace", @@ -273,6 +274,14 @@ "title": "Lines", "description": "The lines of text to use" }, + "macros": { + "additionalProperties": { + "$ref": "#/$defs/MacroSetSpec" + }, + "type": "object", + "title": "Macros", + "description": "Map of macro names to settings; used by spec-set-macros" + }, "source": { "type": "string", "title": "Source", @@ -637,6 +646,29 @@ "additionalProperties": false, "type": "object" }, + "MacroSetSpec": { + "properties": { + "value": { + "type": "string", + "title": "Value", + "description": "The new value to assign to the macro" + }, + "kind": { + "type": "string", + "enum": [ + "global", + "define" + ], + "title": "Kind", + "description": "Force '%global' or '%define' form (defaults to whichever is already in the spec)" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "value" + ] + }, "Origin": { "properties": { "type": {