sui-eval 0.1.147

Clean-room Nix language evaluator — lazy tree-walker + bytecode VM with construction-guaranteed Lazy<T>
Documentation
//! Miscellaneous builtins: genericClosure, functionArgs, placeholder, import,
//! scopedImport, getEnv, currentTime, findFile, unsafeGetAttrPos, toFile.

use super::*;

pub(crate) fn register(builtins: &mut NixAttrs) {
    register_builtin(builtins, "functionArgs", |args| {
        match &args[0] {
            Value::Lambda(closure) => {
                let mut result = NixAttrs::new();
                if let rnix::ast::Param::Pattern(pat) = &closure.param {
                    for entry in pat.pat_entries() {
                        if let Some(ident) = entry.ident() {
                            let has_default = entry.default().is_some();
                            result.insert(ident.to_string(), Value::Bool(has_default));
                        }
                    }
                }
                Ok(Value::Attrs(Rc::new(result)))
            }
            Value::Builtin(_) => Ok(Value::Attrs(Rc::new(NixAttrs::new()))),
            _ => Err(EvalError::TypeError("functionArgs: expected function".to_string())),
        }
    });

    // Impure builtins
    register_builtin(builtins, "getEnv", |args| {
        let name = args[0].as_string()?;
        Ok(Value::string(std::env::var(name).unwrap_or_default()))
    });

    register_builtin(builtins, "currentTime", |_args| {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs() as i64)
            .unwrap_or(0);
        Ok(Value::Int(now))
    });

    register_builtin(builtins, "placeholder", |args| {
        // CppNix `hashPlaceholder`: "/" + nix-base32(sha256("nix-output:" + name)).
        // (Not a hex digest, not a "placeholder-" prefix — the byte-exact form is
        // load-bearing: it is embedded verbatim in derivation env/args, so any
        // divergence changes every drv hash that self-references an output.)
        let output = args[0].as_string()?;
        use sha2::{Digest, Sha256};
        let hash = Sha256::digest(format!("nix-output:{output}").as_bytes());
        Ok(Value::string(format!(
            "/{}",
            sui_compat::store_path::nix_base32_encode(hash.as_slice())
        )))
    });

    // genericClosure
    register_builtin(builtins, "genericClosure", |args| {
        use std::collections::VecDeque;
        let input = args[0].to_attrs()?;
        let start_set = input
            .get("startSet")
            .ok_or_else(|| EvalError::AttrNotFound("startSet".into()))?
            .to_list()?;
        let operator = input
            .get("operator")
            .ok_or_else(|| EvalError::AttrNotFound("operator".into()))?
            .clone();

        let mut result: Vec<Value> = Vec::new();
        let mut work_list: VecDeque<Value> = start_set.into();
        let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();

        while let Some(item) = work_list.pop_front() {
            let item_attrs = item.to_attrs()?;
            let key_val = item_attrs
                .get("key")
                .ok_or_else(|| EvalError::AttrNotFound("key".into()))?
                .clone();
            let key_str = format!("{}", crate::eval::force_value(&key_val)?);
            if seen.contains(&key_str) {
                continue;
            }
            seen.insert(key_str);
            result.push(item.clone());
            let new_items = crate::eval::apply_and_force(operator.clone(), item)?;
            let new_list = new_items.to_list()?;
            work_list.extend(new_list);
        }

        Ok(Value::List(Rc::new(NixList::new(result))))
    });

    // scopedImport
    register_curried(builtins, "scopedImport", |scope_val, path_val| {
        let scope = scope_val.to_attrs()?.clone();
        // IFD: `scopedImport … <drv>` realizes the derivation output before read.
        let raw_path = path_val.coerce_to_realized_path("scopedImport")?;
        let resolved = crate::path::resolve_import(
            crate::eval::current_eval_dir().as_deref(),
            &raw_path,
        ).unwrap_or_else(|_| std::path::PathBuf::from(&raw_path));
        let path = resolved.to_string_lossy().into_owned();
        let read_path = crate::path::materialize_str(&path);
        let source = std::fs::read_to_string(&read_path).map_err(|e| EvalError::IoError {
            context: format!("scopedImport {path}"),
            message: e.to_string(),
        })?;
        fn render_scope_attrs(attrs: &NixAttrs) -> Result<String, EvalError> {
            let mut out = String::from("{");
            for (k, v) in attrs.iter() {
                let forced = crate::eval::force_value(v)?;
                let rhs = match &forced {
                    Value::Int(n) => n.to_string(),
                    Value::Float(f) => format!("{f:.6}"),
                    Value::Bool(true) => "true".to_string(),
                    Value::Bool(false) => "false".to_string(),
                    Value::Null => "null".to_string(),
                    Value::String(ns) => {
                        let escaped = ns
                            .chars
                            .replace('\\', "\\\\")
                            .replace('"', "\\\"")
                            .replace('$', "\\$");
                        format!("\"{escaped}\"")
                    }
                    Value::Path(p) => format!("\"{p}\""),
                    other => {
                        return Err(EvalError::NotImplemented(format!(
                            "scopedImport: cannot render scope value of type {} as literal",
                            other.type_name()
                        )))
                    }
                };
                out.push_str(&format!(" {k} = {rhs};"));
            }
            out.push_str(" }");
            Ok(out)
        }
        let scope_src = render_scope_attrs(&scope)?;
        let wrapped = format!("with {scope_src}; ({source})");
        let path_buf = std::path::PathBuf::from(&path);
        let _guard = crate::eval::push_eval_file(path_buf.clone());
        crate::eval::eval_with_file(&wrapped, Some(path_buf))
    });

    // import
    register_builtin(builtins, "import", |args| {
        crate::perf::inc(crate::perf::Counter::Import);
        // IFD: `import <drv>` realizes the derivation output before reading it.
        // This is the marquee darwin root — `import ishou.stylix-fonts` (a
        // `runCommand` derivation) demands its built output mid-eval.
        let raw_path = args[0].coerce_to_realized_path("import")?;
        let resolved = crate::path::resolve_import(
            crate::eval::current_eval_dir().as_deref(),
            &raw_path,
        ).unwrap_or_else(|_| std::path::PathBuf::from(&raw_path));
        let path = resolved.to_string_lossy().into_owned();

        let canonical = crate::path::normalize(std::path::Path::new(&path));

        let cached = IMPORT_CACHE.with(|c| c.borrow().get(&canonical).cloned());
        if let Some(value) = cached {
            crate::perf::inc(crate::perf::Counter::ImportHit);
            return Ok(value);
        }

        // Redirect the on-disk read to the input's real source tree when
        // `path` lies under a fetched flake input's `-source` store prefix;
        // the store-path `path`/`path_buf` (below) is unchanged so relative
        // imports re-enter the remap and eval-dir/string tracking stays
        // byte-correct.
        let read_path = crate::path::materialize_str(&path);
        let source = std::fs::read_to_string(&read_path)
            .map_err(|e| EvalError::IoError { context: format!("import {path}"), message: e.to_string() })?;
        let path_buf = std::path::PathBuf::from(&path);
        let _guard = crate::eval::push_eval_file(path_buf.clone());
        let value = crate::eval::eval_with_file(&source, Some(path_buf))?;

        IMPORT_CACHE.with(|c| c.borrow_mut().insert(canonical, value.clone()));

        Ok(value)
    });

    // unsafeGetAttrPos name set
    //
    // Returns `{ file; line; column; }` for the source position of key
    // `name` in `set` (or `null` when the key/position is unknown). nixpkgs
    // `lib/types.nix`'s `attrTag` derives each tag's `declarations` from
    // `[ pos.file ]`; a `null`-returning stub made every `attrTag` sub-option
    // `declarations` empty (the options.json dock-declarations divergence:
    // `system.defaults.dock.persistent-{apps,others}.*`).
    //
    // The position table is attached to `set` by `eval_attrset` when the set
    // was built from a literal with static keys; `pos_for` resolves the key's
    // byte offset to a file (store-source-lifted) + 1-based line/column.
    register_curried(builtins, "unsafeGetAttrPos", |name, set| {
        let name = crate::eval::force_value(name)?;
        let name = name.as_string()?;
        let set = crate::eval::force_value(set)?;
        let attrs = match &set {
            Value::Attrs(a) => a,
            // CppNix returns null when the first arg isn't found in an
            // attrset; a non-attrset second arg is a type error there, but
            // returning null is the safe, byte-faithful behavior for the
            // paths nixpkgs exercises (it always passes an attrset).
            _ => return Ok(Value::Null),
        };
        match attrs.pos_for(&name) {
            Some(p) => {
                let mut result = NixAttrs::new();
                result.insert("file".to_string(), Value::string(p.file));
                result.insert("line".to_string(), Value::Int(p.line as i64));
                result.insert("column".to_string(), Value::Int(p.column as i64));
                Ok(Value::Attrs(Rc::new(result)))
            }
            None => Ok(Value::Null),
        }
    });

    // findFile (curried)
    register_curried(builtins, "findFile", |search_path, name_val| {
        let entries = search_path.as_list()?;
        let name = name_val.as_string()?;
        for entry in entries {
            let entry = crate::eval::force_value(entry)?;
            let attrs = entry.to_attrs()?;
            let prefix = attrs
                .get("prefix")
                .ok_or_else(|| EvalError::AttrNotFound("prefix".into()))?
                .to_str()?;
            let path = attrs
                .get("path")
                .ok_or_else(|| EvalError::AttrNotFound("path".into()))?
                .to_str()?;
            if name == prefix || name.starts_with(&format!("{prefix}/")) {
                let suffix = if name == prefix {
                    String::new()
                } else {
                    name[prefix.len()..].to_string()
                };
                let full_path = format!("{path}{suffix}");
                if std::path::Path::new(&full_path).exists() {
                    return Ok(Value::Path(Box::new(SmolStr::from(full_path.as_str()))));
                }
            }
        }
        Err(EvalError::TypeError(format!("findFile: file '{name}' not found in search path")))
    });

    // toFile (curried) — compute the CppNix text:sha256 store path
    // (byte-equivalent with cppnix) AND write the content so a
    // subsequent `builtins.readFile` can read it back.  Tries
    // /nix/store first; on PermissionDenied falls back to a
    // process-local sui-tofile-cache so the round-trip succeeds even
    // when the operator isn't a nixbld user.
    register_curried(builtins, "toFile", |name_val, content_val| {
        let name = name_val.as_string()?;
        let content = content_val.as_string()?;
        let store_path =
            sui_compat::content_address::compute_text_store_path(&name, content.as_bytes(), &[])
                .map_err(|e| EvalError::TypeError(
                    format!("toFile: store-path computation failed: {e}"),
                ))?
                .to_absolute_path();
        write_store_text_object(&store_path, content.as_bytes())
            .map_err(|e| EvalError::IoError {
                context: format!("toFile {store_path}"),
                message: e.to_string(),
            })?;
        // CppNix's `builtins.toFile` returns a STRING carrying the store
        // path as opaque (`Plain`) context — NOT a Path value. A Path
        // value would be RE-copied when coerced into a derivation env
        // (per the copy-to-store "a Path is always copied" rule), yielding
        // a doubled `<newhash>-<storehash>-name` path and diverging every
        // consumer (e.g. lua's `setupHook` → neovim, redis). Referencing a
        // String-with-context is verbatim, exactly like nix.
        let mut ctx = StringContext::new();
        ctx.add_plain(store_path.clone());
        Ok(Value::String(std::rc::Rc::new(NixString::with_context(
            SmolStr::from(store_path.as_str()),
            ctx,
        ))))
    });
}

/// Try to materialize `content` at `store_path`, falling back to a
/// per-user sui-tofile-cache when /nix/store is read-only.  Idempotent:
/// writes the file only when missing (or, in the fallback, only when
/// the cached basename hasn't been materialized yet this session).
///
/// Pairs with [`read_store_text_object`] in `paths.rs`, which
/// transparently consults the same fallback location when reading.
pub(crate) fn write_store_text_object(
    store_path: &str,
    content: &[u8],
) -> Result<(), std::io::Error> {
    let primary = std::path::Path::new(store_path);
    if primary.exists() {
        return Ok(());
    }
    match std::fs::write(primary, content) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
            let fallback_dir = std::env::temp_dir().join("sui-tofile-cache");
            std::fs::create_dir_all(&fallback_dir)?;
            let basename = primary
                .file_name()
                .ok_or_else(|| std::io::Error::other(
                    format!("toFile: cannot derive basename from {store_path}"),
                ))?;
            let fallback_path = fallback_dir.join(basename);
            if !fallback_path.exists() {
                std::fs::write(&fallback_path, content)?;
            }
            Ok(())
        }
        Err(e) => Err(e),
    }
}