Tsonnet #34 - Dabbling with untouched bindings
Tsonnet gains warnings for unused variables and untouched object fields
Welcome to the Tsonnet series!
If you’re not following along, check out how it all started in the first post of the series.
In the previous post, we wrapped up the arithmetic tutorial:
This time, before proceeding to cover the next tutorial, I’m going to revisit one laziness aspect: the evaluation (or no-evaluation) of untouched bindings.
Warnings, not errors
Up until now, Tsonnet would hard-error on cyclic references regardless of whether the offending variable was ever touched. That’s a bit heavy-handed. Jsonnet is lazily evaluated -- if a variable has a cycle but is never used, the program should still run. We should warn, not panic. For example:
// samples/variables/untouched_variable.jsonnet
local a = 1, b = 42;
bJsonnet behaves like this:
$ jsonnet samples/variables/untouched_variable.jsonnet
42Jsonnet does nothing to alert the programmer that something is unused. Maybe that belongs to a linter, but the compiler not even warning is a bad experience, IMHO.
The same logic applies to unused variables: they’re suspicious, but they shouldn’t crash anything.
// samples/objects/untouched_field.jsonnet
local result = {
a: 1,
b: 42,
};
result.b$ jsonnet samples/objects/untouched_field.jsonnet
42Let’s work through each of these in order.
Warn on cyclic refs for unused variables
The first thing we need is a way to tell which variables are actually reachable from the body of a local expression. For that, I added collect_free_idents and reachable_bindings to the type checker:
collect_free_idents walks an expression and collects every identifier referenced. reachable_bindings does a simple graph traversal starting from the identifiers used in the body, following variable references transitively. If a binding is never reachable from the body, it’s unused.
I’m delegating part of what used to be Local-processing into the translate_seq function, where the logic actually runs, to keep things tidy:
The key part: if a cyclic reference involves a variable that’s reachable from the body, we still error. If it’s unreachable, we just warn and move on. Here’s what it looks like in practice:
$ dune exec -- tsonnet samples/variables/untouched_invalid_variable.jsonnet
Warning: .../untouched_invalid_variable.jsonnet:1:31 Cyclic reference found for c
1: local a = 1, b = a, c = d, d = c;
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Warning: .../untouched_invalid_variable.jsonnet:1:24 Cyclic reference found for d
1: local a = 1, b = a, c = d, d = c;
^^^^^^^^^^^^^^^^^^^^^^^^^^
1c and d have a cycle between them, but b is what the program actually evaluates. So we warn, and produce the result.
Warn on unused variables
With reachability analysis in place, unused variable warnings follow naturally. A variable is unused if it’s not in the reachable set. Adding that to translate_seq:
I also refactored the warning infrastructure a bit. Instead of calling prerr_endline inline, there’s now a proper Error.warn function:
Much cleaner. Let’s see it in action:
$ dune exec -- tsonnet samples/variables/untouched_variable.jsonnet
Warning: .../untouched_variable.jsonnet:1:0 Unused variable a
1: local a = 1, b = 42;
^^^^^^^^^^^^^^^^^^^^
42a is defined but never used. The program still returns 42.
Warn on untouched object fields
Variables were the easy part. Objects are more involved because we’re dealing with two separate phases -- the type checker and the interpreter -- and both need to handle cyclic field references gracefully.
The approach is the same: if a cyclic object field is never accessed during evaluation, warn instead of error. The tricky bit is detecting “accessed during evaluation” correctly.
Both modules now track which fields are actively being evaluated using a mutable ObjectFields set:
(* In interpreter.ml *)
let evaluating_fields = ref ObjectFields.empty
(* In type.ml *)
let translating_fields = ref ObjectFields.emptyWhen we start evaluating a field, we add it to the set. When we’re done (or on error), we remove it. If we try to evaluate a field already in the set -- cycle detected:
For object fields that are never accessed, the type checker now warns instead of erroring. translate_object was changed to iterate over entries and emit warnings rather than propagate errors:
Here’s a sample that demonstrates the distinction. When we only access result.b, the cyclic c/d pair just produces warnings:
// samples/objects/untouched_invalid_field.jsonnet
local result = {
a: 1,
b: self.a,
c: self.d,
d: self.c
};
result.b$ dune exec -- tsonnet samples/objects/untouched_invalid_field.jsonnet
Warning: .../untouched_invalid_field.jsonnet:1:0 Unused variable result
...
Warning: .../untouched_invalid_field.jsonnet:1:15 Cyclic reference found for 1->c
...
Warning: .../untouched_invalid_field.jsonnet:1:15 Cyclic reference found for 1->d
...
1Meanwhile, if a cyclic field is actually accessed at runtime, it still errors hard:
// samples/semantics/invalid_object_with_cyclic_field.jsonnet
{
a: 1,
b: self.c,
c: self.b,
}$ dune exec -- tsonnet samples/semantics/invalid_object_with_cyclic_field.jsonnet
Warning: .../invalid_object_with_cyclic_field.jsonnet:1:0 Cyclic reference found for 1->b
...
.../invalid_object_with_cyclic_field.jsonnet:3:12 Cyclic reference found for 1->c
...
[1]The type checker warns (because it doesn’t know at type-check time which fields will be accessed), but the interpreter finds the cycle at runtime and errors properly.
Warn on cyclic refs for untouched object fields
The previous changes got the type checker side right, but the interpreter was still getting it wrong. When rendering an object, interpret_runtime_object_fields was folding into a plain list and silently skipping any field that errored -- including cyclic ones. So if you did access a cyclic field at runtime, you’d get an empty result instead of an error. Not great.
The fix is straightforward: go back to a monadic fold and let errors propagate normally. The cycle detection in interpret_object_field_access already handles the “is this field in a cycle?” question via evaluating_fields -- interpret_runtime_object_fields doesn’t need to second-guess it.
There’s also a small fix in the type checker’s translate_object_field_access. When chaining into a TruntimeObject, the field lookup was using the outer venv -- meaning self and $ weren’t in scope. The fix builds a proper field_venv before looking up the field, the same way the interpreter does it:
I sprinkled a TODO here because I don’t want to do this refactoring now. XD
Everything is captured in the cram tests:
One thing I noticed while working through the test cases: the sample that used to be called valid_object_with_cyclic_field.jsonnet is not actually valid -- it errors when the cyclic fields are accessed. Renamed it to invalid_object_with_cyclic_field.jsonnet. These things happen when you’re naming files before you’ve implemented the feature that would tell you whether they’re valid or not.
Conclusion
The reachability analysis for variables and the evaluating_fields tracking for objects both push in the same direction -- lean on lazy evaluation instead of fighting it. This is the nature of Jsonnet, and Tsonnet should embrace it.
You can check the entire diff here.
I think, after that, we can start playing with much more fun things: functions!










