modules all the way down

modules all the way down
Photo by Nik / Unsplash

Meta-programming is an important capability to have in a language. It's not necessary if you want to build every bell and whistle in to a language at its core, but it allows you to incrementally improve a language if you know you won't have it all figured out right from the start.

In STZ we're going to have an infinite number of meta layers. The scheme works simply enough. Anything you bind in your module gets exported. Anything you explicitly export in your module also gets exported. If it is the final layer, anything that was exported goes in to the final compilation unit.

// layer1.stz
module :: 'layer1'.
foo :: 1.

// layer2.stz
module :: 'layer2'.
import: 'layer1'
bar :: foo + 1.

// layer3.stz
module :: 'layer3'.
import: 'layer2'.
baz :: bar + 1.

stz layer3 will now produce a binary which only knows baz and its value will be 3. The intermediate layers are stored on disk so that when you make a change to layer3 you don't have to completely recompile everything that came before it.

There are many advantages to doing it this way. It allows you to redesign a library that doesn't suit your needs by importing it, altering it or inserting your own methods, then export it under your own module name. Now your program can use your version of the library instead of the original.

You can have two different versions of a library included side by side and not have any conflicts. To that you bind the import to a variable.

// layer3.stz
module :: 'layer3'.
layer2 := import: 'layer3'.
baz :: layer2 bar + 1.

It also allows you to, if you wanted, create a top level compilation module that only contains main in it such that nothing else is exported in your executable.

You can explicitly remove a method in your own layer by not re-exporting it too. That allows you to make a sandboxed version of a library. For instance - core without sockets and files. I might make STZ ship with such a library by default.

You can also import another layer completely and add your own sugar on top:

// layer2.stz
module :: 'layer2'.
layer1 :: import: 'layer1'.
bar :: layer1 foo + 1.

// layer3.stz
module :: 'layer3'.
import: 'layer2'.
baz :: foo + bar.

You can think of an .stz file as a script that will produce outputs. It will run in an interpreter when compiling and the produced output will run standalone.

The commands to alter what you are or are not exporting come down to the module API:

// import another module
[context import: name] [name: String → ø | ].

// export something
[context bind: name to: object] [name: String, object: Any → ø | ].

// unexport something
[context unbind: name] [name: String -> ø | ].

// set the export mode to 'public' (the default)
// (available to anyone who imports the module)
[context public] [-> ø | ].

// set the export mode to 'shared'
// (available to other code in the same module)
[context shared] [-> ø | ].

// set the export mode to 'private'
// (only available in this file - not that common)
[context private] [-> ø | ].

Any files in the compilation directories that match the same module: name will be grouped together.

You could always import and bind it to a name but that is likely to get tiresome. It is idiomatic to import the whole of a module in to your compilation space and only alias it if there would be a conflict. I'm not your mother, so I won't tell you whether to pre-empt the idea of a conflict by always aliasing. But I think we're better off fixing a conflict when it happens rather than expecting the worst.

module :: 'my-program'.
 core1 :: import: 'core-0.0.1'.
 core2 :: import: 'core-0.0.2'.

hello-world-1: core1 string = 'hello world'.
hello-world-2: core2 string = 'hello world'.

Note that anything in a 'type' field is run at compile time as part of your module layer. In this case hello-world is available to be used in this module but isn't being exported so it will disappear once the compilation has ended.

Because the compiler looks at every file in the compilation directories you can 'extent' a module any time with your own file. This might cause a whole bunch of recompilation the first time though:

// my-core-extensions.stz
module :: 'core'.

// add the all important tau constant to core
τ :: 2 * π.

Note the 'module' export is a special one. Every file will export it and if it does not explicitly name it, it is defaulted to main. The main module imports core by default. This allows for relatively easy scripting:

import: 'layer3'.
baz copy> stdout.
> stzc main
3

When you're prototyping, scripting, or starting out - you don't even need a main method because you don't need to compile anything. stzc runs as an interpreter. It's only when you want to produce a real executable that you export a main method.

// main.stz
import: 'layer3'.
[main] [-> return: Integer |
  baz copy> stdout.
  return 0].
> stzc main -o test-program
> ./test-program
3

We can see that the difference between 'running a script' and 'building a program' is the inclusion of the main method. If the compiler sees that there is a main export, it will add a post-layer to produce an executable. Without it, it will simply run the compilation to its conclusion and throw away anything that doesn't need to be cached.

This has the added benefit that you can error check your code without producing the executable. It'll be marginally faster to only to the parsing and interpreting phases and skip the compile phase.

The interpreter is not meant to be blazingly fast. It's meant for incremental compilation and the convenience of scripting. STZ is a compiled language, at the end of the day, which is there to gain all the benefits of pre-compilation over just-in-time compilation.