diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a63f6622..5986614e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -350,6 +350,7 @@ jobs: python3 -m unittest tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native - name: Build release compiler + id: release_build run: cargo build --locked --release --verbose - name: Run Rust tests @@ -381,7 +382,7 @@ jobs: --features llvm-target-core64 --jobs 2 - name: Verify bundled Linux CRT matrix - if: ${{ always() }} + if: ${{ !cancelled() && steps.release_build.outcome == 'success' }} run: | set -euo pipefail @@ -430,7 +431,7 @@ jobs: aarch64_shared_language_workloads_run_under_qemu --jobs 2 - name: Run RISC-V networking contracts under QEMU - if: ${{ always() }} + if: ${{ !cancelled() && steps.release_build.outcome == 'success' }} run: | set -euo pipefail @@ -861,7 +862,7 @@ jobs: env: WAVE_RUNTIME_ARTIFACT_DIR: ${{ runner.temp }} run: >- - cargo test --locked --no-fail-fast --test runtime_regressions --test std_io_regressions --test native_providers --test stabilization_17 --test native_boundaries --target aarch64-pc-windows-msvc + cargo test --locked --no-fail-fast --test runtime_regressions --test std_io_regressions --test native_providers --test stabilization_17 --test native_boundaries --test release_constants --target aarch64-pc-windows-msvc --no-default-features --features llvm-target-aarch64 --jobs 2 - name: Save failed native runtime compiler and fixture @@ -1019,7 +1020,7 @@ jobs: WAVE_RUN_WASM_RUNTIME_TESTS: "1" run: >- cargo test --locked --no-default-features --features llvm-target-wasm - --test stabilization_17 webassembly_ --jobs 2 + --test stabilization_17 --test release_constants webassembly_ --jobs 2 - name: Build and run browser and WASI modules run: | diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..8d07ac06 --- /dev/null +++ b/build.rs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MPL-2.0 +use std::{env, path::Path, process::Command}; + +fn git(args: &[&str]) -> Option { + let result = Command::new("git").args(args).output().ok()?; + result + .status + .success() + .then(|| String::from_utf8_lossy(&result.stdout).trim().to_owned()) +} +fn main() { + println!("cargo:rerun-if-env-changed=WAVE_STD_REVISION"); + // Track both detached HEAD and branch ref changes, including worktrees. + for path in [ + git(&["rev-parse", "--git-path", "HEAD"]), + git(&["rev-parse", "--git-path", "packed-refs"]), + ] + .into_iter() + .flatten() + { + println!("cargo:rerun-if-changed={path}"); + } + if let Some(reference) = git(&["symbolic-ref", "-q", "HEAD"]) { + if let Some(path) = git(&["rev-parse", "--git-path", &reference]) { + println!("cargo:rerun-if-changed={path}"); + } + } + // Source archives can supply the original immutable commit explicitly. + let revision = env::var("WAVE_STD_REVISION") + .ok() + .or_else(|| { + // Do not accidentally record an enclosing, unrelated repository. + let root = git(&["rev-parse", "--show-toplevel"])?; + if Path::new(&root).canonicalize().ok()? + != Path::new(&env::var("CARGO_MANIFEST_DIR").ok()?) + .canonicalize() + .ok()? + { + return None; + } + git(&["rev-parse", "--verify", "HEAD"]) + }) + .unwrap_or_default(); + assert!( + revision.is_empty() + || ((revision.len() == 40 || revision.len() == 64) + && revision.bytes().all(|c| c.is_ascii_hexdigit())), + "WAVE_STD_REVISION must be a full Git commit ID" + ); + if revision.is_empty() { + println!("cargo:warning=No std revision recorded; set WAVE_STD_REVISION when building source archives or use install std --ref"); + } + println!("cargo:rustc-env=WAVE_BUNDLED_STD_REVISION={revision}"); +} diff --git a/front/hir/src/lib.rs b/front/hir/src/lib.rs index 50b9fb71..8d41c4bf 100644 --- a/front/hir/src/lib.rs +++ b/front/hir/src/lib.rs @@ -26,6 +26,7 @@ pub mod async_lower; pub mod conversions; mod numeric_checks; use conversions::{ConversionError, NumericExpressionInfo}; +pub use numeric_checks::{integer_literal_float, ConstantValue}; use parser::ast::visit::{walk_expression, walk_node, walk_nodes}; use parser::ast::{ASTNode, Expression, MatchPattern, StatementNode, WaveType}; @@ -82,6 +83,7 @@ pub struct TypedProgram { expression_types: Vec, expected_types: Vec>, numeric_expressions: Vec>, + constant_values: HashMap, expression_spans: Vec>, variant_constructions: Vec>, pattern_ids: HashMap, @@ -196,6 +198,7 @@ impl TypedProgram { expression_types, expected_types, numeric_expressions: Vec::new(), + constant_values: HashMap::new(), expression_spans, variant_constructions, pattern_ids, @@ -204,15 +207,23 @@ impl TypedProgram { pattern_spans, }; program.numeric_expressions = conversions::build(&program); - if let Err(diagnostic) = numeric_checks::validate(&program) { - return Err(HirLoweringError { - syntax: program.syntax, - diagnostic, - }); - } + program.constant_values = match numeric_checks::validate(&program) { + Ok(values) => values, + Err(diagnostic) => { + return Err(HirLoweringError { + syntax: program.syntax, + diagnostic, + }) + } + }; Ok(program) } + pub fn constant_value_of(&self, expression: &Expression) -> Option<&ConstantValue> { + self.expression_id(expression) + .and_then(|id| self.constant_values.get(&id)) + } + pub fn numeric_expression(&self, id: ExpressionId) -> Option<&NumericExpressionInfo> { self.numeric_expressions .get(id.index()) diff --git a/front/hir/src/numeric_checks.rs b/front/hir/src/numeric_checks.rs index 8c673dfb..a8a619da 100644 --- a/front/hir/src/numeric_checks.rs +++ b/front/hir/src/numeric_checks.rs @@ -5,6 +5,30 @@ use super::{conversions::*, *}; use parser::ast::{FunctionNode, Literal, Mutability, Operator}; use utils::const_int::ConstInt; +/// Fully converted numeric value, independent of backend representation. +#[derive(Clone, Debug)] +pub enum ConstantValue { + Int(ConstInt), + Float(f64), +} + +/// Materialize an integer token at the floating width already selected by HIR. +/// In particular, f32 must round once, without an intermediate f64 conversion. +pub fn integer_literal_float(raw: &str, bits: u16) -> Option { + let parsed = lexer::number::IntegerLiteral::parse(raw)?; + let mut n = ConstInt::from_digits(&parsed.digits, parsed.radix)?; + if n.bits() > 1024 { + return None; + } + if parsed.negative { + n = n.negated(); + } + match Number::Int(n).normalize(&WaveType::Float(bits)) { + Number::Float(n) => Some(n), + _ => None, + } +} + type Scope = HashMap>; #[derive(Clone)] enum Number { @@ -23,6 +47,8 @@ impl Number { } fn normalize(self, ty: &WaveType) -> Self { match (self, ty) { + (Self::Int(n), WaveType::Float(32)) => Self::Float(n.to_f32() as f64), + (Self::Int(n), WaveType::Float(64)) => Self::Float(n.to_f64()), (Self::Int(n), ty) if integer_width(ty).is_some() => { let bits = integer_width(ty).unwrap(); Self::Int(n.normalize(bits, !unsigned(ty))) @@ -35,6 +61,8 @@ impl Number { struct Checker<'a> { program: &'a TypedProgram, globals: Scope, + constants: HashMap, + required: bool, } type Failure = (ExpressionId, String); impl Checker<'_> { @@ -126,7 +154,16 @@ impl Checker<'_> { right, } => { let a = self.eval(left, scope)?; - let b = self.eval(right, scope)?; + let short = match (&a, operator) { + (Some(n), Operator::LogicalAnd) if !n.truth() => Some(false), + (Some(n), Operator::LogicalOr) if n.truth() => Some(true), + _ => None, + }; + let b = if short.is_some() { + None + } else { + self.eval(right, scope)? + }; let Some(fact) = fact else { return Ok(None); }; @@ -140,75 +177,133 @@ impl Checker<'_> { } } } - match (a, b) { - (Some(Number::Int(a)), Some(Number::Int(b))) => { - use Operator::*; - let n = match operator { - Add => a.checked_add(&b), - Subtract => a.checked_sub(&b), - Multiply => a.checked_mul(&b), - Divide if !b.is_zero() => a.div_rem(&b).map(|(q, _)| q), - Remainder if !b.is_zero() => a.div_rem(&b).map(|(_, r)| r), - ShiftLeft => b.to_usize().and_then(|n| a.checked_shl(n)), - ShiftRight => b.to_usize().map(|n| a.shifted_right(n)), - BitwiseAnd => a.bitand(&b), - BitwiseOr => a.bitor(&b), - BitwiseXor => a.bitxor(&b), - Equal => Some(ConstInt::from_u64((a == b) as u64)), - NotEqual => Some(ConstInt::from_u64((a != b) as u64)), - Less => Some(ConstInt::from_u64((a < b) as u64)), - LessEqual => Some(ConstInt::from_u64((a <= b) as u64)), - Greater => Some(ConstInt::from_u64((a > b) as u64)), - GreaterEqual => Some(ConstInt::from_u64((a >= b) as u64)), - LogicalAnd => { - Some(ConstInt::from_u64((!a.is_zero() && !b.is_zero()) as u64)) + if let Some(value) = short { + Some(Number::Int(ConstInt::from_u64(value as u64))) + } else { + match (a, b) { + (Some(Number::Int(a)), Some(Number::Int(b))) => { + use Operator::*; + if self.required && matches!(operator, Divide | Remainder) { + if b.is_zero() { + return Err(self.fail( + right, + "division or remainder by zero in constant expression", + )); + } + if matches!(operator, Divide) { + let ty = fact.computation_type.as_ref().unwrap(); + let width = integer_width(ty).unwrap(); + let min = ConstInt::from_u64(1) + .checked_shl(width as usize - 1) + .unwrap() + .negated(); + if !unsigned(ty) + && a == min + && b == ConstInt::from_u64(1).negated() + { + return Err(self.fail( + expr, + "signed division overflows in constant expression", + )); + } + } } - LogicalOr => { - Some(ConstInt::from_u64((!a.is_zero() || !b.is_zero()) as u64)) + let n = match operator { + Add => a.checked_add(&b), + Subtract => a.checked_sub(&b), + Multiply => a.checked_mul(&b), + Divide if !b.is_zero() => a.div_rem(&b).map(|(q, _)| q), + Remainder if !b.is_zero() => a.div_rem(&b).map(|(_, r)| r), + ShiftLeft => b.to_usize().and_then(|n| a.checked_shl(n)), + ShiftRight => b.to_usize().map(|n| a.shifted_right(n)), + BitwiseAnd => a.bitand(&b), + BitwiseOr => a.bitor(&b), + BitwiseXor => a.bitxor(&b), + Equal => Some(ConstInt::from_u64((a == b) as u64)), + NotEqual => Some(ConstInt::from_u64((a != b) as u64)), + Less => Some(ConstInt::from_u64((a < b) as u64)), + LessEqual => Some(ConstInt::from_u64((a <= b) as u64)), + Greater => Some(ConstInt::from_u64((a > b) as u64)), + GreaterEqual => Some(ConstInt::from_u64((a >= b) as u64)), + LogicalAnd => { + Some(ConstInt::from_u64((!a.is_zero() && !b.is_zero()) as u64)) + } + LogicalOr => { + Some(ConstInt::from_u64((!a.is_zero() || !b.is_zero()) as u64)) + } + _ => None, + }; + n.map(Number::Int) + } + (Some(Number::Float(a)), Some(Number::Float(b))) => { + use Operator::*; + let single = fact.computation_type == Some(WaveType::Float(32)); + let result = match operator { + Add => Some(if single { + ((a as f32) + (b as f32)) as f64 + } else { + a + b + }), + Subtract => Some(if single { + ((a as f32) - (b as f32)) as f64 + } else { + a - b + }), + Multiply => Some(if single { + ((a as f32) * (b as f32)) as f64 + } else { + a * b + }), + Divide => Some(if single { + ((a as f32) / (b as f32)) as f64 + } else { + a / b + }), + Remainder => Some(if single { + ((a as f32) % (b as f32)) as f64 + } else { + a % b + }), + _ => None, + }; + match operator { + Equal => Some(Number::Int(ConstInt::from_u64((a == b) as u64))), + NotEqual => Some(Number::Int(ConstInt::from_u64((a != b) as u64))), + Less => Some(Number::Int(ConstInt::from_u64((a < b) as u64))), + LessEqual => Some(Number::Int(ConstInt::from_u64((a <= b) as u64))), + Greater => Some(Number::Int(ConstInt::from_u64((a > b) as u64))), + GreaterEqual => { + Some(Number::Int(ConstInt::from_u64((a >= b) as u64))) + } + _ => result.map(Number::Float), } - _ => None, - }; - n.map(Number::Int) - } - (Some(Number::Float(a)), Some(Number::Float(b))) => { - use Operator::*; - let single = fact.computation_type == Some(WaveType::Float(32)); - let result = match operator { - Add => Some(if single { - ((a as f32) + (b as f32)) as f64 - } else { - a + b - }), - Subtract => Some(if single { - ((a as f32) - (b as f32)) as f64 - } else { - a - b - }), - Multiply => Some(if single { - ((a as f32) * (b as f32)) as f64 - } else { - a * b - }), - Divide => Some(if single { - ((a as f32) / (b as f32)) as f64 - } else { - a / b - }), - Remainder => Some(if single { - ((a as f32) % (b as f32)) as f64 - } else { - a % b - }), - _ => None, - }; - result.map(Number::Float) + } + _ => None, } - _ => None, } } - _ => None, + _ => { + let mut result = Ok(()); + parser::ast::visit::walk_expression_children(expr, &mut |child| { + if result.is_ok() { + result = self.inspect(child, scope); + } + }); + result?; + None + } }; let Some(mut value) = value else { + if self.required + && fact.is_some_and(|f| { + matches!( + f.result_type, + WaveType::Int(_) | WaveType::Uint(_) | WaveType::Float(_) | WaveType::Bool + ) + }) + { + return Err(self.fail(expr, "unsupported numeric constant expression")); + } return Ok(None); }; let Some(fact) = fact else { @@ -235,13 +330,54 @@ impl Checker<'_> { _ => value, }.normalize(&step.target_type); } + if self.required { + let constant = match &value { + Number::Int(n) => Some(ConstantValue::Int(n.clone())), + Number::Float(n) => Some(ConstantValue::Float(*n)), + _ => None, + }; + if let Some(constant) = constant { + self.constants + .insert(self.program.expression_id(expr).unwrap(), constant); + } + } Ok(Some(value)) } fn inspect(&mut self, expr: &Expression, scope: &Scope) -> Result<(), Failure> { + self.eval(expr, scope).map(|_| ()) + } + // Syntactic eligibility is checked even in a dead logical operand; only + // evaluation errors are suppressed by short-circuiting. + fn require_constant(&self, expr: &Expression) -> Result<(), Failure> { + let allowed = match expr { + Expression::Literal(_) + | Expression::Null + | Expression::ArrayLiteral(_) + | Expression::StructLiteral { .. } + | Expression::Grouped(_) + | Expression::Cast { .. } => true, + Expression::BinaryExpression { .. } => self + .program + .numeric_expression_of(expr) + .is_some_and(|fact| fact.computation_type.is_some()), + Expression::Unary { operator, .. } => matches!( + operator, + Operator::Neg | Operator::BitwiseNot | Operator::Not | Operator::LogicalNot + ), + Expression::Variable(name) => { + self.globals.contains_key(name) + || self.program.variant_construction_of(expr).is_some() + } + Expression::FunctionCall { .. } => self.program.variant_construction_of(expr).is_some(), + _ => false, + }; + if !allowed { + return Err(self.fail(expr, "unsupported constant expression: calls and memory access require runtime evaluation")); + } let mut result = Ok(()); - super::walk_expression(expr, &mut |inner| { + parser::ast::visit::walk_expression_children(expr, &mut |child| { if result.is_ok() { - result = self.eval(inner, scope).map(|_| ()); + result = self.require_constant(child); } }); result @@ -334,9 +470,14 @@ impl Checker<'_> { } _ => { let mut result = Ok(()); + let mut visited = HashSet::new(); super::walk_node(node, &mut |expr| { - if result.is_ok() { - result = self.eval(expr, scope).map(|_| ()); + let id = self.program.expression_id(expr).unwrap(); + if result.is_ok() && !visited.contains(&id) { + result = self.inspect(expr, scope); + super::walk_expression(expr, &mut |child| { + visited.insert(self.program.expression_id(child).unwrap()); + }); } }); result?; @@ -345,7 +486,9 @@ impl Checker<'_> { Ok(()) } } -pub(super) fn validate(program: &TypedProgram) -> Result<(), SemanticDiagnostic> { +pub(super) fn validate( + program: &TypedProgram, +) -> Result, SemanticDiagnostic> { let diagnostic = |index, (id, message): Failure| SemanticDiagnostic { code: "E3001".into(), message: message.clone(), @@ -354,8 +497,7 @@ pub(super) fn validate(program: &TypedProgram) -> Result<(), SemanticDiagnostic> span: program.expression_span(id).cloned(), label: message, note: None, - help: "use a valid shift count or a finite value within the destination integer range" - .into(), + help: "use a supported constant expression with valid numeric operations".into(), }; let definitions: HashMap<_, _> = program .syntax() @@ -371,6 +513,8 @@ pub(super) fn validate(program: &TypedProgram) -> Result<(), SemanticDiagnostic> let mut checker = Checker { program, globals: Scope::new(), + constants: HashMap::new(), + required: true, }; for node in program.syntax() { if let ASTNode::Enum(e) = node { @@ -402,6 +546,9 @@ pub(super) fn validate(program: &TypedProgram) -> Result<(), SemanticDiagnostic> while let Some((name, ready)) = stack.pop() { let &(index, expr) = &definitions[&name]; if ready { + checker + .require_constant(expr) + .map_err(|e| diagnostic(index, e))?; let value = checker .eval(expr, &Scope::new()) .map_err(|e| diagnostic(index, e))?; @@ -418,10 +565,25 @@ pub(super) fn validate(program: &TypedProgram) -> Result<(), SemanticDiagnostic> } } } + for (index, node) in program.syntax().iter().enumerate() { + if let ASTNode::Variable(v) = node { + if v.mutability == Mutability::Static { + if let Some(expr) = &v.initial_value { + checker + .require_constant(expr) + .map_err(|e| diagnostic(index, e))?; + checker + .eval(expr, &Scope::new()) + .map_err(|e| diagnostic(index, e))?; + } + } + } + } + checker.required = false; for (index, node) in program.syntax().iter().enumerate() { checker .node(node, &mut Scope::new()) .map_err(|e| diagnostic(index, e))?; } - Ok(()) + Ok(checker.constants) } diff --git a/front/parser/src/ast/visit.rs b/front/parser/src/ast/visit.rs index aa507653..3ad3ff84 100644 --- a/front/parser/src/ast/visit.rs +++ b/front/parser/src/ast/visit.rs @@ -140,22 +140,27 @@ pub fn walk_expression(expression: &Expression, visit: &mut impl FnMut(&Expressi return; } visit(expression); + walk_expression_children(expression, &mut |child| walk_expression(child, visit)); +} + +/// Visit immediate children, allowing semantic visitors to control evaluation order. +pub fn walk_expression_children(expression: &Expression, visit: &mut impl FnMut(&Expression)) { match expression { - Expression::Located { value, .. } => walk_expression(value, visit), + Expression::Located { value, .. } => visit(value), Expression::StructLiteral { fields, .. } => { for (_, value) in fields { - walk_expression(value, visit); + visit(value); } } Expression::FunctionCall { args, .. } => { for argument in args { - walk_expression(argument, visit); + visit(argument); } } Expression::MethodCall { object, args, .. } => { - walk_expression(object, visit); + visit(object); for argument in args { - walk_expression(argument, visit); + visit(argument); } } Expression::Deref(inner) @@ -165,7 +170,7 @@ pub fn walk_expression(expression: &Expression, visit: &mut impl FnMut(&Expressi | Expression::Unary { expr: inner, .. } | Expression::Cast { expr: inner, .. } | Expression::FieldAccess { object: inner, .. } - | Expression::IncDec { target: inner, .. } => walk_expression(inner, visit), + | Expression::IncDec { target: inner, .. } => visit(inner), Expression::BinaryExpression { left, right, .. } | Expression::IndexAccess { target: left, @@ -180,19 +185,19 @@ pub fn walk_expression(expression: &Expression, visit: &mut impl FnMut(&Expressi target: left, value: right, } => { - walk_expression(left, visit); - walk_expression(right, visit); + visit(left); + visit(right); } Expression::ArrayLiteral(values) => { for value in values { - walk_expression(value, visit); + visit(value); } } Expression::AsmBlock { inputs, outputs, .. } => { for (_, expression) in inputs.iter().chain(outputs.iter()) { - walk_expression(expression, visit); + visit(expression); } } Expression::Null | Expression::Literal(_) | Expression::Variable(_) => {} diff --git a/llvm/src/codegen/consts.rs b/llvm/src/codegen/consts.rs index b87efeee..a48e16b8 100644 --- a/llvm/src/codegen/consts.rs +++ b/llvm/src/codegen/consts.rs @@ -95,6 +95,23 @@ fn const_from_expected<'ctx>( program: &TypedProgram, target_data: &TargetData, ) -> Result, ConstEvalError> { + if let Some(value) = program.constant_value_of(expr) { + match (value, expected) { + (hir::ConstantValue::Int(n), BasicTypeEnum::IntType(ty)) => { + return Ok(ty + .const_int_arbitrary_precision(&n.to_le_words(ty.get_bit_width() as u16)) + .into()); + } + (hir::ConstantValue::Float(n), BasicTypeEnum::FloatType(ty)) => { + return Ok(ty.const_float(*n).into()); + } + _ => { + return Err(ConstEvalError::Unsupported( + "ICE: folded constant differs from HIR storage type".into(), + )) + } + } + } let Some(fact) = program.numeric_expression_of(expr) else { return const_raw( context, @@ -370,8 +387,12 @@ fn const_raw<'ctx>( // --- ints --- Expression::Literal(Literal::Int(s)) => match expected { BasicTypeEnum::FloatType(float_ty) => { - let value = lexer::number::IntegerLiteral::parse(s) - .and_then(|value| value.to_f64()) + let bits = if float_ty == context.f32_type() { + 32 + } else { + 64 + }; + let value = hir::integer_literal_float(s, bits) .ok_or_else(|| ConstEvalError::InvalidLiteral(s.clone()))?; Ok(float_ty.const_float(value).as_basic_value_enum()) } diff --git a/llvm/src/expression/rvalue/literals.rs b/llvm/src/expression/rvalue/literals.rs index 6346ddae..ba9589d7 100644 --- a/llvm/src/expression/rvalue/literals.rs +++ b/llvm/src/expression/rvalue/literals.rs @@ -22,10 +22,6 @@ use inkwell::values::{BasicValue, BasicValueEnum}; use inkwell::AddressSpace; use parser::ast::Literal; -fn parse_int_as_f64(s: &str) -> Option { - lexer::number::IntegerLiteral::parse(s)?.to_f64() -} - fn is_zero_int_literal(s: &str) -> bool { lexer::number::IntegerLiteral::parse(s).is_some_and(|n| n.is_zero()) } @@ -87,7 +83,8 @@ pub(crate) fn gen<'ctx, 'a>( } Some(BasicTypeEnum::FloatType(ft)) => { - let f = parse_int_as_f64(v) + let bits = if ft == env.context.f32_type() { 32 } else { 64 }; + let f = hir::integer_literal_float(v, bits) .unwrap_or_else(|| panic!("invalid float literal from int token: {}", v)); ft.const_float(f).as_basic_value_enum() } diff --git a/src/cli.rs b/src/cli.rs index 5844c59a..0e43e80f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -47,8 +47,8 @@ enum CliCommand { target: Option, format: PrintFormat, }, - StdInstall, - StdUpdate, + StdInstall(Option), + StdUpdate(Option), Help, Version, } @@ -298,8 +298,12 @@ fn dispatch(mut global: Global, cmd: CliCommand) -> Result<(), CliError> { target, format, } => dispatch_print(&global, &item, target.as_deref(), format), - CliCommand::StdInstall => wave_std::std_install(), - CliCommand::StdUpdate => wave_std::std_update(), + CliCommand::StdInstall(reference) => { + wave_std::std_install_with_reference(reference.as_deref()) + } + CliCommand::StdUpdate(reference) => { + wave_std::std_update_with_reference(reference.as_deref()) + } } } @@ -389,7 +393,7 @@ fn dispatch_build(global: &Global, build: &BuildRequest) -> Result<(), CliError> })?; if !status.success() { - process::exit(status.code().unwrap_or(1)); + process::exit(crate::runner::child_exit_code(status)); } } } @@ -1410,44 +1414,40 @@ fn parse_print(args: &[String]) -> Result { }) } -fn parse_install(args: &[String]) -> Result { - let target = args - .first() - .ok_or_else(|| CliError::usage("usage: wavec install "))?; - if args.len() > 1 { - return Err(CliError::usage(format!( - "unexpected extra argument: {}", - args[1] - ))); +fn parse_std_reference(args: &[String]) -> Result, CliError> { + if args.first().map(String::as_str) != Some("std") { + return Err(CliError::usage( + "usage: wavec install|update std [--ref ]", + )); } - - match target.as_str() { - "std" => Ok(CliCommand::StdInstall), - _ => Err(CliError::usage(format!( - "unknown install target: {}", - target - ))), + let reference = match &args[1..] { + [] => return Ok(None), + [flag, value] if flag == "--ref" => value.as_str(), + [value] if value.starts_with("--ref=") => &value[6..], + _ => { + return Err(CliError::usage( + "usage: wavec install|update std [--ref ]", + )) + } + }; + if reference.is_empty() + || reference.starts_with('-') + || reference.contains(':') + || reference.chars().any(char::is_whitespace) + { + return Err(CliError::usage( + "invalid std reference: expected a Git commit, branch or tag", + )); } + Ok(Some(reference.to_owned())) } -fn parse_update(args: &[String]) -> Result { - let target = args - .first() - .ok_or_else(|| CliError::usage("usage: wavec update "))?; - if args.len() > 1 { - return Err(CliError::usage(format!( - "unexpected extra argument: {}", - args[1] - ))); - } +fn parse_install(args: &[String]) -> Result { + Ok(CliCommand::StdInstall(parse_std_reference(args)?)) +} - match target.as_str() { - "std" => Ok(CliCommand::StdUpdate), - _ => Err(CliError::usage(format!( - "unknown update target: {}", - target - ))), - } +fn parse_update(args: &[String]) -> Result { + Ok(CliCommand::StdUpdate(parse_std_reference(args)?)) } fn parse_input_kind(v: &str) -> Result { @@ -4690,12 +4690,12 @@ pub fn print_help() { println!( " {:<22} {}", "install std".color("38,139,235"), - "Install Wave standard library" + "Install compiler-pinned std; --ref overrides with compatibility validation" ); println!( " {:<22} {}", "update std".color("38,139,235"), - "Update Wave standard library" + "Restore compiler-pinned std; --ref selects a compatible revision" ); println!( " {:<22} {}", @@ -4960,6 +4960,31 @@ mod tests { } #[cfg(feature = "llvm-target-riscv")] + #[test] + fn std_commands_accept_only_one_explicit_reference() { + let args = |items: &[&str]| items.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(parse_std_reference(&args(&["std"])).unwrap(), None); + for input in [ + vec!["std", "--ref", "release"], + vec!["std", "--ref=release"], + ] { + assert_eq!( + parse_std_reference(&args(&input)).unwrap().as_deref(), + Some("release") + ); + } + for input in [ + vec![], + vec!["std", "--ref"], + vec!["std", "--ref="], + vec!["std", "--ref", "--all"], + vec!["std", "--ref=a", "--ref=b"], + vec!["other"], + ] { + assert!(parse_std_reference(&args(&input)).is_err()); + } + } + #[test] fn riscv64_sysroot_selection_skips_incomplete_and_foreign_runtimes() { let incomplete = temp_sysroot("incomplete"); diff --git a/src/runner.rs b/src/runner.rs index db391c98..2d42555e 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -34,6 +34,20 @@ use llvm::codegen::target::target_spec_for_triple; use llvm::codegen::*; use std::path::{Path, PathBuf}; use std::process::Stdio; + +pub(crate) fn child_exit_code(status: std::process::ExitStatus) -> i32 { + if let Some(code) = status.code() { + return code; + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return 128 + signal; + } + } + 1 +} use std::sync::{Arc, Mutex}; use std::{fs, process, process::Command}; @@ -901,7 +915,7 @@ pub(crate) unsafe fn run_wave_file( }); if !status.success() { - process::exit(status.code().unwrap_or(1)); + process::exit(child_exit_code(status)); } } diff --git a/src/std.rs b/src/std.rs index 14dad069..6111d96b 100644 --- a/src/std.rs +++ b/src/std.rs @@ -23,23 +23,71 @@ use std::time::{SystemTime, UNIX_EPOCH}; use std::{env, fs}; const STD_REPOSITORY: &str = "https://github.com/wavefnd/Wave.git"; -const STD_REFERENCE: &str = "master"; +pub const BUNDLED_STD_REVISION: &str = env!("WAVE_BUNDLED_STD_REVISION"); pub fn std_install() -> Result<(), CliError> { - install_or_update_std(false) + install_or_update_std(false, None) } pub fn std_update() -> Result<(), CliError> { - install_or_update_std(true) + install_or_update_std(true, None) } -fn install_or_update_std(is_update: bool) -> Result<(), CliError> { +pub fn std_install_with_reference(reference: Option<&str>) -> Result<(), CliError> { + install_or_update_std(false, reference) +} + +pub fn std_update_with_reference(reference: Option<&str>) -> Result<(), CliError> { + install_or_update_std(true, reference) +} + +fn resolve_std_reference(reference: Option<&str>) -> Result<&str, CliError> { + select_std_reference(reference, BUNDLED_STD_REVISION) +} + +fn select_std_reference<'a>( + reference: Option<&'a str>, + bundled_revision: &'a str, +) -> Result<&'a str, CliError> { + let value = reference.unwrap_or(bundled_revision); + if value.is_empty() { + return Err(CliError::usage( + "this compiler has no pinned std revision; pass --ref ", + )); + } + if value.starts_with('-') || value.contains(':') || value.chars().any(char::is_whitespace) { + return Err(CliError::usage( + "invalid std reference: expected a Git commit, branch or tag", + )); + } + Ok(value) +} + +fn install_or_update_std(is_update: bool, reference: Option<&str>) -> Result<(), CliError> { let install_dir = resolve_std_install_dir()?; if install_dir.exists() && !is_update { return Err(CliError::StdAlreadyInstalled { path: install_dir }); } + let reference = resolve_std_reference(reference)?; + install_from_repository(STD_REPOSITORY, reference, &install_dir, validate_staged_std)?; + + if is_update { + println!("✅ std updated: {}", install_dir.display()); + } else { + println!("✅ std installed: {}", install_dir.display()); + } + + Ok(()) +} + +fn install_from_repository( + repository: &str, + reference: &str, + install_dir: &Path, + validate: impl FnOnce(&Path, &Path) -> Result<(), CliError>, +) -> Result<(), CliError> { let install_parent = install_dir.parent().ok_or_else(|| { CliError::CommandFailed(format!( "std installation path '{}' has no parent", @@ -55,7 +103,8 @@ fn install_or_update_std(is_update: bool) -> Result<(), CliError> { let stage_std = stage_home.join(".wave/lib/wave/std"); let result = (|| { - let (src_std, source_revision) = fetch_std_from_wave_repo_sparse(&checkout)?; + let (src_std, source_revision) = + fetch_std_from_wave_repo_sparse(&checkout, repository, reference)?; validate_std_manifest(&src_std)?; copy_dir_all(&src_std, &stage_std)?; @@ -63,58 +112,65 @@ fn install_or_update_std(is_update: bool) -> Result<(), CliError> { stage_std.join("INSTALL_META"), format!( "repo={}\nref={}\nrevision={}\ncompatibility_revision={}\n", - STD_REPOSITORY, - STD_REFERENCE, + repository, + reference, source_revision, parser::import::STD_COMPATIBILITY_REVISION ), )?; - validate_staged_std(&stage_home, &stage_std)?; - replace_std_tree(&stage_std, &install_dir) + validate(&stage_home, &stage_std)?; + replace_std_tree(&stage_std, install_dir) })(); let _ = fs::remove_dir_all(&checkout); let _ = fs::remove_dir_all(&stage_home); - result?; - - if is_update { - println!("✅ std updated: {}", install_dir.display()); - } else { - println!("✅ std installed: {}", install_dir.display()); - } - - Ok(()) + result } -fn fetch_std_from_wave_repo_sparse(checkout: &Path) -> Result<(PathBuf, String), CliError> { +fn fetch_std_from_wave_repo_sparse( + checkout: &Path, + repository: &str, + reference: &str, +) -> Result<(PathBuf, String), CliError> { if !tool_exists("git") { return Err(CliError::ExternalToolMissing("git".to_string())); } + // Fetch the requested ref/commit directly; --branch cannot name a pinned + // commit. Never fetch a moving master as a fallback for a missing pin. + run_cmd(Command::new("git").arg("init").arg(checkout), "git init")?; run_cmd( Command::new("git") - .arg("clone") - .arg("--depth") - .arg("1") - .arg("--filter=blob:none") - .arg("--sparse") - .arg("--branch") - .arg(STD_REFERENCE) - .arg(STD_REPOSITORY) - .arg(checkout), - "git clone", + .arg("-C") + .arg(checkout) + .args(["remote", "add", "origin", repository]), + "git remote add", )?; - run_cmd( Command::new("git") .arg("-C") .arg(checkout) - .arg("sparse-checkout") - .arg("set") - .arg("std"), + .args(["sparse-checkout", "set", "std"]), "git sparse-checkout set std", )?; + run_cmd( + Command::new("git").arg("-C").arg(checkout).args([ + "fetch", + "--depth=1", + "--filter=blob:none", + "origin", + reference, + ]), + "git fetch std reference", + )?; + run_cmd( + Command::new("git") + .arg("-C") + .arg(checkout) + .args(["checkout", "--detach", "FETCH_HEAD"]), + "git checkout std revision", + )?; let source_revision = run_cmd_stdout( Command::new("git") @@ -157,6 +213,8 @@ fn validate_staged_std(stage_home: &Path, stage_std: &Path) -> Result<(), CliErr for source in sources { let output = Command::new(&compiler) .env("HOME", stage_home) + .arg("--std-root") + .arg(stage_std) .arg("check") .arg(&source) .output()?; @@ -305,6 +363,117 @@ fn make_tmp_dir_in(parent: &Path, prefix: &str) -> Result { mod tests { use super::*; + #[test] + fn pinned_fetch_and_failed_updates_preserve_the_installed_tree() { + let root = make_tmp_dir("wave-std-pinned").unwrap(); + let repository = root.join("repo"); + fs::create_dir_all(repository.join("std")).unwrap(); + let git = |args: &[&str]| { + let out = Command::new("git") + .current_dir(&repository) + .args([ + "-c", + "user.name=Wave tests", + "-c", + "user.email=tests@example.invalid", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + String::from_utf8(out.stdout).unwrap().trim().to_owned() + }; + git(&["init"]); + let manifest = repository.join("std/manifest.json"); + fs::write( + &manifest, + format!( + r#"{{"name":"std","format":1,"compatibility_revision":{}}}"#, + parser::import::STD_COMPATIBILITY_REVISION + ), + ) + .unwrap(); + fs::write(repository.join("std/version.wave"), "old").unwrap(); + git(&["add", "."]); + git(&["commit", "-m", "compatible"]); + let pinned = git(&["rev-parse", "HEAD"]); + git(&["tag", "compatible"]); + fs::write( + &manifest, + r#"{"name":"std","format":1,"compatibility_revision":999999}"#, + ) + .unwrap(); + fs::write(repository.join("std/version.wave"), "new").unwrap(); + git(&["add", "."]); + git(&["commit", "-m", "incompatible"]); + let installed = root.join("install/std"); + let repo = repository.to_str().unwrap(); + install_from_repository(repo, &pinned, &installed, |_, _| Ok(())).unwrap(); + assert_eq!( + fs::read_to_string(installed.join("version.wave")).unwrap(), + "old" + ); + assert!(fs::read_to_string(installed.join("INSTALL_META")) + .unwrap() + .contains(&format!("revision={pinned}"))); + for reference in ["HEAD", "missing-reference"] { + assert!(install_from_repository(repo, reference, &installed, |_, _| Ok(())).is_err()); + assert_eq!( + fs::read_to_string(installed.join("version.wave")).unwrap(), + "old" + ); + } + assert!( + install_from_repository(repo, "compatible", &installed, |_, _| Err( + CliError::CommandFailed("validation failed".into()) + )) + .is_err() + ); + assert!(install_from_repository( + root.join("missing-repository").to_str().unwrap(), + &pinned, + &installed, + |_, _| Ok(()) + ) + .is_err()); + assert_eq!( + fs::read_to_string(installed.join("version.wave")).unwrap(), + "old" + ); + assert_eq!( + fs::read_dir(installed.parent().unwrap()).unwrap().count(), + 1 + ); + install_from_repository(repo, "compatible", &installed, |_, stage| { + assert_eq!(fs::read_to_string(stage.join("version.wave"))?, "old"); + Ok(()) + }) + .unwrap(); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn std_reference_defaults_to_the_recorded_immutable_revision() { + // Exercise both Git checkouts and source archives in every test run, + // rather than branching on a build-time constant. + let revision = "0123456789abcdef0123456789abcdef01234567"; + assert_eq!(select_std_reference(None, revision).unwrap(), revision); + assert!(select_std_reference(None, "").is_err()); + assert_eq!( + select_std_reference(Some("release-tag"), "").unwrap(), + "release-tag" + ); + assert_eq!( + resolve_std_reference(Some("release-tag")).unwrap(), + "release-tag" + ); + for value in ["", "--all", "head:local", "two refs"] { + assert!(resolve_std_reference(Some(value)).is_err()); + } + } + #[test] fn successful_replacement_exposes_only_the_staged_tree() { let root = make_tmp_dir("wave-std-replace-success").unwrap(); diff --git a/std/sys/wasm64/memory.wave b/std/sys/wasm64/memory.wave index e7a5eaba..24989621 100644 --- a/std/sys/wasm64/memory.wave +++ b/std/sys/wasm64/memory.wave @@ -96,7 +96,7 @@ pub fun sys_free(p: ptr, size: i64) -> i64 { pub fun mmap( addr: ptr, length: i64, prot: i32, flags: i32, fd: i64, offset: i64 ) -> ptr { - if (addr != null || fd != -1 || offset != 0) { + if (addr != null || length <= 0 || prot != (PROT_READ | PROT_WRITE) || flags != (MAP_PRIVATE | MAP_ANONYMOUS) || fd != -1 || offset != 0) { return null; } return sys_alloc(length) as ptr; diff --git a/std/sys/windows/env.wave b/std/sys/windows/env.wave index b85f499f..62ca358d 100644 --- a/std/sys/windows/env.wave +++ b/std/sys/windows/env.wave @@ -3,34 +3,41 @@ // Copyright (c) 2024-2026 LunaStev and contributors // SPDX-License-Identifier: Apache-2.0 -// Copies the native double-NUL-terminated environment block into caller-owned -// storage, preserving the format consumed by std::env. +// Preserve every entry (including =C: drive entries) and both final NULs. +import("std::sys::windows::fs_errors")::{windows_fs_error}; +extern(system, "GetEnvironmentStringsW") fun win_get_environment_strings() -> ptr; +extern(system, "FreeEnvironmentStringsW") fun win_free_environment_strings(block: ptr) -> i32; +extern(system, "GetLastError") fun env_last_error() -> u32; +extern(system, "WideCharToMultiByte") fun env_to_utf8( + code_page: u32, flags: u32, source: ptr, units: i32, + destination: ptr, capacity: i32, default_char: ptr, used_default: ptr +) -> i32; -extern(system, "GetEnvironmentStringsA") fun win_get_environment_strings() -> ptr; -extern(system, "FreeEnvironmentStringsA") fun win_free_environment_strings(block: ptr) -> i32; - -pub fun env_read(buf: ptr, cap: i64) -> i64 { - if (buf == null || cap <= 0) { - return -22; +fun release_environment(block: ptr, status: i64) -> i64 { + if (win_free_environment_strings(block) == 0 && status >= 0) { + return windows_fs_error(env_last_error()); } + return status; +} - var block: ptr = win_get_environment_strings(); - if (block == null) { - return -1; - } +pub fun env_read(buf: ptr, cap: i64) -> i64 { + if (buf == null || cap <= 0) { return -22; } + var block: ptr = win_get_environment_strings(); + if (block == null) { return windows_fs_error(env_last_error()); } - var i: i64 = 0; - while (i + 1 < cap) { - var next: i64 = i + 1; - buf[i] = block[i]; - if (block[i] == 0 && block[next] == 0) { - buf[next] = 0; - win_free_environment_strings(block); - return i + 2; - } - i += 1; + var units: i64 = 0; + while (units < 2147483646) { + if (block[units] == 0 && block[units + 1] == 0) { break; } + units += 1; } - - win_free_environment_strings(block); - return -28; + if (units >= 2147483646) { return release_environment(block, -75); } + units += 2; + // A positive explicit length converts embedded NULs too. Query the byte + // count before writing, so insufficient capacity leaves the caller intact. + var needed: i32 = env_to_utf8(65001, 128, block, units as i32, null, 0, null, null); + if (needed == 0) { return release_environment(block, windows_fs_error(env_last_error())); } + if (needed as i64 > cap) { return release_environment(block, -28); } + var written: i32 = env_to_utf8(65001, 128, block, units as i32, buf, needed, null, null); + if (written == 0) { return release_environment(block, windows_fs_error(env_last_error())); } + return release_environment(block, written as i64); } diff --git a/std/sys/windows/fs.wave b/std/sys/windows/fs.wave index 261b51ac..f1a8502a 100644 --- a/std/sys/windows/fs.wave +++ b/std/sys/windows/fs.wave @@ -282,11 +282,30 @@ pub fun chdir(path: str) -> i64 { } pub fun access(path: str, mode: i32) -> i64 { + if ((mode & ~7) != 0) { return -22; } + if ((mode & FS_X_OK) != 0) { return -95; } var native: WidePath = wide_path(path); if (native.error != 0) { return native.error; } - if (win_get_file_attributes(native.data) == INVALID_FILE_ATTRIBUTES) { + var attributes: u32 = win_get_file_attributes(native.data); + if (attributes == INVALID_FILE_ATTRIBUTES) { + return release_path(native, windows_fs_error(win_file_last_error())); + } + if (mode == FS_F_OK) { return release_path(native, 0); } + // Readonly applies to file contents, not the directory attribute hint. + if ((mode & FS_W_OK) != 0 && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 && (attributes & 1) != 0) { + return release_path(native, -13); + } + var rights: u32 = 0; + if ((mode & FS_R_OK) != 0) { rights = rights | GENERIC_READ; } + if ((mode & FS_W_OK) != 0) { rights = rights | GENERIC_WRITE; } + // OPEN_EXISTING never creates or truncates; BACKUP_SEMANTICS permits directories. + var handle: ptr = win_create_file(native.data, rights, FILE_SHARE_ALL, null, OPEN_EXISTING, 33554432, null); + if (handle == (-1 as ptr)) { + return release_path(native, windows_fs_error(win_file_last_error())); + } + if (win_close_handle(handle) == 0) { return release_path(native, windows_fs_error(win_file_last_error())); } return release_path(native, 0); diff --git a/tests/fixtures/native_boundaries/windows.wave b/tests/fixtures/native_boundaries/windows.wave index 4ec1837d..174070af 100644 --- a/tests/fixtures/native_boundaries/windows.wave +++ b/tests/fixtures/native_boundaries/windows.wave @@ -1,12 +1,13 @@ // SPDX-License-Identifier: MPL-2.0 import("std::sys::windows::fs")::{ - Stat, open, close, write, read, stat, fstat, lseek, dup, unlink, mkdir, rmdir, + Stat, access, open, close, write, read, stat, fstat, lseek, dup, unlink, mkdir, rmdir, FS_O_CREAT, FS_O_EXCL, FS_O_TRUNC, FS_O_APPEND, FS_O_RDWR, FS_O_WRONLY, FS_O_RDONLY }; import("std::sys::windows::memory")::{mmap, munmap, sys_alloc, sys_free, PROT_READ, PROT_WRITE, MAP_PRIVATE, MAP_ANONYMOUS}; extern(system, "CreateFileW") fun directory_handle(name: ptr, access: u32, share: u32, security: ptr, creation: u32, flags: u32, template: ptr) -> ptr; +extern(system, "SetFileAttributesW") fun set_attributes(path: ptr, attributes: u32) -> i32; extern(system, "VirtualQuery") fun query_memory(address: ptr, info: ptr, length: u64) -> u64; struct MemoryInfo { base: ptr; allocation_base: ptr; allocation_protect: u32; @@ -20,6 +21,11 @@ fun main() -> i32 { var bytes: array = [10, 20, 30, 40]; var fd: i64 = open("boundary.bin", FS_O_CREAT | FS_O_EXCL | FS_O_RDWR, 0); if (fd < 0 || write(fd, &bytes[0], 2) != 2 || close(fd) != 0) { return 1; } + if (access("boundary.bin", 0) != 0 || access("boundary.bin", 6) != 0 || access("missing.bin", 0) != -2 + || access("boundary.bin", 1) != -95 || access("boundary.bin", 8) != -22) { return 27; } + var filename: array = [98,111,117,110,100,97,114,121,46,98,105,110,0]; + if (set_attributes(&filename[0], 1) == 0 || access("boundary.bin", 2) != -13 + || access("boundary.bin", 4) != 0 || set_attributes(&filename[0], 128) == 0) { return 28; } var first: i64 = open("boundary.bin", FS_O_WRONLY | FS_O_APPEND, 0); var second: i64 = open("boundary.bin", FS_O_RDWR | FS_O_APPEND, 0); if (first < 0 || second < 0) { return 2; } diff --git a/tests/fixtures/native_boundaries/windows_mock.c b/tests/fixtures/native_boundaries/windows_mock.c index 18fb6ec5..51210697 100644 --- a/tests/fixtures/native_boundaries/windows_mock.c +++ b/tests/fixtures/native_boundaries/windows_mock.c @@ -17,6 +17,11 @@ void *CreateFileW(const uint16_t *name, uint32_t access, uint32_t share, if (scenario == 0 && (access != 0x40000000 || creation != 5)) failed++; if (scenario == 1 && ((access & (0x40000000 | 2)) || !(access & 4) || creation != 3)) failed++; if (scenario >= 2 && scenario <= 4 && (!(access & 0x40000000) || creation != 3)) failed++; + if (scenario >= 20) { + uint32_t wanted = scenario == 20 ? 0x80000000 : scenario == 21 ? 0x40000000 : 0xc0000000; + if (access != wanted || creation != 3 || attrs != 33554432) failed++; + if (scenario == 24) return (void *)(intptr_t)-1; + } return (void *)(uintptr_t)4096; } void *GetCurrentProcess(void) { return (void *)(intptr_t)-1; } @@ -64,7 +69,7 @@ int ReadFile(void *h, void *b, uint32_t n, uint32_t *r, void *o) { return 0; } int WriteFile(void *h, void *b, uint32_t n, uint32_t *r, void *o) { return 0; } int FlushFileBuffers(void *h) { return 0; } int SetFilePointerEx(void *h, int64_t d, int64_t *p, uint32_t m) { failed++; return 0; } -uint32_t GetFileAttributesW(const uint16_t *p) { return 0; } +uint32_t GetFileAttributesW(const uint16_t *p) { return scenario == 23 ? 1 : scenario == 25 ? 17 : scenario == 26 ? UINT32_MAX : 0; } int GetFileAttributesExW(const uint16_t *p, int k, void *out) { return 0; } uint32_t GetCurrentDirectoryW(uint32_t n, uint16_t *b) { return 0; } int SetCurrentDirectoryW(const uint16_t *p) { return 0; } diff --git a/tests/fixtures/native_boundaries/windows_mock.wave b/tests/fixtures/native_boundaries/windows_mock.wave index 1106b433..f92e5ea0 100644 --- a/tests/fixtures/native_boundaries/windows_mock.wave +++ b/tests/fixtures/native_boundaries/windows_mock.wave @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MPL-2.0 -import("./provider")::{open, close, fstat, Stat, FS_O_WRONLY, FS_O_APPEND, FS_O_TRUNC}; +import("./provider")::{access, open, close, fstat, Stat, FS_O_WRONLY, FS_O_APPEND, FS_O_TRUNC}; import("./memory")::{mmap, munmap, sys_alloc}; extern(c) fun choose(n: i32); extern(c) fun failures() -> i32; @@ -42,5 +42,21 @@ fun main() -> i32 { || mmap(null, 16, 3, 34, 0, 0) != null || mmap(null, 16, 3, 34, -1, 1) != null || mmap(null, 0, 3, 34, -1, 0) != null || allocation_count() != count) { return 14; } if (sys_alloc(16) == null || last_protection() != 4) { return 15; } + choose(20); + if (access("file", 4) != 0 || closed_handles() != 1) { return 16; } + choose(21); + if (access("file", 2) != 0 || closed_handles() != 1) { return 17; } + choose(22); + if (access("file", 6) != 0 || closed_handles() != 1) { return 18; } + choose(23); + if (access("file", 2) != -13 || access("file", 0) != 0 || closed_handles() != 0) { return 19; } + choose(24); + if (access("file", 6) != -13 || closed_handles() != 0) { return 20; } + choose(25); + if (access("folder", 6) != 0 || closed_handles() != 1) { return 21; } + choose(26); + if (access("missing", 0) != -13 || access("missing", 4) != -13 || closed_handles() != 0) { return 22; } + if (access("file", 1) != -95 || access("file", 7) != -95 || access("file", 8) != -22 + || access("file", -1) != -22 || closed_handles() != 0) { return 23; } return failures(); } diff --git a/tests/fixtures/release_constants/mmap.cjs b/tests/fixtures/release_constants/mmap.cjs new file mode 100644 index 00000000..499dac7a --- /dev/null +++ b/tests/fixtures/release_constants/mmap.cjs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MPL-2.0 +const fs = require('node:fs'); +const assert = require('node:assert/strict'); +(async () => { + const {instance, module} = await WebAssembly.instantiate(fs.readFileSync(process.argv[2]), {}); + assert.deepEqual(WebAssembly.Module.imports(module), []); + assert.equal(instance.exports.verify(), 0); + assert.equal(instance.exports.verify(), 0); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/tests/fixtures/release_constants/mmap.wave b/tests/fixtures/release_constants/mmap.wave new file mode 100644 index 00000000..8bfba109 --- /dev/null +++ b/tests/fixtures/release_constants/mmap.wave @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MPL-2.0 +import("std::sys::wasm64::memory")::{mmap, munmap}; +extern(c, "llvm.wasm.memory.size.i64") fun memory_size(index: i32) -> i64; +fun reject() -> bool { + return mmap(null, 16, 0, 34, -1, 0) == null + && mmap(null, 16, 1, 34, -1, 0) == null + && mmap(null, 16, 2, 34, -1, 0) == null + && mmap(null, 16, 7, 34, -1, 0) == null + && mmap(null, 16, 3, 35, -1, 0) == null + && mmap(null, 16, 3, 2, -1, 0) == null + && mmap(null, 16, 3, 34, 0, 0) == null + && mmap(null, 16, 3, 34, -1, 1) == null + && mmap(16 as ptr, 16, 3, 34, -1, 0) == null + && mmap(null, 0, 3, 34, -1, 0) == null + && mmap(null, -1, 3, 34, -1, 0) == null; +} +pub fun verify() -> i32 { + var before: i64 = memory_size(0); + if (!reject() || memory_size(0) != before) { return 1; } + var p: ptr = mmap(null, 16, 3, 34, -1, 0); + if (p == null) { return 2; } + deref p = 42; + if (deref p != 42 || munmap(p, 16) != 0) { return 3; } + before = memory_size(0); + if (!reject() || memory_size(0) != before) { return 4; } + var q: ptr = mmap(null, 16, 3, 34, -1, 0); + if (q != p || munmap(q, 16) != 0) { return 5; } + return 0; +} +fun main() -> i32 { return verify(); } diff --git a/tests/fixtures/release_constants/numeric.wave b/tests/fixtures/release_constants/numeric.wave new file mode 100644 index 00000000..f95fe6d3 --- /dev/null +++ b/tests/fixtures/release_constants/numeric.wave @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +const FORWARD: i32 = BASE * 3 + 1; +const BASE: i32 = 7; +const CAST: i32 = (300 as u8) as i32; +const WRAP: u8 = (255 as u8) + (2 as u8); +const NEG: i32 = -7 / 3; +const REM: i32 = -7 % 3; +const BITS: u64 = ((1 as u64) << 40) | 13; +const FLOAT: f32 = ((16777216 as f32) + (1 as f32)) - (16777216 as f32); +const LESS: bool = 2.5 < 3.0; +const UNEQUAL: bool = (0.0 / 0.0) != 1.0; +const DEAD: bool = false && (1 / 0 == 1); +const DEAD_SHIFT: bool = true || (1 << 32 == 0); +const DEAD_CAST: bool = true || ((1e300 as i32) == 0); +const WIDE: u1024 = ((1 as u1024) << 1000) + 17; +const WIDE_NEG: i1024 = -(((1 as i1024) << 999) + 7); +const WIDE_QUOT: u1024 = WIDE / (3 as u1024); +const ARRAY: array = [FORWARD, CAST]; +struct Pair { x: i32; y: bool; } +const PAIR: Pair = Pair { x: BASE + 1, y: LESS && !DEAD }; +static VALUE: i32 = FORWARD + CAST; +const ROUNDED: f32 = 1152921573326323713 as f32; +fun main() -> i32 { + var literal: f32 = 1152921573326323713 as f32; + var integer: u64 = 1152921573326323713; + if (ROUNDED != literal || literal != (integer as f32)) { return 7; } + var base: i32 = 7; + var cast: i32 = 300; + var a: u8 = 255; + var two: u8 = 2; + var neg: i32 = -7; + var three: i32 = 3; + var one: u64 = 1; + var float: f32 = 16777216.0; + var wide: u1024 = ((1 as u1024) << 1000) + 17; + var wide_three: u1024 = 3; + if (FORWARD != base * 3 + 1 || CAST != ((cast as u8) as i32)) { return 1; } + if (WRAP != a + two || NEG != neg / three || REM != neg % three) { return 2; } + if (BITS != ((one << 40) | 13) || FLOAT != ((float + (1 as f32)) - float)) { return 3; } + if (!LESS || !UNEQUAL || DEAD || !DEAD_SHIFT || !DEAD_CAST) { return 4; } + if (WIDE != wide || WIDE_QUOT != wide / wide_three || WIDE_NEG != -(((1 as i1024) << 999) + 7)) { return 5; } + if (ARRAY[0] != 22 || ARRAY[1] != 44 || PAIR.x != 8 || !PAIR.y || VALUE != 66) { return 6; } + return 0; +} diff --git a/tests/fixtures/release_constants/windows_env.wave b/tests/fixtures/release_constants/windows_env.wave new file mode 100644 index 00000000..383af701 --- /dev/null +++ b/tests/fixtures/release_constants/windows_env.wave @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MPL-2.0 +import("std::env::environ")::{env_get}; +import("std::env::consts")::{ENV_ERR_NO_SPACE}; +fun main() -> i32 { + var data: array; + var n: i64 = env_get("WAVE_한😀", &data[0], 64); + var expected: str = "값😀"; + if (n != 7 || data[7] != 0) { return 1; } + var i: i64 = 0; + while (i < n) { if (data[i] != (expected as ptr)[i]) { return 2; } i += 1; } + if (env_get("WAVE_EMPTY", &data[0], 64) != 0 || data[0] != 0) { return 3; } + data[0] = 85; + if (env_get("WAVE_한😀", &data[0], 7) != ENV_ERR_NO_SPACE || data[0] != 85) { return 4; } + return 0; +} diff --git a/tests/fixtures/release_constants/windows_env_mock.c b/tests/fixtures/release_constants/windows_env_mock.c new file mode 100644 index 00000000..3a7b1544 --- /dev/null +++ b/tests/fixtures/release_constants/windows_env_mock.c @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MPL-2.0 +#include +#include +static int scenario, acquired, released, converted, failed; +static uint16_t block[] = {'=', 'C', ':', '=', 'x', 0, 0xd55c, '=', 0xd83d, 0xde00, 0, 'E', '=', 0, 0}; +static const unsigned char bytes[] = {'=', 'C', ':', '=', 'x', 0, 0xed, 0x95, 0x9c, '=', 0xf0, 0x9f, 0x98, 0x80, 0, 'E', '=', 0, 0}; +void choose(int n) { scenario = n; acquired = released = converted = 0; } +int failures(void) { return failed; } +int acquisitions(void) { return acquired; } +int releases(void) { return released; } +int conversions(void) { return converted; } +uint16_t *GetEnvironmentStringsW(void) { acquired++; return scenario == 1 ? 0 : block; } +int FreeEnvironmentStringsW(uint16_t *p) { if (p != block) failed++; released++; return scenario != 5; } +uint32_t GetLastError(void) { return 5; } +int WideCharToMultiByte(uint32_t cp, uint32_t flags, const uint16_t *src, int units, + unsigned char *dst, int cap, const char *def, int *used) { + if (cp != 65001 || flags != 128 || src != block || units != 15 || def || used) failed++; + if (!dst) { if (cap) failed++; return scenario == 2 ? 0 : sizeof bytes; } + converted++; + if (cap != sizeof bytes) failed++; + if (scenario == 3) return 0; + memcpy(dst, bytes, sizeof bytes); return sizeof bytes; +} diff --git a/tests/fixtures/release_constants/windows_env_mock.wave b/tests/fixtures/release_constants/windows_env_mock.wave new file mode 100644 index 00000000..9bb86329 --- /dev/null +++ b/tests/fixtures/release_constants/windows_env_mock.wave @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MPL-2.0 +extern(c) fun choose(n: i32); +extern(c) fun failures() -> i32; +extern(c) fun acquisitions() -> i32; +extern(c) fun releases() -> i32; +extern(c) fun conversions() -> i32; +fun main() -> i32 { + var data: array; + choose(0); + if (env_read(null, 20) != -22 || env_read(&data[0], 0) != -22 || acquisitions() != 0) { return 1; } + data[0] = 85; + if (env_read(&data[0], 18) != -28 || data[0] != 85 || releases() != 1 || conversions() != 0) { return 2; } + choose(0); + if (env_read(&data[0], 19) != 19 || releases() != 1 || conversions() != 1) { return 3; } + var expected: array = [61,67,58,61,120,0,237,149,156,61,240,159,152,128,0,69,61,0,0]; + var i: i32 = 0; + while (i < 19) { if (data[i] != expected[i]) { return 4; } i += 1; } + choose(1); + if (env_read(&data[0], 20) != -13 || releases() != 0) { return 5; } + choose(2); + if (env_read(&data[0], 20) != -13 || releases() != 1 || conversions() != 0) { return 6; } + choose(3); + if (env_read(&data[0], 20) != -13 || releases() != 1 || conversions() != 1) { return 7; } + choose(5); + if (env_read(&data[0], 20) != -13 || releases() != 1) { return 8; } + return failures(); +} diff --git a/tests/native_boundaries.rs b/tests/native_boundaries.rs index 2a83096f..f71e813b 100644 --- a/tests/native_boundaries.rs +++ b/tests/native_boundaries.rs @@ -297,3 +297,74 @@ fn environment_provider_cross_target_objects() { } } } + +#[test] +fn windows_unicode_environment() { + let case = Case::new(); + for target in ["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"] { + if llvm::codegen::target::target_spec_for_triple(target).is_none() { + continue; + } + for opt in ["-O0", "-O2"] { + success( + case.command() + .arg("build") + .arg(repo("tests/fixtures/release_constants/windows_env.wave")) + .args(["--target", target, "--emit=obj", opt, "--out-dir"]) + .arg(case.0.join(format!("{target}-{opt}"))) + .output() + .unwrap(), + ); + } + } + #[cfg(target_os = "windows")] + for opt in ["-O0", "-O2"] { + success( + case.command() + .arg("build") + .arg(repo("tests/fixtures/release_constants/windows_env.wave")) + .args([opt, "-o", "env.exe"]) + .output() + .unwrap(), + ); + success( + Command::new(case.0.join("env.exe")) + .env("WAVE_한😀", "값😀") + .env("WAVE_EMPTY", "") + .output() + .unwrap(), + ); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn windows_environment_conversion_capacity_and_cleanup() { + let case = Case::new(); + let provider = fs::read_to_string(repo("std/sys/windows/env.wave")) + .unwrap() + .replace("extern(system,", "extern(c,"); + let fixture = fs::read_to_string(repo( + "tests/fixtures/release_constants/windows_env_mock.wave", + )) + .unwrap(); + fs::write(case.0.join("case.wave"), format!("{provider}\n{fixture}")).unwrap(); + success( + Command::new("cc") + .args(["-c", "-O2"]) + .arg(repo("tests/fixtures/release_constants/windows_env_mock.c")) + .arg("-o") + .arg(case.0.join("mock.o")) + .output() + .unwrap(), + ); + for opt in ["-O0", "-O2"] { + success( + case.command() + .args(["build", "case.wave", "mock.o", opt, "-o", "case.exe"]) + .output() + .unwrap(), + ); + success(Command::new(case.0.join("case.exe")).output().unwrap()); + } +} diff --git a/tests/release_constants.rs b/tests/release_constants.rs new file mode 100644 index 00000000..a95e03f2 --- /dev/null +++ b/tests/release_constants.rs @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MPL-2.0 +use std::{ + fs, + path::{Path, PathBuf}, + process::{Command, Output}, + sync::atomic::{AtomicU64, Ordering}, +}; +static NEXT: AtomicU64 = AtomicU64::new(0); +struct Case(PathBuf); +impl Case { + fn new() -> Self { + let p = std::env::temp_dir().join(format!( + "wave-release-constants-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn command(&self) -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_wavec")); + c.current_dir(&self.0) + .args(["--std-root"]) + .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("std")); + c + } + fn source(&self, source: &str) { + fs::write(self.0.join("case.wave"), source).unwrap(); + } + fn build(&self, opt: &str) -> Output { + self.command() + .args(["build", "case.wave", opt, "--emit=ir,bin", "-o", "case.exe"]) + .output() + .unwrap() + } + fn run(&self, source: &str) { + self.source(source); + for opt in ["-O0", "-O2"] { + ok(self.build(opt)); + ok(Command::new(self.0.join("case.exe")).output().unwrap()); + } + } +} +impl Drop for Case { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn ok(o: Output) { + assert!( + o.status.success(), + "{:?}\n{}\n{}", + o.status, + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ); +} +fn native() -> bool { + let o = Command::new(env!("CARGO_BIN_EXE_wavec")) + .args(["print", "default-target"]) + .output() + .unwrap(); + llvm::codegen::target::target_spec_for_triple(String::from_utf8_lossy(&o.stdout).trim()) + .is_some() +} +#[test] +fn frontend_constant_expressions_match_runtime() { + if !native() { + return; + } + Case::new().run(include_str!("fixtures/release_constants/numeric.wave")); +} +#[test] +fn constant_errors_agree_between_check_and_build() { + let case = Case::new(); + for (source, message) in [ + ("const N: i32 = 1 / 0;", "division or remainder by zero"), + ("const N: i32 = 1 % 0;", "division or remainder by zero"), + ( + "const N: i8 = (-128 as i8) / (-1 as i8);", + "signed division overflows", + ), + ("const N: i32 = 1 << 32;", "shift count"), + ("const N: i32 = (0.0 / 0.0) as i32;", "NaN or infinity"), + ( + "fun f() -> i32 { return 3; } const N: i32 = f();", + "unsupported constant expression", + ), + ( + "static N: i32 = 1; const M: i32 = N;", + "unsupported constant expression", + ), + ( + "const A: array = [1]; const N: i32 = A[0];", + "unsupported constant expression", + ), + ( + "const P: ptr = null; const Q: ptr = P + 1;", + "unsupported constant expression", + ), + ( + "fun f() -> bool { return true; } const N: bool = false && f();", + "unsupported constant expression", + ), + ] { + case.source(&format!("{source} fun main() -> i32 {{ return 0; }}")); + for command in ["check", "build"] { + for format in ["human", "json"] { + let out = case + .command() + .args([command, "case.wave", &format!("--error-format={format}")]) + .output() + .unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success() && err.contains(message), + "{source}: {out:?}" + ); + if format == "json" { + let value = utils::json::parse(err.trim()).unwrap(); + let error = value.get("error").unwrap(); + assert_eq!(error.get_str("code"), Some("E3001")); + assert!(error.get_num("column").unwrap() > 0.0); + } + } + } + } +} +#[cfg(unix)] +#[test] +fn cli_preserves_signal_and_regular_child_status() { + if !native() { + return; + } + let case = Case::new(); + // SIGTERM has the same number on the supported Unix targets and does not dump core. + case.source( + "extern(c) fun raise(signal: i32) -> i32; fun main() -> i32 { raise(15); return 0; }", + ); + for args in [ + vec!["run", "case.wave"], + vec!["build", "case.wave", "--run"], + ] { + let out = case.command().args(args).output().unwrap(); + assert_eq!(out.status.code(), Some(143), "{out:?}"); + } + case.source("fun main() -> i32 { return 37; }"); + let out = case.command().args(["run", "case.wave"]).output().unwrap(); + assert_eq!(out.status.code(), Some(37), "{out:?}"); +} + +#[cfg(feature = "llvm-target-wasm")] +#[test] +fn webassembly_mmap_rejects_without_allocator_effects() { + let case = Case::new(); + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + for opt in ["-O0", "-O2"] { + ok(case + .command() + .arg("build") + .arg(root.join("tests/fixtures/release_constants/mmap.wave")) + .args([ + "--target=wasm64-unknown-unknown", + "--emit=obj", + opt, + "--out-dir", + ]) + .arg(&case.0) + .output() + .unwrap()); + if std::env::var_os("WAVE_RUN_WASM_RUNTIME_TESTS").is_some() { + ok(Command::new("wasm-ld") + .args(["-mwasm64", "--no-entry", "--export=verify"]) + .arg(case.0.join("mmap.o")) + .arg("-o") + .arg(case.0.join("mmap.wasm")) + .output() + .unwrap()); + ok(Command::new("node") + .arg("--experimental-wasm-memory64") + .arg(root.join("tests/fixtures/release_constants/mmap.cjs")) + .arg(case.0.join("mmap.wasm")) + .output() + .unwrap()); + } + } +} + +#[test] +fn constants_preserve_every_integer_width_and_intermediate_conversions() { + if !native() { + return; + } + let case = Case::new(); + let mut source = String::new(); + let mut body = String::from("fun main() -> i32 {\n"); + for width in [8, 16, 32, 64, 128, 256, 512, 1024] { + source.push_str(&format!(r#" +const U{width}: u{width} = (((1 as u{width}) << {top}) - 1) * (3 as u{width}); +const S{width}: i{width} = ((-17 as i{width}) / (3 as i{width})) + ((-17 as i{width}) % (3 as i{width})); +const B{width}: u{width} = (~(13 as u{width}) & (255 as u{width})) ^ (7 as u{width}); +const C{width}: i{width} = ((257 as i{width}) as u8) as i{width}; +"#, top = width - 1)); + body.push_str(&format!(r#" +var a{width}: u{width} = (1 as u{width}) << {top}; +var b{width}: i{width} = -17; +var c{width}: i{width} = 3; +var d{width}: u{width} = 13; +var e{width}: i{width} = 257 as i{width}; +if (U{width} != (a{width} - 1) * 3 || S{width} != {division} + || B{width} != ((~d{width} & (255 as u{width})) ^ 7) || C{width} != ((e{width} as u8) as i{width})) {{ return 1; }} +"#, top = width - 1, + // Native i128 division may require an external compiler runtime. + // Wider constant quotients are checked against their exact known value. + division = if width <= 64 { format!("b{width} / c{width} + b{width} % c{width}") } + else { format!("(-7 as i{width})") })); + } + source.push_str(&body); + source.push_str("return 0; }"); + case.run(&source); +} diff --git a/utils/src/const_int.rs b/utils/src/const_int.rs index eef0671d..bc788430 100644 --- a/utils/src/const_int.rs +++ b/utils/src/const_int.rs @@ -60,6 +60,16 @@ impl ConstInt { pub fn is_negative(&self) -> bool { self.negative } + + /// Little-endian two's-complement words at the requested storage width. + pub fn to_le_words(&self, bits: u16) -> Vec { + assert!((1..=1024).contains(&bits)); + let mut words = self.twos_complement()[..(bits as usize).div_ceil(64)].to_vec(); + if !bits.is_multiple_of(64) { + *words.last_mut().unwrap() &= (1u64 << (bits % 64)) - 1; + } + words + } pub fn bits(&self) -> usize { self.words .iter() @@ -359,6 +369,19 @@ mod tests { v } } + #[test] + fn storage_words_preserve_sign_and_truncate_only_at_the_requested_width() { + assert_eq!(int(-1).to_le_words(1), vec![1]); + assert_eq!(int(-2).to_le_words(8), vec![254]); + assert_eq!(int(-1).to_le_words(128), vec![u64::MAX; 2]); + assert_eq!(int(-1).to_le_words(1024), vec![u64::MAX; 16]); + let wide = ConstInt::from_u64(1).checked_shl(1000).unwrap(); + let words = wide.to_le_words(1024); + assert_eq!(words[15], 1 << 40); + assert!(words[..15].iter().all(|w| *w == 0)); + assert_eq!(wide.to_le_words(64), vec![0]); + } + #[test] fn signed_arithmetic_matches_native_integers() { for a in -32i128..=32 {