From da415708c44dd0eaccdd1848bcdce334462ec0d7 Mon Sep 17 00:00:00 2001 From: Dan Bjorge Date: Wed, 14 Jan 2026 16:05:04 -0800 Subject: [PATCH 1/2] feat: add comment support for .nvmrc files Implements comment stripping for .nvmrc files following nvm v0.40.0 behavior. Comments (anything after #) are now stripped from .nvmrc files, supporting both inline comments and full-line comments. Leaves .node-version parsing as-is. Changes: - Add nvmrc.rs module for .nvmrc parsing with comment support - Refactor format detection to version_file_format.rs - Add unit tests for comment stripping and format detection - Add E2E tests for various comment scenarios Fixes: #1209 --- .changeset/mighty-pots-fetch.md | 5 + e2e/__snapshots__/nvmrc-comments.test.ts.snap | 247 ++++++++++++++++++ e2e/nvmrc-comments.test.ts | 86 ++++++ src/main.rs | 2 + src/nvmrc.rs | 128 +++++++++ src/version_file_format.rs | 60 +++++ src/version_files.rs | 36 ++- 7 files changed, 552 insertions(+), 12 deletions(-) create mode 100644 .changeset/mighty-pots-fetch.md create mode 100644 e2e/__snapshots__/nvmrc-comments.test.ts.snap create mode 100644 e2e/nvmrc-comments.test.ts create mode 100644 src/nvmrc.rs create mode 100644 src/version_file_format.rs diff --git a/.changeset/mighty-pots-fetch.md b/.changeset/mighty-pots-fetch.md new file mode 100644 index 000000000..e20c3b3a3 --- /dev/null +++ b/.changeset/mighty-pots-fetch.md @@ -0,0 +1,5 @@ +--- +"fnm": minor +--- + +support comments in .nvmrc files diff --git a/e2e/__snapshots__/nvmrc-comments.test.ts.snap b/e2e/__snapshots__/nvmrc-comments.test.ts.snap new file mode 100644 index 000000000..b364a57be --- /dev/null +++ b/e2e/__snapshots__/nvmrc-comments.test.ts.snap @@ -0,0 +1,247 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Bash .node-version should not strip comments: Bash 1`] = ` +"set -e +eval "$(fnm env)" +fnm install +fnm use +if [ "$(node --version)" != "v8.11.3" ]; then + echo "Expected node version to be v8.11.3. Got $(node --version)" + exit 1 +fi" +`; + +exports[`Bash .nvmrc with comment on first line: Bash 1`] = ` +"set -e +eval "$(fnm env)" +fnm install +fnm use +if [ "$(node --version)" != "v8.11.3" ]; then + echo "Expected node version to be v8.11.3. Got $(node --version)" + exit 1 +fi" +`; + +exports[`Bash .nvmrc with empty lines and comments: Bash 1`] = ` +"set -e +eval "$(fnm env)" +fnm install +fnm use +if [ "$(node --version)" != "v8.11.3" ]; then + echo "Expected node version to be v8.11.3. Got $(node --version)" + exit 1 +fi" +`; + +exports[`Bash .nvmrc with inline comment after version: Bash 1`] = ` +"set -e +eval "$(fnm env)" +fnm install +fnm use +if [ "$(node --version)" != "v8.11.3" ]; then + echo "Expected node version to be v8.11.3. Got $(node --version)" + exit 1 +fi" +`; + +exports[`Bash .nvmrc with multiple comment lines: Bash 1`] = ` +"set -e +eval "$(fnm env)" +fnm install +fnm use +if [ "$(node --version)" != "v8.11.3" ]; then + echo "Expected node version to be v8.11.3. Got $(node --version)" + exit 1 +fi" +`; + +exports[`Bash .nvmrc with whitespace and inline comments: Bash 1`] = ` +"set -e +eval "$(fnm env)" +fnm install +fnm use +if [ "$(node --version)" != "v8.11.3" ]; then + echo "Expected node version to be v8.11.3. Got $(node --version)" + exit 1 +fi" +`; + +exports[`Fish .node-version should not strip comments: Fish 1`] = ` +"fnm env | source +fnm install +fnm use +set ____test____ (node --version) +if test "$____test____" != "v8.11.3" + echo "Expected node version to be v8.11.3. Got $____test____" + exit 1 +end" +`; + +exports[`Fish .nvmrc with comment on first line: Fish 1`] = ` +"fnm env | source +fnm install +fnm use +set ____test____ (node --version) +if test "$____test____" != "v8.11.3" + echo "Expected node version to be v8.11.3. Got $____test____" + exit 1 +end" +`; + +exports[`Fish .nvmrc with empty lines and comments: Fish 1`] = ` +"fnm env | source +fnm install +fnm use +set ____test____ (node --version) +if test "$____test____" != "v8.11.3" + echo "Expected node version to be v8.11.3. Got $____test____" + exit 1 +end" +`; + +exports[`Fish .nvmrc with inline comment after version: Fish 1`] = ` +"fnm env | source +fnm install +fnm use +set ____test____ (node --version) +if test "$____test____" != "v8.11.3" + echo "Expected node version to be v8.11.3. Got $____test____" + exit 1 +end" +`; + +exports[`Fish .nvmrc with multiple comment lines: Fish 1`] = ` +"fnm env | source +fnm install +fnm use +set ____test____ (node --version) +if test "$____test____" != "v8.11.3" + echo "Expected node version to be v8.11.3. Got $____test____" + exit 1 +end" +`; + +exports[`Fish .nvmrc with whitespace and inline comments: Fish 1`] = ` +"fnm env | source +fnm install +fnm use +set ____test____ (node --version) +if test "$____test____" != "v8.11.3" + echo "Expected node version to be v8.11.3. Got $____test____" + exit 1 +end" +`; + +exports[`PowerShell .node-version should not strip comments: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env | Out-String | Invoke-Expression +fnm install +fnm use +if ( "$(node --version)" -ne "v8.11.3" ) { echo "Expected node version to be v8.11.3. Got $(node --version)"; exit 1 }" +`; + +exports[`PowerShell .nvmrc with comment on first line: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env | Out-String | Invoke-Expression +fnm install +fnm use +if ( "$(node --version)" -ne "v8.11.3" ) { echo "Expected node version to be v8.11.3. Got $(node --version)"; exit 1 }" +`; + +exports[`PowerShell .nvmrc with empty lines and comments: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env | Out-String | Invoke-Expression +fnm install +fnm use +if ( "$(node --version)" -ne "v8.11.3" ) { echo "Expected node version to be v8.11.3. Got $(node --version)"; exit 1 }" +`; + +exports[`PowerShell .nvmrc with inline comment after version: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env | Out-String | Invoke-Expression +fnm install +fnm use +if ( "$(node --version)" -ne "v8.11.3" ) { echo "Expected node version to be v8.11.3. Got $(node --version)"; exit 1 }" +`; + +exports[`PowerShell .nvmrc with multiple comment lines: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env | Out-String | Invoke-Expression +fnm install +fnm use +if ( "$(node --version)" -ne "v8.11.3" ) { echo "Expected node version to be v8.11.3. Got $(node --version)"; exit 1 }" +`; + +exports[`PowerShell .nvmrc with whitespace and inline comments: PowerShell 1`] = ` +"$ErrorActionPreference = "Stop" +fnm env | Out-String | Invoke-Expression +fnm install +fnm use +if ( "$(node --version)" -ne "v8.11.3" ) { echo "Expected node version to be v8.11.3. Got $(node --version)"; exit 1 }" +`; + +exports[`Zsh .node-version should not strip comments: Zsh 1`] = ` +"set -e +eval "$(fnm env)" +fnm install +fnm use +if [ "$(node --version)" != "v8.11.3" ]; then + echo "Expected node version to be v8.11.3. Got $(node --version)" + exit 1 +fi" +`; + +exports[`Zsh .nvmrc with comment on first line: Zsh 1`] = ` +"set -e +eval "$(fnm env)" +fnm install +fnm use +if [ "$(node --version)" != "v8.11.3" ]; then + echo "Expected node version to be v8.11.3. Got $(node --version)" + exit 1 +fi" +`; + +exports[`Zsh .nvmrc with empty lines and comments: Zsh 1`] = ` +"set -e +eval "$(fnm env)" +fnm install +fnm use +if [ "$(node --version)" != "v8.11.3" ]; then + echo "Expected node version to be v8.11.3. Got $(node --version)" + exit 1 +fi" +`; + +exports[`Zsh .nvmrc with inline comment after version: Zsh 1`] = ` +"set -e +eval "$(fnm env)" +fnm install +fnm use +if [ "$(node --version)" != "v8.11.3" ]; then + echo "Expected node version to be v8.11.3. Got $(node --version)" + exit 1 +fi" +`; + +exports[`Zsh .nvmrc with multiple comment lines: Zsh 1`] = ` +"set -e +eval "$(fnm env)" +fnm install +fnm use +if [ "$(node --version)" != "v8.11.3" ]; then + echo "Expected node version to be v8.11.3. Got $(node --version)" + exit 1 +fi" +`; + +exports[`Zsh .nvmrc with whitespace and inline comments: Zsh 1`] = ` +"set -e +eval "$(fnm env)" +fnm install +fnm use +if [ "$(node --version)" != "v8.11.3" ]; then + echo "Expected node version to be v8.11.3. Got $(node --version)" + exit 1 +fi" +`; diff --git a/e2e/nvmrc-comments.test.ts b/e2e/nvmrc-comments.test.ts new file mode 100644 index 000000000..50cd836a4 --- /dev/null +++ b/e2e/nvmrc-comments.test.ts @@ -0,0 +1,86 @@ +import { writeFile } from "node:fs/promises" +import { join } from "node:path" +import { script } from "./shellcode/script.js" +import { Bash, Fish, PowerShell, WinCmd, Zsh } from "./shellcode/shells.js" +import testCwd from "./shellcode/test-cwd.js" +import testNodeVersion from "./shellcode/test-node-version.js" +import describe from "./describe.js" + +for (const shell of [Bash, Zsh, Fish, PowerShell, WinCmd]) { + describe(shell, () => { + test(`.nvmrc with comment on first line`, async () => { + await writeFile(join(testCwd(), ".nvmrc"), "# comment\nv8.11.3") + await script(shell) + .then(shell.env({})) + .then(shell.call("fnm", ["install"])) + .then(shell.call("fnm", ["use"])) + .then(testNodeVersion(shell, "v8.11.3")) + .takeSnapshot(shell) + .execute(shell) + }) + + test(`.nvmrc with inline comment after version`, async () => { + await writeFile(join(testCwd(), ".nvmrc"), "v8.11.3 # this is Node 8 LTS") + await script(shell) + .then(shell.env({})) + .then(shell.call("fnm", ["install"])) + .then(shell.call("fnm", ["use"])) + .then(testNodeVersion(shell, "v8.11.3")) + .takeSnapshot(shell) + .execute(shell) + }) + + test(`.nvmrc with multiple comment lines`, async () => { + await writeFile( + join(testCwd(), ".nvmrc"), + "# First comment\n# Second comment\n\nv8.11.3\n# Trailing comment", + ) + await script(shell) + .then(shell.env({})) + .then(shell.call("fnm", ["install"])) + .then(shell.call("fnm", ["use"])) + .then(testNodeVersion(shell, "v8.11.3")) + .takeSnapshot(shell) + .execute(shell) + }) + + test(`.nvmrc with empty lines and comments`, async () => { + await writeFile( + join(testCwd(), ".nvmrc"), + "\n\n# comment\n\nv8.11.3\n\n# another comment", + ) + await script(shell) + .then(shell.env({})) + .then(shell.call("fnm", ["install"])) + .then(shell.call("fnm", ["use"])) + .then(testNodeVersion(shell, "v8.11.3")) + .takeSnapshot(shell) + .execute(shell) + }) + + test(`.nvmrc with whitespace and inline comments`, async () => { + await writeFile( + join(testCwd(), ".nvmrc"), + " # comment with spaces \n v8.11.3 # inline comment ", + ) + await script(shell) + .then(shell.env({})) + .then(shell.call("fnm", ["install"])) + .then(shell.call("fnm", ["use"])) + .then(testNodeVersion(shell, "v8.11.3")) + .takeSnapshot(shell) + .execute(shell) + }) + + test(`.node-version should not strip comments`, async () => { + await writeFile(join(testCwd(), ".node-version"), "v8.11.3") + await script(shell) + .then(shell.env({})) + .then(shell.call("fnm", ["install"])) + .then(shell.call("fnm", ["use"])) + .then(testNodeVersion(shell, "v8.11.3")) + .takeSnapshot(shell) + .execute(shell) + }) + }) +} diff --git a/src/main.rs b/src/main.rs index 6ab10eab5..cd3e68a96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod fs; mod http; mod installed_versions; mod lts; +mod nvmrc; mod package_json; mod path_ext; mod progress; @@ -30,6 +31,7 @@ mod system_version; mod user_version; mod user_version_reader; mod version; +mod version_file_format; mod version_file_strategy; mod version_files; diff --git a/src/nvmrc.rs b/src/nvmrc.rs new file mode 100644 index 000000000..405a45d0e --- /dev/null +++ b/src/nvmrc.rs @@ -0,0 +1,128 @@ +use crate::user_version::UserVersion; +use std::str::FromStr; + +pub struct Nvmrc { + content: String, +} + +impl Nvmrc { + pub fn parse(content: String) -> Option { + Some(Self { content }) + } + + pub fn version(self) -> Option { + let cleaned = Self::strip_comments(&self.content); + if cleaned.is_empty() { + return None; + } + UserVersion::from_str(&cleaned).ok() + } + + fn strip_comments(content: &str) -> String { + content + .lines() + .map(|line| { + // Remove everything after # (including #) + line.split('#').next().unwrap_or("") + }) + .map(|line| line.trim()) + .find(|line| !line.is_empty()) + .unwrap_or("") + .to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_comments_with_leading_comment() { + let content = "# This is a comment\n20.16.0"; + assert_eq!(Nvmrc::strip_comments(content), "20.16.0"); + } + + #[test] + fn test_strip_comments_with_inline_comment() { + let content = "20.16.0 # this is Node 20 LTS"; + assert_eq!(Nvmrc::strip_comments(content), "20.16.0"); + } + + #[test] + fn test_strip_comments_with_multiple_comments() { + let content = "# First comment\n# Second comment\n18.0.0\n# Trailing comment"; + assert_eq!(Nvmrc::strip_comments(content), "18.0.0"); + } + + #[test] + fn test_strip_comments_with_lts_alias() { + let content = "lts/iron\n\n# (maps to Node v20 LTS)"; + assert_eq!(Nvmrc::strip_comments(content), "lts/iron"); + } + + #[test] + fn test_strip_comments_with_empty_lines() { + let content = "\n\n# comment\n\nv21.x.x\n\n# another comment"; + assert_eq!(Nvmrc::strip_comments(content), "v21.x.x"); + } + + #[test] + fn test_strip_comments_with_whitespace() { + let content = " # comment with spaces \n 20.16.0 "; + assert_eq!(Nvmrc::strip_comments(content), "20.16.0"); + } + + #[test] + fn test_strip_comments_only_comments() { + let content = "# comment 1\n# comment 2\n# comment 3"; + assert_eq!(Nvmrc::strip_comments(content), ""); + } + + #[test] + fn test_strip_comments_empty_string() { + let content = ""; + assert_eq!(Nvmrc::strip_comments(content), ""); + } + + #[test] + fn test_strip_comments_whitespace_only() { + let content = " \n\n \n"; + assert_eq!(Nvmrc::strip_comments(content), ""); + } + + #[test] + fn test_strip_comments_no_comments() { + let content = "20.16.0"; + assert_eq!(Nvmrc::strip_comments(content), "20.16.0"); + } + + #[test] + fn test_strip_comments_multiple_versions_takes_first() { + let content = "20.16.0\n18.0.0"; + assert_eq!(Nvmrc::strip_comments(content), "20.16.0"); + } + + #[test] + fn test_strip_comments_with_v_prefix() { + let content = "# Use Node 20\nv20.16.0 # Latest LTS"; + assert_eq!(Nvmrc::strip_comments(content), "v20.16.0"); + } + + #[test] + fn test_version_parsing() { + let nvmrc = Nvmrc::parse("# comment\n20.16.0".to_string()).unwrap(); + assert!(nvmrc.version().is_some()); + } + + #[test] + fn test_version_parsing_with_inline_comment() { + let nvmrc = Nvmrc::parse("20.16.0 # comment".to_string()).unwrap(); + assert!(nvmrc.version().is_some()); + } + + #[test] + fn test_version_parsing_only_comments() { + let nvmrc = Nvmrc::parse("# only comments".to_string()).unwrap(); + assert!(nvmrc.version().is_none()); + } +} diff --git a/src/version_file_format.rs b/src/version_file_format.rs new file mode 100644 index 000000000..d9933fbb7 --- /dev/null +++ b/src/version_file_format.rs @@ -0,0 +1,60 @@ +use std::path::Path; + +#[derive(Debug, PartialEq)] +pub enum VersionFileFormat { + Nvmrc, + PackageJson, + Other, +} + +impl VersionFileFormat { + pub fn infer_from_path(path: &Path) -> Self { + match path.file_name().and_then(|name| name.to_str()) { + Some(".nvmrc") => Self::Nvmrc, + Some("package.json") => Self::PackageJson, + _ => Self::Other, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_infer_nvmrc() { + let path = PathBuf::from("/some/path/.nvmrc"); + assert_eq!( + VersionFileFormat::infer_from_path(&path), + VersionFileFormat::Nvmrc + ); + } + + #[test] + fn test_infer_package_json() { + let path = PathBuf::from("/some/path/package.json"); + assert_eq!( + VersionFileFormat::infer_from_path(&path), + VersionFileFormat::PackageJson + ); + } + + #[test] + fn test_infer_node_version() { + let path = PathBuf::from("/some/path/.node-version"); + assert_eq!( + VersionFileFormat::infer_from_path(&path), + VersionFileFormat::Other + ); + } + + #[test] + fn test_infer_unknown() { + let path = PathBuf::from("/some/path/unknown.txt"); + assert_eq!( + VersionFileFormat::infer_from_path(&path), + VersionFileFormat::Other + ); + } +} diff --git a/src/version_files.rs b/src/version_files.rs index 59c54adb0..e064f0ce3 100644 --- a/src/version_files.rs +++ b/src/version_files.rs @@ -1,7 +1,9 @@ use crate::config::FnmConfig; use crate::default_version; +use crate::nvmrc::Nvmrc; use crate::package_json::PackageJson; use crate::user_version::UserVersion; +use crate::version_file_format::VersionFileFormat; use crate::version_file_strategy::VersionFileStrategy; use encoding_rs_io::DecodeReaderBytes; use log::info; @@ -67,10 +69,7 @@ pub fn get_user_version_for_file( path: impl AsRef, config: &FnmConfig, ) -> Option { - let is_pkg_json = match path.as_ref().file_name() { - Some(name) => name == "package.json", - None => false, - }; + let format = VersionFileFormat::infer_from_path(path.as_ref()); let file = std::fs::File::open(path).ok()?; let file = { let mut reader = DecodeReaderBytes::new(file); @@ -78,17 +77,16 @@ pub fn get_user_version_for_file( reader.read_to_string(&mut version).map(|_| version) }; - match (file, is_pkg_json, config.resolve_engines()) { - (_, true, false) => None, - (Err(err), _, _) => { + match (file, format) { + (Err(err), _) => { info!("Can't read file: {}", err); None } - (Ok(version), false, _) => { - info!("Found string {:?} in version file", version); - UserVersion::from_str(version.trim()).ok() - } - (Ok(pkg_json), true, true) => { + (Ok(pkg_json), VersionFileFormat::PackageJson) => { + if !config.resolve_engines() { + return None; + } + let pkg_json = serde_json::from_str::(&pkg_json).ok(); let range: Option = pkg_json.as_ref().and_then(PackageJson::node_range).cloned(); @@ -101,5 +99,19 @@ pub fn get_user_version_for_file( None } } + (Ok(nvmrc), VersionFileFormat::Nvmrc) => { + let version = Nvmrc::parse(nvmrc).and_then(Nvmrc::version); + + if let Some(version) = &version { + info!("Parsed {:?} from .nvmrc", version); + } else { + info!("Could not parse .nvmrc"); + } + version + } + (Ok(content), VersionFileFormat::Other) => { + info!("Found string {:?} in version file", content); + UserVersion::from_str(content.trim()).ok() + } } } From 0b19a85d1e34796f872023b75fbbc87114e0533a Mon Sep 17 00:00:00 2001 From: Dan Bjorge Date: Wed, 14 Jan 2026 16:16:42 -0800 Subject: [PATCH 2/2] refactor: clean up syntax of nvmrc parsing logs --- src/version_files.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/version_files.rs b/src/version_files.rs index e064f0ce3..331b2f4f9 100644 --- a/src/version_files.rs +++ b/src/version_files.rs @@ -101,11 +101,9 @@ pub fn get_user_version_for_file( } (Ok(nvmrc), VersionFileFormat::Nvmrc) => { let version = Nvmrc::parse(nvmrc).and_then(Nvmrc::version); - - if let Some(version) = &version { - info!("Parsed {:?} from .nvmrc", version); - } else { - info!("Could not parse .nvmrc"); + match &version { + Some(v) => info!("Parsed {:?} from .nvmrc", v), + None => info!("Could not parse .nvmrc"), } version }