Skip to content

Commit 551fe68

Browse files
committed
internal/mod/modfile: new package
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]>
1 parent 0e95843 commit 551fe68

File tree

4 files changed

+453
-0
lines changed

4 files changed

+453
-0
lines changed

internal/mod/modfile/modfile.go

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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
16+
17+
import (
18+
_ "embed"
19+
"fmt"
20+
"sync"
21+
22+
"golang.org/x/mod/semver"
23+
24+
"cuelang.org/go/cue"
25+
"cuelang.org/go/cue/cuecontext"
26+
"cuelang.org/go/cue/errors"
27+
"cuelang.org/go/cue/parser"
28+
"cuelang.org/go/cue/token"
29+
"cuelang.org/go/internal/mod/module"
30+
)
31+
32+
//go:embed schema.cue
33+
var moduleSchemaData []byte
34+
35+
type File struct {
36+
Module string `json:"module"`
37+
Language Language `json:"language"`
38+
Deps map[string]*Dep `json:"deps,omitempty"`
39+
versions []module.Version
40+
}
41+
42+
type Language struct {
43+
Version string `json:"version"`
44+
}
45+
46+
type Dep struct {
47+
Version string `json:"v"`
48+
Default bool `json:"default,omitempty"`
49+
}
50+
51+
type noDepsFile struct {
52+
Module string `json:"module"`
53+
}
54+
55+
var (
56+
moduleSchemaOnce sync.Once
57+
_moduleSchema cue.Value
58+
)
59+
60+
func moduleSchema() cue.Value {
61+
moduleSchemaOnce.Do(func() {
62+
ctx := cuecontext.New()
63+
schemav := ctx.CompileBytes(moduleSchemaData, cue.Filename("cuelang.org/go/internal/mod/modfile/schema.cue"))
64+
schemav = lookup(schemav, cue.Def("#File"))
65+
//schemav = schemav.Unify(lookup(schemav, cue.Hid("#Strict", "_")))
66+
if err := schemav.Validate(); err != nil {
67+
panic(fmt.Errorf("internal error: invalid CUE module.cue schema: %v", errors.Details(err, nil)))
68+
}
69+
_moduleSchema = schemav
70+
})
71+
return _moduleSchema
72+
}
73+
74+
func lookup(v cue.Value, sels ...cue.Selector) cue.Value {
75+
return v.LookupPath(cue.MakePath(sels...))
76+
}
77+
78+
// Parse verifies that the module file has correct syntax.
79+
// The file name is used for error messages.
80+
// All dependencies must be specified correctly: with major
81+
// versions in the module paths and canonical dependency
82+
// versions.
83+
func Parse(modfile []byte, filename string) (*File, error) {
84+
return parse(modfile, filename, true)
85+
}
86+
87+
// ParseLegacy parses the legacy version of the module file
88+
// that only supports the single field "module" and ignores all other
89+
// fields.
90+
func ParseLegacy(modfile []byte, filename string) (*File, error) {
91+
v := moduleSchema().Context().CompileBytes(modfile, cue.Filename(filename))
92+
if err := v.Err(); err != nil {
93+
return nil, errors.Wrapf(err, token.NoPos, "invalid module.cue file")
94+
}
95+
var f noDepsFile
96+
if err := v.Decode(&f); err != nil {
97+
return nil, newCUEError(err, filename)
98+
}
99+
return &File{
100+
Module: f.Module,
101+
}, nil
102+
}
103+
104+
// ParseNonStrict is like Parse but allows some laxity in the parsing:
105+
// - if a module path lacks a version, it's taken from the version.
106+
// - if a non-canonical version is used, it will be canonicalized.
107+
//
108+
// The file name is used for error messages.
109+
func ParseNonStrict(modfile []byte, filename string) (*File, error) {
110+
return parse(modfile, filename, false)
111+
}
112+
113+
func parse(modfile []byte, filename string, strict bool) (*File, error) {
114+
file, err := parser.ParseFile(filename, modfile)
115+
if err != nil {
116+
return nil, errors.Wrapf(err, token.NoPos, "invalid module.cue file syntax")
117+
}
118+
// TODO disallow non-data-mode CUE.
119+
120+
v := moduleSchema().Context().BuildFile(file)
121+
if err := v.Validate(cue.Concrete(true)); err != nil {
122+
return nil, errors.Wrapf(err, token.NoPos, "invalid module.cue file value")
123+
}
124+
v = v.Unify(moduleSchema())
125+
if err := v.Validate(); err != nil {
126+
return nil, newCUEError(err, filename)
127+
}
128+
var mf File
129+
if err := v.Decode(&mf); err != nil {
130+
return nil, errors.Wrapf(err, token.NoPos, "internal error: cannot decode into modFile struct")
131+
}
132+
if strict {
133+
_, v, ok := module.SplitPathVersion(mf.Module)
134+
if !ok {
135+
return nil, fmt.Errorf("module path %q in %s does not contain major version", mf.Module, filename)
136+
}
137+
if semver.Major(v) != v {
138+
return nil, fmt.Errorf("module path %s in %q should contain the major version only", mf.Module, filename)
139+
}
140+
}
141+
if v := mf.Language.Version; v != "" && !semver.IsValid(v) {
142+
return nil, fmt.Errorf("language version %q in %s is not well formed", v, filename)
143+
}
144+
var versions []module.Version
145+
// Check that major versions match dependency versions.
146+
for m, dep := range mf.Deps {
147+
v, err := module.NewVersion(m, dep.Version)
148+
if err != nil {
149+
return nil, fmt.Errorf("invalid module.cue file %s: cannot make version from module %q, version %q: %v", filename, m, dep.Version, err)
150+
}
151+
versions = append(versions, v)
152+
if strict && v.Path() != m {
153+
return nil, fmt.Errorf("invalid module.cue file %s: no major version in %q", filename, m)
154+
}
155+
}
156+
157+
mf.versions = versions[:len(versions):len(versions)]
158+
module.Sort(mf.versions)
159+
return &mf, nil
160+
}
161+
162+
func newCUEError(err error, filename string) error {
163+
// TODO we have some potential to improve error messages here.
164+
return err
165+
}
166+
167+
// DepVersions returns the versions of all the modules depended on by the
168+
// file. The caller should not modify the returned slice.
169+
func (f *File) DepVersions() []module.Version {
170+
return f.versions
171+
}

internal/mod/modfile/modfile_test.go

+139
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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
16+
17+
import (
18+
"strings"
19+
"testing"
20+
21+
"github.com/go-quicktest/qt"
22+
"github.com/google/go-cmp/cmp/cmpopts"
23+
24+
"cuelang.org/go/cue/errors"
25+
"cuelang.org/go/internal/mod/module"
26+
)
27+
28+
var tests = []struct {
29+
testName string
30+
data string
31+
wantError string
32+
want *File
33+
wantVersions []module.Version
34+
}{{
35+
testName: "NoDeps",
36+
data: `
37+
module: "foo.com/bar@v0"
38+
`,
39+
want: &File{
40+
Module: "foo.com/bar@v0",
41+
},
42+
}, {
43+
testName: "WithDeps",
44+
data: `
45+
language: version: "v0.4.3"
46+
module: "foo.com/bar@v0"
47+
deps: "example.com@v1": v: "v1.2.3"
48+
deps: "other.com/something@v0": v: "v0.2.3"
49+
`,
50+
want: &File{
51+
Language: Language{
52+
Version: "v0.4.3",
53+
},
54+
Module: "foo.com/bar@v0",
55+
Deps: map[string]*Dep{
56+
"example.com@v1": {
57+
Version: "v1.2.3",
58+
},
59+
"other.com/something@v0": {
60+
Version: "v0.2.3",
61+
},
62+
},
63+
},
64+
wantVersions: parseVersions("[email protected]", "other.com/[email protected]"),
65+
}, {
66+
testName: "MisspelledLanguageVersionField",
67+
data: `
68+
langugage: version: "v0.4.3"
69+
module: "foo.com/bar@v0"
70+
`,
71+
wantError: `langugage: field not allowed:
72+
cuelang.org/go/internal/mod/modfile/schema.cue:14:8
73+
cuelang.org/go/internal/mod/modfile/schema.cue:16:2
74+
module.cue:2:1`,
75+
}, {
76+
testName: "InvalidLanguageVersion",
77+
data: `
78+
language: version: "vblah"
79+
module: "foo.com/bar@v0"`,
80+
wantError: `language version "vblah" in module.cue is not well formed`,
81+
}, {
82+
testName: "InvalidDepVersion",
83+
data: `
84+
module: "foo.com/bar@v1"
85+
deps: "example.com@v1": v: "1.2.3"
86+
`,
87+
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`,
88+
}, {
89+
testName: "NonCanonicalVersion",
90+
data: `
91+
module: "foo.com/bar@v1"
92+
deps: "example.com@v1": v: "v1.2"
93+
`,
94+
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`,
95+
}, {
96+
testName: "NonCanonicalModule",
97+
data: `
98+
module: "foo.com/bar"
99+
`,
100+
wantError: `module path "foo.com/bar" in module.cue does not contain major version`,
101+
}, {
102+
testName: "NonCanonicalDep",
103+
data: `
104+
module: "foo.com/bar@v1"
105+
deps: "example.com": v: "v1.2.3"
106+
`,
107+
wantError: `invalid module.cue file module.cue: no major version in "example.com"`,
108+
}, {
109+
testName: "MismatchedMajorVersion",
110+
data: `
111+
module: "foo.com/bar@v1"
112+
deps: "example.com@v1": v: "v0.1.2"
113+
`,
114+
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\)`,
115+
}}
116+
117+
func TestParse(t *testing.T) {
118+
for _, test := range tests {
119+
t.Run(test.testName, func(t *testing.T) {
120+
f, err := Parse([]byte(test.data), "module.cue")
121+
if test.wantError != "" {
122+
gotErr := strings.TrimSuffix(errors.Details(err, nil), "\n")
123+
qt.Assert(t, qt.Matches(gotErr, test.wantError))
124+
return
125+
}
126+
qt.Assert(t, qt.IsNil(err), qt.Commentf("details: %v", errors.Details(err, nil)))
127+
qt.Assert(t, qt.CmpEquals(f, test.want, cmpopts.IgnoreUnexported(File{})))
128+
qt.Assert(t, qt.DeepEquals(f.DepVersions(), test.wantVersions))
129+
})
130+
}
131+
}
132+
133+
func parseVersions(vs ...string) []module.Version {
134+
vvs := make([]module.Version, 0, len(vs))
135+
for _, v := range vs {
136+
vvs = append(vvs, module.MustParseVersion(v))
137+
}
138+
return vvs
139+
}

0 commit comments

Comments
 (0)