Skip to content

Commit

Permalink
internal/mod/modfile: new package
Browse files Browse the repository at this point in the history
This provides functionality for parsing a module.cue
file into a Go struct. Eventually it will provide
support for writing such a file too.

This is reminiscent of golang.org/x/mod/modfile
but there's no shared code, so we don't even try
to adapt that code.

Note: we use sync.Once to parse the module file schema
so that the stats remain the same in the cmd/cue tests.

Note: experimental feature.

For #2330.

Signed-off-by: Roger Peppe <[email protected]>
Change-Id: I806585943b34e54db2d0b6d0653efefddd24213e
Reviewed-on: https://review.gerrithub.io/c/cue-lang/cue/+/1168707
Reviewed-by: Paul Jolly <[email protected]>
TryBot-Result: CUEcueckoo <[email protected]>
Unity-Result: CUE porcuepine <[email protected]>
  • Loading branch information
rogpeppe committed Sep 13, 2023
1 parent 0e95843 commit 551fe68
Show file tree
Hide file tree
Showing 4 changed files with 453 additions and 0 deletions.
171 changes: 171 additions & 0 deletions internal/mod/modfile/modfile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright 2023 CUE Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package modfile

import (
_ "embed"
"fmt"
"sync"

"golang.org/x/mod/semver"

"cuelang.org/go/cue"
"cuelang.org/go/cue/cuecontext"
"cuelang.org/go/cue/errors"
"cuelang.org/go/cue/parser"
"cuelang.org/go/cue/token"
"cuelang.org/go/internal/mod/module"
)

//go:embed schema.cue
var moduleSchemaData []byte

type File struct {
Module string `json:"module"`
Language Language `json:"language"`
Deps map[string]*Dep `json:"deps,omitempty"`
versions []module.Version
}

type Language struct {
Version string `json:"version"`
}

type Dep struct {
Version string `json:"v"`
Default bool `json:"default,omitempty"`
}

type noDepsFile struct {
Module string `json:"module"`
}

var (
moduleSchemaOnce sync.Once
_moduleSchema cue.Value
)

func moduleSchema() cue.Value {
moduleSchemaOnce.Do(func() {
ctx := cuecontext.New()
schemav := ctx.CompileBytes(moduleSchemaData, cue.Filename("cuelang.org/go/internal/mod/modfile/schema.cue"))
schemav = lookup(schemav, cue.Def("#File"))
//schemav = schemav.Unify(lookup(schemav, cue.Hid("#Strict", "_")))
if err := schemav.Validate(); err != nil {
panic(fmt.Errorf("internal error: invalid CUE module.cue schema: %v", errors.Details(err, nil)))
}
_moduleSchema = schemav
})
return _moduleSchema
}

func lookup(v cue.Value, sels ...cue.Selector) cue.Value {
return v.LookupPath(cue.MakePath(sels...))
}

// 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
// versions in the module paths and canonical dependency
// versions.
func Parse(modfile []byte, filename string) (*File, error) {
return parse(modfile, filename, true)
}

// ParseLegacy parses the legacy version of the module file
// that only supports the single field "module" and ignores all other
// fields.
func ParseLegacy(modfile []byte, filename string) (*File, error) {
v := moduleSchema().Context().CompileBytes(modfile, cue.Filename(filename))
if err := v.Err(); err != nil {
return nil, errors.Wrapf(err, token.NoPos, "invalid module.cue file")
}
var f noDepsFile
if err := v.Decode(&f); err != nil {
return nil, newCUEError(err, filename)
}
return &File{
Module: f.Module,
}, nil
}

// ParseNonStrict is like Parse but allows some laxity in the parsing:
// - if a module path lacks a version, it's taken from the version.
// - if a non-canonical version is used, it will be canonicalized.
//
// The file name is used for error messages.
func ParseNonStrict(modfile []byte, filename string) (*File, error) {
return parse(modfile, filename, false)
}

func parse(modfile []byte, filename string, strict bool) (*File, error) {
file, err := parser.ParseFile(filename, modfile)
if err != nil {
return nil, errors.Wrapf(err, token.NoPos, "invalid module.cue file syntax")
}
// TODO disallow non-data-mode CUE.

v := moduleSchema().Context().BuildFile(file)
if err := v.Validate(cue.Concrete(true)); err != nil {
return nil, errors.Wrapf(err, token.NoPos, "invalid module.cue file value")
}
v = v.Unify(moduleSchema())
if err := v.Validate(); err != nil {
return nil, newCUEError(err, filename)
}
var mf File
if err := v.Decode(&mf); err != nil {
return nil, errors.Wrapf(err, token.NoPos, "internal error: cannot decode into modFile struct")
}
if strict {
_, v, ok := module.SplitPathVersion(mf.Module)
if !ok {
return nil, fmt.Errorf("module path %q in %s does not contain major version", mf.Module, filename)
}
if semver.Major(v) != v {
return nil, fmt.Errorf("module path %s in %q should contain the major version only", mf.Module, filename)
}
}
if v := mf.Language.Version; v != "" && !semver.IsValid(v) {
return nil, fmt.Errorf("language version %q in %s is not well formed", v, filename)
}
var versions []module.Version
// Check that major versions match dependency versions.
for m, dep := range mf.Deps {
v, err := module.NewVersion(m, dep.Version)
if err != nil {
return nil, fmt.Errorf("invalid module.cue file %s: cannot make version from module %q, version %q: %v", filename, m, dep.Version, err)
}
versions = append(versions, v)
if strict && v.Path() != m {
return nil, fmt.Errorf("invalid module.cue file %s: no major version in %q", filename, m)
}
}

mf.versions = versions[:len(versions):len(versions)]
module.Sort(mf.versions)
return &mf, nil
}

func newCUEError(err error, filename string) error {
// TODO we have some potential to improve error messages here.
return err
}

// DepVersions returns the versions of all the modules depended on by the
// file. The caller should not modify the returned slice.
func (f *File) DepVersions() []module.Version {
return f.versions
}
139 changes: 139 additions & 0 deletions internal/mod/modfile/modfile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright 2023 CUE Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package modfile

import (
"strings"
"testing"

"github.com/go-quicktest/qt"
"github.com/google/go-cmp/cmp/cmpopts"

"cuelang.org/go/cue/errors"
"cuelang.org/go/internal/mod/module"
)

var tests = []struct {
testName string
data string
wantError string
want *File
wantVersions []module.Version
}{{
testName: "NoDeps",
data: `
module: "foo.com/bar@v0"
`,
want: &File{
Module: "foo.com/bar@v0",
},
}, {
testName: "WithDeps",
data: `
language: version: "v0.4.3"
module: "foo.com/bar@v0"
deps: "example.com@v1": v: "v1.2.3"
deps: "other.com/something@v0": v: "v0.2.3"
`,
want: &File{
Language: Language{
Version: "v0.4.3",
},
Module: "foo.com/bar@v0",
Deps: map[string]*Dep{
"example.com@v1": {
Version: "v1.2.3",
},
"other.com/something@v0": {
Version: "v0.2.3",
},
},
},
wantVersions: parseVersions("[email protected]", "other.com/[email protected]"),
}, {
testName: "MisspelledLanguageVersionField",
data: `
langugage: version: "v0.4.3"
module: "foo.com/bar@v0"
`,
wantError: `langugage: field not allowed:
cuelang.org/go/internal/mod/modfile/schema.cue:14:8
cuelang.org/go/internal/mod/modfile/schema.cue:16:2
module.cue:2:1`,
}, {
testName: "InvalidLanguageVersion",
data: `
language: version: "vblah"
module: "foo.com/bar@v0"`,
wantError: `language version "vblah" in module.cue is not well formed`,
}, {
testName: "InvalidDepVersion",
data: `
module: "foo.com/bar@v1"
deps: "example.com@v1": v: "1.2.3"
`,
wantError: `invalid module.cue file module.cue: cannot make version from module "example.com@v1", version "1.2.3": version "1.2.3" \(of module "example.com@v1"\) is not well formed`,
}, {
testName: "NonCanonicalVersion",
data: `
module: "foo.com/bar@v1"
deps: "example.com@v1": v: "v1.2"
`,
wantError: `invalid module.cue file module.cue: cannot make version from module "example.com@v1", version "v1.2": version "v1.2" \(of module "example.com@v1"\) is not canonical`,
}, {
testName: "NonCanonicalModule",
data: `
module: "foo.com/bar"
`,
wantError: `module path "foo.com/bar" in module.cue does not contain major version`,
}, {
testName: "NonCanonicalDep",
data: `
module: "foo.com/bar@v1"
deps: "example.com": v: "v1.2.3"
`,
wantError: `invalid module.cue file module.cue: no major version in "example.com"`,
}, {
testName: "MismatchedMajorVersion",
data: `
module: "foo.com/bar@v1"
deps: "example.com@v1": v: "v0.1.2"
`,
wantError: `invalid module.cue file module.cue: cannot make version from module "example.com@v1", version "v0.1.2": mismatched major version suffix in "example.com@v1" \(version v0.1.2\)`,
}}

func TestParse(t *testing.T) {
for _, test := range tests {
t.Run(test.testName, func(t *testing.T) {
f, err := Parse([]byte(test.data), "module.cue")
if test.wantError != "" {
gotErr := strings.TrimSuffix(errors.Details(err, nil), "\n")
qt.Assert(t, qt.Matches(gotErr, test.wantError))
return
}
qt.Assert(t, qt.IsNil(err), qt.Commentf("details: %v", errors.Details(err, nil)))
qt.Assert(t, qt.CmpEquals(f, test.want, cmpopts.IgnoreUnexported(File{})))
qt.Assert(t, qt.DeepEquals(f.DepVersions(), test.wantVersions))
})
}
}

func parseVersions(vs ...string) []module.Version {
vvs := make([]module.Version, 0, len(vs))
for _, v := range vs {
vvs = append(vvs, module.MustParseVersion(v))
}
return vvs
}
Loading

0 comments on commit 551fe68

Please sign in to comment.