Skip to content
Merged
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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,11 @@ The shim mappings are automatically registered via `Shims()`.
## Version Resolution

**Priority order:**
1. **Local**: Walk up from `pwd` to the filesystem root; the nearest directory that sets a version wins. Within one directory, `.dtvem/runtimes.json` comes first, then the runtime's ecosystem version files (`versionFiles` in `internal/config/versionfile.go`; Node.js: `.node-version`, then `.nvmrc`; Python: `.python-version`)
1. **Local**: Walk up from `pwd` to the filesystem root; the nearest directory that sets a version wins. Within one directory, `.dtvem/runtimes.json` comes first, then the runtime's ecosystem version files (`versionFiles` in `internal/config/versionfile.go`; Node.js: `.node-version`, then `.nvmrc`; Python: `.python-version`; Ruby: `.ruby-version`)
2. **Global**: `~/.dtvem/config/runtimes.json`
3. **Error**: No version configured (`config.ErrNoVersion`; the shim falls back to the system PATH)

**Ecosystem version files** hold an exact (`22.12.0`) or partial (`22`, `22.12`) version, with the runtime's optional prefix (`v` for Node.js, `python-` for Python); blank lines and `#` comments are skipped, and the first word of the first remaining line is used (as pyenv and rbenv do). A partial version resolves to the highest installed match. Anything else (e.g. `.nvmrc` aliases like `lts/*`, pyenv's `system` or virtualenv names), or an empty file, is a `*config.VersionFileError`: the shim reports it instead of falling back to another version. `config.Resolve` returns the version and the file that set it (`dtvem current` shows it).
**Ecosystem version files** hold an exact (`22.12.0`) or partial (`22`, `22.12`) version, with the runtime's optional prefix (`v` for Node.js, `python-` for Python, `ruby-` for Ruby); Ruby also accepts an exact pre-release (`4.0.0-preview2`, `3.4.0-rc1`). Blank lines and `#` comments are skipped, and the first word of the first remaining line is used (as pyenv and rbenv do). A partial version resolves to the highest installed match; a release ranks above its own pre-releases (`internal/version`), so `4.0` picks `4.0.0` over `4.0.0-preview2`. Anything else (e.g. `.nvmrc` aliases like `lts/*`, pyenv's `system` or virtualenv names), or an empty file, is a `*config.VersionFileError`: the shim reports it instead of falling back to another version. `config.Resolve` returns the version and the file that set it (`dtvem current` shows it).

**Config format** (both local and global):
```json
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Managing multiple versions of Python, Node.js, Ruby, and other runtimes across d

✅ **Migration Tool**: Import existing installations from nvm, pyenv, etc.

✅ **Per-Directory Versions**: `.dtvem/runtimes.json` for project-specific versions, plus `.node-version` / `.nvmrc` for Node.js and `.python-version` for Python
✅ **Per-Directory Versions**: `.dtvem/runtimes.json` for project-specific versions, plus `.node-version` / `.nvmrc` for Node.js, `.python-version` for Python, and `.ruby-version` for Ruby

✅ **No Shell Hooks**: Works in cmd.exe, PowerShell, bash, zsh, fish, etc.

Expand Down
6 changes: 3 additions & 3 deletions src/cmd/current.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ var currentCmd = &cobra.Command{
Long: `Show the currently active version for a specific runtime or all runtimes.
The active version is determined by checking local settings first, then global settings.
Local settings are read from .dtvem/runtimes.json and from each runtime's own
version files (Node.js: .node-version and .nvmrc; Python: .python-version), in
the current directory or the nearest parent that has one. The Source column
shows which file set each version.
version files (Node.js: .node-version and .nvmrc; Python: .python-version;
Ruby: .ruby-version), in the current directory or the nearest parent that has
one. The Source column shows which file set each version.

Examples:
dtvem current # Show all active versions
Expand Down
48 changes: 39 additions & 9 deletions src/internal/config/versionfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
var versionFiles = map[string]versionFileRules{
"node": {names: []string{".node-version", ".nvmrc"}, prefix: "v"},
"python": {names: []string{".python-version"}, prefix: "python-"},
"ruby": {names: []string{".ruby-version"}, prefix: "ruby-", prereleases: true},
}

// versionFileRules describes a runtime's version files and how versions are
Expand All @@ -25,6 +26,9 @@ type versionFileRules struct {
// prefix is an optional prefix written before a version, such as the
// "v" in "v22.12.0" or the "python-" in "python-3.12.4".
prefix string
// prereleases allows an exact version to name a pre-release, such as
// Ruby's "4.0.0-preview2" or "3.4.0-rc1".
prereleases bool
}

// VersionFileError reports a version file whose contents dtvem cannot use.
Expand Down Expand Up @@ -69,12 +73,19 @@ func parseVersionSpec(path, content string, rules versionFileRules) (string, err
}

// version returns the version raw asks for, without the runtime's prefix:
// an exact ("22.12.0") or partial ("22", "22.12") version number. Anything
// else, such as an .nvmrc alias like "lts/*" or pyenv's "system", is a
// *VersionFileError for the file at path.
// an exact ("22.12.0") or partial ("22", "22.12") version number, or, if
// the runtime allows them, an exact pre-release ("4.0.0-preview2").
// Anything else, such as an .nvmrc alias like "lts/*" or pyenv's "system",
// is a *VersionFileError for the file at path.
func (r versionFileRules) version(path, raw string) (string, error) {
spec := strings.TrimPrefix(raw, r.prefix)
if !isVersionNumber(spec) {

release, pre, isPre := strings.Cut(spec, "-")
valid := isVersionNumber(release)
if isPre {
valid = valid && r.prereleases && strings.Count(release, ".") == 2 && isPrerelease(pre)
}
if !valid {
return "", &VersionFileError{Path: path, Value: raw}
}
return spec, nil
Expand All @@ -88,13 +99,32 @@ func isVersionNumber(s string) bool {
return false
}
for _, part := range parts {
if part == "" {
if !isDigits(part) {
return false
}
for _, c := range part {
if c < '0' || c > '9' {
return false
}
}
return true
}

// isPrerelease reports whether s names a pre-release the way Ruby does:
// "preview" or "rc" followed by a number.
func isPrerelease(s string) bool {
for _, tag := range []string{"preview", "rc"} {
if n, ok := strings.CutPrefix(s, tag); ok && isDigits(n) {
return true
}
}
return false
}

// isDigits reports whether s is a non-empty run of ASCII digits.
func isDigits(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
Expand Down
130 changes: 130 additions & 0 deletions src/internal/config/versionfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,76 @@ func TestParseVersionSpec_PythonUnsupported(t *testing.T) {
}
}

func TestParseVersionSpec_Ruby(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{name: "exact version", content: "3.3.6\n", want: "3.3.6"},
{name: "ruby- prefix", content: "ruby-3.3.6", want: "3.3.6"},
{name: "partial version", content: "3.3", want: "3.3"},
{name: "preview", content: "4.0.0-preview2", want: "4.0.0-preview2"},
{name: "release candidate with ruby- prefix", content: "ruby-3.4.0-rc1", want: "3.4.0-rc1"},
{name: "comment and blank lines", content: "# CI pin\n\n3.3.6\n", want: "3.3.6"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseVersionSpec(".ruby-version", tt.content, versionFiles["ruby"])
if err != nil {
t.Fatalf("parseVersionSpec(%q) unexpected error: %v", tt.content, err)
}
if got != tt.want {
t.Errorf("parseVersionSpec(%q) = %q, want %q", tt.content, got, tt.want)
}
})
}
}

func TestParseVersionSpec_RubyUnsupported(t *testing.T) {
tests := []struct {
name string
content string
}{
{name: "JRuby", content: "jruby-9.4.5.0"},
{name: "TruffleRuby", content: "truffleruby-24.1.0"},
{name: "engine-only ruby", content: "ruby"},
{name: "engine-only truffleruby", content: "truffleruby"},
{name: "system", content: "system"},
{name: "partial pre-release", content: "4.0-preview2"},
{name: "unknown pre-release tag", content: "3.4.0-beta1"},
{name: "pre-release without a number", content: "3.4.0-preview"},
{name: "old patch level", content: "2.0.0-p648"},
{name: "v prefix is not Ruby's", content: "v3.3.6"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := parseVersionSpec(".ruby-version", tt.content, versionFiles["ruby"])

var fileErr *VersionFileError
if !errors.As(err, &fileErr) {
t.Fatalf("parseVersionSpec(%q) error = %v, want *VersionFileError", tt.content, err)
}
if fileErr.Value != tt.content {
t.Errorf("VersionFileError.Value = %q, want %q", fileErr.Value, tt.content)
}
})
}
}

func TestParseVersionSpec_PrereleasesAreRubyOnly(t *testing.T) {
for _, runtimeName := range []string{"node", "python"} {
_, err := parseVersionSpec("version-file", "4.0.0-preview2", versionFiles[runtimeName])

var fileErr *VersionFileError
if !errors.As(err, &fileErr) {
t.Errorf("parseVersionSpec(%s rules, pre-release) error = %v, want *VersionFileError", runtimeName, err)
}
}
}

// versionFixture is a dtvem root and a project directory to resolve from.
type versionFixture struct {
t *testing.T
Expand Down Expand Up @@ -493,6 +563,66 @@ func TestResolve_PythonVersionFile(t *testing.T) {
assertResolution(t, res, "3.12.12", source)
}

func TestResolve_RubyVersionFile(t *testing.T) {
tests := []struct {
name string
spec string
want string
}{
{name: "exact", spec: "3.3.9", want: "3.3.9"},
{name: "ruby- prefix", spec: "ruby-3.3.9", want: "3.3.9"},
{name: "partial picks highest installed", spec: "3.4", want: "3.4.7"},
{name: "partial picks release over its preview", spec: "4.0", want: "4.0.0"},
{name: "major picks release over its preview", spec: "4", want: "4.0.0"},
{name: "partial with only a preview installed", spec: "3.5", want: "3.5.0-preview1"},
{name: "exact preview", spec: "4.0.0-preview2", want: "4.0.0-preview2"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newVersionFixture(t)
f.install("ruby", "3.3.9", "3.4.1", "3.4.7", "3.5.0-preview1", "4.0.0-preview2", "4.0.0")
source := f.write(".ruby-version", tt.spec+"\n")
f.chdir("lib")

res, err := Resolve("ruby")
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
assertResolution(t, res, tt.want, source)
})
}
}

func TestResolve_RubyPrecedence(t *testing.T) {
t.Run("runtimes.json before .ruby-version", func(t *testing.T) {
f := newVersionFixture(t)
source := f.write(filepath.Join(".dtvem", "runtimes.json"), `{"ruby": "3.3.9"}`)
f.write(".ruby-version", "3.4.7")
f.chdir(".")

res, err := Resolve("ruby")
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
assertResolution(t, res, "3.3.9", source)
})

t.Run("unsupported .ruby-version does not fall back to the global version", func(t *testing.T) {
f := newVersionFixture(t)
if err := SetGlobalVersion("ruby", "3.4.7"); err != nil {
t.Fatalf("SetGlobalVersion() error: %v", err)
}
f.write(".ruby-version", "jruby-9.4.5.0")
f.chdir(".")

var fileErr *VersionFileError
if _, err := Resolve("ruby"); !errors.As(err, &fileErr) {
t.Errorf("Resolve() error = %v, want *VersionFileError", err)
}
})
}

func TestResolve_PythonPrecedence(t *testing.T) {
t.Run("runtimes.json before .python-version", func(t *testing.T) {
f := newVersionFixture(t)
Expand Down
25 changes: 23 additions & 2 deletions src/internal/version/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,31 @@ func sortVersionsDesc(versions []string) {

// compareVersionStrings compares two version strings semantically.
// Returns >0 if a > b, <0 if a < b, 0 if equal.
//
// A pre-release sorts below the release it precedes ("4.0.0-preview2" <
// "4.0.0"), so a partial version never picks a pre-release over a release.
func compareVersionStrings(a, b string) int {
aParts := parseVersionParts(a)
bParts := parseVersionParts(b)
aRelease, aPre, _ := strings.Cut(strings.TrimPrefix(a, "v"), "-")
bRelease, bPre, _ := strings.Cut(strings.TrimPrefix(b, "v"), "-")

if c := compareReleaseParts(parseVersionParts(aRelease), parseVersionParts(bRelease)); c != 0 {
return c
}

switch {
case aPre == bPre:
return 0
case aPre == "":
return 1
case bPre == "":
return -1
}
return strings.Compare(aPre, bPre)
}

// compareReleaseParts compares two numeric version part lists, treating
// missing trailing parts as zero.
func compareReleaseParts(aParts, bParts []int) int {
maxLen := len(aParts)
if len(bParts) > maxLen {
maxLen = len(bParts)
Expand Down
45 changes: 39 additions & 6 deletions src/internal/version/matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,18 +252,51 @@ func TestSortVersionsDesc(t *testing.T) {
}
}

func TestResolvePartialVersion_PrefersReleaseOverPrerelease(t *testing.T) {
tests := []struct {
name string
input string
available []string
expected string
}{
{name: "release listed first", input: "4.0", available: []string{"4.0.0", "4.0.0-preview2"}, expected: "4.0.0"},
{name: "pre-release listed first", input: "4.0", available: []string{"4.0.0-preview2", "4.0.0"}, expected: "4.0.0"},
{name: "major only", input: "4", available: []string{"4.0.0-preview2", "3.4.7", "4.0.0"}, expected: "4.0.0"},
{name: "only a pre-release matches", input: "3.5", available: []string{"3.4.7", "3.5.0-preview1"}, expected: "3.5.0-preview1"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ResolvePartialVersion(tt.input, tt.available)
if err != nil {
t.Fatalf("ResolvePartialVersion(%q) returned error: %v", tt.input, err)
}
if result != tt.expected {
t.Errorf("ResolvePartialVersion(%q, %v) = %q, want %q", tt.input, tt.available, result, tt.expected)
}
})
}
}

func TestCompareVersionStrings(t *testing.T) {
tests := []struct {
a string
b string
expected int // >0, <0, or 0
}{
{"22.15.1", "22.15.0", 1}, // a > b
{"22.15.0", "22.15.1", -1}, // a < b
{"22.15.0", "22.15.0", 0}, // equal
{"22.15.0", "22.5.0", 1}, // 15 > 5
{"3.10.0", "3.9.0", 1}, // 10 > 9
{"22.0.0", "21.99.99", 1}, // major takes precedence
{"22.15.1", "22.15.0", 1}, // a > b
{"22.15.0", "22.15.1", -1}, // a < b
{"22.15.0", "22.15.0", 0}, // equal
{"22.15.0", "22.5.0", 1}, // 15 > 5
{"3.10.0", "3.9.0", 1}, // 10 > 9
{"22.0.0", "21.99.99", 1}, // major takes precedence
{"4.0.0", "4.0.0-preview2", 1}, // release above its pre-release
{"4.0.0-preview2", "4.0.0", -1}, // pre-release below its release
{"4.0.1", "4.0.0-preview2", 1}, // numeric parts still come first
{"4.0.0-preview2", "3.4.7", 1}, // pre-release above older releases
{"3.5.0-preview2", "3.5.0-preview1", 1}, // later pre-release first
{"3.5.0-rc1", "3.5.0-preview3", 1}, // rc after preview
{"23.0.0", "23.0.0-rc.1", 1}, // digits in the pre-release are not a fourth part
}

for _, tt := range tests {
Expand Down
Loading