-
Notifications
You must be signed in to change notification settings - Fork 190
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
internal/devconfig: add PackageSpec for parsing package strings
Attempt to formalize raw Devbox package strings (hereby called package specs) by introducing a new `PackageSpec` type. This commit just adds the new parsing logic, it doesn't integrate it with any other code yet. The `PackageSpec` type is the result of parsing a raw string with `ParsePackageSpec`: type PackageSpec struct { Name, Version string Installable flake.Installable AttrPathInstallable flake.Installable RunX types.PkgRef } func ParsePackageSpec(raw, nixpkgsCommit string) PackageSpec When a package spec is ambiguous, `ParsePackageSpec` will populate the `PackageSpec` fields with all possible interpretations. For example, the spec `go` could be `go@latest`, a flake named `go`, or the attribute path `nixpkgs#go`. It's up to a resolver to try them in some priority order and pick one.
- Loading branch information
Showing
2 changed files
with
375 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,142 @@ | ||
package devpkg | ||
|
||
import ( | ||
"strings" | ||
|
||
"go.jetpack.io/devbox/nix/flake" | ||
"go.jetpack.io/pkg/runx/impl/types" | ||
) | ||
|
||
// PackageSpec specifies a Devbox package to install. Devbox supports a number | ||
// of package spec syntaxes: | ||
// | ||
// | Syntax | Example | Description | | ||
// | ---------------- | --------------------------- | ------------------------------------ | | ||
// | name@version | go@v1.22 | Resolved w/ search service | | ||
// | name | go | Same as above; implies @latest | | ||
// | flake | github:/nixos/nixpkgs#go | Matches Nix installable syntax | | ||
// | attr path | go | Equivalent to flake:nixpkgs#go | | ||
// | legacy attr path | go | Uses deprecated nixpkgs.commit field | | ||
// | runx | runx:golangci/golangci-lint | Experimental | | ||
// | ||
// Most package specs are ambiguous, making it impossible to tell which exact | ||
// syntax the user intended. PackageSpec parses as many as it can and leaves it | ||
// up to the caller to prioritize and resolve them. | ||
// | ||
// For example, the package spec "cachix" is a Devbox package (implied @latest), | ||
// a flake ref (cachix), and an attribute path (nixpkgs#cachix). When resolving | ||
// the package, Devbox must pick one (most likely cachix@latest). | ||
type PackageSpec struct { | ||
// Name and Version are the parsed components of Devbox's name@version | ||
// package syntax. | ||
Name, Version string | ||
|
||
// Installable is the spec parsed as a Nix flake installable. | ||
Installable flake.Installable | ||
|
||
// AttrPathInstallable is the spec parsed as a nixpkgs attribute path. | ||
// If the project has a legacy nixpkgs commit field set, then | ||
// AttrPathInstallable will use it. Otherwise, it defaults to whatever | ||
// is in the user's flake registry, which is usually nixpkgs-unstable. | ||
AttrPathInstallable flake.Installable | ||
|
||
// RunX is the spec parsed as a RunX package reference. | ||
RunX types.PkgRef | ||
} | ||
|
||
// ParsePackageSpec parses a raw Devbox package specifier. nixpkgsCommit should | ||
// be empty unless the Devbox project has the deprecated nixpkgs.commit field. | ||
// | ||
// Parsing is strictly syntactical. ParsePackageSpec does not make any network | ||
// calls or execute any Nix commands to disambiguate the raw spec. | ||
func ParsePackageSpec(raw, nixpkgsCommit string) PackageSpec { | ||
if raw == "" { | ||
return PackageSpec{} | ||
} | ||
if after, ok := strings.CutPrefix(raw, "runx:"); ok { | ||
runx, err := types.NewPkgRef(after) | ||
if err == nil { | ||
return PackageSpec{RunX: runx} | ||
} | ||
} | ||
|
||
spec := PackageSpec{} | ||
spec.Installable, _ = flake.ParseInstallable(raw) | ||
if spec.isInstallableUnambiguous(raw) { | ||
// Definitely a flake, no need to keep going. | ||
return spec | ||
} | ||
|
||
isValidAttrPath := !strings.ContainsRune(raw, '#') | ||
if !isValidAttrPath { | ||
// Not an attribute path, so can't be a Devbox package either. | ||
return spec | ||
} | ||
|
||
spec.AttrPathInstallable = flake.Installable{ | ||
Ref: flake.Ref{ | ||
Type: flake.TypeIndirect, | ||
ID: "nixpkgs", | ||
Ref: nixpkgsCommit, | ||
}, | ||
AttrPath: raw, | ||
} | ||
if nixpkgsCommit != "" { | ||
// Don't interpret raw as a flake ref if its ambiguous. | ||
// Otherwise, we would end up with an Installable that doesn't | ||
// respect the nixpkgs.commit field. | ||
// | ||
// For example, "cachix" is an indirect flake reference that | ||
// installs the default package from the cachix flake. But when | ||
// nixpkgs.commit is set, the user is actually trying to install | ||
// nixpkgs/<ref>#cachix. | ||
spec.Installable = flake.Installable{} | ||
} | ||
|
||
i := strings.LastIndexByte(raw, '@') | ||
if i <= 0 || i == len(raw)-1 { | ||
// When a Devbox spec doesn't specify a version, we need | ||
// to check for the deprecated nixpkgs.commit field. If | ||
// it's set, then we treat the spec as an attribute | ||
// path. Otherwise, we assume the latest version.In | ||
// other words: | ||
// | ||
// {"packages":["go"]} -> go@latest | ||
// {"packages":["go"],"nixpkgs":{"commit":"abc"}} -> nixpkgs/abc#go | ||
if nixpkgsCommit == "" { | ||
spec.Name = raw | ||
spec.Version = "latest" | ||
} | ||
// Leave Name and Version empty; rely on | ||
// AttrPathInstallable for legacy-style packages. | ||
return spec | ||
} | ||
spec.Name, spec.Version = raw[:i], raw[i+1:] | ||
return spec | ||
} | ||
|
||
// isInstallableUnambiguous returns true if the raw, unparsed form of | ||
// p.Installable has an explicit scheme or starts with "./" or "/". Unambiguous | ||
// installables are never parsed as Devbox package names or attribute paths. | ||
func (p PackageSpec) isInstallableUnambiguous(raw string) bool { | ||
// The scheme is optional for indirect and path flake types, so we need | ||
// to check explicitly. | ||
var ( | ||
isFlake = p.Installable.Ref.Type != "" | ||
isIndirect = p.Installable.Ref.Type == flake.TypeIndirect | ||
isPath = p.Installable.Ref.Type == flake.TypePath | ||
) | ||
if isFlake && !isIndirect && !isPath { | ||
return true | ||
} | ||
if isIndirect && strings.HasPrefix(raw, "flake:") { | ||
return true | ||
} | ||
if isPath && strings.HasPrefix(raw, "path:") { | ||
return true | ||
} | ||
if isPath && (strings.HasPrefix(raw, "./") || strings.HasPrefix(raw, "/")) { | ||
return true | ||
} | ||
return false | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,233 @@ | ||
package devpkg | ||
|
||
import ( | ||
"fmt" | ||
"testing" | ||
|
||
"github.com/google/go-cmp/cmp" | ||
"go.jetpack.io/devbox/nix/flake" | ||
"go.jetpack.io/pkg/runx/impl/types" | ||
) | ||
|
||
func TestParsePackageSpec(t *testing.T) { | ||
cases := []struct { | ||
in string | ||
want PackageSpec | ||
}{ | ||
{in: "", want: PackageSpec{}}, | ||
{in: "mail:nixpkgs#go", want: PackageSpec{}}, | ||
|
||
// Common name@version strings. | ||
{ | ||
in: "go", want: PackageSpec{ | ||
Name: "go", Version: "latest", | ||
Installable: mustFlake(t, "flake:go"), | ||
AttrPathInstallable: mustFlake(t, "nixpkgs#go"), | ||
}, | ||
}, | ||
{ | ||
in: "go@latest", want: PackageSpec{ | ||
Name: "go", Version: "latest", | ||
Installable: mustFlake(t, "flake:go@latest"), | ||
AttrPathInstallable: mustFlake(t, "nixpkgs#go@latest"), | ||
}, | ||
}, | ||
{ | ||
in: "go@1.22.0", want: PackageSpec{ | ||
Name: "go", Version: "1.22.0", | ||
Installable: mustFlake(t, "flake:go@1.22.0"), | ||
AttrPathInstallable: mustFlake(t, "nixpkgs#go@1.22.0"), | ||
}, | ||
}, | ||
|
||
// name@version splitting edge-cases. | ||
{ | ||
in: "emacsPackages.@@latest", want: PackageSpec{ | ||
Name: "emacsPackages.@", Version: "latest", | ||
Installable: mustFlake(t, "flake:emacsPackages.@@latest"), | ||
AttrPathInstallable: mustFlake(t, "nixpkgs#emacsPackages.@@latest"), | ||
}, | ||
}, | ||
{ | ||
in: "emacsPackages.@", want: PackageSpec{ | ||
Name: "emacsPackages.@", Version: "latest", | ||
Installable: mustFlake(t, "flake:emacsPackages.@"), | ||
AttrPathInstallable: mustFlake(t, "nixpkgs#emacsPackages.@"), | ||
}, | ||
}, | ||
{ | ||
in: "@angular/cli", want: PackageSpec{ | ||
Name: "@angular/cli", Version: "latest", | ||
Installable: mustFlake(t, "flake:@angular/cli"), | ||
AttrPathInstallable: mustFlake(t, "nixpkgs#@angular/cli"), | ||
}, | ||
}, | ||
{ | ||
in: "nodePackages.@angular/cli", want: PackageSpec{ | ||
Name: "nodePackages.", Version: "angular/cli", | ||
Installable: mustFlake(t, "flake:nodePackages.@angular/cli"), | ||
AttrPathInstallable: mustFlake(t, "nixpkgs#nodePackages.@angular/cli"), | ||
}, | ||
}, | ||
|
||
// Flake installables. | ||
{ | ||
in: "nixpkgs#go", | ||
want: PackageSpec{Installable: mustFlake(t, "flake:nixpkgs#go")}, | ||
}, | ||
{ | ||
in: "flake:nixpkgs", | ||
want: PackageSpec{Installable: mustFlake(t, "flake:nixpkgs")}, | ||
}, | ||
{ | ||
in: "flake:nixpkgs#go", | ||
want: PackageSpec{Installable: mustFlake(t, "flake:nixpkgs#go")}, | ||
}, | ||
{ | ||
in: "./my-php-flake", | ||
want: PackageSpec{Installable: mustFlake(t, "path:./my-php-flake")}, | ||
}, | ||
{ | ||
in: "./my-php-flake#hello", | ||
want: PackageSpec{Installable: mustFlake(t, "path:./my-php-flake#hello")}, | ||
}, | ||
{ | ||
in: "/my-php-flake", | ||
want: PackageSpec{Installable: mustFlake(t, "path:/my-php-flake")}, | ||
}, | ||
{ | ||
in: "/my-php-flake#hello", | ||
want: PackageSpec{Installable: mustFlake(t, "path:/my-php-flake#hello")}, | ||
}, | ||
{ | ||
in: "path:my-php-flake", | ||
want: PackageSpec{Installable: mustFlake(t, "path:my-php-flake")}, | ||
}, | ||
{ | ||
in: "path:my-php-flake#hello", | ||
want: PackageSpec{Installable: mustFlake(t, "path:my-php-flake#hello")}, | ||
}, | ||
{ | ||
in: "github:F1bonacc1/process-compose/v0.43.1", | ||
want: PackageSpec{Installable: mustFlake(t, "github:F1bonacc1/process-compose/v0.43.1")}, | ||
}, | ||
{ | ||
in: "github:nixos/nixpkgs/5233fd2ba76a3accb5aaa999c00509a11fd0793c#hello", | ||
want: PackageSpec{Installable: mustFlake(t, "github:nixos/nixpkgs/5233fd2ba76a3accb5aaa999c00509a11fd0793c#hello")}, | ||
}, | ||
{ | ||
in: "mail:nixpkgs", | ||
want: PackageSpec{ | ||
Name: "mail:nixpkgs", Version: "latest", | ||
AttrPathInstallable: mustFlake(t, "nixpkgs#mail:nixpkgs"), | ||
}, | ||
}, | ||
|
||
// RunX | ||
{ | ||
in: "runx:golangci/golangci-lint", want: PackageSpec{ | ||
RunX: types.PkgRef{ | ||
Owner: "golangci", | ||
Repo: "golangci-lint", | ||
Version: "latest", | ||
}, | ||
}, | ||
}, | ||
{ | ||
in: "runx:golangci/golangci-lint@1.2.3", want: PackageSpec{ | ||
RunX: types.PkgRef{ | ||
Owner: "golangci", | ||
Repo: "golangci-lint", | ||
Version: "1.2.3", | ||
}, | ||
}, | ||
}, | ||
|
||
// RunX missing scheme. | ||
{ | ||
in: "golangci/golangci-lint", want: PackageSpec{ | ||
Name: "golangci/golangci-lint", Version: "latest", | ||
Installable: mustFlake(t, "flake:golangci/golangci-lint"), | ||
AttrPathInstallable: mustFlake(t, "nixpkgs#golangci/golangci-lint"), | ||
}, | ||
}, | ||
{ | ||
in: "golangci/golangci-lint@1.2.3", want: PackageSpec{ | ||
Name: "golangci/golangci-lint", Version: "1.2.3", | ||
Installable: mustFlake(t, "flake:golangci/golangci-lint@1.2.3"), | ||
AttrPathInstallable: mustFlake(t, "nixpkgs#golangci/golangci-lint@1.2.3"), | ||
}, | ||
}, | ||
} | ||
|
||
for _, tc := range cases { | ||
t.Run(fmt.Sprintf("in=%s", tc.in), func(t *testing.T) { | ||
got := ParsePackageSpec(tc.in, "") | ||
if diff := cmp.Diff(tc.want, got); diff != "" { | ||
t.Errorf("wrong PackageSpec for %q (-want +got):\n%s", tc.in, diff) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
// TestParseDeprecatedPackageSpec tests parsing behavior when the deprecated | ||
// nixpkgs.commit field is set to nixpkgs-unstable. It's split into a separate | ||
// test in case we ever drop support for nixpkgs.commit entirely. | ||
func TestParseDeprecatedPackageSpec(t *testing.T) { | ||
nixpkgsCommit := flake.Ref{Type: flake.TypeIndirect, ID: "nixpkgs", Ref: "nixpkgs-unstable"} | ||
cases := []struct { | ||
in string | ||
want PackageSpec | ||
}{ | ||
{in: "", want: PackageSpec{}}, | ||
|
||
// Parses Devbox package when @version specified. | ||
{ | ||
in: "go@latest", want: PackageSpec{ | ||
Name: "go", Version: "latest", | ||
AttrPathInstallable: mustFlake(t, "nixpkgs/nixpkgs-unstable#go@latest"), | ||
}, | ||
}, | ||
{ | ||
in: "go@1.22.0", want: PackageSpec{ | ||
Name: "go", Version: "1.22.0", | ||
AttrPathInstallable: mustFlake(t, "nixpkgs/nixpkgs-unstable#go@1.22.0"), | ||
}, | ||
}, | ||
|
||
// Missing @version does not imply @latest and is not a flake reference. | ||
{in: "go", want: PackageSpec{AttrPathInstallable: mustFlake(t, "nixpkgs/nixpkgs-unstable#go")}}, | ||
{in: "cachix", want: PackageSpec{AttrPathInstallable: mustFlake(t, "nixpkgs/nixpkgs-unstable#cachix")}}, | ||
|
||
// Unambiguous flake reference should not be parsed as an attribute path. | ||
{in: "flake:cachix", want: PackageSpec{Installable: mustFlake(t, "flake:cachix#")}}, | ||
{in: "./flake", want: PackageSpec{Installable: mustFlake(t, "path:./flake")}}, | ||
{in: "path:flake", want: PackageSpec{Installable: mustFlake(t, "path:flake")}}, | ||
{in: "nixpkgs#go", want: PackageSpec{Installable: mustFlake(t, "nixpkgs#go")}}, | ||
{in: "nixpkgs/branch#go", want: PackageSpec{Installable: mustFlake(t, "nixpkgs/branch#go")}}, | ||
|
||
// // RunX unaffected by nixpkgs.commit. | ||
{in: "runx:golangci/golangci-lint", want: PackageSpec{RunX: types.PkgRef{Owner: "golangci", Repo: "golangci-lint", Version: "latest"}}}, | ||
} | ||
|
||
for _, tc := range cases { | ||
t.Run(fmt.Sprintf("in=%s", tc.in), func(t *testing.T) { | ||
got := ParsePackageSpec(tc.in, nixpkgsCommit.Ref) | ||
if diff := cmp.Diff(tc.want, got); diff != "" { | ||
t.Errorf("wrong PackageSpec for %q (-want +got):\n%s", tc.in, diff) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
// mustFlake parses s as a [flake.Installable] and fails the test if there's an | ||
// error. It allows using the string form of a flake in test cases so they're | ||
// easier to read. | ||
func mustFlake(t *testing.T, s string) flake.Installable { | ||
t.Helper() | ||
i, err := flake.ParseInstallable(s) | ||
if err != nil { | ||
t.Fatal("error parsing wanted flake installable:", err) | ||
} | ||
return i | ||
} |