Tsonnet #45 - The devil in the details #1
Closing every scope-checking blind spot exposed by removing catch-all patterns — conditionals, field access chains, function definitions, function calls, and closures.
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 removed every catch-all pattern match across the compiler, letting OCaml’s exhaustiveness checker tell us exactly which variants were being silently ignored:
The scope checker is on the operating table first. Let’s go.
Scope-checking conditionals
The If variant was the most obvious omission — the scope checker had no arm for it. Before the catch-all removal, it was swallowed by | _ -> ok (). After the first cleanup pass in the previous post it was still sitting in a list of variants that were silently accepted:
That’s wrong. An if can contain self or $ in its condition, then-branch, or else-branch. If that if is outside an object, the scope checker should catch it.
// samples/errors/conditional_self_out_of_scope.jsonnet
local value = if true then self.one else 1;
valueBefore this fix, that passed without error. validate_if recursively walks all three branches:
Now the earlier example correctly errors:
$ dune exec -- tsonnet samples/errors/conditional_self_out_of_scope.jsonnet
ERROR: samples/errors/conditional_self_out_of_scope.jsonnet:1:27 Can't use self outside of an object
1: local value = if true then self.one else 1;
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[1]The positive case — self inside an if inside an object — still works as expected:
// samples/conditionals/conditional_object_scope.jsonnet
{
one: 1,
two: if true then self.one + 1 else 0,
three: if false then 0 else $.one + 2,
}Scope-checking indexed object field access
The ObjectFieldAccess handler was already checking the scope reference — self, $, or ObjVarRef — against the context. But it was ignoring the chain of field expressions.
The chain (e.g. [self.one] in obj[self.one].two) was not being validated. A chain expression containing self or $ outside an object went undetected.
// samples/errors/object_field_access_index_self_out_of_scope.jsonnet
local obj = { one: 1 };
obj[self.one].two// samples/errors/object_field_access_computed_self_out_of_scope.jsonnet
local obj = { one: 1 };
obj.[self.one].two// samples/errors/object_field_access_index_toplevel_out_of_scope.jsonnet
local obj = { one: 1 };
obj[$.one].twoThe fix threads the chain through to validate_expression_list:
Inside validate_object_field_access, after the scope check runs, the chain gets validated in sequence:
All three variations now produce the correct scope errors.
Scope-checking function definitions
FunctionDef was completely unvalidated. A function defined at the top level could reference self or $ in its body or parameter defaults without triggering any error. validate_function_def walks each parameter’s optional default expression, then validates the body:
The four error samples:
// samples/errors/function_def_self_out_of_scope.jsonnet
local get_value() = self.value;
get_value()// samples/errors/function_def_toplevel_out_of_scope.jsonnet
local get_value() = $.value;
get_value()// samples/errors/function_def_default_self_out_of_scope.jsonnet
local get_value(value = self.value) = value;
get_value()// samples/errors/function_def_default_toplevel_out_of_scope.jsonnet
local get_value(value = $.value) = value;
get_value()All four now produce the correct scope errors.
Scope-checking function calls
FunctionCall was the next silent passer. A call like get_value(self.value) at the top level should fail:
// samples/errors/function_call_arg_self_out_of_scope.jsonnet
local get_value(value) = value;
get_value(self.value)validate_function_call validates the callee expression and every argument, positional or named:
Six error samples cover callee, positional args, and named args for both self and $:
// samples/errors/function_call_callee_self_out_of_scope.jsonnet
(self.get_value)()// samples/errors/function_call_callee_toplevel_out_of_scope.jsonnet
($.get_value)()// samples/errors/function_call_arg_self_out_of_scope.jsonnet
local get_value(value) = value;
get_value(self.value)// samples/errors/function_call_arg_toplevel_out_of_scope.jsonnet
local get_value(value) = value;
get_value($.value)// samples/errors/function_call_named_arg_self_out_of_scope.jsonnet
local get_value(value) = value;
get_value(value = self.value)// samples/errors/function_call_named_arg_toplevel_out_of_scope.jsonnet
local get_value(value) = value;
get_value(value = $.value)Scope-checking closures (unified with function definitions)
Closure was the last variant sitting in the catch-all. It has the same structure as FunctionDef -- both have params with optional defaults and a body. Instead of duplicating the validation, I unified them:
The old validate_function_def renamed to validate_function_signature_and_body and takes params and body directly:
Four error samples cover body and defaults for both self and $:
// samples/errors/closure_self_out_of_scope.jsonnet
function() self.value// samples/errors/closure_toplevel_out_of_scope.jsonnet
function() $.value// samples/errors/closure_default_self_out_of_scope.jsonnet
function(value = self.value) value// samples/errors/closure_default_toplevel_out_of_scope.jsonnet
function(value = $.value) valueCleaning up the non-parser variants
The last diff separates the remaining no-ops into explicit groups with comments explaining why each group doesn’t need validation:
Three groups: terminal literals that have no sub-expressions, internal/runtime-only variants that parsing never produces, and — after the closure unification — nothing left. The last catch-all is gone.
Testing
The cram tests cover both the positive case and every error case. The conditionals.t test confirms that self and $ inside a conditional within an object still works:
The errors.t file collects all the new scope error tests — conditionals, object field access, function definitions, function calls, and closures:
All 19 new tests pass, and every existing cram test still passes unchanged.
Conclusion
The scope checker is clean. Every expr variant that can appear during scope analysis now has explicit handling.
The type checker is next. It has at least as many loose ends as the scope checker did — probably more. Future me gets to sort them out.
The entire diff can be seen here.















