From a6952f58cc80b6cf620382168ed61f560025822b Mon Sep 17 00:00:00 2001 From: Mark <399551+mwiebe@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:46:37 -0700 Subject: [PATCH 1/6] fix(expr): preserve compatible unresolved targets Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com> --- crates/openjd-expr/src/value.rs | 11 ++++++++++ .../tests/integration/test_expr_value.rs | 16 ++++++++++++++ .../test_target_type_propagation.rs | 21 +++++++++++++++++++ specs/expr/values.md | 10 +++++---- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/openjd-expr/src/value.rs b/crates/openjd-expr/src/value.rs index 99c86464..66f01a36 100644 --- a/crates/openjd-expr/src/value.rs +++ b/crates/openjd-expr/src/value.rs @@ -749,6 +749,17 @@ impl ExprValue { if target.code() == TypeCode::Any { return Ok(self); } + // Unresolved values have no concrete payload to convert. Preserve + // the placeholder when its constraint already satisfies the target, + // and otherwise report the same incompatibility a concrete value + // would. Value-dependent coercions must wait until resolution. + if let ExprValue::Unresolved(constraint) = &self { + return if target.match_type(constraint).is_some() { + Ok(self) + } else { + Err(format!("Cannot coerce {constraint} to {target}")) + }; + } // Match-first: also accepts the case where the target is a union // and the value's type is one of its members. Falls back to the // existing strict-equality behavior for non-union targets. diff --git a/crates/openjd-expr/tests/integration/test_expr_value.rs b/crates/openjd-expr/tests/integration/test_expr_value.rs index 5ed559df..db29771b 100644 --- a/crates/openjd-expr/tests/integration/test_expr_value.rs +++ b/crates/openjd-expr/tests/integration/test_expr_value.rs @@ -148,6 +148,22 @@ fn coerce_list_elements() { assert_eq!(v.expr_type().to_string(), "list[int]"); } +#[test] +fn coerce_compatible_unresolved_preserves_constraint() { + let value = ExprValue::unresolved(ExprType::INT) + .coerce(&ExprType::INT, PathFormat::Posix) + .unwrap(); + assert_eq!(value, ExprValue::unresolved(ExprType::INT)); +} + +#[test] +fn coerce_incompatible_unresolved_errors() { + let error = ExprValue::unresolved(ExprType::list(ExprType::INT)) + .coerce(&ExprType::INT, PathFormat::Posix) + .unwrap_err(); + assert_eq!(error, "Cannot coerce list[int] to int"); +} + // ══════════════════════════════════════════════════════════════ // repr_python // ══════════════════════════════════════════════════════════════ diff --git a/crates/openjd-expr/tests/integration/test_target_type_propagation.rs b/crates/openjd-expr/tests/integration/test_target_type_propagation.rs index 07032f87..f397ce16 100644 --- a/crates/openjd-expr/tests/integration/test_target_type_propagation.rs +++ b/crates/openjd-expr/tests/integration/test_target_type_propagation.rs @@ -450,6 +450,27 @@ fn any_target_is_noop_for_string() { ); } +#[test] +fn compatible_scalar_target_preserves_unresolved_result() { + let st = symtab(&[("X", ExprValue::unresolved(ExprType::INT))]); + let result = eval_with_target_type("X + 1", &ExprType::INT, &st).unwrap(); + assert_eq!(result, ExprValue::unresolved(ExprType::INT)); +} + +#[test] +fn incompatible_scalar_target_rejects_unresolved_result() { + let st = symtab(&[("X", ExprValue::unresolved(ExprType::list(ExprType::INT)))]); + let err = eval_with_target_type("X", &ExprType::INT, &st) + .unwrap_err() + .to_string(); + let expected = concat!( + "Cannot coerce list[int] to int\n", + " X\n", + " ^" + ); + assert!(err.contains(expected), "got:\n{err}\nexpected:\n{expected}"); +} + #[test] fn call_arguments_not_constrained_by_caller_target() { // The caller's target applies to the call's result; arguments get diff --git a/specs/expr/values.md b/specs/expr/values.md index 13319f40..e87970ea 100644 --- a/specs/expr/values.md +++ b/specs/expr/values.md @@ -478,7 +478,9 @@ through the entire expression, catching type mismatches at validation time. Unresolved values are **type-only placeholders**: they carry an `ExprType` but no concrete data. Because they're wrapped values, they can pass through the evaluator's memory tracking and dispatch without a special code path. `Display` on an unresolved -value renders as `unresolved[T]` for debug/error output. Calling the `.coerce()` -target-type path on an unresolved value is a no-op (the unresolved wrapper is -preserved through coercion), so validation-time format string resolution can still -exercise the full coercion chain symbolically. +value renders as `unresolved[T]` for debug/error output. + +Target-type coercion preserves `unresolved[T]` unchanged when `T` already +satisfies the target type. It rejects an incompatible target, such as +`unresolved[list[int]]` against `int`. Coercions that depend on a concrete +payload are deferred until the value is resolved. From a6863eb93d499b879d963bed0ddbd1cc5c409fad Mon Sep 17 00:00:00 2001 From: Mark <399551+mwiebe@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:31:08 -0700 Subject: [PATCH 2/6] fix(expr): coerce unresolved values by type Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com> --- crates/openjd-expr/src/value.rs | 115 +++++++++++++++++- .../tests/integration/test_expr_value.rs | 47 +++++++ .../test_target_type_propagation.rs | 13 +- specs/expr/values.md | 15 ++- 4 files changed, 175 insertions(+), 15 deletions(-) diff --git a/crates/openjd-expr/src/value.rs b/crates/openjd-expr/src/value.rs index 66f01a36..341c668e 100644 --- a/crates/openjd-expr/src/value.rs +++ b/crates/openjd-expr/src/value.rs @@ -727,6 +727,11 @@ impl ExprValue { /// Coercion is non-destructive: only conversions that don't lose /// information are attempted (`int → float`, `int → string`, etc). /// + /// Unresolved values apply the same table at the type level and return + /// an unresolved value constrained to the result type. Checks that need + /// a concrete payload, such as parsing a string as an integer, are + /// deferred until resolution. + /// /// An `any` target is unconstrained: every value is returned /// unchanged. /// @@ -749,13 +754,13 @@ impl ExprValue { if target.code() == TypeCode::Any { return Ok(self); } - // Unresolved values have no concrete payload to convert. Preserve - // the placeholder when its constraint already satisfies the target, - // and otherwise report the same incompatibility a concrete value - // would. Value-dependent coercions must wait until resolution. + // Unresolved values have no payload, so apply the concrete coercion + // table at the type level and defer payload-dependent checks until + // resolution. The result constraint must satisfy the target just as + // a concrete result's type would. if let ExprValue::Unresolved(constraint) = &self { - return if target.match_type(constraint).is_some() { - Ok(self) + return if let Some(result_type) = Self::unresolved_coercion_result(constraint, target) { + Ok(ExprValue::unresolved(result_type)) } else { Err(format!("Cannot coerce {constraint} to {target}")) }; @@ -857,6 +862,104 @@ impl ExprValue { } } + fn unresolved_coercion_result(source: &ExprType, target: &ExprType) -> Option { + if target.code() == TypeCode::Any { + return Some(source.clone()); + } + if source.code() == TypeCode::Unresolved { + return source + .params() + .first() + .and_then(|inner| Self::unresolved_coercion_result(inner, target)); + } + if source.code() == TypeCode::Union { + let result_types = source + .params() + .iter() + .map(|member| Self::unresolved_coercion_result(member, target)) + .collect::>>()?; + return Some(ExprType::union(result_types)); + } + if source.code() == TypeCode::Any + || matches!( + source.code(), + TypeCode::TypeVarT + | TypeCode::TypeVarT1 + | TypeCode::TypeVarT2 + | TypeCode::TypeVarT3 + ) + { + return Some(target.clone()); + } + if target.code() == TypeCode::Union { + if target.match_type(source).is_some() { + return Some(source.clone()); + } + for member in target.params() { + if matches!( + member.code(), + TypeCode::NullType | TypeCode::List | TypeCode::Union + ) { + continue; + } + if let Some(result_type) = Self::unresolved_coercion_result(source, member) { + return Some(result_type); + } + } + return None; + } + if matches!( + target.code(), + TypeCode::TypeVarT | TypeCode::TypeVarT1 | TypeCode::TypeVarT2 | TypeCode::TypeVarT3 + ) || source == target + { + return Some(source.clone()); + } + if source.code() == TypeCode::List + && source.params().len() == 1 + && target.code() == TypeCode::List + && target.params().len() == 1 + { + let source_elem = &source.params()[0]; + if source_elem == &ExprType::NULLTYPE + || Self::unresolved_coercion_result(source_elem, &target.params()[0]).is_some() + { + return Some(target.clone()); + } + return None; + } + + let has_scalar_rule = matches!( + (source.code(), target.code()), + (TypeCode::Int, TypeCode::Float) + | (TypeCode::Float, TypeCode::Int) + | ( + TypeCode::Bool + | TypeCode::Int + | TypeCode::Float + | TypeCode::Path + | TypeCode::RangeExpr, + TypeCode::String + ) + | ( + TypeCode::String, + TypeCode::NullType + | TypeCode::Bool + | TypeCode::Int + | TypeCode::Float + | TypeCode::Path + | TypeCode::RangeExpr + ) + ); + if has_scalar_rule + || (source.code() == TypeCode::RangeExpr && target == &ExprType::list(ExprType::INT)) + { + Some(target.clone()) + } else { + None + } + } + /// Python-style repr: `ExprValue(42)`, `ExprValue('hello')`, `ExprValue([1, 2], type='list[int]')`. pub fn repr_python(&self) -> String { match self { diff --git a/crates/openjd-expr/tests/integration/test_expr_value.rs b/crates/openjd-expr/tests/integration/test_expr_value.rs index db29771b..a353afc1 100644 --- a/crates/openjd-expr/tests/integration/test_expr_value.rs +++ b/crates/openjd-expr/tests/integration/test_expr_value.rs @@ -156,6 +156,53 @@ fn coerce_compatible_unresolved_preserves_constraint() { assert_eq!(value, ExprValue::unresolved(ExprType::INT)); } +#[test] +fn coerce_unresolved_applies_type_level_rules() { + let cases = [ + (ExprType::INT, ExprType::STRING), + (ExprType::INT, ExprType::FLOAT), + (ExprType::PATH, ExprType::STRING), + ( + ExprType::list(ExprType::INT), + ExprType::list(ExprType::FLOAT), + ), + (ExprType::RANGE_EXPR, ExprType::list(ExprType::INT)), + ]; + for (source, target) in cases { + let value = ExprValue::unresolved(source) + .coerce(&target, PathFormat::Posix) + .unwrap(); + assert_eq!(value, ExprValue::unresolved(target)); + } +} + +#[test] +fn coerce_unresolved_defers_payload_checks() { + let value = ExprValue::unresolved(ExprType::STRING) + .coerce(&ExprType::INT, PathFormat::Posix) + .unwrap(); + assert_eq!(value, ExprValue::unresolved(ExprType::INT)); +} + +#[test] +fn coerce_unresolved_narrows_union_constraint() { + let value = ExprValue::unresolved(ExprType::union(vec![ExprType::INT, ExprType::STRING])) + .coerce(&ExprType::INT, PathFormat::Posix) + .unwrap(); + assert_eq!(value, ExprValue::unresolved(ExprType::INT)); +} + +#[test] +fn coerce_unresolved_does_not_broaden_for_union_target() { + let value = ExprValue::unresolved(ExprType::INT) + .coerce( + &ExprType::union(vec![ExprType::INT, ExprType::STRING]), + PathFormat::Posix, + ) + .unwrap(); + assert_eq!(value, ExprValue::unresolved(ExprType::INT)); +} + #[test] fn coerce_incompatible_unresolved_errors() { let error = ExprValue::unresolved(ExprType::list(ExprType::INT)) diff --git a/crates/openjd-expr/tests/integration/test_target_type_propagation.rs b/crates/openjd-expr/tests/integration/test_target_type_propagation.rs index f397ce16..df3b5958 100644 --- a/crates/openjd-expr/tests/integration/test_target_type_propagation.rs +++ b/crates/openjd-expr/tests/integration/test_target_type_propagation.rs @@ -457,17 +457,20 @@ fn compatible_scalar_target_preserves_unresolved_result() { assert_eq!(result, ExprValue::unresolved(ExprType::INT)); } +#[test] +fn coercible_scalar_target_narrows_unresolved_result() { + let st = symtab(&[("X", ExprValue::unresolved(ExprType::INT))]); + let result = eval_with_target_type("X + 1", &ExprType::STRING, &st).unwrap(); + assert_eq!(result, ExprValue::unresolved(ExprType::STRING)); +} + #[test] fn incompatible_scalar_target_rejects_unresolved_result() { let st = symtab(&[("X", ExprValue::unresolved(ExprType::list(ExprType::INT)))]); let err = eval_with_target_type("X", &ExprType::INT, &st) .unwrap_err() .to_string(); - let expected = concat!( - "Cannot coerce list[int] to int\n", - " X\n", - " ^" - ); + let expected = concat!("Cannot coerce list[int] to int\n", " X\n", " ^"); assert!(err.contains(expected), "got:\n{err}\nexpected:\n{expected}"); } diff --git a/specs/expr/values.md b/specs/expr/values.md index e87970ea..3e4f415f 100644 --- a/specs/expr/values.md +++ b/specs/expr/values.md @@ -480,7 +480,14 @@ concrete data. Because they're wrapped values, they can pass through the evaluat memory tracking and dispatch without a special code path. `Display` on an unresolved value renders as `unresolved[T]` for debug/error output. -Target-type coercion preserves `unresolved[T]` unchanged when `T` already -satisfies the target type. It rejects an incompatible target, such as -`unresolved[list[int]]` against `int`. Coercions that depend on a concrete -payload are deferred until the value is resolved. +Target-type coercion applies the same conversion table to unresolved types that +it applies to concrete values. The payload remains unresolved, but its type is +narrowed to the coercion result. For example, `unresolved[int]` against a +`string` target becomes `unresolved[string]`, and +`unresolved[int | string]` against an `int` target becomes `unresolved[int]`. + +Checks that require a concrete payload are deferred until runtime. For example, +`unresolved[string]` can narrow to `unresolved[int]`; once resolved, the string +must still parse as an integer. A source and target with no type-level coercion +rule, such as `unresolved[list[int]]` against `int`, is rejected during +validation. From 17fc0aa169f10d1093312b890bceb6d62dc5015a Mon Sep 17 00:00:00 2001 From: Mark <399551+mwiebe@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:06:02 -0700 Subject: [PATCH 3/6] fix(expr): accept partially coercible unresolved unions Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com> --- crates/openjd-expr/src/value.rs | 12 ++-- .../tests/integration/test_expr_value.rs | 69 +++++++++++++++++++ .../integration/test_target_type_union.rs | 66 ++++++++++++++++++ specs/expr/values.md | 23 +++++-- 4 files changed, 162 insertions(+), 8 deletions(-) diff --git a/crates/openjd-expr/src/value.rs b/crates/openjd-expr/src/value.rs index 341c668e..ad1e554a 100644 --- a/crates/openjd-expr/src/value.rs +++ b/crates/openjd-expr/src/value.rs @@ -873,12 +873,16 @@ impl ExprValue { .and_then(|inner| Self::unresolved_coercion_result(inner, target)); } if source.code() == TypeCode::Union { - let result_types = source + let result_types: Vec<_> = source .params() .iter() - .map(|member| Self::unresolved_coercion_result(member, target)) - .collect::>>()?; - return Some(ExprType::union(result_types)); + .filter_map(|member| Self::unresolved_coercion_result(member, target)) + .collect(); + return if result_types.is_empty() { + None + } else { + Some(ExprType::union(result_types)) + }; } if source.code() == TypeCode::Any || matches!( diff --git a/crates/openjd-expr/tests/integration/test_expr_value.rs b/crates/openjd-expr/tests/integration/test_expr_value.rs index a353afc1..3236fbd8 100644 --- a/crates/openjd-expr/tests/integration/test_expr_value.rs +++ b/crates/openjd-expr/tests/integration/test_expr_value.rs @@ -192,6 +192,66 @@ fn coerce_unresolved_narrows_union_constraint() { assert_eq!(value, ExprValue::unresolved(ExprType::INT)); } +#[test] +fn coerce_unresolved_keeps_successful_union_members() { + let value = ExprValue::unresolved(ExprType::union(vec![ + ExprType::INT, + ExprType::list(ExprType::INT), + ])) + .coerce(&ExprType::INT, PathFormat::Posix) + .unwrap(); + assert_eq!(value, ExprValue::unresolved(ExprType::INT)); +} + +#[test] +fn coerce_unresolved_nullable_and_nested_union_sources() { + let cases = [ + ("int?", "string", "string"), + ("int?", "int", "int"), + ("int?", "int | string", "int"), + ("string?", "int | string", "string"), + ("int?", "int? | list[int]", "int?"), + ("int? | list[int]", "int | list[int]", "int | list[int]"), + ("string? | list[string]", "string?", "string?"), + ]; + + for (source, target, expected) in cases { + let value = ExprValue::unresolved(ExprType::parse(source).unwrap()) + .coerce(&ExprType::parse(target).unwrap(), PathFormat::Posix) + .unwrap(); + assert_eq!( + value, + ExprValue::unresolved(ExprType::parse(expected).unwrap()), + "source={source}, target={target}" + ); + } +} + +#[test] +fn coerce_unresolved_empty_list_to_typed_list() { + let value = ExprValue::unresolved(ExprType::list(ExprType::NULLTYPE)) + .coerce(&ExprType::list(ExprType::STRING), PathFormat::Posix) + .unwrap(); + assert_eq!( + value, + ExprValue::unresolved(ExprType::list(ExprType::STRING)) + ); +} + +#[test] +fn coerce_unresolved_unions_successful_result_types() { + let value = ExprValue::unresolved(ExprType::union(vec![ExprType::BOOL, ExprType::INT])) + .coerce( + &ExprType::union(vec![ExprType::FLOAT, ExprType::STRING]), + PathFormat::Posix, + ) + .unwrap(); + assert_eq!( + value, + ExprValue::unresolved(ExprType::union(vec![ExprType::FLOAT, ExprType::STRING])) + ); +} + #[test] fn coerce_unresolved_does_not_broaden_for_union_target() { let value = ExprValue::unresolved(ExprType::INT) @@ -211,6 +271,15 @@ fn coerce_incompatible_unresolved_errors() { assert_eq!(error, "Cannot coerce list[int] to int"); } +#[test] +fn coerce_unresolved_errors_when_no_union_member_succeeds() { + let source = ExprType::union(vec![ExprType::PATH, ExprType::list(ExprType::INT)]); + let error = ExprValue::unresolved(source.clone()) + .coerce(&ExprType::INT, PathFormat::Posix) + .unwrap_err(); + assert_eq!(error, format!("Cannot coerce {source} to int")); +} + // ══════════════════════════════════════════════════════════════ // repr_python // ══════════════════════════════════════════════════════════════ diff --git a/crates/openjd-expr/tests/integration/test_target_type_union.rs b/crates/openjd-expr/tests/integration/test_target_type_union.rs index e6537b03..bdbf3375 100644 --- a/crates/openjd-expr/tests/integration/test_target_type_union.rs +++ b/crates/openjd-expr/tests/integration/test_target_type_union.rs @@ -216,6 +216,72 @@ fn optional_string_accepts_null() { assert_eq!(r, ExprValue::Null); } +#[test] +fn unresolved_nullable_sources_keep_successful_possibilities() { + let cases = [ + ("WrappedAction.Timeout", "int?", "int", "int"), + ("WrappedAction.Timeout", "int?", "int | string", "int"), + ( + "WrappedAction.Cancelation.Mode", + "string?", + "int | string", + "string", + ), + ("U.IntOpt", "int?", "int? | list[int]", "int?"), + ( + "U.IntOptOrList", + "int? | list[int]", + "int | list[int]", + "int | list[int]", + ), + ( + "U.StringOptOrList", + "string? | list[string]", + "string?", + "string?", + ), + ]; + + for (name, source, target, expected) in cases { + let mut st = SymbolTable::new(); + st.set(name, ExprValue::unresolved(parse_type(source))) + .unwrap(); + let result = eval_with_target_type(name, &parse_type(target), &st).unwrap(); + assert_eq!( + result, + ExprValue::unresolved(parse_type(expected)), + "symbol={name}, source={source}, target={target}" + ); + } +} + +#[test] +fn wrapped_action_timeout_format_string_accepts_union_target() { + let mut st = SymbolTable::new(); + st.set( + "WrappedAction.Timeout", + ExprValue::unresolved(parse_type("int?")), + ) + .unwrap(); + let target = parse_type("int | string"); + let options = FormatStringOptions::default().with_target_type(&target); + let result = FormatString::new("{{ WrappedAction.Timeout }}") + .unwrap() + .resolve_with(&st, &options) + .unwrap(); + assert_eq!(result, ExprValue::unresolved(ExprType::INT)); +} + +#[test] +fn nullable_list_elements_narrow_from_list_target() { + let mut st = SymbolTable::new(); + st.set("U.IntOpt", ExprValue::unresolved(parse_type("int?"))) + .unwrap(); + let result = + eval_with_target_type("[U.IntOpt, U.IntOpt]", &parse_type("list[int]"), &st).unwrap(); + assert_eq!(result, ExprValue::unresolved(parse_type("list[int]"))); +} + #[test] fn optional_int_coerces_string_to_int() { // `int?` is `int | nulltype`. A string value matches neither diff --git a/specs/expr/values.md b/specs/expr/values.md index 3e4f415f..cbc4a8f9 100644 --- a/specs/expr/values.md +++ b/specs/expr/values.md @@ -471,20 +471,35 @@ let result = evaluate_expression("Param.Frame + Param.Name", &symtab); // → TypeError: cannot add int and string ``` -When any argument to a function is unresolved, the function returns -`Unresolved(return_type)` instead of computing a value. This propagates type information -through the entire expression, catching type mismatches at validation time. - Unresolved values are **type-only placeholders**: they carry an `ExprType` but no concrete data. Because they're wrapped values, they can pass through the evaluator's memory tracking and dispatch without a special code path. `Display` on an unresolved value renders as `unresolved[T]` for debug/error output. +All operations involving unresolved values use existential validation. For an +`unresolved[T]` operand, `T` describes the set of concrete values and types to which +that operand could resolve. With multiple unresolved operands, the possibilities are +all combinations of their permitted resolutions. + +An operation succeeds symbolically if at least one possible combination would +succeed. Possibilities that would fail are discarded. If no combination can succeed, +the operation fails immediately. Otherwise, unless evaluation can prove a concrete +result independent of the unresolved inputs, the result is unresolved and its +constraint is the union of the result types from all successful possibilities. + +As expression processing progresses and unresolved operands become narrower or +concrete, the same operation is evaluated with fewer possibilities. It may then +produce a concrete result or report a value-dependent error that could not be proven +at an earlier stage. + Target-type coercion applies the same conversion table to unresolved types that it applies to concrete values. The payload remains unresolved, but its type is narrowed to the coercion result. For example, `unresolved[int]` against a `string` target becomes `unresolved[string]`, and `unresolved[int | string]` against an `int` target becomes `unresolved[int]`. +Likewise, coercing `unresolved[int | list[int]]` to `int` succeeds as +`unresolved[int]`: the `int` possibility succeeds even though the `list[int]` +possibility cannot. Checks that require a concrete payload are deferred until runtime. For example, `unresolved[string]` can narrow to `unresolved[int]`; once resolved, the string From a421788d62573bb6a5f9d191d8f715c92c6084a9 Mon Sep 17 00:00:00 2001 From: Mark <399551+mwiebe@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:47:27 -0700 Subject: [PATCH 4/6] fix(expr): make coerce results satisfy their target type Two divergences between the concrete coercion table and the type-level table used for unresolved placeholders: The `range_expr` arm matched on a bare `list` target while ignoring the element type, so it returned a `list[int]` for any `list[T]` target, breaking the postcondition that a coerced value satisfies the target it was coerced to. Materialize, then widen element-wise, and reject targets whose element type `int` cannot reach. The unresolved table now accepts `range_expr -> list[T]` on the same condition instead of only `list[int]`. A type-variable target was accepted for unresolved values but has no arm in the concrete table, so a concrete value always fails against one. Rejecting both keeps validation from passing an expression that can only fail once the value is known. Unresolved coercion may accept a pair the concrete value later rejects, since the deciding information is a payload the placeholder does not carry. It must never reject a pair the concrete value would accept: that fails a template at validation time that would have run correctly. Record that asymmetry in the docstring and specs/expr/values.md. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com> --- crates/openjd-expr/src/value.rs | 40 ++++++++--- .../tests/integration/test_expr_value.rs | 67 +++++++++++++++++++ specs/expr/values.md | 22 +++++- 3 files changed, 118 insertions(+), 11 deletions(-) diff --git a/crates/openjd-expr/src/value.rs b/crates/openjd-expr/src/value.rs index ad1e554a..59111648 100644 --- a/crates/openjd-expr/src/value.rs +++ b/crates/openjd-expr/src/value.rs @@ -730,7 +730,10 @@ impl ExprValue { /// Unresolved values apply the same table at the type level and return /// an unresolved value constrained to the result type. Checks that need /// a concrete payload, such as parsing a string as an integer, are - /// deferred until resolution. + /// deferred until resolution. The type-level table may therefore accept + /// a pair that the concrete value later rejects, but it must never + /// reject one the concrete value would accept — that would fail a + /// template during validation that would have run correctly. /// /// An `any` target is unconstrained: every value is returned /// unchanged. @@ -843,7 +846,17 @@ impl ExprValue { crate::eval::DEFAULT_OPERATION_LIMIT )); } - Ok(ExprValue::ListInt(r.to_vec())) + let materialized = ExprValue::ListInt(r.to_vec()); + match target.params().first() { + // A `list[int]` target (or a bare `list`) is satisfied by + // the materialized range. Any other element type needs the + // element-wise pass below, or the result would not satisfy + // the target it was coerced to. + Some(elem) if elem != &ExprType::INT => { + materialized.coerce(target, path_format) + } + _ => Ok(materialized), + } } _ if target.code() == TypeCode::List && target.params().len() == 1 => { let elem_type = &target.params()[0]; @@ -912,11 +925,11 @@ impl ExprValue { } return None; } - if matches!( - target.code(), - TypeCode::TypeVarT | TypeCode::TypeVarT1 | TypeCode::TypeVarT2 | TypeCode::TypeVarT3 - ) || source == target - { + // Note there is deliberately no rule for a type-variable *target*: + // the concrete table has no arm for one either, so a concrete value + // always fails against it. Accepting the unresolved case would let + // validation pass an expression that can only fail at runtime. + if source == target { return Some(source.clone()); } if source.code() == TypeCode::List @@ -955,9 +968,16 @@ impl ExprValue { | TypeCode::RangeExpr ) ); - if has_scalar_rule - || (source.code() == TypeCode::RangeExpr && target == &ExprType::list(ExprType::INT)) - { + // A `range_expr` materializes to `list[int]`, which then satisfies a + // `list[T]` target whenever `int` itself coerces to `T` — mirroring + // the concrete path's materialize-then-widen behavior. + let has_range_list_rule = source.code() == TypeCode::RangeExpr + && target.code() == TypeCode::List + && match target.params().first() { + Some(elem) => Self::unresolved_coercion_result(&ExprType::INT, elem).is_some(), + None => true, + }; + if has_scalar_rule || has_range_list_rule { Some(target.clone()) } else { None diff --git a/crates/openjd-expr/tests/integration/test_expr_value.rs b/crates/openjd-expr/tests/integration/test_expr_value.rs index 3236fbd8..a9a4e35f 100644 --- a/crates/openjd-expr/tests/integration/test_expr_value.rs +++ b/crates/openjd-expr/tests/integration/test_expr_value.rs @@ -280,6 +280,73 @@ fn coerce_unresolved_errors_when_no_union_member_succeeds() { assert_eq!(error, format!("Cannot coerce {source} to int")); } +/// A coerced value must satisfy the target it was coerced to. A +/// `range_expr` materializes to `list[int]`, so a `list[T]` target needs +/// the elements widened to `T` rather than handed back as ints. +#[test] +fn coerce_range_expr_to_list_widens_elements() { + let range = RangeExpr::from_values(vec![1, 2, 3]).unwrap(); + for target in ["list[int]", "list[float]", "list[string]"] { + let target = ExprType::parse(target).unwrap(); + let value = ExprValue::RangeExpr(range.clone()) + .coerce(&target, PathFormat::Posix) + .unwrap(); + assert_eq!(value.expr_type(), target, "target={target}"); + } +} + +/// The unresolved table must agree with the concrete one: accepting a +/// `list[T]` target only when `int` itself coerces to `T`. +#[test] +fn coerce_unresolved_range_expr_to_list_matches_concrete() { + for target in ["list[int]", "list[float]", "list[string]"] { + let target = ExprType::parse(target).unwrap(); + let value = ExprValue::unresolved(ExprType::RANGE_EXPR) + .coerce(&target, PathFormat::Posix) + .unwrap(); + assert_eq!(value, ExprValue::unresolved(target)); + } +} + +/// `list[bool]` has no `int → bool` rule, so both paths must reject it +/// rather than one silently yielding a `list[int]`. +#[test] +fn coerce_range_expr_to_incompatible_list_errors_on_both_paths() { + let target = ExprType::list(ExprType::BOOL); + let range = RangeExpr::from_values(vec![1, 2, 3]).unwrap(); + assert!(ExprValue::RangeExpr(range) + .coerce(&target, PathFormat::Posix) + .is_err()); + assert_eq!( + ExprValue::unresolved(ExprType::RANGE_EXPR) + .coerce(&target, PathFormat::Posix) + .unwrap_err(), + "Cannot coerce range_expr to list[bool]" + ); +} + +/// A concrete value never coerces to a type variable, so an unresolved +/// placeholder must not either — otherwise validation would accept an +/// expression that can only fail once the value is known. +#[test] +fn coerce_unresolved_rejects_type_var_target() { + for target in ["T", "T1", "T2", "T3"] { + let target = ExprType::parse(target).unwrap(); + assert!( + ExprValue::Int(42) + .coerce(&target, PathFormat::Posix) + .is_err(), + "concrete should reject {target}" + ); + assert_eq!( + ExprValue::unresolved(ExprType::INT) + .coerce(&target, PathFormat::Posix) + .unwrap_err(), + format!("Cannot coerce int to {target}") + ); + } +} + // ══════════════════════════════════════════════════════════════ // repr_python // ══════════════════════════════════════════════════════════════ diff --git a/specs/expr/values.md b/specs/expr/values.md index cbc4a8f9..0b964ac6 100644 --- a/specs/expr/values.md +++ b/specs/expr/values.md @@ -357,9 +357,20 @@ Applied when the evaluation result needs to match an expected type: - STRING → FLOAT (parse) - INT → FLOAT - RANGE_EXPR → STRING -- RANGE_EXPR → LIST[INT] +- RANGE_EXPR → LIST[T] (materialize to `LIST[INT]`, then widen element-wise) - LIST[T] → LIST[U] (element-wise coercion) +Coercion is type-directed: when it succeeds, the resulting value's type +satisfies the requested target. A `range_expr` coerced to `list[float]` +therefore yields a `list[float]`, not the `list[int]` it materializes +into first; a target whose element type `int` cannot reach, such as +`list[bool]`, is rejected instead. + +A **type variable** target (`T`, `T1`, `T2`, `T3`) has no coercion rule. +Type variables are placeholders in generic function signatures, resolved +by signature matching before any value is coerced, so reaching coercion +with one still unbound is always an error. + An **`any`** target is unconstrained (RFC 0005 lists it as "matches anything"): every value is returned unchanged, so an `any` target can never be the reason a coercion fails (issue #291, case C). @@ -506,3 +517,12 @@ Checks that require a concrete payload are deferred until runtime. For example, must still parse as an integer. A source and target with no type-level coercion rule, such as `unresolved[list[int]]` against `int`, is rejected during validation. + +The two directions are deliberately asymmetric, and only in one direction. +Unresolved coercion may accept a pair the concrete value later rejects, because +the deciding information is a payload the placeholder does not carry. It must +never reject a pair the concrete value would accept: doing so fails a template at +validation time that would have run correctly, which no later stage can recover +from. Any such case is a bug in the type-level table, not a deliberate +narrowing — the `range_expr → list[T]` and type-variable-target rules above apply +identically on both paths for this reason. From e1348cbbf3713a1b3ac39b6f25a510309c8144a0 Mon Sep 17 00:00:00 2001 From: Mark <399551+mwiebe@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:42:02 -0700 Subject: [PATCH 5/6] fix(expr): restrict implicit range_expr coercion to list[int] The materialize-then-widen behavior introduced in the previous commit overshot: RFC 0005's implicit coercion table lists list[int] as the only list type a range_expr coerces to, and implicit rules do not chain. The widening belongs to the explicit list() conversion (RFC 0006), whose list[int] result the list[T] -> list[U] rule can then apply to. Restore the postcondition the right way: reject a list[T] target with any element type other than int, on both the concrete path and the unresolved type-level table, instead of widening element-wise toward the target. The target check now runs before the materialization size cap so an invalid target reports the type error rather than a size error. Update specs/expr/values.md and the affected tests to match. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com> --- crates/openjd-expr/src/value.rs | 34 +++++----- .../tests/integration/test_expr_value.rs | 67 ++++++++++--------- specs/expr/values.md | 15 +++-- 3 files changed, 63 insertions(+), 53 deletions(-) diff --git a/crates/openjd-expr/src/value.rs b/crates/openjd-expr/src/value.rs index 59111648..9d225c42 100644 --- a/crates/openjd-expr/src/value.rs +++ b/crates/openjd-expr/src/value.rs @@ -832,6 +832,20 @@ impl ExprValue { } (ExprValue::RangeExpr(r), TypeCode::String) => Ok(ExprValue::String(r.to_string())), (ExprValue::RangeExpr(r), TypeCode::List) => { + // RFC 0005: `list[int]` is the only list type a range_expr + // implicitly coerces to. Implicit rules do not chain, so the + // materialized `list[int]` is never widened element-wise + // toward another target; templates use the explicit `list()` + // conversion (RFC 0006) when they want that. + if let Some(elem) = target.params().first() { + if elem != &ExprType::INT { + return Err(format!( + "Cannot coerce range_expr to {target}: a range \ + expression only implicitly coerces to list[int] \ + (use list() for an explicit conversion)" + )); + } + } // coerce() runs outside any EvalContext (post-evaluation // target-type hook, public API), so no operation or memory // budget applies here. Cap the materialization at the @@ -846,17 +860,7 @@ impl ExprValue { crate::eval::DEFAULT_OPERATION_LIMIT )); } - let materialized = ExprValue::ListInt(r.to_vec()); - match target.params().first() { - // A `list[int]` target (or a bare `list`) is satisfied by - // the materialized range. Any other element type needs the - // element-wise pass below, or the result would not satisfy - // the target it was coerced to. - Some(elem) if elem != &ExprType::INT => { - materialized.coerce(target, path_format) - } - _ => Ok(materialized), - } + Ok(ExprValue::ListInt(r.to_vec())) } _ if target.code() == TypeCode::List && target.params().len() == 1 => { let elem_type = &target.params()[0]; @@ -968,13 +972,13 @@ impl ExprValue { | TypeCode::RangeExpr ) ); - // A `range_expr` materializes to `list[int]`, which then satisfies a - // `list[T]` target whenever `int` itself coerces to `T` — mirroring - // the concrete path's materialize-then-widen behavior. + // `list[int]` is the only list type a `range_expr` implicitly + // coerces to (RFC 0005); implicit rules do not chain, so no + // element-wise widening applies — mirroring the concrete path. let has_range_list_rule = source.code() == TypeCode::RangeExpr && target.code() == TypeCode::List && match target.params().first() { - Some(elem) => Self::unresolved_coercion_result(&ExprType::INT, elem).is_some(), + Some(elem) => elem == &ExprType::INT, None => true, }; if has_scalar_rule || has_range_list_rule { diff --git a/crates/openjd-expr/tests/integration/test_expr_value.rs b/crates/openjd-expr/tests/integration/test_expr_value.rs index a9a4e35f..43c9333a 100644 --- a/crates/openjd-expr/tests/integration/test_expr_value.rs +++ b/crates/openjd-expr/tests/integration/test_expr_value.rs @@ -280,49 +280,52 @@ fn coerce_unresolved_errors_when_no_union_member_succeeds() { assert_eq!(error, format!("Cannot coerce {source} to int")); } -/// A coerced value must satisfy the target it was coerced to. A -/// `range_expr` materializes to `list[int]`, so a `list[T]` target needs -/// the elements widened to `T` rather than handed back as ints. +/// RFC 0005: `list[int]` is the only list type a `range_expr` implicitly +/// coerces to, and a coerced value must satisfy the target it was coerced +/// to — so the coercion yields a `list[int]`, never a widened list. #[test] -fn coerce_range_expr_to_list_widens_elements() { +fn coerce_range_expr_to_list_int_only() { let range = RangeExpr::from_values(vec![1, 2, 3]).unwrap(); - for target in ["list[int]", "list[float]", "list[string]"] { - let target = ExprType::parse(target).unwrap(); - let value = ExprValue::RangeExpr(range.clone()) - .coerce(&target, PathFormat::Posix) - .unwrap(); - assert_eq!(value.expr_type(), target, "target={target}"); - } + let target = ExprType::list(ExprType::INT); + let value = ExprValue::RangeExpr(range) + .coerce(&target, PathFormat::Posix) + .unwrap(); + assert_eq!(value, ExprValue::ListInt(vec![1, 2, 3])); } -/// The unresolved table must agree with the concrete one: accepting a -/// `list[T]` target only when `int` itself coerces to `T`. +/// Implicit coercion rules do not chain: a `list[T]` target with any +/// element type other than `int` is rejected on both the concrete and +/// unresolved paths, rather than materializing and widening element-wise. +/// Templates use the explicit `list()` conversion for that. #[test] -fn coerce_unresolved_range_expr_to_list_matches_concrete() { - for target in ["list[int]", "list[float]", "list[string]"] { +fn coerce_range_expr_to_other_list_errors_on_both_paths() { + let range = RangeExpr::from_values(vec![1, 2, 3]).unwrap(); + for target in ["list[float]", "list[string]", "list[bool]"] { let target = ExprType::parse(target).unwrap(); - let value = ExprValue::unresolved(ExprType::RANGE_EXPR) - .coerce(&target, PathFormat::Posix) - .unwrap(); - assert_eq!(value, ExprValue::unresolved(target)); + assert!( + ExprValue::RangeExpr(range.clone()) + .coerce(&target, PathFormat::Posix) + .is_err(), + "concrete should reject {target}" + ); + assert_eq!( + ExprValue::unresolved(ExprType::RANGE_EXPR) + .coerce(&target, PathFormat::Posix) + .unwrap_err(), + format!("Cannot coerce range_expr to {target}") + ); } } -/// `list[bool]` has no `int → bool` rule, so both paths must reject it -/// rather than one silently yielding a `list[int]`. +/// The unresolved table must agree with the concrete one, accepting only +/// the `list[int]` target. #[test] -fn coerce_range_expr_to_incompatible_list_errors_on_both_paths() { - let target = ExprType::list(ExprType::BOOL); - let range = RangeExpr::from_values(vec![1, 2, 3]).unwrap(); - assert!(ExprValue::RangeExpr(range) +fn coerce_unresolved_range_expr_to_list_matches_concrete() { + let target = ExprType::list(ExprType::INT); + let value = ExprValue::unresolved(ExprType::RANGE_EXPR) .coerce(&target, PathFormat::Posix) - .is_err()); - assert_eq!( - ExprValue::unresolved(ExprType::RANGE_EXPR) - .coerce(&target, PathFormat::Posix) - .unwrap_err(), - "Cannot coerce range_expr to list[bool]" - ); + .unwrap(); + assert_eq!(value, ExprValue::unresolved(target)); } /// A concrete value never coerces to a type variable, so an unresolved diff --git a/specs/expr/values.md b/specs/expr/values.md index 0b964ac6..e3d287ca 100644 --- a/specs/expr/values.md +++ b/specs/expr/values.md @@ -357,14 +357,17 @@ Applied when the evaluation result needs to match an expected type: - STRING → FLOAT (parse) - INT → FLOAT - RANGE_EXPR → STRING -- RANGE_EXPR → LIST[T] (materialize to `LIST[INT]`, then widen element-wise) +- RANGE_EXPR → LIST[INT] (the only list type; see below) - LIST[T] → LIST[U] (element-wise coercion) Coercion is type-directed: when it succeeds, the resulting value's type -satisfies the requested target. A `range_expr` coerced to `list[float]` -therefore yields a `list[float]`, not the `list[int]` it materializes -into first; a target whose element type `int` cannot reach, such as -`list[bool]`, is rejected instead. +satisfies the requested target. Implicit rules do not chain: `list[int]` +is the only list type a `range_expr` implicitly coerces to (RFC 0005), so +a `list[T]` target with any other element type — `list[float]`, +`list[string]`, `list[bool]` — is rejected rather than materialized and +widened element-wise. Templates that want the widened list chain the +explicit `list()` conversion (RFC 0006), whose `list[int]` result the +`LIST[T] → LIST[U]` rule then applies to. A **type variable** target (`T`, `T1`, `T2`, `T3`) has no coercion rule. Type variables are placeholders in generic function signatures, resolved @@ -524,5 +527,5 @@ the deciding information is a payload the placeholder does not carry. It must never reject a pair the concrete value would accept: doing so fails a template at validation time that would have run correctly, which no later stage can recover from. Any such case is a bug in the type-level table, not a deliberate -narrowing — the `range_expr → list[T]` and type-variable-target rules above apply +narrowing — the `range_expr → list[int]` and type-variable-target rules above apply identically on both paths for this reason. From 9af43abcddff3601b004c0fe0dccf43937c12f1d Mon Sep 17 00:00:00 2001 From: Mark <399551+mwiebe@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:25:12 -0700 Subject: [PATCH 6/6] fix(expr): accept list[any] range targets and defer list element checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the coercion tables, both rejections in the direction the asymmetry rule forbids (failing validation for a value that would have run correctly): The range_expr element-type guard compared against int by strict equality, rejecting a list[any] target even though a list[int] value already satisfies list[any] — no widening involved. Accept an int or any element target, on both the concrete path and the type-level table (which was also internally inconsistent: unresolved[list[int]] coerced to list[any] but unresolved[range_expr] did not). The type-level list/list rule required the source element type to reach the target's, but the concrete path accepts an empty list of any element type against any list[U] (element-wise over zero elements). Since an unresolved[list[S]] could resolve to the empty list, accept every list/list pair and defer the element compatibility check until the payload is known — consistent with how unresolved[string] -> int defers the parse check. Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com> --- crates/openjd-expr/src/value.rs | 24 ++++--- .../tests/integration/test_expr_value.rs | 64 +++++++++++++++---- specs/expr/values.md | 17 +++-- 3 files changed, 76 insertions(+), 29 deletions(-) diff --git a/crates/openjd-expr/src/value.rs b/crates/openjd-expr/src/value.rs index 9d225c42..01af370e 100644 --- a/crates/openjd-expr/src/value.rs +++ b/crates/openjd-expr/src/value.rs @@ -836,9 +836,11 @@ impl ExprValue { // implicitly coerces to. Implicit rules do not chain, so the // materialized `list[int]` is never widened element-wise // toward another target; templates use the explicit `list()` - // conversion (RFC 0006) when they want that. + // conversion (RFC 0006) when they want that. An `any` + // element target is accepted because a `list[int]` value + // already satisfies `list[any]` — no widening involved. if let Some(elem) = target.params().first() { - if elem != &ExprType::INT { + if elem != &ExprType::INT && elem.code() != TypeCode::Any { return Err(format!( "Cannot coerce range_expr to {target}: a range \ expression only implicitly coerces to list[int] \ @@ -941,13 +943,13 @@ impl ExprValue { && target.code() == TypeCode::List && target.params().len() == 1 { - let source_elem = &source.params()[0]; - if source_elem == &ExprType::NULLTYPE - || Self::unresolved_coercion_result(source_elem, &target.params()[0]).is_some() - { - return Some(target.clone()); - } - return None; + // Any list/list pair is accepted: an `unresolved[list[S]]` + // could resolve to the empty list, which the concrete path + // coerces to any `list[U]` (element-wise over zero elements). + // Rejecting here would fail a template during validation that + // could have run correctly. The element compatibility check is + // deferred until the value resolves with a non-empty payload. + return Some(target.clone()); } let has_scalar_rule = matches!( @@ -975,10 +977,12 @@ impl ExprValue { // `list[int]` is the only list type a `range_expr` implicitly // coerces to (RFC 0005); implicit rules do not chain, so no // element-wise widening applies — mirroring the concrete path. + // An `any` element target is accepted because a `list[int]` value + // already satisfies `list[any]`. let has_range_list_rule = source.code() == TypeCode::RangeExpr && target.code() == TypeCode::List && match target.params().first() { - Some(elem) => elem == &ExprType::INT, + Some(elem) => elem == &ExprType::INT || elem.code() == TypeCode::Any, None => true, }; if has_scalar_rule || has_range_list_rule { diff --git a/crates/openjd-expr/tests/integration/test_expr_value.rs b/crates/openjd-expr/tests/integration/test_expr_value.rs index 43c9333a..0c2d27d0 100644 --- a/crates/openjd-expr/tests/integration/test_expr_value.rs +++ b/crates/openjd-expr/tests/integration/test_expr_value.rs @@ -238,6 +238,38 @@ fn coerce_unresolved_empty_list_to_typed_list() { ); } +/// Any list/list pair is accepted at the type level, even when the +/// element types have no coercion rule: an `unresolved[list[S]]` could +/// resolve to the empty list, which the concrete path coerces to any +/// `list[U]`. Rejecting the pair would fail a template during validation +/// that could have run correctly. The element check defers to resolution. +#[test] +fn coerce_unresolved_list_pair_accepted_unconditionally() { + for (source, target) in [ + ("list[int]", "list[bool]"), + ("list[int]", "list[path]"), + ("list[string]", "list[list[int]]"), + ] { + let source = ExprType::parse(source).unwrap(); + let target = ExprType::parse(target).unwrap(); + // The concrete empty list coerces to the target... + let empty = ExprValue::make_list(vec![], source.params()[0].clone()).unwrap(); + assert!( + empty.coerce(&target, PathFormat::Posix).is_ok(), + "concrete [] as {source} should coerce to {target}" + ); + // ...so the unresolved pair must be accepted too. + let value = ExprValue::unresolved(source.clone()) + .coerce(&target, PathFormat::Posix) + .unwrap(); + assert_eq!( + value, + ExprValue::unresolved(target.clone()), + "unresolved[{source}] should coerce to {target}" + ); + } +} + #[test] fn coerce_unresolved_unions_successful_result_types() { let value = ExprValue::unresolved(ExprType::union(vec![ExprType::BOOL, ExprType::INT])) @@ -282,15 +314,19 @@ fn coerce_unresolved_errors_when_no_union_member_succeeds() { /// RFC 0005: `list[int]` is the only list type a `range_expr` implicitly /// coerces to, and a coerced value must satisfy the target it was coerced -/// to — so the coercion yields a `list[int]`, never a widened list. +/// to — so the coercion yields a `list[int]`, never a widened list. A +/// `list[any]` target is also satisfied by a `list[int]` value (no +/// widening involved), so it is accepted too. #[test] fn coerce_range_expr_to_list_int_only() { let range = RangeExpr::from_values(vec![1, 2, 3]).unwrap(); - let target = ExprType::list(ExprType::INT); - let value = ExprValue::RangeExpr(range) - .coerce(&target, PathFormat::Posix) - .unwrap(); - assert_eq!(value, ExprValue::ListInt(vec![1, 2, 3])); + for target in ["list[int]", "list[any]"] { + let target = ExprType::parse(target).unwrap(); + let value = ExprValue::RangeExpr(range.clone()) + .coerce(&target, PathFormat::Posix) + .unwrap(); + assert_eq!(value, ExprValue::ListInt(vec![1, 2, 3]), "target={target}"); + } } /// Implicit coercion rules do not chain: a `list[T]` target with any @@ -317,15 +353,17 @@ fn coerce_range_expr_to_other_list_errors_on_both_paths() { } } -/// The unresolved table must agree with the concrete one, accepting only -/// the `list[int]` target. +/// The unresolved table must agree with the concrete one, accepting the +/// `list[int]` and `list[any]` targets. #[test] fn coerce_unresolved_range_expr_to_list_matches_concrete() { - let target = ExprType::list(ExprType::INT); - let value = ExprValue::unresolved(ExprType::RANGE_EXPR) - .coerce(&target, PathFormat::Posix) - .unwrap(); - assert_eq!(value, ExprValue::unresolved(target)); + for target in ["list[int]", "list[any]"] { + let target = ExprType::parse(target).unwrap(); + let value = ExprValue::unresolved(ExprType::RANGE_EXPR) + .coerce(&target, PathFormat::Posix) + .unwrap(); + assert_eq!(value, ExprValue::unresolved(target)); + } } /// A concrete value never coerces to a type variable, so an unresolved diff --git a/specs/expr/values.md b/specs/expr/values.md index e3d287ca..8c4e66f7 100644 --- a/specs/expr/values.md +++ b/specs/expr/values.md @@ -365,9 +365,11 @@ satisfies the requested target. Implicit rules do not chain: `list[int]` is the only list type a `range_expr` implicitly coerces to (RFC 0005), so a `list[T]` target with any other element type — `list[float]`, `list[string]`, `list[bool]` — is rejected rather than materialized and -widened element-wise. Templates that want the widened list chain the -explicit `list()` conversion (RFC 0006), whose `list[int]` result the -`LIST[T] → LIST[U]` rule then applies to. +widened element-wise. (A `list[any]` target is accepted, since a +`list[int]` value already satisfies it — no widening involved.) Templates +that want the widened list chain the explicit `list()` conversion +(RFC 0006), whose `list[int]` result the `LIST[T] → LIST[U]` rule then +applies to. A **type variable** target (`T`, `T1`, `T2`, `T3`) has no coercion rule. Type variables are placeholders in generic function signatures, resolved @@ -517,9 +519,12 @@ possibility cannot. Checks that require a concrete payload are deferred until runtime. For example, `unresolved[string]` can narrow to `unresolved[int]`; once resolved, the string -must still parse as an integer. A source and target with no type-level coercion -rule, such as `unresolved[list[int]]` against `int`, is rejected during -validation. +must still parse as an integer. Any `unresolved[list[S]]` against any `list[U]` +target is accepted for the same reason: the value could resolve to the empty +list, which coerces to every list type, so element compatibility can only be +checked once the payload is known. A source and target with no type-level +coercion rule at all, such as `unresolved[list[int]]` against `int`, is +rejected during validation. The two directions are deliberately asymmetric, and only in one direction. Unresolved coercion may accept a pair the concrete value later rejects, because