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:
(* For any other expression types, no special scope validation needed *)
| Unit | Null _ | Number _ | String _ | Bool _ | EvaluatedObject _
| RuntimeObject _ | ObjectPtr _ | FunctionDef _ | FunctionCall _ | Closure _
| If _ -> ok ()
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;
value
Before this fix, that passed without error. validate_if recursively walks all three branches:
diff --git a/lib/scope.ml b/lib/scope.ml
index 48dfa83..b4e36a3 100644
--- a/lib/scope.ml
+++ b/lib/scope.ml
@@ -51,10 +50,12 @@ let rec _validate expr context =
_validate expr context
| IndexedExpr (_, _, index_expr) ->
_validate index_expr context
+ | If (_, cond_expr, then_expr, else_expr_opt) ->
+ validate_if cond_expr then_expr else_expr_opt context
(* For any other expression types, no special scope validation needed *)
| Unit | Null _ | Number _ | String _ | Bool _ | EvaluatedObject _
| RuntimeObject _ | ObjectPtr _ | FunctionDef _ | FunctionCall _ | Closure _
- | If _ -> ok ()
+ -> ok ()
and validate_if cond_expr then_expr else_expr_opt context =
_validate cond_expr context >>= fun () ->
_validate then_expr context >>= fun () ->
(match else_expr_opt with
| Some else_expr -> _validate else_expr context
| None -> ok ()
)
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.
| ObjectFieldAccess (pos, scope, _) ->
validate_object_field_access pos scope context
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].two
The fix threads the chain through to validate_expression_list:
- | ObjectFieldAccess (pos, scope, _) ->
- validate_object_field_access pos scope context
+ | ObjectFieldAccess (pos, scope, chain) ->
+ validate_object_field_access pos scope chain context
Inside validate_object_field_access, after the scope check runs, the chain gets validated in sequence:
- match scope with
- | Self | TopLevel ->
- if not context.in_object then
- let with_error_msg = match scope with
- | Self -> Error.Msg.self_out_of_scope
- | TopLevel -> Error.Msg.no_toplevel_object
- | ObjVarRef _ -> "" (* unreachable *)
- in
- Error.error_at pos with_error_msg
- else ok ()
- | ObjVarRef _ ->
- (* Variable references are allowed anywhere *)
- ok ()
+ let* () =
+ match scope with
+ | Self | TopLevel ->
+ if not context.in_object then
+ let with_error_msg = match scope with
+ | Self -> Error.Msg.self_out_of_scope
+ | TopLevel -> Error.Msg.no_toplevel_object
+ | ObjVarRef _ -> "" (* unreachable *)
+ in
+ Error.error_at pos with_error_msg
+ else ok ()
+ | ObjVarRef _ ->
+ (* Variable references are allowed anywhere *)
+ ok ()
+ in
+ validate_expression_list chain context
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:
+ | FunctionDef (_, def) ->
+ validate_function_def def context
(* For any other expression types, no special scope validation needed *)
| Unit | Null _ | Number _ | String _ | Bool _ | EvaluatedObject _
- | RuntimeObject _ | ObjectPtr _ | FunctionDef _ | FunctionCall _ | Closure _
+ | RuntimeObject _ | ObjectPtr _ | FunctionCall _ | Closure _
-> ok ()
and validate_function_def def context =
let* () = List.fold_left
(fun acc (_, default_expr) ->
acc >>= fun () ->
match default_expr with
| Some expr -> _validate expr context
| None -> ok ()
)
(ok ())
def.params
in
_validate def.body context
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:
+ | FunctionCall (_, call) ->
+ validate_function_call call context
(* Terminal variants *)
| Null _ | Number _ | String _ | Bool _ -> ok ()
and validate_function_call call context =
let* () = _validate call.callee context in
List.fold_left
(fun acc arg ->
acc >>= fun () ->
match arg with
| Positional expr -> _validate expr context
| Named (_, expr) -> _validate expr context
)
(ok ())
call.args
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:
- | FunctionDef (_, def) ->
- validate_function_def def context
+ | FunctionDef (_, {name = _; params; body})
+ | Closure (_, {params; body}) ->
+ validate_function_signature_and_body params body context
The old validate_function_def renamed to validate_function_signature_and_body and takes params and body directly:
-and validate_function_def def context =
+and validate_function_signature_and_body params body context =
let* () = List.fold_left
(fun acc (_, default_expr) ->
acc >>= fun () ->
match default_expr with
| Some expr -> _validate expr context
| None -> ok ()
)
(ok ())
- def.params
+ params
in
- _validate def.body context
+ _validate body context
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) value
Cleaning 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:
- (* For any other expression types, no special scope validation needed *)
- | Unit | Null _ | Number _ | String _ | Bool _ | EvaluatedObject _
- | RuntimeObject _ | ObjectPtr _ | FunctionCall _ | Closure _
- -> ok ()
+ (* Terminal variants *)
+ | Null _ | Number _ | String _ | Bool _ -> ok ()
+ (* These variants are not produced by parsing source files.
+ Scope validation runs before type checking/interpreting,
+ so they only appear through internal or runtime, and have
+ no source-level scope to check. *)
+ | Unit | EvaluatedObject _ | RuntimeObject _ | ObjectPtr _ -> ok ()
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:
diff --git a/test/cram/conditionals.t b/test/cram/conditionals.t
@@ -18,3 +18,7 @@
2: [if true then 42]: "value"
^^^^^^^^^^^^^^^^^^^^^
[1]
+
+
+ $ tsonnet ../../samples/conditionals/conditional_object_scope.jsonnet
+ { "one": 1, "three": 3, "two": 2 }
The errors.t file collects all the new scope error tests — conditionals, object field access, function definitions, function calls, and closures:
diff --git a/test/cram/errors.t b/test/cram/errors.t
+ $ tsonnet ../../samples/errors/conditional_self_out_of_scope.jsonnet
+ ERROR: ...:1:27 Can't use self outside of an object
+
+ $ tsonnet ../../samples/errors/conditional_toplevel_out_of_scope.jsonnet
+ ERROR: ...:1:35 No top-level object found
+
+ $ tsonnet ../../samples/errors/object_field_access_index_self_out_of_scope.jsonnet
+ ERROR: ...:2:4 Can't use self outside of an object
+
+ $ tsonnet ../../samples/errors/object_field_access_computed_self_out_of_scope.jsonnet
+ ERROR: ...:2:5 Can't use self outside of an object
+
+ $ tsonnet ../../samples/errors/object_field_access_index_toplevel_out_of_scope.jsonnet
+ ERROR: ...:2:4 No top-level object found
+
+ $ tsonnet ../../samples/errors/function_def_self_out_of_scope.jsonnet
+ ERROR: ...:1:20 Can't use self outside of an object
+
+ $ tsonnet ../../samples/errors/function_def_toplevel_out_of_scope.jsonnet
+ ERROR: ...:1:20 No top-level object found
+
+ $ tsonnet ../../samples/errors/function_def_default_self_out_of_scope.jsonnet
+ ERROR: ...:1:24 Can't use self outside of an object
+
+ $ tsonnet ../../samples/errors/function_def_default_toplevel_out_of_scope.jsonnet
+ ERROR: ...:1:24 No top-level object found
+
+ $ tsonnet ../../samples/errors/function_call_callee_self_out_of_scope.jsonnet
+ ERROR: ...:1:1 Can't use self outside of an object
+
+ $ tsonnet ../../samples/errors/function_call_callee_toplevel_out_of_scope.jsonnet
+ ERROR: ...:1:1 No top-level object found
+
+ $ tsonnet ../../samples/errors/function_call_arg_self_out_of_scope.jsonnet
+ ERROR: ...:2:10 Can't use self outside of an object
+
+ $ tsonnet ../../samples/errors/function_call_arg_toplevel_out_of_scope.jsonnet
+ ERROR: ...:2:10 No top-level object found
+
+ $ tsonnet ../../samples/errors/function_call_named_arg_self_out_of_scope.jsonnet
+ ERROR: ...:2:18 Can't use self outside of an object
+
+ $ tsonnet ../../samples/errors/function_call_named_arg_toplevel_out_of_scope.jsonnet
+ ERROR: ...:2:18 No top-level object found
+
+ $ tsonnet ../../samples/errors/closure_self_out_of_scope.jsonnet
+ ERROR: ...:1:11 Can't use self outside of an object
+
+ $ tsonnet ../../samples/errors/closure_toplevel_out_of_scope.jsonnet
+ ERROR: ...:1:11 No top-level object found
+
+ $ tsonnet ../../samples/errors/closure_default_self_out_of_scope.jsonnet
+ ERROR: ...:1:17 Can't use self outside of an object
+
+ $ tsonnet ../../samples/errors/closure_default_toplevel_out_of_scope.jsonnet
+ ERROR: ...:1:17 No top-level object found
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.
Thanks for reading Bit Maybe Wise! Subscribe — and if you don't, the only thing out of scope is your inbox.
Photo by Matthew Ansley on Unsplash

Top comments (0)