From 07c89d734a136772052d76937805abb66f19c1fc Mon Sep 17 00:00:00 2001 From: LunaStev Date: Sun, 18 Oct 2026 21:59:58 +0900 Subject: [PATCH] feat: add package targets, profiles, and unified artifact layouts Signed-off-by: LunaStev --- README.md | 45 +- compiler/src/invocation.rs | 389 +++++++++++----- compiler/src/lib.rs | 5 +- compiler/src/selection.rs | 1 + docs/cli-contract-design.md | 10 +- docs/commands.md | 16 +- docs/doctor.md | 27 +- docs/metadata.md | 88 +++- docs/native-interop.md | 31 +- docs/reference.md | 44 +- docs/release-readiness.md | 32 +- docs/roadmap.md | 10 +- docs/schemas/build-plan-v1.json | 38 ++ docs/schemas/messages-v1.json | 28 ++ docs/schemas/metadata-v1.json | 3 +- docs/schemas/metadata-v2.json | 305 ++++++++++++ docs/targets-and-profiles.md | 197 ++++++++ examples/parser_fuzz.rs | 8 +- manifest/src/lib.rs | 50 +- manifest/src/model.rs | 739 ++++++++++++++++++++++++++++++ manifest/src/native.rs | 4 +- manifest/src/parse.rs | 62 ++- manifest/src/render.rs | 12 +- src/cli.rs | 10 +- src/commands/build.rs | 442 +++++++++++++----- src/commands/doctor.rs | 260 ++++++----- src/commands/info.rs | 26 +- src/commands/init.rs | 1 + src/commands/metadata.rs | 136 +++++- src/native.rs | 701 ++++++++++++++++++++++++---- tests/build_artifact_integrity.rs | 262 ++++++++--- tests/clean.rs | 101 ++-- tests/doctor.rs | 144 +++++- tests/fixtures/fake_wavec.rs | 20 +- tests/fixtures/target_wavec.rs | 164 +++++++ tests/native_build.rs | 116 ++++- tests/native_interop.py | 39 +- tests/package_targets.rs | 494 ++++++++++++++++++++ tests/project_state.rs | 6 +- tests/target_metadata.rs | 230 ++++++++++ tests/wave_compatibility.py | 95 ++++ tests/wavec_contract.rs | 22 +- wson/src/lib.rs | 26 +- 43 files changed, 4752 insertions(+), 687 deletions(-) create mode 100644 docs/schemas/build-plan-v1.json create mode 100644 docs/schemas/metadata-v2.json create mode 100644 docs/targets-and-profiles.md create mode 100644 manifest/src/model.rs create mode 100644 tests/fixtures/target_wavec.rs create mode 100644 tests/package_targets.rs create mode 100644 tests/target_metadata.rs diff --git a/README.md b/README.md index cbf7bf0..6d9096e 100644 --- a/README.md +++ b/README.md @@ -134,14 +134,14 @@ application example. ```sh vex init [--lib] -vex build [--target ] [--release] [--dry-run] [--locked] [--offline] -vex run [--target ] [--release] [--dry-run] [--locked] [--offline] [-- ] -vex check [--target ] [--release] [--dry-run] [--locked] [--offline] +vex build [--lib] [--bin ] [--target ] [--profile |--release] [--dry-run] [--locked] [--offline] +vex run [--bin ] [--target ] [--profile |--release] [--dry-run] [--locked] [--offline] [-- ] +vex check [--lib] [--bin ] [--target ] [--profile |--release] [--dry-run] [--locked] [--offline] vex fetch [--locked] [--offline] vex update [...] vex info vex tree [--invert ] [--depth ] [--locked] [--offline] -vex metadata [--format=json] [--locked] [--offline] +vex metadata [--format=json] [--format-version <1|2>] [--locked] [--offline] vex doctor [--format=json] [--target ] vex clean [--dry-run] vex prune [--apply] [--dry-run] @@ -166,7 +166,9 @@ fetching, migrating or repairing project state. It uses an existing shared state lock; run `vex fetch` first when the local graph or coordination state is missing. `--locked` also enforces lockfile compatibility; metadata preserves v2/v3 bytes with or without that flag. `--offline` is accepted and is already the default. -See [the metadata v1 contract](docs/metadata.md) for fields and ordering. +The default schema remains 1 for single-target packages; mixed or multiple targets +require `--format-version 2`. See [metadata schemas](docs/metadata.md) for fields +and ordering. `tree --invert ` explains which packages depend on a selected package; `--depth 0` prints only the displayed root. `doctor` inspects the installed tools @@ -218,13 +220,17 @@ Vex uses `vex.ws` as the project manifest. The extension is `.ws`. } ``` -The current root manifest object accepts only `format`, `name`, `version`, `lib`, `description`, -`author`, `license`, `compiler`, and `dependencies`. Dependency objects accept only `name`, +Formats 1 and 2 accept `format`, `name`, `version`, `lib`, `description`, +`author`, `license`, `compiler`, `native`, and `dependencies`. Format 3 replaces +root `lib` with explicit `targets`, and additionally accepts `default_run`, +`profiles`, `build`, and `target_config`. Dependency objects accept only `name`, `version`, `path`, `git`, `branch`, `tag`, and `rev`. Unknown fields are errors, including names intended as private or experimental extensions; adding a field requires an explicit Vex schema change. -New manifests use `format = 2`: quoted strings decode JSON escapes (`\\`, +Generated single-target manifests use `format = 2`; format 3 adds the explicit +[target/profile model](docs/targets-and-profiles.md) with the same string rules. +In formats 2 and 3, quoted strings decode JSON escapes (`\\`, `\"`, `\n`, `\r`, `\t`, and Unicode escapes). Strings can contain URLs, commas, comment markers, quotes, and Unicode without becoming WSON syntax. Comments are recognized only outside strings; duplicate fields are errors with source locations. @@ -270,8 +276,9 @@ files, even if its HEAD matches `vex.lock`. If this happens, preserve your changes outside the managed checkout, restore that checkout yourself, and rerun `vex fetch`. Vex will not discard local changes automatically. -Every dependency is a library package: its manifest must set `lib = true` and -its canonical entry is `src/lib.wave`. Wave source imports the package name, +Every dependency must provide a library: its manifest sets `lib = true` in +formats 1/2, or declares `targets.lib` in format 3. The canonical library entry +is `src/lib.wave`; any binaries declared by that dependency are not built. Wave source imports the package name, not the entry filename: ```wave @@ -390,6 +397,19 @@ Non-UTF-8 program arguments work on supported OS interfaces; a dry-run JSON plan cannot represent them and fails explicitly. Compiler input/dependency paths that the JSON/WSON protocols cannot represent also fail instead of being replaced. +Format 3 supports one library and several explicitly named binaries in a package. +`build`/`check` default to all root targets; use `--lib` and repeatable `--bin ` +to select them. `run` selects `--bin`, `default_run`, or the only binary, and reports +ambiguity otherwise. Root `dev`/`release` and custom profiles select optimization +levels 0–3. `--release` aliases `--profile release` and conflicts with `--profile`. + +All regular/native outputs now use `target///bin/` and +`lib/`, with separate intermediate directories. Existing files at old paths stay +in place. Exact-triple settings can select a root-owned sysroot/linker and explicit +binary platform mode/entry/startup/linker script. Unsupported combinations fail +before compilation. See [targets, profiles, and artifacts](docs/targets-and-profiles.md) +for the manifest and selection contracts. + Build progress is written to stderr with Cargo-style stages such as `Resolving`, `Fetching`, `Compiling`, `Checking`, `Running`, and `Finished`. Program output remains on stdout. Examples: @@ -436,8 +456,9 @@ make a command read-only. Help and info do not create coordination state. Dry-run uses shared protection and may create only `.vex/` and its coordination file. It never fetches, repairs dependencies, creates target output, or rewrites -the lockfile. Its single compiler planning call returns validated JSON on stdout; -this diagnostic compiler plan is separate from the versioned `vex metadata` API. +the lockfile. One planner call per selected target produces a single versioned +Vex build-plan JSON envelope on stdout, including each validated compiler plan; +this is separate from the read-only `vex metadata` API. Git candidates are staged and fully validated before publication. Existing checkouts and backup/recovery records remain under `.vex/`; lockfile replacement diff --git a/compiler/src/invocation.rs b/compiler/src/invocation.rs index d5eaf12..4c0f435 100644 --- a/compiler/src/invocation.rs +++ b/compiler/src/invocation.rs @@ -19,14 +19,159 @@ impl Execution { } } -pub fn run_build_with_dry_run( +/// A validated compiler plan. Constructing it does not create build outputs. +pub struct PreparedBuild { + plan: serde_json::Value, + args: Vec, + root: PathBuf, + outputs: Vec, + optional_outputs: Vec, + execution: Option<(Execution, PathBuf)>, + generation: Option, +} +impl PreparedBuild { + pub fn plan(&self) -> &serde_json::Value { + &self.plan + } + pub fn output_paths(&self) -> impl Iterator { + self.outputs.iter().chain(&self.optional_outputs) + } + /// Validate ownership for the full batch before any target is compiled. + pub fn preflight_ownership(&self) -> Result<(), Error> { + state::artifacts::Batch::prepare_with_optional( + &self.root, + self.outputs.clone(), + self.optional_outputs.iter().cloned(), + ) + .map(|_| ()) + .map_err(Error::environment) + } + pub fn execute( + self, + compiler: &crate::Compiler, + mut report: Option<&mut dyn FnMut(serde_json::Value) -> Result<(), Error>>, + ) -> Result, Error> { + let Self { + plan, + args, + root, + outputs, + optional_outputs, + execution, + generation, + } = self; + let wavec = &compiler.path; + state::ensure_dir(Path::new("target")).map_err(Error::environment)?; + let mut compile_args: Vec<_> = args[..] + .iter() + .filter(|a| a.as_str() != "--run") + .cloned() + .collect(); + let ownership = state::artifacts::Batch::prepare_with_optional( + &root, + outputs, + optional_outputs.iter().cloned(), + ) + .map_err(Error::environment)?; + let compile_result = (|| -> Result { + let status = if let Some(sink) = report.as_mut() { + insert_build_flag(&mut compile_args, "--error-format=json"); + let output = + process::output_without_deadline(Command::new(wavec).args(&compile_args)) + .map_err(Error::environment)?; + diagnostic::output::stdout(format_args!( + "{}", + String::from_utf8_lossy(&output.stdout) + ))?; + // Compiler diagnostics retain their original structured payload and spans. + // User-program streams never pass through this compiler-only capture. + diagnostic::output::stderr(format_args!( + "{}", + String::from_utf8_lossy(&output.stderr) + ))?; + emit_diagnostics(&output.stderr, &mut **sink)?; + output.status + } else { + process::status(Command::new(wavec).args(&compile_args), None, false).map_err( + |e| { + Error::environment(format!( + "failed to execute `{}` build: {e}", + wavec.display() + )) + }, + )? + }; + Ok(status) + })(); + let compile_result = compile_result.and_then(|status| { + if status.success() { + ownership.verify_outputs().map_err(Error::compiler) + } else { + Err(compiler_error( + status, + format!("wavec build failed [{}]", classify_exit(status)), + )) + } + }); + finish_compilation(compile_result, ownership.finish())?; + if let Some(sink) = report.as_mut() { + let mut outputs: Vec<_> = if let Some(output) = plan["link"]["output"].as_str() { + vec![output.to_owned()] + } else { + plan["compile"] + .as_array() + .unwrap() + .iter() + .filter_map(|job| job["output"].as_str().map(str::to_owned)) + .collect() + }; + outputs.extend( + optional_outputs + .iter() + .filter(|path| path.is_file()) + .map(|path| path.to_string_lossy().into_owned()), + ); + sink( + serde_json::json!({"event":"artifact", "origin":"vex", "target":plan["target"], + "emit":plan["emit"], "paths":outputs, "executable":execution.as_ref().map(|(_,path)| path)}), + )?; + } + if let Some((execution, output)) = execution { + state::reject_link(&output).map_err(Error::environment)?; + let actual = output.canonicalize().map_err(|e| { + Error::new( + if e.kind() == std::io::ErrorKind::NotFound { + diagnostic::Category::Compiler + } else { + diagnostic::Category::Environment + }, + format!("compiler did not produce {}: {e}", output.display()), + ) + })?; + let generation = generation + .as_deref() + .unwrap() + .canonicalize() + .map_err(|e| Error::environment(e.to_string()))?; + if !actual.starts_with(&generation) || !actual.is_file() { + return Err(Error::compiler( + "compiler output escaped the run generation", + )); + } + return Ok(Some(execution)); + } + Ok(None) + } +} + +pub fn prepare_build( compiler: &crate::Compiler, args: &[String], runtime: &[OsString], user_requested_dry_run: bool, generation: Option<&Path>, mut report: Option<&mut dyn FnMut(serde_json::Value) -> Result<(), Error>>, -) -> Result, Error> { +) -> Result { let mut dry_run_args = args.to_vec(); if !contains_dry_run_flag(&dry_run_args) { insert_build_flag(&mut dry_run_args, "--dry-run"); @@ -91,123 +236,151 @@ pub fn run_build_with_dry_run( }; let root = std::env::current_dir().map_err(Error::environment)?; let (outputs, optional_outputs) = validate_plan_outputs(&plan, &root, &args[..separator])?; - if user_requested_dry_run { - if !runtime.is_empty() { - let values = runtime - .iter() - .map(|arg| { - arg.to_str().map(str::to_owned).ok_or_else(|| { - Error::usage("dry-run JSON cannot represent non-UTF-8 program arguments") - }) + validate_requested_outputs(&plan, &root, &args[..separator])?; + if user_requested_dry_run && !runtime.is_empty() { + let values = runtime + .iter() + .map(|arg| { + arg.to_str().map(str::to_owned).ok_or_else(|| { + Error::usage("dry-run JSON cannot represent non-UTF-8 program arguments") }) - .collect::, _>>()?; - let args = plan["execute"]["args"] - .as_array_mut() - .ok_or_else(|| Error::compiler("run plan is missing execute.args"))?; - args.extend(values.into_iter().map(serde_json::Value::String)); - } + }) + .collect::, _>>()?; + let args = plan["execute"]["args"] + .as_array_mut() + .ok_or_else(|| Error::compiler("run plan is missing execute.args"))?; + args.extend(values.into_iter().map(serde_json::Value::String)); + } + Ok(PreparedBuild { + plan, + args: args[..separator].to_vec(), + root, + outputs, + optional_outputs, + execution, + generation: generation.map(Path::to_path_buf), + }) +} + +/// Compatibility entry point for callers that need only one compiler plan. +pub fn run_build_with_dry_run( + compiler: &crate::Compiler, + args: &[String], + runtime: &[OsString], + user_requested_dry_run: bool, + generation: Option<&Path>, + mut report: Option<&mut dyn FnMut(serde_json::Value) -> Result<(), Error>>, +) -> Result, Error> { + let prepared = if let Some(sink) = report.as_mut() { + prepare_build( + compiler, + args, + runtime, + user_requested_dry_run, + generation, + Some(&mut **sink), + )? + } else { + prepare_build( + compiler, + args, + runtime, + user_requested_dry_run, + generation, + None, + )? + }; + if user_requested_dry_run { diagnostic::outln!( "{}", - serde_json::to_string_pretty(&plan).map_err(Error::internal)? + serde_json::to_string_pretty(prepared.plan()).map_err(Error::internal)? ); - return Ok(None); + Ok(None) + } else { + prepared.execute(compiler, report) } - state::ensure_dir(Path::new("target")).map_err(Error::environment)?; - let mut compile_args: Vec<_> = args[..separator] - .iter() - .filter(|a| a.as_str() != "--run") - .cloned() - .collect(); - let ownership = state::artifacts::Batch::prepare_with_optional( - &root, - outputs, - optional_outputs.iter().cloned(), - ) - .map_err(Error::environment)?; - let compile_result = (|| -> Result { - let status = if let Some(sink) = report.as_mut() { - insert_build_flag(&mut compile_args, "--error-format=json"); - let output = process::output_without_deadline(Command::new(wavec).args(&compile_args)) - .map_err(Error::environment)?; - diagnostic::output::stdout(format_args!( - "{}", - String::from_utf8_lossy(&output.stdout) - ))?; - // Compiler diagnostics retain their original structured payload and spans. - // User-program streams never pass through this compiler-only capture. - diagnostic::output::stderr(format_args!( - "{}", - String::from_utf8_lossy(&output.stderr) - ))?; - emit_diagnostics(&output.stderr, &mut **sink)?; - output.status - } else { - process::status(Command::new(wavec).args(&compile_args), None, false).map_err(|e| { - Error::environment(format!( - "failed to execute `{}` build: {e}", - wavec.display() - )) - })? - }; - Ok(status) - })(); - let compile_result = compile_result.and_then(|status| { - if status.success() { - ownership.verify_outputs().map_err(Error::compiler) - } else { - Err(compiler_error( - status, - format!("wavec build failed [{}]", classify_exit(status)), - )) +} + +/// Bind compiler destinations to the artifact identity selected by Vex. Merely +/// remaining somewhere under target/ is insufficient for a multi-target batch. +fn validate_requested_outputs( + plan: &serde_json::Value, + root: &Path, + args: &[String], +) -> Result<(), Error> { + if args.iter().any(|arg| arg == "--emit=check") { + if plan["emit"] != "check" + || !plan["link"].is_null() + || !plan["compile"].as_array().unwrap().is_empty() + || !plan["emit_jobs"].as_array().unwrap().is_empty() + { + return Err(Error::compiler( + "compiler check plan must not emit artifacts", + )); } - }); - finish_compilation(compile_result, ownership.finish())?; - if let Some(sink) = report.as_mut() { - let mut outputs: Vec<_> = if let Some(output) = plan["link"]["output"].as_str() { - vec![output.to_owned()] - } else { - plan["compile"] - .as_array() - .unwrap() - .iter() - .filter_map(|job| job["output"].as_str().map(str::to_owned)) - .collect() - }; - outputs.extend( - optional_outputs - .iter() - .filter(|path| path.is_file()) - .map(|path| path.to_string_lossy().into_owned()), - ); - sink( - serde_json::json!({"event":"artifact", "origin":"vex", "target":plan["target"], - "emit":plan["emit"], "paths":outputs, "executable":execution.as_ref().map(|(_,path)| path)}), - )?; + return Ok(()); } - if let Some((execution, output)) = execution { - state::reject_link(&output).map_err(Error::environment)?; - let actual = output.canonicalize().map_err(|e| { - Error::new( - if e.kind() == std::io::ErrorKind::NotFound { - diagnostic::Category::Compiler - } else { - diagnostic::Category::Environment - }, - format!("compiler did not produce {}: {e}", output.display()), - ) - })?; - let generation = generation - .unwrap() - .canonicalize() - .map_err(|e| Error::environment(e.to_string()))?; - if !actual.starts_with(&generation) || !actual.is_file() { + let Some(expected) = args + .windows(2) + .find(|pair| pair[0] == "-o") + .map(|pair| &pair[1]) + else { + return Ok(()); + }; + let normalize = |path: &Path| { + let path = root.join(path); + let mut result = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + result.pop(); + } + _ => result.push(component.as_os_str()), + } + } + result + }; + let expected = normalize(Path::new(expected)); + let compile = plan["compile"].as_array().unwrap(); + let object = args.iter().any(|arg| arg == "--emit=obj"); + if object && (plan["emit"] != "obj" || !plan["link"].is_null()) { + return Err(Error::compiler( + "compiler object plan does not match the selected package target output kind", + )); + } + if !object && (plan["emit"] != "bin" || plan["link"].is_null()) { + return Err(Error::compiler( + "compiler linked plan does not match the selected package target output kind", + )); + } + if let Some(output) = plan["link"]["output"].as_str() { + if normalize(Path::new(output)) != expected { return Err(Error::compiler( - "compiler output escaped the run generation", + "compiler plan final output does not match the selected package target output", )); } - return Ok(Some(execution)); + if let Some(directory) = args + .iter() + .find_map(|arg| arg.strip_prefix("--target-dir=")) + { + let directory = normalize(Path::new(directory)); + for job in compile { + let output = normalize(Path::new(job["output"].as_str().unwrap())); + if output == directory || !output.starts_with(&directory) { + return Err(Error::compiler("compiler plan intermediate output escapes the selected package target directory")); + } + } + } + } else if plan["emit"] != "obj" + || compile.len() != 1 + || normalize(Path::new(compile[0]["output"].as_str().unwrap())) != expected + { + return Err(Error::compiler( + "compiler object plan does not match the selected package target output", + )); } - Ok(None) + Ok(()) } fn finish_compilation( diff --git a/compiler/src/lib.rs b/compiler/src/lib.rs index 3f243e1..9b36195 100644 --- a/compiler/src/lib.rs +++ b/compiler/src/lib.rs @@ -2,7 +2,10 @@ mod invocation; mod plan; mod selection; -pub use invocation::{contains_dry_run_flag, create_run_generation, run_build_with_dry_run}; +pub use invocation::{ + contains_dry_run_flag, create_run_generation, prepare_build, run_build_with_dry_run, + PreparedBuild, +}; pub use selection::Compiler; /// Validate the compiler protocol without starting a process. diff --git a/compiler/src/selection.rs b/compiler/src/selection.rs index b61b071..cacfb38 100644 --- a/compiler/src/selection.rs +++ b/compiler/src/selection.rs @@ -161,6 +161,7 @@ impl Compiler { "invalid or inconsistent compiler target-spec", )); } + self.specs.insert(Some(triple.to_owned()), spec.clone()); self.specs.insert(key, spec.clone()); Ok(spec) } diff --git a/docs/cli-contract-design.md b/docs/cli-contract-design.md index 40b8c3a..a0319f4 100644 --- a/docs/cli-contract-design.md +++ b/docs/cli-contract-design.md @@ -97,8 +97,8 @@ inherited access controls apply on Windows. Report paths are user-selected. `--message-file` combined with `--dry-run` validates the destination but creates no report. An existing destination (including a dangling symlink) or missing parent is an environment error, just as for an ordinary report. Dry-run still permits -coordination files only, with no recovery or migration. Existing dry-run plan -output on stdout remains unchanged. Flags after the run argument separator `--` +coordination files only, with no recovery or migration. Dry-run stdout is a versioned Vex build-plan envelope containing the selected +package targets and their compiler plans; see [the build contract](targets-and-profiles.md). Flags after the run argument separator `--` are program arguments and do not change Vex reporting behavior. If opening/writing the report fails before runtime starts, stop further work @@ -138,3 +138,9 @@ run executable before the program starts. Vex diagnostic events include structur context fields and cause chains. Neither human messages nor localized compiler text is reparsed to assign an exit category. Report writes still stop work before runtime starts and never replace an already executed program's exit status. + + +Artifact events retain schema 1 and add `package_target: {name, kind}`, `profile`, +and `artifact_kind`, allowing consumers to distinguish a library and binary with +the same name. `kind` is `library` or `binary`; profile is the selected profile +name. These fields do not replace the compiler target triple or emit kind. diff --git a/docs/commands.md b/docs/commands.md index 237b819..e1acd11 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -12,13 +12,13 @@ Vex - Wave package manager Usage: vex [-q|--quiet] [-v|--verbose] [--message-file ] [--manifest-path ] [options] vex init [--lib] - vex build [--target ] [--release] [--dry-run] [--locked] [--offline] - vex run [--target ] [--release] [--dry-run] [--locked] [--offline] [-- ] - vex check [--target ] [--release] [--dry-run] [--locked] [--offline] + vex build [--lib] [--bin ] [--target ] [--profile |--release] [--dry-run] [--locked] [--offline] + vex run [--bin ] [--target ] [--profile |--release] [--dry-run] [--locked] [--offline] [-- ] + vex check [--lib] [--bin ] [--target ] [--profile |--release] [--dry-run] [--locked] [--offline] vex fetch [--locked] [--offline] vex update [...] vex info - vex metadata [--format=json] [--locked] [--offline] + vex metadata [--format=json] [--format-version <1|2>] [--locked] [--offline] vex tree [--invert ] [--depth ] [--locked] [--offline] vex clean [--dry-run] vex prune [--apply] [--dry-run] @@ -40,19 +40,19 @@ usage: vex init [--lib] ## build ```text -usage: vex build [--target ] [--release] [--dry-run] [--locked] [--offline] +usage: vex build [--lib] [--bin ] [--target ] [--profile | --release] [--dry-run] [--locked] [--offline] ``` ## check ```text -usage: vex check [--target ] [--release] [--dry-run] [--locked] [--offline] +usage: vex check [--lib] [--bin ] [--target ] [--profile | --release] [--dry-run] [--locked] [--offline] ``` ## run ```text -usage: vex run [--target ] [--release] [--dry-run] [--locked] [--offline] [-- ] +usage: vex run [--bin ] [--target ] [--profile | --release] [--dry-run] [--locked] [--offline] [-- ] ``` ## fetch @@ -84,7 +84,7 @@ usage: vex tree [--invert ] [--depth ] [--locked] [--offline] ## metadata ```text -usage: vex metadata [--format=json] [--locked] [--offline] +usage: vex metadata [--format=json] [--format-version <1|2>] [--locked] [--offline] ``` ## doctor diff --git a/docs/doctor.md b/docs/doctor.md index 9e24447..c7e17d6 100644 --- a/docs/doctor.md +++ b/docs/doctor.md @@ -8,13 +8,15 @@ directory and its ancestors. An explicitly missing or invalid manifest is an err Doctor reports: - Vex version, host OS/architecture, executable path and invocation directory. -- The selected compiler path/version, supported targets and requested target. +- The selected compiler path/version, supported targets and effective target + (`--target`, then project `build.target`, then the compiler default). `VEX_WAVEC` is authoritative, including when its value is invalid. Relative compiler paths and PATH entries are interpreted from the invocation directory. - Git's executable path and version. -- An available project's manifest, canonical input and exact compiler requirement. -- Whether the existing dependency graph permits checking a compiler dry-run plan. -- The planned linker's path and execute permission when the plan requires linking. +- An available project's manifest, declared target entries and exact compiler requirement. +- Whether the existing dependency graph permits checking every root target + with the root's effective `dev` optimization level. +- Each planned linker's path and execute permission when its plan requires linking. Doctor does not install tools, fetch dependencies, create temporary projects, create coordination files, update lockfiles or checkouts, recover transactions, @@ -23,7 +25,12 @@ requests and, when possible, `wavec build --dry-run`. They use the existing boun noninteractive subprocess supervision. No target linker is executed. Project planning uses the existing graph under a read-only shared project lease. -The lease remains held while the compiler reads dependency sources. Missing +The lease remains held while the compiler reads dependency sources for every +declared target. Entry paths for all targets are checked before planning begins; +a missing secondary binary cannot be hidden by a valid library. Binary plans +include the package's own library import when present. Planner calls use the same +output layout and platform settings as a dev build, without executing compilation +or modifying artifact ownership. Missing coordination or dependencies, an inconsistent local graph, or pending recovery produces a warning and an explicit `not_checked` dry-run result. Doctor preserves that state; run `vex fetch` explicitly to prepare or recover the project. Outside a @@ -62,7 +69,7 @@ The machine-readable contract is [doctor-v1.json](schemas/doctor-v1.json). | --- | --- | | `schema_version` | Integer `1`; consumers must check it | | `success` | No required check failed; not a claim that unchecked checks passed | -| `checks` | Ordered diagnostic records; consumers should select by `id` | +| `checks` | Ordered diagnostic records; consumers should select by `id` and target details | | `checks[].id` | Check name, such as `wavec`, `capabilities`, `local_graph` or `dry_run` | | `checks[].status` | `ok`, `warning`, `error` or `not_checked` | | `checks[].required` | Whether this result is part of required environment validation | @@ -75,3 +82,11 @@ within schema v1. Consumers should ignore unknown fields and check IDs; changes existing field types or meanings require a new schema version. Object-key order is not a contract. Paths are absolute native paths where they identify tools or the selected manifest. Target strings are the compiler's capability names. + + +For mixed/multiple-target projects, `dry_run` and `linker` IDs repeat once per +planned target. Successful `dry_run` details add `package_target: {name, kind}`, +`profile: "dev"`, and effective `opt_level`. Linker details identify the package +target too. These are additive schema-1 fields. The report does not select a run +default or execute its program. CLI/compiler target selection remains separate +from a package's binary/library target name. diff --git a/docs/metadata.md b/docs/metadata.md index 5b743c6..c8443a7 100644 --- a/docs/metadata.md +++ b/docs/metadata.md @@ -1,8 +1,9 @@ -# Metadata schema v1 +# Project metadata schemas -`vex metadata [--format=json] [--locked] [--offline]` prints exactly one UTF-8 JSON -document on stdout. Diagnostics and lock-wait status use stderr. No compiler is -required. `--manifest-path` selects a project using the same rules as build. +`vex metadata [--format=json] [--format-version <1|2>] [--locked] [--offline]` +prints exactly one UTF-8 JSON document on stdout. Diagnostics and lock-wait status +use stderr. The default schema version remains 1. No compiler is required. +`--manifest-path` selects a project using the same rules as build. The command only reads the existing local graph under a shared project lease. It creates no coordination file, lockfile, checkout, transaction, recovery or @@ -12,38 +13,77 @@ Run `vex fetch` explicitly to prepare or recover that state. Validation never queries a remote. An explicitly requested `--message-file` remains a separate user-requested JSONL output, with the usual no-overwrite contract. +Both versions contain: + | Field | Meaning | | --- | --- | -| `schema_version` | Integer `1`; consumers must check it | +| `schema_version` | Integer 1 or 2; consumers must check it | | `root` | Selected project's package record | | `target_directory` | Absolute project `target` path, whether or not it exists | -| `packages` | All direct and transitive dependencies, sorted by package name | +| `packages` | Direct and transitive dependencies, sorted by package name | -Root and dependency package records contain `name`, `version`, `kind` (`binary` -or `library`), `root` (physical absolute directory), `manifest_path` (absolute -`vex.ws` path), `entry_path` (absolute canonical entry layout, `src/main.wave` or -`src/lib.wave`), and `dependencies` (sorted direct package names). `entry_path` -describes the manifest's conventional entry; metadata does not compile or search -for a fallback input. Package names are unique in the graph. Root edges refer to -`packages`, and dependency edges refer to the same list. +Package records share `name`, `version`, `manifest_format` (1, 2, or 3), `compiler` +(null or an exact version requirement), `root` (physical absolute directory), +`manifest_path` (absolute `vex.ws` path), nullable `native` input declarations, +and `dependencies` (sorted direct package names). Package names are unique in the +graph. Root and dependency edges refer to `packages`. -Dependency records also contain `source`: +Dependency records additionally contain `source`: - Path: `kind: "path"` and `requested`, the declared path string. - Git: `kind: "git"`, credential-free `url`, full exact `commit`, and nullable `branch`, `tag`, `rev` selectors. At most one selector is non-null. -Paths use native separators and preserve Unicode. A path that cannot be expressed -as a JSON string is an explicit error, never a lossy replacement. Git URLs exclude +## Schema 1 + +[JSON schema](schemas/metadata-v1.json). Each package contains exactly one `kind` +(`binary` or `library`) and absolute `entry_path`. For legacy manifests this is +`src/main.wave` or `src/lib.wave`; a format-3 single binary reports its declared +path. Metadata does not compile or search for a fallback input. + +Schema 1 requires each root/dependency package to have exactly one target. A +mixed library/binary or multiple-binary package produces an error with instructions +to use `--format-version 2`; targets are never silently omitted or merged into a +misleading single package kind. Existing single-target field meanings and types +are preserved, and callers can explicitly request `--format-version 1`. + +## Schema 2 + +[JSON schema](schemas/metadata-v2.json). Each root and dependency package replaces +`kind` and `entry_path` with `targets`, an array in deterministic manifest order. +Library precedes the declared binaries. Each target contains: + +| Field | Meaning | +| --- | --- | +| `name` | Declared binary name, or package name for its library | +| `kind` | `library` or `binary` | +| `entry_path` | Absolute declared entry path | +| `output` | `object` or `shared` for library, null for binary | +| `exports` | Declared C export symbol list; empty when absent | +| `platform` | Nullable `mode`, `entry`, `startup`, and `linker_script` fields | + +Each package also exposes `default_run` (nullable binary name), `profiles` (object +keyed by profile name with effective `opt_level` and nullable `inherits`), +`build_target` (nullable project default triple), and `target_config` (array of +exact-triple declarations). A target configuration has `triple`, nullable +`sysroot` and `linker`, and a `bins` array of `{name, platform}` overrides. + +Platform/sysroot/linker-script paths retain their declaration spelling and are +not probed for SDK or compiler availability. These fields describe each package; +dependency tool settings and profiles do not override the root build. Metadata +does not apply target overrides or claim compiler support. It exposes no guessed +host triple, selected profile, or final artifact paths. Legacy packages are +normalized to one target and the default dev/release profiles in schema 2. + +## Stability + +Paths preserve Unicode and native separators. A path that cannot be expressed as +a JSON string is an explicit error, never a lossy replacement. Git URLs exclude source authentication. The lockfile is preserved byte-for-byte, including v2, -when compatible; v1 requiring migration is rejected until an explicit fetch. +when compatible; a v1 requiring migration is rejected until an explicit fetch. The same project/graph on the same filesystem produces deterministic output. Absolute paths intentionally differ when the project is moved. Object-key order -is not an API guarantee. Consumers should ignore additive fields in schema v1; -changes to existing field meanings or types require a new schema version. - -Every root/package object exposes `manifest_format` (legacy default 1, escaped -strings 2) and `compiler` (null or an exact version requirement). These are additive -schema-1 fields. Metadata still does not invoke a compiler, fetch, recover, or write -project state. Unsupported future manifest formats fail without migration. +is not an API guarantee. Consumers should ignore additive fields in their chosen +schema; changes to existing field meanings or types require a new schema version. +Unsupported future manifest formats fail without migration. diff --git a/docs/native-interop.md b/docs/native-interop.md index 6537d39..1200dee 100644 --- a/docs/native-interop.md +++ b/docs/native-interop.md @@ -89,8 +89,7 @@ export(c) fun wave_add(a: i32, b: i32) -> i32 { return a + b; } Use `output = "shared"` for a shared library. Both values require `lib = true`. A library without an explicit output setting now builds an object instead of attempting to link an executable without `main`. A binary package can declare -native inputs but cannot set a library output kind. `vex run` still rejects -library packages. `vex check` performs Wave checking without linking native +native inputs but cannot set a library output kind. `vex run` selects a binary and rejects a library-only package. `vex check` performs Wave checking without linking native inputs or producing native artifacts; it still validates their selected paths. Windows DLLs also require an explicit list of exported C symbols: @@ -119,26 +118,30 @@ Exceptions or Rust panics must not unwind through an incompatible ABI boundary. ## Artifacts and reproducibility -Library packages and binary packages using native declarations (including -transitive declarations) use: +All package targets, including legacy manifests and binaries without native +inputs, use: -- `target//dev/` or `target//release/` -- `.o` (`.obj` on Windows) for objects +- `target///bin/` for named binaries +- `target///lib/` for library artifacts and companions +- `.o` (`.obj` on Windows) for object libraries - `lib.so`, `lib.dylib`, or `.dll` for shared libraries -- `` (`.exe` on Windows) for native binaries -- `.vex-obj//` below that directory for intermediate objects +- `` (`.exe` on Windows, `.wasm` for Wasm) for binaries +- `.vex-obj////` for intermediate objects -Run outputs remain in a per-run generation; its lease protects them while the -program executes. Binary projects with no native declarations retain their -existing layout. This is the native slice of the broader targets/layout design; -multiple named package targets and custom profiles are not introduced. +Run outputs remain under a per-run generation; its lease protects them while the +program executes. Old outputs are not moved or removed automatically. Format 3 +supports a library together with explicit named binaries and profiles; library +output/exports move to `targets.lib`, while native inputs stay in root `native`. +For example, `targets = { lib = { output = "shared", exports = ["wave_add"] } }`. +See [the unified target model](targets-and-profiles.md) for selection and settings. ELF shared libraries receive a stable SONAME; Mach-O shared libraries receive an `@rpath` install name. Windows import libraries and other compiler-generated companions are preserved and tracked when produced. `--message-file` artifact events report paths only after successful output verification. Metadata schema 1 -adds a nullable `native` declaration object for each package without querying a -compiler. Consumers must continue to tolerate additive fields. +retains nullable `native` input declarations for single-target packages without +querying a compiler. Schema 2 adds each target's output/exports and platform settings +and supports mixed/multiple targets. Consumers must continue to tolerate additive fields. Builds refuse overwriting unowned or edited artifact files, including implicit Windows companions. Move such files aside explicitly before rebuilding. `clean` diff --git a/docs/reference.md b/docs/reference.md index dc859f5..d693c11 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -12,14 +12,19 @@ Builds and user programs execute from the selected project root. | Field | Type | Default / meaning | | --- | --- | --- | -| `format` | integer 1 or 2 | 1 when absent; generated manifests use 2 | +| `format` | integer 1, 2, or 3 | 1 when absent; generated single-target manifests use 2 | | `name` | string | Required package/import identity | | `version` | version literal or string | `0.1.0`; dependency equality is exact | -| `lib` | boolean | false; true selects `src/lib.wave`, otherwise `src/main.wave` | +| `lib` | boolean | Formats 1/2 only: false; true selects `src/lib.wave`, otherwise `src/main.wave` | | `compiler` | string | Absent means no version requirement; exact canonical wavec version, including prerelease, without build metadata | | `description`, `author`, `license` | string | Optional descriptive metadata | | `dependencies` | array of objects | Empty when absent | -| `native` | object | Optional prebuilt library inputs, search paths and library output; see [native interoperability](native-interop.md) | +| `native` | object | Prebuilt library inputs and search paths; formats 1/2 also declare library output here; see [native interoperability](native-interop.md) | +| `targets` | object | Required in format 3; optional `lib` and array `bins` declare at least one target | +| `default_run` | string | Format 3: optional declared binary name | +| `profiles` | object | Format 3: dev/release overrides and custom profiles inheriting one builtin | +| `build` | object | Format 3: optional default compiler `target` triple | +| `target_config` | array of objects | Format 3: exact-triple sysroot/linker and binary platform overrides | A dependency requires a unique `name` and exactly one nonempty `path` or `git` string. Optional `version` is an exact package version assertion, not a range. @@ -38,7 +43,12 @@ cannot identify conflicting sources or versions anywhere in the graph. } ``` -Format 1 preserves legacy literal backslashes. Format 2 decodes `\\`, `\"`, +See [targets, profiles, and artifacts](targets-and-profiles.md) for all format-3 +fields, selection, platform restrictions, and migration. Existing formats are +not automatically rewritten. Library dependencies may declare binaries in format +3, but consumers only use their library. + +Format 1 preserves legacy literal backslashes. Formats 2 and 3 decode `\\`, `\"`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`, and JSON `\uXXXX` sequences; unsupported escapes are rejected. A format change is explicit: files are never silently reinterpreted. Additive fields may retain a @@ -155,8 +165,32 @@ JSONL exclusively to a new file, never mixed into stdout. Failure before program start stops Vex; reporting failure after program start warns and preserves the program exit. Missing final events mean an incomplete report. -Machine-readable schemas: [metadata](schemas/metadata-v1.json), +Machine-readable schemas: [metadata v1](schemas/metadata-v1.json), +[metadata v2](schemas/metadata-v2.json), [build-plan v1](schemas/build-plan-v1.json), [doctor](schemas/doctor-v1.json) and [message events](schemas/messages-v1.json). Additive fields are permitted; changed existing meanings require a new schema version. There is no registry or publish command in this release. + + +## Build selection and outputs + +`--lib` and repeatable `--bin ` select root package targets. Build/check +without selectors process all root targets; run uses `--bin`, `default_run`, or +the only binary. `--target ` still selects a compiler platform. +`--profile ` defaults to dev; `--release` aliases the release profile and +conflicts with an explicit profile. The root's profile is used for imported code. + +Every selected target is planned and preflighted before compilation. Targets then +compile in deterministic order, stopping at the first failure while preserving +already successful artifacts. General and native output paths are unified under +`target///{bin,lib}/`, with intermediate objects under +`.vex-obj////`. Old paths are left untouched. Run keeps an +independent generation, lease and lock-release boundary. + +Dry-run stdout is one `vex.build-plan` schema-1 envelope for single or multiple +targets. It contains `target`, `profile: {name, opt_level}`, and ordered `plans` +entries with `package_target: {name, kind}` and the validated `compiler_plan`. +It replaces the previous direct compiler-plan output. Planning runs once per +selected target; capability queries are cached within the invocation. See the +[full build contract](targets-and-profiles.md). diff --git a/docs/release-readiness.md b/docs/release-readiness.md index cff062b..37ea0c1 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -1,4 +1,4 @@ -# Unreleased: state coordination and format migration +# Unreleased: targets, profiles, and format migration This describes the development branch, not an already published Vex release. No release version or tag is assigned by this change. @@ -9,6 +9,7 @@ No release version or tag is assigned by this change. | --- | --- | --- | --- | | Manifest without `format`, or format 1 | Literal backslashes | Same | Same | | Manifest format 2 | JSON string escapes | Same | Same | +| Manifest format 3 | Explicit package targets/profiles; JSON string escapes | Same; no manifest rewrite | Same | | Lockfile v1 | Resolve and migrate to v3 | Reject unchanged | Migrate only from locally available sources | | Lockfile v2 | Read legacy strings; successful resolution may migrate to v3 | Preserve bytes if graph matches | Never fetch; migration still allowed without `--locked` | | Lockfile v3 | Read/write escaped strings | Preserve bytes if graph matches | Never fetch | @@ -27,6 +28,35 @@ keys, and reports parser source locations. Unsupported versioned escapes fail before graph publication. Package version strings retain their existing meaning; format versions do not introduce SemVer dependency requirements or a registry. +## Target/profile migration (#46, #47, #48, #54) + +Manifest format 3 opts into an explicit library and named binaries, root profiles, +default run selection, and exact-triple build settings. Legacy formats retain +their meaning, and are not rewritten. Use `targets.lib` instead of root `lib`, +and put library output/exports there instead of `native`. Keep native inputs in +root `native`. Lockfile v3 retains its source, commit and edge meanings. + +All manifests now produce ordinary/native outputs under +`target///{bin,lib}/`, with isolated intermediate objects. +Previous files remain at their old locations; Vex neither moves nor deletes them. +Only recorded, unchanged outputs qualify for cleanup. Run retains independent +generations and releases the build lock before starting the program. + +`--release` aliases `--profile release`; selecting both is a usage error. New +profiles have optimization levels 0–3 and inherit only dev or release. Build/check +default to all root targets; run requires an unambiguous binary choice. + +Dry-run stdout changes for every project: it is one `vex.build-plan` schema-1 +JSON envelope, containing the profile and target-specific compiler plans, rather +than a direct compiler plan. Consumers must update their readers. Metadata keeps +schema 1 as the default for single-target packages; a mixed/multiple-target +package requires explicit `--format-version 2`. Metadata remains read-only and +does not query a compiler or guess target-specific output paths. + +See [the complete contract](targets-and-profiles.md). These source changes still +need the normal review and native platform CI; older release evidence does not +validate this implementation. + ## Dependency transaction 1. Validate CLI options and the root manifest without creating state. diff --git a/docs/roadmap.md b/docs/roadmap.md index 2dbc28a..0eae403 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -20,10 +20,16 @@ have independent OS leases; there is no automatic GC. Prune defaults to preview and requires `--apply` for deletion. Recovery transactions, transaction backups and prior compiler installations remain outside deletion scope. +The current target/profile work (#46/#47/#48/#54) adds manifest format 3, +explicit library and binary targets, unified artifact paths, root profiles and +exact-triple platform settings. Metadata schema 2 describes these declarations; +dry-run uses a Vex build-plan envelope. See [the contract](targets-and-profiles.md) +for compatibility details. Acceptance follows the checks for this source commit. + ## Separate designs -User workspaces (#30), shared caches (#29), multiple package targets and profiles -(#46/#47/#48), manifest editing (#28), build fingerprints (#59), package version +User workspaces (#30), shared caches (#29), manifest editing (#28), build +fingerprints (#59), package version ranges (#132/#51), optional dependencies, registry and publish remain separate product work. Implemented project discovery, metadata, lockfile v3, source credential handling, project leases, isolated run generations and atomic init diff --git a/docs/schemas/build-plan-v1.json b/docs/schemas/build-plan-v1.json new file mode 100644 index 0000000..007c83c --- /dev/null +++ b/docs/schemas/build-plan-v1.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Vex build-plan v1", + "type": "object", + "required": ["schema", "schema_version", "target", "profile", "plans"], + "properties": { + "schema": {"const": "vex.build-plan"}, + "schema_version": {"const": 1}, + "target": {"type": "string"}, + "profile": { + "type": "object", + "required": ["name", "opt_level"], + "properties": { + "name": {"type": "string"}, + "opt_level": {"type": "integer", "minimum": 0, "maximum": 3} + } + }, + "plans": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["package_target", "compiler_plan"], + "properties": { + "package_target": { + "type": "object", + "required": ["name", "kind"], + "properties": { + "name": {"type": "string"}, + "kind": {"enum": ["library", "binary"]} + } + }, + "compiler_plan": {"type": "object"} + } + } + } + } +} diff --git a/docs/schemas/messages-v1.json b/docs/schemas/messages-v1.json index eb37898..8bf9bd2 100644 --- a/docs/schemas/messages-v1.json +++ b/docs/schemas/messages-v1.json @@ -148,6 +148,34 @@ "string", "null" ] + }, + "package_target": { + "type": "object", + "required": [ + "name", + "kind" + ], + "properties": { + "name": { + "type": "string" + }, + "kind": { + "enum": [ + "library", + "binary" + ] + } + } + }, + "profile": { + "type": "string" + }, + "artifact_kind": { + "enum": [ + "object", + "shared", + "binary" + ] } } }, diff --git a/docs/schemas/metadata-v1.json b/docs/schemas/metadata-v1.json index 40b2d43..4a0759a 100644 --- a/docs/schemas/metadata-v1.json +++ b/docs/schemas/metadata-v1.json @@ -71,7 +71,8 @@ "manifest_format": { "enum": [ 1, - 2 + 2, + 3 ] }, "compiler": { diff --git a/docs/schemas/metadata-v2.json b/docs/schemas/metadata-v2.json new file mode 100644 index 0000000..f1f64db --- /dev/null +++ b/docs/schemas/metadata-v2.json @@ -0,0 +1,305 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Vex metadata v2", + "type": "object", + "required": [ + "schema_version", + "root", + "packages", + "target_directory" + ], + "properties": { + "schema_version": { + "const": 2 + }, + "root": { + "$ref": "#/$defs/package" + }, + "packages": { + "type": "array", + "items": { + "$ref": "#/$defs/package" + } + }, + "target_directory": { + "type": "string" + } + }, + "$defs": { + "package": { + "type": "object", + "required": [ + "name", + "version", + "root", + "manifest_path", + "dependencies", + "manifest_format", + "compiler", + "targets", + "default_run", + "profiles", + "build_target", + "target_config" + ], + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "root": { + "type": "string" + }, + "manifest_path": { + "type": "string" + }, + "dependencies": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "manifest_format": { + "enum": [ + 1, + 2, + 3 + ] + }, + "compiler": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "enum": [ + "path", + "git" + ] + } + } + }, + "native": { + "type": [ + "object", + "null" + ], + "properties": { + "output": { + "enum": [ + "object", + "shared", + null + ] + }, + "exports": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "uniqueItems": true + }, + "libraries": { + "type": "array", + "items": { + "type": "object" + } + }, + "search_paths": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "targets": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/target" + } + }, + "default_run": { + "type": [ + "string", + "null" + ] + }, + "profiles": { + "type": "object", + "required": [ + "dev", + "release" + ], + "additionalProperties": { + "type": "object", + "required": [ + "opt_level", + "inherits" + ], + "properties": { + "opt_level": { + "type": "integer", + "minimum": 0, + "maximum": 3 + }, + "inherits": { + "enum": [ + "dev", + "release", + null + ] + } + } + } + }, + "build_target": { + "type": [ + "string", + "null" + ] + }, + "target_config": { + "type": "array", + "items": { + "type": "object", + "required": [ + "triple", + "sysroot", + "linker", + "bins" + ], + "properties": { + "triple": { + "type": "string" + }, + "sysroot": { + "type": [ + "string", + "null" + ] + }, + "linker": { + "type": [ + "string", + "null" + ] + }, + "bins": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "platform" + ], + "properties": { + "name": { + "type": "string" + }, + "platform": { + "$ref": "#/$defs/platform" + } + } + } + } + } + } + } + } + }, + "platform": { + "type": "object", + "required": [ + "mode", + "entry", + "startup", + "linker_script" + ], + "properties": { + "mode": { + "enum": [ + "hosted", + "freestanding", + "wasi", + "bare_wasm", + null + ] + }, + "entry": { + "type": [ + "string", + "null" + ] + }, + "startup": { + "enum": [ + "default", + "none", + null + ] + }, + "linker_script": { + "type": [ + "string", + "null" + ] + } + } + }, + "target": { + "type": "object", + "required": [ + "name", + "kind", + "entry_path", + "output", + "exports", + "platform" + ], + "properties": { + "name": { + "type": "string" + }, + "kind": { + "enum": [ + "library", + "binary" + ] + }, + "entry_path": { + "type": "string" + }, + "output": { + "enum": [ + "object", + "shared", + null + ] + }, + "exports": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "platform": { + "$ref": "#/$defs/platform" + } + } + } + } +} diff --git a/docs/targets-and-profiles.md b/docs/targets-and-profiles.md new file mode 100644 index 0000000..22639d1 --- /dev/null +++ b/docs/targets-and-profiles.md @@ -0,0 +1,197 @@ +# Package targets, profiles, and artifacts + +Manifest format 3 declares a package's build targets. Formats 1 and 2 retain their +single-target meaning and string rules; Vex reads them without rewriting the +manifest. Their outputs use the same new layout as format 3. Lockfile v3 is +unchanged: target declarations do not introduce package self-edges or alter the +meaning of a dependency source, commit, or edge. + +## Declare targets + +```wson +{ + format = 3, + name = "demo", + version = "0.1.0", + targets = { + lib = { output = "object" }, + bins = [ + { name = "demo", path = "src/main.wave" }, + { name = "admin", path = "src/bin/admin.wave" } + ] + }, + default_run = "demo", + profiles = { + dev = { opt_level = 0 }, + release = { opt_level = 2 }, + fast = { inherits = "release", opt_level = 3 } + } +} +``` + +A package declares at least one target. `targets.lib` is optional, names the +package's sole library, and uses `src/lib.wave`. Its `output` is `object` (the +default) or `shared`; `exports` is a list of C symbols for shared output, following +[the native ABI contract](native-interop.md). Format 3 does not accept root `lib` +or `native.output`/`native.exports`; declare those choices in `targets.lib`. +`native.libraries` and `native.search_paths` still describe package-owned inputs. + +Each `targets.bins` entry requires `name` and `path`. Paths are portable relative +`.wave` paths inside the package, without symlink escapes. Vex does not discover +additional binaries by scanning `src/bin`. Binary/profile names use portable +`[A-Za-z_][A-Za-z0-9_-]*` components and exclude Windows device names; names must +not differ only in ASCII case. A binary and library may share a name because their +artifacts use separate directories. + +A binary imports its own package library through the ordinary Wave package import +contract. Vex does not automatically link a separately emitted copy of that +library object into the binary. A dependency package must provide a library; +its declared binaries are not built as part of consuming that dependency. + +## Select targets and profiles + +`build` and `check` select every root target by default. `--lib` selects the +library, and repeatable `--bin ` selects declared binaries. Selection errors +are reported before compilation. All selected plans and output destinations are +validated before the first compiler execution. Vex then processes targets in a +deterministic order and stops at the first failure. Previously completed target +artifacts remain available; the command is not a transaction across targets. + +`run` selects `--bin `, then `default_run`, then the only binary. A package +with no binary or several binaries without a default produces an actionable +error. A library is never an executable run target. Bare WebAssembly has no process +argument ABI; nonempty program arguments require a WASI target. + +```sh +vex build # All root targets, dev profile +vex check --lib --bin demo # Selected library and binary +vex build --bin admin --profile fast +vex build --release # Alias for --profile release +vex run --bin demo -- argument +``` + +`--profile ` defaults to `dev`. `--release` and `--profile` cannot be used +together. Profiles are selected from the root manifest and apply to the entire +build, including imported dependency code. The default optimization levels are +`dev = 0` and `release = 2`. Either builtin can set `opt_level`; a custom profile +must inherit exactly `dev` or `release`, and may override its level. Custom-profile +chains are not supported. `opt_level` accepts integers 0, 1, 2, or 3 only. + +Debug information, overflow/assertion behavior, LTO, and `s`/`z` optimizations are +not declared because the supported compiler contract does not guarantee them. +Unknown settings fail instead of being silently ignored. Dependency profiles do +not override the root's chosen build settings. + +## Configure a compiler target + +`--target ` selects a compiler platform, separately from a package target +such as `--bin admin`. When absent, `build.target` supplies a project default; +otherwise the selected compiler supplies its default. Vex validates the selected +triple and applicable settings against compiler capabilities before building. +Settings match an exact compiler triple, with no target predicate language or +change to the Wave dependency graph. + +```wson +{ + format = 3, + name = "firmware", + targets = { + bins = [{ + name = "firmware", + path = "src/main.wave", + mode = "freestanding", + entry = "start", + startup = "none", + linker_script = "link/firmware.ld" + }] + }, + build = { target = "aarch64-unknown-linux-gnu" }, + target_config = [{ + triple = "aarch64-unknown-linux-gnu", + sysroot = "/opt/sdk/aarch64", + linker = "aarch64-linux-gnu-gcc", + bins = [{ name = "firmware", entry = "platform_start" }] + }] +} +``` + +The example describes settings; availability of a matching compiler target, +SDK, linker, startup environment and entry point must be verified separately. +Vex does not install them automatically. + +| Field | Contract | +| --- | --- | +| Binary `mode` | `hosted`, `freestanding`, `wasi`, or `bare_wasm`; absent uses the selected target's native/Wasm hostedness | +| Binary `entry` | ASCII symbol identifier; freestanding/bare Wasm modes only | +| Binary `startup` | `default` or `none`; `none` requires freestanding/bare Wasm | +| Binary `linker_script` | Package-relative linker-script path; freestanding only | +| `build.target` | Optional default exact compiler triple | +| `target_config[].triple` | Unique exact triple selecting these settings | +| `target_config[].sysroot` | SDK directory, relative to project root or absolute; applied to compile and link | +| `target_config[].linker` | One executable name or path, with no embedded arguments | +| `target_config[].bins` | Named binary overrides of mode/entry/startup/linker_script | + +An override changes only explicitly present fields. The resulting combination +must be valid. A sysroot can point outside the project; package source and linker +script paths remain constrained to their declaring project. Only root project +tool settings choose the linker/sysroot for a build; dependency manifests do not +replace them. Unsupported compiler/target combinations fail before compilation. +For the supported compiler, a sysroot is available on ELF and bundled Mach-O +linkers; a custom Mach-O linker with sysroot, COFF sysroots, and Wasm sysroots +are rejected because one consistent compile/link SDK cannot be guaranteed. +Raw compiler/linker flag lists are not part of this manifest interface. + +## Output layout and cleanup + +Every build, including legacy manifests and builds without native inputs, uses: + +```text +target/// +├── bin/[.exe or .wasm] +├── lib/ +└── .vex-obj//// +``` + +Object libraries are `.o` (`.obj` on Windows). Shared libraries use +`lib.so`, `lib.dylib`, or `.dll`; import libraries and +other compiler companions remain alongside their library output. Bin and lib +namespaces prevent collisions when a Windows binary and DLL have the same name. + +Old output files are not moved, adopted, or deleted automatically. Vex continues +to refuse overwriting unknown or edited artifacts. `clean` removes only unchanged +recorded outputs. `run` retains a unique `target/.vex-run//` prefix, +releases the build lock before spawning the program, and protects live outputs +with a run lease. There is no automatic generation garbage collection. + +## Dry-run and metadata + +`build --dry-run`, `check --dry-run`, and `run --dry-run` always print one +Vex-owned JSON document, +even when selecting a single target: + +```json +{ + "schema": "vex.build-plan", + "schema_version": 1, + "target": "x86_64-unknown-linux-gnu", + "profile": { "name": "dev", "opt_level": 0 }, + "plans": [ + { + "package_target": { "name": "demo", "kind": "binary" }, + "compiler_plan": { "schema_version": 1 } + } + ] +} +``` + +`compiler_plan` contains the complete validated compiler plan; the abbreviated +object above illustrates the envelope only. There is one planner invocation per +selected package target. Compiler capability queries are cached for that Vex +process. Dry-run may create the existing coordination directory/file only, and +never outputs, dependency checkouts, recovery, or a lockfile rewrite. The former +direct compiler-plan stdout shape is replaced by this envelope. + +[Metadata schema 2](metadata.md) exposes the declared targets/profiles/platform +configuration without querying a compiler. It does not guess an actual default +compiler triple or an artifact path. `vex metadata` defaults to schema 1 for +single-target consumers; use `--format-version 2` for mixed or multiple targets. diff --git a/examples/parser_fuzz.rs b/examples/parser_fuzz.rs index b0dc4ad..cc43f0c 100644 --- a/examples/parser_fuzz.rs +++ b/examples/parser_fuzz.rs @@ -35,15 +35,17 @@ fn main() { args.next() .unwrap_or_else(|| "target/parser-fuzz-crash".into()), ); - let seeds: [&[u8]; 3] = [ + let seeds: [&[u8]; 4] = [ br#"{format=2,name="app",compiler="0.2.1-pre-beta",dependencies=[]}"#, + br#"{format=3,name="app",targets={lib={},bins=[{name="app",path="src/main.wave"}]},default_run="app",profiles={fast={inherits="release",opt_level=3}},target_config=[{triple="wasm32-wasip1",bins=[{name="app",mode="wasi"}]}]}"#, br#"{version=3,package=[]}"#, br#"{"schema_version":1,"compile":[],"link":null,"execute":null}"#, ]; let mut state = seed; for index in 0..count { - let kind = index % 3; - let mut bytes = seeds[kind].to_vec(); + let corpus = index % seeds.len(); + let kind = corpus.saturating_sub(1); + let mut bytes = seeds[corpus].to_vec(); let mut random = || { state ^= state << 13; state ^= state >> 7; diff --git a/manifest/src/lib.rs b/manifest/src/lib.rs index 36e31ec..b8f66bc 100644 --- a/manifest/src/lib.rs +++ b/manifest/src/lib.rs @@ -1,9 +1,15 @@ +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +mod model; mod native; mod parse; mod render; +pub use model::{ + BinaryOverride, BuildProfile, PackageTarget, PlatformMode, PlatformSettings, Startup, + TargetConfig, TargetKind, +}; pub use native::{NativeConfig, NativeLibrary, NativeOutput, NativeSearchPath}; pub use render::render_new_manifest; @@ -14,6 +20,11 @@ pub fn decode(raw: &str, source_path: PathBuf) -> Result { pub const MANIFEST_FILE: &str = "vex.ws"; +/// Validate a target name before writing an explicit-target manifest. +pub fn validate_target_name(name: &str) -> Result<(), String> { + model::validate_component(name, "target name") +} + /// Package names are Wave import identifiers. Never silently normalize a name. pub fn validate_package_name(name: &str) -> Result<(), String> { let mut bytes = name.bytes(); @@ -57,6 +68,11 @@ pub struct Manifest { pub version: String, pub lib: bool, pub native: Option, + pub targets: Vec, + pub default_run: Option, + pub profiles: BTreeMap, + pub build_target: Option, + pub target_config: Vec, pub description: Option, pub author: Option, pub license: Option, @@ -109,10 +125,36 @@ impl Manifest { } pub fn default_entry_path(&self) -> PathBuf { - if self.lib { - PathBuf::from("src/lib.wave") - } else { - PathBuf::from("src/main.wave") + self.targets + .iter() + .find(|target| target.kind == TargetKind::Library) + .or_else(|| self.targets.first()) + .expect("validated manifests have targets") + .path + .clone() + } + + pub fn profile(&self, name: &str) -> Result<&BuildProfile, String> { + self.profiles + .get(name) + .ok_or_else(|| format!("unknown build profile `{name}`")) + } + + pub fn platform_for(&self, target: &PackageTarget, triple: &str) -> PlatformSettings { + let mut platform = target.platform.clone(); + if let Some(overrides) = self + .target_config + .iter() + .find(|config| config.triple == triple) + .and_then(|config| { + config + .bins + .iter() + .find(|bin| bin.name == target.name && target.kind == TargetKind::Binary) + }) + { + platform.overlay(&overrides.platform); } + platform } } diff --git a/manifest/src/model.rs b/manifest/src/model.rs new file mode 100644 index 0000000..b4ab74b --- /dev/null +++ b/manifest/src/model.rs @@ -0,0 +1,739 @@ +use std::collections::{BTreeMap, HashSet}; +use std::path::PathBuf; + +use wson::{WsonMap, WsonValue}; + +use crate::{native, parse::reject_unknown_fields, NativeConfig, NativeOutput}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TargetKind { + Library, + Binary, +} +impl TargetKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Library => "library", + Self::Binary => "binary", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackageTarget { + pub name: String, + pub kind: TargetKind, + pub path: PathBuf, + pub output: Option, + pub exports: Vec, + pub platform: PlatformSettings, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PlatformMode { + Hosted, + Freestanding, + Wasi, + BareWasm, +} +impl PlatformMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Hosted => "hosted", + Self::Freestanding => "freestanding", + Self::Wasi => "wasi", + Self::BareWasm => "bare_wasm", + } + } +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Startup { + Default, + None, +} +impl Startup { + pub fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::None => "none", + } + } +} +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PlatformSettings { + pub mode: Option, + pub entry: Option, + pub startup: Option, + pub linker_script: Option, +} +impl PlatformSettings { + pub(crate) fn overlay(&mut self, other: &Self) { + if other.mode.is_some() { + self.mode = other.mode; + } + if other.entry.is_some() { + self.entry.clone_from(&other.entry); + } + if other.startup.is_some() { + self.startup = other.startup; + } + if other.linker_script.is_some() { + self.linker_script.clone_from(&other.linker_script); + } + } +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BuildProfile { + pub opt_level: u8, + pub inherits: Option, +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TargetConfig { + pub triple: String, + pub sysroot: Option, + pub linker: Option, + pub bins: Vec, +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BinaryOverride { + pub name: String, + pub platform: PlatformSettings, +} + +pub(crate) struct Model { + pub targets: Vec, + pub default_run: Option, + pub profiles: BTreeMap, + pub build_target: Option, + pub target_config: Vec, +} + +pub(crate) fn legacy(name: &str, lib: bool, native: Option<&NativeConfig>) -> Model { + Model { + targets: vec![PackageTarget { + name: name.to_owned(), + kind: if lib { + TargetKind::Library + } else { + TargetKind::Binary + }, + path: if lib { "src/lib.wave" } else { "src/main.wave" }.into(), + output: lib.then(|| { + native + .and_then(|n| n.output) + .unwrap_or(NativeOutput::Object) + }), + exports: native.map(|n| n.exports.clone()).unwrap_or_default(), + platform: PlatformSettings::default(), + }], + default_run: None, + profiles: default_profiles(), + build_target: None, + target_config: vec![], + } +} + +pub(crate) fn parse(data: &WsonMap, package_name: &str) -> Result { + let target_object = object(data.get("targets"), "manifest field `targets`")?; + reject_unknown_fields(target_object, &["lib", "bins"], "targets")?; + let mut targets = Vec::new(); + if let Some(value) = target_object.get("lib") { + let library = object(Some(value), "targets.lib")?; + reject_unknown_fields(library, &["output", "exports"], "targets.lib")?; + let output = match string(library, "output", "targets.lib")?.as_deref() { + None | Some("object") => NativeOutput::Object, + Some("shared") => NativeOutput::Shared, + Some(_) => return Err("targets.lib output must be `object` or `shared`".into()), + }; + // The native export parser enforces one portable C symbol per entry. + let mut export_config = library.clone(); + export_config.insert("output".into(), WsonValue::String(output.as_str().into())); + let exports = native::parse(Some(&WsonValue::Object(export_config)), true)? + .expect("library config exists") + .exports; + targets.push(PackageTarget { + name: package_name.into(), + kind: TargetKind::Library, + path: "src/lib.wave".into(), + output: Some(output), + exports, + platform: PlatformSettings::default(), + }); + } + let mut bin_names = HashSet::new(); + for value in array(target_object, "bins", "targets")? { + let bin = object(Some(value), "binary target")?; + reject_unknown_fields( + bin, + &["name", "path", "mode", "entry", "startup", "linker_script"], + "binary target", + )?; + let name = required_string(bin, "name", "binary target")?; + validate_component(&name, "binary target name")?; + if !bin_names.insert(name.to_ascii_lowercase()) { + return Err(format!("binary target `{name}` is declared more than once (names are ASCII-case-insensitive)")); + } + let path = required_string(bin, "path", "binary target")?; + native::validate_path(&path, "binary target path")?; + if !path.ends_with(".wave") { + return Err("binary target path must end in `.wave`".into()); + } + let platform = platform(bin, "binary target")?; + validate_platform(&platform, "binary target")?; + targets.push(PackageTarget { + name, + kind: TargetKind::Binary, + path: path.into(), + output: None, + exports: vec![], + platform, + }); + } + if targets.is_empty() { + return Err("format 3 requires at least one explicitly declared target".into()); + } + let default_run = string(data, "default_run", "manifest")?; + if let Some(name) = &default_run { + if !targets + .iter() + .any(|t| t.kind == TargetKind::Binary && t.name == *name) + { + return Err(format!( + "default_run `{name}` must name a declared binary target" + )); + } + } + let profiles = profiles(data.get("profiles"))?; + let build_target = match data.get("build") { + None => None, + Some(value) => { + let build = object(Some(value), "build")?; + reject_unknown_fields(build, &["target"], "build")?; + let target = string(build, "target", "build")?; + if let Some(triple) = &target { + validate_triple(triple)?; + } + target + } + }; + let mut configs = Vec::new(); + let mut triples = HashSet::new(); + for value in array(data, "target_config", "manifest")? { + let config = object(Some(value), "target_config entry")?; + reject_unknown_fields( + config, + &["triple", "sysroot", "linker", "bins"], + "target_config", + )?; + let triple = required_string(config, "triple", "target_config")?; + validate_triple(&triple)?; + if !triples.insert(triple.clone()) { + return Err(format!( + "target_config `{triple}` is declared more than once" + )); + } + let sysroot = string(config, "sysroot", "target_config")?.map(PathBuf::from); + let linker = string(config, "linker", "target_config")?; + if linker.as_ref().is_some_and(|value| value.starts_with('-')) { + return Err( + "target_config linker must be one executable name or path without arguments".into(), + ); + } + let mut bins = Vec::new(); + let mut names = HashSet::new(); + for value in array(config, "bins", "target_config")? { + let bin = object(Some(value), "target_config binary override")?; + reject_unknown_fields( + bin, + &["name", "mode", "entry", "startup", "linker_script"], + "target_config binary override", + )?; + let name = required_string(bin, "name", "target_config binary override")?; + let target = targets + .iter() + .find(|t| t.kind == TargetKind::Binary && t.name == name) + .ok_or_else(|| { + format!("target_config binary `{name}` does not name a declared binary target") + })?; + if !names.insert(name.clone()) { + return Err(format!( + "target_config binary `{name}` is declared more than once" + )); + } + let platform = platform(bin, "target_config binary override")?; + let mut combined = target.platform.clone(); + combined.overlay(&platform); + validate_platform(&combined, "target_config binary override")?; + bins.push(BinaryOverride { name, platform }); + } + configs.push(TargetConfig { + triple, + sysroot, + linker, + bins, + }); + } + Ok(Model { + targets, + default_run, + profiles, + build_target, + target_config: configs, + }) +} + +fn default_profiles() -> BTreeMap { + [ + ( + "dev".into(), + BuildProfile { + opt_level: 0, + inherits: None, + }, + ), + ( + "release".into(), + BuildProfile { + opt_level: 2, + inherits: None, + }, + ), + ] + .into() +} +fn profiles(value: Option<&WsonValue>) -> Result, String> { + let mut result = default_profiles(); + let Some(value) = value else { + return Ok(result); + }; + let declarations = object(Some(value), "profiles")?; + // Resolve builtins first so a custom profile inherits its effective value. + for builtin in ["dev", "release"] { + if let Some(value) = declarations.get(builtin) { + let profile = object(Some(value), "profile")?; + reject_unknown_fields(profile, &["opt_level"], &format!("profile `{builtin}`"))?; + if let Some(level) = opt_level(profile)? { + result.get_mut(builtin).unwrap().opt_level = level; + } + } + } + for (name, value) in declarations { + validate_component(name, "profile name")?; + if matches!(name.as_str(), "dev" | "release") { + continue; + } + let profile = object(Some(value), "profile")?; + reject_unknown_fields( + profile, + &["inherits", "opt_level"], + &format!("profile `{name}`"), + )?; + let inherits = required_string(profile, "inherits", "custom profile")?; + if !matches!(inherits.as_str(), "dev" | "release") { + return Err(format!( + "custom profile `{name}` must inherit only `dev` or `release`" + )); + } + let opt_level = opt_level(profile)?.unwrap_or(result[&inherits].opt_level); + result.insert( + name.clone(), + BuildProfile { + opt_level, + inherits: Some(inherits), + }, + ); + } + let mut names = HashSet::new(); + if result + .keys() + .any(|name| !names.insert(name.to_ascii_lowercase())) + { + return Err("profile names must not differ only in ASCII case".into()); + } + Ok(result) +} +fn opt_level(profile: &WsonMap) -> Result, String> { + match profile.get("opt_level") { + None => Ok(None), + Some(WsonValue::Int(value)) if (0..=3).contains(value) => Ok(Some(*value as u8)), + Some(_) => Err("profile opt_level must be an integer from 0 to 3".into()), + } +} +fn platform(object: &WsonMap, context: &str) -> Result { + let mode = match string(object, "mode", context)?.as_deref() { + None => None, + Some("hosted") => Some(PlatformMode::Hosted), + Some("freestanding") => Some(PlatformMode::Freestanding), + Some("wasi") => Some(PlatformMode::Wasi), + Some("bare_wasm") => Some(PlatformMode::BareWasm), + Some(_) => { + return Err(format!( + "{context} mode must be hosted, freestanding, wasi, or bare_wasm" + )) + } + }; + let entry = string(object, "entry", context)?; + if entry.as_ref().is_some_and(|entry| { + let mut bytes = entry.bytes(); + !bytes + .next() + .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_') + || !bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_') + }) { + return Err(format!( + "{context} entry must be an ASCII symbol identifier" + )); + } + let startup = match string(object, "startup", context)?.as_deref() { + None => None, + Some("default") => Some(Startup::Default), + Some("none") => Some(Startup::None), + Some(_) => return Err(format!("{context} startup must be `default` or `none`")), + }; + let linker_script = string(object, "linker_script", context)? + .map(|path| { + native::validate_path(&path, "linker_script")?; + Ok::<_, String>(PathBuf::from(path)) + }) + .transpose()?; + Ok(PlatformSettings { + mode, + entry, + startup, + linker_script, + }) +} +fn validate_platform(platform: &PlatformSettings, context: &str) -> Result<(), String> { + match platform.mode.unwrap_or(PlatformMode::Hosted) { + PlatformMode::Hosted | PlatformMode::Wasi if platform.entry.is_some() || platform.startup == Some(Startup::None) || platform.linker_script.is_some() => + Err(format!("{context} entry, startup=none, and linker_script require freestanding or bare_wasm mode")), + PlatformMode::BareWasm if platform.linker_script.is_some() => Err(format!("{context} bare_wasm does not support linker_script")), + _ => Ok(()), + } +} +pub(crate) fn validate_component(value: &str, context: &str) -> Result<(), String> { + let mut bytes = value.bytes(); + if !bytes + .next() + .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_') + || !bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) + || native::reserved_windows_component(value) + { + return Err(format!("{context} must be a portable [A-Za-z_][A-Za-z0-9_-]* name and must not be a Windows device name")); + } + Ok(()) +} +fn validate_triple(triple: &str) -> Result<(), String> { + let components: Vec<_> = triple.split('-').collect(); + // Compiler target identifiers include two-component WASI names such as + // `wasm32-wasip1`; availability is checked against the selected compiler. + if components.len() < 2 + || components.iter().any(|part| { + part.is_empty() || !part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + }) + { + return Err("build target must be an exact compiler target triple".into()); + } + Ok(()) +} +fn object<'a>(value: Option<&'a WsonValue>, context: &str) -> Result<&'a WsonMap, String> { + match value { + Some(WsonValue::Object(value)) => Ok(value), + _ => Err(format!("{context} must be an object")), + } +} +fn array<'a>(object: &'a WsonMap, field: &str, context: &str) -> Result<&'a [WsonValue], String> { + match object.get(field) { + None => Ok(&[]), + Some(WsonValue::Array(value)) => Ok(value), + Some(_) => Err(format!("{context} field `{field}` must be an array")), + } +} +fn required_string(object: &WsonMap, field: &str, context: &str) -> Result { + string(object, field, context)? + .ok_or_else(|| format!("{context} field `{field}` must be a string")) +} +fn string(object: &WsonMap, field: &str, context: &str) -> Result, String> { + match object.get(field) { + None => Ok(None), + Some(WsonValue::String(value)) + if !value.trim().is_empty() && !value.chars().any(char::is_control) => + { + Ok(Some(value.clone())) + } + Some(WsonValue::String(_)) => Err(format!( + "{context} field `{field}` must not be empty or contain control characters" + )), + Some(_) => Err(format!("{context} field `{field}` must be a string")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{decode, render_new_manifest}; + + fn manifest(fields: &str) -> Result { + decode( + &format!("{{format=3,name=\"app\",{fields}}}"), + "vex.ws".into(), + ) + } + + #[test] + fn explicit_targets_allow_library_and_same_named_binary_and_preserve_order() { + let m = manifest(r#"targets={lib={},bins=[{name="app",path="src/main.wave"},{name="admin",path="src/bin/admin.wave"}]},default_run="app""#).unwrap(); + assert!(m.lib); + assert_eq!(m.targets.len(), 3); + assert_eq!(m.targets[0].kind, TargetKind::Library); + assert_eq!(m.targets[0].output, Some(NativeOutput::Object)); + assert_eq!(m.targets[0].name, "app"); + assert_eq!(m.targets[0].path, PathBuf::from("src/lib.wave")); + assert_eq!(m.targets[1].kind, TargetKind::Binary); + assert_eq!(m.targets[1].name, "app"); + assert_eq!(m.targets[2].name, "admin"); + assert_eq!(m.default_entry_path(), PathBuf::from("src/lib.wave")); + assert_eq!(m.default_run.as_deref(), Some("app")); + assert_eq!(m.profile("dev").unwrap().opt_level, 0); + assert_eq!(m.profile("release").unwrap().opt_level, 2); + assert!(m.profile("missing").is_err()); + } + + #[test] + fn library_shared_exports_reuse_native_symbol_contract() { + let m = manifest(r#"targets={lib={output="shared",exports=["wave_add"]}},native={libraries=[{name="m"}]}"#).unwrap(); + assert_eq!(m.targets[0].output, Some(NativeOutput::Shared)); + assert_eq!(m.targets[0].exports, ["wave_add"]); + assert_eq!(m.native.unwrap().libraries[0].name.as_deref(), Some("m")); + for fields in [ + r#"targets={lib={output="static"}}"#, + r#"targets={lib={exports=["value"]}}"#, + r#"targets={lib={output="shared",exports=["value","value"]}}"#, + r#"targets={lib={path="elsewhere.wave"}}"#, + r#"targets={lib={}},native={output="object"}"#, + r#"targets={lib={}},native={exports=[]}"#, + r#"targets={lib={}},lib=false"#, + ] { + assert!(manifest(fields).is_err(), "accepted {fields}"); + } + } + + #[test] + fn old_formats_normalize_targets_without_changing_escape_or_native_meanings() { + for format in [1, 2] { + let m = decode(&format!("{{format={format},name=\"libapp\",lib=true,native={{output=\"shared\",exports=[\"value\"]}}}}"), "vex.ws".into()).unwrap(); + assert_eq!(m.targets[0].kind, TargetKind::Library); + assert_eq!(m.targets[0].output, Some(NativeOutput::Shared)); + assert_eq!(m.targets[0].exports, ["value"]); + assert_eq!(m.native.unwrap().exports, ["value"]); + assert_eq!(m.format, format); + } + let m = decode(r#"{name="app"}"#, "vex.ws".into()).unwrap(); + assert!(!m.lib); + assert_eq!(m.targets[0].kind, TargetKind::Binary); + assert_eq!(m.targets[0].path, PathBuf::from("src/main.wave")); + for field in [ + "targets={lib={}}", + "profiles={}", + "default_run=\"app\"", + "build={}", + "target_config=[]", + ] { + for format in [1, 2] { + assert!(decode( + &format!("{{format={format},name=\"app\",{field}}}"), + "vex.ws".into() + ) + .unwrap_err() + .contains("requires `format = 3`")); + } + } + for format in [2, 3] { + let targets = if format == 3 { ",targets={lib={}}" } else { "" }; + let m = decode( + &format!(r#"{{format={format},name="app",description="C:\\tmp\nnext"{targets}}}"#), + "vex.ws".into(), + ) + .unwrap(); + assert_eq!(m.description.as_deref(), Some("C:\\tmp\nnext")); + } + let m = decode( + r#"{name="app",description="C:\tmp\nnext"}"#, + "vex.ws".into(), + ) + .unwrap(); + assert_eq!(m.description.as_deref(), Some(r"C:\tmp\nnext")); + } + + #[test] + fn binary_target_names_and_paths_are_portable_and_nonempty() { + for name in [ + "", " ", "1app", "-app", "a.b", "café", "CON", "nul", "COM1", "Lpt9", "app/new", + ] { + let fields = format!( + "targets={{bins=[{{name={},path=\"src/main.wave\"}}]}}", + wson::quote(name) + ); + assert!(manifest(&fields).is_err(), "accepted name {name:?}"); + } + for path in [ + "", + "../main.wave", + "/main.wave", + "src/../main.wave", + "./main.wave", + "src//main.wave", + "src\\main.wave", + "C:/main.wave", + "src/main.wv", + "src/-main.wave", + "src/CON.wave", + "src/main\n.wave", + ] { + let fields = format!( + "targets={{bins=[{{name=\"app\",path={}}}]}}", + wson::quote(path) + ); + assert!(manifest(&fields).is_err(), "accepted path {path:?}"); + } + let m = manifest(r#"targets={bins=[{name="_app-2",path="src/한글 app.wave"}]}"#).unwrap(); + assert_eq!(m.default_entry_path(), PathBuf::from("src/한글 app.wave")); + for fields in [ + "targets={}", + "targets={bins=[]}", + "targets={lib=false}", + "targets=[]", + r#"targets={bins=[{name="app",path="src/a.wave"},{name="APP",path="src/b.wave"}]}"#, + r#"targets={bins=[{name="app",path="src/a.wave",typo=true}]}"#, + r#"targets={lib={}},default_run="app""#, + r#"targets={bins=[{name="app",path="src/a.wave"}]},default_run="APP""#, + ] { + assert!(manifest(fields).is_err(), "accepted {fields}"); + } + } + + #[test] + fn profiles_inherit_only_effective_builtins_and_reject_unsupported_fields() { + let m = manifest(r#"targets={lib={}},profiles={fast={inherits="release"},dev={opt_level=1},release={opt_level=3},small={inherits="dev",opt_level=0}}"#).unwrap(); + assert_eq!(m.profile("dev").unwrap().opt_level, 1); + assert_eq!(m.profile("release").unwrap().opt_level, 3); + assert_eq!(m.profile("fast").unwrap().opt_level, 3); + assert_eq!(m.profile("small").unwrap().opt_level, 0); + assert_eq!( + m.profile("fast").unwrap().inherits.as_deref(), + Some("release") + ); + for profiles in [ + "[]", + "{fast={}}", + r#"{fast={inherits="fast"}}"#, + r#"{fast={inherits="other"},other={inherits="dev"}}"#, + r#"{dev={inherits="release"}}"#, + "{dev={opt_level=4}}", + "{dev={opt_level=-1}}", + r#"{dev={opt_level="s"}}"#, + "{release={lto=true}}", + "{dev={debug=true}}", + r#"{fast={inherits="release",overflow_checks=true}}"#, + r#"{DEV={inherits="dev"}}"#, + r#"{CON={inherits="dev"}}"#, + r#"{fast={inherits="release",opt_level=2,opt_level=3}}"#, + ] { + assert!( + manifest(&format!("targets={{lib={{}}}},profiles={profiles}")).is_err(), + "accepted profiles {profiles}" + ); + } + } + + #[test] + fn exact_target_overrides_overlay_only_matching_binary_and_triple() { + let m = manifest(r#"targets={lib={},bins=[{name="app",path="src/main.wave",mode="freestanding",entry="_start",startup="default",linker_script="ld/default.ld"}]},build={target="x86_64-unknown-linux-gnu"},target_config=[{triple="x86_64-unknown-linux-gnu",sysroot="/opt/sdk",linker="/opt/SDK tools/clang",bins=[{name="app",startup="none",linker_script="ld/custom.ld"}]}]"#).unwrap(); + assert_eq!(m.build_target.as_deref(), Some("x86_64-unknown-linux-gnu")); + assert_eq!(m.target_config[0].sysroot, Some(PathBuf::from("/opt/sdk"))); + let base = &m.targets[1]; + let platform = m.platform_for(base, "x86_64-unknown-linux-gnu"); + assert_eq!(platform.mode, Some(PlatformMode::Freestanding)); + assert_eq!(platform.entry.as_deref(), Some("_start")); + assert_eq!(platform.startup, Some(Startup::None)); + assert_eq!(platform.linker_script, Some(PathBuf::from("ld/custom.ld"))); + assert_eq!( + m.platform_for(base, "aarch64-unknown-linux-gnu"), + base.platform + ); + assert_eq!( + m.platform_for(&m.targets[0], "x86_64-unknown-linux-gnu"), + PlatformSettings::default() + ); + let wasi = manifest(r#"targets={bins=[{name="app",path="src/main.wave",mode="wasi"}]},build={target="wasm32-wasip1"},target_config=[{triple="wasm32-wasip1"}]"#).unwrap(); + assert_eq!(wasi.build_target.as_deref(), Some("wasm32-wasip1")); + } + + #[test] + fn rejects_unknown_duplicate_and_incompatible_platform_configuration() { + let bin = r#"targets={bins=[{name="app",path="src/main.wave"}]}"#; + for config in [ + "{}", + "[{triple=1}]", + r#"[{triple="linux"}]"#, + r#"[{triple="*-unknown-linux-gnu"}]"#, + r#"[{triple="x86_64-unknown-linux-gnu",sysroot=""}]"#, + r#"[{triple="x86_64-unknown-linux-gnu",linker="-flag"}]"#, + r#"[{triple="x86_64-unknown-linux-gnu",linker_args=[]}]"#, + r#"[{triple="x86_64-unknown-linux-gnu",bins=[{name="other"}]}]"#, + r#"[{triple="x86_64-unknown-linux-gnu",bins=[{name="app"},{name="app"}]}]"#, + r#"[{triple="x86_64-unknown-linux-gnu"},{triple="x86_64-unknown-linux-gnu"}]"#, + ] { + assert!( + manifest(&format!("{bin},target_config={config}")).is_err(), + "accepted {config}" + ); + } + for fields in [ + r#"mode="other""#, + r#"entry="-start""#, + r#"entry="_start""#, + r#"mode="hosted",startup="none""#, + r#"mode="wasi",entry="_start""#, + r#"mode="freestanding",startup="other""#, + r#"mode="bare_wasm",linker_script="link.ld""#, + r#"mode="freestanding",linker_script="../link.ld""#, + ] { + assert!( + manifest(&format!( + "targets={{bins=[{{name=\"app\",path=\"src/main.wave\",{fields}}}]}}" + )) + .is_err(), + "accepted {fields}" + ); + } + for mode in ["hosted", "freestanding", "wasi", "bare_wasm"] { + let m = manifest(&format!( + "targets={{bins=[{{name=\"app\",path=\"src/main.wave\",mode=\"{mode}\"}}]}}" + )) + .unwrap(); + assert_eq!(m.targets[0].platform.mode.unwrap().as_str(), mode); + } + } + + #[test] + fn new_manifest_round_trips_explicit_format_three_targets_and_escaped_author() { + for is_lib in [false, true] { + let author = "Author \"quoted\" \\ path\nnext"; + let raw = render_new_manifest("app", author, is_lib); + let m = decode(&raw, "vex.ws".into()).unwrap(); + assert_eq!(m.format, 3); + assert_eq!(m.lib, is_lib); + assert_eq!(m.author.as_deref(), Some(author)); + assert_eq!(m.targets.len(), 1); + assert_eq!( + m.targets[0].kind, + if is_lib { + TargetKind::Library + } else { + TargetKind::Binary + } + ); + } + } +} diff --git a/manifest/src/native.rs b/manifest/src/native.rs index 3c26cad..7c2603c 100644 --- a/manifest/src/native.rs +++ b/manifest/src/native.rs @@ -197,7 +197,7 @@ fn validate_name(name: &str) -> Result<(), String> { Ok(()) } -fn validate_path(path: &str, context: &str) -> Result<(), String> { +pub(crate) fn validate_path(path: &str, context: &str) -> Result<(), String> { if path.contains(['\\', ':', '*', '?', '"', '<', '>', '|']) || path.split('/').any(|part| { part.is_empty() @@ -212,7 +212,7 @@ fn validate_path(path: &str, context: &str) -> Result<(), String> { Ok(()) } -fn reserved_windows_component(part: &str) -> bool { +pub(crate) fn reserved_windows_component(part: &str) -> bool { let stem = part.split('.').next().unwrap_or(part).to_ascii_uppercase(); matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") || ["COM", "LPT"].into_iter().any(|prefix| { diff --git a/manifest/src/parse.rs b/manifest/src/parse.rs index a27b2e0..0d1d2e4 100644 --- a/manifest/src/parse.rs +++ b/manifest/src/parse.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use std::path::PathBuf; -use wson::{loads, WsonMap, WsonValue}; +use wson::{loads_versions, WsonMap, WsonValue}; use crate::{Dependency, DependencySource, Manifest}; @@ -16,17 +16,51 @@ const ROOT_FIELDS: &[&str] = &[ "author", "license", "dependencies", + "targets", + "default_run", + "profiles", + "build", + "target_config", ]; const DEPENDENCY_FIELDS: &[&str] = &["name", "version", "path", "git", "branch", "tag", "rev"]; pub(crate) fn parse_manifest(raw: &str, source_path: PathBuf) -> Result { - let data = loads(raw, "format", 2).map_err(|e| format!("failed to parse manifest: {e}"))?; + let data = loads_versions(raw, "format", &[2, 3]) + .map_err(|e| format!("failed to parse manifest: {e}"))?; reject_unknown_fields(&data, ROOT_FIELDS, "manifest")?; - if !matches!(data.get("format"), None | Some(WsonValue::Int(1 | 2))) { + let format = match data.get("format") { + None | Some(WsonValue::Int(1)) => 1, + Some(WsonValue::Int(2)) => 2, + Some(WsonValue::Int(3)) => 3, + _ => return Err("unsupported manifest format; expected 1 (legacy), 2 (escaped strings), or 3 (explicit targets)".into()), + }; + if format < 3 { + for field in [ + "targets", + "default_run", + "profiles", + "build", + "target_config", + ] { + if data.contains_key(field) { + return Err(format!("manifest field `{field}` requires `format = 3`")); + } + } + } else if data.contains_key("lib") { return Err( - "unsupported manifest format; expected 1 (legacy) or 2 (escaped strings)".into(), + "format 3 declares its library in `targets.lib`; root `lib` is not supported".into(), ); } + if format == 3 { + if let Some(WsonValue::Object(native)) = data.get("native") { + if native.contains_key("output") || native.contains_key("exports") { + return Err( + "format 3 declares library output and exports in `targets.lib`, not `native`" + .into(), + ); + } + } + } let name = match data.get("name") { Some(WsonValue::String(value)) => restore_url_separators(value), @@ -45,6 +79,15 @@ pub(crate) fn parse_manifest(raw: &str, source_path: PathBuf) -> Result Result String { format!( r#"{{ - format = 2, + format = 3, name = "{name}", version = 0.1.0, - lib = {is_lib}, + targets = {targets}, description = "{name} Project", author = "{author}", license = "Unknown", dependencies = [] }} "#, + targets = if is_lib { + "{ lib = { output = \"object\" } }".to_string() + } else { + format!( + "{{ bins = [{{ name = \"{}\", path = \"src/main.wave\" }}] }}", + escape_wson_string(project_name) + ) + }, name = escape_wson_string(project_name), author = escape_wson_string(author) ) diff --git a/src/cli.rs b/src/cli.rs index ba6d62f..3238db2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -242,18 +242,20 @@ fn print_help() -> Result<(), diagnostic::Error> { ); diagnostic::outln!(" vex init [--lib]"); diagnostic::outln!( - " vex build [--target ] [--release] [--dry-run] [--locked] [--offline]" + " vex build [--lib] [--bin ] [--target ] [--profile |--release] [--dry-run] [--locked] [--offline]" ); diagnostic::outln!( - " vex run [--target ] [--release] [--dry-run] [--locked] [--offline] [-- ]" + " vex run [--bin ] [--target ] [--profile |--release] [--dry-run] [--locked] [--offline] [-- ]" ); diagnostic::outln!( - " vex check [--target ] [--release] [--dry-run] [--locked] [--offline]" + " vex check [--lib] [--bin ] [--target ] [--profile |--release] [--dry-run] [--locked] [--offline]" ); diagnostic::outln!(" vex fetch [--locked] [--offline]"); diagnostic::outln!(" vex update [...]"); diagnostic::outln!(" vex info"); - diagnostic::outln!(" vex metadata [--format=json] [--locked] [--offline]"); + diagnostic::outln!( + " vex metadata [--format=json] [--format-version <1|2>] [--locked] [--offline]" + ); diagnostic::outln!(" vex tree [--invert ] [--depth ] [--locked] [--offline]"); diagnostic::outln!(" vex clean [--dry-run]"); diagnostic::outln!(" vex prune [--apply] [--dry-run]"); diff --git a/src/commands/build.rs b/src/commands/build.rs index 29b4da4..45eefaf 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -4,9 +4,11 @@ use std::path::{Path, PathBuf}; use std::time::Instant; use crate::ui; -use compiler::{run_build_with_dry_run, Compiler}; +use compiler::{prepare_build, Compiler}; use manifest::Manifest; +use manifest::{PackageTarget, TargetKind}; use resolver::{resolve_validated, ResolveOptions, UpdatePolicy}; +use serde_json::json; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum BuildMode { @@ -19,6 +21,9 @@ pub enum BuildMode { struct VexBuildOptions { target: Option, release: bool, + profile: Option, + library: bool, + binaries: Vec, dry_run: bool, locked: bool, offline: bool, @@ -39,8 +44,7 @@ pub fn build( let compiler = Compiler::select(); let _project = selection.enter()?; let manifest = Manifest::load()?; - let mut compiler = compiler?; - run_build(mode, &manifest, options, runtime, &mut compiler, messages) + run_build(mode, &manifest, options, runtime, compiler, messages) } fn run_build( @@ -48,49 +52,34 @@ fn run_build( manifest: &Manifest, options: VexBuildOptions, runtime: &[std::ffi::OsString], - compiler: &mut Compiler, + compiler: Result, messages: &mut Messages, ) -> Result { let started = Instant::now(); - std::env::current_dir() - .map_err(Error::environment)? - .to_str() - .ok_or_else(|| { - Error::resolution("wavec JSON protocol cannot represent a non-UTF-8 project root") - })?; - if let Some(target) = &options.target { + let root = std::env::current_dir().map_err(Error::environment)?; + root.to_str().ok_or_else(|| { + Error::resolution("wavec JSON protocol cannot represent a non-UTF-8 project root") + })?; + let selected = select_targets(manifest, mode, &options)?; + let profile_name = + options + .profile + .as_deref() + .unwrap_or(if options.release { "release" } else { "dev" }); + let profile = manifest.profile(profile_name).map_err(Error::resolution)?; + for target in &selected { + validate_entry(manifest, target)?; + } + let mut selected_compiler = compiler?; + let compiler = &mut selected_compiler; + let requested_triple = options + .target + .as_deref() + .or(manifest.build_target.as_deref()); + if let Some(target) = requested_triple { compiler.validate_target(target)?; } compiler.validate_version(manifest.compiler.as_deref())?; - ui::verbose(format!( - "target: {}", - options.target.as_deref().unwrap_or("compiler host default") - )); - ui::verbose(format!("compiler: {}", compiler.path().display())); - ui::trace(format!( - "build options: {options:?}; compiler plan is queried for this invocation" - )); - let default_input = resolve_default_input(manifest, mode)?; - - let mut global_args = Vec::new(); - if options.release { - global_args.push("-O2".to_string()); - } - if let Some(target) = options.target.as_ref() { - global_args.push("--target".to_string()); - global_args.push(target.clone()); - } - - let mut build_args = vec![default_input]; - match mode { - BuildMode::Build => {} - BuildMode::Run => build_args.push("--run".to_string()), - BuildMode::Check => build_args.push("--emit=check".to_string()), - } - if options.dry_run { - build_args.push("--dry-run".to_string()); - } - let resolution = resolve_validated( manifest, ResolveOptions { @@ -102,98 +91,178 @@ fn run_build( ui::status, |candidate| compiler.validate_version(candidate.compiler.as_deref()), )?; - + let spec = compiler.target_spec(requested_triple)?; + let triple = spec["triple"].as_str().unwrap().to_owned(); + ui::verbose(format!("target: {triple}")); + ui::verbose(format!("compiler: {}", compiler.path().display())); + ui::trace(format!( + "build options: {options:?}; compiler plan is queried for each selected package target" + )); ui::verbose(format!( "resolved {} dependencies", resolution.package_count() )); + if mode == BuildMode::Run + && !runtime.is_empty() + && spec["object_format"] == "wasm" + && spec["hosted"] == false + { + return Err(Error::resolution( + "bare WebAssembly has no process argument ABI; use a WASI target for program arguments", + )); + } let mut run_lease = None; let generation = if mode == BuildMode::Run { - let path = if options.dry_run { + Some(if options.dry_run { PathBuf::from("target/.vex-run/planned") } else { let path = compiler::create_run_generation()?; - let root = std::env::current_dir().map_err(Error::environment)?; run_lease = Some(state::artifacts::RunLease::create(&root, &path).map_err(Error::environment)?); path - }; - build_args.push(format!("--target-dir={}", path.display())); - Some(path) + }) } else { None }; - if let Some(native) = crate::native::prepare( - manifest, - &resolution, - compiler, - options.target.as_deref(), - options.release, - mode == BuildMode::Check, - generation.as_deref(), - )? { - // Native preparation supplies one canonical target and owns all output - // paths, including run-generation intermediates. - global_args.retain(|arg| arg != "--target"); - if let Some(target) = &options.target { - global_args.retain(|arg| arg != target); + let mut prepared = Vec::new(); + let reporting = messages.enabled(); + messages.status("compiler")?; + for target in &selected { + let configuration = crate::native::prepare_target( + manifest, + target, + &resolution, + compiler, + &triple, + profile_name, + mode == BuildMode::Check, + generation.as_deref(), + )?; + let mut args = resolution.dependency_args()?; + if target.kind == TargetKind::Binary && manifest.lib { + args.push(format!("--dep={}={}", manifest.name, root.display())); + } + args.push(format!("-O{}", profile.opt_level)); + args.extend(configuration.global); + args.push("build".into()); + args.push(input_string(&target.path)?); + if mode == BuildMode::Run { + args.push("--run".into()); } - global_args.extend(native.global); - build_args.retain(|arg| !arg.starts_with("--target-dir=")); - build_args.extend(native.inputs); - build_args.extend(native.output); + if mode == BuildMode::Check { + args.push("--emit=check".into()); + } + args.extend(configuration.inputs); + args.extend(configuration.output); + let identity = json!({"name":target.name,"kind":target.kind.as_str()}); + let mut report = |mut event: serde_json::Value| { + event["package_target"] = identity.clone(); + event["profile"] = json!(profile_name); + messages.emit(event) + }; + let plan = prepare_build( + compiler, + &args, + runtime, + options.dry_run, + generation.as_deref(), + if reporting { Some(&mut report) } else { None }, + ) + .map_err(|e| target_error(e, target, profile_name))?; + prepared.push((*target, plan)); } - let mut wavec_args = Vec::new(); - wavec_args.extend(resolution.dependency_args()?); - wavec_args.extend(global_args); - wavec_args.push("build".to_string()); - wavec_args.extend(build_args); - - ui::verbose(if options.dry_run { - "freshness: planning only; compiler will not execute" - } else { - "freshness: no build cache; compiling the current project" - }); - let package = format!("{} v{}", manifest.name, manifest.version); + // Even optional linker companions must be disjoint between package targets. + let paths = prepared + .iter() + .flat_map(|(_, p)| p.output_paths().cloned()) + .collect::>(); + state::artifacts::validate_output_paths(&root, &paths).map_err(Error::compiler)?; if options.dry_run { - ui::status("Planning", &package); - } else { + ui::verbose("freshness: planning only; compiler will not execute"); + for (target, _) in &prepared { + ui::status( + "Planning", + format!("{} {}", target.kind.as_str(), target.name), + ); + } + let plans = prepared + .iter() + .map(|(target, plan)| { + json!({ + "package_target":{"name":target.name,"kind":target.kind.as_str()}, + "compiler_plan":plan.plan() + }) + }) + .collect::>(); + let envelope = json!({"schema":"vex.build-plan","schema_version":1,"target":triple, + "profile":{"name":profile_name,"opt_level":profile.opt_level},"plans":plans}); + diagnostic::outln!( + "{}", + serde_json::to_string_pretty(&envelope).map_err(Error::internal)? + ); + return Ok(Outcome::success()); + } + for (target, plan) in &prepared { + plan.preflight_ownership() + .map_err(|e| target_error(e, target, profile_name))?; + } + ui::verbose("freshness: no build cache; compiling the current project"); + let mut execution = None; + for (target, plan) in prepared { ui::status( - match mode { - BuildMode::Check => "Checking", - BuildMode::Build | BuildMode::Run => "Compiling", + if mode == BuildMode::Check { + "Checking" + } else { + "Compiling" }, - &package, + format!( + "{} v{} ({} {})", + manifest.name, + manifest.version, + target.kind.as_str(), + target.name + ), ); + let final_paths = if let Some(path) = plan.plan()["link"]["output"].as_str() { + vec![path.to_owned()] + } else { + plan.plan()["compile"] + .as_array() + .unwrap() + .iter() + .filter_map(|job| job["output"].as_str().map(str::to_owned)) + .collect() + }; + let identity = json!({"name":target.name,"kind":target.kind.as_str()}); + let artifact_kind = target.output.map(|v| v.as_str()).unwrap_or("binary"); + let mut report = |mut event: serde_json::Value| { + event["package_target"] = identity.clone(); + event["profile"] = json!(profile_name); + if event["event"] == "artifact" { + event["artifact_kind"] = json!(artifact_kind); + } + messages.emit(event) + }; + execution = plan + .execute(compiler, if reporting { Some(&mut report) } else { None }) + .map_err(|e| target_error(e, target, profile_name))?; + for path in final_paths { + ui::status("Artifact", path); + } } - - messages.status("compiler")?; - let reporting = messages.enabled(); - let mut report = |event| messages.emit(event); - let execution = run_build_with_dry_run( - compiler, - &wavec_args, - runtime, - options.dry_run, - generation.as_deref(), - if reporting { Some(&mut report) } else { None }, - )?; drop(resolution); let mut outcome = Outcome::success(); if let Some(execution) = execution { - ui::status("Running", &manifest.name); + ui::status("Running", &selected[0].name); messages.status("running")?; - let status = execution.execute()?; - outcome = Outcome::program(status); + outcome = Outcome::program(execution.execute()?); } - drop(run_lease); - if !options.dry_run && outcome.code == 0 { + if outcome.code == 0 { ui::status( "Finished", format!( - "{} profile in {:.2}s", - if options.release { "release" } else { "dev" }, + "{profile_name} profile in {:.2}s", started.elapsed().as_secs_f64() ), ); @@ -201,6 +270,100 @@ fn run_build( Ok(outcome) } +fn target_error(error: Error, target: &PackageTarget, profile: &str) -> Error { + error + .with_field( + "package_target", + format!("{}:{}", target.kind.as_str(), target.name), + ) + .with_field("profile", profile) +} + +fn select_targets<'a>( + manifest: &'a Manifest, + mode: BuildMode, + options: &VexBuildOptions, +) -> Result, Error> { + let binaries = manifest + .targets + .iter() + .filter(|t| t.kind == TargetKind::Binary) + .collect::>(); + if mode == BuildMode::Run { + if options.library { + return Err(Error::usage("vex run requires a binary; --lib is invalid")); + } + if options.binaries.len() > 1 { + return Err(Error::usage("vex run selects exactly one binary")); + } + let name = options + .binaries + .first() + .map(String::as_str) + .or(manifest.default_run.as_deref()); + return if let Some(name) = name { + binaries + .into_iter() + .find(|t| t.name == name) + .map(|t| vec![t]) + .ok_or_else(|| Error::resolution(format!("unknown binary target `{name}`"))) + } else if binaries.len() == 1 { + Ok(binaries) + } else if binaries.is_empty() { + Err(Error::resolution( + "library packages cannot be run; select a package with a binary target", + )) + } else { + Err(Error::resolution( + "multiple binary targets; select one with --bin or set default_run", + )) + }; + } + for name in &options.binaries { + if !binaries.iter().any(|t| t.name == *name) { + return Err(Error::resolution(format!("unknown binary target `{name}`"))); + } + } + if options.library && !manifest.lib { + return Err(Error::resolution("package has no library target")); + } + let all = !options.library && options.binaries.is_empty(); + let mut targets = manifest + .targets + .iter() + .filter(|t| { + all || match t.kind { + TargetKind::Library => options.library, + TargetKind::Binary => options.binaries.contains(&t.name), + } + }) + .collect::>(); + targets.sort_by_key(|t| (t.kind != TargetKind::Library, &t.name)); + Ok(targets) +} + +pub(crate) fn validate_entry(manifest: &Manifest, target: &PackageTarget) -> Result<(), Error> { + let path = &target.path; + if !path.is_file() { + return Err(Error::resolution(format!( + "missing canonical package entry `{}` for {} `{}`", + path.display(), + target.kind.as_str(), + target.name + ))); + } + if manifest.format == 3 { + let root = std::env::current_dir().map_err(Error::environment)?; + let actual = path.canonicalize().map_err(Error::environment)?; + if !actual.starts_with(root) { + return Err(Error::resolution( + "package target entry escapes its package directory", + )); + } + } + input_string(path).map(|_| ()) +} + fn parse_vex_build_options(mode: BuildMode, args: &[String]) -> Result { let mut options = VexBuildOptions::default(); let mut i = 0; @@ -214,7 +377,7 @@ fn parse_vex_build_options(mode: BuildMode, args: &[String]) -> Result Result options.release = true, + "--release" => { + if options.release { + return Err("--release may only be specified once".into()); + } + options.release = true; + } + "--profile" | "--bin" => { + i += 1; + let value = args + .get(i) + .filter(|s| !s.is_empty() && !s.starts_with('-')) + .ok_or_else(|| format!("missing value for `{token}`"))?; + set_selection(&mut options, token, value)?; + } + "--lib" => { + if options.library { + return Err("--lib may only be specified once".into()); + } + options.library = true; + } + _ if token.starts_with("--profile=") => { + set_selection(&mut options, "--profile", &token[10..])? + } + _ if token.starts_with("--bin=") => set_selection(&mut options, "--bin", &token[6..])?, "--dry-run" => options.dry_run = true, "--locked" => options.locked = true, "--offline" => options.offline = true, @@ -248,9 +434,35 @@ fn parse_vex_build_options(mode: BuildMode, args: &[String]) -> Result 1) { + return Err( + "vex run selects one binary with --bin; --lib and multiple binaries are invalid".into(), + ); + } Ok(options) } +fn set_selection(options: &mut VexBuildOptions, option: &str, value: &str) -> Result<(), String> { + if value.is_empty() || value.starts_with('-') { + return Err(format!("missing value for `{option}`")); + } + if option == "--profile" { + if options.profile.is_some() { + return Err("--profile may only be specified once".into()); + } + options.profile = Some(value.into()); + } else { + if options.binaries.iter().any(|name| name == value) { + return Err(format!("duplicate binary selection `{value}`")); + } + options.binaries.push(value.into()); + } + Ok(()) +} + fn set_target(options: &mut VexBuildOptions, target: &str) -> Result<(), String> { if target.is_empty() { return Err("missing value for `--target`: expected a target triple".to_string()); @@ -278,35 +490,17 @@ fn set_target(options: &mut VexBuildOptions, target: &str) -> Result<(), String> fn build_usage(mode: BuildMode) -> &'static str { match mode { BuildMode::Build => { - "usage: vex build [--target ] [--release] [--dry-run] [--locked] [--offline]" + "usage: vex build [--lib] [--bin ] [--target ] [--profile | --release] [--dry-run] [--locked] [--offline]" } BuildMode::Run => { - "usage: vex run [--target ] [--release] [--dry-run] [--locked] [--offline] [-- ]" + "usage: vex run [--bin ] [--target ] [--profile | --release] [--dry-run] [--locked] [--offline] [-- ]" } BuildMode::Check => { - "usage: vex check [--target ] [--release] [--dry-run] [--locked] [--offline]" + "usage: vex check [--lib] [--bin ] [--target ] [--profile | --release] [--dry-run] [--locked] [--offline]" } } } -fn resolve_default_input(manifest: &Manifest, mode: BuildMode) -> Result { - if mode == BuildMode::Run && manifest.lib { - return Err(Error::resolution( - "library packages cannot be run; use a binary package with src/main.wave.".to_string(), - )); - } - - let preferred = manifest.default_entry_path(); - if preferred.is_file() { - return input_string(&preferred); - } - - Err(Error::resolution(format!( - "missing canonical package entry `{}`\nhelp: create this file; Vex does not select arbitrary .wave files", - preferred.display() - ))) -} - fn input_string(path: &Path) -> Result { path.to_str().map(str::to_owned).ok_or_else(|| { Error::resolution("wavec JSON protocol cannot represent a non-UTF-8 input path") diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 1a3104a..29eb442 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -1,6 +1,6 @@ use compiler::Compiler; use diagnostic::Error; -use manifest::Manifest; +use manifest::{Manifest, TargetKind}; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -144,6 +144,22 @@ pub fn doctor(args: &[String], selection: &crate::project::Selection) -> Result< None } }; + let project = select_project(selection).and_then(|project| { + project + .map(|project| { + let path = path_text(&project.manifest_path)?; + let manifest = Manifest::load()?; + Ok((project, path, manifest)) + }) + .transpose() + }); + let target = target.or_else(|| { + project + .as_ref() + .ok() + .and_then(|project| project.as_ref()) + .and_then(|(_, _, manifest)| manifest.build_target.clone()) + }); let mut compatible_target = false; if let Some(compiler) = compiler.as_mut() { match compiler.supported_targets() { @@ -200,39 +216,20 @@ pub fn doctor(args: &[String], selection: &crate::project::Selection) -> Result< Value::Null, ); } - match select_project(selection) { - Ok(Some(project)) => { - match path_text(&project.manifest_path) - .and_then(|path| Manifest::load().map(|manifest| (path, manifest))) - { - Ok((path, manifest)) => { - report.check( - "project", - "ok", - false, - &format!("{} at {path}", manifest.name), - json!({"manifest_path":path, "name":manifest.name}), - ); - inspect_project( - &mut report, - &manifest, - compiler.as_mut(), - compatible_target, - target.as_deref(), - &host, - ); - } - Err(error) => { - report.failure("project", error); - report.check( - "dry_run", - "not_checked", - false, - "project manifest is invalid", - Value::Null, - ); - } - } + match project { + Ok(Some((_project, path, manifest))) => { + report.check( + "project", "ok", false, &format!("{} at {path}", manifest.name), + json!({"manifest_path":path, "name":manifest.name, "manifest_format":manifest.format}), + ); + inspect_project( + &mut report, + &manifest, + compiler.as_mut(), + compatible_target, + target.as_deref(), + &host, + ); } Ok(None) => { report.check( @@ -256,7 +253,7 @@ pub fn doctor(args: &[String], selection: &crate::project::Selection) -> Result< "dry_run", "not_checked", false, - "project selection failed", + "project selection or manifest loading failed", Value::Null, ); } @@ -462,20 +459,21 @@ fn inspect_project( Err(error) => report.failure("project_compiler", error), } } - let input = manifest.default_entry_path(); - if !input.is_file() { - report.failure( - "entry", - Error::resolution(format!( - "missing canonical package entry `{}`", - input.display() - )), - ); + let mut targets = manifest.targets.iter().collect::>(); + targets.sort_by_key(|target| (target.kind != TargetKind::Library, &target.name)); + let mut missing_entry = false; + for package_target in &targets { + if let Err(error) = super::build::validate_entry(manifest, package_target) { + report.failure("entry", error); + missing_entry = true; + } + } + if missing_entry { report.check( "dry_run", "not_checked", false, - "canonical project input is unavailable", + "a declared package input is unavailable", Value::Null, ); return; @@ -514,7 +512,8 @@ fn inspect_project( ); return; }; - let result = (|| { + let profile = &manifest.profiles["dev"]; + let prerequisite = (|| { for package in resolution.packages() { let directory = match &package.source { lockfile::LockedSource::Path { resolved, .. } @@ -523,93 +522,112 @@ fn inspect_project( let manifest = Manifest::load_from(directory.join(manifest::MANIFEST_FILE))?; compiler.validate_version(manifest.compiler.as_deref())?; } - let mut args = resolution.dependency_args()?; - let native = - crate::native::prepare(manifest, &resolution, compiler, target, false, false, None)?; - if let Some(native) = &native { - args.extend(native.global.clone()); - } else if let Some(target) = target { - args.extend(["--target".into(), target.into()]); + let spec = compiler.target_spec(target)?; + Ok(spec["triple"].as_str().unwrap().to_owned()) + })(); + let triple = match prerequisite { + Ok(triple) => triple, + Err(error) => { + planning_failure(report, error, target, host); + return; } - args.extend([ - "build".into(), - path_text(&input)?, - "--dry-run".into(), - "--error-format=json".into(), - ]); - if let Some(native) = native { + }; + let mut planned_paths = Vec::new(); + for package_target in targets { + let result = (|| { + let native = crate::native::prepare_target( + manifest, + package_target, + &resolution, + compiler, + &triple, + "dev", + false, + None, + )?; + let mut args = resolution.dependency_args()?; + if package_target.kind == TargetKind::Binary && manifest.lib { + let root = std::env::current_dir().map_err(Error::environment)?; + args.push(format!("--dep={}={}", manifest.name, path_text(&root)?)); + } + args.push(format!("-O{}", profile.opt_level)); + args.extend(native.global); + args.extend(["build".into(), path_text(&package_target.path)?]); args.extend(native.inputs); args.extend(native.output); - } - let output = process::output( - Command::new(compiler.path()) - .args(&args) - .env("NO_COLOR", "1"), - Duration::from_secs(60), - ) - .map_err(Error::environment)?; - if !output.status.success() { - let message = format!( - "wavec dry-run failed: {}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - return Err(if output.status.code() == Some(3) { - Error::environment(message) - } else { - Error::compiler(message) - }); - } - let plan = compiler::validate_plan(&output.stdout).map_err(Error::compiler)?; - let root = std::env::current_dir().map_err(Error::environment)?; - compiler::validate_plan_outputs(&plan, &root, &args)?; - Ok(plan) - })(); - match result { - Ok(plan) => { - report.check( - "dry_run", - "ok", - true, - "compiler dry-run schema v1 and output paths are valid; compilation and linking were not executed", - json!({"schema_version":plan["schema_version"], "target":plan["target"]}), - ); - if let Some(program) = plan["link"]["program"].as_str() { - match find_tool(Path::new(program)).and_then(|path| path_text(&path)) { - Ok(path) => report.check("linker", "ok", false, &format!("planned linker is available at {path}; actual link support was not tested"), json!({"path":path})), - Err(error) if target.is_some_and(|t| !host.iter().any(|h| h == t)) => - report.check("linker", "warning", false, &format!("optional cross-target tool unavailable: {error}"), json!({"program":program})), - Err(error) => report.failure("linker", error), + compiler::prepare_build(compiler, &args, &[], false, None, None) + })(); + match result { + Ok(prepared) => { + let plan = prepared.plan(); + planned_paths.extend(prepared.output_paths().cloned()); + let identity = + json!({"name":package_target.name, "kind":package_target.kind.as_str()}); + report.check("dry_run", "ok", true, + "compiler dry-run schema v1 and output paths are valid; compilation and linking were not executed", + json!({"schema_version":plan["schema_version"], "target":plan["target"], + "package_target":identity,"profile":"dev","opt_level":profile.opt_level})); + if let Some(program) = plan["link"]["program"].as_str() { + match find_tool(Path::new(program)).and_then(|path| path_text(&path)) { + Ok(path) => report.check("linker", "ok", false, + &format!("planned linker is available at {path}; actual link support was not tested"), + json!({"path":path,"package_target":identity})), + Err(error) if target.is_some_and(|t| !host.iter().any(|h| h == t)) => + report.check("linker", "warning", false, &format!("optional cross-target tool unavailable: {error}"), + json!({"program":program,"package_target":identity})), + Err(error) => report.failure("linker", error), + } + } else { + report.check( + "linker", + "not_checked", + false, + "the dry-run plan does not require a linker", + json!({"package_target":identity}), + ); } - } else { - report.check( - "linker", - "not_checked", - false, - "the dry-run plan does not require a linker", - Value::Null, - ); } + Err(error) => planning_failure( + report, + error.context(format!( + "{} `{}`", + package_target.kind.as_str(), + package_target.name + )), + target, + host, + ), } - Err(error) - if error.category == diagnostic::Category::Environment - && target.is_some_and(|t| !host.iter().any(|h| h == t)) => - { - report.check( - "dry_run", - "warning", - false, - &format!("optional cross-target environment unavailable: {error}"), - json!({"category":error.category.name()}), - ); - } - Err(error) => report.failure("dry_run", error), + } + if let Err(error) = std::env::current_dir() + .map_err(Error::environment) + .and_then(|root| { + state::artifacts::validate_output_paths(&root, &planned_paths).map_err(Error::compiler) + }) + { + report.failure("output_paths", error); } // Keep resolution's shared lease alive through compiler planning so updates // cannot replace dependency source while the compiler reads it. drop(resolution); } +fn planning_failure(report: &mut Report, error: Error, target: Option<&str>, host: &[String]) { + if error.category == diagnostic::Category::Environment + && target.is_some_and(|t| !host.iter().any(|h| h == t)) + { + report.check( + "dry_run", + "warning", + false, + &format!("optional cross-target environment unavailable: {error}"), + json!({"category":error.category.name()}), + ); + } else { + report.failure("dry_run", error); + } +} + #[cfg(test)] mod host_target_tests { use super::host_targets_for; diff --git a/src/commands/info.rs b/src/commands/info.rs index 54dca33..c5de0a9 100644 --- a/src/commands/info.rs +++ b/src/commands/info.rs @@ -1,5 +1,5 @@ use diagnostic::Error; -use manifest::{DependencySource, Manifest}; +use manifest::{DependencySource, Manifest, TargetKind}; pub fn info(args: &[String], selection: &crate::project::Selection) -> Result<(), Error> { if matches!(args, [help] if help == "-h" || help == "--help") { @@ -20,7 +20,22 @@ fn run_info(project: &crate::project::Project) -> Result<(), Error> { diagnostic::outln!("Vex project info"); diagnostic::outln!("name: {}", manifest.name); diagnostic::outln!("version: {}", manifest.version); - diagnostic::outln!("type: {}", if manifest.lib { "library" } else { "binary" }); + if manifest.targets.len() == 1 { + diagnostic::outln!("type: {}", kind_name(manifest.targets[0].kind)); + } else { + diagnostic::outln!("type: multiple targets"); + for target in &manifest.targets { + diagnostic::outln!( + " {} {} {}", + kind_name(target.kind), + target.name, + target.path.display() + ); + } + } + if let Some(default_run) = &manifest.default_run { + diagnostic::outln!("default_run: {default_run}"); + } diagnostic::outln!("manifest: {}", project.manifest_path.display()); if let Some(description) = manifest.description.as_ref() { diagnostic::outln!("description: {description}"); @@ -72,3 +87,10 @@ fn run_info(project: &crate::project::Project) -> Result<(), Error> { } Ok(()) } + +fn kind_name(kind: TargetKind) -> &'static str { + match kind { + TargetKind::Library => "library", + TargetKind::Binary => "binary", + } +} diff --git a/src/commands/init.rs b/src/commands/init.rs index f9e52e3..0a11416 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -37,6 +37,7 @@ fn run_init(is_lib: bool) -> Result<(), Error> { .and_then(|n| n.to_str()) .ok_or_else(|| Error::resolution("project directory must have a UTF-8 package name"))?; validate_package_name(project_name).map_err(Error::resolution)?; + manifest::validate_target_name(project_name).map_err(Error::resolution)?; // Validate the name before creating even the coordination directory. let guard = state::Guard::acquire(false, crate::ui::status).map_err(Error::environment)?; resolver::recover_project(&guard, false)?; diff --git a/src/commands/metadata.rs b/src/commands/metadata.rs index 00c3d76..5ea4837 100644 --- a/src/commands/metadata.rs +++ b/src/commands/metadata.rs @@ -1,21 +1,30 @@ use diagnostic::Error; use lockfile::LockedSource; -use manifest::Manifest; +use manifest::{Manifest, PackageTarget, PlatformSettings, TargetKind}; use serde_json::{json, Value}; use std::path::Path; pub fn metadata(args: &[String], selection: &crate::project::Selection) -> Result<(), Error> { if matches!(args, [help] if help == "-h" || help == "--help") { - diagnostic::outln!("usage: vex metadata [--format=json] [--locked] [--offline]"); + diagnostic::outln!( + "usage: vex metadata [--format=json] [--format-version <1|2>] [--locked] [--offline]" + ); return Ok(()); } let mut locked = false; + let mut format_version = None; let mut args = args.iter(); while let Some(arg) = args.next() { match arg.as_str() { "--locked" => locked = true, "--offline" | "--format=json" => {} "--format" if args.next().is_some_and(|s| s == "json") => {} + "--format-version" => { + parse_version(args.next().map(String::as_str), &mut format_version)? + } + value if value.starts_with("--format-version=") => { + parse_version(value.strip_prefix("--format-version="), &mut format_version)?; + } _ => { return Err(Error::usage(format!( "unsupported metadata option `{arg}`; only JSON format is supported" @@ -23,17 +32,15 @@ pub fn metadata(args: &[String], selection: &crate::project::Selection) -> Resul } } } + let format_version = format_version.unwrap_or(1); let project = selection.enter()?; let root = project.manifest_path.parent().unwrap(); - let root_text = json_path(root)?; + json_path(root)?; let manifest = Manifest::load()?; + if format_version == 1 { + require_single_target(&manifest)?; + } let resolution = resolver::inspect(&manifest, locked, crate::ui::status)?; - let mut root_dependencies: Vec<_> = manifest - .dependencies - .iter() - .map(|d| d.name.clone()) - .collect(); - root_dependencies.sort(); let mut packages = Vec::new(); for package in resolution.packages() { let (resolved, source) = match &package.source { @@ -56,18 +63,21 @@ pub fn metadata(args: &[String], selection: &crate::project::Selection) -> Resul let directory = resolved.canonicalize().map_err(Error::environment)?; let package_manifest_path = directory.join(manifest::MANIFEST_FILE); let package_manifest = Manifest::load_from(&package_manifest_path)?; + let mut value = package_record( + &package_manifest, + &directory, + &package_manifest_path, + format_version, + )?; + value["source"] = source; let mut dependencies = package.dependencies.clone(); dependencies.sort(); - packages.push(json!({"name":package.name, "version":package.version, "manifest_format":package_manifest.format, "compiler":package_manifest.compiler, - "source":source, "root":json_path(&directory)?, "manifest_path":json_path(&package_manifest_path)?, - "entry_path":json_path(&directory.join(package_manifest.default_entry_path()))?, - "kind":if package_manifest.lib {"library"} else {"binary"}, "native":crate::native::configuration(&package_manifest), "dependencies":dependencies})); + value["dependencies"] = json!(dependencies); + packages.push(value); } packages.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); - let value: Value = json!({"schema_version":1, - "root":{"name":manifest.name,"version":manifest.version,"manifest_format":manifest.format,"compiler":manifest.compiler,"kind":if manifest.lib {"library"} else {"binary"}, - "root":root_text,"manifest_path":json_path(&project.manifest_path)?, - "entry_path":json_path(&root.join(manifest.default_entry_path()))?,"native":crate::native::configuration(&manifest),"dependencies":root_dependencies}, + let value: Value = json!({"schema_version":format_version, + "root":package_record(&manifest, root, &project.manifest_path, format_version)?, "target_directory":json_path(&root.join("target"))?, "packages":packages}); diagnostic::outln!( "{}", @@ -76,7 +86,99 @@ pub fn metadata(args: &[String], selection: &crate::project::Selection) -> Resul Ok(()) } +fn parse_version(value: Option<&str>, selected: &mut Option) -> Result<(), Error> { + if selected.is_some() { + return Err(Error::usage("--format-version may only be specified once")); + } + *selected = Some(match value { + Some("1") => 1, + Some("2") => 2, + _ => return Err(Error::usage("--format-version requires 1 or 2")), + }); + Ok(()) +} + +fn require_single_target(manifest: &Manifest) -> Result<&PackageTarget, Error> { + if manifest.targets.len() != 1 { + return Err(Error::resolution(format!( + "metadata schema 1 cannot represent the targets of package `{}`\nhelp: use `vex metadata --format-version 2`", + manifest.name + ))); + } + Ok(&manifest.targets[0]) +} + +fn package_record( + manifest: &Manifest, + root: &Path, + manifest_path: &Path, + version: u32, +) -> Result { + let mut dependencies: Vec<_> = manifest + .dependencies + .iter() + .map(|d| d.name.clone()) + .collect(); + dependencies.sort(); + let mut value = json!({"name":manifest.name, "version":manifest.version, + "manifest_format":manifest.format, "compiler":manifest.compiler, + "root":json_path(root)?, "manifest_path":json_path(manifest_path)?, + "native":crate::native::configuration(manifest), "dependencies":dependencies}); + if version == 1 { + let target = require_single_target(manifest)?; + value["kind"] = json!(kind_name(target.kind)); + value["entry_path"] = json!(json_path(&root.join(&target.path))?); + } else { + value["targets"] = Value::Array( + manifest + .targets + .iter() + .map(|target| { + Ok(json!({"name":target.name, "kind":kind_name(target.kind), + "entry_path":json_path(&root.join(&target.path))?, + "output":target.output.map(|output| output.as_str()), "exports":target.exports, + "platform":platform_record(&target.platform)?})) + }) + .collect::>()?, + ); + value["default_run"] = json!(manifest.default_run); + value["profiles"] = json!(manifest + .profiles + .iter() + .map(|(name, profile)| { + ( + name.clone(), + json!({"opt_level":profile.opt_level,"inherits":profile.inherits}), + ) + }) + .collect::>()); + value["build_target"] = json!(manifest.build_target); + value["target_config"] = Value::Array(manifest.target_config.iter().map(|config| { + Ok(json!({"triple":config.triple, + "sysroot":config.sysroot.as_deref().map(json_path).transpose()?, "linker":config.linker, + "bins":config.bins.iter().map(|bin| Ok(json!({"name":bin.name,"platform":platform_record(&bin.platform)?}))) + .collect::, Error>>()?})) + }).collect::>()?); + } + Ok(value) +} + +fn platform_record(platform: &PlatformSettings) -> Result { + Ok( + json!({"mode":platform.mode.map(|mode| mode.as_str()), "entry":platform.entry, + "startup":platform.startup.map(|startup| startup.as_str()), + "linker_script":platform.linker_script.as_deref().map(json_path).transpose()?}), + ) +} + fn json_path(path: &Path) -> Result<&str, Error> { path.to_str() .ok_or_else(|| Error::resolution("metadata JSON cannot represent a non-UTF-8 path")) } + +fn kind_name(kind: TargetKind) -> &'static str { + match kind { + TargetKind::Library => "library", + TargetKind::Binary => "binary", + } +} diff --git a/src/native.rs b/src/native.rs index 5448d5d..4811b89 100644 --- a/src/native.rs +++ b/src/native.rs @@ -1,7 +1,7 @@ //! Manifest-owned native linking. Foreign sources and build scripts are never executed. use compiler::Compiler; use diagnostic::Error; -use manifest::{Manifest, NativeOutput}; +use manifest::{Manifest, NativeOutput, PackageTarget, PlatformMode, Startup, TargetKind}; use resolver::Resolution; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -162,50 +162,25 @@ fn manifests(root: &Manifest, resolution: &Resolution) -> Result, Ok(result) } -pub fn prepare( +/// Prepare one package-owned artifact without creating its output directories. +/// Dependency libraries remain source imports; only explicitly declared foreign +/// inputs are forwarded to the linker. +#[allow(clippy::too_many_arguments)] +pub fn prepare_target( root: &Manifest, + package_target: &PackageTarget, resolution: &Resolution, compiler: &mut Compiler, - target: Option<&str>, - release: bool, + triple: &str, + profile: &str, check: bool, generation: Option<&Path>, -) -> Result, Error> { +) -> Result { + let spec = compiler.target_spec(Some(triple))?; let manifests = manifests(root, resolution)?; - if !root.lib && !manifests.iter().any(|m| m.native.is_some()) { - return Ok(None); - } - let spec = compiler.target_spec(target)?; - let triple = spec["triple"].as_str().unwrap(); - let os = spec["os"].as_str().unwrap(); - if !matches!( - spec["object_format"].as_str(), - Some("elf" | "macho" | "coff") - ) { - return Err(Error::resolution( - "native C ABI artifacts require an ELF, Mach-O, or COFF target", - )); - } - let kind = if root.lib { - Some( - root.native - .as_ref() - .and_then(|n| n.output) - .unwrap_or(NativeOutput::Object), - ) - } else { - None - }; - if kind == Some(NativeOutput::Shared) && spec["hosted"] != true { - return Err(Error::resolution( - "shared libraries require a hosted target", - )); - } - let mut build = NativeBuild { - global: vec![format!("--target={triple}")], - inputs: Vec::new(), - output: Vec::new(), - }; + let mut build = prepare_settings(root, package_target, &spec, profile, check, generation)?; + let output = target_output(package_target); + let format = spec["object_format"].as_str().unwrap(); for manifest in &manifests { let Some(native) = &manifest.native else { continue; @@ -221,6 +196,11 @@ pub fn prepare( if library.target.as_deref().is_some_and(|t| t != triple) { continue; } + if !matches!(format, "elf" | "macho" | "coff") { + return Err(Error::resolution( + "native C ABI inputs require an ELF, Mach-O, or COFF target", + )); + } let argument = if let Some(path) = &library.path { native_path(manifest, path, false)? } else { @@ -228,88 +208,379 @@ pub fn prepare( manifest, library.name.as_deref().unwrap(), &search_paths, - spec["object_format"].as_str().unwrap(), + format, )? }; - if !check && kind != Some(NativeOutput::Object) { - // Keep paths and names in one sequence after Wave's objects. - // Repeated archives can be necessary, including across packages. + if !check && output != Some(NativeOutput::Object) { + // Preserve package and declaration order: static archive order + // and repeated libraries are meaningful to native linkers. build.global.push(format!("-Clink-arg={argument}")); } } } - if check { - return Ok(Some(build)); + Ok(build) +} + +fn target_output(target: &PackageTarget) -> Option { + (target.kind == TargetKind::Library).then_some(target.output.unwrap_or(NativeOutput::Object)) +} + +fn prepare_settings( + root: &Manifest, + target: &PackageTarget, + spec: &Value, + profile: &str, + check: bool, + generation: Option<&Path>, +) -> Result { + let triple = spec["triple"].as_str().unwrap(); + let os = spec["os"].as_str().unwrap(); + let format = spec["object_format"].as_str().unwrap(); + let hosted = spec["hosted"] + .as_bool() + .ok_or_else(|| Error::compiler("target-spec is missing boolean hosted"))?; + let output = target_output(target); + if output.is_some() && !matches!(format, "elf" | "macho" | "coff" | "wasm") + || output == Some(NativeOutput::Shared) && format == "wasm" + { + return Err(Error::resolution( + "shared libraries require an ELF, Mach-O, or COFF target; object output additionally supports WebAssembly", + )); } - let directory = generation.map(Path::to_path_buf).unwrap_or_else(|| { - PathBuf::from("target") - .join(triple) - .join(if release { "release" } else { "dev" }) - }); - let filename = match kind { - Some(NativeOutput::Object) => format!( - "{}.{}", - root.name, - if os == "windows" { "obj" } else { "o" } - ), - Some(NativeOutput::Shared) => match os { - "windows" => format!("{}.dll", root.name), - "macos" => format!("lib{}.dylib", root.name), - _ => format!("lib{}.so", root.name), - }, - None => format!("{}{}", root.name, if os == "windows" { ".exe" } else { "" }), - }; + if output == Some(NativeOutput::Shared) && !hosted { + return Err(Error::resolution( + "shared libraries require a hosted target", + )); + } + let filename = artifact_filename(target, os, format); if os == "windows" && windows_device_name(&filename) { return Err(Error::resolution(format!( - "package `{}` produces reserved Windows output `{filename}`; choose a package name that is not a Windows device name", - root.name + "target `{}` produces reserved Windows output `{filename}`; choose a target name that is not a Windows device name", + target.name ))); } + if output == Some(NativeOutput::Shared) && os == "windows" && target.exports.is_empty() { + return Err(Error::resolution("Windows shared libraries require a nonempty `native.exports` (legacy) or `targets.lib.exports` list of C symbols declared with `export(c)`")); + } + let platform = root.platform_for(target, triple); + let mode = platform.mode.unwrap_or_else(|| { + if format == "wasm" { + if os == "wasi" { + PlatformMode::Wasi + } else { + PlatformMode::BareWasm + } + } else if hosted { + PlatformMode::Hosted + } else { + PlatformMode::Freestanding + } + }); + match mode { + PlatformMode::Hosted if !hosted || format == "wasm" => { + return Err(Error::resolution( + "hosted mode requires a hosted native target", + )); + } + PlatformMode::Freestanding if format != "elf" => { + return Err(Error::resolution( + "freestanding mode requires an ELF target", + )); + } + PlatformMode::Wasi if format != "wasm" || os != "wasi" || !hosted => { + return Err(Error::resolution("wasi mode requires a hosted WASI target")); + } + PlatformMode::BareWasm if format != "wasm" || hosted || os == "wasi" => { + return Err(Error::resolution( + "bare_wasm mode requires a bare WebAssembly target", + )); + } + _ => {} + } + let link_settings = platform.entry.is_some() + || platform.linker_script.is_some() + || platform.startup == Some(Startup::None); + if target.kind == TargetKind::Library && (link_settings || platform.mode.is_some()) { + return Err(Error::resolution( + "binary platform settings cannot be applied to a library target", + )); + } + if matches!(mode, PlatformMode::Hosted | PlatformMode::Wasi) && link_settings { + return Err(Error::resolution( + "entry, linker_script, and startup=none require freestanding or bare_wasm mode", + )); + } + if mode == PlatformMode::BareWasm && platform.startup == Some(Startup::None) { + return Err(Error::resolution("bare_wasm does not support startup=none; omit startup to use the compiler's bare WebAssembly entry behavior")); + } + if mode == PlatformMode::BareWasm && platform.linker_script.is_some() { + return Err(Error::resolution( + "bare WebAssembly does not support linker_script", + )); + } + let script = platform + .linker_script + .as_ref() + .map(|script| { + native_path( + root, + script + .to_str() + .ok_or_else(|| Error::resolution("linker_script requires a UTF-8 path"))?, + false, + ) + }) + .transpose()?; + let mut build = NativeBuild { + global: vec![format!("--target={triple}")], + inputs: Vec::new(), + output: Vec::new(), + }; + if let Some(config) = root + .target_config + .iter() + .find(|config| config.triple == triple) + { + let linker = config + .linker + .as_deref() + .map(|value| linker_path(root, value)) + .transpose()?; + let sysroot = config + .sysroot + .as_ref() + .map(|path| sysroot_path(root, path)) + .transpose()?; + if sysroot.is_some() && !matches!(format, "elf" | "macho") { + return Err(Error::resolution("selected compiler cannot apply one sysroot to compilation and linking for this target")); + } + if sysroot.is_some() && linker.is_some() && format == "macho" { + return Err(Error::resolution("selected compiler cannot forward a Mach-O sysroot to a custom linker; use the compiler's default linker")); + } + if let Some(sysroot) = sysroot { + build.global.push(format!("--sysroot={sysroot}")); + // Bundled ELF and Darwin linkers consume the compiler sysroot + // directly. An explicit ELF linker needs explicit forwarding. + if !check && output != Some(NativeOutput::Object) && linker.is_some() { + build.global.push(format!("-Clink-sysroot={sysroot}")); + } + } + if !check { + if let Some(linker) = linker { + build.global.push(format!("-Clinker={linker}")); + } + } + } + if mode == PlatformMode::Freestanding { + // This controls source/codegen semantics as well as default libraries, + // and is meaningful even when only checking sources. + build.output.push("--freestanding".into()); + } + if check { + return Ok(build); + } + if target.kind == TargetKind::Binary { + if let Some(entry) = &platform.entry { + build.output.push(format!("--entry={entry}")); + } + if let Some(script) = script { + build.output.push(format!("--linker-script={script}")); + } + if mode == PlatformMode::Freestanding && platform.startup == Some(Startup::None) { + build.output.push("--no-start-files".into()); + } + } + let directory = generation + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from("target")) + .join(triple) + .join(profile); + let output_directory = directory.join(if target.kind == TargetKind::Library { + "lib" + } else { + "bin" + }); build .output - .extend(["-o".into(), path_text(&directory.join(filename))?]); - match kind { + .extend(["-o".into(), path_text(&output_directory.join(filename))?]); + match output { Some(NativeOutput::Object) => build.output.push("--emit=obj".into()), Some(NativeOutput::Shared) => { build.output.push("--shared".into()); build .global .extend(["-C".into(), "relocation-model=pic".into()]); - // wavec links through temporary files. Give the final library a - // stable loader identity instead of embedding that temporary path. if os == "windows" { - let exports = &root.native.as_ref().unwrap().exports; - if exports.is_empty() { - return Err(Error::resolution( - "Windows shared libraries require a nonempty `native.exports` list of C symbols declared with `export(c)`", - )); - } build.global.extend( - exports + target + .exports .iter() .map(|name| format!("-Clink-arg=/EXPORT:{name}")), ); } else if os == "macos" { build.global.extend([ "-Clink-arg=-install_name".into(), - format!("-Clink-arg=@rpath/lib{}.dylib", root.name), + format!("-Clink-arg=@rpath/lib{}.dylib", target.name), ]); - } else if spec["object_format"] == "elf" { + } else if format == "elf" { build.global.extend([ "-Clink-arg=-soname".into(), - format!("-Clink-arg=lib{}.so", root.name), + format!("-Clink-arg=lib{}.so", target.name), ]); } } None => {} } - if kind != Some(NativeOutput::Object) { + if output != Some(NativeOutput::Object) { build.output.push(format!( "--target-dir={}", - path_text(&directory.join(".vex-obj").join(&root.name))? + path_text( + &directory + .join(".vex-obj") + .join(if target.kind == TargetKind::Library { + "lib" + } else { + "bin" + }) + .join(&target.name) + .join(output.map(NativeOutput::as_str).unwrap_or("binary")) + )? )); } - Ok(Some(build)) + Ok(build) +} + +fn artifact_filename(target: &PackageTarget, os: &str, format: &str) -> String { + match target_output(target) { + Some(NativeOutput::Object) => format!( + "{}.{}", + target.name, + if format == "coff" { "obj" } else { "o" } + ), + Some(NativeOutput::Shared) => match os { + "windows" => format!("{}.dll", target.name), + "macos" => format!("lib{}.dylib", target.name), + _ => format!("lib{}.so", target.name), + }, + None => format!( + "{}{}", + target.name, + if format == "wasm" { + ".wasm" + } else if os == "windows" { + ".exe" + } else { + "" + } + ), + } +} + +fn project_root(manifest: &Manifest) -> Result { + manifest + .source_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")) + .canonicalize() + .map_err(Error::environment) +} + +fn sysroot_path(manifest: &Manifest, path: &Path) -> Result { + let path = project_root(manifest)? + .join(path) + .canonicalize() + .map_err(|error| { + Error::environment(format!( + "sysroot `{}` is unavailable: {error}", + path.display() + )) + })?; + if !path.is_dir() { + return Err(Error::environment(format!( + "sysroot `{}` must be a directory", + path.display() + ))); + } + path_text(&path) +} + +fn linker_path(manifest: &Manifest, value: &str) -> Result { + if value.is_empty() || value.starts_with('-') || value.contains(['\0', '\r', '\n']) { + return Err(Error::resolution( + "linker must be one executable name or path, without arguments", + )); + } + let path = Path::new(value); + let root = project_root(manifest)?; + if path.components().count() > 1 || path.is_absolute() || value.contains('\\') { + let path = root.join(path); + if !linker_executable(&path) { + return Err(Error::environment(format!( + "linker `{value}` must be an executable regular file" + ))); + } + return path_text(&path); + } + if value.chars().any(char::is_whitespace) { + // Relative filenames with spaces must be explicit paths, such as + // ./my linker, so an argument string cannot become an executable name. + return Err(Error::resolution( + "linker must be one executable name or path, without arguments", + )); + } + let filenames = { + #[cfg(windows)] + { + let mut names = vec![PathBuf::from(value)]; + if path.extension().is_none() { + let extensions = + std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into()); + names.extend( + extensions + .split(';') + .filter(|s| !s.is_empty()) + .map(|suffix| PathBuf::from(format!("{value}{suffix}"))), + ); + } + names + } + #[cfg(not(windows))] + vec![PathBuf::from(value)] + }; + if let Some(paths) = std::env::var_os("PATH") { + for directory in std::env::split_paths(&paths) { + for filename in &filenames { + let candidate = root.join(&directory).join(filename); + if linker_executable(&candidate) { + // Keep the selected executable basename. Linker and rustup-style + // multicall tools use argv[0], and Wave classifies ld.lld + // by that name rather than its canonical symlink target. + return path_text(&candidate); + } + } + } + } + Err(Error::environment(format!( + "could not find executable linker `{value}` in PATH" + ))) +} + +fn linker_executable(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o111 == 0 { + return false; + } + } + true } fn windows_device_name(filename: &str) -> bool { @@ -321,3 +592,269 @@ fn windows_device_name(filename: &str) -> bool { }) }) } + +#[cfg(test)] +mod tests { + use super::*; + use manifest::{PlatformSettings, TargetConfig}; + use std::sync::atomic::{AtomicU64, Ordering}; + + fn package(lib: bool) -> Manifest { + manifest::decode( + &format!("{{name=\"demo\",version=\"0.1.0\",lib={lib}}}"), + PathBuf::from("vex.ws"), + ) + .unwrap() + } + + fn spec(triple: &str, os: &str, format: &str, hosted: bool) -> Value { + json!({"triple":triple,"os":os,"object_format":format,"hosted":hosted}) + } + + fn output(build: &NativeBuild) -> &str { + build + .output + .windows(2) + .find(|args| args[0] == "-o") + .unwrap()[1] + .as_str() + } + + #[test] + fn binaries_libraries_and_shared_companions_have_separate_paths() { + let root = package(false); + let target = &root.targets[0]; + let windows = spec("x86_64-pc-windows-msvc", "windows", "coff", true); + let binary = prepare_settings(&root, target, &windows, "release", false, None).unwrap(); + assert_eq!( + output(&binary), + "target/x86_64-pc-windows-msvc/release/bin/demo.exe" + ); + assert!(binary.output.iter().any(|arg| arg + == "--target-dir=target/x86_64-pc-windows-msvc/release/.vex-obj/bin/demo/binary")); + let mut library = package(true); + library.targets[0].output = Some(NativeOutput::Shared); + library.targets[0].exports = vec!["demo_add".into()]; + let shared = prepare_settings( + &library, + &library.targets[0], + &windows, + "release", + false, + None, + ) + .unwrap(); + assert_eq!( + output(&shared), + "target/x86_64-pc-windows-msvc/release/lib/demo.dll" + ); + assert!(shared + .global + .contains(&"-Clink-arg=/EXPORT:demo_add".into())); + assert!(shared + .output + .iter() + .any(|arg| arg.ends_with(".vex-obj/lib/demo/shared"))); + library.targets[0].output = Some(NativeOutput::Object); + let object = + prepare_settings(&library, &library.targets[0], &windows, "dev", false, None).unwrap(); + assert_eq!( + output(&object), + "target/x86_64-pc-windows-msvc/dev/lib/demo.obj" + ); + assert!(!object + .output + .iter() + .any(|arg| arg.starts_with("--target-dir="))); + } + + #[test] + fn generation_contains_target_profile_and_artifact_identity() { + let root = package(false); + let build = prepare_settings( + &root, + &root.targets[0], + &spec("x86_64-unknown-linux-gnu", "linux", "elf", true), + "fast", + false, + Some(Path::new("target/.vex-run/123-4")), + ) + .unwrap(); + assert_eq!( + output(&build), + "target/.vex-run/123-4/x86_64-unknown-linux-gnu/fast/bin/demo" + ); + } + + #[test] + fn wasm_modes_are_target_checked_and_use_wasm_extension() { + let mut root = package(false); + root.targets[0].platform = PlatformSettings { + mode: Some(PlatformMode::BareWasm), + entry: Some("start".into()), + startup: None, + linker_script: None, + }; + let bare = spec("wasm32-unknown-unknown", "unknown", "wasm", false); + let build = prepare_settings(&root, &root.targets[0], &bare, "dev", false, None).unwrap(); + assert_eq!( + output(&build), + "target/wasm32-unknown-unknown/dev/bin/demo.wasm" + ); + assert!(build.output.contains(&"--entry=start".into())); + assert!(!build.output.contains(&"--no-start-files".into())); + root.targets[0].platform.startup = Some(Startup::None); + let invalid = prepare_settings(&root, &root.targets[0], &bare, "dev", true, None) + .err() + .unwrap(); + assert_eq!(invalid.category, diagnostic::Category::Resolution); + assert!(invalid.contains("startup=none")); + root.targets[0].platform.startup = None; + let wasi = spec("wasm32-wasip1", "wasi", "wasm", true); + assert!(prepare_settings(&root, &root.targets[0], &wasi, "dev", false, None).is_err()); + root.targets[0].platform = PlatformSettings::default(); + assert!(prepare_settings(&root, &root.targets[0], &wasi, "dev", false, None).is_ok()); + root.targets[0].platform.mode = Some(PlatformMode::Hosted); + assert!(prepare_settings(&root, &root.targets[0], &wasi, "dev", false, None).is_err()); + let library = package(true); + for wasm in [&wasi, &bare] { + let object = + prepare_settings(&library, &library.targets[0], wasm, "dev", false, None).unwrap(); + assert!(output(&object).ends_with("/lib/demo.o")); + assert!(object.output.contains(&"--emit=obj".into())); + let mut shared = library.clone(); + shared.targets[0].output = Some(NativeOutput::Shared); + assert!( + prepare_settings(&shared, &shared.targets[0], wasm, "dev", false, None).is_err() + ); + } + } + + #[test] + fn check_validates_export_policy_but_does_not_request_artifacts() { + let mut root = package(true); + root.targets[0].output = Some(NativeOutput::Shared); + let windows = spec("x86_64-pc-windows-msvc", "windows", "coff", true); + assert!(prepare_settings(&root, &root.targets[0], &windows, "dev", true, None).is_err()); + root.targets[0].exports.push("demo_add".into()); + let build = prepare_settings(&root, &root.targets[0], &windows, "dev", true, None).unwrap(); + assert!(build.output.is_empty()); + assert!(!build.global.iter().any(|arg| arg.starts_with("-Clink"))); + } + + #[test] + fn freestanding_check_omits_link_only_arguments() { + let mut root = package(false); + root.targets[0].platform = PlatformSettings { + mode: Some(PlatformMode::Freestanding), + entry: Some("_start".into()), + startup: Some(Startup::None), + linker_script: None, + }; + let bare = spec("x86_64-unknown-none-elf", "none", "elf", false); + let check = prepare_settings(&root, &root.targets[0], &bare, "dev", true, None).unwrap(); + assert_eq!(check.output, vec!["--freestanding"]); + let build = prepare_settings(&root, &root.targets[0], &bare, "dev", false, None).unwrap(); + assert!(build.output.contains(&"--freestanding".into())); + assert!(build.output.contains(&"--entry=_start".into())); + assert!(build.output.contains(&"--no-start-files".into())); + assert!(prepare_settings( + &root, + &root.targets[0], + &spec("x86_64-pc-windows-msvc", "windows", "coff", true), + "dev", + false, + None + ) + .is_err()); + } + + #[test] + fn root_tool_settings_apply_same_external_sdk_to_compile_and_link() { + static ID: AtomicU64 = AtomicU64::new(0); + let temporary = std::env::temp_dir().join(format!( + "vex-native-platform-{}-{}", + std::process::id(), + ID.fetch_add(1, Ordering::Relaxed) + )); + let project = temporary.join("project"); + let sdk = temporary.join("sdk"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&sdk).unwrap(); + let mut root = package(false); + root.source_path = project.join("vex.ws"); + root.target_config.push(TargetConfig { + triple: "aarch64-unknown-linux-gnu".into(), + sysroot: Some(PathBuf::from("../sdk")), + linker: Some(path_text(&std::env::current_exe().unwrap()).unwrap()), + bins: vec![], + }); + let target = spec("aarch64-unknown-linux-gnu", "linux", "elf", true); + let build = prepare_settings(&root, &root.targets[0], &target, "dev", false, None).unwrap(); + let sdk_text = path_text(&sdk.canonicalize().unwrap()).unwrap(); + assert!(build.global.contains(&format!("--sysroot={sdk_text}"))); + assert!(build.global.contains(&format!("-Clink-sysroot={sdk_text}"))); + assert!(build.global.contains(&format!( + "-Clinker={}", + path_text(&std::env::current_exe().unwrap().canonicalize().unwrap()).unwrap() + ))); + let check = prepare_settings(&root, &root.targets[0], &target, "dev", true, None).unwrap(); + assert!(check.global.contains(&format!("--sysroot={sdk_text}"))); + assert!(!check.global.iter().any(|arg| arg.starts_with("-Clink"))); + let other = prepare_settings( + &root, + &root.targets[0], + &spec("x86_64-unknown-linux-gnu", "linux", "elf", true), + "dev", + false, + None, + ) + .unwrap(); + assert_eq!(other.global, vec!["--target=x86_64-unknown-linux-gnu"]); + assert_eq!( + linker_path(&root, "cc --malicious").unwrap_err().category, + diagnostic::Category::Resolution + ); + assert_eq!( + linker_path(&root, "./missing-linker").unwrap_err().category, + diagnostic::Category::Environment + ); + assert_eq!( + linker_path(&root, "vex-absent-test-linker") + .unwrap_err() + .category, + diagnostic::Category::Environment + ); + assert_eq!( + linker_path(&root, ".").unwrap_err().category, + diagnostic::Category::Environment + ); + assert_eq!( + sysroot_path(&root, Path::new("missing-sdk")) + .unwrap_err() + .category, + diagnostic::Category::Environment + ); + assert!(Path::new(&linker_path(&root, "rustc").unwrap()).is_absolute()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let executable = project.join("disabled-linker"); + std::fs::write(&executable, "not executable").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!( + linker_path(&root, "./disabled-linker") + .unwrap_err() + .category, + diagnostic::Category::Environment + ); + let actual = std::env::current_exe().unwrap(); + let named = project.join("ld.lld"); + std::os::unix::fs::symlink(actual, &named).unwrap(); + assert!(linker_path(&root, "./ld.lld") + .unwrap() + .ends_with("/./ld.lld")); + } + std::fs::remove_dir_all(temporary).unwrap(); + } +} diff --git a/tests/build_artifact_integrity.rs b/tests/build_artifact_integrity.rs index 0b3f688..edb82a4 100644 --- a/tests/build_artifact_integrity.rs +++ b/tests/build_artifact_integrity.rs @@ -9,12 +9,26 @@ use std::sync::{ const COMPILER_SOURCE: &str = r#" use std::{env, fs, path::Path, process}; +fn quote(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} fn main() { let args: Vec<_> = env::args().collect(); if args.iter().any(|arg| arg == "--version") { println!("wavec 0.2.1-pre-beta"); return; } + if args.get(1).is_some_and(|arg| arg == "print") { + if args.get(2).is_some_and(|arg| arg == "supported-targets") { + println!("[\"x86_64-unknown-linux-gnu\",\"x86_64-pc-windows-msvc\"]"); + } else { + let triple = args.iter().find_map(|arg| arg.strip_prefix("--target=")) + .map(str::to_owned).unwrap_or_else(|| env::var("PLAN_TARGET").unwrap()); + let (os, format) = if triple.contains("windows") { ("windows", "coff") } else { ("linux", "elf") }; + println!("{{\"triple\":{},\"os\":{},\"object_format\":{},\"hosted\":true,\"supported\":true}}", quote(&triple), quote(os), quote(format)); + } + return; + } if args.iter().any(|arg| arg == "--dry-run") { println!("{}", env::var("PLAN").unwrap()); return; @@ -50,10 +64,16 @@ impl Fixture { ID.fetch_add(1, Ordering::Relaxed) )); fs::create_dir_all(root.join("src")).unwrap(); - fs::write(root.join("vex.ws"), "{name=\"app\",version=0.1.0}").unwrap(); + fs::write(root.join("vex.ws"), "{name=\"app\",version=0.1.0,lib=true}").unwrap(); + fs::write(root.join("src/lib.wave"), "fun main() {}\n").unwrap(); fs::write(root.join("src/main.wave"), "fun main() {}\n").unwrap(); Self { root } } + fn shared() -> Self { + let fixture = Self::new(); + fs::write(fixture.root.join("vex.ws"), "{format=2,name=\"app\",version=0.1.0,lib=true,native={output=\"shared\",exports=[\"wave_add\"]}}").unwrap(); + fixture + } fn command(&self, plan: &Value, outputs: &[&str]) -> Command { static COMPILER: OnceLock = OnceLock::new(); let compiler = COMPILER.get_or_init(|| { @@ -77,6 +97,7 @@ impl Fixture { .current_dir(&self.root) .env("VEX_WAVEC", compiler) .env("PLAN", plan.to_string()) + .env("PLAN_TARGET", plan["target"].as_str().unwrap()) .env("OUTPUTS", outputs.join("\n")) .env("NO_COLOR", "1"); command @@ -111,7 +132,10 @@ impl Drop for Fixture { } fn plan(outputs: &[&str]) -> Value { - json!({"schema_version":1,"mode":"build","target":"x86_64-unknown-linux-gnu","emit":"obj","emit_kinds":["obj"],"control_mode":null,"forced_input_type":null,"inputs":[{"path":"src/main.wave","kind":"wave"}],"emit_jobs":[],"compile":outputs.iter().map(|output| json!({"input":"src/main.wave","kind":"wave","output":output,"command":"compiler"})).collect::>(),"link":null,"execute":null}) + json!({"schema_version":1,"mode":"build","target":"x86_64-unknown-linux-gnu","emit":"obj","emit_kinds":["obj"],"control_mode":null,"forced_input_type":null,"inputs":[{"path":"src/lib.wave","kind":"wave"}],"emit_jobs":[],"compile":outputs.iter().map(|output| json!({"input":"src/lib.wave","kind":"wave","output":output,"command":"compiler"})).collect::>(),"link":null,"execute":null}) +} +fn plan_object(output: &str) -> Value { + plan(&[output]) } fn assert_code(output: &Output, code: i32) { assert_eq!( @@ -126,25 +150,35 @@ fn assert_code(output: &Output, code: i32) { fn unknown_and_modified_outputs_are_rejected_before_compiler_execution() { for modified in [false, true] { let f = Fixture::new(); - let plan = plan(&["target/result.o"]); + let plan = plan(&["target/x86_64-unknown-linux-gnu/dev/lib/app.o"]); if modified { - assert_code(&f.build(&plan, &["target/result.o"]), 0); + assert_code( + &f.build(&plan, &["target/x86_64-unknown-linux-gnu/dev/lib/app.o"]), + 0, + ); fs::remove_file(f.root.join("compiled")).unwrap(); } else { - fs::create_dir(f.root.join("target")).unwrap(); + fs::create_dir_all(f.root.join("target/x86_64-unknown-linux-gnu/dev/lib")).unwrap(); } - fs::write(f.root.join("target/result.o"), b"user content").unwrap(); + fs::write( + f.root.join("target/x86_64-unknown-linux-gnu/dev/lib/app.o"), + b"user content", + ) + .unwrap(); let registry = fs::read(f.root.join(".vex/artifacts.json")).ok(); - assert_code(&f.build(&plan, &["target/result.o"]), 5); + assert_code( + &f.build(&plan, &["target/x86_64-unknown-linux-gnu/dev/lib/app.o"]), + 5, + ); assert!(!f.root.join("compiled").exists()); assert_eq!( - fs::read(f.root.join("target/result.o")).unwrap(), + fs::read(f.root.join("target/x86_64-unknown-linux-gnu/dev/lib/app.o")).unwrap(), b"user content" ); assert_eq!(fs::read(f.root.join(".vex/artifacts.json")).ok(), registry); f.clean(); assert_eq!( - fs::read(f.root.join("target/result.o")).unwrap(), + fs::read(f.root.join("target/x86_64-unknown-linux-gnu/dev/lib/app.o")).unwrap(), b"user content" ); } @@ -153,36 +187,59 @@ fn unknown_and_modified_outputs_are_rejected_before_compiler_execution() { #[test] fn unchanged_owned_outputs_can_be_rebuilt_and_cleaned() { let f = Fixture::new(); - let plan = plan(&["target/result.o"]); + let plan = plan(&["target/x86_64-unknown-linux-gnu/dev/lib/app.o"]); for _ in 0..2 { - assert_code(&f.build(&plan, &["target/result.o"]), 0); + assert_code( + &f.build(&plan, &["target/x86_64-unknown-linux-gnu/dev/lib/app.o"]), + 0, + ); } f.clean(); - assert!(!f.root.join("target/result.o").exists()); + assert!(!f + .root + .join("target/x86_64-unknown-linux-gnu/dev/lib/app.o") + .exists()); } #[test] fn missing_or_nonregular_outputs_do_not_publish_artifact_success() { - for directory in [false, true] { - let f = Fixture::new(); - let plan = plan(&["target/result.o", "target/partial.o"]); - let mut command = f.command(&plan, &["target/partial.o"]); - if directory { - command.env("DIRECTORY_OUTPUT", "1"); - } - let output = command - .args(["--message-file", "messages.jsonl", "build"]) - .output() - .unwrap(); - assert_code(&output, 4); - let events = f.events(); - assert!(!events.iter().any(|event| event["event"] == "artifact")); - assert_eq!(events.last().unwrap()["category"], "compiler"); - if !directory { - f.clean(); - assert!(!f.root.join("target/partial.o").exists()); - } - } + // A real linked plan has both its intermediate and final image as required + // outputs. Producing only the intermediate must not report success. + let f = Fixture::shared(); + let plan = shared_plan(false); + let intermediate = plan["compile"][0]["output"].as_str().unwrap(); + let output = f + .command(&plan, &[intermediate]) + .args(["--message-file", "messages.jsonl", "build"]) + .output() + .unwrap(); + assert_code(&output, 4); + assert!( + f.root.join("compiled").exists(), + "fixture must reach compilation" + ); + assert!(!f.events().iter().any(|event| event["event"] == "artifact")); + assert_eq!(f.events().last().unwrap()["category"], "compiler"); + assert!(f.root.join(intermediate).is_file()); + f.clean(); + assert!(!f.root.join(intermediate).exists()); + + let f = Fixture::new(); + let path = "target/x86_64-unknown-linux-gnu/dev/lib/app.o"; + let output = f + .command(&plan_object(path), &[path]) + .env("DIRECTORY_OUTPUT", "1") + .args(["--message-file", "messages.jsonl", "build"]) + .output() + .unwrap(); + assert_code(&output, 4); + assert!( + f.root.join("compiled").exists(), + "fixture must reach compilation" + ); + assert!(f.root.join(path).is_dir()); + assert!(!f.events().iter().any(|event| event["event"] == "artifact")); + assert_eq!(f.events().last().unwrap()["category"], "compiler"); } #[test] @@ -190,7 +247,10 @@ fn ownership_failure_preserves_primary_compiler_error_and_structured_context() { for (compiler_code, expected) in [(1, 4), (3, 5), (0, 5)] { let f = Fixture::new(); let output = f - .command(&plan(&["target/result.o"]), &["target/result.o"]) + .command( + &plan(&["target/x86_64-unknown-linux-gnu/dev/lib/app.o"]), + &["target/x86_64-unknown-linux-gnu/dev/lib/app.o"], + ) .args(["--message-file", "messages.jsonl", "build"]) .env("COMPILE_EXIT", compiler_code.to_string()) .env("FAIL_OWNERSHIP", "1") @@ -236,22 +296,38 @@ fn escaping_duplicate_and_overlapping_outputs_fail_before_execution() { #[test] fn direct_input_and_final_output_aliases_are_rejected() { let f = Fixture::new(); - let mut plan = plan(&["target/result.o"]); - assert_code(&f.build(&plan, &["target/result.o"]), 0); + let mut plan = plan(&["target/x86_64-unknown-linux-gnu/dev/lib/app.o"]); + assert_code( + &f.build(&plan, &["target/x86_64-unknown-linux-gnu/dev/lib/app.o"]), + 0, + ); fs::remove_file(f.root.join("compiled")).unwrap(); - plan["inputs"][0]["path"] = json!("target/result.o"); - assert_code(&f.build(&plan, &["target/result.o"]), 4); + plan["inputs"][0]["path"] = json!("target/x86_64-unknown-linux-gnu/dev/lib/app.o"); + assert_code( + &f.build(&plan, &["target/x86_64-unknown-linux-gnu/dev/lib/app.o"]), + 4, + ); assert!(!f.root.join("compiled").exists()); } #[test] fn hard_links_to_owned_outputs_are_not_overwritten() { let f = Fixture::new(); - let plan = plan(&["target/result.o"]); - assert_code(&f.build(&plan, &["target/result.o"]), 0); + let plan = plan(&["target/x86_64-unknown-linux-gnu/dev/lib/app.o"]); + assert_code( + &f.build(&plan, &["target/x86_64-unknown-linux-gnu/dev/lib/app.o"]), + 0, + ); fs::remove_file(f.root.join("compiled")).unwrap(); - fs::hard_link(f.root.join("target/result.o"), f.root.join("preserve.o")).unwrap(); - assert_code(&f.build(&plan, &["target/result.o"]), 5); + fs::hard_link( + f.root.join("target/x86_64-unknown-linux-gnu/dev/lib/app.o"), + f.root.join("preserve.o"), + ) + .unwrap(); + assert_code( + &f.build(&plan, &["target/x86_64-unknown-linux-gnu/dev/lib/app.o"]), + 5, + ); assert!(!f.root.join("compiled").exists()); assert_eq!( fs::read(f.root.join("preserve.o")).unwrap(), @@ -259,22 +335,36 @@ fn hard_links_to_owned_outputs_are_not_overwritten() { ); } -fn msvc_plan() -> Value { - let mut plan = plan(&["target/native/input.o"]); - plan["target"] = json!("x86_64-pc-windows-msvc"); +fn shared_plan(windows: bool) -> Value { + let triple = if windows { + "x86_64-pc-windows-msvc" + } else { + "x86_64-unknown-linux-gnu" + }; + let intermediate = format!("target/{triple}/dev/.vex-obj/lib/app/shared/input.o"); + let output = format!( + "target/{triple}/dev/lib/{}", + if windows { "app.dll" } else { "libapp.so" } + ); + let mut plan = plan(&[&intermediate]); + plan["target"] = json!(triple); plan["emit"] = json!("bin"); - plan["link"] = json!({"output":"target/native/answer.dll","inputs":["target/native/input.o"],"program":"lld-link","args":["/OUT:target/native/answer.dll","/DLL"]}); + plan["emit_kinds"] = json!(["bin"]); + plan["link"] = json!({"output":output,"inputs":[intermediate],"program":"fixture linker","args":if windows { vec![format!("/OUT:{output}"), "/DLL".into()] } else { vec![] }}); plan } +fn msvc_plan() -> Value { + shared_plan(true) +} #[test] fn optional_msvc_companions_are_reported_and_cleaned_only_when_produced() { - let f = Fixture::new(); + let f = Fixture::shared(); let outputs = [ - "target/native/input.o", - "target/native/answer.dll", - "target/native/answer.lib", - "target/native/answer.exp", + "target/x86_64-pc-windows-msvc/dev/.vex-obj/lib/app/shared/input.o", + "target/x86_64-pc-windows-msvc/dev/lib/app.dll", + "target/x86_64-pc-windows-msvc/dev/lib/app.lib", + "target/x86_64-pc-windows-msvc/dev/lib/app.exp", ]; let output = f .command(&msvc_plan(), &outputs) @@ -301,27 +391,29 @@ fn optional_msvc_companions_are_reported_and_cleaned_only_when_produced() { #[test] fn optional_msvc_output_collisions_and_unowned_files_are_protected() { for sidecar in [ - "answer.lib", - "answer.exp", - "answer.pdb", - "answer.ilk", - "answer.map", - "answer.dll.manifest", + "app.lib", + "app.exp", + "app.pdb", + "app.ilk", + "app.map", + "app.dll.manifest", ] { - let f = Fixture::new(); - fs::create_dir_all(f.root.join("target/native")).unwrap(); - let path = f.root.join("target/native").join(sidecar); + let f = Fixture::shared(); + fs::create_dir_all(f.root.join("target/x86_64-pc-windows-msvc/dev/lib")).unwrap(); + let path = f + .root + .join("target/x86_64-pc-windows-msvc/dev/lib") + .join(sidecar); fs::write(&path, "user content").unwrap(); assert_code(&f.build(&msvc_plan(), &[]), 5); assert!(!f.root.join("compiled").exists()); assert_eq!(fs::read(path).unwrap(), b"user content"); } - let f = Fixture::new(); + let f = Fixture::shared(); let mut plan = msvc_plan(); - plan["link"]["args"] - .as_array_mut() - .unwrap() - .push(json!("/PDB:target/native/input.o")); + plan["link"]["args"].as_array_mut().unwrap().push(json!( + "/PDB:target/x86_64-pc-windows-msvc/dev/.vex-obj/lib/app/shared/input.o" + )); assert_code(&f.build(&plan, &[]), 4); assert!(!f.root.join("compiled").exists()); } @@ -375,12 +467,48 @@ fn windows_trailing_dot_and_alternate_stream_destinations_are_rejected() { fn symlink_destinations_are_rejected_before_compilation() { use std::os::unix::fs::symlink; let f = Fixture::new(); - fs::create_dir(f.root.join("target")).unwrap(); - symlink(f.root.join("src/main.wave"), f.root.join("target/result.o")).unwrap(); - assert_code(&f.build(&plan(&["target/result.o"]), &[]), 4); + fs::create_dir_all(f.root.join("target/x86_64-unknown-linux-gnu/dev/lib")).unwrap(); + symlink( + f.root.join("src/main.wave"), + f.root.join("target/x86_64-unknown-linux-gnu/dev/lib/app.o"), + ) + .unwrap(); + assert_code( + &f.build( + &plan(&["target/x86_64-unknown-linux-gnu/dev/lib/app.o"]), + &[], + ), + 4, + ); assert!(!f.root.join("compiled").exists()); assert_eq!( fs::read(f.root.join("src/main.wave")).unwrap(), b"fun main() {}\n" ); } + +#[test] +fn compiler_cannot_change_selected_artifact_kind_or_namespace() { + let f = Fixture::new(); + assert_code(&f.build(&plan(&["target/ignored-selection.o"]), &[]), 4); + assert!(!f.root.join("compiled").exists()); + + let f = Fixture::shared(); + let object_instead_of_shared = plan(&["target/x86_64-unknown-linux-gnu/dev/lib/libapp.so"]); + assert_code(&f.build(&object_instead_of_shared, &[]), 4); + assert!(!f.root.join("compiled").exists()); + + let f = Fixture::new(); + let mut linked_instead_of_object = shared_plan(false); + linked_instead_of_object["link"]["output"] = + json!("target/x86_64-unknown-linux-gnu/dev/lib/app.o"); + assert_code(&f.build(&linked_instead_of_object, &[]), 4); + assert!(!f.root.join("compiled").exists()); + + let f = Fixture::shared(); + let mut escaped_intermediate = shared_plan(false); + escaped_intermediate["compile"][0]["output"] = json!("target/another-package.o"); + escaped_intermediate["link"]["inputs"] = json!(["target/another-package.o"]); + assert_code(&f.build(&escaped_intermediate, &[]), 4); + assert!(!f.root.join("compiled").exists()); +} diff --git a/tests/clean.rs b/tests/clean.rs index e3c1404..4cb176b 100644 --- a/tests/clean.rs +++ b/tests/clean.rs @@ -59,10 +59,19 @@ impl Fixture { ); output } - fn build(&self, output: &str, fail: bool) { + fn select_binary(&self, name: &str) { + fs::write(self.root.join("vex.ws"), format!( + "{{format=3,name=\"app\",version=0.1.0,targets={{bins=[{{name=\"{name}\",path=\"src/main.wave\"}}]}}}}" + )).unwrap(); + } + fn build(&self, name: &str, fail: bool) { + self.build_profile(name, "dev", fail); + } + fn build_profile(&self, name: &str, profile: &str, fail: bool) { + self.select_binary(name); let result = self - .command(&["build"]) - .env("FAKE_BUILD_OUTPUT", output) + .command(&["build", "--profile", profile]) + .env("FAKE_BUILD_OUTPUT", artifact_profile(name, profile)) .env("VEX_TEST_COMPILE_EXIT", if fail { "1" } else { "0" }) .output() .unwrap(); @@ -87,13 +96,28 @@ impl Drop for Fixture { let _ = fs::remove_dir_all(&self.root); } } -fn program() -> &'static str { +fn target() -> &'static str { if cfg!(windows) { - "program.exe" + "x86_64-pc-windows-msvc" + } else if cfg!(target_os = "macos") { + "x86_64-apple-darwin" } else { - "program" + "x86_64-unknown-linux-gnu" } } +fn artifact_profile(name: &str, profile: &str) -> String { + format!( + "target/{}/{profile}/bin/{name}{}", + target(), + std::env::consts::EXE_SUFFIX + ) +} +fn artifact(name: &str) -> String { + artifact_profile(name, "dev") +} +fn program() -> String { + artifact("app").strip_prefix("target/").unwrap().to_owned() +} #[test] fn pristine_and_legacy_outputs_are_preserved_without_creating_state() { @@ -113,20 +137,20 @@ fn pristine_and_legacy_outputs_are_preserved_without_creating_state() { #[test] fn removes_only_new_unchanged_outputs_and_dry_run_preserves_everything() { let f = Fixture::new(); - f.build("target/owned.o", false); + f.build("owned", false); let registry = fs::read(f.root.join(".vex/artifacts.json")).unwrap(); let lock = fs::read(f.root.join("vex.lock")).unwrap(); fs::write(f.root.join("target/unknown"), "keep").unwrap(); let preview = f.ok(&["--message-file", "preview.jsonl", "clean", "--dry-run"]); - assert!(String::from_utf8_lossy(&preview.stdout).contains("target/owned.o")); + assert!(String::from_utf8_lossy(&preview.stdout).contains(&artifact("owned"))); assert!(!f.root.join("preview.jsonl").exists()); - assert!(f.root.join("target/owned.o").exists()); + assert!(f.root.join(artifact("owned")).exists()); assert_eq!( fs::read(f.root.join(".vex/artifacts.json")).unwrap(), registry ); f.ok(&["clean"]); - assert!(!f.root.join("target/owned.o").exists()); + assert!(!f.root.join(artifact("owned")).exists()); assert_eq!(fs::read(f.root.join("target/unknown")).unwrap(), b"keep"); assert_eq!(fs::read(f.root.join("vex.lock")).unwrap(), lock); assert!(f.root.join("src/main.wave").exists()); @@ -136,30 +160,31 @@ fn removes_only_new_unchanged_outputs_and_dry_run_preserves_everything() { #[test] fn modified_and_preexisting_outputs_are_preserved_before_compilation() { let f = Fixture::new(); - fs::create_dir(f.root.join("target")).unwrap(); - fs::write(f.root.join("target/legacy.o"), "old compiler output").unwrap(); + f.select_binary("legacy"); + fs::create_dir_all(f.root.join(artifact("legacy")).parent().unwrap()).unwrap(); + fs::write(f.root.join(artifact("legacy")), "old compiler output").unwrap(); let output = f .command(&["build"]) - .env("FAKE_BUILD_OUTPUT", "target/legacy.o") + .env("FAKE_BUILD_OUTPUT", artifact("legacy")) .output() .unwrap(); assert_eq!(output.status.code(), Some(5)); assert_eq!( - fs::read(f.root.join("target/legacy.o")).unwrap(), + fs::read(f.root.join(artifact("legacy"))).unwrap(), b"old compiler output" ); - f.build("target/edited.o", false); - fs::write(f.root.join("target/edited.o"), "user change").unwrap(); + f.build("edited", false); + fs::write(f.root.join(artifact("edited")), "user change").unwrap(); f.ok(&["clean"]); let output = f .command(&["build"]) - .env("FAKE_BUILD_OUTPUT", "target/edited.o") + .env("FAKE_BUILD_OUTPUT", artifact("edited")) .output() .unwrap(); assert_eq!(output.status.code(), Some(5)); f.ok(&["clean"]); assert_eq!( - fs::read(f.root.join("target/edited.o")).unwrap(), + fs::read(f.root.join(artifact("edited"))).unwrap(), b"user change" ); } @@ -167,12 +192,13 @@ fn modified_and_preexisting_outputs_are_preserved_before_compilation() { #[test] fn partial_compile_output_is_owned_but_unknown_generation_sidecars_survive() { let f = Fixture::new(); - f.build("target/partial.o", true); + f.build("partial", true); + f.select_binary("app"); f.ok(&["run"]); let generation = f.generation(); fs::write(generation.join("sidecar"), "program-owned").unwrap(); f.ok(&["clean"]); - assert!(!f.root.join("target/partial.o").exists()); + assert!(!f.root.join(artifact("partial")).exists()); assert!(!generation.join(program()).exists()); assert_eq!( fs::read(generation.join("sidecar")).unwrap(), @@ -184,13 +210,13 @@ fn partial_compile_output_is_owned_but_unknown_generation_sidecars_survive() { #[test] fn malformed_registry_and_pending_recovery_fail_before_deletion() { let f = Fixture::new(); - f.build("target/owned.o", false); + f.build("owned", false); let registry = fs::read(f.root.join(".vex/artifacts.json")).unwrap(); for journal in ["transaction.json", "init.json"] { fs::write(f.root.join(".vex").join(journal), "pending").unwrap(); let output = f.command(&["clean"]).output().unwrap(); assert!(!output.status.success()); - assert!(f.root.join("target/owned.o").exists()); + assert!(f.root.join(artifact("owned")).exists()); assert_eq!( fs::read(f.root.join(".vex").join(journal)).unwrap(), b"pending" @@ -204,7 +230,7 @@ fn malformed_registry_and_pending_recovery_fail_before_deletion() { ] { fs::write(f.root.join(".vex/artifacts.json"), content).unwrap(); assert!(!f.command(&["clean"]).output().unwrap().status.success()); - assert!(f.root.join("target/owned.o").exists()); + assert!(f.root.join(artifact("owned")).exists()); } fs::write(f.root.join(".vex/artifacts.json"), registry).unwrap(); } @@ -214,14 +240,16 @@ fn malformed_registry_and_pending_recovery_fail_before_deletion() { fn symlinks_and_permission_edits_are_preserved() { use std::os::unix::fs::{symlink, PermissionsExt}; let f = Fixture::new(); - f.build("target/sub/owned.o", false); - fs::rename(f.root.join("target/sub"), f.root.join("saved")).unwrap(); - symlink(f.root.join("saved"), f.root.join("target/sub")).unwrap(); - f.build("target/mode.o", false); + f.build_profile("owned", "release", false); + let output = f.root.join(artifact_profile("owned", "release")); + let directory = output.parent().unwrap(); + fs::rename(directory, f.root.join("saved")).unwrap(); + symlink(f.root.join("saved"), directory).unwrap(); + f.build("mode", false); fs::set_permissions( - f.root.join("target/mode.o"), + f.root.join(artifact("mode")), fs::Permissions::from_mode( - fs::metadata(f.root.join("target/mode.o")) + fs::metadata(f.root.join(artifact("mode"))) .unwrap() .permissions() .mode() @@ -230,8 +258,11 @@ fn symlinks_and_permission_edits_are_preserved() { ) .unwrap(); f.ok(&["clean"]); - assert!(f.root.join("saved/owned.o").exists()); - assert!(f.root.join("target/mode.o").exists()); + assert!(f + .root + .join(format!("saved/owned{}", std::env::consts::EXE_SUFFIX)) + .exists()); + assert!(f.root.join(artifact("mode")).exists()); } fn accept(listener: &TcpListener) -> TcpStream { @@ -315,10 +346,10 @@ fn run_lease_protects_orphaned_program_after_vex_is_killed() { #[test] fn nonregular_owned_paths_do_not_block_cleanup() { let f = Fixture::new(); - f.build("target/owned.o", false); - fs::remove_file(f.root.join("target/owned.o")).unwrap(); + f.build("owned", false); + fs::remove_file(f.root.join(artifact("owned"))).unwrap(); assert!(Command::new("mkfifo") - .arg(f.root.join("target/owned.o")) + .arg(f.root.join(artifact("owned"))) .status() .unwrap() .success()); @@ -341,5 +372,5 @@ fn nonregular_owned_paths_do_not_block_cleanup() { } std::thread::sleep(Duration::from_millis(10)); } - assert!(fs::symlink_metadata(f.root.join("target/owned.o")).is_ok()); + assert!(fs::symlink_metadata(f.root.join(artifact("owned"))).is_ok()); } diff --git a/tests/doctor.rs b/tests/doctor.rs index 095bc98..8c4f4ed 100644 --- a/tests/doctor.rs +++ b/tests/doctor.rs @@ -59,6 +59,7 @@ impl Fixture { .env("PATH", &self.bin) .env("VEX_WAVEC", &self.wavec) .env("DOCTOR_LOG", self.root.join("calls.log")) + .env("DOCTOR_HOST_TARGET", host_target()) .env( "DOCTOR_TARGETS", json!([host_target(), "doctor-cross-target"]).to_string(), @@ -124,13 +125,27 @@ fn main() { if args == ["print", "supported-targets", "--format=json"] { println!("{}", env::var("DOCTOR_TARGETS").unwrap()); return; } + let target = args.iter().find_map(|s| s.strip_prefix("--target=")).map(str::to_owned) + .unwrap_or_else(|| env::var("DOCTOR_HOST_TARGET").unwrap()); + if args.first().map(String::as_str) == Some("print") && args.get(1).map(String::as_str) == Some("target-spec") { + println!("{{\"triple\":{target:?},\"supported\":true,\"hosted\":true,\"object_format\":\"elf\",\"os\":\"linux\"}}"); return; + } assert!(args.iter().any(|s| s == "--dry-run"), "doctor must not compile"); assert!(!args.iter().any(|s| s == "--run"), "doctor must not run"); if let Ok(error) = env::var("DOCTOR_ERROR") { eprintln!("{error}"); std::process::exit(env::var("DOCTOR_EXIT").ok().and_then(|s| s.parse().ok()).unwrap_or(1)); } let schema = env::var("DOCTOR_SCHEMA").unwrap_or_else(|_| "1".into()); - let link_output = env::var("DOCTOR_LINK_OUTPUT").unwrap_or_else(|_| "target/main".into()); - let link = env::var("DOCTOR_LINK").map(|p| format!("{{\"output\":{link_output:?},\"inputs\":[],\"program\":{p:?},\"args\":[]}}")).unwrap_or_else(|_| "null".into()); - println!("{{\"schema_version\":{schema},\"mode\":\"build\",\"target\":\"test-target\",\"emit\":\"bin\",\"emit_kinds\":[],\"control_mode\":null,\"forced_input_type\":null,\"inputs\":[],\"emit_jobs\":[],\"compile\":[],\"link\":{link},\"execute\":null}}"); + let requested_output = args.windows(2).find(|pair| pair[0] == "-o").map(|pair| pair[1].clone()).unwrap_or_else(|| "target/main".into()); + let link_output = env::var("DOCTOR_LINK_OUTPUT").unwrap_or(requested_output.clone()); + let object = args.iter().any(|arg| arg == "--emit=obj"); + let emit = if object { "obj" } else { "bin" }; + let program = env::var("DOCTOR_LINK").unwrap_or_else(|_| env::current_exe().unwrap().to_string_lossy().into_owned()); + let link = if object { "null".into() } else { + format!("{{\"output\":{link_output:?},\"inputs\":[],\"program\":{program:?},\"args\":[]}}") + }; + let compile = if object { + format!("[{{\"input\":\"src/lib.wave\",\"kind\":\"wave\",\"output\":{requested_output:?},\"command\":\"wavec\"}}]") + } else { "[]".into() }; + println!("{{\"schema_version\":{schema},\"mode\":\"build\",\"target\":{target:?},\"emit\":{emit:?},\"emit_kinds\":[],\"control_mode\":null,\"forced_input_type\":null,\"inputs\":[],\"emit_jobs\":[],\"compile\":{compile},\"link\":{link},\"execute\":null}}"); } "###).unwrap(); let path = root.join(exe("fake")); @@ -251,7 +266,10 @@ fn prepared_project_plan_is_checked_under_explicit_selection_without_build_or_mu 1 ); assert_eq!(calls.lines().filter(|s| s.contains("build")).count(), 1); - assert!(calls.contains("build src/main.wave --dry-run --error-format=json")); + assert!(calls.contains("src/main.wave")); + assert!(calls.contains("--dry-run") && calls.contains("--error-format=json")); + assert!(calls.contains("-O0")); + assert!(calls.contains(&format!("target/{}/dev/bin/doctor_test", host_target()))); assert_eq!(before, snapshot(&f.work)); } #[test] @@ -460,3 +478,121 @@ fn unsafe_compiler_outputs_fail_doctor_before_linker_checks_without_writes() { assert_eq!(before, snapshot(&f.work)); } } + +#[test] +fn mixed_targets_use_dev_profile_self_library_mapping_and_all_plans_without_writes() { + let f = Fixture::new(); + f.project(true); + fs::create_dir_all(f.work.join("src/bin")).unwrap(); + fs::write(f.work.join("src/lib.wave"), "pub fun greet() {}\n").unwrap(); + fs::write(f.work.join("src/bin/admin.wave"), "fun main() {}\n").unwrap(); + fs::write( + f.work.join("vex.ws"), + r#"{format=3, name="doctor_test", targets={lib={}, bins=[ + {name="doctor_test",path="src/main.wave"}, {name="admin",path="src/bin/admin.wave"} + ]}, profiles={dev={opt_level=1}}}"#, + ) + .unwrap(); + let before = snapshot(&f.work); + let out = f.json(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let report = document(&out); + let plans = report["checks"] + .as_array() + .unwrap() + .iter() + .filter(|item| item["id"] == "dry_run") + .collect::>(); + assert_eq!(plans.len(), 3); + assert_eq!(plans[0]["details"]["package_target"]["kind"], "library"); + assert_eq!(plans[1]["details"]["package_target"]["name"], "admin"); + assert!(plans.iter().all(|item| item["status"] == "ok" + && item["details"]["profile"] == "dev" + && item["details"]["opt_level"] == 1)); + let calls = f.calls(); + let builds = calls + .lines() + .filter(|call| call.contains(" build ")) + .collect::>(); + assert_eq!(builds.len(), 3); + assert!(builds.iter().all(|call| call.contains("-O1"))); + assert!(!builds[0].contains("--dep=doctor_test=")); + assert!(builds[1..] + .iter() + .all(|call| call.contains("--dep=doctor_test="))); + assert_eq!( + calls + .lines() + .filter(|call| call.contains("print target-spec")) + .count(), + 1 + ); + assert_eq!(before, snapshot(&f.work)); + assert!(!f.work.join("target").exists()); +} + +#[test] +fn project_default_compiler_target_is_checked_and_explicit_target_takes_precedence() { + let f = Fixture::new(); + f.project(true); + fs::write(f.work.join("vex.ws"), r#"{format=3,name="doctor_test",targets={bins=[{name="app",path="src/main.wave"}]}, build={target="doctor-cross-target"}}"#).unwrap(); + let out = f + .command() + .env("DOCTOR_TARGETS", json!(["doctor-cross-target"]).to_string()) + .args(["doctor", "--format=json"]) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let report = document(&out); + assert_eq!( + check(&report, "target")["details"]["requested"], + "doctor-cross-target" + ); + assert_eq!( + check(&report, "dry_run")["details"]["target"], + "doctor-cross-target" + ); + assert!(f.calls().contains("--target=doctor-cross-target")); + let out = f + .command() + .args(["doctor", "--format=json", "--target", &host_target()]) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + check(&document(&out), "target")["details"]["requested"], + host_target() + ); +} + +#[test] +fn every_declared_entry_is_validated_before_doctor_plans_any_target() { + let f = Fixture::new(); + f.project(true); + fs::write(f.work.join("src/lib.wave"), "pub fun greet() {}\n").unwrap(); + fs::write(f.work.join("vex.ws"), r#"{format=3,name="doctor_test",targets={lib={},bins=[{name="missing",path="src/bin/missing.wave"}]}}"#).unwrap(); + let before = snapshot(&f.work); + let out = f.json(); + assert_eq!(out.status.code(), Some(3)); + let report = document(&out); + assert_eq!(check(&report, "entry")["status"], "error"); + assert!(check(&report, "entry")["message"] + .as_str() + .unwrap() + .contains("missing")); + assert_eq!(check(&report, "dry_run")["status"], "not_checked"); + assert!(!f.calls().lines().any(|call| call.contains(" build "))); + assert_eq!(before, snapshot(&f.work)); +} diff --git a/tests/fixtures/fake_wavec.rs b/tests/fixtures/fake_wavec.rs index a3ad880..7aa7536 100644 --- a/tests/fixtures/fake_wavec.rs +++ b/tests/fixtures/fake_wavec.rs @@ -31,7 +31,7 @@ fn main() { let status = std::process::Command::new(&args[0]).args(&args[1..]).status().unwrap(); std::process::exit(status.code().unwrap_or(1)); } - if env::current_exe().unwrap().file_stem().unwrap() == "program" { + if env::current_exe().unwrap().file_stem().unwrap() == "program" || env::current_exe().unwrap().components().any(|c| c.as_os_str() == ".vex-run") { if let Ok(vex) = env::var("VEX_TEST_NESTED") { assert!(std::process::Command::new(vex).args(["fetch", "--locked", "--offline"]).status().unwrap().success()); } @@ -58,7 +58,7 @@ fn main() { } if args.first().is_some_and(|s| s == "print") { if args.get(1).is_some_and(|s| s == "target-spec") { - let target = args.iter().find_map(|a| a.strip_prefix("--target=")).unwrap_or("x86_64-unknown-linux-gnu"); + let target = args.iter().find_map(|a| a.strip_prefix("--target=")).unwrap_or(if cfg!(windows) { "x86_64-pc-windows-msvc" } else if cfg!(target_os="macos") { "x86_64-apple-darwin" } else { "x86_64-unknown-linux-gnu" }); let (os, format) = if target.contains("windows") { ("windows", "coff") } else if target.contains("darwin") { ("macos", "macho") } else { ("linux", "elf") }; @@ -69,13 +69,13 @@ fn main() { println!("{}", env::var("FAKE_TARGETS").unwrap_or_else(|_| "[\"x86_64-unknown-linux-gnu\",\"aarch64-unknown-linux-gnu\"]".into())); return; } - let generation = args.iter().find_map(|a| a.strip_prefix("--target-dir=")).map(PathBuf::from); + let explicit_output = args.iter().position(|a| a == "-o").and_then(|i| args.get(i+1)).map(PathBuf::from); if args.iter().any(|a| a == "--dry-run") { checkpoint("PLAN"); let mode = if args.iter().any(|a| a == "--run") { "build+run" } else { "build" }; + let emit = if args.iter().any(|a| a == "--emit=check") { "check" } else { "bin" }; let schema = env::var("FAKE_SCHEMA").unwrap_or_else(|_| "1".into()); - let output = generation.map(|p| p.join(if cfg!(windows) { "program.exe" } else { "program" })) - .or_else(|| env::var_os("FAKE_BUILD_OUTPUT").map(PathBuf::from)); + let output = env::var_os("FAKE_BUILD_OUTPUT").map(PathBuf::from).or_else(|| explicit_output.clone()); let (link, execute) = if let Some(output) = output { let output = env::var("FAKE_OUTPUT").unwrap_or_else(|_| output.to_string_lossy().into_owned()); let link = format!("{{\"output\":{output:?},\"inputs\":[],\"program\":\"linker\",\"args\":[]}}"); @@ -85,7 +85,7 @@ fn main() { } else { format!("{{\"program\":{output:?},\"args\":{runtime:?}}}") }; (link, if mode == "build+run" { execute } else { "null".into() }) } else { ("null".into(), "null".into()) }; - println!("{{\"schema_version\":{schema},\"mode\":\"{mode}\",\"target\":\"test-target\",\"emit\":\"bin\",\"emit_kinds\":[],\"control_mode\":null,\"forced_input_type\":null,\"inputs\":[],\"emit_jobs\":[],\"compile\":[],\"link\":{link},\"execute\":{execute}}}"); + println!("{{\"schema_version\":{schema},\"mode\":\"{mode}\",\"target\":\"test-target\",\"emit\":\"{emit}\",\"emit_kinds\":[],\"control_mode\":null,\"forced_input_type\":null,\"inputs\":[],\"emit_jobs\":[],\"compile\":[],\"link\":{link},\"execute\":{execute}}}"); } else { assert!(!args.iter().any(|a| a == "--run" || a == "--")); checkpoint("COMPILE"); @@ -94,9 +94,11 @@ fn main() { fs::write(output, b"compiled artifact").unwrap(); } if let Ok(code) = env::var("VEX_TEST_COMPILE_EXIT") { std::process::exit(code.parse().unwrap()); } - if let Some(generation) = generation { - fs::create_dir_all(&generation).unwrap(); - fs::copy(env::current_exe().unwrap(), generation.join(if cfg!(windows) { "program.exe" } else { "program" })).unwrap(); + if env::var_os("FAKE_BUILD_OUTPUT").is_none() { + if let Some(output) = explicit_output { + fs::create_dir_all(output.parent().unwrap()).unwrap(); + fs::copy(env::current_exe().unwrap(), &output).unwrap(); + } } else { println!("FAKE_WAVEC_EXECUTED"); } } } diff --git a/tests/fixtures/target_wavec.rs b/tests/fixtures/target_wavec.rs new file mode 100644 index 0000000..cec1da1 --- /dev/null +++ b/tests/fixtures/target_wavec.rs @@ -0,0 +1,164 @@ +// Independent compiler process fixture; compiled by rustc without dependencies. +use std::{env, fs, io::Write, path::PathBuf}; + +fn quote(value: &str) -> String { + let mut result = String::from("\""); + for ch in value.chars() { + match ch { + '\\' => result.push_str("\\\\"), + '"' => result.push_str("\\\""), + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + ch if ch.is_control() => result.push_str(&format!("\\u{:04x}", ch as u32)), + ch => result.push(ch), + } + } + result.push('"'); + result +} +fn list(values: &[String]) -> String { + format!( + "[{}]", + values + .iter() + .map(|value| quote(value)) + .collect::>() + .join(",") + ) +} +fn host() -> &'static str { + if cfg!(windows) { + "x86_64-pc-windows-msvc" + } else if cfg!(target_os = "macos") { + "x86_64-apple-darwin" + } else { + "x86_64-unknown-linux-gnu" + } +} +fn main() { + let args: Vec = env::args().skip(1).collect(); + if env::current_exe().unwrap().file_stem().unwrap() != "target-wavec" { + println!("TARGET_PROGRAM:{}", list(&args)); + if let Ok(vex) = env::var("TARGET_PROGRAM_CHECK_LOCK_RELEASE") { + let status = std::process::Command::new(vex) + .args(["fetch", "--locked", "--offline"]) + .status() + .unwrap(); + assert!( + status.success(), + "Vex must release its build lock before program spawn" + ); + } + std::process::exit( + env::var("TARGET_PROGRAM_EXIT") + .unwrap_or_else(|_| "17".into()) + .parse() + .unwrap(), + ); + } + if let Some(path) = env::var_os("TARGET_COMPILER_LOG") { + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!(file, "{}", list(&args)).unwrap(); + } + if args.iter().any(|arg| arg == "--version") { + println!("wavec 0.2.1-pre-beta"); + return; + } + let value = |flag: &str| { + args.iter() + .find_map(|arg| arg.strip_prefix(&format!("{flag}=")).map(str::to_owned)) + .or_else(|| { + args.iter() + .position(|arg| arg == flag) + .and_then(|i| args.get(i + 1).cloned()) + }) + }; + let triple = value("--target").unwrap_or_else(|| host().into()); + if args.first().is_some_and(|arg| arg == "print") { + if args.get(1).is_some_and(|arg| arg == "supported-targets") { + println!("[\"x86_64-unknown-linux-gnu\",\"aarch64-unknown-linux-gnu\",\"x86_64-pc-windows-msvc\",\"aarch64-pc-windows-msvc\",\"x86_64-apple-darwin\",\"aarch64-apple-darwin\",\"wasm32-unknown-unknown\",\"wasm32-wasip1\"]"); + } else { + let (os, format) = if triple == "wasm32-wasip1" { ("wasi", "wasm") } + else if triple == "wasm32-unknown-unknown" { ("unknown", "wasm") } + else if triple.contains("windows") { + ("windows", "coff") + } else if triple.contains("darwin") { + ("macos", "macho") + } else { + ("linux", "elf") + }; + println!("{{\"triple\":{},\"supported\":true,\"hosted\":{},\"os\":{},\"object_format\":{}}}", quote(&triple), os != "unknown", quote(os), quote(format)); + } + return; + } + let input = args.iter().find(|arg| arg.ends_with(".wave")).unwrap(); + let dry = args.iter().any(|arg| arg == "--dry-run"); + let failure = if dry { + "TARGET_PLAN_FAIL_INPUT" + } else { + "TARGET_COMPILE_FAIL_INPUT" + }; + if env::var(failure).is_ok_and(|pattern| input.ends_with(&pattern)) { + eprintln!("fixture rejected {input}"); + std::process::exit(1); + } + let check = args.iter().any(|arg| arg == "--emit=check"); + let object = args.iter().any(|arg| arg == "--emit=obj"); + let run = args.iter().any(|arg| arg == "--run"); + let output = value("-o").unwrap_or_else(|| "target/default".into()); + let intermediate = if object { + output.clone() + } else { + PathBuf::from(value("--target-dir").unwrap_or_else(|| "target".into())) + .join("package.o") + .to_string_lossy() + .into_owned() + }; + if dry { + let emit = if check { + "check" + } else if object { + "obj" + } else { + "bin" + }; + let compile = if check { + "[]".into() + } else { + format!("[{{\"input\":{},\"kind\":\"wave\",\"output\":{},\"command\":\"target fixture compile\"}}]", quote(input), quote(&intermediate)) + }; + let link = if check || object { + "null".into() + } else { + format!("{{\"output\":{},\"inputs\":[{}],\"program\":\"target fixture linker\",\"args\":[]}}", quote(&output), quote(&intermediate)) + }; + let execute = if run { + format!("{{\"program\":{},\"args\":[]}}", quote(&output)) + } else { + "null".into() + }; + println!("{{\"schema_version\":1,\"mode\":{},\"target\":{},\"emit\":{},\"emit_kinds\":[],\"control_mode\":null,\"forced_input_type\":null,\"inputs\":[{{\"path\":{},\"kind\":\"wave\"}}],\"emit_jobs\":[],\"compile\":{},\"link\":{},\"execute\":{}}}", quote(if run { "build+run" } else { "build" }), quote(&triple), quote(emit), quote(input), compile, link, execute); + return; + } + assert!(!run, "Vex must separate compilation from execution"); + if !check { + let outputs = if object { + vec![intermediate] + } else { + vec![intermediate, output.clone()] + }; + for output in outputs { + let path = PathBuf::from(output); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, b"target fixture artifact").unwrap(); + } + if output.contains(".vex-run") { + fs::copy(env::current_exe().unwrap(), output).unwrap(); + } + } +} diff --git a/tests/native_build.rs b/tests/native_build.rs index d6e4872..974a07d 100644 --- a/tests/native_build.rs +++ b/tests/native_build.rs @@ -180,7 +180,13 @@ fn code(output: &Output, expected: i32) { } fn plan(output: &Output) -> Value { code(output, 0); - serde_json::from_slice(&output.stdout).unwrap() + let value: Value = serde_json::from_slice(&output.stdout).unwrap(); + if value.get("plans").is_some() { + assert_eq!(value["plans"].as_array().unwrap().len(), 1); + value["plans"][0]["compiler_plan"].clone() + } else { + value + } } fn snapshot(root: &Path) -> BTreeMap> { fn visit(root: &Path, directory: &Path, result: &mut BTreeMap>) { @@ -239,7 +245,8 @@ fn dry_run_selects_package_target_and_profile_outputs_without_mutation() { assert_eq!( output, &json!(format!( - "target/x86_64-unknown-linux-gnu/release/{filename}" + "target/x86_64-unknown-linux-gnu/release/{}/{filename}", + if lib { "lib" } else { "bin" } )) ); assert_eq!(snapshot(&f.project), before); @@ -273,7 +280,10 @@ fn explicit_targets_choose_windows_and_macos_library_names() { } else { &value["link"]["output"] }; - assert_eq!(output, &json!(format!("target/{target}/dev/{filename}"))); + assert_eq!( + output, + &json!(format!("target/{target}/dev/lib/{filename}")) + ); assert_eq!(value["target"], target); assert!(!f.project.join("target").exists()); } @@ -351,7 +361,7 @@ fn root_and_transitive_inputs_use_the_declaring_package_and_preserve_link_order( assert_eq!(fs::read(f.project.join("vex.lock")).unwrap(), lock); assert!(f .project - .join("target/x86_64-unknown-linux-gnu/dev/app") + .join("target/x86_64-unknown-linux-gnu/dev/bin/app") .is_file()); } @@ -432,7 +442,7 @@ fn native_named_library_uses_target_specific_shared_then_static_candidates() { #[test] fn prebuilt_native_input_that_aliases_a_compile_output_is_preserved() { - let relative = "target/x86_64-unknown-linux-gnu/dev/.vex-obj/app/native.o"; + let relative = "target/x86_64-unknown-linux-gnu/dev/.vex-obj/bin/app/binary/native.o"; let f = Fixture::new( false, &format!(",native={{libraries=[{{path=\"{relative}\"}}]}}"), @@ -446,7 +456,7 @@ fn prebuilt_native_input_that_aliases_a_compile_output_is_preserved() { assert!(!f.project.join("compiler-started").exists()); assert!(!f .project - .join("target/x86_64-unknown-linux-gnu/dev/app") + .join("target/x86_64-unknown-linux-gnu/dev/bin/app") .exists()); } @@ -584,7 +594,7 @@ fn check_validates_manifest_inputs_without_linking_or_emitting_native_artifacts( assert!(f.project.join("compiler-started").exists()); assert!(!f .project - .join("target/x86_64-unknown-linux-gnu/dev/libapp.so") + .join("target/x86_64-unknown-linux-gnu/dev/lib/libapp.so") .exists()); } @@ -662,6 +672,96 @@ fn native_run_uses_an_independent_generation_and_preserves_program_exit_and_argu assert!(f.project.join(output).is_file()); assert!(!f .project - .join("target/x86_64-unknown-linux-gnu/dev/app") + .join("target/x86_64-unknown-linux-gnu/dev/bin/app") .exists()); } + +#[test] +fn format_three_shared_library_and_same_named_binary_have_disjoint_windows_outputs() { + let f = Fixture::new(false, ""); + fs::write( + f.project.join("src/lib.wave"), + "export(c) fun wave_add() -> i32 { return 1; }\n", + ) + .unwrap(); + fs::write(f.project.join("vex.ws"), r#"{format=3,name="app",targets={lib={output="shared",exports=["wave_add"]},bins=[{name="app",path="src/main.wave"}]},default_run="app"}"#).unwrap(); + let result = f.run(&["build", "--target=x86_64-pc-windows-msvc", "--dry-run"]); + code(&result, 0); + let envelope: Value = serde_json::from_slice(&result.stdout).unwrap(); + let plans = envelope["plans"].as_array().unwrap(); + assert_eq!(plans.len(), 2); + assert_eq!(plans[0]["package_target"]["kind"], "library"); + assert_eq!(plans[1]["package_target"]["kind"], "binary"); + assert_eq!( + plans[0]["compiler_plan"]["link"]["output"], + "target/x86_64-pc-windows-msvc/dev/lib/app.dll" + ); + assert_eq!( + plans[1]["compiler_plan"]["link"]["output"], + "target/x86_64-pc-windows-msvc/dev/bin/app.exe" + ); + assert_eq!( + plans[0]["compiler_plan"]["link"]["args"], + json!(["/EXPORT:wave_add"]) + ); + assert_eq!(plans[1]["compiler_plan"]["link"]["args"], json!([])); + assert_ne!( + plans[0]["compiler_plan"]["compile"][0]["output"], + plans[1]["compiler_plan"]["compile"][0]["output"] + ); + let invocations = f.invocations(); + let binary = invocations + .iter() + .rev() + .find(|args| args.iter().any(|arg| arg == "src/main.wave")) + .unwrap(); + assert!(binary.iter().any(|arg| arg.starts_with("--dep=app="))); + assert!(!f.project.join("target").exists()); +} + +#[test] +fn format_three_mixed_dependencies_link_only_declared_native_inputs_in_order() { + let f = Fixture::new(false, ""); + let leaf = f.package("leaf", true, ",native={libraries=[{path=\"leaf.a\"}]}", ""); + let middle = f.package("middle", true, "", "{name=\"leaf\",path=\"../leaf\"}"); + fs::write(middle.join("vex.ws"), r#"{format=3,name="middle",targets={lib={},bins=[{name="tool",path="src/tool.wave"}]},native={libraries=[{path="middle.a"}]},dependencies=[{name="leaf",path="../leaf"}]}"#).unwrap(); + fs::write( + f.project.join("src/lib.wave"), + "pub fun answer() -> i32 { return 42; }\n", + ) + .unwrap(); + fs::write(f.project.join("vex.ws"), r#"{format=3,name="app",targets={lib={},bins=[{name="app",path="src/main.wave"}]},native={libraries=[{path="root.a"}]},dependencies=[{name="middle",path="../middle"}]}"#).unwrap(); + for (root, name) in [ + (&f.project, "root.a"), + (&middle, "middle.a"), + (&leaf, "leaf.a"), + ] { + fs::write(root.join(name), "prebuilt archive").unwrap(); + } + code(&f.run(&["fetch", "--offline"]), 0); + let result = f.run(&[ + "build", + "--bin", + "app", + "--locked", + "--offline", + "--dry-run", + ]); + code(&result, 0); + let args = f.build_arguments(); + let inputs = link_arguments(&args); + assert_eq!(inputs.len(), 3); + for (actual, expected) in inputs.iter().zip([ + f.project.join("root.a"), + middle.join("middle.a"), + leaf.join("leaf.a"), + ]) { + assert_path(actual, &expected); + } + assert!(args.iter().any(|arg| arg.starts_with("--dep=app="))); + assert!(!args.iter().any(|arg| arg.contains("src/tool.wave"))); + assert!(!args + .iter() + .any(|arg| arg.ends_with("lib/app.o") || arg.ends_with("lib/middle.o"))); + assert!(!f.project.join("compiler-started").exists()); +} diff --git a/tests/native_interop.py b/tests/native_interop.py index 166776d..0861015 100644 --- a/tests/native_interop.py +++ b/tests/native_interop.py @@ -201,7 +201,8 @@ def inbound(vex, root, tools, spec): run([vex, "fetch", "--offline"], app, tools.env) locked = (app / "vex.lock").read_bytes() before = snapshot(app) - plan = json.loads(run([vex, "build", "--dry-run", "--locked", "--offline"], app, tools.env).stdout) + build_plan = json.loads(run([vex, "build", "--dry-run", "--locked", "--offline"], app, tools.env).stdout) + plan = build_plan["plans"][0]["compiler_plan"] if snapshot(app) != before or plan["target"] != spec["triple"]: raise RuntimeError("native dry-run mutated the project or selected the wrong target") outputs = build(vex, app, tools.env, root / "inbound-build.jsonl") @@ -242,7 +243,7 @@ def outbound(vex, root, tools, spec): (project / "src/lib.wave").write_text( 'export(c) fun wave_add(a: i32, b: i32) -> i32 { return a + b; }\n', encoding="utf-8") run([vex, "fetch", "--offline"], project, tools.env) - output_dir = project / "target" / spec["triple"] / "dev" + output_dir = project / "target" / spec["triple"] / "dev" / "lib" obj = output_dir / ("wave_exports" + tools.object_extension) outputs = build(vex, project, tools.env, root / "outbound-object.jsonl") if obj not in outputs: @@ -294,6 +295,37 @@ def outbound(vex, root, tools, spec): raise RuntimeError("clean did not remove the owned shared artifact set safely") +def format_three_exports(vex, root, tools, spec): + """The explicit library model retains the real C ABI on every CI host.""" + project = root / "format three exports" + (project / "src").mkdir(parents=True) + (project / "src/lib.wave").write_text( + 'export(c) fun wave_add(a: i32, b: i32) -> i32 { return a + b; }\n', encoding="utf-8") + for kind in ("object", "shared"): + library = dict(output=kind) + if kind == "shared": + library["exports"] = ["wave_add"] + manifest = dict(format=3, name="fmt3_exports", targets=dict(lib=library)) + (project / "vex.ws").write_text(wson(manifest) + "\n", encoding="utf-8") + if kind == "object": + run([vex, "fetch", "--offline"], project, tools.env) + locked = (project / "vex.lock").read_bytes() + outputs = build(vex, project, tools.env, root / ("fmt3-" + kind + ".jsonl")) + name = ("fmt3_exports" + tools.object_extension if kind == "object" else + {"coff": "fmt3_exports.dll", "macho": "libfmt3_exports.dylib", + "elf": "libfmt3_exports.so"}[spec["object_format"]]) + artifact = project / "target" / spec["triple"] / "dev" / "lib" / name + if artifact not in outputs: + raise RuntimeError("format 3 library artifact escaped the unified lib directory") + if tools.windows and kind == "shared" and artifact.with_suffix(".lib") not in outputs: + raise RuntimeError("format 3 shared artifact omitted its Windows import library") + consumer = root / ("format three " + kind + " consumer") + consumer.mkdir() + tools.consume(consumer, artifact, shared=kind == "shared") + if (project / "vex.lock").read_bytes() != locked: + raise RuntimeError("format 3 native output selection changed vex.lock") + + def archive_order(vex, root, tools): """A name/path mix must preserve the manifest's static-library order.""" project = root / "library order" @@ -345,7 +377,8 @@ def main(): inbound(vex, root, tools, spec) archive_order(vex, root, tools) outbound(vex, root, tools, spec) - print("Native C/C++/Rust inputs and Wave object/shared consumers passed.", flush=True) + format_three_exports(vex, root, tools, spec) + print("Native C/C++/Rust inputs and legacy/format 3 Wave object/shared consumers passed.", flush=True) if __name__ == "__main__": diff --git a/tests/package_targets.rs b/tests/package_targets.rs new file mode 100644 index 0000000..1214810 --- /dev/null +++ b/tests/package_targets.rs @@ -0,0 +1,494 @@ +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + OnceLock, +}; + +const MIXED: &str = r#"targets={lib={},bins=[{name="zeta",path="src/bin/zeta.wave"},{name="app",path="src/main.wave"},{name="admin",path="src/bin/admin.wave"}]}"#; +fn triple() -> &'static str { + if cfg!(windows) { + "x86_64-pc-windows-msvc" + } else if cfg!(target_os = "macos") { + "x86_64-apple-darwin" + } else { + "x86_64-unknown-linux-gnu" + } +} +fn compiler() -> &'static Path { + static COMPILER: OnceLock = OnceLock::new(); + COMPILER.get_or_init(|| { + let root = std::env::temp_dir().join(format!("vex-target-compiler-{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + let compiler = root.join(if cfg!(windows) { + "target-wavec.exe" + } else { + "target-wavec" + }); + let output = Command::new("rustc") + .args(["--edition=2021"]) + .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/target_wavec.rs")) + .arg("-o") + .arg(&compiler) + .output() + .unwrap(); + code(&output, 0); + compiler + }) +} +struct Fixture { + root: PathBuf, + app: PathBuf, +} +impl Fixture { + fn new(fields: &str) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let root = std::env::temp_dir().join(format!( + "vex package targets {} {}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let app = root.join("app"); + let fixture = Self { root, app }; + fixture.package("app", fields); + fs::write(fixture.app.join("vex.lock"), "{version=3,package=[]}\n").unwrap(); + fixture + } + fn package(&self, name: &str, fields: &str) -> PathBuf { + let root = self.root.join(name); + fs::create_dir_all(root.join("src/bin")).unwrap(); + fs::write( + root.join("vex.ws"), + format!("{{format=3,name=\"{name}\",{fields}}}"), + ) + .unwrap(); + for path in [ + "src/lib.wave", + "src/main.wave", + "src/bin/admin.wave", + "src/bin/zeta.wave", + ] { + fs::write(root.join(path), "fun main() {}\n").unwrap(); + } + root + } + fn command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_vex")); + command + .current_dir(&self.app) + .env("VEX_WAVEC", compiler()) + .env("TARGET_COMPILER_LOG", self.root.join("compiler.jsonl")) + .env("NO_COLOR", "1"); + command + } + fn run(&self, args: &[&str]) -> Output { + self.command().args(args).output().unwrap() + } + fn invocations(&self) -> Vec> { + fs::read_to_string(self.root.join("compiler.jsonl")) + .unwrap_or_default() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + fn build_calls(&self) -> Vec> { + self.invocations() + .into_iter() + .filter(|args| args.iter().any(|a| a == "build")) + .collect() + } + fn artifact(&self, kind: &str, name: &str, profile: &str) -> PathBuf { + let filename = if kind == "lib" { + format!("{name}.{}", if cfg!(windows) { "obj" } else { "o" }) + } else { + format!("{name}{}", if cfg!(windows) { ".exe" } else { "" }) + }; + self.app + .join("target") + .join(triple()) + .join(profile) + .join(kind) + .join(filename) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} +fn code(output: &Output, expected: i32) { + assert_eq!( + output.status.code(), + Some(expected), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} +fn plan(output: &Output) -> Value { + code(output, 0); + serde_json::from_slice(&output.stdout).unwrap() +} +fn identities(plan: &Value) -> Vec { + plan["plans"] + .as_array() + .unwrap() + .iter() + .map(|plan| plan["package_target"].clone()) + .collect() +} +fn snapshot(root: &Path) -> BTreeMap> { + fn visit(root: &Path, path: &Path, entries: &mut BTreeMap>) { + for entry in fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + visit(root, &path, entries); + } else { + entries.insert( + path.strip_prefix(root).unwrap().into(), + fs::read(path).unwrap(), + ); + } + } + } + let mut entries = BTreeMap::new(); + visit(root, root, &mut entries); + entries +} + +#[test] +fn default_build_plans_library_then_sorted_binaries_once_without_mutation() { + let f = Fixture::new(MIXED); + code(&f.run(&["fetch"]), 0); + let before = snapshot(&f.app); + let output = plan(&f.run(&["build", "--dry-run", "--locked", "--offline"])); + assert_eq!(output["schema"], "vex.build-plan"); + assert_eq!(output["schema_version"], 1); + assert_eq!(output["target"], triple()); + assert_eq!(output["profile"], json!({"name":"dev","opt_level":0})); + assert_eq!( + identities(&output), + vec![ + json!({"kind":"library","name":"app"}), + json!({"kind":"binary","name":"admin"}), + json!({"kind":"binary","name":"app"}), + json!({"kind":"binary","name":"zeta"}) + ] + ); + assert_eq!(f.build_calls().len(), 4); + assert!(f + .build_calls() + .iter() + .all(|args| args.iter().any(|arg| arg == "--dry-run"))); + assert_eq!(snapshot(&f.app), before); + assert!(!f.app.join("target").exists()); + let plans = output["plans"].as_array().unwrap(); + assert_eq!( + Path::new( + plans[0]["compiler_plan"]["compile"][0]["output"] + .as_str() + .unwrap() + ), + f.artifact("lib", "app", "dev") + .strip_prefix(&f.app) + .unwrap() + ); + assert_eq!( + Path::new( + plans[2]["compiler_plan"]["link"]["output"] + .as_str() + .unwrap() + ), + f.artifact("bin", "app", "dev") + .strip_prefix(&f.app) + .unwrap() + ); +} + +#[test] +fn selections_support_library_and_multiple_named_binaries_without_other_sources() { + for (args, expected) in [ + ( + vec!["build", "--lib", "--dry-run"], + vec![json!({"kind":"library","name":"app"})], + ), + ( + vec!["build", "--bin=zeta", "--bin", "admin", "--dry-run"], + vec![ + json!({"kind":"binary","name":"admin"}), + json!({"kind":"binary","name":"zeta"}), + ], + ), + ( + vec!["build", "--lib", "--bin", "admin", "--dry-run"], + vec![ + json!({"kind":"library","name":"app"}), + json!({"kind":"binary","name":"admin"}), + ], + ), + ] { + let f = Fixture::new(MIXED); + fs::remove_file(f.app.join("src/main.wave")).unwrap(); + assert_eq!(identities(&plan(&f.run(&args))), expected); + } +} + +#[test] +fn check_defaults_to_all_targets_without_artifacts() { + let f = Fixture::new(MIXED); + code(&f.run(&["check", "--locked", "--offline"]), 0); + let calls = f.build_calls(); + assert_eq!(calls.len(), 8); + assert!(calls + .iter() + .all(|args| args.iter().any(|arg| arg == "--emit=check"))); + assert_eq!( + calls + .iter() + .filter(|args| args.iter().any(|arg| arg == "--dry-run")) + .count(), + 4 + ); + assert!(!f.artifact("lib", "app", "dev").exists()); + assert!(!f.artifact("bin", "app", "dev").exists()); +} + +#[test] +fn run_uses_default_or_explicit_binary_and_preserves_program_exit_and_arguments() { + let f = Fixture::new(&format!("{MIXED},default_run=\"admin\"")); + let output = f + .command() + .args(["run", "--", "a b", "--profile", "program-only"]) + .env( + "TARGET_PROGRAM_CHECK_LOCK_RELEASE", + env!("CARGO_BIN_EXE_vex"), + ) + .output() + .unwrap(); + code(&output, 17); + assert!(String::from_utf8_lossy(&output.stdout) + .contains(r#"TARGET_PROGRAM:["a b","--profile","program-only"]"#)); + let calls = f.build_calls(); + assert_eq!(calls.len(), 2); + assert!(calls[0].iter().any(|arg| arg == "src/bin/admin.wave")); + assert!(!calls[1].iter().any(|arg| arg == "--run")); + let output = plan(&f.run(&["run", "--bin", "zeta", "--dry-run", "--", "hello"])); + assert_eq!( + identities(&output), + vec![json!({"kind":"binary","name":"zeta"})] + ); + assert_eq!( + output["plans"][0]["compiler_plan"]["execute"]["args"], + json!(["hello"]) + ); + assert!(!f.artifact("bin", "admin", "dev").exists()); +} + +#[test] +fn run_requires_an_unambiguous_binary_and_rejects_library_selection_without_state() { + for (fields, args, expected) in [ + (MIXED, vec!["run"], 3), + (MIXED, vec!["run", "--lib"], 2), + (MIXED, vec!["run", "--bin", "app", "--bin", "admin"], 2), + ("targets={lib={}}", vec!["run"], 3), + ] { + let f = Fixture::new(fields); + let before = snapshot(&f.app); + code(&f.run(&args), expected); + assert!(f.invocations().is_empty()); + assert_eq!(snapshot(&f.app), before); + assert!(!f.app.join(".vex").exists()); + } + let f = Fixture::new(r#"targets={bins=[{name="only",path="src/main.wave"}]}"#); + assert_eq!( + identities(&plan(&f.run(&["run", "--dry-run"]))), + vec![json!({"kind":"binary","name":"only"})] + ); +} + +#[test] +fn profile_selection_changes_optimization_and_artifact_identity() { + for (selection, profile, level) in [ + (vec!["--profile", "fast"], "fast", "-O3"), + (vec!["--release"], "release", "-O2"), + (vec![], "dev", "-O0"), + ] { + let f = Fixture::new( + r#"targets={bins=[{name="app",path="src/main.wave"}]},profiles={fast={inherits="release",opt_level=3}}"#, + ); + let mut args = vec!["build", "--dry-run"]; + args.extend(selection); + let output = plan(&f.run(&args)); + assert_eq!(output["profile"]["name"], profile); + assert!(f.build_calls()[0].iter().any(|arg| arg == level)); + assert_eq!( + Path::new( + output["plans"][0]["compiler_plan"]["link"]["output"] + .as_str() + .unwrap() + ), + f.artifact("bin", "app", profile) + .strip_prefix(&f.app) + .unwrap() + ); + } +} + +#[test] +fn invalid_or_repeated_selection_fails_before_compiler_or_project_mutation() { + for (args, expected) in [ + (vec!["build", "--bin", "missing"], 3), + (vec!["build", "--profile", "missing"], 3), + (vec!["build", "--lib", "--lib"], 2), + (vec!["build", "--bin", "app", "--bin=app"], 2), + (vec!["build", "--profile=dev", "--profile", "release"], 2), + (vec!["build", "--release", "--profile", "release"], 2), + (vec!["build", "--bin"], 2), + (vec!["build", "--profile="], 2), + ] { + let f = Fixture::new(MIXED); + let before = snapshot(&f.app); + code(&f.run(&args), expected); + assert!(f.invocations().is_empty()); + assert_eq!(snapshot(&f.app), before); + assert!(!f.app.join("target").exists()); + assert!(!f.app.join(".vex").exists()); + } +} + +#[test] +fn missing_last_selected_source_prevents_all_compiler_calls_and_output() { + let f = Fixture::new(MIXED); + fs::remove_file(f.app.join("src/bin/zeta.wave")).unwrap(); + let before = snapshot(&f.app); + code(&f.run(&["build"]), 3); + assert!(f.invocations().is_empty()); + assert_eq!(snapshot(&f.app), before); + assert!(!f.app.join("target").exists()); +} + +#[test] +fn all_plans_and_ownership_preflight_complete_before_any_compilation() { + let f = Fixture::new(MIXED); + let output = f + .command() + .args(["build"]) + .env("TARGET_PLAN_FAIL_INPUT", "zeta.wave") + .output() + .unwrap(); + code(&output, 4); + let calls = f.build_calls(); + assert_eq!(calls.len(), 4); + assert!(calls + .iter() + .all(|args| args.iter().any(|arg| arg == "--dry-run"))); + assert!(!f.artifact("lib", "app", "dev").exists()); + assert!(!f.artifact("bin", "admin", "dev").exists()); + let f = Fixture::new(MIXED); + let existing = f.artifact("bin", "zeta", "dev"); + fs::create_dir_all(existing.parent().unwrap()).unwrap(); + fs::write(&existing, b"user file").unwrap(); + code(&f.run(&["build"]), 5); + assert!(f + .build_calls() + .iter() + .all(|args| args.iter().any(|arg| arg == "--dry-run"))); + assert!(!f.artifact("lib", "app", "dev").exists()); + assert!(!f.artifact("bin", "admin", "dev").exists()); + assert_eq!(fs::read(existing).unwrap(), b"user file"); +} + +#[test] +fn first_compile_failure_stops_batch_and_keeps_earlier_successful_artifacts() { + let f = Fixture::new(MIXED); + let output = f + .command() + .args(["build"]) + .env("TARGET_COMPILE_FAIL_INPUT", "admin.wave") + .output() + .unwrap(); + code(&output, 4); + assert!(f.artifact("lib", "app", "dev").is_file()); + assert!(!f.artifact("bin", "admin", "dev").exists()); + assert!(!f.artifact("bin", "app", "dev").exists()); + let executed = f + .build_calls() + .into_iter() + .filter(|args| !args.iter().any(|arg| arg == "--dry-run")) + .collect::>(); + assert_eq!(executed.len(), 2); + assert!(executed[0].iter().any(|arg| arg == "src/lib.wave")); + assert!(executed[1].iter().any(|arg| arg == "src/bin/admin.wave")); +} + +#[test] +fn mixed_dependency_consumes_only_library_and_self_library_mapping_is_not_locked() { + let f = Fixture::new(&format!( + r#"{MIXED},dependencies=[{{name="tools",path="../tools"}}]"# + )); + let dependency = f.package( + "tools", + r#"targets={lib={},bins=[{name="tool",path="src/missing.wave"}]}"#, + ); + fs::remove_file(f.app.join("vex.lock")).unwrap(); + code(&f.run(&["fetch"]), 0); + let lock = fs::read(f.app.join("vex.lock")).unwrap(); + let before = snapshot(&f.app); + let output = plan(&f.run(&["build", "--locked", "--offline", "--dry-run"])); + assert_eq!(output["plans"].as_array().unwrap().len(), 4); + let calls = f.build_calls(); + assert_eq!(calls.len(), 4); + for (index, args) in calls.iter().enumerate() { + assert!( + args.iter().any(|arg| arg.starts_with("--dep=tools=")), + "{args:?}" + ); + assert_eq!( + args.iter().any(|arg| arg.starts_with("--dep=app=")), + index > 0, + "{args:?}" + ); + assert!(!args.iter().any(|arg| arg == "src/missing.wave")); + } + assert!(dependency.join("src/lib.wave").is_file()); + assert_eq!(fs::read(f.app.join("vex.lock")).unwrap(), lock); + assert_eq!(snapshot(&f.app), before); + let parsed = lockfile::decode(std::str::from_utf8(&lock).unwrap()).unwrap(); + assert_eq!(parsed.packages.len(), 1); + assert_eq!(parsed.packages[0].name, "tools"); + assert!(parsed.packages[0].dependencies.is_empty()); +} + +#[test] +fn bare_wasm_rejects_program_arguments_before_planning_or_allocating_a_generation() { + let f = Fixture::new( + r#"targets={bins=[{name="app",path="src/main.wave",mode="bare_wasm"}]},build={target="wasm32-unknown-unknown"}"#, + ); + let result = f.run(&["run", "--", "arg"]); + code(&result, 3); + assert!(String::from_utf8_lossy(&result.stderr).contains("process argument ABI")); + assert!(f.build_calls().is_empty()); + assert!(!f.app.join("target").exists()); +} + +#[test] +fn project_selection_errors_precede_missing_compiler() { + let f = Fixture::new(MIXED); + for arguments in [ + vec!["build", "--bin", "missing"], + vec!["build", "--profile", "missing"], + ] { + let result = f + .command() + .args(arguments) + .env("VEX_WAVEC", "") + .output() + .unwrap(); + code(&result, 3); + assert!(f.build_calls().is_empty()); + assert!(!f.app.join(".vex").exists()); + } +} diff --git a/tests/project_state.rs b/tests/project_state.rs index aa2d030..24a77da 100644 --- a/tests/project_state.rs +++ b/tests/project_state.rs @@ -181,9 +181,11 @@ fn running_program_releases_state_and_retains_its_generation() { .unwrap() .path(); let artifact = generation.join(if cfg!(windows) { - "program.exe" + "x86_64-pc-windows-msvc/dev/bin/app.exe" + } else if cfg!(target_os = "macos") { + "x86_64-apple-darwin/dev/bin/app" } else { - "program" + "x86_64-unknown-linux-gnu/dev/bin/app" }); let original = fs::read(&artifact).unwrap(); let mut update = f.command(&["update"]).spawn().unwrap(); diff --git a/tests/target_metadata.rs b/tests/target_metadata.rs new file mode 100644 index 0000000..abe1a27 --- /dev/null +++ b/tests/target_metadata.rs @@ -0,0 +1,230 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT: AtomicU64 = AtomicU64::new(0); +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "vex-target-metadata-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&root).unwrap(); + Self(root) + } + fn package(&self, name: &str, fields: &str) -> PathBuf { + let root = self.0.join(name); + fs::create_dir_all(root.join("src/bin")).unwrap(); + fs::write( + root.join("vex.ws"), + format!("{{ format = 3, name = \"{name}\", {fields} }}"), + ) + .unwrap(); + for path in ["src/main.wave", "src/lib.wave", "src/bin/admin.wave"] { + fs::write(root.join(path), "fun main() {}\n").unwrap(); + } + root + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn vex(root: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_vex")) + .current_dir(root) + .args(args) + .env("VEX_WAVEC", root.join("compiler-must-not-be-invoked")) + .output() + .unwrap() +} +fn success(output: &Output) { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} +fn snapshot(root: &Path) -> BTreeMap, std::time::SystemTime)> { + fn visit( + root: &Path, + directory: &Path, + entries: &mut BTreeMap, std::time::SystemTime)>, + ) { + for entry in fs::read_dir(directory).unwrap() { + let path = entry.unwrap().path(); + let metadata = fs::symlink_metadata(&path).unwrap(); + if metadata.is_dir() { + visit(root, &path, entries); + } else if metadata.is_file() { + entries.insert( + path.strip_prefix(root).unwrap().to_owned(), + (fs::read(&path).unwrap(), metadata.modified().unwrap()), + ); + } + } + } + let mut entries = BTreeMap::new(); + visit(root, root, &mut entries); + entries +} +#[test] +fn schema_two_exposes_mixed_targets_profiles_and_declared_platform_settings_without_compiler() { + let f = Fixture::new(); + let app = f.package("app", r#" + targets = { lib = { output = "shared", exports = ["wave_add"] }, bins = [ + { name = "app", path = "src/main.wave" }, + { name = "admin", path = "src/bin/admin.wave", mode = "freestanding", entry = "start", startup = "none", linker_script = "link/app.ld" } + ] }, + default_run = "app", + profiles = { fast = { inherits = "release", opt_level = 3 } }, + build = { target = "x86_64-unknown-linux-gnu" }, + target_config = [{ triple = "x86_64-unknown-linux-gnu", sysroot = "/sdk", linker = "clang", bins = [{ name = "app", mode = "hosted" }] }] + "#); + success(&vex(&app, &["fetch"])); + let before = snapshot(&f.0); + let output = vex( + &app, + &["metadata", "--format-version", "2", "--locked", "--offline"], + ); + success(&output); + assert_eq!(before, snapshot(&f.0)); + assert!(!app.join("target").exists()); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["schema_version"], 2); + let root = &value["root"]; + assert!(root.get("kind").is_none()); + assert!(root.get("entry_path").is_none()); + assert_eq!(root["manifest_format"], 3); + assert_eq!(root["default_run"], "app"); + assert_eq!(root["build_target"], "x86_64-unknown-linux-gnu"); + assert_eq!(root["profiles"]["dev"]["opt_level"], 0); + assert_eq!(root["profiles"]["release"]["opt_level"], 2); + assert_eq!(root["profiles"]["fast"]["opt_level"], 3); + assert_eq!(root["profiles"]["fast"]["inherits"], "release"); + let targets = root["targets"].as_array().unwrap(); + let library = targets + .iter() + .find(|target| target["kind"] == "library") + .unwrap(); + assert_eq!(library["name"], "app"); + assert_eq!(library["output"], "shared"); + assert_eq!(library["exports"], serde_json::json!(["wave_add"])); + let admin = targets + .iter() + .find(|target| target["name"] == "admin") + .unwrap(); + assert_eq!( + admin["entry_path"], + app.canonicalize() + .unwrap() + .join("src/bin/admin.wave") + .to_str() + .unwrap() + ); + assert_eq!(admin["platform"]["mode"], "freestanding"); + assert_eq!(admin["platform"]["entry"], "start"); + assert_eq!(admin["platform"]["startup"], "none"); + assert_eq!(admin["platform"]["linker_script"], "link/app.ld"); + assert_eq!(root["target_config"][0]["linker"], "clang"); + assert_eq!( + root["target_config"][0]["bins"][0]["platform"]["mode"], + "hosted" + ); + assert_eq!( + output.stdout, + vex(&app, &["metadata", "--format-version=2"]).stdout + ); +} +#[test] +fn schema_one_rejects_ambiguous_root_without_writing_state() { + let f = Fixture::new(); + let app = f.package( + "app", + r#"targets = { lib = {}, bins = [{name = "app", path = "src/main.wave"}] }"#, + ); + let before = snapshot(&f.0); + for args in [vec!["metadata"], vec!["metadata", "--format-version", "1"]] { + let output = vex(&app, &args); + assert_eq!(output.status.code(), Some(3)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--format-version 2")); + } + assert_eq!(before, snapshot(&f.0)); +} +#[test] +fn schema_one_rejects_a_mixed_dependency_and_schema_two_exposes_it() { + let f = Fixture::new(); + f.package( + "tools", + r#"targets = { lib = {}, bins = [{name = "tools", path = "src/main.wave"}] }"#, + ); + let app = f.package("app", r#"targets = { bins = [{name = "app", path = "src/main.wave"}] }, dependencies = [{name = "tools", path = "../tools"}]"#); + success(&vex(&app, &["fetch"])); + let before = snapshot(&f.0); + let output = vex(&app, &["metadata"]); + assert_eq!(output.status.code(), Some(3)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("tools")); + let output = vex(&app, &["metadata", "--format-version", "2"]); + success(&output); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["packages"][0]["targets"].as_array().unwrap().len(), 2); + assert_eq!(before, snapshot(&f.0)); +} +#[test] +fn schema_one_preserves_legacy_shape_and_supports_a_single_declared_binary() { + let f = Fixture::new(); + let app = f.package( + "app", + r#"targets = { bins = [{name = "admin", path = "src/bin/admin.wave"}] }"#, + ); + success(&vex(&app, &["fetch"])); + let output = vex(&app, &["metadata"]); + success(&output); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["schema_version"], 1); + assert_eq!(value["root"]["kind"], "binary"); + assert_eq!( + value["root"]["entry_path"], + app.canonicalize() + .unwrap() + .join("src/bin/admin.wave") + .to_str() + .unwrap() + ); + assert!(value["root"].get("targets").is_none()); + fs::write(app.join("vex.ws"), "{format = 2, name = \"app\"}").unwrap(); + let output = vex(&app, &["metadata"]); + success(&output); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["root"]["kind"], "binary"); + assert_eq!( + value["root"]["entry_path"], + app.canonicalize() + .unwrap() + .join("src/main.wave") + .to_str() + .unwrap() + ); +} +#[test] +fn format_version_options_fail_before_project_discovery() { + let f = Fixture::new(); + for args in [ + vec!["metadata", "--format-version"], + vec!["metadata", "--format-version", "3"], + vec!["metadata", "--format-version=0"], + vec!["metadata", "--format-version", "2", "--format-version=2"], + ] { + let output = vex(&f.0, &args); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + } + assert!(fs::read_dir(&f.0).unwrap().next().is_none()); +} diff --git a/tests/wave_compatibility.py b/tests/wave_compatibility.py index 4e638a9..a54f276 100644 --- a/tests/wave_compatibility.py +++ b/tests/wave_compatibility.py @@ -24,6 +24,100 @@ def run(command, cwd, env, *, succeeds=True): return result + +def target_profile_smoke(vex, root, env, host_target): + """Exercise format 3 through the selected real compiler on each CI host.""" + app = root / "multiple-targets" + dependency = root / "multi-support" + for package in (app, dependency): + (package / "src").mkdir(parents=True) + (dependency / "vex.ws").write_text( + '{format=3,name="support",targets={lib={},bins=[' + '{name="unused",path="src/missing-binary.wave"}]}}\n', encoding="utf-8") + (dependency / "src/lib.wave").write_text( + 'pub fun value() -> i32 { return 42; }\n', encoding="utf-8") + (app / "vex.ws").write_text( + '{format=3,name="multi",targets={lib={},bins=[' + '{name="multi",path="src/main.wave"},' + '{name="tools",path="src/tools.wave"}]},default_run="multi",' + 'profiles={fast={inherits="release",opt_level=3}},' + 'dependencies=[{name="support",path="../multi-support"}]}\n', encoding="utf-8") + (app / "src/lib.wave").write_text( + 'import("support")::{value};\npub fun answer() -> i32 { return value(); }\n' + 'fun hidden() -> i32 { return 9; }\n', encoding="utf-8") + (app / "src/main.wave").write_text( + 'import("multi")::{answer};\nfun main() -> i32 { ' + 'var result: i32 = answer(); println("default {}", result); return result - 42; }\n', + encoding="utf-8") + tool_source = ('import("multi")::{answer};\nfun main() -> i32 { ' + 'var result: i32 = answer(); println("tools {}", result); return result - 42; }\n') + (app / "src/tools.wave").write_text(tool_source, encoding="utf-8") + run([vex, "fetch", "--offline"], app, env) + locked = (app / "vex.lock").read_bytes() + + def snapshot(): + return {str(path.relative_to(app)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in app.rglob("*") if path.is_file()} + + before = snapshot() + planned = run([vex, "build", "--profile", "fast", "--dry-run", "--locked", "--offline"], app, env) + envelope = json.loads(planned.stdout) + plans = envelope.get("plans", []) + if (envelope.get("schema") != "vex.build-plan" or envelope.get("schema_version") != 1 + or envelope.get("target") != host_target + or envelope.get("profile") != {"name": "fast", "opt_level": 3} + or [item["package_target"] for item in plans] != [ + {"name": "multi", "kind": "library"}, + {"name": "multi", "kind": "binary"}, + {"name": "tools", "kind": "binary"}]): + raise RuntimeError("mixed targets or custom profile were lost in the Vex build-plan") + if snapshot() != before: + raise RuntimeError("format 3 dry-run changed project files") + expected_root = Path("target") / host_target / "fast" + expected_paths = [] + for item in plans: + plan = item["compiler_plan"] + target = item["package_target"] + if plan["target"] != host_target: + raise RuntimeError("mixed compiler plan selected the wrong target") + if target["kind"] == "library": + output = Path(plan["compile"][0]["output"]) + expected = expected_root / "lib" / ("multi.obj" if "windows" in host_target else "multi.o") + else: + output = Path(plan["link"]["output"]) + expected = expected_root / "bin" / (target["name"] + (".exe" if "windows" in host_target else "")) + if output != expected: + raise RuntimeError(f"unexpected unified output path: {output}, expected {expected}") + expected_paths.append(app / output) + run([vex, "build", "--profile", "fast", "--locked", "--offline"], app, env) + if not all(path.is_file() for path in expected_paths): + raise RuntimeError("mixed-target build omitted a planned artifact") + for selection, marker in [([], "default 42"), (["--bin", "tools"], "tools 42")]: + result = run([vex, "run", *selection, "--profile", "fast", "--locked", "--offline"], app, env) + if marker not in result.stdout.splitlines(): + raise RuntimeError("default_run or explicit binary selection used the wrong target") + run([vex, "check", "--profile", "fast", "--locked", "--offline"], app, env) + if (dependency / "src/missing-binary.wave").exists() or (dependency / "target").exists(): + raise RuntimeError("dependency binary targets were built instead of consuming its library") + rejected = run([vex, "metadata", "--locked", "--offline"], app, env, succeeds=False) + if rejected.returncode != 3 or "--format-version 2" not in rejected.stderr: + raise RuntimeError("metadata schema 1 did not reject a mixed-target package") + metadata = json.loads(run([vex, "metadata", "--format-version", "2", "--locked", "--offline"], app, env).stdout) + if (metadata["schema_version"] != 2 or len(metadata["root"]["targets"]) != 3 + or metadata["root"]["default_run"] != "multi" + or metadata["root"]["profiles"]["fast"]["opt_level"] != 3): + raise RuntimeError("metadata schema 2 lost targets, default_run, or custom profiles") + (app / "src/tools.wave").write_text( + 'import("multi")::{hidden};\nfun main() -> i32 { return hidden(); }\n', encoding="utf-8") + rejected = run([vex, "check", "--bin", "tools", "--locked", "--offline"], app, env, succeeds=False) + if rejected.returncode != 4 or "hidden" not in rejected.stderr or "private" not in rejected.stderr: + raise RuntimeError("private self-import did not retain the compiler failure contract") + (app / "src/tools.wave").write_text(tool_source, encoding="utf-8") + if (app / "vex.lock").read_bytes() != locked: + raise RuntimeError("format 3 locked/offline operations changed vex.lock") + print("Format 3 targets, output layout, custom profiles, self-import and metadata passed.", flush=True) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--vex", type=Path, required=True) @@ -73,6 +167,7 @@ def main(): raise RuntimeError("compiler plan disagrees with capabilities") if command == "build": host_target = plan["target"] + target_profile_smoke(vex, root, env, host_target) run([vex, "check", "--target", host_target, "--locked", "--offline"], nested, env) rejected = run([vex, "build", "--emit=obj"], app, env, succeeds=False) if "unknown Vex option" not in rejected.stderr: diff --git a/tests/wavec_contract.rs b/tests/wavec_contract.rs index 5063459..166175e 100644 --- a/tests/wavec_contract.rs +++ b/tests/wavec_contract.rs @@ -111,6 +111,7 @@ fn vex_uses_path_and_override_wavec_and_rejects_unknown_schema() { fs::read_to_string(schema_log) .expect("schema log must exist") .lines() + .filter(|line| line.contains(" build ")) .count(), 1, "Vex must not execute a real build after incompatible dry-run output" @@ -200,7 +201,10 @@ fn compile_fake_wavec(root: &Path) -> PathBuf { fn assert_contract_invocations(log: &Path) { let lines = fs::read_to_string(log).expect("fake wavec log must exist"); - let lines = lines.lines().collect::>(); + let lines = lines + .lines() + .filter(|line| line.contains(" build ")) + .collect::>(); assert_eq!( lines.len(), 2, @@ -274,7 +278,7 @@ fn capability_preflight_rejects_targets_before_state_and_queries_once_per_invoca assert_success(&out, command); } let logged = fs::read_to_string(log).unwrap(); - assert_eq!(logged.lines().count(), 6); + assert_eq!(logged.lines().count(), 8); assert_eq!( logged .lines() @@ -362,8 +366,18 @@ fn runtime_arguments_and_cwd_survive_project_selection_and_dry_run_is_single_jso .unwrap(); assert_success(&out, "dry run"); let plan: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); - assert_eq!(plan["execute"]["args"], serde_json::json!(runtime)); - assert_eq!(fs::read_to_string(&log).unwrap().lines().count(), 1); + assert_eq!( + plan["plans"][0]["compiler_plan"]["execute"]["args"], + serde_json::json!(runtime) + ); + assert_eq!( + fs::read_to_string(&log) + .unwrap() + .lines() + .filter(|line| line.contains(" build ")) + .count(), + 1 + ); assert_eq!(before, fs::read(project.join("vex.lock")).unwrap()); assert!(!project.join("target/.vex-run/planned").exists()); } diff --git a/wson/src/lib.rs b/wson/src/lib.rs index b4419ce..9fd10a0 100644 --- a/wson/src/lib.rs +++ b/wson/src/lib.rs @@ -22,6 +22,15 @@ pub fn read_document(path: &std::path::Path) -> Result { } pub fn loads(raw: &str, version_key: &str, escaped_version: i64) -> Result { + loads_versions(raw, version_key, &[escaped_version]) +} + +/// Decode escapes only for the explicitly listed format versions. +pub fn loads_versions( + raw: &str, + version_key: &str, + escaped_versions: &[i64], +) -> Result { if raw.len() > MAX_DOCUMENT_BYTES { return Err("WSON document exceeds 8 MiB".into()); } @@ -32,7 +41,8 @@ pub fn loads(raw: &str, version_key: &str, escaped_version: i64) -> Result