Skip to content

Commit deea2f7

Browse files
committed
devpkg: use struct field values in FlakeRef.String
Improve FlakeRef.String so that it reassembles the flakeref using the current field values instead of returning the original parsed string that it came from. This makes it easier to modify parsed flakerefs or to create new ones from scratch. The strings are normalized so that when two flakerefs are equal, their strings will also be equal. The docs for FlakeRef.String go into more detail on what the normalized form looks like. Being able to parse/modify/encode flake references is part of the work to support multiple package outputs. Having a single (well-tested type) for handling flakes should help manage the complexity as another syntax for outputs is added.
1 parent df1982d commit deea2f7

File tree

3 files changed

+221
-20
lines changed

3 files changed

+221
-20
lines changed

.golangci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ linters-settings:
4141
- name: bool-literal-in-expr
4242
- name: cognitive-complexity
4343
arguments:
44-
- 30 # TODO: gradually reduce it
44+
- 32 # TODO: gradually reduce it
4545
- name: datarace
4646
- name: duplicated-imports
4747
- name: early-return

internal/devpkg/flakeref.go

Lines changed: 134 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package devpkg
22

33
import (
44
"net/url"
5+
"path"
56
"strings"
67

78
"go.jetpack.io/devbox/internal/redact"
@@ -52,9 +53,6 @@ type FlakeRef struct {
5253
// or "git". Note that the URL is not the same as the raw unparsed
5354
// flakeref.
5455
URL string `json:"url,omitempty"`
55-
56-
// raw stores the original unparsed flakeref string.
57-
raw string
5856
}
5957

6058
// ParseFlakeRef parses a raw flake reference. Nix supports a variety of
@@ -78,7 +76,7 @@ func ParseFlakeRef(ref string) (FlakeRef, error) {
7876
}
7977

8078
// Handle path-style references first.
81-
parsed := FlakeRef{raw: ref}
79+
parsed := FlakeRef{}
8280
if ref[0] == '.' || ref[0] == '/' {
8381
if strings.ContainsAny(ref, "?#") {
8482
// The Nix CLI does seem to allow paths with a '?'
@@ -99,7 +97,6 @@ func parseFlakeURLRef(ref string) (parsed FlakeRef, fragment string, err error)
9997
// A good way to test how Nix parses a flake reference is to run:
10098
//
10199
// nix eval --json --expr 'builtins.parseFlakeRef "ref"' | jq
102-
parsed.raw = ref
103100
refURL, err := url.Parse(ref)
104101
if err != nil {
105102
return FlakeRef{}, "", redact.Errorf("parse flake reference as URL: %v", err)
@@ -231,9 +228,111 @@ func parseGitHubFlakeRef(refURL *url.URL, parsed *FlakeRef) error {
231228
return nil
232229
}
233230

234-
// String returns the raw flakeref string as given to ParseFlakeRef.
231+
// String encodes the flake reference as a URL-like string. It normalizes
232+
// the result such that if two FlakeRef values are equal, then their strings
233+
// will also be equal.
234+
//
235+
// There are multiple ways to encode a flake reference. String uses the
236+
// following rules to normalize the result:
237+
//
238+
// - the URL-like form is always used, even for paths and indirects.
239+
// - the scheme is always present, even if it's optional.
240+
// - paths are cleaned per path.Clean.
241+
// - fields that can be put in either the path or the query string are always
242+
// put in the path.
243+
// - query parameters are sorted by key.
244+
//
245+
// If f is missing a type or has any invalid fields, String returns an empty
246+
// string.
235247
func (f FlakeRef) String() string {
236-
return f.raw
248+
switch f.Type {
249+
case "file":
250+
if f.URL == "" {
251+
return ""
252+
}
253+
return "file+" + f.URL
254+
case "git":
255+
if f.URL == "" {
256+
return ""
257+
}
258+
if !strings.HasPrefix(f.URL, "git") {
259+
f.URL = "git+" + f.URL
260+
}
261+
262+
// Nix removes "ref" and "rev" from the query string
263+
// (but not other parameters) after parsing. If they're empty,
264+
// we can skip parsing the URL. Otherwise, we need to add them
265+
// back.
266+
if f.Ref == "" && f.Rev == "" {
267+
return f.URL
268+
}
269+
url, err := url.Parse(f.URL)
270+
if err != nil {
271+
// This should be rare and only happen if the caller
272+
// messed with the parsed URL.
273+
return ""
274+
}
275+
url.RawQuery = buildQueryString("ref", f.Ref, "rev", f.Rev, "dir", f.Dir)
276+
return url.String()
277+
case "github":
278+
if f.Owner == "" || f.Repo == "" {
279+
return ""
280+
}
281+
url := &url.URL{
282+
Scheme: "github",
283+
Opaque: buildOpaquePath(f.Owner, f.Repo, f.Rev, f.Ref),
284+
RawQuery: buildQueryString("host", f.Host, "dir", f.Dir),
285+
}
286+
return url.String()
287+
case "indirect":
288+
if f.ID == "" {
289+
return ""
290+
}
291+
url := &url.URL{
292+
Scheme: "flake",
293+
Opaque: buildOpaquePath(f.ID, f.Ref, f.Rev),
294+
RawQuery: buildQueryString("dir", f.Dir),
295+
}
296+
return url.String()
297+
case "path":
298+
if f.Path == "" {
299+
return ""
300+
}
301+
f.Path = path.Clean(f.Path)
302+
url := &url.URL{
303+
Scheme: "path",
304+
Opaque: buildOpaquePath(strings.Split(f.Path, "/")...),
305+
}
306+
307+
// Add the / prefix back if strings.Split removed it.
308+
if f.Path[0] == '/' {
309+
url.Opaque = "/" + url.Opaque
310+
} else if f.Path == "." {
311+
url.Opaque = "."
312+
}
313+
return url.String()
314+
case "tarball":
315+
if f.URL == "" {
316+
return ""
317+
}
318+
if !strings.HasPrefix(f.URL, "tarball") {
319+
f.URL = "tarball+" + f.URL
320+
}
321+
if f.Dir == "" {
322+
return f.URL
323+
}
324+
325+
url, err := url.Parse(f.URL)
326+
if err != nil {
327+
// This should be rare and only happen if the caller
328+
// messed with the parsed URL.
329+
return ""
330+
}
331+
url.RawQuery = buildQueryString("dir", f.Dir)
332+
return url.String()
333+
default:
334+
return ""
335+
}
237336
}
238337

239338
func isGitHash(s string) bool {
@@ -260,6 +359,33 @@ func isArchive(path string) bool {
260359
strings.HasSuffix(path, ".zip")
261360
}
262361

362+
// buildOpaquePath escapes and joins path elements for a URL flakeref. The
363+
// resulting path is cleaned according to url.JoinPath.
364+
func buildOpaquePath(elem ...string) string {
365+
for i := range elem {
366+
elem[i] = url.PathEscape(elem[i])
367+
}
368+
u := &url.URL{}
369+
return u.JoinPath(elem...).String()
370+
}
371+
372+
// buildQueryString builds a URL query string from a list of key-value string
373+
// pairs, omitting any keys with empty values.
374+
func buildQueryString(keyval ...string) string {
375+
if len(keyval)%2 != 0 {
376+
panic("buildQueryString: odd number of key-value pairs")
377+
}
378+
379+
query := make(url.Values, len(keyval)/2)
380+
for i := 0; i < len(keyval); i += 2 {
381+
k, v := keyval[i], keyval[i+1]
382+
if v != "" {
383+
query.Set(k, v)
384+
}
385+
}
386+
return query.Encode()
387+
}
388+
263389
// FlakeInstallable is a Nix command line argument that specifies how to install
264390
// a flake. It can be a plain flake reference, or a flake reference with an
265391
// attribute path and/or output specification.
@@ -309,20 +435,15 @@ func ParseFlakeInstallable(raw string) (FlakeInstallable, error) {
309435
// cannot point to files with a '#' or '?' in their name, since those
310436
// would be parsed as the URL fragment or query string. This mimic's
311437
// Nix's CLI behavior.
312-
prefix := ""
313438
if raw[0] == '.' || raw[0] == '/' {
314-
prefix = "path:"
315-
raw = prefix + raw
439+
raw = "path:" + raw
316440
}
317441

318442
var err error
319443
install.Ref, install.AttrPath, err = parseFlakeURLRef(raw)
320444
if err != nil {
321445
return FlakeInstallable{}, err
322446
}
323-
// Make sure to reset the raw flakeref to the original string
324-
// after parsing.
325-
install.Ref.raw = raw[len(prefix):]
326447
return install, nil
327448
}
328449

internal/devpkg/flakeref_test.go

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,17 +149,81 @@ func TestParseFlakeRef(t *testing.T) {
149149
for ref, want := range cases {
150150
t.Run(ref, func(t *testing.T) {
151151
got, err := ParseFlakeRef(ref)
152-
if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(FlakeRef{})); diff != "" {
152+
if diff := cmp.Diff(want, got); diff != "" {
153153
if err != nil {
154154
t.Errorf("got error: %s", err)
155155
}
156156
t.Errorf("wrong flakeref (-want +got):\n%s", diff)
157157
}
158-
if err != nil {
159-
return
160-
}
161-
if ref != got.String() {
162-
t.Errorf("got.String() = %q != %q", got, ref)
158+
})
159+
}
160+
}
161+
162+
func TestFlakeRefString(t *testing.T) {
163+
cases := map[FlakeRef]string{
164+
{}: "",
165+
166+
// Path references.
167+
{Type: "path", Path: "."}: "path:.",
168+
{Type: "path", Path: "./"}: "path:.",
169+
{Type: "path", Path: "./flake"}: "path:flake",
170+
{Type: "path", Path: "./relative/flake"}: "path:relative/flake",
171+
{Type: "path", Path: "/"}: "path:/",
172+
{Type: "path", Path: "/flake"}: "path:/flake",
173+
{Type: "path", Path: "/absolute/flake"}: "path:/absolute/flake",
174+
175+
// Path references with escapes.
176+
{Type: "path", Path: "%"}: "path:%25",
177+
{Type: "path", Path: "/%2F"}: "path:/%252F",
178+
{Type: "path", Path: "./Ûñî©ôδ€/flake\n"}: "path:%C3%9B%C3%B1%C3%AE%C2%A9%C3%B4%CE%B4%E2%82%AC/flake%0A",
179+
{Type: "path", Path: "/Ûñî©ôδ€/flake\n"}: "path:/%C3%9B%C3%B1%C3%AE%C2%A9%C3%B4%CE%B4%E2%82%AC/flake%0A",
180+
181+
// Indirect references.
182+
{Type: "indirect", ID: "indirect"}: "flake:indirect",
183+
{Type: "indirect", ID: "indirect", Dir: "sub/dir"}: "flake:indirect?dir=sub%2Fdir",
184+
{Type: "indirect", ID: "indirect", Ref: "ref"}: "flake:indirect/ref",
185+
{Type: "indirect", ID: "indirect", Ref: "my/ref"}: "flake:indirect/my%2Fref",
186+
{Type: "indirect", ID: "indirect", Rev: "5233fd2ba76a3accb5aaa999c00509a11fd0793c"}: "flake:indirect/5233fd2ba76a3accb5aaa999c00509a11fd0793c",
187+
{Type: "indirect", ID: "indirect", Ref: "ref", Rev: "5233fd2ba76a3accb5aaa999c00509a11fd0793c"}: "flake:indirect/ref/5233fd2ba76a3accb5aaa999c00509a11fd0793c",
188+
189+
// GitHub references.
190+
{Type: "github", Owner: "NixOS", Repo: "nix"}: "github:NixOS/nix",
191+
{Type: "github", Owner: "NixOS", Repo: "nix", Ref: "v1.2.3"}: "github:NixOS/nix/v1.2.3",
192+
{Type: "github", Owner: "NixOS", Repo: "nix", Ref: "my/ref"}: "github:NixOS/nix/my%2Fref",
193+
{Type: "github", Owner: "NixOS", Repo: "nix", Ref: "5233fd2ba76a3accb5aaa999c00509a11fd0793c"}: "github:NixOS/nix/5233fd2ba76a3accb5aaa999c00509a11fd0793c",
194+
{Type: "github", Owner: "NixOS", Repo: "nix", Ref: "5233fd2bb76a3accb5aaa999c00509a11fd0793z"}: "github:NixOS/nix/5233fd2bb76a3accb5aaa999c00509a11fd0793z",
195+
{Type: "github", Owner: "NixOS", Repo: "nix", Dir: "sub/dir"}: "github:NixOS/nix?dir=sub%2Fdir",
196+
{Type: "github", Owner: "NixOS", Repo: "nix", Dir: "sub/dir", Host: "example.com"}: "github:NixOS/nix?dir=sub%2Fdir&host=example.com",
197+
198+
// Git references.
199+
{Type: "git", URL: "git://example.com/repo/flake"}: "git://example.com/repo/flake",
200+
{Type: "git", URL: "https://example.com/repo/flake"}: "git+https://example.com/repo/flake",
201+
{Type: "git", URL: "ssh://[email protected]/repo/flake"}: "git+ssh://[email protected]/repo/flake",
202+
{Type: "git", URL: "git:/repo/flake"}: "git:/repo/flake",
203+
{Type: "git", URL: "file:///repo/flake"}: "git+file:///repo/flake",
204+
{Type: "git", URL: "ssh://[email protected]/repo/flake", Ref: "my/ref", Rev: "e486d8d40e626a20e06d792db8cc5ac5aba9a5b4"}: "git+ssh://[email protected]/repo/flake?ref=my%2Fref&rev=e486d8d40e626a20e06d792db8cc5ac5aba9a5b4",
205+
{Type: "git", URL: "ssh://[email protected]/repo/flake?dir=sub%2Fdir", Ref: "my/ref", Rev: "e486d8d40e626a20e06d792db8cc5ac5aba9a5b4", Dir: "sub/dir"}: "git+ssh://[email protected]/repo/flake?dir=sub%2Fdir&ref=my%2Fref&rev=e486d8d40e626a20e06d792db8cc5ac5aba9a5b4",
206+
{Type: "git", URL: "git:repo/flake?dir=sub%2Fdir", Ref: "my/ref", Rev: "e486d8d40e626a20e06d792db8cc5ac5aba9a5b4", Dir: "sub/dir"}: "git:repo/flake?dir=sub%2Fdir&ref=my%2Fref&rev=e486d8d40e626a20e06d792db8cc5ac5aba9a5b4",
207+
208+
// Tarball references.
209+
{Type: "tarball", URL: "http://example.com/flake"}: "tarball+http://example.com/flake",
210+
{Type: "tarball", URL: "https://example.com/flake"}: "tarball+https://example.com/flake",
211+
{Type: "tarball", URL: "https://example.com/flake", Dir: "sub/dir"}: "tarball+https://example.com/flake?dir=sub%2Fdir",
212+
{Type: "tarball", URL: "file:///home/flake"}: "tarball+file:///home/flake",
213+
214+
// File URL references.
215+
{Type: "file", URL: "file:///flake"}: "file+file:///flake",
216+
{Type: "file", URL: "http://example.com/flake"}: "file+http://example.com/flake",
217+
{Type: "file", URL: "http://example.com/flake.git"}: "file+http://example.com/flake.git",
218+
{Type: "file", URL: "http://example.com/flake.tar?dir=sub%2Fdir", Dir: "sub/dir"}: "file+http://example.com/flake.tar?dir=sub%2Fdir",
219+
}
220+
221+
for ref, want := range cases {
222+
t.Run(want, func(t *testing.T) {
223+
t.Logf("input = %#v", ref)
224+
got := ref.String()
225+
if got != want {
226+
t.Errorf("got %#q, want %#q", got, want)
163227
}
164228
})
165229
}
@@ -253,3 +317,19 @@ func TestFlakeInstallableAllOutputs(t *testing.T) {
253317
t.Errorf("AllOutputs() = true for empty outputs slice, want false")
254318
}
255319
}
320+
321+
func TestBuildQueryString(t *testing.T) {
322+
defer func() {
323+
if r := recover(); r == nil {
324+
t.Error("wanted panic for odd-number of key-value parameters")
325+
}
326+
}()
327+
328+
// staticcheck impressively catches buildQueryString calls that have an
329+
// odd number of parameters. Build the slice in a convoluted way to
330+
// throw it off and suppress the warning (gopls doesn't have nolint
331+
// directives).
332+
var elems []string
333+
elems = append(elems, "1")
334+
buildQueryString(elems...)
335+
}

0 commit comments

Comments
 (0)