diff --git a/fix-vm/src/instructions/arithmetic.rs b/fix-vm/src/instructions/arithmetic.rs index a4f110c..0d2c514 100644 --- a/fix-vm/src/instructions/arithmetic.rs +++ b/fix-vm/src/instructions/arithmetic.rs @@ -2,6 +2,7 @@ use std::cmp::Ordering; use gc_arena::{Gc, Mutation, RefLock}; +use crate::instructions::misc::canon_path_str; use crate::value::*; use crate::{BytecodeReader, NixNum, Step, VmError, VmRuntimeCtx, VmRuntimeCtxExt as _}; @@ -14,7 +15,25 @@ impl<'gc> crate::Vm<'gc> { mc: &Mutation<'gc>, ) -> Step { let (lhs, rhs) = self.force_and_retry::<(StrictValue, StrictValue)>(reader, mc)?; - if let (Some(ls), Some(rs)) = (ctx.get_string(lhs), ctx.get_string(rhs)) { + // CppNix: if the LHS is a path, the result is a path obtained by + // canonicalizing the concatenated string. RHS may be a path or a + // string. (A `string + path` keeps the string-typed result, handled + // by the next branch.) + if lhs.is::() { + let (Some(ls), Some(rs)) = (ctx.get_string_or_path(lhs), ctx.get_string_or_path(rhs)) + else { + return self.finish_err(fix_error::Error::eval_error(format!( + "cannot append {} to a path", + rhs.ty() + ))); + }; + let combined = format!("{ls}{rs}"); + let canon = canon_path_str(&combined); + let sid = ctx.intern_string(canon); + self.push(Value::new_inline(crate::value::Path(sid))); + return Step::Continue(()); + } + if let (Some(ls), Some(rs)) = (ctx.get_string(lhs), ctx.get_string_or_path(rhs)) { let ns = Gc::new(mc, crate::NixString::new(format!("{ls}{rs}"))); self.push(Value::new_gc(ns)); return Step::Continue(()); @@ -232,6 +251,10 @@ impl<'gc> crate::Vm<'gc> { if lhs.is::() && rhs.is::() { return Ok(true); } + // Paths only equal paths (not strings, even if their text matches). + if let (Some(a), Some(b)) = (lhs.as_inline::(), rhs.as_inline::()) { + return Ok(a.0 == b.0); + } if let (Some(a), Some(b)) = (ctx.get_string(lhs), ctx.get_string(rhs)) { return Ok(a == b); } @@ -294,6 +317,12 @@ impl<'gc> crate::Vm<'gc> { self.push(Value::new_inline(pred(a.cmp(b)))); return Ok(()); } + if let (Some(a), Some(b)) = (lhs.as_inline::(), rhs.as_inline::()) { + let a = ctx.resolve_string(a.0); + let b = ctx.resolve_string(b.0); + self.push(Value::new_inline(pred(a.cmp(b)))); + return Ok(()); + } // TODO: compare other types Err(crate::vm_err("cannot compare these types")) } diff --git a/fix-vm/src/instructions/misc.rs b/fix-vm/src/instructions/misc.rs index 4bb6a55..7a2d12c 100644 --- a/fix-vm/src/instructions/misc.rs +++ b/fix-vm/src/instructions/misc.rs @@ -5,7 +5,7 @@ use fix_common::StringId; use fix_error::Error; use num_enum::TryFromPrimitive; -use crate::value::{AttrSet, NixString, StrictValue}; +use crate::value::{AttrSet, NixString, Path, StrictValue}; use crate::{BytecodeReader, PrimOp, Step, Value, VmRuntimeCtx, VmRuntimeCtxExt}; impl<'gc> crate::Vm<'gc> { @@ -74,6 +74,10 @@ impl<'gc> crate::Vm<'gc> { let val = self.force_and_retry::(reader, mc)?; if val.is::() || val.is::() { self.push(val.relax()); + } else if let Some(p) = val.as_inline::() { + // Coercing a path to a string yields the canonical path text. + // FIXME: copy to store + self.push(Value::new_inline(p.0)); } else { todo!("coerce other types to string: {:?}", val.ty()); } @@ -120,6 +124,11 @@ impl<'gc> crate::Vm<'gc> { ) -> Step { let path_val = self.force_and_retry::(reader, mc)?; let dir_id = reader.read_string_id(); + // Already a path: keep as-is. ResolvePath is idempotent on paths. + if let Some(p) = path_val.as_inline::() { + self.push(Value::new_inline(p)); + return Step::Continue(()); + } let path = match ctx.get_string(path_val) { Some(s) => s.to_owned(), None => { @@ -134,22 +143,15 @@ impl<'gc> crate::Vm<'gc> { Err(e) => return self.finish_err(e), }; let sid = ctx.intern_string(resolved); - self.push(Value::new_inline(sid)); + self.push(Value::new_inline(Path(sid))); Step::Continue(()) } } -/// Resolve a Nix path literal against `current_dir`. -/// -/// Mirrors nix-js's `op_resolve_path`: absolute paths returned as-is, `~/X` -/// expanded against `$HOME`, otherwise joined onto `current_dir`. The result -/// is normalized by removing `.` components and resolving `..` lexically -/// (no symlink resolution). fn resolve_path_str(current_dir: &str, path: &str) -> Result> { let raw = if path.starts_with('/') { - return Ok(path.to_owned()); + return Ok(canon_path_str(path)); } else if let Some(rest) = path.strip_prefix("~/") { - #[allow(deprecated)] let mut dir = std::env::home_dir().ok_or_else(|| Error::eval_error("home dir not defined"))?; dir.push(rest); @@ -159,8 +161,13 @@ fn resolve_path_str(current_dir: &str, path: &str) -> Result> dir.push(path); dir }; + Ok(canon_path_str(&raw)) +} + +pub(crate) fn canon_path_str(path: impl AsRef) -> String { + let p = path.as_ref(); let mut normalized = PathBuf::new(); - for component in raw.components() { + for component in p.components() { match component { Component::Prefix(p) => normalized.push(p.as_os_str()), Component::RootDir => normalized.push("/"), @@ -171,5 +178,5 @@ fn resolve_path_str(current_dir: &str, path: &str) -> Result> Component::Normal(c) => normalized.push(c), } } - Ok(normalized.to_string_lossy().into_owned()) + normalized.to_string_lossy().into_owned() } diff --git a/fix-vm/src/lib.rs b/fix-vm/src/lib.rs index 793da57..51601fa 100644 --- a/fix-vm/src/lib.rs +++ b/fix-vm/src/lib.rs @@ -98,6 +98,7 @@ pub enum ExtraScope { trait VmRuntimeCtxExt: VmRuntimeCtx { fn get_string<'a, 'gc: 'a>(&'a self, val: StrictValue<'gc>) -> Option<&'a str>; + fn get_string_or_path<'a, 'gc: 'a>(&'a self, val: StrictValue<'gc>) -> Option<&'a str>; fn get_string_id<'a, 'gc: 'a>( &'a mut self, val: StrictValue<'gc>, @@ -114,6 +115,18 @@ impl VmRuntimeCtxExt for T { } } + /// Like `get_string`, but also accepts `Path` values (returning their + /// underlying canonical-path string). Use this in places where Nix + /// would coerce a path to a string (string interpolation, file IO + /// builtins, etc.). + fn get_string_or_path<'a, 'gc: 'a>(&'a self, val: StrictValue<'gc>) -> Option<&'a str> { + if let Some(p) = val.as_inline::() { + Some(self.resolve_string(p.0)) + } else { + self.get_string(val) + } + } + fn get_string_id<'a, 'gc: 'a>( &'a mut self, val: StrictValue<'gc>, @@ -154,6 +167,8 @@ impl ConvertValueWithSeen for T { Value::String(s) } else if let Some(ns) = val.as_gc::() { Value::String(ns.as_str().to_owned()) + } else if let Some(p) = val.as_inline::() { + Value::Path(self.resolve_string(p.0).to_owned()) } else if let Some(attrs) = val.as_gc::() { let bits = val.to_bits(); if attrs.entries.is_empty() { diff --git a/fix-vm/src/primops/conv.rs b/fix-vm/src/primops/conv.rs index 8b13789..1dd1aef 100644 --- a/fix-vm/src/primops/conv.rs +++ b/fix-vm/src/primops/conv.rs @@ -1 +1,53 @@ +use fix_common::StringId; +use fix_error::Error; +use gc_arena::Mutation; +use crate::bytecode_reader::BytecodeReader; +use crate::value::*; +use crate::{Step, Vm, VmRuntimeCtx}; + +impl<'gc> Vm<'gc> { + pub(crate) fn primop_to_string( + &mut self, + _ctx: &mut impl VmRuntimeCtx, + reader: &mut BytecodeReader<'_>, + mc: &Mutation<'gc>, + ) -> Step { + let val = self.force_and_retry::(reader, mc)?; + if val.is::() || val.is::() { + return self.return_from_primop(val.relax(), reader); + } + if let Some(p) = val.as_inline::() { + return self.return_from_primop(Value::new_inline(p.0), reader); + } + // TODO: derivations / `__toString` / `outPath`, + // numbers, lists. + self.finish_err(Error::eval_error(format!( + "cannot coerce {} to a string", + val.ty() + ))) + } + + pub(crate) fn primop_type_of( + &mut self, + ctx: &mut impl VmRuntimeCtx, + reader: &mut BytecodeReader<'_>, + mc: &Mutation<'gc>, + ) -> Step { + let val = self.force_and_retry::(reader, mc)?; + let name: &str = match val.ty() { + NixType::Int => "int", + NixType::Float => "float", + NixType::Bool => "bool", + NixType::Null => "null", + NixType::String => "string", + NixType::Path => "path", + NixType::AttrSet => "set", + NixType::List => "list", + NixType::Closure | NixType::PrimOp | NixType::PrimOpApp => "lambda", + NixType::Thunk => unreachable!("forced"), + }; + let sid = ctx.intern_string(name); + self.return_from_primop(Value::new_inline(sid), reader) + } +} diff --git a/fix-vm/src/primops/io.rs b/fix-vm/src/primops/io.rs index 621c5a2..8f1d9ff 100644 --- a/fix-vm/src/primops/io.rs +++ b/fix-vm/src/primops/io.rs @@ -7,6 +7,7 @@ use gc_arena::{Gc, Mutation}; use hashbrown::HashSet; use crate::bytecode_reader::BytecodeReader; +use crate::instructions::misc::canon_path_str; use crate::value::*; use crate::{Break, CallFrame, PendingLoad, PendingScope, Step, Vm, VmRuntimeCtx, VmRuntimeCtxExt}; @@ -19,11 +20,11 @@ impl<'gc> Vm<'gc> { ) -> Step { // stack: [path] let path_val = self.force_and_retry::(reader, mc)?; - let path_str = match ctx.get_string(path_val) { + let path_str = match ctx.get_string_or_path(path_val) { Some(s) => s.to_owned(), None => { return self.finish_err(Error::eval_error(format!( - "expected a path string, got {}", + "expected a path or string, got {}", path_val.ty() ))); } @@ -93,11 +94,11 @@ impl<'gc> Vm<'gc> { // stack: [scope, path] let (scope_attrs, path_val) = self.force_and_retry::<(Gc, StrictValue)>(reader, mc)?; - let path_str = match ctx.get_string(path_val) { + let path_str = match ctx.get_string_or_path(path_val) { Some(s) => s.to_owned(), None => { return self.finish_err(Error::eval_error(format!( - "expected a path string, got {}", + "expected a path or string, got {}", path_val.ty() ))); } @@ -145,17 +146,28 @@ impl<'gc> Vm<'gc> { mc: &Mutation<'gc>, ) -> Step { let path_val = self.force_and_retry::(reader, mc)?; - let path = match ctx.get_string(path_val) { - Some(s) => s.to_owned(), - None => { - return self.finish_err(Error::eval_error(format!( - "expected a path string, got {}", - path_val.ty() - ))); - } + // CppNix: pathExists requires an absolute path. A `Path` value is + // always absolute; a string is accepted only if it starts with `/`. + let (path, is_path_value) = if let Some(p) = path_val.as_inline::() { + (ctx.resolve_string(p.0).to_owned(), true) + } else if let Some(s) = ctx.get_string(path_val) { + (s.to_owned(), false) + } else { + return self.finish_err(Error::eval_error(format!( + "expected a path or string, got {}", + path_val.ty() + ))); }; + if !is_path_value && !path.starts_with('/') { + return self.finish_err(Error::eval_error(format!( + "string '{path}' doesn't represent an absolute path" + ))); + } + // CppNix collapses consecutive slashes and resolves `.` / `..` lexically + // before checking. Trailing-slash / trailing-dot mean "must be a directory". let must_be_dir = path.ends_with('/') || path.ends_with("/."); - let p = std::path::Path::new(&path); + let canon = canon_path_str(&path); + let p = std::path::Path::new(&canon); let exists = if must_be_dir { std::fs::metadata(p).map(|m| m.is_dir()).unwrap_or(false) } else { diff --git a/fix-vm/src/primops/mod.rs b/fix-vm/src/primops/mod.rs index 90e7cf0..ff9bc3b 100644 --- a/fix-vm/src/primops/mod.rs +++ b/fix-vm/src/primops/mod.rs @@ -62,6 +62,10 @@ impl<'gc> Vm<'gc> { ScopedImportFinalize => self.primop_scoped_import_finalize(ctx, reader, mc), PathExists => self.primop_path_exists(ctx, reader, mc), + ToPath => self.primop_to_path(ctx, reader, mc), + IsPath => self.primop_is_path(reader, mc), + ToString => self.primop_to_string(ctx, reader, mc), + TypeOf => self.primop_type_of(ctx, reader, mc), phase => todo!("primop phase {phase:?}"), } diff --git a/fix-vm/src/primops/path.rs b/fix-vm/src/primops/path.rs index 8b13789..24550f6 100644 --- a/fix-vm/src/primops/path.rs +++ b/fix-vm/src/primops/path.rs @@ -1 +1,46 @@ +use fix_error::Error; +use gc_arena::Mutation; +use crate::bytecode_reader::BytecodeReader; +use crate::instructions::misc::canon_path_str; +use crate::value::*; +use crate::{Step, Vm, VmRuntimeCtx, VmRuntimeCtxExt}; + +impl<'gc> Vm<'gc> { + pub(crate) fn primop_to_path( + &mut self, + ctx: &mut impl VmRuntimeCtx, + reader: &mut BytecodeReader<'_>, + mc: &Mutation<'gc>, + ) -> Step { + // coerce to path THEN TO STRING + let val = self.force_and_retry::(reader, mc)?; + if let Some(Path(s)) = val.as_inline::() { + return self.return_from_primop(Value::new_inline(s), reader); + } + let Some(s) = ctx.get_string(val) else { + return self.finish_err(Error::eval_error(format!( + "cannot coerce {} to a path", + val.ty() + ))); + }; + if !s.starts_with('/') { + return self.finish_err(Error::eval_error(format!( + "string '{s}' doesn't represent an absolute path" + ))); + } + let canon = canon_path_str(s); + let sid = ctx.intern_string(canon); + self.return_from_primop(Value::new_inline(sid), reader) + } + + pub(crate) fn primop_is_path( + &mut self, + reader: &mut BytecodeReader<'_>, + mc: &Mutation<'gc>, + ) -> Step { + let val = self.force_and_retry::(reader, mc)?; + let is_path = val.is::(); + self.return_from_primop(Value::new_inline(is_path), reader) + } +} diff --git a/fix-vm/src/value.rs b/fix-vm/src/value.rs index e91cedb..e5da1eb 100644 --- a/fix-vm/src/value.rs +++ b/fix-vm/src/value.rs @@ -117,6 +117,7 @@ define_value_types! { Null => RawTag::P3, "Null"; StringId => RawTag::P4, "SmallString"; PrimOp => RawTag::P5, "PrimOp"; + Path => RawTag::N6, "Path"; } gc { i64 => RawTag::P6, "BigInt"; @@ -289,6 +290,8 @@ impl<'gc> Value<'gc> { NixType::PrimOp } else if self.is::() { NixType::String + } else if self.is::() { + NixType::Path } else if self.is::() { NixType::AttrSet } else if self.is::() { @@ -404,6 +407,20 @@ impl RawStore for StringId { } } +/// A canonicalized absolute path. Inline value carrying an interned +/// `StringId` whose contents are the path's absolute, dot-resolved form. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Path(pub StringId); + +impl RawStore for Path { + fn to_val(self, value: &mut RawValue) { + self.0.to_val(value); + } + fn from_val(value: &RawValue) -> Self { + Self(StringId::from_val(value)) + } +} + /// Heap-allocated Nix string. /// /// Stored on the GC heap via `Gc<'gc, NixString>`. The string data itself @@ -659,6 +676,7 @@ pub(crate) enum NixType { Bool, Null, String, + Path, AttrSet, List, Thunk, @@ -676,6 +694,7 @@ impl NixType { Bool => "a boolean", Null => "null", String => "a string", + Path => "a path", AttrSet => "a set", List => "a list", Thunk => "a thunk",