Reusing Module APIs

This is a long section, but it's foundational to a deep understanding of the full expressive power you have available to you at Build time. You're encouraged to read through this in full! But remember, while you may sometimes end up consuming Modules that were defined using these advanced features, you'll never be forced to directly use any Build time metaprogramming feature yourself. These will always be conveniences for more advanced users.

Multiple Implementations of a Module API

The most basic, and also most important form of reuse in Claro codebases will be in the form of multiple Modules sharing a common API. This doesn't require any special syntax or setup whatsoever, once you've defined a valid .claro_module_api file any number of Modules may implement that API. Each claro_module(...) definition simply needs to declare its module_api_file = ... to reference the same exact .claro_module_api file.

For example, the following API:

Fig 1:


# animal.claro_module_api
opaque newtype InternalState

newtype State : struct {
  name: string,
  internal: InternalState
}

implement AnimalSounds::AnimalSounds<State>;

Can be implemented multiple times, by more than one Module:

Fig 2:


# BUILD
load("@claro-lang//:rules.bzl", "claro_module", "claro_binary")

claro_module(
    name = "dog",
    module_api_file = "animal.claro_module_api",
    srcs = ["dog.claro"],
    deps = {"AnimalSounds": ":animal_sounds"},
    # `AnimalSounds` is referenced in this Module's API so must be exported.
    exports = ["AnimalSounds"],
)
claro_module(
    name = "cat",
    module_api_file = "animal.claro_module_api",
    srcs = ["cat.claro"],
    deps = {"AnimalSounds": ":animal_sounds"},
    # `AnimalSounds` is referenced in this Module's API so must be exported.
    exports = ["AnimalSounds"],
)
# ...

In general, the Build targets declared above will be totally sufficient!

Going Deeper

The API definition above declares that any Module implementing the API will export a type that includes a name field, but may configure its own internal state as it wishes. To make this example more compelling, if you read the API closely, however, you may notice that as presently defined there would be no way for any dependent Module to actually interact with this API as defined, because there's no way to instantiate the opaque newtype InternalState1.

So, to actually make this API useful, implementing Modules would need to somehow explicitly export some Procedure that gives dependents the ability to instantiate the InternalState. You'll notice that care has been taken to make sure that Claro's API syntax is flexible enough to allow for multiple APIs to be conceptually (or in this case, literally) concatenated to create one larger API for a Module to implement. So that's exactly what we'll do here, with each module exporting an additional procedure from its API to act as a "constructor" for its opaque type.

Fig 3:


# dog_cons.claro_module_api
function create(name: string, isHappy: boolean) -> State;

Fig 4:


# cat_cons.claro_module_api
function create(name: string, favoriteInsult: string) -> State;

Fig 5:


# BUILD
load("@claro-lang//:rules.bzl", "claro_module", "claro_binary")
    ["BUILD", "animal.claro_module_api", "cat_cons.claro_module_api", "dog_cons.claro_module_api"],
)

genrule(
    name = "dog_api",
    srcs = ["animal.claro_module_api", "dog_cons.claro_module_api"],
    outs = ["dog.claro_module_api"],
    cmd = "cat $(SRCS) > $(OUTS)"
)
claro_module(
    name = "dog",
    module_api_file = ":dog_api", # Updated to use the API with a constructor.
    srcs = ["dog.claro"],
    deps = {"AnimalSounds": ":animal_sounds"},
    # `AnimalSounds` is referenced in this Module's API so must be exported.
    exports = ["AnimalSounds"],
)

genrule(
    name = "cat_api",
    srcs = ["animal.claro_module_api", "cat_cons.claro_module_api"],
    outs = ["cat.claro_module_api"],
    cmd = "cat $(SRCS) > $(OUTS)"
)
claro_module(
    name = "cat",
    module_api_file = ":cat_api", # Updated to use the API with a constructor.
    srcs = ["cat.claro"],
    deps = {"AnimalSounds": ":animal_sounds"},
    # `AnimalSounds` is referenced in this Module's API so must be exported.
    exports = ["AnimalSounds"],
)
# ...

In the future claro_module(...) will accept a list of .claro_module_api files instead of a single file to make this pattern easier to access without having to manually drop down to a genrule(...) to concatenate API files.

And now, importantly, multiple Modules implementing the same API can coexist in the same Claro program with no conflict!

Fig 6:


# BUILD
load("@claro-lang//:rules.bzl", "claro_module", "claro_binary")

# ...

claro_binary(
    name = "animals_example",
    main_file = "animals_example.claro",
    deps = {
        "AnimalSounds": ":animal_sounds",
        "Cat": ":cat",
        "Dog": ":dog",
    },
)

Fig 7:


# animals_example.claro
var animals: [oneof<Cat::State, Dog::State>] = [
    Dog::create("Milo", true),
    Dog::create("Fido", false),
    Cat::create("Garfield", "This is worse than Monday morning.")
  ];

for (animal in animals) {
  print(AnimalSounds::AnimalSounds::makeNoise(animal));
}

Output:

Woof!
Grrrr...
This is worse than Monday morning.

Read more about Dynamic Dispatch if you're confused how the above Contract Procedure call works.

Expressing the Above Build Targets More Concisely

Now, you'd be right to think that the above Build target declarations are extremely verbose. And potentially worse, they also contain much undesirable duplication that would have to kept in sync manually over time. Thankfully, Bazel provides many ways to address both of these issues.

Remember that Bazel's BUILD files are written using Starlark, a subset of Python, so we have a significant amount of flexibility available to us when declaring Build targets! We'll walk through a few different options for defining these targets much more concisely.

Using List Comprehension to Define Multiple Similar Targets at Once

The very first thing we'll notice is that the vast majority of these targets are duplicated. So, as programmers, our first thought should be to ask how we can factor out the common logic, to avoid repeating ourselves. The below rewritten BUILD file does a much better job of making the similarities between the Cat and Dog modules explicit, and also prevents them from drifting apart accidentally over time.

Fig 8:


# BUILD
load("@claro-lang//:rules.bzl", "claro_module", "claro_binary")

[ # This list-comprehension should feel very reminiscent of Claro's own comprehensions.
    [ # Generate multiple targets at once by declaring them in a list or some other collection.
        genrule(
            name = "{0}_api".format(name),
            srcs = ["animal.claro_module_api", "{0}_cons.claro_module_api".format(name)],
            outs = ["{0}.claro_module_api".format(name)],
            cmd = "cat $(SRCS) > $(OUTS)"
        ),
        claro_module(
            name = name,
            module_api_file = ":{0}_api".format(name),
            srcs = srcs,
            deps = {"AnimalSounds": ":animal_sounds"},
            # `AnimalSounds` is referenced in this Module's API so must be exported.
            exports = ["AnimalSounds"],
        )
    ]
    for name, srcs in {"dog": ["dog.claro"], "cat": ["cat.claro"]}.items()
]

Declaring a Macro in a .bzl File to Make This Factored Out Build Logic Portable

Now let's say that you wanted to declare another "Animal" in a totally separate package in your project. You could easily copy-paste the Build targets found in the previous BUILD file... but of course, this would invalidate our goal of avoiding duplication. So instead, as programmers our spider-senses should be tingling that we should factor this common logic not just into the loop (list comprehension), but into a full-blown function that can be reused and called from anywhere in our project. Bazel thankfully gives us access to defining so-called "Macros" that fill exactly this purpose2.

The Build targets in the prior examples could be factored out into a Macro definition in a .bzl (Bazel extension file) like so:

Fig 9:


# animals.bzl
load("@claro-lang//:rules.bzl", "claro_module")

def Animal(name, srcs):
    native.genrule( # In .bzl files you'll need to prefix builtin rules with `native.`
        name = "{0}_api".format(name),
        srcs = ["animal.claro_module_api", "{0}_cons.claro_module_api".format(name)],
        outs = ["{0}.claro_module_api".format(name)],
        cmd = "cat $(SRCS) > $(OUTS)"
    )
    claro_module(
        name = name,
        module_api_file = ":{0}_api".format(name),
        srcs = srcs,
        deps = {"AnimalSounds": ":animal_sounds"},
        # This Module is referenced in this Module's API so must be exported.
        exports = ["AnimalSounds"],
    )

And then, the macro can be used from BUILD files like so3:

Fig 10:


# BUILD
load(":animals.bzl", "Animal")

Animal(name = "dog", srcs = ["dog.claro"])
Animal(name = "cat", srcs = ["cat.claro"])

It couldn't possibly get much more concise than this! If you find yourself in a situation where you'll be defining lots of very similar Modules, it's highly recommended that you at least consider whether an approach similar to this one will work for you.

Swapping Dependencies at Build Time Based on Build Flags

TODO(steving) I think that I probably want to move this to be its own top-level section.

TODO(steving) Fill out this section describing how this is effectively Dependency Injection handled at Build time rather than depending on heavyweight DI frameworks.


1

For more context, read about Opaque Types.

2

It's highly recommended to start with Macros, but if you find that a Macro is getting a lot of use (for example if you're publishing it for external consumption) you may find it beneficial to convert your Macro into a Bazel Rule. Bazel Rules have much nicer usage ergonomics as they enable Bazel to enforce certain higher level constraints such as requiring that certain parameters only accept files with a certain suffix. However, Bazel Rules are much more complicated to define than Macros so this should really be left to very advanced Bazel users.

3

In practice, if you want a Bazel Macro to be reusable outside the Build package in which its .bzl file is defined, you'll need to use fully qualified target labels. E.g. //full/path/to:target rather than :target, as the latter is a "relative" label whose meaning is dependent on the Build package the Macro is used in, which is usually not what you want.