Skip to content

Commit

Permalink
cmd/cue: more resilient mod init version
Browse files Browse the repository at this point in the history
Before now, if there wasn't an available version,
`cue mod init` would create a module file with
no language version. However as the language version
field is now mandatory, this isn't a good approach.

Instead, we sanity check that the version is at least
the minimum supported version and use the fallback
version otherwise.

Technically we could compare with the fallback version
instead, because the actual version should always be at
least that, but tests often use a fixed version that's
not kept up to date, so this approach seems a bit better.

Signed-off-by: Roger Peppe <rogpeppe@gmail.com>
Change-Id: I732c94467285a2c5d502b67c4ba0616e6bb1a53b
Reviewed-on: https://review.gerrithub.io/c/cue-lang/cue/+/1193331
TryBot-Result: CUEcueckoo <cueckoo@cuelang.org>
Reviewed-by: Daniel Martí <mvdan@mvdan.cc>
Unity-Result: CUE porcuepine <cue.porcuepine@gmail.com>
  • Loading branch information
rogpeppe committed Apr 19, 2024
1 parent 7f3e9cf commit a1dec7d
Show file tree
Hide file tree
Showing 5 changed files with 83 additions and 36 deletions.
37 changes: 28 additions & 9 deletions cmd/cue/cmd/mod.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ import (
"strings"

"github.com/spf13/cobra"
gomodule "golang.org/x/mod/module"
"golang.org/x/mod/semver"

"cuelang.org/go/internal/cueexperiment"
"cuelang.org/go/mod/modfile"
"cuelang.org/go/mod/module"
gomodule "golang.org/x/mod/module"
)

func newModCmd(c *Command) *cobra.Command {
Expand Down Expand Up @@ -119,10 +120,14 @@ func runModInit(cmd *Command, args []string) (err error) {
mf := &modfile.File{
Module: modulePath,
}
if vers := versionForModFile(); vers != "" {
mf.Language = &modfile.Language{
Version: vers,
}
vers := versionForModFile()
if vers == "" {
// Shouldn't happen because we should use the
// fallback version if we can't the version otherwise.
return fmt.Errorf("cannot determine language version for module")
}
mf.Language = &modfile.Language{
Version: vers,
}

err = os.Mkdir(mod, 0755)
Expand Down Expand Up @@ -150,13 +155,27 @@ func runModInit(cmd *Command, args []string) (err error) {

func versionForModFile() string {
version := cueVersion()
earliestPossibleVersion := modfile.EarliestClosedSchemaVersion()
if semver.Compare(version, earliestPossibleVersion) < 0 {
// The reported version is earlier than it should be,
// which can occur for some pseudo versions, or
// potentially the cue command has been forked and
// published under an independent version numbering.
//
// In this case, we use the latest known schema version
// as the best guess as to a version that actually
// reflects the capabilities of the module file.
version = modfile.LatestKnownSchemaVersion()
}
if gomodule.IsPseudoVersion(version) {
// If we have a version like v0.7.1-0.20240130142347-7855e15cb701
// we want it to turn into the base version (v0.7.0 in that example).
// If there's no base version (e.g. v0.0.0-...) then PseudoVersionBase
// will return the empty string, which is exactly what we want
// because we don't want to put v0.0.0 in a module.cue file.
version, _ = gomodule.PseudoVersionBase(version)
// Subject the resulting base version to the same sanity check
// as above.
pv, _ := gomodule.PseudoVersionBase(version)
if pv != "" && semver.Compare(pv, earliestPossibleVersion) >= 0 {
version = pv
}
}
return version
}
21 changes: 15 additions & 6 deletions cmd/cue/cmd/testdata/script/modinit_without_version.txtar
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
# Check that cue mod init fails when it lacks any version
# information at all.
# Check that cue mod init uses the latest schema
# version when it lacks any version information at all.
# A zero pseudo-version is one such case, as there are no semver numbers.
env CUE_EXPERIMENT=modules
env CUE_VERSION_OVERRIDE=v0.0.0-00010101000000-000000000000
! exec cue mod init foo.example
cmp stderr want-stderr
exec cue mod init foo.example
cmp cue.mod/module.cue want-module

-- want-stderr --
cannot round-trip module file: no language version declared in module.cue

# cue mod tidy should be a no-op after cue mod init
env CUE_CACHE_DIR=$WORK/.tmp/cache
exec cue mod tidy
cmp cue.mod/module.cue want-module

-- want-module --
module: "foo.example@v0"
language: {
version: "v0.9.0-alpha.0"
}
36 changes: 30 additions & 6 deletions mod/modfile/modfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func (f *File) Format() ([]byte, error) {
// it's almost certainly a bogus version because all versions
// we care about fail when there are unknown fields, but the
// original schema allowed all fields.
return nil, fmt.Errorf("language version %v is too early for module.cue (need at least %v)", f.Language.Version, earliestClosedSchemaVersion())
return nil, fmt.Errorf("language version %v is too early for module.cue (need at least %v)", f.Language.Version, EarliestClosedSchemaVersion())
}
return data, err
}
Expand Down Expand Up @@ -160,13 +160,37 @@ func lookup(v cue.Value, sels ...cue.Selector) cue.Value {
return v.LookupPath(cue.MakePath(sels...))
}

func earliestClosedSchemaVersion() string {
v, _ := moduleSchemaDo(func(ctx *cue.Context, info *schemaInfo) (string, error) {
return info.EarliestClosedSchemaVersion, nil
})
return v
// EarliestClosedSchemaVersion returns the earliest module.cue schema version
// that excludes unknown fields. Any version declared in a module.cue file
// should be at least this, because that's when we added the language.version
// field itself.
func EarliestClosedSchemaVersion() string {
return schemaVersionLimits()[0]
}

// LatestKnownSchemaVersion returns the language version
// associated with the most recent known schema.
func LatestKnownSchemaVersion() string {
return schemaVersionLimits()[1]
}

var schemaVersionLimits = sync.OnceValue(func() [2]string {
limits, _ := moduleSchemaDo(func(_ *cue.Context, info *schemaInfo) ([2]string, error) {
earliest := ""
latest := ""
for v := range info.Versions {
if earliest == "" || semver.Compare(v, earliest) < 0 {
earliest = v
}
if latest == "" || semver.Compare(v, latest) > 0 {
latest = v
}
}
return [2]string{earliest, latest}, nil
})
return limits
})

// Parse verifies that the module file has correct syntax.
// The file name is used for error messages.
// All dependencies must be specified correctly: with major
Expand Down
10 changes: 9 additions & 1 deletion mod/modfile/modfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ language: {
Version: "v0.4.3",
},
},
wantError: `language version v0.4.3 is too early for module.cue \(need at least v0.8.0\)`,
wantError: `cannot round-trip module file: cannot find schema suitable for reading module file with language version "v0.4.3"`,
}, {
name: "WithInvalidModuleVersion",
file: &File{
Expand Down Expand Up @@ -396,6 +396,14 @@ language: {
})
}

func TestEarliestClosedSchemaVersion(t *testing.T) {
qt.Assert(t, qt.Equals(EarliestClosedSchemaVersion(), "v0.8.0"))
}

func TestLatestKnownSchemaVersion(t *testing.T) {
qt.Assert(t, qt.Equals(LatestKnownSchemaVersion(), "v0.9.0-alpha.0"))
}

func parseVersions(vs ...string) []module.Version {
vvs := make([]module.Version, 0, len(vs))
for _, v := range vs {
Expand Down
15 changes: 1 addition & 14 deletions mod/modfile/schema.cue
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,7 @@ versions: [string]: {
#Strict!: _
}

versions: "v0.0.0": {
// Historically all fields were allowed.
#File: {
module?: string
...
}
#Strict: #File
}

// earliestClosedSchemaVersion holds the earliest module.cue schema version
// that excludes unknown fields.
earliestClosedSchemaVersion: "v0.8.0"

versions: (earliestClosedSchemaVersion): {
versions: "v0.8.0": {
// Define this version in terms of the later versions
// rather than the other way around, so that
// the latest version is clearest.
Expand Down

0 comments on commit a1dec7d

Please sign in to comment.