Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

internal/devpkg: add PackageSpec for parsing package strings #1878

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions internal/devpkg/spec.go
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
}
233 changes: 233 additions & 0 deletions internal/devpkg/spec_test.go
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
}