this repo has no description
1// Copyright 2025 CUE Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15package cueexperiment
16
17import (
18 "fmt"
19 "maps"
20 "reflect"
21 "slices"
22 "strings"
23
24 "cuelang.org/go/internal/mod/semver"
25)
26
27// This contains experiments that are configured per file.
28
29// File defines the experiments that can be set per file. Users can activate
30// experiments by setting them using a file-based attribute @experiment() in
31// a CUE file. When an experiment is first introduced, it is disabled by
32// default.
33//
34// preview: the version from when the experiment was introduced.
35// stable: the version from when it is permanently set to true.
36// withdrawn: results in an error if the user attempts to use the flag.
37type File struct {
38 // version is the module version of the file that was compiled.
39 version string
40
41 // experiments is a comma-separated list of experiments that are enabled
42 // for this file. This is for documentation purposes only, as the
43 // experiments are already set in the struct fields.
44 experiments string
45
46 // Testing is used to enable experiments for testing.
47 //
48 // TODO: we could later use it for enabling testing features, such as
49 // testing-specific builtins.
50 Testing bool `experiment:"preview:v0.13.0"`
51
52 // Accepted_ is for testing purposes only. It should be removed when an
53 // experiment is accepted and can be used to test this feature instead.
54 Accepted_ bool `experiment:"preview:v0.13.0,stable:v0.15.0"`
55
56 // StructCmp enables comparison of structs. This also defines the ==
57 // operator to be defined on all values. For instance, comparing 1 and
58 // "foo" will return false, whereas previously it would return an error.
59 //
60 // Proposal: https://cuelang.org/issue/2583
61 // Spec change: https://cuelang.org/cl/1217013
62 // Spec change: https://cuelang.org/cl/1217014
63 StructCmp bool `experiment:"preview:v0.14.0,stable:v0.15.0"`
64
65 // ExplicitOpen enables the postfix ... operator to explicitly open
66 // closed structs, allowing additional fields to be added.
67 //
68 // Proposal: https://cuelang.org/issue/4032
69 // Spec change: https://cuelang.org/cl/1221642
70 // Requires cue fix when upgrading
71 ExplicitOpen bool `experiment:"preview:v0.15.0"`
72
73 // AliasV2 enables the use of 'self' identifier to refer to the
74 // enclosing struct and enables the postfix alias syntax (~X and ~(K,V)).
75 // The file where this experiment is enabled disallows the use of old prefix
76 // alias syntax (X=).
77 //
78 // Proposal: https://cuelang.org/issue/4014
79 // Spec change: https://cuelang.org/cl/1222377
80 // Requires cue fix when upgrading
81 AliasV2 bool `experiment:"preview:v0.15.0"`
82}
83
84// LanguageVersion returns the language version of the file or "" if no language
85// version is associated with it.
86func (f *File) LanguageVersion() string {
87 return f.version
88}
89
90// NewFile parses the given comma-separated list of experiments for
91// the given version and returns a PerFile struct with the experiments enabled.
92// A empty version indicates the default version.
93func NewFile(version string, experiments ...string) (*File, error) {
94 // TODO: cash versions for a given version where there is no experiment
95 // string.
96 m := parseExperiments(experiments...)
97 f := &File{
98 version: version,
99 experiments: strings.Join(slices.Sorted(maps.Keys(m)), ","),
100 }
101
102 if err := parseConfig(f, version, m); err != nil {
103 return nil, err
104 }
105 return f, nil
106}
107
108// IsPreview returns true if the experiment exists and can be used
109// for the given version.
110func IsPreview(experiment, version string) bool {
111 return isPreview(experiment, version, File{})
112}
113
114func isPreview(experiment, version string, t any) bool {
115 expInfo := getExperimentInfoT(experiment, t)
116 if expInfo == nil {
117 return false
118 }
119 return expInfo.isValidForVersion(version)
120}
121
122func (e *experimentInfo) isValidForVersion(version string) bool {
123 // Check if experiment is available for this version
124 if version != "" && e.Preview != "" {
125 if semver.Compare(version, e.Preview) < 0 {
126 return false
127 }
128 }
129
130 // Check if experiment is rejected for this version
131 if e.Withdrawn != "" {
132 if version == "" || semver.Compare(version, e.Withdrawn) >= 0 {
133 return false
134 }
135 }
136
137 return true
138}
139
140// IsStable returns true if the experiment is stable (no longer
141// experimental) for the given version.
142func IsStable(experiment, version string) bool {
143 expInfo := getExperimentInfo(experiment)
144 if expInfo == nil {
145 return false
146 }
147 return expInfo.isStableForVersion(version)
148}
149
150func (e *experimentInfo) isStableForVersion(version string) bool {
151 if e.Stable == "" {
152 return false
153 }
154 return version == "" || semver.Compare(version, e.Stable) >= 0
155}
156
157// CanApplyFix validates whether an experiment fix can be applied
158// to a file with the given version and existing experiments.
159func CanApplyFix(experiment, version, target string) error {
160 return canApplyExperimentFix(experiment, version, target, File{})
161}
162
163func canApplyExperimentFix(experiment, version, target string, t any) error {
164 expInfo := getExperimentInfoT(experiment, t)
165 if expInfo == nil {
166 return fmt.Errorf("unknown experiment %q", experiment)
167 }
168
169 // Check if experiment is valid for this version
170 if !expInfo.isValidForVersion(target) {
171 if version != "" && expInfo.Preview != "" &&
172 semver.Compare(target, expInfo.Preview) < 0 {
173 const msg = "experiment %q requires language version %s or later, have %s"
174 return fmt.Errorf(msg, experiment, expInfo.Preview, version)
175 }
176
177 if expInfo.Withdrawn != "" {
178 if version == "" || semver.Compare(target, expInfo.Withdrawn) >= 0 {
179 const msg = "experiment %q is withdrawn in language version %s"
180 return fmt.Errorf(msg, experiment, expInfo.Withdrawn)
181 }
182 }
183 }
184
185 // Check if experiment is already stable (cannot fix)
186 if expInfo.isStableForVersion(version) {
187 const msg = "experiment %q is already stable as of language version %s - cannot apply fix"
188 return fmt.Errorf(msg, experiment, expInfo.Stable)
189 }
190
191 return nil
192}
193
194// GetActive returns all experiments that are active (can be enabled)
195// for the given version, but not yet accepted.
196func GetActive(origVersion, targetVersion string) []string {
197 return getActiveExperiments(origVersion, targetVersion, File{})
198}
199
200func getActiveExperiments(origVersion, targetVersion string, t any) []string {
201 var active []string
202
203 ft := reflect.TypeOf(t)
204 for i := 0; i < ft.NumField(); i++ {
205 field := ft.Field(i)
206 tagStr, ok := field.Tag.Lookup("experiment")
207 if !ok {
208 continue
209 }
210 name := strings.ToLower(field.Name)
211 expInfo := parseExperimentTag(tagStr)
212
213 // Skip if not yet available for this version
214 if targetVersion != "" && expInfo.Preview != "" && semver.Compare(targetVersion, expInfo.Preview) < 0 {
215 continue
216 }
217
218 // Skip if already stable
219 if expInfo.Stable != "" && (targetVersion == "" || semver.Compare(origVersion, expInfo.Stable) >= 0) {
220 continue
221 }
222
223 // Skip if withdrawn
224 if expInfo.Withdrawn != "" {
225 continue
226 }
227
228 active = append(active, name)
229 }
230
231 slices.Sort(active)
232 return active
233}
234
235// GetUpgradable returns all experiments that are stable
236// (possibly in later versions), that can be upgraded from the current
237// version (must be lower than stable) to the desired version.
238func GetUpgradable(origVersion, targetVersion string) []string {
239 return getUpgradeExperiments(origVersion, targetVersion, File{})
240}
241
242func getUpgradeExperiments(origVersion, targetVersion string, t any) []string {
243 var accepted []string
244 if origVersion == "" {
245 panic("original version is empty")
246 }
247
248 ft := reflect.TypeOf(t)
249 for i := 0; i < ft.NumField(); i++ {
250 field := ft.Field(i)
251 tagStr, ok := field.Tag.Lookup("experiment")
252 if !ok {
253 continue
254 }
255 name := strings.ToLower(field.Name)
256 expInfo := parseExperimentTag(tagStr)
257
258 if expInfo.Stable != "" &&
259 semver.Compare(targetVersion, expInfo.Preview) >= 0 &&
260 semver.Compare(origVersion, expInfo.Stable) < 0 {
261 accepted = append(accepted, name)
262 }
263 }
264
265 slices.Sort(accepted)
266 return accepted
267}
268
269// ShouldRemoveAttribute returns true if the experiment attribute
270// should be removed because the experiment is stable for the given version.
271func ShouldRemoveAttribute(experiment, version string) bool {
272 return IsStable(experiment, version)
273}
274
275// experimentInfo holds parsed experiment lifecycle information
276type experimentInfo struct {
277 Preview string
278 Stable string
279 Withdrawn string
280}
281
282// getExperimentInfo returns experiment lifecycle info for the given experiment name
283func getExperimentInfo(experiment string) *experimentInfo {
284 return getExperimentInfoT(experiment, File{})
285}
286
287func getExperimentInfoT(experiment string, t any) *experimentInfo {
288 ft := reflect.TypeOf(t)
289 for i := 0; i < ft.NumField(); i++ {
290 field := ft.Field(i)
291 if strings.EqualFold(field.Name, experiment) {
292 if tagStr, ok := field.Tag.Lookup("experiment"); ok {
293 return parseExperimentTag(tagStr)
294 }
295 }
296 }
297 return nil
298}
299
300// parseExperimentTag parses experiment tag string into experimentInfo
301func parseExperimentTag(tagStr string) *experimentInfo {
302 info := &experimentInfo{}
303 for f := range strings.SplitSeq(tagStr, ",") {
304 key, rest, _ := strings.Cut(f, ":")
305 if !semver.IsValid(rest) {
306 panic(fmt.Sprintf("invalid semver in experiment tag %q: %q", key, rest))
307 }
308 switch key {
309 case "preview":
310 info.Preview = rest
311 case "stable":
312 info.Stable = rest
313 case "withdrawn":
314 info.Withdrawn = rest
315 }
316 }
317 return info
318}