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 <raito@lix.systems>
This commit is contained in:
Raito Bezarius
2025-05-27 11:42:53 +02:00
parent 3e4bffcc24
commit 1e40171ea4
17 changed files with 126 additions and 2 deletions
+3
View File
@@ -66,6 +66,9 @@ delan:
forgejo: delan
github: delan
delroth:
github: delroth
detroyejr:
display_name: Jonathan De Troye
github: detroyejr
+47
View File
@@ -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. Its 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.
+12 -2
View File
@@ -2028,12 +2028,17 @@ void ExprConcatStrings::eval(EvalState & state, Env & env, Value & v)
state.ctx.errors.make<EvalError>("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 "";
+3
View File
@@ -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,
};
@@ -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`.
+1
View File
@@ -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',
@@ -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
@@ -0,0 +1 @@
--extra-experimental-features coerce-integers
@@ -0,0 +1 @@
let x = "" + 42; in x == "42"
@@ -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
@@ -0,0 +1 @@
"${42}" == 42
@@ -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
@@ -0,0 +1 @@
--extra-experimental-features coerce-integers
@@ -0,0 +1 @@
builtins.substring 0 1 42 == "4"
@@ -0,0 +1 @@
true
@@ -0,0 +1 @@
--extra-experimental-features coerce-integers
@@ -0,0 +1 @@
"${42}" == "42"