Ucg expressions can reference a bound name, do math, concatenate lists or strings, copy and modify a struct, or format a string.
Many UCG expressions or statements use a symbol. A symbol might be used as
either a name for a binding or a name for a field. Symbols must start with an
ascii letter and can contain any ascii letter, number, _
, or -
characters.
There is a special symbol in UCG for obtaining a value from the environment.
The env
symbol references the environment variables in environment at the
time of the build. You reference an environment variable just like it was in a
tuple. By default, attempting to reference a variable that doesn't exist will
be a compile error. You can turn this behavior off with the --nostrict
argument to the compiler. When in nostrict mode nonexistent variables will
result in a warning and be set to the NULL empty value.
let env_name = env.DEPLOY_ENV;
UCG has a number of binary infix operators. Some work only on numeric values and others work on more than one type.
The UCG selector operator .
selects a field or index from tuples or lists.
They can descend arbitrarily deep into data structures.
You can reference a field in a tuple by putting the field name after a dot. You
can index into a list by referencing the index after the .
. Lists are always
0 indexed.
let tuple = { inner = { field = "value", }, list = [1, 2, 3], "quoted field" = "quoted value", }; // reference the field in the inner tuple in our tuple defined above. tuple.inner.field; // reference the field in the list contained in our tuple defined above. tuple.list.0;
Selectors can quote fields if there are quoted fields with spaces in the tuple.
tuple."quoted field";
UCG supports the following numeric operators, +
, -
, *
, /
Each one is type safe
and infers the types from the values they operate on. The operators expect both the
left and right operands to be of the same type.
1 + 1; 1.0 - 1.0;
The +
operator can also do concatenation on strings and lists. As with the numeric
version both sides must be the same type, either string or list.
"Hello " + "World"; // "Hello World" [1, 2] + [3]; // [1, 2, 3]
UCG supports the comparison operators ==
, !=
, >=
, <=
, <
, >
, and in
.
They all expect both sides to be of the same type.
The >
, <
, >=
, and >=
operators are only supported on numeric types
(i.e. int, and float).
1 > 2; // result is false 2 < 3; // result is true 10 > "9"; // This is a compile error. (1+2) == 3;
The equality operators ==
and !=
are supported for all types and will
perform deep equal comparisons on complex types.
let tpl1 = { foo = "bar", one = 1 }; let tpl2 = { foo = "bar", one = 1 }; tpl1 == tpl2; // returns true let tpl2 = { foo = "bar", one = 1 duck = "quack", }; tpl1 == tpl3; // returns false
Because tuples are an ordered set both tuples in a comparison must have their fields in the same order to compare as equal.
The in
operator tests for the existence of a field in a tuple or an element in a
list.
let tpl = { foo = "bar" }; foo in tpl; // evaluates to true "foo" in tpl; // also evaluates to true.
Lists do a deep equal comparison when testing for the existence of an element.
let lst = [1, "two", {three = 3}]; 1 in lst; // evaluates to true; {three = 3} in lst; // evaluates to true {three = "3"} in lst; // evaluates to false {three = 3, two = 2} in lst // evaluates to false
UCG has the standard boolean operators: &&
and ||
. Both of them short
circuit and they require the expressions on each side to be boolean.
true && false == false; false || true == true;
In addition to the binary operators &&
and ||
UCG also has the unary
operator not
which reverses a boolean value.
not true == false; not false == true;
UCG binary operators follow the typical operator precedence for math. *
and
/
are higher precendence than +
and -
which are higher precedence than
any of the comparison operators.
Precedence table
Higher values bind tighter than lower values.
Operator | Precedence | Description |
---|---|---|
== | 1 | Equality Comparison |
!= | 1 | Inequality Comparison |
>= | 1 | Greater Than or Equal |
<= | 1 | Less Than or Equal |
< | 1 | Greater Than |
> | 1 | Less Than |
=~ | 1 | Regex Match |
!~ | 1 | Negated Regex Match |
in | 2 | Contains field or item |
is | 2 | Type check |
+ | 3 | Sum or concatenation |
- | 3 | Subtraction |
* | 4 | Product |
/ | 4 | Division |
%% | 4 | Modulus |
&& | 5 | And |
|| | 5 | Or |
. | 6 | Dot Selector |
ucg has the is
operator for testing that something is of a given base type.
The type must be a string literal matching one of:
"null"
"str"
"int"
"float"
"tuple"
"list"
"func"
"module"
("foo" is "str") == true;
UCG can cast primitive types to other primitive types. UCG is very conservative in
the casts it allows however and a failed cast is a compile error. The allowed casts
are int(expr)
, float(expr)
, str(expr)
, and bool(expr)
. Casts are not function
calls even though they look like them and as a result you can not define your own
functions with the same name as a cast. If the expressions do not resolve to a primitive
type that is castable to the desired type then a compile error will occur.
UCG expressions have a special copy expression for tuples. These faciliate a
form of data reuse as well as a way to get a modified version of a tuple. Copy
expressions start with a selector referencing a tuple followed by braces {}
with name = value
pairs separated by commas. Trailing commas are allowed.
Copied expressions can change base fields in the copied tuple or add new fields. If you are changing the value of a base field in the copy then the new value must be of the same type as the base field's value. This allows you to define a base "type" of sorts and ensure that any modified fields stay the same.
let base = { field1 = "value1", field2 = 100, field3 = 5.6, }; let overridden = base{ field1 = "new value" }; let expanded = base{ field2 = 200, field3 = "look ma a new field", }; let bad = base{ field1 = 300, // Error!!! must be a string. };
There is a special selector that can be used in a copy expression to refer to
the base tuple in a copy called self
. self
can only be used in the body of
the copy.
let nestedtpl = { field1 = "value1", inner = { field2 = 2 inner = { field3 = "three", }, }, }; let copiedtpl = nestedtpl{ inner = self.inner{ inner = self.inner{ field4 = 4, }, }, };
Import expressions bring in a ucg file and expose their bound values as a tuple
in the current file. Import expressions are idempotent and cached so you can
use them more than once in a file safely. Import expressions start with the import
keyword and are followed by a string containing the path of the file to import.
// You can import an entire file into the namespace. let imported = import "some_file.ucg"; // Or you can just import a single value from that file. let imported_val = (import "some_file.ucg").val;
UCG has a format expression that has a limited form of string templating. Format expressions come in two forms.
The simplest form starts with a string followed by the %
operator and a list
of arguments in parentheses separated by commas. Trailing commas are allowed.
The format string should have @
characters in each location where a value
should be placed. Any primitive value can be used as an argument.
"https://@:@/" % (host, port)
A slightly more complex form starts with a string followed by the %
operator
and an unparenthesized expression. the template string can then reference the
result of the expression in expressions embedded within the format string. The
expressions result can be referenced using the special name item
in the
embedded expression. The result of the expression will be rendered as the
default string representation in the resulting string.
let tpl = { foo = { bar = [0, 1, 2], }, }; "foo.bar.1 == @{item.foo.bar.1}" % tpl;
The @
symbol can be escaped with a double slash.
"https://username:password\\@@:@/" % (host, port)
If the %
operator is followed by a parenthesized expression it will be treated
as the first form with one item.
UCG can generate lists from a range with an optional step.
1:10 == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; 0:2:10 == [0, 2, 4, 6, 8, 10];
Functions close over the environment up to the point where they are declared in
the file. One consequence of this is that they can not call themselves so
recursive functions are not possible. This is probably a feature. They are
useful for constructing tuples of a certain shape or otherwise promoting data
reuse. You define a function with the function
keyword followed by the
arguments in parentheses, a =>
, and then a valid expression.
let myfunc = func (arg1, arg2) => { host = arg1, port = arg2, connstr = "couchdb://@:@" % (arg1, arg2), }; let my_dbconf = myfunc("couchdb.example.org", "9090"); let my_dbhost = dbconf.host; let add = func (arg1, arg2) => arg1 + arg2; add(1, 1) == 2;
UCG has a few functional processing expressions called map
, filter
, and
reduce
. All of them can process a string, list, or tuple.
Their syntax starts with either map
filter
, or reduce
followed by a symbol
that references a valid func and finally an expression that resolves to either
a list or a tuple.
Map functions should produce either a valid value or a list of [field, value] that will replace the element or field it is curently processing.
Lists
When mapping a function across a list the result field can be any valid value. The function is expected to take a single argument.
let list1 = [1, 2, 3, 4]; let mapper = func (item) => item + 1; map(mapper, list1) == [2, 3, 4, 5];
Tuples
Functions for mapping across a tuple are expected to take two arguments. The first argument is the name of the field. The second argument is the value in that field. The result should be a two item list with the first item being the new field name and the second item being the new value.
let test_tpl = { foo = "bar", quux = "baz", }; let tpl_mapper = func (name, val) => select (name, [name, val]) => { "foo" = ["foo", "barbar"], quux = ["cute", "pygmy"], }; map(tpl_mapper, test_tpl) == {foo = "barbar", cute = "pygmy"};
Strings
let string = "foo"; let string_mapper = func (item) => item + item; map(string_reducer, 0, string) == "ffoooo";
Filter expressions should return a field with false or NULL for items to filter out of the list or tuple. Any other value in the return field results in the item or field staying in the resulting list or tuple.
Lists
let list2 = ["foo", "bar", "foo", "bar"]; let filtrator = func (item) => select (item, NULL) { foo = item, }; filter(filtrator, list2) == ["foo", "foo"];
Tuples
let test_tpl = { foo = "bar", quux = "baz", }; let tpl_filter = func (name, val) => name != "foo"; filter(tpl_filter, test_tpl) == { quux = "baz" };
Strings
let string = "foo"; let string_filter = func (item) => item != "f"; filter(string_reducer, 0, string) == "oo";
Reduce expressions start with the reduce keyword followed by a symbol referencing a func, an expression for the accumulator, and finally the tuple, list, or string to process.
Tuples
let test_tpl = { foo = "bar", quux = "baz", }; let tpl_reducer = func (acc, name, val) => acc{ keys = self.keys + [name], vals = self.vals + [val], }; reduce(tpl_reducer, {keys = [], vals = []}, test_tpl) == {keys = ["foo", "quux"], vals = ["bar", "baz"]};
Lists
let list1 = [1, 2, 3, 4]; let list_reducer = func (acc, item) => acc + item; reduce(list_reducer, 0, list1) == 0 + 1 + 2 + 3 + 4;
Strings
let string = "foo"; let string_reducer = func (acc, item) => acc + [item]; reduce(string_reducer, 0, string) == ["f", "o", "o"];
UCG can include the contents of other files as an expression. Currently we only
support strings and base64 encoding but we plan to support yaml, and json in
the future. include expressions start with the include
keyword a type
(currently only str
), and a path. Relative paths are calculated relative to
the including file.
let script = include str "./script.sh";
UCG supports a limited conditional expression called a select. A select
expression starts with the select
keyword and is followed by a an expression
resolving to a string or boolean naming the field to select, an optional
expression resolving to the default value in parenthesis a =>
and finally a
tuple literal to select the field from. If the field selected is not in the
tuple then the default value will be used. If no default is specified then
select will throw a compile failure for the unhandled case.
let want = "baz"; // field default select (want, "quux") => { baz = "foo", fuzz = "bang", }; // result will be "foo" // field default select ("quack", "quux") => { baz = "foo", fuzz = "bang", }; // result will be "quux" let ifresult = select (true) => { true = "true result", false = "false result", }; // result will be "true result"
UCG has another form of reusable execution that is a little more robust than functions are. Modules allow you to parameterize a set of statements and build the statements later. Modules are an expression. They can be bound to a value and then reused later. Modules do not close over their environment but they can import other UCG files into the module using import statements including the file they are located themselves. This works since the statements in a module are not evaluated until you attempt to call the module with a copy expression.
Module expressions start with the module keyword followed by a tuple
representing their parameters with any associated default values. The body of
the module is separated from the parameter tuple by the =>
symbol an optional
(expr)
defining the out expression, and a series of statements delimited by
{
and }
respectively. The body of the module can contain any valid UCG
statement. You instantiate a module via the copy expression. By default if
there is no out expression then the module will export all of the named
bindings in the statements.
let top_mod = module { deep_value = "None", } => { let shared_funcs = import "shared.UCG"; let embedded_def = module { deep_value = "None", } => { let value = mod.deep_value; }; let embedded = embedded_def{deep_value = mod.deep_value}; }; let embedded_with_params = top_mod{deep_value = "Some"}; // By default all of the named bindings in a module are exported so we can // get the embedded tuple out via a selector. embedded_with_params.embedded.value == "Some";
If there is a return expression then the module will only export the result of that expression. The out expression is computed after the last statement in the module has been evaluated.
let top_mod_out_expr = module { deep_value = "None", } => (embedded) { // we will only expose the embedded binding in our module let shared_funcs = import "shared.UCG"; let embedded_def = module { deep_value = "None", } => { let value = mod.deep_value; }; let embedded = embedded_def{deep_value = mod.deep_value}; }; let embedded_default_params = top_mod_out_expr{}; // We don't have to dereference the embedded binding since the out expression // exported it for us. embedded_default_params.value == "None";
One consequence of a module being able to import the same file they are located in is that modules can be called recursively. They are the only expression that is capable of recursion in UCG. Recursion can be done by importing the module's file inside the module's definition and using it as normal.
Modules have ia recursive reference to the current module mod.this
that can
be used for recursive modules.
let recursive = module { counter=1, stop=10, } => (result) { let result = select mod.counter != mod.stop, { true = [mod.start] + mod.this{counter=mod.counter+1}, false = [mod.start], }; }; recursive{} == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
There is also a convenience function mod.pkg
in the mod binding for a module.
That imports the package/file the module was declared in. This binding is only
present if the module was declared in a file. Modules created as part of an
eval will not have it.
let self_importer = module { item=NULL, } => () { let pkg = mod.pkg(); let result = pkg.recursive{}; }; self_importer{} == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
UCG has a way to declaratively trigger a build failure using the fail
expression.
Fail expressions start with the fail
keyword and are followed an expression
that must resolve to a string with the build failure message.
fail "Oh No This was not what we wanted!"; fail "Expected foo but got @" % ("bar");
UCG has a debugging expression that can be helpful to trace values while developing called the trace expression.
Trace expression are any valid expression preceded by the TRACE
keyword. Trace
expression return the result of the expression unchanged but they also output a
trace statement to stderr printing the result of the expression as well as the
file, line and column where the expression was.
let mk_list = func(a, b) => TRACE [a, b]; mk_list(1, 2);
This will output a line to stderr something like the below:
TRACE: [1, 2] at file: <file name> line: 1 column: 29
This is helpful when developing shared modules or ucg libraries.
UCG has convert expressions which will turn any UCG value into a string using the specified conversion format. This expression is similar to the out expression except instead of writing to a file it writes to a string.
It's useful for previewing the result of converting a ucg value in the repl or for composing multiple conversion formats together into a single composite ucg value.
You can experiment with conversion in the repl:
> convert json {foo="bar"}; "{ \"foo\": \"bar\" }" >
Or store a converted value into a UCG string:
let converted = convert json {foo="bar"};
Next: Statements