DEV Community

Hercules Lemke Merscher
Hercules Lemke Merscher

Posted on • Originally published at bitmaybewise.substack.com

Tsonnet #40 - Call me maybe, but make it typed, part 6

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 implemented methods:

After all this work implementing functions and methods, there's still one thing missing: function calls with named parameters. I implemented the definition, but not the call site when named parameters are present.

yellow telephone

This is what I want to test

// samples/functions/named_params.jsonnet
local my_function(x, y=10) = x + y;
my_function(2, y=3)
Enter fullscreen mode Exit fullscreen mode

A new argument in the room

This calls for a new type variant: call_arg.

diff --git a/lib/ast.ml b/lib/ast.ml
index 4b1102e..1b5724b 100644
--- a/lib/ast.ml
+++ b/lib/ast.ml
@@ -107,9 +107,12 @@ and function_def = {
   params: (string * expr option) list;
   body: expr;
 }
+and call_arg =
+  | Positional of expr
+  | Named of string * expr
 and function_call = {
   callee: expr;
-  args: expr list;
+  args: call_arg list;
 }
 and closure = {
   params: (string * expr option) list;
Enter fullscreen mode Exit fullscreen mode

This way we can leverage the expressiveness of the OCaml type system to enforce a Positional or Named argument.

The parser needs a little tweaking:

diff --git a/lib/parser.mly b/lib/parser.mly
index 0c5601d..94f1a2f 100644
--- a/lib/parser.mly
+++ b/lib/parser.mly
@@ -211,9 +211,14 @@ fundef_body:
   | local_bindings = vars; SEMICOLON; body = fundef_body { Seq [local_bindings; body] }
   ;

+funcall_arg:
+  | name = ID; ASSIGN; e = assignable_expr { Named (name, e) }
+  | e = assignable_expr { Positional e }
+  ;
+
 funcall:
   | fname = ID;
-    LEFT_PAREN; params = separated_nonempty_list(COMMA, assignable_expr); RIGHT_PAREN
+    LEFT_PAREN; params = separated_nonempty_list(COMMA, funcall_arg); RIGHT_PAREN
     { FunctionCall
       (with_pos $startpos $endpos, {
         callee = Ident (with_pos $startpos(fname) $endpos(fname), fname);
@@ -221,10 +226,10 @@ funcall:
       })
     }
   | callee = scoped_expr;
-    LEFT_PAREN; params = separated_nonempty_list(COMMA, assignable_expr); RIGHT_PAREN
+    LEFT_PAREN; params = separated_nonempty_list(COMMA, funcall_arg); 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
+    LEFT_PAREN; params = separated_nonempty_list(COMMA, funcall_arg); RIGHT_PAREN
     { FunctionCall (with_pos $startpos $endpos, { callee = callee; args = params }) }
   ;
Enter fullscreen mode Exit fullscreen mode

Teaching the typechecker to count on its fingers

With the addition of named parameters to function calls, they need to be translated by the type checker:

diff --git a/lib/type.ml b/lib/type.ml
index 4b438db..c537187 100644
--- a/lib/type.ml
+++ b/lib/type.ml
@@ -111,7 +111,10 @@ let rec collect_free_idents = function
   | IndexedExpr (_, name, e) -> name :: collect_free_idents e
   | Local (_, vars) -> List.concat_map (fun (_, e) -> collect_free_idents e) vars
   | FunctionCall (_, call) ->
-    collect_free_idents call.callee @ List.concat_map collect_free_idents call.args
+    collect_free_idents call.callee @ List.concat_map (function
+      | Positional e -> collect_free_idents e
+      | Named (_, e) -> collect_free_idents e
+    ) call.args
   | Closure (_, closure) -> collect_free_idents closure.body
   | _ -> []

@@ -551,27 +554,38 @@ and translate_function_call venv (pos, call) =
     )

 and translate_named_function_call venv (pos, name, args) =
+  let positional_args =
+    List.filter_map (function Positional e -> Some e | Named _ -> None) args
+  in
+  let named_args =
+    List.filter_map (function Named (n, e) -> Some (n, e) | Positional _ -> None) args
+  in
+  let num_positional = List.length positional_args in
   match Env.find_opt name venv with
   | Some (TfunctionDef { params = def_params;
-                         body = body_expr;
-                         return = return_type;
-                       }) ->
-    let num_call = List.length args in
+                          body = body_expr;
+                          return = return_type;
+                        }) ->
     let num_def = List.length def_params in
-    if num_call > num_def
+    let num_provided = num_positional + List.length named_args in
+    if num_provided > num_def
     then
       Error.error_at pos
-        (Error.Msg.wrong_number_of_params num_def num_call)
+        (Error.Msg.wrong_number_of_params num_def num_provided)
     else
       let* (venv', resolved_params) =
         List.fold_left
           (fun acc (index, (param_name, def_param_type)) ->
             let* (venv', params') = acc in
-            if index < num_call
-            then
-              let call_param = List.nth args index in
+            let call_expr_opt =
+              if index < num_positional
+              then Some (List.nth positional_args index)
+              else Option.map snd (List.find_opt (fun (n, _) -> n = param_name) named_args)
+            in
+            match call_expr_opt with
+            | Some call_param ->
               let* (venv'', call_param_type) = translate venv' call_param in
-              match def_param_type with
+              (match def_param_type with
               | Tunresolved ->
                 ok (venv'', params' @ [(param_name, call_param_type)])
               | expected ->
@@ -581,7 +595,8 @@ and translate_named_function_call venv (pos, name, args) =
                   (Error.Msg.type_mismatch
                     ~expected:(to_string expected)
                     ~got:(to_string call_param_type))
-            else
+              )
+            | None ->
               ok (venv', params' @ [(param_name, def_param_type)])
           )
           (ok (venv, []))
@@ -640,30 +655,43 @@ and translate_closure venv (_pos, closure) =
   in
   ok (venv, Tclosure { params = params_typed; body = closure.body })

-and translate_closure_call venv (pos, def_params, body, call_params) =
-  let num_call = List.length call_params in
+and translate_closure_call venv (pos, def_params, body, call_args) =
+  let positional_args =
+    List.filter_map (function Positional e -> Some e | Named _ -> None) call_args
+  in
+  let named_args =
+    List.filter_map (function Named (n, e) -> Some (n, e) | Positional _ -> None) call_args
+  in
+  let num_positional = List.length positional_args in
   let num_def = List.length def_params in
   let num_required =
     List.length (List.filter (fun (_, default) -> Option.is_none default) def_params)
   in
-  if num_call < num_required || num_call > num_def
-  then Error.error_at pos (Error.Msg.wrong_number_of_params num_def num_call)
+  let num_provided = num_positional + List.length named_args in
+  if num_provided < num_required || num_provided > num_def
+  then Error.error_at pos (Error.Msg.wrong_number_of_params num_def num_provided)
   else
     let* (venv', resolved_params) =
       List.fold_left
         (fun acc (index, (param_name, default)) ->
           let* (venv', params') = acc in
-          if index < num_call
-          then
-            let call_param = List.nth call_params index in
+          let call_expr_opt =
+            if index < num_positional
+            then Some (List.nth positional_args index)
+            else Option.map snd (List.find_opt (fun (n, _) -> n = param_name) named_args)
+          in
+          match call_expr_opt with
+          | Some call_param ->
             let* (venv'', call_param_type) = translate venv' call_param in
             ok (venv'', params' @ [(param_name, call_param_type)])
-          else
-            match default with
+          | None ->
+            (match default with
             | Some default_expr ->
               let* (venv'', default_type) = translate venv' default_expr in
               ok (venv'', params' @ [(param_name, default_type)])
-            | None -> Error.error_at pos (Error.Msg.wrong_number_of_params num_def num_call)
+            | None ->
+              Error.error_at pos (Error.Msg.wrong_number_of_params num_def num_provided)
+            )
         )
         (ok (venv, []))
         (List.mapi (fun i p -> (i, p)) def_params)
Enter fullscreen mode Exit fullscreen mode

I feel like translate_named_function_call and translate_closure_call could be unified, similar to what I did with apply_function in the interpreter. They differ in subtle ways though, so I'll leave that for another day.

The interpreter changes are only following the new type variant addition:

diff --git a/lib/interpreter.ml b/lib/interpreter.ml
index 15f9932..37e367e 100644
--- a/lib/interpreter.ml
+++ b/lib/interpreter.ml
@@ -420,19 +420,26 @@ and interpret_function_def env (pos, def) =
   let env' = Env.add_local def.name (FunctionDef (pos, def)) env in
   ok (env', Unit)

-and apply_function env pos def_params body call_params =
-  let num_call = List.length call_params in
+and apply_function env pos def_params body call_args =
+  let positional_args =
+    List.filter_map (function Positional e -> Some e | Named _ -> None) call_args
+  in
+  let named_args =
+    List.filter_map (function Named (n, e) -> Some (n, e) | Positional _ -> None) call_args
+  in
+  let num_positional = List.length positional_args in
   let num_def = List.length def_params in
   let num_required =
     List.length (
       List.filter (fun (_, default) -> Option.is_none default) def_params
     )
   in
-  if num_call < num_required || num_call > num_def
+  let num_provided = num_positional + List.length named_args in
+  if num_provided < num_required || num_provided > num_def
   then
-    Error.error_at pos (Error.Msg.wrong_number_of_params num_def num_call)
+    Error.error_at pos (Error.Msg.wrong_number_of_params num_def num_provided)
   else
-    let* evaluated_call_params =
+    let* evaluated_positional =
       List.fold_left
         (fun acc param ->
           let* params = acc in
@@ -440,19 +447,32 @@ and apply_function env pos def_params body call_params =
           ok (params @ [v])
         )
         (ok [])
-        call_params
+        positional_args
+    in
+    let* evaluated_named =
+      List.fold_left
+        (fun acc (name, expr) ->
+          let* params = acc in
+          let* (_, v) = interpret env expr in
+          ok (params @ [(name, v)])
+        )
+        (ok [])
+        named_args
     in
     let* bindings =
       List.fold_left
         (fun acc (index, (param_name, default)) ->
           let* bindings = acc in
-          if index < num_call
+          if index < num_positional
           then
-            ok (bindings @ [(param_name, List.nth evaluated_call_params index)])
+            ok (bindings @ [(param_name, List.nth evaluated_positional index)])
           else
-            match default with
-            | Some default_expr -> ok (bindings @ [(param_name, default_expr)])
-            | None -> Error.error_at pos (Error.Msg.wrong_number_of_params num_def num_call)
+            match List.assoc_opt param_name evaluated_named with
+            | Some v -> ok (bindings @ [(param_name, v)])
+            | None ->
+              match default with
+              | Some default_expr -> ok (bindings @ [(param_name, default_expr)])
+              | None -> Error.error_at pos (Error.Msg.wrong_number_of_params num_def num_provided)
         )
         (ok [])
         (List.mapi (fun i p -> (i, p)) def_params)
Enter fullscreen mode Exit fullscreen mode

Does it actually work? (Yes.)

diff --git a/samples/functions/named_params.jsonnet b/samples/functions/named_params.jsonnet
new file mode 100644
index 0000000..76406b1
--- /dev/null
+++ b/samples/functions/named_params.jsonnet
@@ -0,0 +1,2 @@
+local my_function(x, y=10) = x + y;
+my_function(2, y=3)
diff --git a/test/cram/functions.t b/test/cram/functions.t
index 9a9fe4d..89cfd27 100644
--- a/test/cram/functions.t
+++ b/test/cram/functions.t
@@ -10,5 +10,8 @@
   $ tsonnet ../../samples/functions/closure.jsonnet
   25

+  $ tsonnet ../../samples/functions/named_params.jsonnet
+  5
+
   $ tsonnet ../../samples/functions/method.jsonnet
   4
Enter fullscreen mode Exit fullscreen mode

Putting it all together (with cocktails)

With that I can put a closure (pun intended) to the function tutorial.

// samples/tutorials/functions.jsonnet

// Define a local function.
// Default arguments are like Python:
local my_function(x, y=10) = x + y;

// Define a local multiline function.
local multiline_function(x) =
  // One can nest locals.
  local temp = x * 2;
  // Every local ends with a semi-colon.
  [temp, temp + 1];

local object = {
  // A method
  my_method(x): x * x,
};

{
  // Functions are first class citizens.
  call_inline_function:
    (function(x) x * x)(5),

  call_multiline_function: multiline_function(4),

  // Using the variable fetches the function,
  // the parens call the function.
  call: my_function(2),

  // Like Python, parameters can be named at
  // call time.
  named_params: my_function(x=2),
  // This allows changing their order
  named_params2: my_function(y=3, x=2),

  // object.my_method returns the function,
  // which is then called like any other.
  call_method1: object.my_method(3),

  // TODO
  // standard_lib:
  //   std.join(' ', std.split('foo/bar', '/')),
  // len: [
  //   std.length('hello'),
  //   std.length([1, 2, 3]),
  // ],
}
Enter fullscreen mode Exit fullscreen mode

The standard library has to wait. We must implement other building blocks, such as conditionals, for loops, etc — the usual stuff we can't live without in a programming language.

// samples/tutorials/sours.jsonnet

// This function returns an object. Although
// the braces look like Java or C++ they do
// not mean a statement block, they are instead
// the value being returned.
local Sour(spirit, garnish='Lemon twist') = {
  ingredients: [
    { kind: spirit, qty: 2 },
    { kind: 'Egg white', qty: 1 },
    { kind: 'Lemon Juice', qty: 1 },
    { kind: 'Simple Syrup', qty: 1 },
  ],
  garnish: garnish,
  served: 'Straight Up',
};

{
  'Whiskey Sour': Sour('Bulleit Bourbon',
                       'Orange bitters'),
  'Pisco Sour': Sour('Machu Pisco',
                     'Angostura bitters'),
}
Enter fullscreen mode Exit fullscreen mode

Have you tried Pisco Sour already? If you're into alcoholic drinks, give it a try. It has a tasty flavour.

Ta-da! Cram tests in place:

diff --git a/test/cram/tutorials.t b/test/cram/tutorials.t
index f3780c1..9990a7e 100644
--- a/test/cram/tutorials.t
+++ b/test/cram/tutorials.t
@@ -105,3 +105,37 @@
     "obj": { "a": 1, "b": 3, "c": 4 },
     "obj_member": true
   }
+
+  $ tsonnet ../../samples/tutorials/functions.jsonnet
+  {
+    "call": 12,
+    "call_inline_function": 25,
+    "call_method1": 9,
+    "call_multiline_function": [ 8, 9 ],
+    "named_params": 12,
+    "named_params2": 5
+  }
+
+  $ tsonnet ../../samples/tutorials/sours.jsonnet
+  {
+    "Pisco Sour": {
+      "garnish": "Angostura bitters",
+      "ingredients": [
+        { "kind": "Machu Pisco", "qty": 2 },
+        { "kind": "Egg white", "qty": 1 },
+        { "kind": "Lemon Juice", "qty": 1 },
+        { "kind": "Simple Syrup", "qty": 1 }
+      ],
+      "served": "Straight Up"
+    },
+    "Whiskey Sour": {
+      "garnish": "Orange bitters",
+      "ingredients": [
+        { "kind": "Bulleit Bourbon", "qty": 2 },
+        { "kind": "Egg white", "qty": 1 },
+        { "kind": "Lemon Juice", "qty": 1 },
+        { "kind": "Simple Syrup", "qty": 1 }
+      ],
+      "served": "Straight Up"
+    }
+  }
Enter fullscreen mode Exit fullscreen mode

Conclusion

And that's a wrap on Tsonnet's function support (for now). Named parameters, default values, closures, methods — it's all there. The type checker knows how to count positional arguments, match them against names, and fill in defaults. Not bad for a type system that started life unable to tell a function from a hole in the ground.

This closes the chapter on functions in the Jsonnet tutorial too. We went from bare locals all the way to my_function(y=3, x=2) working correctly, with a Pisco Sour to show for it.

Here is the entire diff.


Thanks for reading Bit Maybe Wise! That's a closure on functions. Subscribe so you don't miss what comes next — conditionals, loops, and poor life choices.

Photo by Mike Meyers on Unsplash

Top comments (0)