cuelang.org/go@v0.13.0/mod/modfile/modfile_test.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
    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/cuetest"
    26  	"cuelang.org/go/mod/module"
    27  )
    28  
    29  var parseTests = []struct {
    30  	testName             string
    31  	parse                func(modfile []byte, filename string) (*File, error)
    32  	data                 string
    33  	wantError            string
    34  	want                 *File
    35  	wantVersions         []module.Version
    36  	wantDefaults         map[string]string
    37  	wantModVersionForPkg map[string]string
    38  }{{
    39  	testName: "NoDeps",
    40  	parse:    Parse,
    41  	data: `
    42  module: "foo.com/bar@v0"
    43  language: version: "v0.8.0-alpha.0"
    44  `,
    45  	want: &File{
    46  		Module: "foo.com/bar@v0",
    47  		Language: &Language{
    48  			Version: "v0.8.0-alpha.0",
    49  		},
    50  	},
    51  	wantDefaults: map[string]string{
    52  		"foo.com/bar": "v0",
    53  	},
    54  	wantModVersionForPkg: map[string]string{
    55  		"foo.com/bar":              "foo.com/bar@v0",
    56  		"foo.com/bar@v0":           "foo.com/bar@v0",
    57  		"foo.com/bar/baz@v0":       "foo.com/bar@v0",
    58  		"foo.com/bar@v1":           "",
    59  		"foo.com/bar:hello":        "foo.com/bar@v0",
    60  		"foo.com/bar/baz:hello":    "foo.com/bar@v0",
    61  		"foo.com/bar/baz@v0:hello": "foo.com/bar@v0",
    62  	},
    63  }, {
    64  	testName: "WithDeps",
    65  	parse:    Parse,
    66  	data: `
    67  module: "foo.com/bar@v0"
    68  language: version: "v0.8.1"
    69  deps: "example.com@v1": {
    70  	default: true
    71  	v: "v1.2.3"
    72  }
    73  deps: "example.com/other@v1": v: "v1.9.10"
    74  deps: "example.com/other/more/nested@v2": {
    75  	v: "v2.9.20"
    76  	default: true
    77  }
    78  deps: "other.com/something@v0": v: "v0.2.3"
    79  `,
    80  	want: &File{
    81  		Language: &Language{
    82  			Version: "v0.8.1",
    83  		},
    84  		Module: "foo.com/bar@v0",
    85  		Deps: map[string]*Dep{
    86  			"example.com@v1": {
    87  				Default: true,
    88  				Version: "v1.2.3",
    89  			},
    90  			"other.com/something@v0": {
    91  				Version: "v0.2.3",
    92  			},
    93  			"example.com/other@v1": {
    94  				Version: "v1.9.10",
    95  			},
    96  			"example.com/other/more/nested@v2": {
    97  				Version: "v2.9.20",
    98  				Default: true,
    99  			},
   100  		},
   101  	},
   102  	wantVersions: parseVersions(
   103  		"example.com/other/more/nested@v2.9.20",
   104  		"example.com/other@v1.9.10",
   105  		"example.com@v1.2.3",
   106  		"other.com/something@v0.2.3",
   107  	),
   108  	wantDefaults: map[string]string{
   109  		"example.com/other/more/nested": "v2",
   110  		"foo.com/bar":                   "v0",
   111  		"example.com":                   "v1",
   112  	},
   113  	wantModVersionForPkg: map[string]string{
   114  		"example.com":                       "example.com@v1.2.3",
   115  		"example.com/x/y@v1":                "example.com@v1.2.3",
   116  		"example.com/x/y@v1:x":              "example.com@v1.2.3",
   117  		"example.com/other@v1":              "example.com/other@v1.9.10",
   118  		"example.com/other/p@v1":            "example.com/other@v1.9.10",
   119  		"example.com/other/more":            "example.com@v1.2.3",
   120  		"example.com/other/more@v1":         "example.com/other@v1.9.10",
   121  		"example.com/other/more/nested":     "example.com/other/more/nested@v2.9.20",
   122  		"example.com/other/more/nested/x:p": "example.com/other/more/nested@v2.9.20",
   123  	},
   124  }, {
   125  	testName: "WithSource",
   126  	parse:    Parse,
   127  	data: `
   128  module: "foo.com/bar@v0"
   129  language: version: "v0.9.0-alpha.0"
   130  source: kind: "git"
   131  `,
   132  	want: &File{
   133  		Language: &Language{
   134  			Version: "v0.9.0-alpha.0",
   135  		},
   136  		Module: "foo.com/bar@v0",
   137  		Source: &Source{
   138  			Kind: "git",
   139  		},
   140  	},
   141  	wantDefaults: map[string]string{
   142  		"foo.com/bar": "v0",
   143  	},
   144  }, {
   145  	testName: "WithExplicitSource",
   146  	parse:    Parse,
   147  	data: `
   148  module: "foo.com/bar@v0"
   149  language: version: "v0.9.0-alpha.0"
   150  source: kind: "self"
   151  `,
   152  	want: &File{
   153  		Language: &Language{
   154  			Version: "v0.9.0-alpha.0",
   155  		},
   156  		Module: "foo.com/bar@v0",
   157  		Source: &Source{
   158  			Kind: "self",
   159  		},
   160  	},
   161  	wantDefaults: map[string]string{
   162  		"foo.com/bar": "v0",
   163  	},
   164  }, {
   165  	testName: "WithUnknownSourceKind",
   166  	parse:    Parse,
   167  	data: `
   168  module: "foo.com/bar@v0"
   169  language: version: "v0.9.0-alpha.0"
   170  source: kind: "bad"
   171  `,
   172  	wantError: `source.kind: 2 errors in empty disjunction:(.|\n)+`,
   173  }, {
   174  	testName: "WithEarlierVersionAndSource",
   175  	parse:    Parse,
   176  	data: `
   177  module: "foo.com/bar@v0"
   178  language: version: "v0.8.6"
   179  source: kind: "git"
   180  `,
   181  	wantError: `invalid module.cue file: source field is not allowed at this language version; need at least v0.9.0-alpha.0`,
   182  }, {
   183  	testName: "AmbiguousDefaults",
   184  	parse:    Parse,
   185  	data: `
   186  module: "foo.com/bar@v0"
   187  language: version: "v0.8.0"
   188  deps: "example.com@v1": {
   189  	default: true
   190  	v: "v1.2.3"
   191  }
   192  deps: "example.com@v2": {
   193  	default: true
   194  	v: "v2.0.0"
   195  }
   196  `,
   197  	wantError: `multiple default major versions found for example.com`,
   198  }, {
   199  	testName: "AmbiguousDefaultsWithMainModule",
   200  	parse:    Parse,
   201  	data: `
   202  module: "foo.com/bar@v0"
   203  language: version: "v0.8.0"
   204  deps: "foo.com/bar@v1": {
   205  	default: true
   206  	v: "v1.2.3"
   207  }
   208  `,
   209  	wantError: `multiple default major versions found for foo.com/bar`,
   210  }, {
   211  	testName: "MisspelledLanguageVersionField",
   212  	parse:    Parse,
   213  	data: `
   214  module: "foo.com/bar@v0"
   215  langugage: version: "v0.4.3"
   216  `,
   217  	wantError: `no language version declared in module.cue`,
   218  }, {
   219  	testName: "MissingLanguageVersionField",
   220  	parse:    Parse,
   221  	data: `
   222  module: "foo.com/bar@v0"
   223  `,
   224  	wantError: `no language version declared in module.cue`,
   225  }, {
   226  	testName: "InvalidLanguageVersion",
   227  	parse:    Parse,
   228  	data: `
   229  language: version: "vblah"
   230  module: "foo.com/bar@v0"`,
   231  	wantError: `language version "vblah" in module.cue is not valid semantic version`,
   232  }, {
   233  	testName: "EmptyLanguageVersion",
   234  	parse:    Parse,
   235  	data: `
   236  language: {}
   237  module: "foo.com/bar@v0"`,
   238  	wantError: `no language version declared in module.cue`,
   239  }, {
   240  	testName: "NonCanonicalLanguageVersion",
   241  	parse:    Parse,
   242  	data: `
   243  module: "foo.com/bar@v0"
   244  language: version: "v0.8"
   245  `,
   246  	wantError: `language version v0.8 in module.cue is not canonical`,
   247  }, {
   248  	testName: "InvalidDepVersion",
   249  	parse:    Parse,
   250  	data: `
   251  module: "foo.com/bar@v1"
   252  language: version: "v0.8.0"
   253  deps: "example.com@v1": v: "1.2.3"
   254  `,
   255  	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`,
   256  }, {
   257  	testName: "NonCanonicalVersion",
   258  	parse:    Parse,
   259  	data: `
   260  module: "foo.com/bar@v1"
   261  language: version: "v0.8.0"
   262  deps: "example.com@v1": v: "v1.2"
   263  `,
   264  	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`,
   265  }, {
   266  	testName: "NonCanonicalModule",
   267  	parse:    Parse,
   268  	data: `
   269  module: "foo.com/bar@v0.1.2"
   270  language: version: "v0.8.0"
   271  `,
   272  	wantError: `module path foo.com/bar@v0.1.2 in "module.cue" should contain the major version only`,
   273  }, {
   274  	testName: "NonCanonicalDep",
   275  	parse:    Parse,
   276  	data: `
   277  module: "foo.com/bar@v1"
   278  language: version: "v0.8.0"
   279  deps: "example.com": v: "v1.2.3"
   280  `,
   281  	wantError: `invalid module.cue file module.cue: no major version in "example.com"`,
   282  }, {
   283  	testName: "MismatchedMajorVersion",
   284  	parse:    Parse,
   285  	data: `
   286  module: "foo.com/bar@v1"
   287  language: version: "v0.8.0"
   288  deps: "example.com@v1": v: "v0.1.2"
   289  `,
   290  	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\)`,
   291  }, {
   292  	testName: "NonStrictNoMajorVersions",
   293  	parse:    ParseNonStrict,
   294  	data: `
   295  module: "foo.com/bar"
   296  language: version: "v0.8.0"
   297  deps: "example.com": v: "v1.2.3"
   298  `,
   299  	want: &File{
   300  		Module:   "foo.com/bar",
   301  		Language: &Language{Version: "v0.8.0"},
   302  		Deps: map[string]*Dep{
   303  			"example.com": {
   304  				Version: "v1.2.3",
   305  			},
   306  		},
   307  	},
   308  	wantVersions: parseVersions("example.com@v1.2.3"),
   309  	wantDefaults: map[string]string{
   310  		"foo.com/bar": "v0",
   311  	},
   312  	wantModVersionForPkg: map[string]string{
   313  		"example.com":          "", // No default major version.
   314  		"example.com@v1":       "example.com@v1.2.3",
   315  		"example.com/x/y@v1":   "example.com@v1.2.3",
   316  		"example.com/x/y@v1:x": "example.com@v1.2.3",
   317  	},
   318  }, {
   319  	testName: "LegacyWithExtraFields",
   320  	parse:    ParseLegacy,
   321  	data: `
   322  module: "foo.com/bar"
   323  something: 4
   324  language: version: "xxx"
   325  `,
   326  	want: &File{
   327  		Module: "foo.com/bar",
   328  	},
   329  }, {
   330  	testName: "LegacyReferencesNotAllowed",
   331  	parse:    ParseLegacy,
   332  	data: `
   333  module: _foo
   334  _foo: "blah.example"
   335  `,
   336  	wantError: `invalid module.cue file syntax: references not allowed in data mode:
   337      module.cue:2:9`,
   338  }, {
   339  	testName: "LegacyNoModule",
   340  	parse:    ParseLegacy,
   341  	data:     "",
   342  	want:     &File{},
   343  }, {
   344  	testName: "LegacyEmptyModule",
   345  	parse:    ParseLegacy,
   346  	data:     `module: ""`,
   347  	want:     &File{},
   348  }, {
   349  	testName:  "NonLegacyEmptyModule",
   350  	parse:     Parse,
   351  	data:      `module: "", language: version: "v0.8.0"`,
   352  	wantError: `empty module path in "module.cue"`,
   353  }, {
   354  	testName: "ReferencesNotAllowed#1",
   355  	parse:    Parse,
   356  	data: `
   357  module: "foo.com/bar"
   358  _foo: "v0.9.0"
   359  language: version: _foo
   360  `,
   361  	wantError: `invalid module.cue file syntax: references not allowed in data mode:
   362      module.cue:4:20`,
   363  }, {
   364  	testName: "ReferencesNotAllowed#2",
   365  	parse:    Parse,
   366  	data: `
   367  module: "foo.com/bar"
   368  let foo = "v0.9.0"
   369  language: version: foo
   370  `,
   371  	wantError: `invalid module.cue file syntax: references not allowed in data mode:
   372      module.cue:3:1
   373  invalid module.cue file syntax: references not allowed in data mode:
   374      module.cue:4:20`,
   375  }, {
   376  	testName: "DefinitionsNotAllowed",
   377  	parse:    Parse,
   378  	data: `
   379  module: "foo.com/bar"
   380  #x: "v0.9.0"
   381  language: version: "v0.9.0"
   382  `,
   383  	wantError: `invalid module.cue file syntax: definitions not allowed in data mode:
   384      module.cue:3:1`,
   385  }, {
   386  	testName: "CustomData",
   387  	parse:    Parse,
   388  	data: `
   389  module: "foo.com/bar@v0"
   390  language: version: "v0.9.0"
   391  custom: "somewhere.com": foo: true
   392  `,
   393  	want: &File{
   394  		Module:   "foo.com/bar@v0",
   395  		Language: &Language{Version: "v0.9.0"},
   396  		Custom: map[string]map[string]any{
   397  			"somewhere.com": {
   398  				"foo": true,
   399  			},
   400  		},
   401  	},
   402  	wantDefaults: map[string]string{
   403  		"foo.com/bar": "v0",
   404  	},
   405  }, {
   406  	testName: "FixLegacyWithModulePath",
   407  	parse:    FixLegacy,
   408  	data: `
   409  module: "foo.com/bar"
   410  `,
   411  	want: &File{
   412  		Module:   "foo.com/bar",
   413  		Language: &Language{Version: "v0.9.0"},
   414  	},
   415  	wantDefaults: map[string]string{
   416  		"foo.com/bar": "v0",
   417  	},
   418  }, {
   419  	testName: "FixLegacyWithoutModulePath",
   420  	parse:    FixLegacy,
   421  	data: `
   422  `,
   423  	want: &File{
   424  		Module:   "test.example",
   425  		Language: &Language{Version: "v0.9.0"},
   426  	},
   427  	wantDefaults: map[string]string{
   428  		"test.example": "v0",
   429  	},
   430  }, {
   431  	testName: "FixLegacyWithEmptyModulePath",
   432  	parse:    FixLegacy,
   433  	data: `
   434  module: ""
   435  `,
   436  	want: &File{
   437  		Module:   "test.example",
   438  		Language: &Language{Version: "v0.9.0"},
   439  	},
   440  	wantDefaults: map[string]string{
   441  		"test.example": "v0",
   442  	},
   443  }, {
   444  	testName: "FixLegacyWithCustomFields",
   445  	parse:    FixLegacy,
   446  	data: `
   447  module: "foo.com"
   448  some: true
   449  other: field: 123
   450  `,
   451  	want: &File{
   452  		Module:   "foo.com",
   453  		Language: &Language{Version: "v0.9.0"},
   454  		Custom: map[string]map[string]any{
   455  			"legacy": {
   456  				"some":  true,
   457  				"other": map[string]any{"field": int64(123)},
   458  			},
   459  		},
   460  	},
   461  	wantDefaults: map[string]string{
   462  		"foo.com": "v0",
   463  	},
   464  }}
   465  
   466  func TestParse(t *testing.T) {
   467  	for _, test := range parseTests {
   468  		t.Run(test.testName, func(t *testing.T) {
   469  			f, err := test.parse([]byte(test.data), "module.cue")
   470  			if test.wantError != "" {
   471  				gotErr := strings.TrimSuffix(errors.Details(err, nil), "\n")
   472  				qt.Assert(t, qt.Matches(gotErr, test.wantError), qt.Commentf("error %v", err))
   473  				return
   474  			}
   475  			qt.Assert(t, qt.IsNil(err), qt.Commentf("details: %v", strings.TrimSuffix(errors.Details(err, nil), "\n")))
   476  			qt.Assert(t, fileEquals(f, test.want))
   477  			qt.Assert(t, qt.DeepEquals(f.DepVersions(), test.wantVersions))
   478  			qt.Assert(t, qt.DeepEquals(f.DefaultMajorVersions(), test.wantDefaults))
   479  			path, vers, ok := strings.Cut(f.Module, "@")
   480  			if ok {
   481  				qt.Assert(t, qt.Equals(f.QualifiedModule(), f.Module))
   482  				qt.Assert(t, qt.Equals(f.ModulePath(), path))
   483  				qt.Assert(t, qt.Equals(f.MajorVersion(), vers))
   484  			} else if f.Module == "" {
   485  				qt.Assert(t, qt.Equals(f.QualifiedModule(), ""))
   486  				qt.Assert(t, qt.Equals(f.ModulePath(), ""))
   487  				qt.Assert(t, qt.Equals(f.MajorVersion(), ""))
   488  			} else {
   489  				qt.Assert(t, qt.Equals(f.QualifiedModule(), f.Module+"@v0"))
   490  				qt.Assert(t, qt.Equals(f.ModulePath(), f.Module))
   491  				qt.Assert(t, qt.Equals(f.MajorVersion(), "v0"))
   492  			}
   493  			for p, m := range test.wantModVersionForPkg {
   494  				t.Run("package-"+p, func(t *testing.T) {
   495  					mv, ok := f.ModuleForImportPath(p)
   496  					if m == "" {
   497  						qt.Assert(t, qt.IsFalse(ok), qt.Commentf("got version %v", mv))
   498  						return
   499  					}
   500  					qt.Check(t, qt.IsTrue(ok))
   501  					qt.Check(t, qt.Equals(mv.String(), m))
   502  				})
   503  			}
   504  		})
   505  	}
   506  }
   507  
   508  func TestFormat(t *testing.T) {
   509  	type formatTest struct {
   510  		name      string
   511  		file      *File
   512  		wantError string
   513  		want      string
   514  	}
   515  	tests := []formatTest{{
   516  		name: "WithLanguage",
   517  		file: &File{
   518  			Language: &Language{
   519  				Version: "v0.8.0",
   520  			},
   521  			Module: "foo.com/bar@v0",
   522  			Deps: map[string]*Dep{
   523  				"example.com@v1": {
   524  					Version: "v1.2.3",
   525  				},
   526  				"other.com/something@v0": {
   527  					Version: "v0.2.3",
   528  				},
   529  			},
   530  		},
   531  		want: `module: "foo.com/bar@v0"
   532  language: {
   533  	version: "v0.8.0"
   534  }
   535  deps: {
   536  	"example.com@v1": {
   537  		v: "v1.2.3"
   538  	}
   539  	"other.com/something@v0": {
   540  		v: "v0.2.3"
   541  	}
   542  }
   543  `}, {
   544  		name: "WithoutLanguage",
   545  		file: &File{
   546  			Module: "foo.com/bar@v0",
   547  			Language: &Language{
   548  				Version: "v0.8.0",
   549  			},
   550  		},
   551  		want: `module: "foo.com/bar@v0"
   552  language: {
   553  	version: "v0.8.0"
   554  }
   555  `}, {
   556  		name: "WithVersionTooEarly",
   557  		file: &File{
   558  			Module: "foo.com/bar@v0",
   559  			Language: &Language{
   560  				Version: "v0.4.3",
   561  			},
   562  		},
   563  		wantError: `cannot parse result: cannot find schema suitable for reading module file with language version "v0.4.3"`,
   564  	}, {
   565  		name: "WithInvalidModuleVersion",
   566  		file: &File{
   567  			Module: "foo.com/bar@v0",
   568  			Language: &Language{
   569  				Version: "badversion--",
   570  			},
   571  		},
   572  		wantError: `cannot parse result: language version "badversion--" in module.cue is not valid semantic version`,
   573  	}, {
   574  		name: "WithNonNilEmptyDeps",
   575  		file: &File{
   576  			Module: "foo.com/bar@v0",
   577  			Language: &Language{
   578  				Version: "v0.8.0",
   579  			},
   580  			Deps: map[string]*Dep{},
   581  		},
   582  		want: `module: "foo.com/bar@v0"
   583  language: {
   584  	version: "v0.8.0"
   585  }
   586  `,
   587  	}}
   588  	cuetest.Run(t, tests, func(t *cuetest.T, test *formatTest) {
   589  		data, err := test.file.Format()
   590  		if test.wantError != "" {
   591  			qt.Assert(t, qt.ErrorMatches(err, test.wantError))
   592  			return
   593  		}
   594  		qt.Assert(t, qt.IsNil(err))
   595  		t.Equal(string(data), test.want)
   596  
   597  		// Check that it round-trips.
   598  		f, err := Parse(data, "")
   599  		qt.Assert(t, qt.IsNil(err))
   600  		qt.Assert(t, fileEquals(f, test.file))
   601  	})
   602  }
   603  
   604  func TestEarliestClosedSchemaVersion(t *testing.T) {
   605  	qt.Assert(t, qt.Equals(EarliestClosedSchemaVersion(), "v0.8.0-alpha.0"))
   606  }
   607  
   608  func parseVersions(vs ...string) []module.Version {
   609  	vvs := make([]module.Version, 0, len(vs))
   610  	for _, v := range vs {
   611  		vvs = append(vvs, module.MustParseVersion(v))
   612  	}
   613  	return vvs
   614  }
   615  
   616  // fileEquals returns a checker that checks whether two File instances
   617  // are equal.
   618  func fileEquals(got, want *File) qt.Checker {
   619  	return qt.CmpEquals(got, want,
   620  		cmpopts.IgnoreUnexported(File{}),
   621  		cmpopts.EquateEmpty(),
   622  	)
   623  }