Skip to content

Commit 793bcc5

Browse files
committed
devpkg: better flake installable parsing
Add a `ParseFlakeInstallable` to parse Nix flake installables. An installable is a superset of a flake reference. It allows an attribute path and/or outputs to be specified using the `flake#attrpath^outputs` syntax.
1 parent 068a486 commit 793bcc5

File tree

2 files changed

+184
-0
lines changed

2 files changed

+184
-0
lines changed

internal/devpkg/flakeref.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,98 @@ func isArchive(path string) bool {
259259
strings.HasSuffix(path, ".tar.bz2") ||
260260
strings.HasSuffix(path, ".zip")
261261
}
262+
263+
// FlakeInstallable is a Nix command line argument that specifies how to install
264+
// a flake. It can be a plain flake reference, or a flake reference with an
265+
// attribute path and/or output specification.
266+
//
267+
// Some examples are:
268+
//
269+
// - "." installs the default attribute from the flake in the current
270+
// directory.
271+
// - ".#hello" installs the hello attribute from the flake in the current
272+
// directory.
273+
// - "nixpkgs#hello" installs the hello attribute from the nixpkgs flake.
274+
// - "github:NixOS/nixpkgs/unstable#curl^lib" installs the the lib output of
275+
// curl attribute from the flake on the nixpkgs unstable branch.
276+
//
277+
// The flake installable syntax is only valid in Nix command line arguments, not
278+
// in Nix expressions. See FlakeRef and the [Nix manual for details on the
279+
// differences between flake references and installables.
280+
//
281+
// [Nix manual]: https://nixos.org/manual/nix/unstable/command-ref/new-cli/nix#installables
282+
type FlakeInstallable struct {
283+
Ref FlakeRef
284+
AttrPath string
285+
Outputs []string
286+
287+
raw string
288+
}
289+
290+
// ParseFlakeInstallable parses a flake installable. The string s must contain a
291+
// valid flake reference parsable by ParseFlakeRef, optionally followed by an
292+
// #attrpath and/or an ^output.
293+
func ParseFlakeInstallable(raw string) (FlakeInstallable, error) {
294+
if raw == "" {
295+
return FlakeInstallable{}, redact.Errorf("empty flake installable")
296+
}
297+
298+
// The output spec must be parsed and removed first, otherwise it will
299+
// be parsed as part of the flakeref's URL fragment.
300+
install := FlakeInstallable{raw: raw}
301+
before, after := splitOutputSpec(raw)
302+
if after != "" {
303+
install.Outputs = strings.Split(after, ",")
304+
}
305+
raw = before
306+
307+
// Interpret installables with path-style flakerefs as URLs to extract
308+
// the attribute path (fragment). This means that path-style flakerefs
309+
// cannot point to files with a '#' or '?' in their name, since those
310+
// would be parsed as the URL fragment or query string. This mimic's
311+
// Nix's CLI behavior.
312+
prefix := ""
313+
if raw[0] == '.' || raw[0] == '/' {
314+
prefix = "path:"
315+
raw = prefix + raw
316+
}
317+
318+
var err error
319+
install.Ref, install.AttrPath, err = parseFlakeURLRef(raw)
320+
if err != nil {
321+
return FlakeInstallable{}, err
322+
}
323+
// Make sure to reset the raw flakeref to the original string
324+
// after parsing.
325+
install.Ref.raw = raw[len(prefix):]
326+
return install, nil
327+
}
328+
329+
// AllOutputs returns true if the installable specifies all outputs with the
330+
// "^*" syntax.
331+
func (f FlakeInstallable) AllOutputs() bool {
332+
for _, out := range f.Outputs {
333+
if out == "*" {
334+
return true
335+
}
336+
}
337+
return false
338+
}
339+
340+
// DefaultOutputs returns true if the installable does not specify any outputs.
341+
func (f FlakeInstallable) DefaultOutputs() bool {
342+
return len(f.Outputs) == 0
343+
}
344+
345+
// String returns the raw installable string as given to ParseFlakeInstallable.
346+
func (f FlakeInstallable) String() string {
347+
return f.raw
348+
}
349+
350+
// splitOutputSpec cuts a flake installable around the last instance of ^.
351+
func splitOutputSpec(s string) (before, after string) {
352+
if i := strings.LastIndexByte(s, '^'); i >= 0 {
353+
return s[:i], s[i+1:]
354+
}
355+
return s, ""
356+
}

internal/devpkg/flakeref_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,92 @@ func TestParseFlakeRef(t *testing.T) {
164164
})
165165
}
166166
}
167+
168+
func TestParseFlakeInstallable(t *testing.T) {
169+
cases := map[string]FlakeInstallable{
170+
// Empty string is not a valid installable.
171+
"": {},
172+
173+
// Not a path and not a valid URL.
174+
"://bad/url": {},
175+
176+
".": {Ref: FlakeRef{Type: "path", Path: "."}},
177+
".#app": {AttrPath: "app", Ref: FlakeRef{Type: "path", Path: "."}},
178+
".#app^out": {AttrPath: "app", Outputs: []string{"out"}, Ref: FlakeRef{Type: "path", Path: "."}},
179+
".#app^out,lib": {AttrPath: "app", Outputs: []string{"out", "lib"}, Ref: FlakeRef{Type: "path", Path: "."}},
180+
".#app^*": {AttrPath: "app", Outputs: []string{"*"}, Ref: FlakeRef{Type: "path", Path: "."}},
181+
".^*": {Outputs: []string{"*"}, Ref: FlakeRef{Type: "path", Path: "."}},
182+
183+
"./flake": {Ref: FlakeRef{Type: "path", Path: "./flake"}},
184+
"./flake#app": {AttrPath: "app", Ref: FlakeRef{Type: "path", Path: "./flake"}},
185+
"./flake#app^out": {AttrPath: "app", Outputs: []string{"out"}, Ref: FlakeRef{Type: "path", Path: "./flake"}},
186+
"./flake#app^out,lib": {AttrPath: "app", Outputs: []string{"out", "lib"}, Ref: FlakeRef{Type: "path", Path: "./flake"}},
187+
"./flake^out": {Outputs: []string{"out"}, Ref: FlakeRef{Type: "path", Path: "./flake"}},
188+
189+
"indirect": {Ref: FlakeRef{Type: "indirect", ID: "indirect"}},
190+
"nixpkgs#app": {AttrPath: "app", Ref: FlakeRef{Type: "indirect", ID: "nixpkgs"}},
191+
"nixpkgs#app^out": {AttrPath: "app", Outputs: []string{"out"}, Ref: FlakeRef{Type: "indirect", ID: "nixpkgs"}},
192+
"nixpkgs#app^out,lib": {AttrPath: "app", Outputs: []string{"out", "lib"}, Ref: FlakeRef{Type: "indirect", ID: "nixpkgs"}},
193+
"nixpkgs^out": {Outputs: []string{"out"}, Ref: FlakeRef{Type: "indirect", ID: "nixpkgs"}},
194+
195+
"%23#app": {AttrPath: "app", Ref: FlakeRef{Type: "indirect", ID: "#"}},
196+
"./%23#app": {AttrPath: "app", Ref: FlakeRef{Type: "path", Path: "./#"}},
197+
"/%23#app": {AttrPath: "app", Ref: FlakeRef{Type: "path", Path: "/#"}},
198+
"path:/%23#app": {AttrPath: "app", Ref: FlakeRef{Type: "path", Path: "/#"}},
199+
"http://example.com/%23.tar#app": {AttrPath: "app", Ref: FlakeRef{Type: "tarball", URL: "http://example.com/%23.tar#app"}},
200+
}
201+
202+
for installable, want := range cases {
203+
t.Run(installable, func(t *testing.T) {
204+
got, err := ParseFlakeInstallable(installable)
205+
if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(FlakeRef{}, FlakeInstallable{})); diff != "" {
206+
if err != nil {
207+
t.Errorf("got error: %s", err)
208+
}
209+
t.Errorf("wrong installable (-want +got):\n%s", diff)
210+
}
211+
if err != nil {
212+
return
213+
}
214+
if installable != got.String() {
215+
t.Errorf("got.String() = %q != %q", got, installable)
216+
}
217+
})
218+
}
219+
}
220+
221+
func TestFlakeInstallableDefaultOutputs(t *testing.T) {
222+
install := FlakeInstallable{Outputs: nil}
223+
if !install.DefaultOutputs() {
224+
t.Errorf("DefaultOutputs() = false for nil outputs slice, want true")
225+
}
226+
227+
install = FlakeInstallable{Outputs: []string{}}
228+
if !install.DefaultOutputs() {
229+
t.Errorf("DefaultOutputs() = false for empty outputs slice, want true")
230+
}
231+
232+
install = FlakeInstallable{Outputs: []string{"out"}}
233+
if install.DefaultOutputs() {
234+
t.Errorf("DefaultOutputs() = true for %v, want false", install.Outputs)
235+
}
236+
}
237+
238+
func TestFlakeInstallableAllOutputs(t *testing.T) {
239+
install := FlakeInstallable{Outputs: []string{"*"}}
240+
if !install.AllOutputs() {
241+
t.Errorf("AllOutputs() = false for %v, want true", install.Outputs)
242+
}
243+
install = FlakeInstallable{Outputs: []string{"out", "*"}}
244+
if !install.AllOutputs() {
245+
t.Errorf("AllOutputs() = false for %v, want true", install.Outputs)
246+
}
247+
install = FlakeInstallable{Outputs: nil}
248+
if install.AllOutputs() {
249+
t.Errorf("AllOutputs() = true for nil outputs slice, want false")
250+
}
251+
install = FlakeInstallable{Outputs: []string{}}
252+
if install.AllOutputs() {
253+
t.Errorf("AllOutputs() = true for empty outputs slice, want false")
254+
}
255+
}

0 commit comments

Comments
 (0)