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"