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
23 changes: 6 additions & 17 deletions crates/vp_global_cli/src/commands/env/pin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

use std::{io::Write, process::ExitStatus};

use vp_js_runtime::{NodeProvider, VersionSource, resolve_node_version};
use vp_js_runtime::{NodeProvider, VersionSource, nvmrc_version_spans, resolve_node_version};
use vp_pm_cli::{
PackageManagerType, download_package_manager_for_cwd,
resolve_package_manager_from_package_json, resolve_package_manager_version_for_cwd,
Expand Down Expand Up @@ -445,24 +445,13 @@ async fn pin_node_version_file(
Ok(true)
}

/// Locate the version token using the same line rules as read_nvmrc_file.
/// Locate the version token so pinning replaces only it.
/// Keep comments, reserved key/value lines, whitespace, and line endings intact.
fn nvmrc_version_span(content: &str) -> Result<Option<std::ops::Range<usize>>, Error> {
let mut span = None;
let mut offset = 0;
for line in content.split_inclusive('\n') {
let value = line.split_once('#').map_or(line, |(value, _)| value);
let trimmed = value.trim();
if !trimmed.is_empty() && !trimmed.contains('=') {
if span.is_some() {
return Err(Error::Other(
"cannot pin .nvmrc with multiple version declarations".into(),
));
}
let start = offset + value.len() - value.trim_start().len();
span = Some(start..start + trimmed.len());
}
offset += line.len();
let mut spans = nvmrc_version_spans(content);
let span = spans.next();
if spans.next().is_some() {
return Err(Error::Other("cannot pin .nvmrc with multiple version declarations".into()));
}
Ok(span)
}
Expand Down
69 changes: 48 additions & 21 deletions crates/vp_js_runtime/src/dev_engines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
//!
//! For `PackageJson` types (devEngines, engines), see `vp_shared::package_json`.

use std::ops::Range;

// Re-export shared types for internal use
pub use vp_shared::PackageJson;
use vt_path::AbsolutePath;
Expand Down Expand Up @@ -64,12 +66,32 @@ pub async fn read_node_version_file(project_path: &AbsolutePath) -> Option<Str>
pub async fn read_nvmrc_file(project_path: &AbsolutePath) -> Option<Str> {
let path = project_path.join(".nvmrc");
let content = tokio::fs::read_to_string(&path).await.ok()?;
let mut versions = content.lines().filter_map(|line| {
let value = line.split_once('#').map_or(line, |(value, _)| value).trim();
(!value.is_empty() && !value.contains('=')).then_some(value)
});
let version = versions.next()?;
if versions.next().is_some() {
parse_nvmrc_content(&content)
}

/// Byte ranges of the version declarations in `.nvmrc` content.
///
/// Blank lines, `#` comments, and reserved key/value lines are skipped. More
/// than one declaration is ambiguous, so callers reject it.
pub fn nvmrc_version_spans(content: &str) -> impl Iterator<Item = Range<usize>> + '_ {
let mut offset = 0;
content.split_inclusive('\n').filter_map(move |line| {
let line_start = offset;
offset += line.len();
let value = line.split_once('#').map_or(line, |(value, _)| value);
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.contains('=') {
return None;
}
let start = line_start + value.len() - value.trim_start().len();
Some(start..start + trimmed.len())
})
}

fn parse_nvmrc_content(content: &str) -> Option<Str> {
let mut spans = nvmrc_version_spans(content);
let version = &content[spans.next()?];
if spans.next().is_some() {
return None;
}

Expand Down Expand Up @@ -148,23 +170,28 @@ mod tests {

tokio::fs::write(temp_path.join(".nvmrc"), "v22.13.1\n").await.unwrap();
assert_eq!(read_nvmrc_file(&temp_path).await, Some("22.13.1".into()));
}

tokio::fs::write(temp_path.join(".nvmrc"), "node\n").await.unwrap();
assert_eq!(read_nvmrc_file(&temp_path).await, Some("latest".into()));

tokio::fs::write(
temp_path.join(".nvmrc"),
"# Use the project version\n\nv22.13.1 # pinned\nNVM_SYMLINK_CURRENT=true\n",
)
.await
.unwrap();
assert_eq!(read_nvmrc_file(&temp_path).await, Some("22.13.1".into()));

tokio::fs::write(temp_path.join(".nvmrc"), "20\n22\n").await.unwrap();
assert!(read_nvmrc_file(&temp_path).await.is_none());
#[test]
fn test_parse_nvmrc_content() {
assert_eq!(parse_nvmrc_content("v22.13.1\n"), Some("22.13.1".into()));
assert_eq!(parse_nvmrc_content("node\n"), Some("latest".into()));
assert_eq!(
parse_nvmrc_content(
"# Use the project version\n\nv22.13.1 # pinned\nNVM_SYMLINK_CURRENT=true\n"
),
Some("22.13.1".into())
);
assert!(parse_nvmrc_content("20\n22\n").is_none());
assert!(parse_nvmrc_content("system\n").is_none());
assert!(parse_nvmrc_content("# comment only\n").is_none());
}

tokio::fs::write(temp_path.join(".nvmrc"), "system\n").await.unwrap();
assert!(read_nvmrc_file(&temp_path).await.is_none());
#[test]
fn test_nvmrc_version_spans() {
let content = "# Node\r\n v20.18.0 # keep\r\nkey=value\r\n\r\n22";
let versions: Vec<_> = nvmrc_version_spans(content).map(|span| &content[span]).collect();
assert_eq!(versions, ["v20.18.0", "22"]);
}

// ========================================================================
Expand Down
4 changes: 3 additions & 1 deletion crates/vp_js_runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ mod provider;
mod providers;
mod runtime;

pub use dev_engines::{parse_node_version_content, read_node_version_file, read_nvmrc_file};
pub use dev_engines::{
nvmrc_version_spans, parse_node_version_content, read_node_version_file, read_nvmrc_file,
};
pub use error::Error;
pub use platform::{Arch, Os, Platform};
pub use provider::{
Expand Down
Loading