1// Copyright 2018 The Go Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4
5// Package module defines the [Version] type along with support code.
6//
7// WARNING: THIS PACKAGE IS EXPERIMENTAL.
8// ITS API MAY CHANGE AT ANY TIME.
9//
10// The [Version] type holds a pair of module path and version.
11// The module path conforms to the checks implemented by [Check].
12//
13// # Escaped Paths
14//
15// Module versions appear as substrings of file system paths (as stored by
16// the modcache package).
17// In general we cannot rely on file systems to be case-sensitive. Although
18// module paths cannot currently contain upper case characters because
19// OCI registries forbid that, versions can. That
20// is, we cannot rely on the file system to keep foo.com/v@v1.0.0-PRE and
21// foo.com/v@v1.0.0-PRE separate. Windows and macOS don't. Instead, we must
22// never require two different casings of a file path.
23//
24// One possibility would be to make the escaped form be the lowercase
25// hexadecimal encoding of the actual path bytes. This would avoid ever
26// needing different casings of a file path, but it would be fairly illegible
27// to most programmers when those paths appeared in the file system
28// (including in file paths in compiler errors and stack traces)
29// in web server logs, and so on. Instead, we want a safe escaped form that
30// leaves most paths unaltered.
31//
32// The safe escaped form is to replace every uppercase letter
33// with an exclamation mark followed by the letter's lowercase equivalent.
34//
35// For example,
36//
37// foo.com/v@v1.0.0-PRE -> foo.com/v@v1.0.0-!p!r!e
38//
39// Versions that avoid upper-case letters are left unchanged.
40// Note that because import paths are ASCII-only and avoid various
41// problematic punctuation (like : < and >), the escaped form is also ASCII-only
42// and avoids the same problematic punctuation.
43//
44// Neither versions nor module paths allow exclamation marks, so there is no
45// need to define how to escape a literal !.
46//
47// # Unicode Restrictions
48//
49// Today, paths are disallowed from using Unicode.
50//
51// Although paths are currently disallowed from using Unicode,
52// we would like at some point to allow Unicode letters as well, to assume that
53// file systems and URLs are Unicode-safe (storing UTF-8), and apply
54// the !-for-uppercase convention for escaping them in the file system.
55// But there are at least two subtle considerations.
56//
57// First, note that not all case-fold equivalent distinct runes
58// form an upper/lower pair.
59// For example, U+004B ('K'), U+006B ('k'), and U+212A ('K' for Kelvin)
60// are three distinct runes that case-fold to each other.
61// When we do add Unicode letters, we must not assume that upper/lower
62// are the only case-equivalent pairs.
63// Perhaps the Kelvin symbol would be disallowed entirely, for example.
64// Or perhaps it would escape as "!!k", or perhaps as "(212A)".
65//
66// Second, it would be nice to allow Unicode marks as well as letters,
67// but marks include combining marks, and then we must deal not
68// only with case folding but also normalization: both U+00E9 ('é')
69// and U+0065 U+0301 ('e' followed by combining acute accent)
70// look the same on the page and are treated by some file systems
71// as the same path. If we do allow Unicode marks in paths, there
72// must be some kind of normalization to allow only one canonical
73// encoding of any character used in an import path.
74package module
75
76// IMPORTANT NOTE
77//
78// This file essentially defines the set of valid import paths for the cue command.
79// There are many subtle considerations, including Unicode ambiguity,
80// security, network, and file system representations.
81
82import (
83 "cmp"
84 "fmt"
85 "slices"
86 "strings"
87
88 "cuelang.org/go/cue/ast"
89 "cuelang.org/go/internal/mod/semver"
90)
91
92// A Version (for clients, a module.Version) is defined by a module path and version pair.
93// These are stored in their plain (unescaped) form.
94// This type is comparable.
95type Version struct {
96 path string
97 version string
98}
99
100// Path returns the module path part of the Version,
101// which always includes the major version suffix
102// unless a module path, like "github.com/foo/bar@v0".
103// Note that in general the path should include the major version suffix
104// even though it's implied from the version. The Canonical
105// method can be used to add the major version suffix if not present.
106// The BasePath method can be used to obtain the path without
107// the suffix.
108func (m Version) Path() string {
109 return m.path
110}
111
112// Equal reports whether m is equal to m1.
113func (m Version) Equal(m1 Version) bool {
114 return m.path == m1.path && m.version == m1.version
115}
116
117func (m Version) Compare(m1 Version) int {
118 if c := cmp.Compare(m.path, m1.path); c != 0 {
119 return c
120 }
121 // To help go.sum formatting, allow version/file.
122 // Compare semver prefix by semver rules,
123 // file by string order.
124 va, fa, _ := strings.Cut(m.version, "/")
125 vb, fb, _ := strings.Cut(m1.version, "/")
126 if c := semver.Compare(va, vb); c != 0 {
127 return c
128 }
129 return cmp.Compare(fa, fb)
130}
131
132// BasePath returns the path part of m without its major version suffix.
133func (m Version) BasePath() string {
134 if m.IsLocal() {
135 return m.path
136 }
137 basePath, _, ok := ast.SplitPackageVersion(m.path)
138 if !ok {
139 panic(fmt.Errorf("broken invariant: failed to split version in %q", m.path))
140 }
141 return basePath
142}
143
144// Version returns the version part of m. This is either
145// a canonical semver version or "none" or the empty string.
146func (m Version) Version() string {
147 return m.version
148}
149
150// IsValid reports whether m is non-zero.
151func (m Version) IsValid() bool {
152 return m.path != ""
153}
154
155// IsCanonical reports whether m is valid and has a canonical
156// semver version.
157func (m Version) IsCanonical() bool {
158 return m.IsValid() && m.version != "" && m.version != "none"
159}
160
161func (m Version) IsLocal() bool {
162 return m.path == "local"
163}
164
165// String returns the string form of the Version:
166// (Path@Version, or just Path if Version is empty).
167func (m Version) String() string {
168 if m.version == "" {
169 return m.path
170 }
171 return m.BasePath() + "@" + m.version
172}
173
174func MustParseVersion(s string) Version {
175 v, err := ParseVersion(s)
176 if err != nil {
177 panic(err)
178 }
179 return v
180}
181
182// ParseVersion parses a $module@$version
183// string into a Version.
184// The version must be canonical (i.e. it can't be
185// just a major version).
186func ParseVersion(s string) (Version, error) {
187 basePath, vers, ok := ast.SplitPackageVersion(s)
188 if !ok {
189 return Version{}, fmt.Errorf("invalid module path@version %q", s)
190 }
191 if semver.Canonical(vers) != vers {
192 return Version{}, fmt.Errorf("module version in %q is not canonical", s)
193 }
194 return Version{basePath + "@" + semver.Major(vers), vers}, nil
195}
196
197func MustNewVersion(path string, version string) Version {
198 v, err := NewVersion(path, version)
199 if err != nil {
200 panic(err)
201 }
202 return v
203}
204
205// NewVersion forms a Version from the given path and version.
206// The version must be canonical, empty or "none".
207// If the path doesn't have a major version suffix, one will be added
208// if the version isn't empty; if the version is empty, it's an error.
209//
210// As a special case, the path "local" is used to mean all packages
211// held in the gen, pkg and usr directories.
212func NewVersion(path string, version string) (Version, error) {
213 switch {
214 case path == "local":
215 if version != "" {
216 return Version{}, fmt.Errorf("module 'local' cannot have version")
217 }
218 case version != "" && version != "none":
219 if !semver.IsValid(version) {
220 return Version{}, fmt.Errorf("version %q (of module %q) is not well formed", version, path)
221 }
222 if semver.Canonical(version) != version {
223 return Version{}, fmt.Errorf("version %q (of module %q) is not canonical", version, path)
224 }
225 maj := semver.Major(version)
226 _, vmaj, ok := ast.SplitPackageVersion(path)
227 if ok && maj != vmaj {
228 return Version{}, fmt.Errorf("mismatched major version suffix in %q (version %v)", path, version)
229 }
230 if !ok {
231 fullPath := path + "@" + maj
232 if _, _, ok := ast.SplitPackageVersion(fullPath); !ok {
233 return Version{}, fmt.Errorf("cannot form version path from %q, version %v", path, version)
234 }
235 path = fullPath
236 }
237 default:
238 base, _, ok := ast.SplitPackageVersion(path)
239 if !ok {
240 return Version{}, fmt.Errorf("path %q has no major version", path)
241 }
242 if base == "local" {
243 return Version{}, fmt.Errorf("module 'local' cannot have version")
244 }
245 }
246 if version == "" {
247 if err := CheckPath(path); err != nil {
248 return Version{}, err
249 }
250 } else {
251 if err := Check(path, version); err != nil {
252 return Version{}, err
253 }
254 }
255 return Version{
256 path: path,
257 version: version,
258 }, nil
259}
260
261// Sort sorts the list by Path, breaking ties by comparing Version fields.
262// The Version fields are interpreted as semantic versions (using semver.Compare)
263// optionally followed by a tie-breaking suffix introduced by a slash character,
264// like in "v0.0.1/module.cue".
265//
266// Deprecated: use [slices.SortFunc] with [Version.Compare].
267//
268//go:fix inline
269func Sort(list []Version) {
270 slices.SortFunc(list, Version.Compare)
271}