cuelang.org/go@v0.10.1/mod/modfile/modfile.go (about)

     1  // Copyright 2023 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  //     http://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  
    15  // Package modfile provides functionality for reading and parsing
    16  // the CUE module file, cue.mod/module.cue.
    17  //
    18  // WARNING: THIS PACKAGE IS EXPERIMENTAL.
    19  // ITS API MAY CHANGE AT ANY TIME.
    20  package modfile
    21  
    22  import (
    23  	_ "embed"
    24  	"fmt"
    25  	"slices"
    26  	"strings"
    27  	"sync"
    28  
    29  	"cuelang.org/go/internal/mod/semver"
    30  
    31  	"cuelang.org/go/cue"
    32  	"cuelang.org/go/cue/ast"
    33  	"cuelang.org/go/cue/build"
    34  	"cuelang.org/go/cue/cuecontext"
    35  	"cuelang.org/go/cue/errors"
    36  	"cuelang.org/go/cue/format"
    37  	"cuelang.org/go/cue/token"
    38  	"cuelang.org/go/internal/cueversion"
    39  	"cuelang.org/go/internal/encoding"
    40  	"cuelang.org/go/internal/filetypes"
    41  	"cuelang.org/go/mod/module"
    42  )
    43  
    44  //go:embed schema.cue
    45  var moduleSchemaData string
    46  
    47  const schemaFile = "cuelang.org/go/mod/modfile/schema.cue"
    48  
    49  // File represents the contents of a cue.mod/module.cue file.
    50  type File struct {
    51  	// Module holds the module path, which may
    52  	// not contain a major version suffix.
    53  	// Use the [File.QualifiedModule] method to obtain a module
    54  	// path that's always qualified. See also the
    55  	// [File.ModulePath] and [File.MajorVersion] methods.
    56  	Module   string                    `json:"module"`
    57  	Language *Language                 `json:"language,omitempty"`
    58  	Source   *Source                   `json:"source,omitempty"`
    59  	Deps     map[string]*Dep           `json:"deps,omitempty"`
    60  	Custom   map[string]map[string]any `json:"custom,omitempty"`
    61  	versions []module.Version
    62  	// defaultMajorVersions maps from module base path to the
    63  	// major version default for that path.
    64  	defaultMajorVersions map[string]string
    65  	// actualSchemaVersion holds the actual schema version
    66  	// that was used to validate the file. This will be one of the
    67  	// entries in the versions field in schema.cue and
    68  	// is set by the Parse functions.
    69  	actualSchemaVersion string
    70  }
    71  
    72  // Module returns the fully qualified module path
    73  // if is one. It returns the empty string when [ParseLegacy]
    74  // is used and the module field is empty.
    75  //
    76  // Note that when the module field does not contain a major
    77  // version suffix, "@v0" is assumed.
    78  func (f *File) QualifiedModule() string {
    79  	if strings.Contains(f.Module, "@") {
    80  		return f.Module
    81  	}
    82  	if f.Module == "" {
    83  		return ""
    84  	}
    85  	return f.Module + "@v0"
    86  }
    87  
    88  // ModulePath returns the path part of the module without
    89  // its major version suffix.
    90  func (f *File) ModulePath() string {
    91  	path, _, _ := module.SplitPathVersion(f.QualifiedModule())
    92  	return path
    93  }
    94  
    95  // MajorVersion returns the major version of the module,
    96  // not including the "@".
    97  // If there is no module (which can happen when [ParseLegacy]
    98  // is used or if Module is explicitly set to an empty string),
    99  // it returns the empty string.
   100  func (f *File) MajorVersion() string {
   101  	_, vers, _ := module.SplitPathVersion(f.QualifiedModule())
   102  	return vers
   103  }
   104  
   105  // baseFileVersion is used to decode the language version
   106  // to decide how to decode the rest of the file.
   107  type baseFileVersion struct {
   108  	Language struct {
   109  		Version string `json:"version"`
   110  	} `json:"language"`
   111  }
   112  
   113  // Source represents how to transform from a module's
   114  // source to its actual contents.
   115  type Source struct {
   116  	Kind string `json:"kind"`
   117  }
   118  
   119  // Validate checks that src is well formed.
   120  func (src *Source) Validate() error {
   121  	switch src.Kind {
   122  	case "git", "self":
   123  		return nil
   124  	}
   125  	return fmt.Errorf("unrecognized source kind %q", src.Kind)
   126  }
   127  
   128  // Format returns a formatted representation of f
   129  // in CUE syntax.
   130  func (f *File) Format() ([]byte, error) {
   131  	if len(f.Deps) == 0 && f.Deps != nil {
   132  		// There's no way to get the CUE encoder to omit an empty
   133  		// but non-nil slice (despite the current doc comment on
   134  		// [cue.Context.Encode], so make a copy of f to allow us
   135  		// to do that.
   136  		f1 := *f
   137  		f1.Deps = nil
   138  		f = &f1
   139  	}
   140  	// TODO this could be better:
   141  	// - it should omit the outer braces
   142  	v := cuecontext.New().Encode(f)
   143  	if err := v.Validate(cue.Concrete(true)); err != nil {
   144  		return nil, err
   145  	}
   146  	n := v.Syntax(cue.Concrete(true)).(*ast.StructLit)
   147  
   148  	data, err := format.Node(&ast.File{
   149  		Decls: n.Elts,
   150  	})
   151  	if err != nil {
   152  		return nil, fmt.Errorf("cannot format: %v", err)
   153  	}
   154  	// Sanity check that it can be parsed.
   155  	// TODO this could be more efficient by checking all the file fields
   156  	// before formatting the output.
   157  	f1, err := ParseNonStrict(data, "-")
   158  	if err != nil {
   159  		return nil, fmt.Errorf("cannot parse result: %v", strings.TrimSuffix(errors.Details(err, nil), "\n"))
   160  	}
   161  	if f.Language != nil && f1.actualSchemaVersion == "v0.0.0" {
   162  		// It's not a legacy module file (because the language field is present)
   163  		// but we've used the legacy schema to parse it, which means that
   164  		// it's almost certainly a bogus version because all versions
   165  		// we care about fail when there are unknown fields, but the
   166  		// original schema allowed all fields.
   167  		return nil, fmt.Errorf("language version %v is too early for module.cue (need at least %v)", f.Language.Version, EarliestClosedSchemaVersion())
   168  	}
   169  	return data, err
   170  }
   171  
   172  type Language struct {
   173  	Version string `json:"version,omitempty"`
   174  }
   175  
   176  type Dep struct {
   177  	Version string `json:"v"`
   178  	Default bool   `json:"default,omitempty"`
   179  }
   180  
   181  type noDepsFile struct {
   182  	Module string `json:"module"`
   183  }
   184  
   185  var (
   186  	moduleSchemaOnce sync.Once // guards the creation of _moduleSchema
   187  	// TODO remove this mutex when https://cuelang.org/issue/2733 is fixed.
   188  	moduleSchemaMutex sync.Mutex // guards any use of _moduleSchema
   189  	_schemas          schemaInfo
   190  )
   191  
   192  type schemaInfo struct {
   193  	Versions                    map[string]cue.Value `json:"versions"`
   194  	EarliestClosedSchemaVersion string               `json:"earliestClosedSchemaVersion"`
   195  }
   196  
   197  // moduleSchemaDo runs f with information about all the schema versions
   198  // present in schema.cue. It does this within a mutex because it is
   199  // not currently allowed to use cue.Value concurrently.
   200  // TODO remove the mutex when https://cuelang.org/issue/2733 is fixed.
   201  func moduleSchemaDo[T any](f func(*schemaInfo) (T, error)) (T, error) {
   202  	moduleSchemaOnce.Do(func() {
   203  		// It is important that this cue.Context not be used for building any other cue.Value,
   204  		// such as in [Parse] or [ParseLegacy].
   205  		// A value holds memory as long as the context it was built with is kept alive for,
   206  		// and this context is alive forever via the _schemas global.
   207  		//
   208  		// TODO(mvdan): this violates the documented API rules in the cue package:
   209  		//
   210  		//    Only values created from the same Context can be involved in the same operation.
   211  		//
   212  		// However, this appears to work in practice, and all alternatives right now would be
   213  		// either too costly or awkward. We want to lift that API restriction, and this works OK,
   214  		// so leave it as-is for the time being.
   215  		ctx := cuecontext.New()
   216  		schemav := ctx.CompileString(moduleSchemaData, cue.Filename(schemaFile))
   217  		if err := schemav.Decode(&_schemas); err != nil {
   218  			panic(fmt.Errorf("internal error: invalid CUE module.cue schema: %v", errors.Details(err, nil)))
   219  		}
   220  	})
   221  	moduleSchemaMutex.Lock()
   222  	defer moduleSchemaMutex.Unlock()
   223  	return f(&_schemas)
   224  }
   225  
   226  func lookup(v cue.Value, sels ...cue.Selector) cue.Value {
   227  	return v.LookupPath(cue.MakePath(sels...))
   228  }
   229  
   230  // EarliestClosedSchemaVersion returns the earliest module.cue schema version
   231  // that excludes unknown fields. Any version declared in a module.cue file
   232  // should be at least this, because that's when we added the language.version
   233  // field itself.
   234  func EarliestClosedSchemaVersion() string {
   235  	return earliestClosedSchemaVersion()
   236  }
   237  
   238  var earliestClosedSchemaVersion = sync.OnceValue(func() string {
   239  	earliest, _ := moduleSchemaDo(func(info *schemaInfo) (string, error) {
   240  		earliest := ""
   241  		for v := range info.Versions {
   242  			if earliest == "" || semver.Compare(v, earliest) < 0 {
   243  				earliest = v
   244  			}
   245  		}
   246  		return earliest, nil
   247  	})
   248  	return earliest
   249  })
   250  
   251  // LatestKnownSchemaVersion returns the language version
   252  // associated with the most recent known schema.
   253  //
   254  // Deprecated: use [cuelang.org/go/cue.LanguageVersion] instead. This
   255  // function will be removed in v0.11.
   256  func LatestKnownSchemaVersion() string {
   257  	return cueversion.LanguageVersion()
   258  }
   259  
   260  // Parse verifies that the module file has correct syntax
   261  // and follows the schema following the required language.version field.
   262  // The file name is used for error messages.
   263  // All dependencies must be specified correctly: with major
   264  // versions in the module paths and canonical dependency versions.
   265  func Parse(modfile []byte, filename string) (*File, error) {
   266  	return parse(modfile, filename, true)
   267  }
   268  
   269  // ParseLegacy parses the legacy version of the module file
   270  // that only supports the single field "module" and ignores all other
   271  // fields.
   272  func ParseLegacy(modfile []byte, filename string) (*File, error) {
   273  	ctx := cuecontext.New()
   274  	file, err := parseDataOnlyCUE(ctx, modfile, filename)
   275  	if err != nil {
   276  		return nil, errors.Wrapf(err, token.NoPos, "invalid module.cue file syntax")
   277  	}
   278  	// Unfortunately we need a new context. See the note inside [moduleSchemaDo].
   279  	v := ctx.BuildFile(file)
   280  	if err := v.Err(); err != nil {
   281  		return nil, errors.Wrapf(err, token.NoPos, "invalid module.cue file")
   282  	}
   283  	var f noDepsFile
   284  	if err := v.Decode(&f); err != nil {
   285  		return nil, newCUEError(err, filename)
   286  	}
   287  	return &File{
   288  		Module:              f.Module,
   289  		actualSchemaVersion: "v0.0.0",
   290  	}, nil
   291  }
   292  
   293  // ParseNonStrict is like Parse but allows some laxity in the parsing:
   294  //   - if a module path lacks a version, it's taken from the version.
   295  //   - if a non-canonical version is used, it will be canonicalized.
   296  //
   297  // The file name is used for error messages.
   298  func ParseNonStrict(modfile []byte, filename string) (*File, error) {
   299  	return parse(modfile, filename, false)
   300  }
   301  
   302  // FixLegacy converts a legacy module.cue file as parsed by [ParseLegacy]
   303  // into a format suitable for parsing with [Parse]. It adds a language.version
   304  // field and moves all unrecognized fields into custom.legacy.
   305  //
   306  // If there is no module field or it is empty, it is set to "test.example".
   307  //
   308  // If the file already parses OK with [ParseNonStrict], it returns the
   309  // result of that.
   310  func FixLegacy(modfile []byte, filename string) (*File, error) {
   311  	f, err := ParseNonStrict(modfile, filename)
   312  	if err == nil {
   313  		// It parses OK so it doesn't need fixing.
   314  		return f, nil
   315  	}
   316  	ctx := cuecontext.New()
   317  	file, err := parseDataOnlyCUE(ctx, modfile, filename)
   318  	if err != nil {
   319  		return nil, errors.Wrapf(err, token.NoPos, "invalid module.cue file syntax")
   320  	}
   321  	v := ctx.BuildFile(file)
   322  	if err := v.Validate(cue.Concrete(true)); err != nil {
   323  		return nil, errors.Wrapf(err, token.NoPos, "invalid module.cue file value")
   324  	}
   325  	var allFields map[string]any
   326  	if err := v.Decode(&allFields); err != nil {
   327  		return nil, err
   328  	}
   329  	mpath := "test.example"
   330  	if m, ok := allFields["module"]; ok {
   331  		if mpath1, ok := m.(string); ok && mpath1 != "" {
   332  			mpath = mpath1
   333  		} else if !ok {
   334  			return nil, fmt.Errorf("module field has unexpected type %T", m)
   335  		}
   336  		// TODO decide what to do if the module path isn't OK according to the new rules.
   337  	}
   338  	customLegacy := make(map[string]any)
   339  	for k, v := range allFields {
   340  		if k != "module" {
   341  			customLegacy[k] = v
   342  		}
   343  	}
   344  	var custom map[string]map[string]any
   345  	if len(customLegacy) > 0 {
   346  		custom = map[string]map[string]any{
   347  			"legacy": customLegacy,
   348  		}
   349  	}
   350  	f = &File{
   351  		Module: mpath,
   352  		Language: &Language{
   353  			// If there's a legacy module file, the CUE code
   354  			// is unlikely to be using new language features,
   355  			// so keep the language version fixed rather than
   356  			// using cueversion.LanguageVersion().
   357  			// See https://cuelang.org/issue/3222.
   358  			Version: "v0.9.0",
   359  		},
   360  		Custom: custom,
   361  	}
   362  	// Round-trip through [Parse] so that we get exactly the same
   363  	// result as a later parse of the same data will. This also
   364  	// adds a major version to the module path if needed.
   365  	data, err := f.Format()
   366  	if err != nil {
   367  		return nil, fmt.Errorf("cannot format fixed file: %v", err)
   368  	}
   369  	f, err = ParseNonStrict(data, "fixed-"+filename)
   370  	if err != nil {
   371  		return nil, fmt.Errorf("cannot parse resulting module file %q: %v", data, err)
   372  	}
   373  	return f, nil
   374  }
   375  
   376  func parse(modfile []byte, filename string, strict bool) (*File, error) {
   377  	// Unfortunately we need a new context. See the note inside [moduleSchemaDo].
   378  	ctx := cuecontext.New()
   379  	file, err := parseDataOnlyCUE(ctx, modfile, filename)
   380  	if err != nil {
   381  		return nil, errors.Wrapf(err, token.NoPos, "invalid module.cue file syntax")
   382  	}
   383  
   384  	v := ctx.BuildFile(file)
   385  	if err := v.Validate(cue.Concrete(true)); err != nil {
   386  		return nil, errors.Wrapf(err, token.NoPos, "invalid module.cue file value")
   387  	}
   388  	// First determine the declared version of the module file.
   389  	var base baseFileVersion
   390  	if err := v.Decode(&base); err != nil {
   391  		return nil, errors.Wrapf(err, token.NoPos, "cannot determine language version")
   392  	}
   393  	if base.Language.Version == "" {
   394  		return nil, ErrNoLanguageVersion
   395  	}
   396  	if !semver.IsValid(base.Language.Version) {
   397  		return nil, fmt.Errorf("language version %q in module.cue is not valid semantic version", base.Language.Version)
   398  	}
   399  	if mv, lv := base.Language.Version, cueversion.LanguageVersion(); semver.Compare(mv, lv) > 0 {
   400  		return nil, fmt.Errorf("language version %q declared in module.cue is too new for current language version %q", mv, lv)
   401  	}
   402  
   403  	mf, err := moduleSchemaDo(func(schemas *schemaInfo) (*File, error) {
   404  		// Now that we're happy we're within bounds, find the latest
   405  		// schema that applies to the declared version.
   406  		latest := ""
   407  		var latestSchema cue.Value
   408  		for vers, schema := range schemas.Versions {
   409  			if semver.Compare(vers, base.Language.Version) > 0 {
   410  				continue
   411  			}
   412  			if latest == "" || semver.Compare(vers, latest) > 0 {
   413  				latest = vers
   414  				latestSchema = schema
   415  			}
   416  		}
   417  		if latest == "" {
   418  			// Should never happen, because there should always
   419  			// be some applicable schema.
   420  			return nil, fmt.Errorf("cannot find schema suitable for reading module file with language version %q", base.Language.Version)
   421  		}
   422  		schema := latestSchema
   423  		v = v.Unify(lookup(schema, cue.Def("#File")))
   424  		if err := v.Validate(); err != nil {
   425  			return nil, newCUEError(err, filename)
   426  		}
   427  		if latest == "v0.0.0" {
   428  			// The chosen schema is the earliest schema which allowed
   429  			// all fields. We don't actually want a module.cue file with
   430  			// an old version to treat those fields as special, so don't try
   431  			// to decode into *File because that will do so.
   432  			// This mirrors the behavior of [ParseLegacy].
   433  			var f noDepsFile
   434  			if err := v.Decode(&f); err != nil {
   435  				return nil, newCUEError(err, filename)
   436  			}
   437  			return &File{
   438  				Module:              f.Module,
   439  				actualSchemaVersion: "v0.0.0",
   440  			}, nil
   441  		}
   442  		var mf File
   443  		if err := v.Decode(&mf); err != nil {
   444  			return nil, errors.Wrapf(err, token.NoPos, "internal error: cannot decode into modFile struct")
   445  		}
   446  		mf.actualSchemaVersion = latest
   447  		return &mf, nil
   448  	})
   449  	if err != nil {
   450  		return nil, err
   451  	}
   452  	mainPath, mainMajor, ok := module.SplitPathVersion(mf.Module)
   453  	if ok {
   454  		if semver.Major(mainMajor) != mainMajor {
   455  			return nil, fmt.Errorf("module path %s in %q should contain the major version only", mf.Module, filename)
   456  		}
   457  	} else if mainPath = mf.Module; mainPath != "" {
   458  		if err := module.CheckPathWithoutVersion(mainPath); err != nil {
   459  			return nil, fmt.Errorf("module path %q in %q is not valid: %v", mainPath, filename, err)
   460  		}
   461  		// There's no main module major version: default to v0.
   462  		mainMajor = "v0"
   463  	}
   464  	if mf.Language != nil {
   465  		vers := mf.Language.Version
   466  		if !semver.IsValid(vers) {
   467  			return nil, fmt.Errorf("language version %q in %s is not well formed", vers, filename)
   468  		}
   469  		if semver.Canonical(vers) != vers {
   470  			return nil, fmt.Errorf("language version %v in %s is not canonical", vers, filename)
   471  		}
   472  	}
   473  	var versions []module.Version
   474  	// The main module is always the default for its own major version.
   475  	defaultMajorVersions := map[string]string{
   476  		mainPath: mainMajor,
   477  	}
   478  	// Check that major versions match dependency versions.
   479  	for m, dep := range mf.Deps {
   480  		vers, err := module.NewVersion(m, dep.Version)
   481  		if err != nil {
   482  			return nil, fmt.Errorf("invalid module.cue file %s: cannot make version from module %q, version %q: %v", filename, m, dep.Version, err)
   483  		}
   484  		versions = append(versions, vers)
   485  		if strict && vers.Path() != m {
   486  			return nil, fmt.Errorf("invalid module.cue file %s: no major version in %q", filename, m)
   487  		}
   488  		if dep.Default {
   489  			mp := vers.BasePath()
   490  			if _, ok := defaultMajorVersions[mp]; ok {
   491  				return nil, fmt.Errorf("multiple default major versions found for %v", mp)
   492  			}
   493  			defaultMajorVersions[mp] = semver.Major(vers.Version())
   494  		}
   495  	}
   496  
   497  	if len(defaultMajorVersions) > 0 {
   498  		mf.defaultMajorVersions = defaultMajorVersions
   499  	}
   500  	mf.versions = versions[:len(versions):len(versions)]
   501  	module.Sort(mf.versions)
   502  	return mf, nil
   503  }
   504  
   505  // ErrNoLanguageVersion is returned by [Parse] and [ParseNonStrict]
   506  // when a cue.mod/module.cue file lacks the `language.version` field.
   507  var ErrNoLanguageVersion = fmt.Errorf("no language version declared in module.cue")
   508  
   509  func parseDataOnlyCUE(ctx *cue.Context, cueData []byte, filename string) (*ast.File, error) {
   510  	dec := encoding.NewDecoder(ctx, &build.File{
   511  		Filename:       filename,
   512  		Encoding:       build.CUE,
   513  		Interpretation: build.Auto,
   514  		Form:           build.Data,
   515  		Source:         cueData,
   516  	}, &encoding.Config{
   517  		Mode:      filetypes.Export,
   518  		AllErrors: true,
   519  	})
   520  	if err := dec.Err(); err != nil {
   521  		return nil, err
   522  	}
   523  	return dec.File(), nil
   524  }
   525  
   526  func newCUEError(err error, filename string) error {
   527  	ps := errors.Positions(err)
   528  	for _, p := range ps {
   529  		if errStr := findErrorComment(p); errStr != "" {
   530  			return fmt.Errorf("invalid module.cue file: %s", errStr)
   531  		}
   532  	}
   533  	// TODO we have more potential to improve error messages here.
   534  	return err
   535  }
   536  
   537  // findErrorComment finds an error comment in the form
   538  //
   539  //	//error: ...
   540  //
   541  // before the given position.
   542  // This works as a kind of poor-man's error primitive
   543  // so we can customize the error strings when verification
   544  // fails.
   545  func findErrorComment(p token.Pos) string {
   546  	if p.Filename() != schemaFile {
   547  		return ""
   548  	}
   549  	off := p.Offset()
   550  	source := moduleSchemaData
   551  	if off > len(source) {
   552  		return ""
   553  	}
   554  	source, _, ok := cutLast(source[:off], "\n")
   555  	if !ok {
   556  		return ""
   557  	}
   558  	_, errorLine, ok := cutLast(source, "\n")
   559  	if !ok {
   560  		return ""
   561  	}
   562  	errStr, ok := strings.CutPrefix(errorLine, "//error: ")
   563  	if !ok {
   564  		return ""
   565  	}
   566  	return errStr
   567  }
   568  
   569  func cutLast(s, sep string) (before, after string, found bool) {
   570  	if i := strings.LastIndex(s, sep); i >= 0 {
   571  		return s[:i], s[i+len(sep):], true
   572  	}
   573  	return "", s, false
   574  }
   575  
   576  // DepVersions returns the versions of all the modules depended on by the
   577  // file. The caller should not modify the returned slice.
   578  //
   579  // This always returns the same value, even if the contents
   580  // of f are changed. If f was not created with [Parse], it returns nil.
   581  func (f *File) DepVersions() []module.Version {
   582  	return slices.Clip(f.versions)
   583  }
   584  
   585  // DefaultMajorVersions returns a map from module base path
   586  // to the major version that's specified as the default for that module.
   587  // The caller should not modify the returned map.
   588  func (f *File) DefaultMajorVersions() map[string]string {
   589  	return f.defaultMajorVersions
   590  }