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.
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)
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;
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 }) }
;
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)
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)
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
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]),
// ],
}
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'),
}
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"
+ }
+ }
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)