Notes
2026/01/30

Implicit vs explicit macro bindings for recursive definitions

One of Kombucha's defining features is that no functions and no constructs are syntactically privileged, even core constructs such as assignment and pattern matching are implemented as user-defined functions, using a lightweight macro system. However, in contrast to languages like Lisp, Kombucha's macro system is entirely syntactical, meaning that no macros need to be evaluated to know where bindings come into scope.

What that means in practice is that you can tell which names are values and which names are bindings just by looking at the source code, without having to execute any macros (in your head or by the compiler). A Python-style assignment such as x = y looks innocent enough, but the left hand side name x is a binding, whereas the right hand side name y is a value that is looked up in the environment. Kombucha distinguishes these concepts syntactically.

Two types of macro scopes

In Kombucha, macros always bind their bindings in the nearest scope, which is a block wrapped in { ... }. There are two types of macro scopes, an enclosing scope and an argument scope:

// enclosing scope, where `{ ... }` surrounds the macro `=`:
{
    // bind `x` in the _enclosing_ scope:
    $x = "hello"

    // we can now use `x` here:
    foo(x)
}

// argument scope, where `{ ... }` follows the macro `=>` as an argument:
match(value, [
    // bind `x` in the argument scope `{ ... }`, but _resolve_ `y`
    ($x, y) => {
        // `x` is bound here
        "a pair with the value of y as its second element"
    }
    // bind `x` and `y` in the argument scope `{ ... }`
    ($x, $y) => {
        // `x` and `y` are bound here
        "it's a pair!"
    }
])

In the above examples, I used $ as a prefix to mark bindings (which will be bound in the scope) and distinguish them from variables (which are resolved in the current scope).

But there's another possibility: We could treat all names in certain contexts as bindings and require all variables in these contexts to be explicitly annotated using Ruby/Elixir-style ^x ”pin” syntax. Our bindings-by-default context will be the left hand side of any infix expression that...

Our above example could then be rewritten as follows:

// enclosing scope, where `{ ... }` surrounds the macro `=`:
{
    // bind `x` in the _enclosing_ scope:
    x = "hello"

    // we can now use `x` here:
    foo(x)
}

// argument scope, where `{ ... }` follows the macro `=>` as an argument:
match(value, [
    // bind `x` in the argument scope `{ ... }`, but _resolve_ `y`
    (x, ^y) => {
        // `x` is bound here
        "a pair with the value of y as its second element"
    }
    // bind `x` and `y` in the argument scope `{ ... }`
    (x, y) => {
        // `x` and `y` are bound here
        "it's a pair!"
    }
])

This is a lot cleaner and also closer to other languages, at the expense of requiring some awareness of when left hand sides of infix functions are treated as macro contexts. But the rule is simple enough to learn that this seems acceptable.

Combining enclosing scope and argument scope

The real problem comes up when trying to combine enclosing and argument scopes, for example in the case of building a macro that supports recursive function definitions: A definition such as f(x, y) = { ... } needs to bind f (and only f) in the enclosing scope, but it also needs to bind f (recursively), x, and y (as arguments) in the function body { ... } How can we distinguish f (which needs to be bound twice) from x and y (which should not be bound in the enclosing scope)? With the explicit $ syntax, this is possible (but far from pretty):

{
    $$f($x, $y) = {
        // `f`, `x`, and `y` are bound here
    }

    // only `f` is bound here, because it was "double-bound" using $$
    f("hello", "world")
}

How can we combine enclosing and argument scopes without explicit $? Using ^ to mark x and y as being “less bound” than f feels strange, because we do not resolve x and y in the outer scope at any point:

{
    f(^x, ^y) = {
        // `f`, `x`, and `y` are bound here
    }

    // only `f` is bound here, because `^x` and `^y` went out of scope
    f("hello", "world")
}

Another option is disallowing a combination of enclosing-scope and argument-scope macros altogether. Functions could then only be defined as a combination of assignment and lambdas:

{
    // non-recursive function definition:
    f = ((x, y) => {
        // `f`, `x`, and `y` are bound here
    })

    // recursive function definition:
    g = (g ~> {
        // `g` is (recursively) bound here
        (x, y) => {
            // `g`, `x`, and `y` are bound here
        }
    })

    // only `f` and `g` are bound here
    f("hello", "world")
    g("hello", "world")
}

This simplifies the implementation (and the mental model of macros), but ends up looking pretty verbose, especially in a language like Kombucha that requires explicit disambiguation of nested infix functions using ( ... ).

One last option is to bind all names twice whenever a macro appears both in an enclosing scope and has a { ... } argument. The function name would then still need to be split from the arguments, but at least it wouldn't need to be repeated:

{
    // recursive function definition:
    g = {
        (x, y) => {
            // `g`, `x`, and `y` are bound here
        }
    }

    // only `g` is bound here
    g("hello", "world")
}

One variation on that last option is to treat all enclosing scope macros as binding their names both in the enclosing scope and in their right hand side argument (thus implicitly wrapping their right hand side in { ... }). This gives us the cleanest syntax for recursive function definitions, but has the drawback that all assignment is implicitly recursive:

{
    // recursive function definition:
    g = ((x, y) => {
        // `g`, `x`, and `y` are bound here
    })

    // only `g` is bound here
    g("hello", "world")

    // this works fine:
    x = "hello"

    // but this would bind `x` recursively, instead of shadowing it:
    x = [x]
}

Summing up, here are the options:

Better syntax for combining infix functions

Lastly, any of the three options that involve splitting the function name from its arguments using an infix function raises the question of how infix functions can be combined without always requiring ( ... ) for disambiguation.

One option would be to use newlines as precedence markers, so that => binds more tightly than = in the following example:

{
    f =
        (x, y) => {

        }
}

Another option would be to allow trailing arguments for infix functions. (This would be compatible with keyword arguments for prefix functions, because trailing { ... } arguments can only appear before keyword arguments, which makes it unambiguous that the trailing { ... } after an infix call must belong to the infix function.)

{
    // { ... } is a trailing argument here:
    f = (x, y) {
        // `f`, `x`, and `y` are bound here
    }
}

Is there a clear winner here? Not really. Implicit double binding, no shadowing, and trailing arguments for infix functions seem like a promising combination, but also feel like there's a lot of magic involved and are more complex than I'd like. It might be a good approach to try, let's see how it works in practice.