diff --git a/Cargo.lock b/Cargo.lock index 8f4d5fd..626fb41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,17 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + [[package]] name = "anstyle" version = "1.0.14" @@ -2603,6 +2614,7 @@ dependencies = [ name = "rix-cli" version = "0.1.0" dependencies = [ + "annotate-snippets", "colored", "rix-eval", "rix-parser", @@ -2616,6 +2628,7 @@ dependencies = [ name = "rix-eval" version = "0.1.0" dependencies = [ + "annotate-snippets", "base32", "codespan", "codespan-reporting", @@ -2625,6 +2638,7 @@ dependencies = [ "gix", "hex", "md5", + "parking_lot", "proptest", "quick-xml", "regex", diff --git a/Cargo.toml b/Cargo.toml index 0e3775f..8fd4d65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ rix-eval = { path = "rix-eval" } usage = { package = "usage-rs", version = "6.9.1" } thiserror = "2.0.20" rootcause = "0.13.0" +annotate-snippets = "0.12.16" rowan = "0.17.0" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" @@ -35,6 +36,7 @@ reqwest = { version = "0.13", default-features = false, features = ["blocking", flate2 = { version = "1.1.10", features = ["zlib-rs"], default-features = false } tar = "0.4" colored = "2.2" +parking_lot = "0.12" [profile.dev.package."*"] opt-level = 3 diff --git a/README.md b/README.md index 191181b..f2af6e7 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,46 @@ This project is based on: - [Tom's nix-eval crate](https://github.com/Industrial/nixos-dotfiles/tree/main/rust/tools/nix-eval) - [rnix parser](https://github.com/nix-community/rnix-parser) + +## Cross compilation (work in progress) + +Rix tracks two platform triples (a `cpu-os` pair such as `x86_64-linux` or +`x86_64-darwin`) while building: + +- the *build* platform performs the build (the machine running `rix`), and +- the *host* platform runs the produced package. + +They are configured with global flags: + +```console +$ rix build -A xnu --system x86_64-linux --cross-system x86_64-darwin +``` + +- `--system` sets the build platform and what `builtins.currentSystem` + reports. It defaults to the running machine. +- `--cross-system` sets the host platform. When it differs from the build + platform the build is a cross build. +- `--runner` gives the command used to execute host-platform binaries during + the build (for example `--runner darling` for Darwin binaries on Linux, or + `--runner wine` for Windows). + +The platforms are exposed to build scripts as `$buildPlatform`, +`$hostPlatform` and `$system` (the host platform), and to Nix expressions as + +- `builtins.currentSystem` / `builtins.currentBuildPlatform`, +- `builtins.currentHostPlatform`, and +- `builtins.isCrossCompiling`. + +When a runner is configured, Rix installs a `rix-run-host` helper on `PATH` +(its path is also exported as `$RIX_TARGET_RUNNER`) that executes a +host-platform binary through the runner: + +```sh +rix-run-host ./target-binary --some-flag +``` + +Without an explicit runner, same-OS foreign-CPU builds (for example +`x86_64-linux` -> `aarch64-linux`) fall back to a `qemu--static` style +emulator, and other combinations only warn that host binaries cannot run. +Rix deliberately does not guess a runner from `PATH` so that a misconfigured +cross build fails loudly rather than silently doing the wrong thing. diff --git a/rix-cli/Cargo.toml b/rix-cli/Cargo.toml index 7cb9a5d..ab1d0a4 100644 --- a/rix-cli/Cargo.toml +++ b/rix-cli/Cargo.toml @@ -14,3 +14,4 @@ rix-eval.workspace = true serde_json.workspace = true colored.workspace = true rootcause.workspace = true +annotate-snippets.workspace = true diff --git a/rix-cli/src/main.rs b/rix-cli/src/main.rs index 5fbc9a0..edd2bf9 100644 --- a/rix-cli/src/main.rs +++ b/rix-cli/src/main.rs @@ -1,5 +1,5 @@ use nix_eval::{BuildOptions, Builder, Evaluator, NixValue}; -use rootcause::{Report, report}; +use rootcause::Report; use std::collections::HashMap; use std::io::{self, Read}; use std::path::{Path, PathBuf}; @@ -33,6 +33,18 @@ struct Cli { #[usage(short = 'e', long = "expr", global)] expression: Option, + /// Build platform (for example x86_64-linux) exposed as builtins.currentSystem + #[usage(long, global)] + system: Option, + + /// Host platform to cross compile for (for example x86_64-darwin) + #[usage(long = "cross-system", global)] + cross_system: Option, + + /// Command used by packages to execute host-platform binaries during a cross build + #[usage(long, global)] + runner: Option, + /// Produce output in JSON format #[usage(long, global)] json: bool, @@ -99,8 +111,29 @@ enum Commands { } fn main() -> Result<(), Report> { let cli = Cli::parse(); + // SAFETY: these run before any threads are spawned and before the evaluator + // reads its environment, so there is no concurrent access. + unsafe { + if let Some(system) = &cli.system { + std::env::set_var("RIX_SYSTEM", system); + } + if let Some(cross_system) = &cli.cross_system { + std::env::set_var("RIX_CROSS_SYSTEM", cross_system); + } + if let Some(runner) = &cli.runner { + std::env::set_var("RIX_TARGET_RUNNER", runner); + } + } let evaluator = Evaluator::new(); + let result = run(&evaluator, &cli); + if let Err(e) = result { + eprintln!("{}", evaluator.render_error(&e)); + std::process::exit(1); + } + Ok(()) +} +fn run(evaluator: &Evaluator, cli: &Cli) -> nix_eval::Result<()> { // Determine command: default to Build if -f or -A is provided at top-level or program name is nix-build let prog_name = std::env::var("RIX_PROG_NAME") .ok() @@ -121,50 +154,50 @@ fn main() -> Result<(), Report> { out_link, expr, }) => run_build( - &evaluator, + evaluator, file.or(cli.file.clone()), attr.or(cli.attr.clone()), out_link.or(cli.out_link.clone()), expr.or(cli.expression.clone()), - &cli, + cli, ), Some(Commands::Eval { expr, file, attr }) => run_eval( - &evaluator, + evaluator, file.or(cli.file.clone()), attr.or(cli.attr.clone()), expr.or(cli.expression.clone()), - &cli, + cli, ), Some(Commands::ShowDerivation { file, attr }) => run_show_derivation( - &evaluator, + evaluator, file.or(cli.file.clone()), attr.or(cli.attr.clone()), - &cli, + cli, ), None => { if is_nix_instantiate { - run_show_derivation(&evaluator, cli.file.clone(), cli.attr.clone(), &cli) + run_show_derivation(evaluator, cli.file.clone(), cli.attr.clone(), cli) } else if is_nix_build || cli.file.is_some() || cli.attr.is_some() { run_build( - &evaluator, + evaluator, cli.file.clone(), cli.attr.clone(), cli.out_link.clone(), cli.expression.clone(), - &cli, + cli, ) } else if let Some(expr) = cli.expression.clone() { - run_eval(&evaluator, None, None, Some(expr), &cli) + run_eval(evaluator, None, None, Some(expr), cli) } else { // If nothing specified and default.nix exists, build default.nix if Path::new("default.nix").exists() { run_build( - &evaluator, + evaluator, Some("default.nix".to_string()), None, cli.out_link.clone(), None, - &cli, + cli, ) } else { let mut buffer = String::new(); @@ -173,7 +206,7 @@ fn main() -> Result<(), Report> { println!("Usage: rix [build|eval|show-derivation] [-f FILE] [-A ATTR]"); return Ok(()); } - run_eval(&evaluator, None, None, Some(buffer), &cli) + run_eval(evaluator, None, None, Some(buffer), cli) } } } @@ -185,21 +218,17 @@ fn resolve_expression( file: Option, attr: Option, expr: Option, -) -> Result { +) -> nix_eval::Result { let mut root_val = if let Some(path_str) = file { - evaluator - .evaluate_from_file(Path::new(&path_str)) - .map_err(|e| report!("{}", e))? + evaluator.evaluate_from_file(Path::new(&path_str))? } else if let Some(expression_str) = expr { - evaluator - .evaluate(&expression_str) - .map_err(|e| report!("{}", e))? + evaluator.evaluate(&expression_str)? } else if Path::new("default.nix").exists() { - evaluator - .evaluate_from_file(Path::new("default.nix")) - .map_err(|e| report!("{}", e))? + evaluator.evaluate_from_file(Path::new("default.nix"))? } else { - return Err(report!("No expression or file provided to evaluate")); + return Err(nix_eval::Error::EvaluationError { + reason: "No expression or file provided to evaluate".to_string(), + }); }; // Auto-call function if root value is a function taking an attribute set @@ -208,21 +237,24 @@ fn resolve_expression( // Select attribute path if requested if let Some(attr_path) = attr { for part in attr_path.split('.') { - root_val = root_val.force(evaluator).map_err(|e| report!("{}", e))?; + root_val = root_val.force(evaluator)?; match root_val { NixValue::AttributeSet(map) => { - let next = map - .get(part) - .cloned() - .ok_or_else(|| report!("Attribute '{}' not found in package set", part))?; + let next = + map.get(part) + .cloned() + .ok_or_else(|| nix_eval::Error::EvaluationError { + reason: format!("Attribute '{}' not found in package set", part), + })?; root_val = unwrap_function(evaluator, next)?; } other => { - return Err(report!( - "Cannot select attribute '{}' from non-attribute-set: {}", - part, - other - )); + return Err(nix_eval::Error::EvaluationError { + reason: format!( + "Cannot select attribute '{}' from non-attribute-set: {}", + part, other + ), + }); } } } @@ -231,15 +263,13 @@ fn resolve_expression( unwrap_function(evaluator, root_val) } -fn unwrap_function(evaluator: &Evaluator, mut val: NixValue) -> Result { - val = val.force(evaluator).map_err(|e| report!("{}", e))?; +fn unwrap_function(evaluator: &Evaluator, mut val: NixValue) -> nix_eval::Result { + val = val.force(evaluator)?; while matches!(val, NixValue::Function(_)) { if let NixValue::Function(func) = val { let empty_set = NixValue::AttributeSet(HashMap::new()); - val = func - .apply(evaluator, empty_set) - .map_err(|e| report!("{}", e))?; - val = val.force(evaluator).map_err(|e| report!("{}", e))?; + val = func.apply(evaluator, empty_set)?; + val = val.force(evaluator)?; } } Ok(val) @@ -252,7 +282,7 @@ fn run_build( out_link: Option, expr: Option, cli: &Cli, -) -> Result<(), Report> { +) -> nix_eval::Result<()> { let value = resolve_expression(evaluator, file, attr, expr)?; let builder = Builder::new(); @@ -263,11 +293,12 @@ fn run_build( out_link: out_link .map(PathBuf::from) .or_else(|| Some(PathBuf::from("result"))), + build_platform: cli.system.clone(), + host_platform: cli.cross_system.clone(), + runner: cli.runner.clone(), }; - let result = builder - .build(evaluator, &value, &options) - .map_err(|e| report!("{}", e))?; + let result = builder.build(evaluator, &value, &options)?; if cli.json { let json_map: HashMap = result @@ -275,7 +306,12 @@ fn run_build( .into_iter() .map(|(k, v)| (k, v.to_string_lossy().to_string())) .collect(); - println!("{}", serde_json::to_string_pretty(&json_map)?); + let json_str = serde_json::to_string_pretty(&json_map).map_err(|e| { + nix_eval::Error::EvaluationError { + reason: e.to_string(), + } + })?; + println!("{}", json_str); } Ok(()) @@ -287,10 +323,14 @@ fn run_eval( attr: Option, expr: Option, cli: &Cli, -) -> Result<(), Report> { +) -> nix_eval::Result<()> { let value = resolve_expression(evaluator, file, attr, expr)?; if cli.json { - println!("{}", serde_json::to_string_pretty(&value)?); + let json_str = + serde_json::to_string_pretty(&value).map_err(|e| nix_eval::Error::EvaluationError { + reason: e.to_string(), + })?; + println!("{}", json_str); } else { println!("{}", value); } @@ -302,14 +342,19 @@ fn run_show_derivation( file: Option, attr: Option, cli: &Cli, -) -> Result<(), Report> { +) -> nix_eval::Result<()> { let value = resolve_expression(evaluator, file, attr, None)?; - let forced = value.force(evaluator).map_err(|e| report!("{}", e))?; + let forced = value.force(evaluator)?; match forced { NixValue::AttributeSet(map) => { if cli.json { - println!("{}", serde_json::to_string_pretty(&map)?); + let json_str = serde_json::to_string_pretty(&map).map_err(|e| { + nix_eval::Error::EvaluationError { + reason: e.to_string(), + } + })?; + println!("{}", json_str); } else { println!("{}", NixValue::AttributeSet(map)); } diff --git a/rix-eval/Cargo.toml b/rix-eval/Cargo.toml index 761fce9..0cd6b4d 100644 --- a/rix-eval/Cargo.toml +++ b/rix-eval/Cargo.toml @@ -29,6 +29,8 @@ flate2.workspace = true tar.workspace = true colored.workspace = true rix-parser.workspace = true +annotate-snippets.workspace = true +parking_lot.workspace = true [dev-dependencies] criterion = { workspace = true, features = ["html_reports"] } diff --git a/rix-eval/src/builder.rs b/rix-eval/src/builder.rs index 07e4cb7..8cf560b 100644 --- a/rix-eval/src/builder.rs +++ b/rix-eval/src/builder.rs @@ -1,6 +1,7 @@ //! Derivation build execution engine for Rix use crate::error::{Error, Result}; +use crate::platform::{self, ExecutionStrategy, Platform}; use crate::store::Store; use crate::value::NixValue; use colored::Colorize; @@ -15,6 +16,12 @@ pub struct BuildOptions { pub keep_failed: bool, pub dry_run: bool, pub out_link: Option, + /// Platform performing the build; defaults to the running machine. + pub build_platform: Option, + /// Platform the outputs run on; defaults to `build_platform`. + pub host_platform: Option, + /// Command used to execute host-platform binaries during a cross build. + pub runner: Option, } impl Default for BuildOptions { @@ -24,6 +31,9 @@ impl Default for BuildOptions { keep_failed: false, dry_run: false, out_link: Some(PathBuf::from("result")), + build_platform: None, + host_platform: None, + runner: None, } } } @@ -95,6 +105,49 @@ impl Builder { None => "unknown".to_string(), }; + // Resolve build/host platforms for cross compilation. A derivation's + // `system` attribute names the machine its output runs on, i.e. the host + // platform; the build platform defaults to the machine running rix. + let host_platform = options + .host_platform + .as_deref() + .and_then(Platform::parse) + .or_else(|| Platform::parse(&system)) + .unwrap_or_else(Platform::current); + let build_platform = options + .build_platform + .as_deref() + .and_then(Platform::parse) + .unwrap_or_else(Platform::current); + let execution = platform::execution_strategy( + &build_platform, + &host_platform, + options.runner.as_deref(), + ); + let is_cross = !host_platform.is_native_to(&build_platform); + + if is_cross { + println!( + "{} cross build {} -> {}", + "==>".cyan().bold(), + build_platform, + host_platform + ); + match &execution { + ExecutionStrategy::Runner { command } => { + println!(" host binaries will run via runner: {}", command.cyan()) + } + ExecutionStrategy::Emulated { emulator } => println!( + " host binaries will run via emulator: {}", + emulator.cyan() + ), + ExecutionStrategy::Unsupported { reason } => { + println!(" {} {}", "warning:".yellow().bold(), reason) + } + ExecutionStrategy::Native => {} + } + } + let builder = match attrs.get("builder") { Some(v) => v.clone().force(evaluator)?.to_string(), None => "/bin/sh".to_string(), @@ -149,17 +202,12 @@ impl Builder { NixValue::List(l) => { let mut parts = Vec::new(); for item in l { - parts.push(item.clone().force(evaluator)?.to_string()); + let forced = item.clone().force(evaluator)?; + parts.push(env_string(evaluator, &forced)?); } parts.join(" ") } - NixValue::AttributeSet(sub) => { - if let Some(out_p) = sub.get("outPath") { - out_p.clone().force(evaluator)?.to_string() - } else { - forced_v.to_string() - } - } + NixValue::AttributeSet(_) => env_string(evaluator, &forced_v)?, _ => forced_v.to_string(), }; env.insert(key, str_val); @@ -282,7 +330,9 @@ impl Builder { cmd_env.insert(out_name.clone(), out_p.clone()); } cmd_env.insert("name".to_string(), name.clone()); - cmd_env.insert("system".to_string(), system.clone()); + // `$system` in Nix names the platform the derivation's outputs run on, + // i.e. the host platform (equal to the build platform for native builds). + cmd_env.insert("system".to_string(), host_platform.triple()); cmd_env.insert("NIX_BUILD_CORES".to_string(), num_cpus().to_string()); cmd_env.insert("TMPDIR".to_string(), temp_dir.to_string_lossy().to_string()); cmd_env.insert("TEMP".to_string(), temp_dir.to_string_lossy().to_string()); @@ -297,6 +347,41 @@ impl Builder { } } + // Cross-compilation: expose platform identity to build scripts and, when + // a runner is available, a `rix-run-host` helper for executing + // host-platform binaries. + cmd_env.insert("buildPlatform".to_string(), build_platform.triple()); + cmd_env.insert("hostPlatform".to_string(), host_platform.triple()); + cmd_env.insert("RIX_BUILD_PLATFORM".to_string(), build_platform.triple()); + cmd_env.insert("RIX_HOST_PLATFORM".to_string(), host_platform.triple()); + + let host_bin_dir = temp_dir.join("host-bin"); + let run_host = host_bin_dir.join("rix-run-host"); + if let Some(prefix) = match &execution { + ExecutionStrategy::Runner { command } => Some(format!("exec {command} \"$@\"")), + ExecutionStrategy::Emulated { emulator } => Some(format!("exec {emulator} \"$@\"")), + ExecutionStrategy::Native => Some("exec \"$@\"".to_string()), + ExecutionStrategy::Unsupported { .. } => None, + } { + std::fs::create_dir_all(&host_bin_dir).map_err(Error::IoError)?; + std::fs::write( + &run_host, + format!( + "#!/bin/sh\n# Execute a host-platform binary during a cross build.\n{prefix}\n" + ), + ) + .map_err(Error::IoError)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&run_host, std::fs::Permissions::from_mode(0o755)); + } + cmd_env.insert( + "RIX_TARGET_RUNNER".to_string(), + run_host.to_string_lossy().to_string(), + ); + } + // Construct PATH with store bin directories and host toolchain let mut path_entries = Vec::new(); for out_p in output_paths.values() { @@ -323,6 +408,9 @@ impl Builder { } } } + if run_host.exists() { + path_entries.push(host_bin_dir.to_string_lossy().to_string()); + } // Add host paths path_entries.push("/usr/local/bin".to_string()); path_entries.push("/opt/homebrew/bin".to_string()); @@ -447,6 +535,31 @@ impl Builder { } } +/// Render a value the way Nix renders it in a derivation environment. +/// +/// Derivations are plain attribute sets carrying `outPath`, so they contribute +/// their output store path rather than their whole attribute set. Without +/// this, `nativeBuildInputs = [ toolchain mold ]` stringifies every attribute +/// of every input (recursively) and can exceed the kernel's per-argument +/// limit (`E2BIG`). +fn env_string(evaluator: &crate::eval::Evaluator, value: &NixValue) -> Result { + if let NixValue::AttributeSet(sub) = value + && let Some(out_p) = sub.get("outPath") + { + // The output path is itself a string; render it *unquoted* so that a + // list of inputs becomes `path1 path2`, matching Nix (a quoted form + // breaks `for input in $nativeBuildInputs` in the builder). + let forced = out_p.clone().force(evaluator)?; + return Ok(match forced { + NixValue::String(s) => s, + NixValue::Path(p) => p.to_string_lossy().to_string(), + NixValue::StorePath(p) => p, + other => other.to_string(), + }); + } + Ok(value.clone().to_string()) +} + /// Generate default builder shell script covering standard phases fn generate_build_script( env: &HashMap, diff --git a/rix-eval/src/error.rs b/rix-eval/src/error.rs index a44b948..dbdf47f 100644 --- a/rix-eval/src/error.rs +++ b/rix-eval/src/error.rs @@ -2,8 +2,31 @@ //! //! All errors follow the "cannot" prefix convention for user-facing messages. +use codespan::FileId; use thiserror::Error; +/// Source location span for an expression or error +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Span { + pub file_id: Option, + pub start: usize, + pub end: usize, +} + +impl Span { + pub fn new(file_id: Option, start: usize, end: usize) -> Self { + Self { + file_id, + start, + end, + } + } + + pub fn to_range(&self) -> std::ops::Range { + self.start..self.end + } +} + /// Error type for Nix evaluation /// /// All errors follow the "cannot" prefix convention for user-facing messages. @@ -67,6 +90,42 @@ pub enum Error { /// IO error occurred during file operations #[error("io error: {0}")] IoError(#[from] std::io::Error), + + /// Evaluation error with a source location. + #[error("{error}")] + SpannedError { error: Box, span: Span }, +} + +impl Error { + /// Attach an evaluation frame to this error. + pub fn with_span(self, span: Span) -> Self { + Self::SpannedError { + error: Box::new(self), + span, + } + } + + /// Source spans from the innermost failure to its outer evaluation frames. + pub fn spans(&self) -> Vec { + let mut spans = Vec::new(); + let mut error = self; + while let Self::SpannedError { error: inner, span } = error { + spans.push(*span); + error = inner; + } + spans.reverse(); + spans + } + + /// Return the innermost (most specific) source span associated with this error. + pub fn span(&self) -> Option { + self.spans().into_iter().next() + } + + /// Render this error using annotate-snippets and the given evaluator + pub fn render_annotated(&self, evaluator: &crate::Evaluator) -> String { + evaluator.render_error(self) + } } /// Result type alias for the library diff --git a/rix-eval/src/eval/evaluator.rs b/rix-eval/src/eval/evaluator.rs index 6d77dd0..7898f9e 100644 --- a/rix-eval/src/eval/evaluator.rs +++ b/rix-eval/src/eval/evaluator.rs @@ -40,6 +40,11 @@ pub struct Evaluator { context_stack: Rc>>, /// Current recursion depth to prevent stack overflows recursion_depth: Cell, + /// Current expression span being evaluated (start, end byte offsets in current file) + current_expr_span: Cell>, + /// Offset added to AST text ranges to translate them into file coordinates. + /// Non-zero while evaluating a re-parsed thunk expression. + span_base: Cell, } impl Default for Evaluator { @@ -59,6 +64,8 @@ impl Evaluator { file_id_to_path: Rc::new(RefCell::new(HashMap::new())), context_stack: Rc::new(RefCell::new(Vec::new())), recursion_depth: Cell::new(0), + current_expr_span: Cell::new(None), + span_base: Cell::new(0), }; // Register basic builtin functions @@ -384,6 +391,139 @@ impl Evaluator { .and_then(|ctx| ctx.file_id) } + /// Get the current source span (file_id, start, end) being evaluated, if any + pub fn current_span(&self) -> Option { + let file_id = self.current_file_id(); + let (start, end) = self.current_expr_span.get()?; + Some(crate::error::Span::new(file_id, start, end)) + } + + /// The current offset applied to AST text ranges to obtain file coordinates. + pub(crate) fn span_base(&self) -> usize { + self.span_base.get() + } + + /// Set the offset applied to AST text ranges. Returns the previous value. + pub(crate) fn set_span_base(&self, base: usize) -> usize { + crate::thunk::set_thread_span_base(base); + self.span_base.replace(base) + } + + /// Create a source span for an expression. Re-parsed thunk and function + /// bodies have ranges relative to their text fragment, so prefer locating a + /// uniquely occurring expression in the original file over arithmetic + /// offset translation. + pub(crate) fn span_for_expr(&self, expr: &Expr) -> crate::error::Span { + let range = expr.syntax().text_range(); + let fallback = crate::error::Span::new( + self.current_file_id(), + self.span_base() + usize::from(range.start()), + self.span_base() + usize::from(range.end()), + ); + + let Some(file_id) = self.current_file_id() else { + return fallback; + }; + let text = expr.syntax().text().to_string(); + if text.is_empty() { + return fallback; + } + let source_map = self.source_map.borrow(); + let source = source_map.source(file_id); + let mut matches = source.match_indices(&text); + let Some((start, _)) = matches.next() else { + return fallback; + }; + if matches.next().is_some() { + return fallback; + } + crate::error::Span::new(Some(file_id), start, start + text.len()) + } + + /// Locate a function parameter pattern in its defining source file. + pub(crate) fn span_for_parameter( + &self, + file_id: Option, + parameter: &crate::function::Parameter, + fallback: crate::error::Span, + ) -> crate::error::Span { + let Some(file_id) = file_id else { + return fallback; + }; + let text = parameter.to_string(); + let source_map = self.source_map.borrow(); + let source = source_map.source(file_id); + let mut matches = source.match_indices(&text); + let Some((start, _)) = matches.next() else { + return fallback; + }; + if matches.next().is_some() { + return fallback; + } + crate::error::Span::new(Some(file_id), start, start + text.len()) + } + + /// Access the internal Files source map + pub fn source_map(&self) -> std::cell::Ref<'_, Files> { + self.source_map.borrow() + } + + /// Render an evaluation error and its source-level evaluation stack. + pub fn render_error(&self, error: &Error) -> String { + let spans = error.spans(); + if spans.is_empty() { + return error.to_string(); + } + + let source_map = self.source_map.borrow(); + let renderer = annotate_snippets::Renderer::styled() + .decor_style(annotate_snippets::renderer::DecorStyle::Unicode); + let message = error.to_string(); + let mut rendered = String::new(); + + for (index, span) in spans.iter().enumerate() { + let Some(file_id) = span.file_id else { + continue; + }; + let source = source_map.source(file_id); + let path = source_map.name(file_id).to_string_lossy(); + let start = span.start.min(source.len()); + let end = span.end.max(start).min(source.len()); + let range = if start == end { + if start < source.len() { + start..start + 1 + } else { + start.saturating_sub(1)..start + } + } else { + start..end + }; + let title = if index == 0 { + message.as_str() + } else { + "while evaluating this expression" + }; + let level = if index == 0 { + annotate_snippets::Level::ERROR + } else { + annotate_snippets::Level::INFO + }; + let report = &[level.primary_title(title).element( + annotate_snippets::Snippet::source(source.as_str()) + .line_start(1) + .path(path.as_ref()) + .annotation(annotate_snippets::AnnotationKind::Primary.span(range)), + )]; + rendered.push_str(&renderer.render(report)); + } + + if rendered.is_empty() { + message + } else { + rendered + } + } + pub(crate) fn push_context(&self, file_id: Option, scope: VariableScope) { self.context_stack .borrow_mut() @@ -488,6 +628,11 @@ impl Evaluator { /// _ => panic!("Expected attribute set"), /// } pub fn evaluate(&self, expr: &str) -> Result { + let file_id = { + let mut source_map = self.source_map.borrow_mut(); + source_map.add("", expr.to_string()) + }; + // Tokenize and parse the Nix expression let tokens = tokenize(expr); let (green_node, errors) = parse(tokens.into_iter()); @@ -504,10 +649,18 @@ impl Evaluator { let syntax_node = SyntaxNode::new_root(green_node); let root = Root::cast(syntax_node).ok_or(Error::AstConversionError)?; - let expr = root.expr().ok_or(Error::NoExpression)?; + let expr_ast = root.expr().ok_or(Error::NoExpression)?; + + // Push context for this expression string + self.push_context(Some(file_id), self.scope.clone()); // Evaluate the expression - let result = self.evaluate_expr(&expr)?; + let result = self.evaluate_expr(&expr_ast); + + // Pop context + self.pop_context(); + + let result = result?; // Fully force the result so that we return a concrete value // instead of a thunk (lazy evaluation). @@ -652,7 +805,16 @@ impl Evaluator { scope: &VariableScope, ) -> Result { self.increment_recursion_depth()?; - let result = self.evaluate_expr_with_scope_impl_inner(expr, scope); + let range = expr.syntax().text_range(); + let base = self.span_base(); + let prev_span = self.current_expr_span.replace(Some(( + base + usize::from(range.start()), + base + usize::from(range.end()), + ))); + let result = self + .evaluate_expr_with_scope_impl_inner(expr, scope) + .map_err(|error| error.with_span(self.span_for_expr(expr))); + self.current_expr_span.set(prev_span); self.decrement_recursion_depth(); result } @@ -666,7 +828,16 @@ impl Evaluator { scope: &VariableScope, ) -> Result { self.increment_recursion_depth()?; - let result = self.evaluate_expr_with_tco_inner(expr, scope); + let range = expr.syntax().text_range(); + let base = self.span_base(); + let prev_span = self.current_expr_span.replace(Some(( + base + usize::from(range.start()), + base + usize::from(range.end()), + ))); + let result = self + .evaluate_expr_with_tco_inner(expr, scope) + .map_err(|error| error.with_span(self.span_for_expr(expr))); + self.current_expr_span.set(prev_span); self.decrement_recursion_depth(); result } @@ -876,26 +1047,36 @@ impl Evaluator { for name in self.builtins.keys() { builtins_attrs.insert(name.clone(), NixValue::Builtin(name.clone())); } - let current_sys = if cfg!(target_os = "macos") { - if cfg!(target_arch = "aarch64") { - "aarch64-darwin" - } else { - "x86_64-darwin" - } - } else if cfg!(target_os = "linux") { - if cfg!(target_arch = "aarch64") { - "aarch64-linux" - } else { - "x86_64-linux" - } - } else { - "unknown-system" - }; + // `--system` / RIX_SYSTEM overrides the platform exposed as + // currentSystem, which is what `import ./pkgs {}` keys off of. + let current_sys = std::env::var("RIX_SYSTEM") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| crate::platform::Platform::current().triple()); + + // Cross-compilation metadata. `RIX_CROSS_SYSTEM` names the host + // platform when it differs from the build platform. + let host_sys = std::env::var("RIX_CROSS_SYSTEM") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| current_sys.clone()); + let is_cross = host_sys != current_sys; builtins_attrs.insert( "currentSystem".to_string(), - NixValue::String(current_sys.to_string()), + NixValue::String(current_sys.clone()), + ); + builtins_attrs.insert( + "currentBuildPlatform".to_string(), + NixValue::String(current_sys), + ); + builtins_attrs.insert( + "currentHostPlatform".to_string(), + NixValue::String(host_sys), ); + builtins_attrs.insert("isCrossCompiling".to_string(), NixValue::Boolean(is_cross)); builtins_attrs.insert( "builtins".to_string(), NixValue::DeferredLookup( diff --git a/rix-eval/src/eval/expressions/functions.rs b/rix-eval/src/eval/expressions/functions.rs index 78c7ae2..780c1b8 100644 --- a/rix-eval/src/eval/expressions/functions.rs +++ b/rix-eval/src/eval/expressions/functions.rs @@ -56,7 +56,13 @@ impl Evaluator { // Create a function closure with the current scope let file_id = self.current_file_id(); - let func = function::Function::new(parameter, &body_expr, scope.clone(), file_id); + let func = function::Function::new( + parameter, + &body_expr, + scope.clone(), + file_id, + self.span_base(), + ); Ok(NixValue::Function(Arc::new(func))) } diff --git a/rix-eval/src/function.rs b/rix-eval/src/function.rs index 771816c..b42a60e 100644 --- a/rix-eval/src/function.rs +++ b/rix-eval/src/function.rs @@ -109,6 +109,10 @@ pub struct Function { /// those thunks need to know what file the function was defined in so that relative /// imports work correctly. pub(crate) file_id: Option, + /// Absolute byte range of the body expression in its defining source file. + /// Function bodies are re-parsed when applied, so this restores their source + /// coordinates for diagnostics. + body_span_start: usize, } impl Function { @@ -129,17 +133,20 @@ impl Function { body_expr: &Expr, closure: VariableScope, file_id: Option, + span_base: usize, ) -> Self { // Store the body expression as text representation for now // In a full implementation, we'd want to store the actual AST node // but that requires handling lifetimes carefully let body_text = body_expr.syntax().text().to_string(); + let range = body_expr.syntax().text_range(); Self { parameter, body_text, closure, file_id, + body_span_start: span_base + usize::from(range.start()), } } @@ -150,11 +157,13 @@ impl Function { closure: VariableScope, file_id: Option, ) -> Self { + let base = crate::thunk::thread_span_base(); Self { parameter, body_text, closure, file_id, + body_span_start: base, } } @@ -204,11 +213,13 @@ impl Function { ); closure.insert("__curried_first_arg".to_string(), first_arg); + let base = crate::thunk::thread_span_base(); Self { parameter, body_text, closure, file_id, + body_span_start: base, } } @@ -248,7 +259,13 @@ impl Function { let root = Root::cast(syntax_node).unwrap(); let dummy_expr = root.expr().unwrap(); - Self::new(parameter, &dummy_expr, closure, file_id) + Self::new( + parameter, + &dummy_expr, + closure, + file_id, + crate::thunk::thread_span_base(), + ) } /// Create a curried foldl' function (2 args applied, needs list) @@ -276,11 +293,13 @@ impl Function { closure.insert("__foldl_op".to_string(), op); closure.insert("__foldl_nul".to_string(), nul); + let base = crate::thunk::thread_span_base(); Self { parameter, body_text, closure, file_id, + body_span_start: base, } } @@ -356,7 +375,16 @@ impl Function { "function expected argument '{}' but it was not provided", entry_name ), - }); + } + .with_span(evaluator.span_for_parameter( + self.file_id, + &self.parameter, + crate::error::Span::new( + self.file_id, + self.body_span_start, + self.body_span_start, + ), + ))); } } @@ -757,12 +785,17 @@ impl Function { let body_expr = root.expr().ok_or(Error::NoExpression)?; - // Push context with the function's file_id + // Push context with the function's file_id. Function bodies are + // re-parsed from text, so translate their local AST ranges back to + // the defining source file before evaluating them. evaluator.push_context(current_file_id, current_scope.clone()); + let prev_base = evaluator.span_base(); + evaluator.set_span_base(current_func.body_span_start); // Evaluate the body expression with TCO support let result = evaluator.evaluate_expr_with_tco(&body_expr, ¤t_scope); + evaluator.set_span_base(prev_base); // Pop context evaluator.pop_context(); diff --git a/rix-eval/src/lib.rs b/rix-eval/src/lib.rs index 3178fa8..ce6dc60 100644 --- a/rix-eval/src/lib.rs +++ b/rix-eval/src/lib.rs @@ -8,6 +8,7 @@ mod builtins; mod error; mod eval; mod function; +pub mod platform; pub mod prelude; pub mod store; mod thunk; @@ -17,9 +18,10 @@ mod xml; // Re-export public API pub use builder::{BuildOptions, BuildResult, Builder}; pub use builtin::Builtin; -pub use error::{Error, Result}; +pub use error::{Error, Result, Span}; pub use eval::{EvaluationContext, Evaluator, VariableScope}; pub use function::Function; +pub use platform::{ExecutionStrategy, Platform}; pub use store::Store; pub use thunk::Thunk; pub use value::{Derivation, NixValue}; diff --git a/rix-eval/src/platform.rs b/rix-eval/src/platform.rs new file mode 100644 index 0000000..bf9fe68 --- /dev/null +++ b/rix-eval/src/platform.rs @@ -0,0 +1,255 @@ +//! Build/host platform handling for cross compilation. +//! +//! Rix uses the same "system triple" vocabulary as Nix: a `cpu-os` pair such +//! as `x86_64-linux`, `aarch64-darwin` or `x86_64-windows`. Two platforms are +//! tracked during a build: +//! +//! * the *build* platform is the machine performing the build (the machine +//! running `rix`), and +//! * the *host* platform is the machine the produced package runs on. +//! +//! When they differ the build is a *cross* build and, depending on the pair, +//! binaries for the host platform cannot be executed natively while the build +//! runs. [`ExecutionStrategy`] captures how (if at all) that can be worked +//! around. + +use std::fmt; + +/// A parsed Nix system triple such as `x86_64-linux`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Platform { + /// CPU component, e.g. `x86_64`, `aarch64`, `riscv64`. + pub cpu: String, + /// Operating system component, e.g. `linux`, `darwin`, `windows`. + pub os: String, +} + +impl Platform { + /// Parse a `cpu-os` triple. Unknown components are accepted so that new + /// platforms do not require an evaluator change; only a missing separator + /// is rejected. + pub fn parse(triple: &str) -> Option { + let (cpu, os) = triple.split_once('-')?; + if cpu.is_empty() || os.is_empty() { + return None; + } + Some(Self { + cpu: cpu.to_string(), + os: os.to_string(), + }) + } + + /// The platform `rix` itself is running on, derived from its own target + /// triple. This mirrors `builtins.currentSystem`. + pub fn current() -> Self { + let cpu = if cfg!(target_arch = "aarch64") { + "aarch64" + } else if cfg!(target_arch = "x86_64") { + "x86_64" + } else if cfg!(target_arch = "riscv64") { + "riscv64" + } else { + "unknown" + }; + let os = if cfg!(target_os = "macos") { + "darwin" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "windows") { + "windows" + } else { + "unknown" + }; + Self { + cpu: cpu.to_string(), + os: os.to_string(), + } + } + + pub fn is_linux(&self) -> bool { + self.os == "linux" + } + + pub fn is_darwin(&self) -> bool { + self.os == "darwin" + } + + pub fn is_windows(&self) -> bool { + self.os == "windows" + } + + /// True when a binary built for `self` can run directly on the machine + /// performing the build. + pub fn is_native_to(&self, build: &Platform) -> bool { + self.cpu == build.cpu && self.os == build.os + } + + /// The canonical `cpu-os` spelling. + pub fn triple(&self) -> String { + format!("{}-{}", self.cpu, self.os) + } +} + +impl fmt::Display for Platform { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.triple()) + } +} + +/// How host-platform binaries can be executed while a cross build is running. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExecutionStrategy { + /// Build and host platforms match; binaries run directly. + Native, + /// A user-supplied runner command prefixes the target executable, e.g. + /// `darling` for Darwin binaries on Linux or `wine` for Windows binaries. + Runner { command: String }, + /// A `qemu-user-static` style emulator handles a foreign CPU running the + /// same OS (or a Linux userspace) via binfmt_misc. The value is the + /// emulator binary that is transparently invoked by the kernel. + Emulated { emulator: String }, + /// No known way to execute host binaries on this build platform. + Unsupported { reason: String }, +} + +/// Decide how (if at all) host-platform binaries can run on the build machine. +/// +/// `runner` is an explicit user override (`--runner`) and always wins; Rix +/// deliberately does not guess a runner from `PATH` so that a misconfigured +/// cross build fails loudly instead of silently doing the wrong thing. +pub fn execution_strategy( + build: &Platform, + host: &Platform, + runner: Option<&str>, +) -> ExecutionStrategy { + if host.is_native_to(build) { + return ExecutionStrategy::Native; + } + + if let Some(runner) = runner.map(str::trim).filter(|r| !r.is_empty()) { + return ExecutionStrategy::Runner { + command: runner.to_string(), + }; + } + + // Same OS, different CPU: a qemu-user-static style emulator registered with + // binfmt_misc can run the binary transparently. + if build.os == host.os && build.cpu != host.cpu { + let arch = match host.cpu.as_str() { + "aarch64" => "aarch64", + "x86_64" => "x86_64", + "riscv64" => "riscv64", + "armv7l" | "armv7" => "arm", + other => other, + }; + return ExecutionStrategy::Emulated { + emulator: format!("qemu-{arch}-static"), + }; + } + + // Foreign OS: only a userspace compatibility layer can help. + if build.is_linux() && host.is_darwin() { + return ExecutionStrategy::Unsupported { + reason: "running Darwin binaries on a Linux build host requires a Darwin \ + userspace runner such as Darling; pass --runner darling (or a \ + Darling wrapper) to enable it" + .to_string(), + }; + } + + if build.is_linux() && host.is_windows() { + return ExecutionStrategy::Unsupported { + reason: "running Windows binaries on a Linux build host requires Wine; \ + pass --runner wine (or a Wine wrapper) to enable it" + .to_string(), + }; + } + + ExecutionStrategy::Unsupported { + reason: format!( + "no known way to execute {}-{} binaries on a {} build host; pass --runner \ + to provide one", + host.cpu, host.os, build + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(triple: &str) -> Platform { + Platform::parse(triple).unwrap() + } + + #[test] + fn parses_and_prints_triples() { + let plat = p("x86_64-darwin"); + assert_eq!(plat.cpu, "x86_64"); + assert_eq!(plat.os, "darwin"); + assert_eq!(plat.triple(), "x86_64-darwin"); + assert!(Platform::parse("x86_64").is_none()); + assert!(Platform::parse("-darwin").is_none()); + assert!(Platform::parse("x86_64-").is_none()); + } + + #[test] + fn native_build_needs_no_runner() { + let build = p("x86_64-linux"); + let host = p("x86_64-linux"); + assert_eq!( + execution_strategy(&build, &host, None), + ExecutionStrategy::Native + ); + } + + #[test] + fn explicit_runner_wins_for_cross_builds() { + let build = p("x86_64-linux"); + let host = p("x86_64-darwin"); + assert_eq!( + execution_strategy(&build, &host, Some("darling")), + ExecutionStrategy::Runner { + command: "darling".to_string() + } + ); + } + + #[test] + fn darwin_without_runner_is_unsupported() { + let build = p("x86_64-linux"); + let host = p("x86_64-darwin"); + match execution_strategy(&build, &host, None) { + ExecutionStrategy::Unsupported { reason } => assert!(reason.contains("Darling")), + other => panic!("expected Unsupported, got {other:?}"), + } + } + + #[test] + fn foreign_cpu_same_os_uses_qemu() { + let build = p("x86_64-linux"); + let host = p("aarch64-linux"); + assert_eq!( + execution_strategy(&build, &host, None), + ExecutionStrategy::Emulated { + emulator: "qemu-aarch64-static".to_string() + } + ); + } + + #[test] + fn windows_without_runner_is_unsupported() { + let build = p("x86_64-linux"); + let host = p("x86_64-windows"); + match execution_strategy(&build, &host, None) { + ExecutionStrategy::Unsupported { reason } => assert!(reason.contains("Wine")), + other => panic!("expected Unsupported, got {other:?}"), + } + } + + #[test] + fn current_is_a_valid_triple() { + let current = Platform::current(); + assert_eq!(Platform::parse(¤t.triple()).unwrap(), current); + } +} diff --git a/rix-eval/src/thunk.rs b/rix-eval/src/thunk.rs index 3319bec..67b6eee 100644 --- a/rix-eval/src/thunk.rs +++ b/rix-eval/src/thunk.rs @@ -6,12 +6,31 @@ use crate::{Error, Evaluator, NixValue, Result, VariableScope}; use codespan::FileId; +use parking_lot::Mutex; use rix_parser::SyntaxNode; use rix_parser::ast::{Expr, Root}; use rix_parser::parser::parse; use rix_parser::tokenizer::tokenize; use rowan::ast::AstNode; -use std::sync::{Arc, Mutex}; +use std::cell::Cell; +use std::sync::Arc; + +thread_local! { + /// Offset applied to AST text ranges when creating thunks, so that thunks + /// created while evaluating a re-parsed expression still report spans in + /// their original file coordinates. Maintained by [`Evaluator::set_span_base`]. + static SPAN_BASE: Cell = const { Cell::new(0) }; +} + +/// Set the thread-local span base, returning the previous value. +pub(crate) fn set_thread_span_base(base: usize) -> usize { + SPAN_BASE.with(|c| c.replace(base)) +} + +/// Get the current thread-local span base. +pub(crate) fn thread_span_base() -> usize { + SPAN_BASE.with(|c| c.get()) +} /// Represents the state of a thunk during evaluation #[derive(Debug, Clone, PartialEq)] @@ -53,6 +72,12 @@ pub struct Thunk { /// This is critical for lazy evaluation: when a thunk is forced, it needs to know /// what file it was created in so that relative imports work correctly. file_id: Option, + /// Absolute byte offset of the start of this thunk's expression within its source file. + /// Used to translate spans from the re-parsed expression text back into file + /// coordinates when reporting errors. + span_start: usize, + /// Absolute byte offset of the end of this thunk's expression within its source file. + span_end: usize, /// The current state of the thunk state: Arc>, /// Cached result after evaluation (None if not yet evaluated) @@ -75,12 +100,18 @@ impl Thunk { // Store the expression as text representation for now // In a full implementation, we'd want to store the actual AST node // but that requires handling lifetimes carefully + let range = expr.syntax().text_range(); let expression_text = expr.syntax().text().to_string(); + let base = thread_span_base(); + let start = base + usize::from(range.start()); + let end = base + usize::from(range.end()); Self { expression_text, closure, file_id, + span_start: start, + span_end: end, state: Arc::new(Mutex::new(ThunkState::Suspended)), cached_value: Arc::new(Mutex::new(None)), } @@ -92,15 +123,25 @@ impl Thunk { closure: VariableScope, file_id: Option, ) -> Self { + // Synthetic expressions have no source location of their own; retain the + // current span base so nested evaluation keeps correct file coordinates. + let base = thread_span_base(); Self { expression_text, closure, file_id, + span_start: base, + span_end: base, state: Arc::new(Mutex::new(ThunkState::Suspended)), cached_value: Arc::new(Mutex::new(None)), } } + /// Absolute byte range of this thunk's expression within its source file + pub fn span(&self) -> (usize, usize) { + (self.span_start, self.span_end) + } + /// Get the expression text stored in this thunk /// /// This is a temporary solution. In a full implementation, we'd return @@ -116,7 +157,7 @@ impl Thunk { /// Get the current state of the thunk pub fn state(&self) -> ThunkState { - self.state.lock().unwrap().clone() + self.state.lock().clone() } /// Check if the thunk is suspended (not yet evaluated) @@ -163,7 +204,7 @@ impl Thunk { /// ``` pub fn force(&self, evaluator: &Evaluator) -> Result { // Check current state - let mut state_guard = self.state.lock().unwrap(); + let mut state_guard = self.state.lock(); match *state_guard { ThunkState::Evaluated => { @@ -171,7 +212,7 @@ impl Thunk { // This is the fast path: if the thunk has been evaluated before, // we return the cached result without re-evaluating. drop(state_guard); - let value_guard = self.cached_value.lock().unwrap(); + let value_guard = self.cached_value.lock(); value_guard .clone() .ok_or_else(|| Error::UnsupportedExpression { @@ -203,7 +244,7 @@ impl Thunk { let error_msgs: Vec = errors.iter().map(|e| format!("{:?}", e)).collect(); // Reset state on error - *self.state.lock().unwrap() = ThunkState::Suspended; + *self.state.lock() = ThunkState::Suspended; return Err(Error::ParseError { reason: error_msgs.join(", "), }); @@ -212,13 +253,13 @@ impl Thunk { let syntax_node = SyntaxNode::new_root(green_node); let root = Root::cast(syntax_node).ok_or_else(|| { // Reset state on error - *self.state.lock().unwrap() = ThunkState::Suspended; + *self.state.lock() = ThunkState::Suspended; Error::AstConversionError })?; let expr = root.expr().ok_or_else(|| { // Reset state on error - *self.state.lock().unwrap() = ThunkState::Suspended; + *self.state.lock() = ThunkState::Suspended; Error::NoExpression })?; @@ -227,10 +268,25 @@ impl Thunk { // Push context with the thunk's file_id and closure evaluator.push_context(self.file_id, self.closure.clone()); + // Translate spans produced while evaluating the re-parsed + // expression text back into file coordinates. + let prev_base = evaluator.span_base(); + evaluator.set_span_base(self.span_start); + // Evaluate the expression using the thunk's closure as the scope // Note: For let bindings, if a variable is not found or is Null in the closure, // the identifier lookup in evaluate_expr_with_scope_impl will check the context stack - let result = evaluator.evaluate_expr_with_scope(&expr, &self.closure); + let result = evaluator + .evaluate_expr_with_scope(&expr, &self.closure) + .map_err(|error| { + error.with_span(crate::error::Span::new( + self.file_id, + self.span_start, + self.span_end, + )) + }); + + evaluator.set_span_base(prev_base); // Pop context (restore previous context) evaluator.pop_context(); @@ -239,30 +295,69 @@ impl Thunk { // Once evaluated, the result is cached so subsequent calls to force() // will return the cached value without re-evaluation. match result { - Ok(value) => { - // If the result is itself a thunk, force it recursively. - // This detects infinite recursion (blackhole) when a thunk - // evaluates to itself. - let final_value = if let NixValue::Thunk(inner) = &value { - inner.force(evaluator)? - } else { - value - }; - let mut state_guard = self.state.lock().unwrap(); - let mut value_guard = self.cached_value.lock().unwrap(); - *state_guard = ThunkState::Evaluated; - *value_guard = Some(final_value.clone()); - Ok(final_value) - } + Ok(value) => self.cache_result(value, evaluator), Err(e) => { // Reset state on error so the thunk can be retried - *self.state.lock().unwrap() = ThunkState::Suspended; + *self.state.lock() = ThunkState::Suspended; Err(e) } } } } } + + /// Cache a successfully evaluated value, resolving thunk indirections. + /// + /// An expression that evaluates to another thunk is an indirection to that + /// thunk's value. The target may reach back to this thunk through a shared + /// recursive scope (`let a = b; b = a; in a`), so the indirection is + /// published *before* the target is forced: leaving this thunk blackholed + /// until then would report a spurious infinite recursion for the re-entrant + /// lookup. + /// + /// Following an indirection must terminate, so a chain that hands this very + /// thunk back is genuine infinite recursion rather than a cacheable value. + fn cache_result(&self, value: NixValue, evaluator: &Evaluator) -> Result { + let NixValue::Thunk(inner) = &value else { + *self.state.lock() = ThunkState::Evaluated; + *self.cached_value.lock() = Some(value.clone()); + return Ok(value); + }; + + { + let mut state_guard = self.state.lock(); + let mut value_guard = self.cached_value.lock(); + *state_guard = ThunkState::Evaluated; + *value_guard = Some(value.clone()); + } + + match inner.force(evaluator) { + Ok(final_value) => { + // `let x = x; in x` and `let x = y; y = x; in x` — every link + // aliases the next, so the chain unwinds back to the thunk that + // started it instead of producing a value. + if let NixValue::Thunk(target) = &final_value + && Self::is_same_thunk(target, self) + { + *self.state.lock() = ThunkState::Suspended; + *self.cached_value.lock() = None; + return Err(Error::InfiniteRecursion); + } + *self.cached_value.lock() = Some(final_value.clone()); + Ok(final_value) + } + Err(error) => { + *self.state.lock() = ThunkState::Suspended; + *self.cached_value.lock() = None; + Err(error) + } + } + } + + /// Whether `other` and `this` are the same thunk allocation. + fn is_same_thunk(other: &Arc, this: &Thunk) -> bool { + std::ptr::eq(Arc::as_ptr(other), this) + } } #[cfg(test)] diff --git a/rix-eval/tests/integration_tests.rs b/rix-eval/tests/integration_tests.rs index 38459a8..95eb5e8 100644 --- a/rix-eval/tests/integration_tests.rs +++ b/rix-eval/tests/integration_tests.rs @@ -314,9 +314,12 @@ fn test_unknown_variable_error() { let result = evaluator.evaluate("unknownVar"); assert!(result.is_err()); match result.unwrap_err() { - nix_eval::Error::UnsupportedExpression { reason } => { - assert!(reason.contains("unknown identifier")); - } - _ => panic!("Expected UnsupportedExpression for unknown variable"), + nix_eval::Error::SpannedError { error, .. } => match *error { + nix_eval::Error::UnsupportedExpression { reason } => { + assert!(reason.contains("unknown identifier")); + } + _ => panic!("Expected UnsupportedExpression for unknown variable"), + }, + _ => panic!("Expected a spanned error for unknown variable"), } }