Skip to content

Commit

Permalink
internal/devconfig: add PackageSpec for parsing package strings
Browse files Browse the repository at this point in the history
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
gcurtis committed Mar 6, 2024
1 parent 9f11a98 commit 8f8cc3d
Show file tree
Hide file tree
Showing 2 changed files with 359 additions and 0 deletions.
133 changes: 133 additions & 0 deletions internal/devconfig/configfile/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"go.jetpack.io/devbox/internal/nix"
"go.jetpack.io/devbox/internal/searcher"
"go.jetpack.io/devbox/internal/ux"
"go.jetpack.io/devbox/nix/flake"
"go.jetpack.io/pkg/runx/impl/types"
)

type PackagesMutator struct {
Expand Down Expand Up @@ -366,3 +368,134 @@ func packagesFromLegacyList(packages []string) []Package {
}
return packagesList
}

// 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.
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
}
226 changes: 226 additions & 0 deletions internal/devconfig/configfile/packages_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package configfile

import (
"fmt"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/tailscale/hujson"
"go.jetpack.io/devbox/nix/flake"
"go.jetpack.io/pkg/runx/impl/types"
)

// TestJsonifyConfigPackages tests the jsonMarshal and jsonUnmarshal of the Config.Packages field
Expand Down Expand Up @@ -288,3 +291,226 @@ func TestParseVersionedName(t *testing.T) {
})
}
}

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 snixpkgs-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
}

0 comments on commit 8f8cc3d

Please sign in to comment.