DEV Community

Hercules Lemke Merscher
Hercules Lemke Merscher

Posted on • Originally published at bitmaybewise.substack.com

Tsonnet #39 - Call me maybe, but make it typed, part 5

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 refactored the AST to use named records, collapsed ClosureCall into FunctionCall, and cleaned up the grammar conflict that was keeping closures as second-class citizens:

Now that we support functions, just one more thing is missing on that front: methods!

yellow pages

The plan (such as it is)

// samples/functions/method.jsonnet
local object = {
  my_method(x): x * x,
};
object.my_method(2)
Enter fullscreen mode Exit fullscreen mode

Sneaking methods past the grammar

After implementing functions and pausing for a moment to reflect on how I'd implement methods, I realised that methods can be easily represented as closures that bind to an object field. So I did it:

diff --git a/lib/parser.mly b/lib/parser.mly
index e7b7771..0c5601d 100644
--- a/lib/parser.mly
+++ b/lib/parser.mly
@@ -101,6 +101,14 @@ obj_key:

 obj_field:
   | k = obj_key; COLON; e = assignable_expr { ObjectField (k, e) }
+  | k = obj_key;
+    LEFT_PAREN; params = separated_nonempty_list(COMMA, fundef_param); RIGHT_PAREN;
+    COLON; body = assignable_expr
+    { ObjectField (
+        k,
+        Closure (with_pos $startpos $endpos, { params = params; body = body })
+      )
+    }
   | e = single_var { ObjectExpr e }
   ;

@@ -215,6 +223,9 @@ funcall:
   | callee = scoped_expr;
     LEFT_PAREN; params = separated_nonempty_list(COMMA, assignable_expr); RIGHT_PAREN
     { FunctionCall (with_pos $startpos $endpos, { callee = callee; args = params }) }
+  | callee = obj_field_access;
+    LEFT_PAREN; params = separated_nonempty_list(COMMA, assignable_expr); RIGHT_PAREN
+    { FunctionCall (with_pos $startpos $endpos, { callee = callee; args = params }) }
   ;

 closure:
Enter fullscreen mode Exit fullscreen mode

This approach avoids adding one more abstraction, and maps precisely onto the semantics.

Turns out, scope bugs don't fix themselves

diff --git a/lib/type.ml b/lib/type.ml
index ad4f333..4b438db 100644
--- a/lib/type.ml
+++ b/lib/type.ml
@@ -102,7 +102,12 @@ let rec collect_free_idents = function
       | ObjectField (_, e) -> collect_free_idents e
       | ObjectExpr e -> collect_free_idents e
     ) entries
-  | ObjectFieldAccess (_, _, exprs) -> List.concat_map collect_free_idents exprs
+  | ObjectFieldAccess (_, scope, exprs) ->
+    let scope_idents = match scope with
+      | ObjVarRef name -> [name]
+      | Self | TopLevel -> []
+    in
+    scope_idents @ List.concat_map collect_free_idents exprs
   | IndexedExpr (_, name, e) -> name :: collect_free_idents e
   | Local (_, vars) -> List.concat_map (fun (_, e) -> collect_free_idents e) vars
   | FunctionCall (_, call) ->
Enter fullscreen mode Exit fullscreen mode

Cram tests corrected:

diff --git a/test/cram/objects.t b/test/cram/objects.t
index 1a402df..11cc9d1 100644
--- a/test/cram/objects.t
+++ b/test/cram/objects.t
@@ -23,32 +23,10 @@
   { "a": 1, "b": 3, "c": 4 }

   $ tsonnet ../../samples/objects/untouched_field.jsonnet
-  WARNING: ../../samples/objects/untouched_field.jsonnet:1:0 Unused variable result
-  
-  1: local result = {
-     ^^^^^^^^^^^^^^^^
-  2:     a: 1,
-     ^^^^^^^^^
-  3:     b: 42,
-     ^^^^^^^^^^
-  ---
   42


   $ tsonnet ../../samples/objects/untouched_invalid_field.jsonnet
-  WARNING: ../../samples/objects/untouched_invalid_field.jsonnet:1:0 Unused variable result
-  
-  1: local result = {
-     ^^^^^^^^^^^^^^^^
-  2:     a: 1,
-     ^^^^^^^^^
-  3:     b: self.a,
-     ^^^^^^^^^^^^^^
-  4:     c: self.d,
-     ^^^^^^^^^^^^^^
-  5:     d: self.c
-     ^^^^^^^^^^^^^
-  ---
   WARNING: ../../samples/objects/untouched_invalid_field.jsonnet:1:15 Cyclic reference found for 1->c

   1: local result = {
diff --git a/test/cram/semantics.t b/test/cram/semantics.t
index c9a790e..3f26924 100644
--- a/test/cram/semantics.t
+++ b/test/cram/semantics.t
@@ -288,17 +288,6 @@


   $ tsonnet ../../samples/semantics/valid_object_access_non_cyclic_field.jsonnet
-  WARNING: ../../samples/semantics/valid_object_access_non_cyclic_field.jsonnet:1:0 Unused variable obj
-  
-  1: local obj = {
-     ^^^^^^^^^^^^^
-  2:     a: 1,
-     ^^^^^^^^^
-  3:     b: self.c,
-     ^^^^^^^^^^^^^^
-  4:     c: self.b,
-     ^^^^^^^^^^^^^^
-  ---
   WARNING: ../../samples/semantics/valid_object_access_non_cyclic_field.jsonnet:1:12 Cyclic reference found for 1->b

   1: local obj = {
Enter fullscreen mode Exit fullscreen mode

Does it work?

Yes, it does:

$ dune exec -- tsonnet samples/functions/method.jsonnet
4
Enter fullscreen mode Exit fullscreen mode
diff --git a/test/cram/functions.t b/test/cram/functions.t
index 5c77837..9a9fe4d 100644
--- a/test/cram/functions.t
+++ b/test/cram/functions.t
@@ -9,3 +9,6 @@

   $ tsonnet ../../samples/functions/closure.jsonnet
   25
+
+  $ tsonnet ../../samples/functions/method.jsonnet
+  4
Enter fullscreen mode Exit fullscreen mode

Conclusion

Methods turned out to be less of a feature and more of a realisation: if you squint at them right, they're just closures sitting inside object fields, and the parser was already halfway there. Two small grammar rules and a scope bug fix later, object.my_method(2) just works. The scope fix was an interesting part — collect_free_idents was silently dropping the receiver when traversing ObjectFieldAccess, which meant variables used as method receivers could slip past the unused-variable analysis unnoticed. Worth catching.

Here is the entire diff.

Next up, time to wrap it up!


Thanks for reading Bit Maybe Wise! object.my_method(2) now returns 4. Your inbox could return something good too -- subscribe and square up.

Photo by Waldemar Brandt on Unsplash

Top comments (0)