From 1e40171ea444864fd843e6ebad0881e36580a0ec Mon Sep 17 00:00:00 2001 From: Raito Bezarius Date: Tue, 20 May 2025 17:56:10 +0200 Subject: [PATCH] libexpr: coerce integers under the XP feature `coerce-integers` This introduces a new (demanded?) feature for coercing integers in interpolation arguments under the experimental feature `coerce-integers`. This feature is being introduced behind an *experimental feature flag* due to the cautious approach we're taking. The codebase has a track record of revealing unexpected behaviors, often in subtle ways, so we want to give this sufficient time and exposure before making it stable. To remove the experimental flag, we want to see **at least two releases or six months of real-world usage -- whichever is longer** -- that demonstrate strong confidence the feature doesn't introduce regressions or unintended side effects. If that level of confidence is reached, we'll proceed to stabilize it. Change-Id: I825904719eeba8f0e2a93cd6b93cfe6cebd7d827 Signed-off-by: Raito Bezarius --- doc/manual/change-authors.yml | 3 ++ doc/manual/rl-next/integer-coercion.md | 47 +++++++++++++++++++ lix/libexpr/eval.cc | 14 +++++- lix/libexpr/value.hh | 3 ++ .../experimental-features/coerce-integers.md | 20 ++++++++ lix/libutil/meson.build | 1 + ...accidental-coerce-integer-attempts.err.exp | 14 ++++++ ...l-accidental-coerce-integer-attempts.flags | 1 + ...ail-accidental-coerce-integer-attempts.nix | 1 + ...al-fail-coerce-integers-without-xp.err.exp | 8 ++++ .../eval-fail-coerce-integers-without-xp.nix | 1 + ...-coercive-builtins-with-xp-feature.err.exp | 10 ++++ ...er-coercive-builtins-with-xp-feature.flags | 1 + ...over-coercive-builtins-with-xp-feature.nix | 1 + .../lang/eval-okay-coerce-integers.exp | 1 + .../lang/eval-okay-coerce-integers.flags | 1 + .../lang/eval-okay-coerce-integers.nix | 1 + 17 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 doc/manual/rl-next/integer-coercion.md create mode 100644 lix/libutil/experimental-features/coerce-integers.md create mode 100644 tests/functional/lang/eval-fail-accidental-coerce-integer-attempts.err.exp create mode 100644 tests/functional/lang/eval-fail-accidental-coerce-integer-attempts.flags create mode 100644 tests/functional/lang/eval-fail-accidental-coerce-integer-attempts.nix create mode 100644 tests/functional/lang/eval-fail-coerce-integers-without-xp.err.exp create mode 100644 tests/functional/lang/eval-fail-coerce-integers-without-xp.nix create mode 100644 tests/functional/lang/eval-fail-over-coercive-builtins-with-xp-feature.err.exp create mode 100644 tests/functional/lang/eval-fail-over-coercive-builtins-with-xp-feature.flags create mode 100644 tests/functional/lang/eval-fail-over-coercive-builtins-with-xp-feature.nix create mode 100644 tests/functional/lang/eval-okay-coerce-integers.exp create mode 100644 tests/functional/lang/eval-okay-coerce-integers.flags create mode 100644 tests/functional/lang/eval-okay-coerce-integers.nix diff --git a/doc/manual/change-authors.yml b/doc/manual/change-authors.yml index f79395a46..142686d82 100644 --- a/doc/manual/change-authors.yml +++ b/doc/manual/change-authors.yml @@ -66,6 +66,9 @@ delan: forgejo: delan github: delan +delroth: + github: delroth + detroyejr: display_name: Jonathan De Troye github: detroyejr diff --git a/doc/manual/rl-next/integer-coercion.md b/doc/manual/rl-next/integer-coercion.md new file mode 100644 index 000000000..38b1b5a4a --- /dev/null +++ b/doc/manual/rl-next/integer-coercion.md @@ -0,0 +1,47 @@ +--- +synopsis: Experimental integer coercion in interpolated strings +issues: [] +cls: [3198] +category: "Features" +credits: [raito, delroth, horrors, winter] +--- + +Ever tried interpolating a port number in Lix and ended up with something like this? + +```nix +"http://${config.network.host}:${builtins.toString config.network.port}/" +``` + +You're not alone. Thousands of Lix users suffer every day from excessive `builtins.toString` syndrome. It’s 2025, and we still have to cast integers to use them in strings. + +To address this, Lix introduces the **`coerce-integers`** experimental feature. When enabled, interpolated integers within `"${...}"` are automatically coerced to strings. This allows writing: + +```nix +"http://${config.network.host}:${config.network.port}/" +``` + +without additional conversion. + +To enable the feature, you need to add `coerce-integers` to your set of experimental features. + +### Stabilization criteria + +The `coerce-integers` feature is experimental and limited strictly to string interpolation (`"${...}"`). Before stabilization, the following must hold: + +1. **Interpolation-only** + Coercion must not occur outside interpolation. Expressions like `"" + 42` must continue to fail. + +2. **Expectation that no explicit cast are being observed** + Cases observing explicit coercion (e.g., via `tryEval` gadget or similar) are expected not to be load-bearing in actual production code. + +### Timeline for stabilization + +If the feature proves safe and is widely adopted across typical usage (e.g., actual configurations in the wild turning on the flag, non-trivial out-of-tree projects using it), the experimental flag will be removed **after six months of active use or two Lix releases**, whichever is longer. + +This avoids locking the feature in experimental status indefinitely, as happened with Flakes, while allowing time for validation and ecosystem integration. + +### What about coercing floats or more? + +Coercion beyond integers -- such as for floats or other types -- is **not planned**, even under an experimental flag. Questions like "what is the canonical string representation of a float?" involve subtle and context-dependent trade-offs. Without a robust and principled mechanism to define and audit such behavior, introducing broader coercion risks setting unintended and hard-to-reverse precedents. The scope of `coerce-integers` is intentionally narrow and will remain so. + +In terms of outlook, a proposal like https://git.lix.systems/lix-project/lix/issues/835 could pave the way for a better solution. diff --git a/lix/libexpr/eval.cc b/lix/libexpr/eval.cc index 34a0cb678..feb27f860 100644 --- a/lix/libexpr/eval.cc +++ b/lix/libexpr/eval.cc @@ -2028,12 +2028,17 @@ void ExprConcatStrings::eval(EvalState & state, Env & env, Value & v) state.ctx.errors.make("cannot add %1% to a float", showType(vTmp)).atPos(i_pos).withFrame(env, *this).debugThrow(); } else { if (s.empty()) s.reserve(es.size()); + + /* If we are coercing inside of an interpolation, we may allow slightly more comfort by coercing things like integers. */ + auto coercionMode = isInterpolation && featureSettings.isEnabled(Xp::CoerceIntegers) + ? StringCoercionMode::Interpolation : StringCoercionMode::Strict; + /* skip canonization of first path, which would only be not canonized in the first place if it's coming from a ./${foo} type path */ auto part = state.coerceToString(i_pos, vTmp, context, "while evaluating a path segment", - StringCoercionMode::Strict, firstType == nString, !first); + coercionMode, firstType == nString, !first); sSize += part->size(); s.emplace_back(std::move(part)); } @@ -2340,12 +2345,17 @@ BackedStringView EvalState::coerceToString( } } + /* Raito: Any addition to this mode is subject to extra scrutiny + * until we have better formatting tools. */ + if (mode >= StringCoercionMode::Interpolation) { + if (v.type() == nInt) return std::to_string(v.integer.value); + } + if (mode >= StringCoercionMode::ToString) { /* Note that `false' is represented as an empty string for shell scripting convenience, just like `null'. */ if (v.type() == nBool && v.boolean) return "1"; if (v.type() == nBool && !v.boolean) return ""; - if (v.type() == nInt) return std::to_string(v.integer.value); if (v.type() == nFloat) return std::to_string(v.fpoint); if (v.type() == nNull) return ""; diff --git a/lix/libexpr/value.hh b/lix/libexpr/value.hh index 07c8c42f9..69177bdaa 100644 --- a/lix/libexpr/value.hh +++ b/lix/libexpr/value.hh @@ -69,11 +69,14 @@ typedef enum { * * - Strict: Only allow coercion of values that are already strings, * paths, or derivations. + * - Interpolation: Additionally allow coercion of unambiguously printable values in a string, for + * now: integers. This mode is meant as a stopgap measure until we get better formatting tools. * - ToString: Additionally allow coercion of integers, booleans, null, * and lists to strings. */ enum class StringCoercionMode { Strict, + Interpolation, ToString, }; diff --git a/lix/libutil/experimental-features/coerce-integers.md b/lix/libutil/experimental-features/coerce-integers.md new file mode 100644 index 000000000..07ffac519 --- /dev/null +++ b/lix/libutil/experimental-features/coerce-integers.md @@ -0,0 +1,20 @@ +--- +name: coerce-integers +internalName: CoerceIntegers +--- +Automatically coerces integer values used in string interpolation to strings. This feature allows constructs like: + +```nix +let + version = 3; +in + "v${version}" +``` + +to evaluate to: + +```nix +"v3" +``` + +instead of producing a type error due to mismatched types in interpolation and hence requiring a call to `builtins.toString`. diff --git a/lix/libutil/meson.build b/lix/libutil/meson.build index 6dc39ff76..e396b0cf9 100644 --- a/lix/libutil/meson.build +++ b/lix/libutil/meson.build @@ -144,6 +144,7 @@ experimental_feature_definitions = files( 'experimental-features/auto-allocate-uids.md', 'experimental-features/ca-derivations.md', 'experimental-features/cgroups.md', + 'experimental-features/coerce-integers.md', 'experimental-features/daemon-trust-override.md', 'experimental-features/fetch-closure.md', 'experimental-features/flakes.md', diff --git a/tests/functional/lang/eval-fail-accidental-coerce-integer-attempts.err.exp b/tests/functional/lang/eval-fail-accidental-coerce-integer-attempts.err.exp new file mode 100644 index 000000000..04cfd8507 --- /dev/null +++ b/tests/functional/lang/eval-fail-accidental-coerce-integer-attempts.err.exp @@ -0,0 +1,14 @@ +error: + … while evaluating x + at /pwd/lang/eval-fail-accidental-coerce-integer-attempts.nix:1:21: + 1| let x = "" + 42; in x == "42" + | ^ + 2| + + … while evaluating a path segment + at /pwd/lang/eval-fail-accidental-coerce-integer-attempts.nix:1:14: + 1| let x = "" + 42; in x == "42" + | ^ + 2| + + error: cannot coerce an integer to a string: 42 diff --git a/tests/functional/lang/eval-fail-accidental-coerce-integer-attempts.flags b/tests/functional/lang/eval-fail-accidental-coerce-integer-attempts.flags new file mode 100644 index 000000000..675354843 --- /dev/null +++ b/tests/functional/lang/eval-fail-accidental-coerce-integer-attempts.flags @@ -0,0 +1 @@ +--extra-experimental-features coerce-integers diff --git a/tests/functional/lang/eval-fail-accidental-coerce-integer-attempts.nix b/tests/functional/lang/eval-fail-accidental-coerce-integer-attempts.nix new file mode 100644 index 000000000..7efb51565 --- /dev/null +++ b/tests/functional/lang/eval-fail-accidental-coerce-integer-attempts.nix @@ -0,0 +1 @@ +let x = "" + 42; in x == "42" diff --git a/tests/functional/lang/eval-fail-coerce-integers-without-xp.err.exp b/tests/functional/lang/eval-fail-coerce-integers-without-xp.err.exp new file mode 100644 index 000000000..e59dd7c6b --- /dev/null +++ b/tests/functional/lang/eval-fail-coerce-integers-without-xp.err.exp @@ -0,0 +1,8 @@ +error: + … while evaluating a path segment + at /pwd/lang/eval-fail-coerce-integers-without-xp.nix:1:2: + 1| "${42}" == 42 + | ^ + 2| + + error: cannot coerce an integer to a string: 42 diff --git a/tests/functional/lang/eval-fail-coerce-integers-without-xp.nix b/tests/functional/lang/eval-fail-coerce-integers-without-xp.nix new file mode 100644 index 000000000..e9788f5ca --- /dev/null +++ b/tests/functional/lang/eval-fail-coerce-integers-without-xp.nix @@ -0,0 +1 @@ +"${42}" == 42 diff --git a/tests/functional/lang/eval-fail-over-coercive-builtins-with-xp-feature.err.exp b/tests/functional/lang/eval-fail-over-coercive-builtins-with-xp-feature.err.exp new file mode 100644 index 000000000..41287fbe3 --- /dev/null +++ b/tests/functional/lang/eval-fail-over-coercive-builtins-with-xp-feature.err.exp @@ -0,0 +1,10 @@ +error: + … while calling the 'substring' builtin + at /pwd/lang/eval-fail-over-coercive-builtins-with-xp-feature.nix:1:1: + 1| builtins.substring 0 1 42 == "4" + | ^ + 2| + + … while evaluating the third argument (the string) passed to builtins.substring + + error: cannot coerce an integer to a string: 42 diff --git a/tests/functional/lang/eval-fail-over-coercive-builtins-with-xp-feature.flags b/tests/functional/lang/eval-fail-over-coercive-builtins-with-xp-feature.flags new file mode 100644 index 000000000..675354843 --- /dev/null +++ b/tests/functional/lang/eval-fail-over-coercive-builtins-with-xp-feature.flags @@ -0,0 +1 @@ +--extra-experimental-features coerce-integers diff --git a/tests/functional/lang/eval-fail-over-coercive-builtins-with-xp-feature.nix b/tests/functional/lang/eval-fail-over-coercive-builtins-with-xp-feature.nix new file mode 100644 index 000000000..a7c93a937 --- /dev/null +++ b/tests/functional/lang/eval-fail-over-coercive-builtins-with-xp-feature.nix @@ -0,0 +1 @@ +builtins.substring 0 1 42 == "4" diff --git a/tests/functional/lang/eval-okay-coerce-integers.exp b/tests/functional/lang/eval-okay-coerce-integers.exp new file mode 100644 index 000000000..27ba77dda --- /dev/null +++ b/tests/functional/lang/eval-okay-coerce-integers.exp @@ -0,0 +1 @@ +true diff --git a/tests/functional/lang/eval-okay-coerce-integers.flags b/tests/functional/lang/eval-okay-coerce-integers.flags new file mode 100644 index 000000000..675354843 --- /dev/null +++ b/tests/functional/lang/eval-okay-coerce-integers.flags @@ -0,0 +1 @@ +--extra-experimental-features coerce-integers diff --git a/tests/functional/lang/eval-okay-coerce-integers.nix b/tests/functional/lang/eval-okay-coerce-integers.nix new file mode 100644 index 000000000..e71d1c8da --- /dev/null +++ b/tests/functional/lang/eval-okay-coerce-integers.nix @@ -0,0 +1 @@ +"${42}" == "42"