diff --git a/internal/devpkg/spec.go b/internal/devpkg/spec.go new file mode 100644 index 00000000000..e6958fc459b --- /dev/null +++ b/internal/devpkg/spec.go @@ -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/#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 +} diff --git a/internal/devpkg/spec_test.go b/internal/devpkg/spec_test.go new file mode 100644 index 00000000000..043824178b3 --- /dev/null +++ b/internal/devpkg/spec_test.go @@ -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 +}