zeolite-lang alternatives and similar packages
Based on the "Compiler" category.
Alternatively, view zeolite-lang alternatives based on common mentions on social networks and blogs.
-
binaryen
DISCONTINUED. DEPRECATED in favor of ghc wasm backend, see https://www.tweag.io/blog/2022-11-22-wasm-backend-merged-in-ghc
SaaSHub - Software Alternatives and Reviews
* Code Quality Rankings and insights are calculated and provided by Lumnify.
They vary from L1 to L5 with "L5" being the highest.
Do you think we are missing an alternative of zeolite-lang or a related project?
README
Zeolite Programming Language
Zeolite is a statically-typed, general-purpose programming language. The type system revolves around defining objects and their usage patterns.
Zeolite prioritizes making code written with it simple to understand for readers who didn't write the original code. This is done by limiting flexibility in some places and increasing it in others. In particular, emphasis is placed on the user's experience when troubleshooting code that is incorrect.
The design of the type system and the language itself is influenced by positive and negative experiences with Java, C++, Haskell, Python, and Go, with collaborative development, and with various systems of code-quality enforcement.
Due to the way GitHub renders embedded HTML, the colors might not show up in the syntax-highlighted code in this document. If you use the Chrome browser, you can view the intended formatting using the Markdown Viewer extension to view the raw version of this document.
Table of Contents
- Project Status
- Language Overview
- Quick Start
- Writing Programs
- Basic Ideas
- Declaring Functions
- Defining Functions
- Using Variables
- Calling Functions
- Functions As Operators
- Data Members and Value Creation
- Conditionals
- Scoping and Cleanup
- Loops
- Multiple Returns
- Optional and Weak Values
- Using Parameters
- Using Interfaces
- Type Inference
- Other Features
- Meta Types
- Runtime Type Reduction
- Internal Type Parameters
- Builtins
- Builtin Types
- Builtin Constants
- Builtin Functions
- Layout and Dependencies
- Unit Testing
- Compiler Pragmas and Macros
Project Status
Zeolite is still evolving in all areas (syntax, build system, etc.), and it still lacks a lot of standard library functionality. That said, it was designed with practical applications in mind. It does not prioritize having impressive toy examples (e.g., merge-sort or "Hello World" in one line); the real value is seen in programs with higher relational complexity.
Language Overview
This section discusses some of the features that make Zeolite unique. It does not go into detail about all of the language's features; see Writing Programs and the full examples for more specific language information.
Programming Paradigms
Zeolite currently uses both procedural and object-oriented programming paradigms. It shares many features with Java, but it also has additional features and restrictions meant to simplify code maintenance.
Parameter Variance
The initial motivation for Zeolite was a type system that allows implicit
conversions between different parameterizations of parameterized types. A
parameterized type is a type with type "place-holders", e.g., template
s in C++
and generics in Java.
Java and C++ do not allow you to safely convert between different
parameterizations. For example, you cannot safely convert a List<String>
into
a List<Object>
in Java. This is primarily because List
uses its type
parameter for both input and output.
Zeolite, on the other hand, uses declaration-site variance for each parameter. (C# also does this to a lesser extent.) This allows the language to support very powerful recursive type conversions for parameterized types. Zeolite also allows use-site variance variance declarations, like Java uses.
Parameters as Variables
Zeolite treats type parameters both as type place-holders (like in C++ and
Java) and as type variables that you can call functions on. This further
allows Zeolite to have interfaces that declare functions that operate on types
in addition to interfaces that declare functions that operate on values. (This
would be like having abstract static
methods in Java.)
This helps solve a few separate problems:
Operations like
equals
comparisons in Java are always dispatched to the left object, which could lead to inconsistent results if the objects are swapped:foo.equals(bar);
is not the same asbar.equals(foo);
. Such problems can be mitigated by makingequals
a type function in an interface rather than a value function.Factory patterns can be abstracted out into interfaces, allowing the concept of default construction (used by Java, C++, and others) to be eliminated. One common issue in C++ is forgetting to disallow direct construction or copying of objects of your
class
.
Integrated Test Runner
The major advantage of statically-typed programming languages is their compile-time detection of code that should not be allowed. On the other hand, there is a major testability gap when it comes to ensuring that your statically-typed code disallows what you expect it to.
Zeolite has a special source-file extension for unit tests, and a built-in compiler mode to run them. These tests can check for success, compilation failures, and even crashes. Normally you would need a third-party test runner to check for required compilation failures and crashes.
Nearly all of the integration testing of the Zeolite language itself is done using this feature, but it is also supported for general use with Zeolite projects.
Integrated Build System
The Zeolite compiler supports a module system that can incrementally compile
projects without the user needing to create build scripts or Makefile
s.
- Modules are configured via a simple config file.
- File-level and symbol-level imports and includes are not necessary, allowing module authors to freely rearrange file structure.
- Type dependencies are automatically resolved during linking so that output binaries contain only the code that is relevant.
- Module authors can back Zeolite code with C++.
- The module system is integrated with the compiler's built-in testing mode.
This means that the programmer can focus on code rather than on build rules, and module authors can avoid writing verbose build instructions for the users of their modules.
Data Encapsulation
The overall design of Zeolite revolves around data encapsulation:
No default construction or copying. This means that objects can only be created by explicit factory functions. (A very common mistake in C++ code is forgetting to disallow or override default construction or copying.)
Only abstract interfaces can be inherited. Types that define procedures or contain data members cannot be further extended. This encourages the programmer to think more about usage patterns and less about data representation when designing interactions between types.
No "privileged" data-member access. No object has direct access to the data members of any other object; not even other objects of the same type. This forces the programmer to also think about usage patterns when dealing with other objects of the same type.
Implementation details are kept separate. In Zeolite, only public inheritance and public functions show up where an object type is declared, to discourage relying on implementation details of the type.
C++ and Java allow (and in some cases require) implementation details (data
members, function definitions, etc.) to show up in the same place as the
user-accessible parts of a class
. The result is that the user of the class
will often rely on knowledge of how it works internally.
Although all of these limitations preclude a lot of design decisions allowed in languages like Java, C++, and Python, they also drastically reduce the possible complexity of inter-object interactions. Additionally, they generally do not require ugly work-arounds; see the full examples.
Quick Start
Installation
Requirements:
- A Unix-like operating system. Zeolite has been tested on Linux and FreeBSD, but in theory it should also work on OpenBSD, NetBSD, OS X, etc.
- A Haskell compiler such as
ghc
that can install packages usingcabal
, as well as thecabal
installer. - A C++ compiler such as
clang++
org++
and the standardar
archiver present on most Unix-like operating systems.
If you use a modern Linux distribution, most of the above can be installed using the package manager that comes with your distribution.
Once you meet all of those requirements, follow the installation instructions
for the zeolite-lang
package on
Hackage. Please take a look at the issues page if
you run into problems.
If you happen to use the kate
text editor, you can use the syntax
highlighting in zeolite.xml
.
Hello World
It's the any% of programming.
// hello-world.0rx
concrete HelloWorld { @type run () -> () }
define HelloWorld { run () { </span> LazyStream<Formatted>.new() .append("Hello World\n") .writeTo(SimpleOutput.stderr()) } }
# Compile.
zeolite -I lib/util --fast HelloWorld hello-world.0rx
# Execute.
./HelloWorld
Also see some full examples for more complete feature usage.
Writing Programs
This section breaks down the separate parts of a Zeolite program. See the full examples for a more integrated language overview.
Basic Ideas
Zeolite programs use object-oriented and procedural programming paradigms.
Type categories are used to define object types, much like class
es in
Java and C++. They are not called "classes", just to avoid confusion about
semantic differences with Java and C++.
All type-category names start with an uppercase letter and contain only letters and digits.
All procedures and data live inside concrete
type categories. Every program
must have at least one concrete
category with the procedure to be executed
when the program is run.
concrete
categories are split into a declaration and a definition.
Code for both should be in files ending with .0rx
. (The .0rp
file type
contains only declarations, and will be discussed later.)
// myprogram/myprogram.0rx
// This declares the type. concrete MyProgram { // The entry point must be a () -> () function. This means that it takes no // arguments and returns no arguments. (@type will be discussed later.) @type run () -> () }
// This defines the type. define MyProgram { run () { // ... } }
IMPORTANT: All programs or modules must be in their own directory so that
zeolite
is able to cache information about the build. Unlike some other
compilers, you do not specify all command-line options every time you
recompile a binary or module.
# Compile.
# All sources in myprogram will be compiled. -m MyProgram selects the entry
# point. The default output name for the binary here is myprogram/MyProgram.
zeolite -m MyProgram myprogram
# Execute.
myprogram/MyProgram
This is the smallest Zeolite program possible.
After compiling the project the first time, you must either use -r
or -f
when recompiling.
# Recompile.
zeolite -r myprogram
# Force compilation from scratch.
zeolite -f -m MyProgram myprogram
An alternative to this is the --fast
mode (as of compiler version 0.4.1.0
),
which allows you to create a binary from a single .0rx
file. This mode does
not require the source to be in a separate directory and does not preserve any
info about the compiler setup. This is useful for simple testing and
experimentation, and should generally not be used otherwise.
Declaring Functions
A function declaration specifies the scope of the function and its argument and return types. (And optionally type parameters and parameter filters, to be discussed later.) The declaration simply indicates the existence of a function, without specifying its behavior.
All function names start with a lowercase letter and contain only letters and digits.
concrete MyCategory { // @value indicates that this function requires a value of type MyCategory. // This function takes 2x Int and returns 2x Int. @value minMax (Int,Int) -> (Int,Int)
// @type indicates that this function operates on MyCategory itself. This is // like a static function in C++. // This function takes no arguments and returns MyCategory. @type create () -> (MyCategory)
// @category indicates that this function operates on MyCategory itself. This // is like a static function in Java. (The semantics of @category are similar // to those of @type unless there are type parameters.) @category copy (MyCategory) -> (MyCategory) }
Defining Functions
Functions are defined in the category definition. They do not need to repeat the function declaration; however, they can do so in order to refine the argument and return types for internal use.
The category definition can also declare additional functions that are not visible externally.
concrete MyCategory { @type minMax (Int,Int) -> (Int,Int) }
define MyCategory { // minMax is defined here. minMax (x,y) { if (superfluousCheck(x,y)) { return x, y } else { return y, x } }
// superfluousCheck is only available inside of MyCategory. @type superfluousCheck (Int,Int) -> (Bool) superfluousCheck (x,y) { return x < y } }
All arguments must either have a unique name or be ignored with _
.
@value
functions have access to a special constant self
, which refers to
the object against which the function was called.
Using Variables
Variables are assigned with <-
to indicate the direction of assignment.
Every variable must be initialized; there are no null
values in Zeolite.
(However, see optional
later on.)
All variable names start with a lowercase letter and contain only letters and
digits. When a location is needed for assignment (e.g., handling a function
return, taking a function argument), you can use _
in place of a variable
name to ignore the value.
// Initialize with a literal. Int value <- 0
// Initialize with a function result. Int value <- getValue()
Unlike other languages, Zeolite does not allow variable masking. For example,
if there is already a variable named x
available, you cannot create a new
x
variable even in a smaller scope.
All variables are shared and their values are not scoped like they are in C++. You should not count on knowing the lifetime of any given value.
Calling Functions
Return values from function calls must always be explicitly handled by assigning them to a variable, passing them to another function or ignoring them. (This is required even if the function does not return anything, primarily to simplify parsing.)
// Utilize the return. Int value <- getValue()
// Explicitly ignore a single value. _ <- getValue()
// Ignore all aspects of the return. // (Prior to compiler version 0.3.0.0, ~ was used instead of .) </span> printHelp()
- Calling a function with
@value
scope requires a value of the correct type, and uses.
notation, e.g.,foo.getValue()
. - Calling a function with
@type
scope requires the type with parameter substutition (if applicable), and uses.
notation, e.g.,MyCategory<Int>.create()
. (Prior to compiler version0.9.0.0
,$
was used instead of.
.) - Calling a function with
@category
scope requires the category itself, and uses the:
notation, e.g.,MyCategory:foo()
. (Prior to compiler version0.9.0.0
,$$
was used instead of:
.) - You can skip qualifying function calls (e.g., in the example above) if the
function being called is in the same scope or higher. For example, you can
call a
@type
function from the procedure for a@value
function in the same category.
The fail
builtin can be used to immediately terminate the program. It is not
considered a function since it cannot return; therefore, you do not need to
precede it with \
.
define MyProgram { run () { fail("MyProgram does nothing") } }
Functions cannot be overloaded like in Java and C++. Every function must have a unique name. Functions inherited from different places can be explicitly merged, however. This can be useful if you want interfaces to have overlapping functionality without having an explicit parent for the overlap.
@value interface Container<#x> { set (#x) -> () }
@value interface Policy<#x> { set (#x) -> () }
concrete MyValue { refines Container<Int> refines Policy<Int>
// An explicit override is required in order to merge set from both parents. @value set (Int) -> () }
Functions As Operators
Zeolite allows some functions to be used as operators. This allows users to avoid excessive parentheses when using named mathematical functions.
Functions with two arguments can use infix notation. The operator precedence
is always between comparisons (e.g., ==
) and logical (e.g., &&
).
Functions with one argument can use prefix notation. These are evaluated strictly before all infix operators.
concrete Math { @type plus (Int,Int) -> (Int) @type neg (Int) -> (Int) }
// ...
// Math.plus is evaluated first.
Int x <- 1 </span></b><span style='color:#0057ae;'>Math</span><span style='color:#644a9b;'>.</span>plus<b><span style='color:#c02040;'>
2 * 5
// Math.neg is evaluated first.
Int y <- </span></b><span style='color:#0057ae;'>Math</span><span style='color:#644a9b;'>.</span>neg<b><span style='color:#c02040;'>
x </span></b><span style='color:#0057ae;'>Math</span><span style='color:#644a9b;'>.</span>plus<b><span style='color:#c02040;'>
2
Data Members and Value Creation
Unlike Java and C++, there is no "default construction" in Zeolite. In addition, Zeolite also lacks the concept of "copy construction" that C++ has. This means that new values can only be created using a factory function. In combination with required variable initialization, this ensures that the programmer never needs to worry about unexpected missing or uninitialized values.
Data members are never externally visible; they only exist in the category definition. Any access outside of the category must be done using explicitly-defined functions.
concrete MyCategory { @type create () -> (MyCategory) }
define MyCategory { // A data member unique to each MyCategory value. @value Int value
create () { // Initialization is done with direct assignment. return MyCategory{ 0 } } }
// ...
// Create a new value in some other procedure. MyCategory myValue <- MyCategory.create()
There is no syntax for accessing a data member from another object; even
objects of the same type. This effectively makes all variables internal
rather than just private
like in Java and C++. As long as parameter variance
is respected, you can provide access to an individual member with getters and
setters.
Conditionals
Zeolite uses the if
/elif
/else
conditional construct. The elif
and else
clauses are always optional.
if (x) { // something } elif (y) { // something } else { // something }
Scoping and Cleanup
Variables can be scoped to specific blocks of code. Additionally, you can
provide a cleanup procedure to be executed upon exit from the block of code.
This is useful if you want to free resources without needing to explicitly do so
for every return
statement.
// Simple scoping during evaluation. scoped { Int x <- getValue() } in if (x < 0) { // ... } elif (x > 0) { // ... } else { // ... }
// Simple scoping during assignment. scoped { Int x <- getValue1() Int y <- getValue2() } in Int z <- x+y
// Scoping with cleanup. scoped { // ... } cleanup { // ... } in { // ... }
// Cleanup without scoping. cleanup { i <- i+1 // Post-increment behavior. } in return i
IMPORTANT: Explicit return
statements and modification of named return
values are disallowed inside of a cleanup
block. This is because if an in
statement also contains a return
, the behavior of the cleanup
would be
ambiguous. Although using return
within cleanup
would be safe in some
situations, making that determination would be nuanced and complex, for both the
user and the compiler.
Loops
Zeolite supports while
loops. It does not explicitly support for
loops,
since such loops are idiosyncratic and do not scale well. Instead, they can be
constructed using a combination of while
and scoped
.
// With break and continue. while (true) { if (true) { break } else { continue } }
// With an update after each iteration. while (true) { // ... } update { // ... }
// Combined with scoped to create a for loop. scoped { Int i <- 0 Int limit <- 10 } in while (i < limit) { // ... } update { i <- i+1 }
Multiple Returns
A procedure definition has two options for returning multiple values:
- Return all values. (Prior to compiler version
0.3.0.0
, multiple returns were enclosed in{}
, e.g.,return { x, y }
.)
define MyCategory { minMax (x,y) { if (x < y) { return x, y } else { return y, x } } }
- Naming the return values and assigning them individually. This can be useful (and less error-prone) if the values are determined at different times. The compiler uses static analysis to ensure that all named variables are guaranteed to be set via all possible control paths.
define MyCategory { // Returns are named on the first line. minMax (x,y) (min,max) { // Returns are optionally initialized up front. min <- y max <- x if (x < y) { // Returns are overwritten. min <- x max <- y } // Implicit return makes sure that all returns are assigned. Optionally, // you can use return _. } }
The caller of a function with multiple returns also has a few options:
- Assign the returns to a set of variables. You can ignore a position by using
_
in that position. (Prior to compiler version0.3.0.0
, multiple assignments were enclosed in{}
, e.g.,{ Int min, _ } <- minMax(4,3)
.)
Int min, _ <- minMax(4,3)
- Pass them directly to a function that requires the same number of compatible arguments. (Note that you cannot concatenate the returns of multiple functions.)
Int delta <- diff(minMax(4,3))
Optional and Weak Values
Zeolite requires that all variables be initialized; however, it provides the
optional
storage modifier to allow a specific variable to be
empty
. This is not the same as null
in Java because optional
variables need to be require
d before use.
// empty is a special value for use with optional. optional Int value <- empty
// Non-optional values automatically convert to optional. value <- 1
// present returns true iff the value is not empty. if (present(value)) { // Use require to convert the value to something usable. </span> foo(require(value)) }
weak
values are similar, but require an additional step to convert them to
optional
first. (weak
values are a pragmatic solution to potential memory
leaks that can arise with cyclic references.)
concrete MyNode { @type create (optional MyNode) -> (MyNode) @value getNext () -> (optional MyNode) }
define MyNode { // Weak can only be used for data members and local variables. @value weak MyNode next
create (next) { // optional automatically converts to weak. return MyNode{ next } }
getNext () { // The only operation you can perform on weak values is strong. return strong(next) } }
Using Parameters
All concrete
categories and all interface
s can have type parameters. Each
parameter can have a variance rule assigned to it. This allows the compiler to
do type conversions between different parameterizations.
Parameter names must start with #
and a lowercase letter, and can only contain
letters and digits.
Parameters are never repeated in the category or function definitions. (Doing so would just create more opportunity for unnecessary compile-time errors.)
// #x is covariant (indicated by being to the right of |), which means that it // can only be used for output purposes. @value interface Reader<|#x> { read () -> (#x) }
// #x is contravariant (indicated by being to the left of |), which means that // it can only be used for input purposes. @value interface Writer<#x|> { write (#x) -> () }
// #x is for output and #y is for input, from the caller's perspective. @value interface Function<#x|#y> { call (#x) -> (#y) }
// By default, parameters are invariant, i.e., cannot be converted. You can also // explicitly specify invariance with <|#x|>. This allows all three variance // types to be present. concrete List<#x> { @value append (#x) -> () @value head () -> (#x) }
- Specifying parameter variance allows the compiler to automatically convert between different types. This is done recursively in terms of parameter substitution.
// Covariance allows conversion upward. Reader<MyValue> reader <- // ... Reader<MyBase> reader2 <- reader
// Contravariance allows conversion downward. Writer<MyBase> writer <- // ... Writer<MyValue> writer2 <- writer
// Conversion is also recursive. Writer<Reader<MyBase>> readerWriter <- // ... Writer<Reader<MyValue>> readerWriter2 <- readerWriter
// Invariance does not allow conversions. List<MyValue> list <- // ... List<MyBase> list2 <- // ...
- You can apply filters to type parameters to require that the parameters meet certain requirements.
concrete ShowMap<#k,#v> { // #k must implement the LessThan builtin @type interface. #k defines LessThan<#k>
<span style='color:#898887;'>// #v must implement the Formatted builtin @value interface.</span>
<i><span style='color:#0057ae;'>#v</span></i> <b>requires</b> <i><span style='color:#0057ae;'>Formatted</span></i>
}
- You can call
@type
functions on parameters as if they were regular types. You can only call functions that are implied by adefines
filter.
concrete MyCategory<#x> { #x defines LessThan<#x> @type compare (#x,#x) -> (Int) }
define MyCategory { compare (x,y) { if (#x.lessThan(x,y)) { return -1 } elif (#x.lessThan(y,x)) { return 1 } else { return 0 } } }
// ...
Int comp <- MyCategory<String>.compare("x","y")
- All of the above is also possible with function parameters, aside from specifying parameter variance.
concrete MyCategory { @type compare<#x> #x defines LessThan<#x> (#x,#x) -> (Int) }
define MyCategory { compare (x,y) { if (#x.lessThan(x,y)) { return -1 } elif (#x.lessThan(y,x)) { return 1 } else { return 0 } } }
// ...
Int comp <- MyCategory.compare<String>("x","y")
Using Interfaces
Zeolite has @value interface
s that are similar to Java interface
s, which
declare functions that implementations must define. In addition, Zeolite also
has @type interface
s that declare @type
functions that must be defined.
(This would be like having abstract static
functions in Java.)
// @value indicates that the interface declares @value functions. @value interface Printable { // @value is not allowed in the declaration. print () -> () }
// @type indicates that the interface declares @type functions. @type interface Diffable<#x> { // @type is not allowed in the declaration. diff (#x,#x) -> (#x) }
@value interface
s can be inherited by other@value interface
s andconcrete
categories usingrefines
.
concrete MyValue { refines Printable
<span style='color:#898887;'>// The functions of Printable do not need to be declared again, but you can do</span>
<span style='color:#898887;'>// so to refine the argument and return types.</span>
}
@type interface
s can only be inherited byconcrete
categories.
concrete MyValue { defines Diffable<MyValue>
<span style='color:#898887;'>// The functions of Diffable do not need to be declared again, but you can do</span>
<span style='color:#898887;'>// so to refine the argument and return types.</span>
}
- You can also specify
refines
anddefines
when defining aconcrete
category. This allows the inheritance to be private.
concrete MyValue { @type create () -> (Formatted) }
define MyValue { // Formatted is not a visible parent outside of MyValue. refines Formatted
create () {
<b>return</b> <span style='color:#0057ae;'>MyValue</span>{ }
}
<span style='color:#898887;'>// Inherited from Formatted.</span>
formatted () {
<b>return</b> <span style='color:#bf0303;'>"MyValue"</span>
}
}
Type Inference
Starting with compiler version 0.7.0.0
, Zeolite supports optional inference of
specific function parameters by using ?
. This must be at the top level (no
nesting), and it cannot be used outside of the parameters of the function.
The type-inference system is intentionally "just clever enough" to do things that the programmer can easily guess. More sophisticated inference is feasible in theory (like Haskell uses); however, type errors with such systems can draw a significant amount of attention away from the task at hand. (For example, a common issue with Haskell is not knowing which line of code contains the actual mistake causing a type error.)
concrete Value<#x> { @category create1<#x> (#x) -> (Value<#x>) @type create2 (#x) -> (Value<#x>) }
// ...
// This is fine. Value<Int> value1 <- Value:create1<?>(10)
// These uses of ? are not allowed: // Value<Int> value2 <- Value<?>.create2(10) // Value<?> value2 <- Value<Int>.create2(10)
Only the function arguments and the parameter filters are used to infer the type substitution; return types are ignored. If inference fails, you will see a compiler error and will need to explicitly write out the type.
Type inference will only succeed if:
There is a valid pattern match between the expected argument types and the types of the passed arguments.
There is exactly one type that matches best:
- For params only used in covariant positions, the lower bound of the type is unambiguous.
- For params only used in contravariant positions, the upper bound of the type is unambiguous.
- For all other situations, the upper and lower bounds are unambiguous and equal to each other.
Type inference in the context of parameterized types is specifically disallowed in order to limit the amount of code the reader needs to search to figure out what types are being used. Forcing explicit specification of types for local variables is more work for the programmer, but it makes the code easier to reason about later on.
Other Features
This section discusses language features that are less frequently used.
Meta Types
Zeolite provides two meta types that allow unnamed combinations of other types.
- A value with an intersection type
[A&B]
can be assigned from something that is bothA
andB
, and can be assigned to either anA
orB
. There is a special empty intersection namedany
that can be assigned from any value but cannot be assigned to any other type.
Intersections can be useful for requiring multiple interfaces without creating
a new category that refines all of those interfaces. An intersection
[Foo&Bar]
in Zeolite is semantically similar to the existential type
forall a. (Foo a, Bar a) => a
in Haskell and ? extends Foo & Bar
in Java,
except that in Zeolite [Foo&Bar]
can be used as a first-class type.
@value interface Reader {}
@value interface Writer {}
concrete Data { refines Reader refines Writer @type new () -> (Data) }
// ...
[Reader&Writer] val <- Data.new() Reader val2 <- val Writer val3 <- val
- A value with a union type
[A|B]
can be assigned from eitherA
orB
, but can only be assigned to something that bothA
andB
can be assigned to. There is a special empty union namedall
that cannot ever be assigned a value but that can be assigned to everything. (empty
is actually of typeoptional all
.)
Unions can be useful if you want to artificially limit what implementations of
a particular @value interface
are allowed by a function argument, e.g.,
a specific set of "verified" implementations.
@value interface Printable {}
concrete Newspaper { refines Printable @type new () -> (Newspaper) }
concrete Magazine { refines Printable @type new () -> (Magazine) }
// ...
[Newspaper|Magazine] val <- Newspaper.new() Printable val2 <- val
Runtime Type Reduction
Some other time. (Or just see
reduce.0rt
.)
Internal Type Parameters
Some other time. (Or just see
internal-params.0rt
.)
Builtins
Builtin Types
See
builtin.0rp
for more details about builtin types.
Builtin concrete
types:
Bool
: Eithertrue
orfalse
.Char
: Use single quotes, e.g.,'a'
. Use literal characters, standard escapes (e.g.,'\n'
), 2-digit hex (e.g.,\x0B
), or 3-digit octal (e.g.,'\012'
). At the moment this only supports ASCII; see Issue #22.Float
: Use decimal notation, e.g.,0.0
or1.0E1
. You must have digits on both sides of the.
.Int
: Use decimal (e.g.,1234
), hex (e.g.,\xABCD
), octal (e.g.,\o0123
), or binary (e.g.,\b0100
).String
: Use double quotes to sequenceChar
literals, e.g.,"hello\012"
. You can build a string efficiently usingString.builder()
.
Builtin @value interface
s:
AsBool
: Convert a value toBool
usingasBool()
.AsChar
: Convert a value toChar
usingasChar()
.AsFloat
: Convert a value toFloat
usingasFloat()
.AsInt
: Convert a value toInt
usingasInt()
.Builder<#x>
: Build a#x
using concatenation.Formatted
: Format a value as aString
usingformatted()
.ReadPosition<#x>
: Random access reads from a container with values of type#x
.
Builtin @type interface
s:
Equals<#x>
: Compare values usingequals(x,y)
.LessThan<#x>
: Compare values usinglessThan(x,y)
.
Builtin meta types:
any
: Value type that can be assigned a value of any type. (This is the terminal object in the category of Zeolite types.)all
: Value type that can be assigned to all other types. (This is the initial object in the category of Zeolite types.)
Builtin Constants
empty
: A missingoptional
value.self
: The value being operated on in@value
functions.
Builtin Functions
present
: Checkoptional
forempty
.reduce<#x,#y>
: (Undocumented for now.)require
: Convertoptional
to non-optional
.strong
: Convertweak
tooptional
.
Layout and Dependencies
Using Public Source Files
You can create public .0rp
source files to declare concrete
categories
and interface
s that are available for use in other sources. This is the only
way to share code between different source files. .0rp
cannot contain
define
s for concrete
categories.
During compilation, all .0rp
files in the project directory are loaded up
front. This is then used as the set of public symbols available when each .0rx
is separately compiled.
Standard Library
The standard library currently temporary and lacks a lot of functionality. See
the public .0rp
sources in lib
. Documentation will eventually follow.
Modules
You can depend on another module using -i lib/util
for a public dependency and
-I lib/util
for a private dependency when calling zeolite
. (A private
dependency is not visible to modules that depend on your module.)
Dependency paths are first checked relative to the module depending on them. If
the dependency is not found there, the compiler then checks the global location
specified by zeolite --get-path
.
Public .0rp
source files are loaded from all dependencies during compilation,
and so their symbols are available to all source files in the module. There is
currently no language syntax for explicitly importing or including modules or
other symbols.
If you are interested in backing a concrete
category with C++, you will need
to write a custom .zeolite-module
file. Better documentation will eventually
follow, but for now:
- Create a
.0rp
with declarations of all of theconcrete
categories you intend to define in C++ code. - Run
zeolite
in--templates
mode to generate.cpp
templates for allconcrete
categories that lack a definition in your module. - Run
zeolite
in-c
mode to get a basic.zeolite-module
. After this, always use recompile mode (-r
) to use your.zeolite-module
. - Take a look at
.zeolite-module
inlib/file
to get an idea of how to tell the compiler where your category definitions are. - Add your code to the generated
.cpp
files.lib/file
is also a reasonable example for this. - If you need to depend on external libraries, fill in the
include_paths
andlink_flags
sections of.zeolite-module
.
Unit Testing
Unit testing is a built-in capability of Zeolite. Unit tests use .0rt
source
files, which are like .0rx
source files with testcase
metadata. The test
files go in the same directory as the rest of your source files. (Elsewhere in
this project these tests are referred to as "integration tests" because this
testing mode is used to ensure that the zeolite
compiler operates properly
end-to-end.)
IMPORTANT: Prior to compiler version 0.10.0.0
, the testcase
syntax was
slightly different, and unittest
was not available.
// myprogram/tests.0rt
// Each testcase starts with a header specifying a name for the group of tests. // This provides common setup code for a group of unit tests.
testcase "passing tests" { // All unittest are expected to execute without any issues. success }
// Everything after the testcase (up until the next testcase) is like a .0rx.
// At least one unittest must be defined when success is expected. Each unittest // must have a distinct name within the testcase. Each unittest is run in a // separate process, making it safe to alter global state.
unittest myTest1 { // The test content goes here. It has access to anything within the testcase // besides other unittest. }
unittest myTest2 { </span> empty }
// A new testcase header indicates the end of the previous test.
testcase "missing function" { // The test is expected to have a compilation error. Note that this cannot be // used to check for parser failures! // // Any testcase can specify require and exclude regex patterns for checking // test output. Each pattern can optionally be qualified with one of compiler, // stderr, or stdout, to specify the source of the output. error require compiler "run" // The compiler error should include "run". exclude compiler "foo" // The compiler error should not include "foo". }
// You can include unittest when an error is expected; however, they will not be // run even if compilation succeeds.
define MyType { // Error! MyType does not have a definition for run. }
concrete MyType { @type run () -> () }
testcase "intentional crash" { // The test is expected to crash. crash require stderr "message" // stderr should include "message". }
// Exactly one unittest must be defined when a crash is expected.
unittest myTest { // Use the fail built-in to cause a test failure. fail("message") }
Unit tests have access to all public symbols in the module. You can run all
tests for module myprogram
using zeolite -t myprogram
.
Compiler Pragmas and Macros
(As of compiler version 0.5.0.0
.)
Pragmas allow compiler-specific directives within source files that do not otherwise need to be a part of the language syntax. Macros have the same format, and are used to insert code after parsing but before compilation.
The syntax for both is $SomePragma$
(no options) or
$AnotherPragma[
OPTIONS
]$
(uses pragma-specific options). The syntax for
OPTIONS
depends on the pragma being used. Pragmas are specific to the
context they are used in.
Source File Pragmas
These must be at the top of the source file, before declaring or defining
categories or testcase
s.
$ModuleOnly$
. This can only be used in.0rp
files. It takes an otherwise-public source file and limits visibility to the module. (This is similar to package-private in Java.)$TestsOnly$
. This can be used in.0rp
and.0rx
files. When used, the file is only visible to other sources that use it, as well as.0rt
sources..0rp
sources still remain public unless$ModuleOnly$
is used. The transitive effect of$TestsOnly$
is preventing the use of particular categories in output binaries.
Procedure Pragmas
These must occur at the very top of a function definition.
$NoTrace$
. (As of compiler version0.6.0.0
.) Disables stack-tracing within this procedure. This is useful for recursive functions, so that trace information does not take up stack space. This does not affect tracing for functions that are called from within the procedure.$TraceCreation$
. (As of compiler version0.6.0.0
.) Includes a trace of the value's creation when the given@value
function is called. If multiple functions in a call stack use$TraceCreation$
, only the trace from the bottom-most function will be included.
$TraceCreation$
is useful when the context that the value was created in is
relevant when debugging crashes. The added execution cost for the function is
trivial; however, it increases the memory size of the value by a few bytes per
call currently on the stack at the time it gets created.
Expression Macros
These can be used in place of language expressions.
$SourceContext$
. (As of compiler version0.7.1.0
.) Inserts aString
-literal with information about the macro's location within the source file. Note that if this is used within an expression macro in.zeolite-module
(seeExprLookup
below), the context will be within the.zeolite-module
file itself. (Remember that macro substitution is not a preprocessor stage, unlike the C preprocessor.)$ExprLookup[
MACRO_NAME
]$
. (As of compiler version0.6.0.0
.) This directly substitutes in a language expression, as if it was parsed from that exact code location.MACRO_NAME
is the key used to look up the expression. Symbols will be resolved in the context that the substutition happens in.MODULE_PATH
is always defined. It is aString
-literal containing the absolute path to the module owning the source file. This can be useful for locating data directories within your module independently of$PWD
.- Custom macros can be included in the
.zeolite-module
for your module. This can be useful if your module requires different parameters from one system to another.
// my-module/.zeolite-module
// (Standard part of .zeolite-module.) path: "."
// Define your macros here. expression_map: [ expression_macro { name: USE_DATA_VERSION // Access using $ExprLookup[USE_DATA_VERSION]$. expression: "2020-05-12" // Substituted in as a Zeolite expression. } expression_macro { name: RECURSION_LIMIT expression: 100000 } expression_macro { name: SHOW_LIMIT // All Zeolite expressions are allowed. expression: "limit: " + $ExprLookup[RECURSION_LIMIT]$.formatted() } ]
// (Standard part of .zeolite-module.) mode: incremental {}
The
name:
must only contain uppercase letters, numbers, and_
, and theexpression:
must parse as a valid Zeolite expression. This is similar to C++ macros, except that the substitution must be independently parsable as a valid expression, and it can only be used where expressions are otherwise allowed.