Skip to content

Commit 31572cf

Browse files
cty+ctymarks: Deep mark wrangling helper
Having now got some experience using marks more extensively in some callers, it's become clear that it's often necessary for different subsystems to be able to collaborate using independent marks without upsetting each other's assumptions. Today that tends to be achieved using hand-written transforms either with cty.Transform or cty.Value.UnmarkDeepWithPaths/cty.Value.MarkWithPaths, both of which can be pretty expensive even in the common case where there are no marks present at all. This new function allows inspecting and transforming marks with far less overhead, by creating new values only for parts of a structure that actually need to change and by reusing (rather than recreating) the "payloads" of the values being modified when we know that only the marks have changed.
1 parent d95a68c commit 31572cf

File tree

5 files changed

+749
-2
lines changed

5 files changed

+749
-2
lines changed

CHANGELOG.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22

33
`cty` now requires Go 1.23 or later.
44

5-
* `cty.Value.Elements` offers a modern `iter.Seq2`-based equivalent of `cty.Value.ElementIterator`.
6-
* `cty.DeepValues` offers a modern `iter.Seq2`-based equivalent of `cty.Walk`.
5+
- `cty.Value.Elements` offers a modern `iter.Seq2`-based equivalent of `cty.Value.ElementIterator`.
6+
- `cty.DeepValues` offers a modern `iter.Seq2`-based equivalent of `cty.Walk`.
7+
- `cty.Value.WrangleMarksDeep` allows inspecting and modifying individual marks throughout a possibly-nested data structure.
8+
9+
Having now got some experience using marks more extensively in some callers, it's become clear that it's often necessary for different subsystems to be able to collaborate using independent marks without upsetting each other's assumptions. Today that tends to be achieved using hand-written transforms either with `cty.Transform` or `cty.Value.UnmarkDeepWithPaths`/`cty.Value.MarkWithPaths`, both of which can be pretty expensive even in the common case where there are no marks present at all.
10+
11+
This new function allows inspecting and transforming marks with far less overhead, by creating new values only for parts of a structure that actually need to change and by reusing (rather than recreating) the "payloads" of the values being modified when we know that only the marks have changed.
712

813
# 1.16.4 (August 20, 2025)
914

cty/ctymarks/doc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Package ctymarks contains some ancillary types, values, and constants for
2+
// use with mark-related parts of the main cty package.
3+
package ctymarks

cty/ctymarks/wrangle.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package ctymarks
2+
3+
// WrangleAction values each describe an action to be taken for a particular
4+
// mark at a particular path that has been visited by a [WrangleFunc].
5+
//
6+
// A nil value of this type represents taking no action at all, and thereby
7+
// potentially allowing other later functions to decide an action instead.
8+
type WrangleAction interface {
9+
wrangleAction()
10+
}
11+
12+
type simpleWrangleAction rune
13+
14+
func (w simpleWrangleAction) wrangleAction() {}
15+
16+
// WrangleKeep is a [WrangleAction] that indicates that the given mark
17+
// should be retained at the given path and that no subsequent wrangle functions
18+
// in the same operation should be given an opportunity to visit the same mark.
19+
const WrangleKeep = simpleWrangleAction('k')
20+
21+
// WrangleDrop is a [WrangleAction] that indicates that the given mark
22+
// should be dropped at the given path, and that no subsequent wrangle functions
23+
// in the same operation should be given an opportunity to visit the same mark.
24+
const WrangleDrop = simpleWrangleAction('d')
25+
26+
// WrangleExpand is a [WrangleAction] that indicates that the given mark
27+
// should be transferred to the top-level value that the current mark wrangling
28+
// process is operating on.
29+
//
30+
// This effectively means that the mark then applies to all parts of the
31+
// original top-level value, rather than to only a small part of the nested
32+
// data structure within it.
33+
const WrangleExpand = simpleWrangleAction('e')
34+
35+
// WrangleReplace returns a [WrangleAction] that indicates that the given
36+
// mark should be removed and the given mark inserted in its place.
37+
//
38+
// This could be useful when values must cross between systems that use
39+
// different marks to represent similar concepts, for example.
40+
func WrangleReplace(newMark any) WrangleAction {
41+
if newMark == nil {
42+
panic("ctymarks.WrangleReplace with nil mark")
43+
}
44+
return wrangleReplaceAction{newMark}
45+
}
46+
47+
// WrangleReplaceMark returns the new mark for the given action if it was
48+
// created using [WrangleReplace], or nil if it is any other kind of action.
49+
func WrangleReplaceMark(action WrangleAction) any {
50+
replace, _ := action.(wrangleReplaceAction)
51+
return replace.newMark
52+
}
53+
54+
type wrangleReplaceAction struct {
55+
newMark any
56+
}
57+
58+
func (w wrangleReplaceAction) wrangleAction() {}

cty/marks_wrangle.go

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
package cty
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"iter"
7+
"maps"
8+
9+
"github.com/zclconf/go-cty/cty/ctymarks"
10+
)
11+
12+
// WrangleMarksDeep is a specialized variant of [Transform] that is focused
13+
// on interrogating and modifying any marks present throughout a data structure,
14+
// without modifying anything else about the value.
15+
//
16+
// Refer to the [WrangleFunc] documentation for more information. Each of
17+
// the provided functions is called in turn for each mark at each distinct path,
18+
// and the first function that returns a non-nil [ctymarks.WrangleAction] "wins"
19+
// and prevents any later ones from running for a particular mark/path pair.
20+
//
21+
// The implementation makes a best effort to avoid constructing new values
22+
// unless marks have actually changed, to keep this operation relatively cheap
23+
// in the presumed-common case where no marks are present at all.
24+
func (v Value) WrangleMarksDeep(wranglers ...WrangleFunc) (Value, error) {
25+
// This function is implemented in this package, rather than in the
26+
// separate "ctymarks", so that it can intrude into the unexported
27+
// internal details of [Value] to minimize overhead when no marks
28+
// are present at all.
29+
var path Path
30+
if v.IsKnown() && !v.Type().IsPrimitiveType() && !v.IsNull() {
31+
// If we have a known, non-null, non-primitive-typed value then we
32+
// can assume there will be at least a little nesting we need to
33+
// represent using our path, and so we'll preallocate some capacity
34+
// which we'll be able to share across all calls that are shallower
35+
// than this level of nesting.
36+
path = make(Path, 0, 4)
37+
}
38+
topMarks := make(ValueMarks)
39+
var errs []error
40+
new := wrangleMarksDeep(v, wranglers, path, topMarks, &errs)
41+
if new == NilVal {
42+
new = v // completely unchanged
43+
}
44+
var err error
45+
switch len(errs) {
46+
case 0:
47+
// nil err is fine, then
48+
case 1:
49+
err = errs[0]
50+
default:
51+
err = errors.Join(errs...)
52+
}
53+
return new.WithMarks(topMarks), err
54+
}
55+
56+
// wrangleMarksDeep is the main implementation of [WrangleMarksDeep], which
57+
// calls itself recursively to handle nested data structures.
58+
//
59+
// If the returned value is [NilVal] then that means that no changes were
60+
// needed to anything at or beneath that nesting level and so the caller should
61+
// just keep the original value exactly.
62+
//
63+
// Modifies topMarks and errs during traversal to collect (respectively) any
64+
// marks that caused [ctymarks.WrangleExpand] and and errors returned by
65+
// wrangle functions.
66+
func wrangleMarksDeep(v Value, wranglers []WrangleFunc, path Path, topMarks ValueMarks, errs *[]error) Value {
67+
var givenMarks, newMarks ValueMarks
68+
makeNewValue := false
69+
// The following is the same idea as [Value.Unmark], but implemented inline
70+
// here so that we can skip copying any existing ValueMarks that might
71+
// already be present, since we know we're not going to try to mutate it.
72+
if marked, ok := v.v.(marker); ok {
73+
v = Value{
74+
ty: v.ty,
75+
v: marked.realV,
76+
}
77+
givenMarks = marked.marks
78+
}
79+
80+
// We call this whenever we know we're returning a new value, to perform
81+
// a one-time copy of the given marks into a new marks map we can modify
82+
// and to set a flag to force us to construct a newly-marked value when
83+
// we return below.
84+
needNewValue := func() {
85+
if newMarks == nil && len(givenMarks) != 0 {
86+
newMarks = make(ValueMarks, len(givenMarks))
87+
maps.Copy(newMarks, givenMarks)
88+
}
89+
makeNewValue = true
90+
}
91+
92+
for mark := range givenMarks {
93+
Wranglers:
94+
for _, wrangler := range wranglers {
95+
action, err := wrangler(mark, path)
96+
if err != nil {
97+
if len(path) != 0 {
98+
err = path.NewError(err)
99+
}
100+
*errs = append(*errs, err)
101+
}
102+
switch action {
103+
case nil:
104+
continue Wranglers
105+
case ctymarks.WrangleKeep:
106+
break Wranglers
107+
case ctymarks.WrangleExpand:
108+
topMarks[mark] = struct{}{}
109+
break Wranglers
110+
case ctymarks.WrangleDrop:
111+
needNewValue()
112+
delete(newMarks, mark)
113+
break Wranglers
114+
default:
115+
newMark := ctymarks.WrangleReplaceMark(action)
116+
if newMark == nil {
117+
// Should not get here because these cases should be
118+
// exhaustive for all possible WrangleAction values.
119+
panic(fmt.Sprintf("unhandled WrangleAction %#v", action))
120+
}
121+
needNewValue()
122+
delete(newMarks, mark)
123+
newMarks[newMark] = struct{}{}
124+
break Wranglers
125+
}
126+
}
127+
}
128+
129+
// We're not going to make any further changes to our _direct_ marks
130+
// after this, so if we didn't already make a copy of the given marks
131+
// we can now safely alias our original set to reuse when we return.
132+
// (We might still construct a new value though, if we recurse into
133+
// a nested value that needs its own changes.)
134+
if newMarks == nil {
135+
newMarks = givenMarks // might still be nil if we didn't have any marks on entry
136+
}
137+
138+
// Now we'll visit nested values recursively, if any.
139+
// The cases below intentionally don't cover primitive types, set types,
140+
// or capsule types, because none of them can possibly have nested marks
141+
// inside. (For set types in particular, any marks on inner values get
142+
// aggregated on the top-level set itself during construction.)
143+
ty := v.Type()
144+
switch {
145+
case v.IsNull() || !v.IsKnown():
146+
// Can't recurse into null or unknown values, regardless of type,
147+
// so nothing to do here.
148+
149+
case ty.IsListType() || ty.IsTupleType():
150+
// These types both have the same internal representation, and we
151+
// know we're not going to change anything about the type, so we
152+
// can share the same implementation for both.
153+
l := v.LengthInt()
154+
if l == 0 {
155+
break // nothing to do for an empty container
156+
}
157+
158+
// We'll avoid allocating a new slice until we know we're going
159+
// to make a change.
160+
var newElems []any // as would appear in Value.v for all three of these types
161+
for i, innerV := range replaceKWithIdx(v.Elements()) {
162+
path := append(path, IndexStep{Key: NumberIntVal(int64(i))})
163+
newInnerV := wrangleMarksDeep(innerV, wranglers, path, topMarks, errs)
164+
if newInnerV != NilVal {
165+
needNewValue()
166+
if newElems == nil {
167+
// If this is the first change we've found then we need to
168+
// allocate our new elems array and retroactively copy
169+
// anything we previously skipped because it was unchanged.
170+
newElems = make([]any, i, l)
171+
copy(newElems, v.v.([]any))
172+
}
173+
newElems = append(newElems, newInnerV.v)
174+
} else if newElems != nil {
175+
// Once we've started building a new value we need to append
176+
// everything to it whether it's changed or not, but we can
177+
// reuse the unchanged element's internal value.
178+
newElems = append(newElems, innerV.v)
179+
}
180+
}
181+
if newElems != nil {
182+
// if we built a new array of elements then it should replace
183+
// the one from our input value.
184+
v.v = newElems
185+
}
186+
187+
case ty.IsMapType() || ty.IsObjectType():
188+
// These types both have the same internal representation, and we
189+
// know we're not going to change anything about the type, so we
190+
// can share the same implementation for both.
191+
l := v.LengthInt()
192+
if l == 0 {
193+
break // nothing to do for an empty container
194+
}
195+
196+
// We'll avoid allocating a new map until we know we're going to
197+
// make a change.
198+
var newElems map[string]any
199+
for keyV, innerV := range v.Elements() {
200+
var pathStep PathStep
201+
if ty.IsObjectType() {
202+
pathStep = GetAttrStep{Name: keyV.AsString()}
203+
} else {
204+
pathStep = IndexStep{Key: keyV}
205+
}
206+
path := append(path, pathStep)
207+
newInnerV := wrangleMarksDeep(innerV, wranglers, path, topMarks, errs)
208+
if newInnerV != NilVal {
209+
needNewValue()
210+
if newElems == nil {
211+
// If this is the first change we've found then we need to
212+
// allocate our new elems map and retroactively copy
213+
// everything from the original map before we overwrite
214+
// the elements that need to change.
215+
newElems = make(map[string]any, l)
216+
maps.Copy(newElems, v.v.(map[string]any))
217+
}
218+
newElems[keyV.AsString()] = newInnerV.v
219+
}
220+
}
221+
if newElems != nil {
222+
// if we built a new map of elements then it should replace
223+
// the one from our input value.
224+
v.v = newElems
225+
}
226+
}
227+
228+
if !makeNewValue {
229+
// We didn't make any changes to the marks, so we don't need to
230+
// construct a new value.
231+
return NilVal
232+
}
233+
return v.WithMarks(newMarks)
234+
}
235+
236+
// WrangleFunc is the signature of a callback function used to visit a
237+
// particular mark associated with a particular path within a value.
238+
//
239+
// [Path] values passed to successive calls to a [WrangleFunc] may share a
240+
// backing array, and so if the function wishes to retain a particular path
241+
// after it returns it must use [Path.Copy] to produce a copy in an unaliased
242+
// backing array.
243+
//
244+
// A function of this type must decide what change to make, if any, to the
245+
// presence of this mark at this location. Returning nil means to take no
246+
// action at all and to potentially allow other later functions of this
247+
// type to decide what to do instead.
248+
//
249+
// If the function returns an error then [Value.WrangleMarksDeep] collects it
250+
// and any other errors returned during traversal, automatically wraps in a
251+
// [cty.PathError] if not at the root, and returns an [`errors.Join`] of all
252+
// of the errors if there are more than one. The indicated action is still taken
253+
// and continued "wrangling" occurs as normal to visit other marks and other
254+
// paths.
255+
//
256+
// [cty.Value.WrangleMarksDeep], and this callback signature used with it,
257+
// are together designed with some assumptions that don't always hold but
258+
// have been common enough to make it seem worth supporting with a first-class
259+
// feature:
260+
//
261+
// - All of the different marks on any specific value are orthogonal to one
262+
// another, and so it's possible to decide an action for each one in
263+
// isolation.
264+
// - Marks within a data structure are orthogonal to the specific values they
265+
// are associated with, and so it's possible to decide an action without
266+
// knowning the value it's associated with. (Though it's possible in
267+
// principle to use the given path to retrieve more information when needed,
268+
// at the expense of some additional traversal overhead.)
269+
// - Most values have no marks at all and when marks are present there are
270+
// relatively few of them, and so it's worth making some extra effort to
271+
// handle the no-marks case cheaply even if it makes the marks-present case
272+
// a little more expensive.
273+
//
274+
// If any of these assumptions don't apply to your situation then this may not
275+
// be an appropriate solution. [cty.Transform] or [cty.TransformWithTransformer]
276+
// might serve as a more general alternative if you need more control.
277+
type WrangleFunc func(mark any, path Path) (ctymarks.WrangleAction, error)
278+
279+
func replaceKWithIdx[K any, V any](in iter.Seq2[K, V]) iter.Seq2[int, V] {
280+
return func(yield func(int, V) bool) {
281+
i := 0
282+
for _, v := range in {
283+
if !yield(i, v) {
284+
break
285+
}
286+
i++
287+
}
288+
}
289+
}

0 commit comments

Comments
 (0)