diff --git a/CLAUDE.md b/CLAUDE.md index dd37676..32fbf36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 046cfc5..663cbc0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/cmd/current.go b/src/cmd/current.go index 0e28445..aae0411 100644 --- a/src/cmd/current.go +++ b/src/cmd/current.go @@ -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 diff --git a/src/internal/config/versionfile.go b/src/internal/config/versionfile.go index 23f5a72..c1ef1b3 100644 --- a/src/internal/config/versionfile.go +++ b/src/internal/config/versionfile.go @@ -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 @@ -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. @@ -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 @@ -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 diff --git a/src/internal/config/versionfile_test.go b/src/internal/config/versionfile_test.go index 27e1da6..bb0d8e4 100644 --- a/src/internal/config/versionfile_test.go +++ b/src/internal/config/versionfile_test.go @@ -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 @@ -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) diff --git a/src/internal/version/matcher.go b/src/internal/version/matcher.go index 60a8114..7069fec 100644 --- a/src/internal/version/matcher.go +++ b/src/internal/version/matcher.go @@ -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) diff --git a/src/internal/version/matcher_test.go b/src/internal/version/matcher_test.go index 4a7481b..bc7db16 100644 --- a/src/internal/version/matcher_test.go +++ b/src/internal/version/matcher_test.go @@ -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 {