Ludwig Syntax Reference

Click here to return to the Advanced Ludwig table of contents.

Structure of a Composition

Ludwig compositions consist of expressions, such as imports, type definitions, and variable bindings. This very simple example shows us the structure of a typical composition:

# A composition statement
composition

# An import
import Fugue.AWS

# A type definition
type Tag:
  key: String
  value: String

# A binding
Tag vpcTag: {
  key: "Name",
  value: "Value"
}

Bindings

Bindings are the most common element in a Ludwig composition. A binding consists of a name, a body, and optionally a type annotation. It’s a good practice to add type annotations for all named function definitions, and wherever type annotations increase readability.

# With type annotation
String greeting: "Hello world!"

# Without type annotation, but same binding, value, and type
greeting: "Hello world!"

Bindings always start with a lowercase character.

Primitive types

Ludwig is statically-typed, which means that every expression has a type that is checked at compile time. Ludwig types can roughly be split into two categories: primitive types and user-defined types.

Let’s illustrate the primitive types.

Int

A standard machine integer.

Example:

  • Int myInt: 1
Float

A double-precision floating point number.

Example:

  • Float myFloat: 3.14
String

A Unicode string surrounded by single or double quotes.

Strings can span multiple lines, and lines are joined by a single space character. Or, you can use \ on the next line to preserve new lines.

Example:

  • With double quotes: String myString1: "Hello world!"
  • With single quotes: String myString2: 'Hello world!'
  • Spanning multiple lines:
String myLongString1:
     "This is a line
     and this is a continuation"

String myLongString2:
     "This is a line
     \and this is a new line"
Bool

Boolean value.

Example:

  • Bool myBool1: True
  • Bool myBool2: False
List

Lists preserve ordering and are homogeneous, so they can contain values of any single type.

Example:

  • List of Ints: List<Int> myList: [1, 2, 3]
  • This list would produce a compiler error because the values are not the same type: List<Int> myMistake: [1, 2, "foo"]
Dictionary

Dictionaries have no explicit ordering; think of them as hashmaps. Keys are always strings, and values can be any primitive type. Like lists, dictionaries are homogeneous, so all values must be of the same type.

Example:

  • Dictionary of Ints: Dictionary<Int> myDictionary: {"foo": 1, "bar": 2}
Bytes

Binary data read from an external file.

Example:

  • Bytes myBytes: Ludwig.Bytes.readFile("/etc/passwd")
Optional

Optional type. The Optional type “wraps” one of the above types, and just means that the value doesn’t have to be specified. You would usually see optionals within records.

Example:

Both of these are valid assignments (though not in the same composition):

  • Optional<Int> myOptInt: Optional(23)
  • Optional<Int> myOptInt: None

All types (primitive but also user-defined) start with an uppercase character.

Records

Defining records

In addition to primitive types, it is possible to use records in Ludwig. A record can be declared in either JSON- or YAML-style syntax.

# JSON-style syntax
prod: {
  region: AWS.Us-west-1,
  instanceSize: EC2.M4_large
}

# YAML-style syntax
stage:
  region: AWS.Us-east-1
  instanceSize: EC2.M3_medium

The types of these bindings are, unsurprisingly, record types. These are written like this:

{region: AWS.Region, instanceSize: EC2.InstanceType} prod: {region: AWS.Us-west-1, instanceSize: EC2.M4_large}

Types like this can become unwieldy to read and write very quickly. That is why the type keyword allows you to introduce an alias. An alias is just a synonym for a type. Here, in JSON-like syntax, you can see the alias Environment standing in for the full record type in the third line:

type Environment: {region: AWS.Region, instanceSize: EC2.InstanceType}

Environment prod: {region: AWS.Us-west-1, instanceSize: EC2.M4_large}

In a similar way to the records themselves, we can also define types in YAML-like syntax in addition to the JSON-like syntax above.

type Environment:
  region: AWS.Region
  instanceSize: EC2.InstanceType

Selecting from records

Fields can be selected from a record using the record.field-name syntax, e.g.:

EC2.InstanceType prod-instance: prod.instanceSize

Updating records

Records can be updated using the with keyword. Both JSON- and YAML-like syntax is available.

prod2: prod with {region: AWS.Us-west-2, availabilityZone: AWS.A}

stage2: stage with
  region: AWS.Us-west-1
  availabilityZone: AWS.B

If a field does not yet exist in the record, it is added. This is why prod2 and stage2 are no longer of the type Environment: they have an extra field, availabilityZone.

Nested record values can also be updated. If we have the following types and environment:

type Environment:
  region: AWS.Region
  instanceSize: EC2.InstanceType
  tag: Tag

type Tag:
  key: String
  value: String

Environment prod3:
  region: AWS.Us-east-1
  instanceSize: EC2.M3_large
  tag:
    key: "Name"
    value: "Production 3 Environment"

Then we can create a prod4 with a new tag like so:

Environment prod4: prod3 with { tag.value: "Production 4 Environment" }

Tuples

Ludwig also has tuples:

my-tuple: ("Hello", 123, True)

And tuples can be given types:

(String, Int, Bool) my-typed-tuple: ("Hello", 123, True)

Algebraic data types

Defining algebraic data types

Ludwig supports algebraic data types. In the simplest form, these are just untagged enumerations:

type Environment:
  | Prod
  | Stage
  | Develop

myEnvironment: Stage

However, they can also carry a value.

type EnvironmentPreference:
  | NoPreference
  | SpecificEnvironment Environment

myPreferredEnvironment: SpecificEnvironment(myEnvironment)

If multiple values are required, use a tuple or a record:

type Point: (Int, Int)

type MyEnumType:
    | NeedRecord {field1: Int, field2: String}
    # value types can be defined inline
    | NeedTuple (Int, String)
    # or in an type
    | NeedTuple2 Point

MyEnumType value1: NeedRecord({field1: 33, field2: "Hello"})
MyEnumType value2: NeedTuple((33, "Hello"))
MyEnumType value3: NeedTuple2((0,0))

Pattern matching

You can see what’s inside an algebraic data type or a tuple value by pattern matching:

env: case myEnvironment of
    | Prod    -> 10
    | Stage   -> 12
    | Develop -> 15

Pattern matching can be used to great effect within functions as well.

fun install {platform: Platform, packages: List<String>} -> String:
  case platform of
    | Debian -> String.join(" ", List.concat(["apt-get -q -y install"], packages))
    | RHEL _ -> String.join(" ", List.concat(["yum -q -y install"], packages))

It is also possible to match on the values inside the constructors.

havePreference: case myPreferredEnvironment of
    | NoPreference          -> False
    | SpecificEnvironment _ -> True

preferenceOrDefault: case myPreferredEnvironment of
    | NoPreference          -> Develop
    | SpecificEnvironment c -> c

Using the example above of myPreferredEnvironment: SpecificEnvironment(myEnvironment):

  • havePreference(myPreferredEnvironment) evaluates to True, as myEnvironment is a SpecificEnvironment.
  • preferenceOrDefault(myPreferredEnvironment) evaluates to Stage for the same reason; if myPreferredEnvironment were the NoPreference value, the result would be a default of Develop.

Record Updates Inside Constructors

We can also safely update values that may be inside of sum type constructors using the same familiar syntax for records. If we have this:

type Foo:
  | Foo {foo: String}

bar: Foo {foo: "foo"}

Then we can create a new binding by updating the interior foo value like so:

baz: bar with { Foo.foo: "baz" }

Record Selects Inside Constructors

Similar to updating values inside of sum type constructors, we can also do selects. The restrictions here are that:

  1. The sum type only has a single constructor.
  2. That single constructor has a value inside.

This syntax is consistent with updates:

type Foo:
  | Foo {foo: String}

Foo bar: Foo({foo: "foo"})
String qux: bar.Foo.foo

Functions

Syntax

Ludwig supports defining and calling functions. Let’s look at a simple example of a function with an explicit type.

type Tag:
    key: String
    value:  String

fun get-value(p: Tag) -> String: p.value

We can call this function using familiar syntax:

example-value: get-value({key: "Name", value: "Production Environment"})

There are two “flavors” of function in Ludwig. The one above is the positional argument flavor – noted by its use of parentheses () in the function definition. There’s also a named argument flavor, which is defined with curly braces {}. Usage is very similar, just that in the named argument flavor, the caller must use named arguments.

fun get-value-named-argument{p: Tag} -> String:
  p.value

example-value-two: get-value-named-argument {
  p: {key: "Name", value: "Staging Environment"}
}

Anonymous functions

Ludwig also supports anonymous functions. For example:

get-anon: fun(p): p.value

The type of this anonymous function is:

fun (Tag) -> String

Function types are useful when we want to store functions in data types.

type Curve:
    name: String
    y:    fun(Int) -> Int

Curve quadratic:
    name: "The simple quadratic curve"
    y:    fun(x): x * x

Shorthand syntax

In most cases you must provide () (positional) or {} (keyword) when calling a function. However, there is a shorthand syntax available for calling functions that take a string or a record as a single argument.

foo1: get-anon{value: "Develop Environment"}   # Same as get-anon({value: "Develop Environment"})
foo2: String.length "Develop Environment"     # Same as String.length("Develop Environment")
foo3: String.length"Develop Environment"      # Same as String.length("Develop Environment")

Conditionals

In addition to Pattern matching, Ludwig also has traditional if-elif-else conditionals.

fun getRegionFromEnvironment(env: Environment) -> AWS.Region:
    if env.is-staging then AWS.Us-west-1 else AWS.Us-east-1

You can put the branches on different lines, but in that case it is important that the indentation of if and else matches.

fun getRegionFromEnvironmentSeparateLines(env: Environment) -> AWS.Region:
    if env.is-staging
    then AWS.Us-west-1
    else AWS.Us-east-1

Any number of elif expressions can be added between the if and else branches.

fun getRegionFromEnvironmentComplex(env: Environment) -> AWS.Region:
    if   env.is-prod         then AWS.Us-west-2
    elif env.is-staging      then AWS.Us-west-1
    else AWS.Us-east-1

However, there must always be an else branch. The following two examples are illegal:

fun getRegionFromEnvironment(env: Environment) -> AWS.Region:
    if env.is-staging then AWS.Us-west-1

fun getRegionFromEnvironmentComplex(env: Environment) -> AWS.Region:
    if   env.is-prod         then AWS.Us-west-2
    elif env.is-staging      then AWS.Us-west-1

Operators

Ludwig has some basic operators built in.

There is basic integer arithmetic, using the +, -, *, and / operators:

foo: 10 / 2

bar: foo + 1

There is a parallel set of floating point operators, which are like the integer operators but preceded with a ., namely .+, .-, .*, and ./.

There is string concatenation, using the ++ operator:

hello: "hello" ++ "world"

Ludwig also has the usual &&, ||, and ! boolean operators.

Local variables

You can define local variables within functions or bindings. To do this, we use the let keyword. The line after the let keyword must have the same indentation.

foo:
    let x: 1
    x + 2

In this example, foo will be bound to the value 3.

You can declare multiple local variables after one another. We also support type annotations and function declarations, very similar to top-level bindings.

bar:
    let fun add-some(x: Int) -> Int: x + 2
    let y: 1
    1 + add-some(y)

List comprehensions

Ludwig supports Python-inspired list comprehensions.

numbers: [i for i in Int.range{to: 10}]

These can be mixed with if conditions.

odd-numbers: [i for i in numbers if i % 2 == 0]

Additionally, you can use patterns in the for part of a list comprehension. This works just like Pattern matching. Since you can also call functions from list comprehensions, this makes for some very powerful combinations:

import Fugue.AWS.EC2 as EC2

# For example, if you have a list of optionals...
List<Optional<String>> optionals:
  [String.getEnv("AMI01"), String.getEnv("AMI02")]

# You can create an instance with only the not-None elements by pattern
# matching on it.
example:
  [EC2.Instance.new {image: ami, ...} for Optional ami in optionals]

Annotations and Pragmas

There are a handful of field annotations and directive pragmas in the Ludwig language, and they use a similar syntax. Users of Fugue don’t need to write code using these annotations and pragmas, but some of them have relevant meaning when you read them. We’ll explain what annotations matter to you, and which don’t, below.

@mutable

The @mutable annotation is one that appears in Fugue.* libraries. It indicates a field that, when changed in a composition, is modified by Fugue upon an update command. If the field is not labelled as @mutable, modifying it will cause the resource to be “destructively updated,” meaning that the resource will be replaced with a new one.

@deprecated

The @deprecated annotation also appears in Fugue.* libraries. It indicates that a given item is deprecated and may be applied to types, constructors, functions, operators, and even entire modules. If a composition references a deprecated item, the lwc compiler sends a warning to stderr, like this one:

ludwig (warning):
  "UseDeprecatedModule.lw" (line 1, column 8):
  module Deprecated-import1 is deprecated:

    1| import Deprecated-import1 as .
              ^^^^^^^^^^^^^^^^^^^^^^^


  Hint:
    Use another module.

lwc tolerates warnings by default, so if there are no errors, the composition still compiles. When using the lwc compiler by itself, you can turn warnings off with --no-warnings or opt to fail on warnings with --werror. See The Ludwig Compiler for more details.

@proto

The @proto annotation is one used internally by Fugue. It cannot be usefully applied by Fugue users at this time.

@language

The @language pragma is generally only used by Fugue. It is primarily used within the Ludwig.Prelude modules to prevent them from importing themselves. There is no useful application of this pragma for Fugue users at this time.

Modules

What are modules?

A module in Ludwig is currently represented by a file. For example, the file Basic.lw provides the Basic module. Modules can be nested hierarchically using directories, so the file Network/Ip.lw provides the Network.Ip module.

As you can see, module names (and thus files containing modules) always start with an uppercase character.

In order to use such a module, we use the import keyword. For example,

import Basic
import Network.Ip

makes all the type definitions and data declarations in the files Basic.lw and Network/Ip.lw available in the present file. To refer to them, you would write Basic.foo or Network.Ip.bar. Alternatively, if writing the fully qualified name is a bit long, you can alias a module:

import Basic as B

This makes types and exports from Basic available under the prefix B; e.g., B.Foo. Finally, you can also import the names into the current scope:

import Basic as .

This means you can now just use foo instead of Basic.foo. This is often very useful, although you have to watch out for naming conflicts.

If you have many imports, indentation-based syntax allows you to type the import keyword only once.

import
    Basic
    Network.Ip as .
    Foo as F

There’s also some syntactic sugar for dealing with a lot of nested imports. Instead of writing:

import
    Fugue.AWS.EC2
    Fugue.AWS.IAM
    Fugue.AWS.Foo as Foo

you can use the following shorthand syntax:

import Fugue.AWS.{EC2, IAM, Foo as Foo}

Module directories

Where Ludwig looks for the files Basic.lw and Network/Ip.lw is determined by the search path. The compiler searches these paths in order and uses the first it finds:

  1. The current directory.
  2. The directories in the LUDWIG_PATH environment variable, e.g:
LUDWIG_PATH=lib/ludwig:~/.ludwig lwc main.lw

3. Additional directories passed in with an -i flag, e.g.
lwc -i lib/ludwig:~/.ludwig lwc main.lw

4. In /usr/local/ludwig, on POSIX systems.

If you need to know which files are actually used, you can dump this info by passing -dmodules to lwc.

Controlling exports

By default, everything in a module is exported. This can be fine-tuned by using export lists. If one or more export lists occur in the file, only the things explicitly mentioned in the lists are exported.

We use the export keyword to define such a list.

export
    type Environment     # Export the type Environment
    default-environment  # Export a variable or function

default-environment: ...

type Environment: ...

You can also re-export whole modules. This is a bit more complicated, since we can nest them. Let’s look at an example. Imagine we have a module Mod.lw:

export module Foo as Foo  # Export in a nested way
export module Bar as .    # Export in this module's scope

import Foo
import bar

And then another module importing this Mod.lw:

import Mod

foo: Mod.Foo.foo  # Because Foo was exported as Foo in Mod
bar: Mod.bar      # Because Bar was exported as . in Mod

Compositions

When running lwc with the --composition flag (which is the default when using Fugue), we expect a single composition as argument. A composition is a special kind of module, much like a “main” module in other languages.

A composition has a composition keyword, usually at the start of the module. For example, in MyComp.lw:

composition

import Fugue.AWS

my-vpc: ...

If you use lwc with the -s simple semantic backend, all bindings declared in the composition are visible in the output, while bindings declared in other (imported) modules are not. Note: By default, lwc uses the null semantic backend, which produces no output if there are no errors or warnings.

Note

Did you know that Fugue offers editor plug-ins so you can read and write Ludwig in your editor/IDE of choice? All plug-ins include Ludwig syntax highlighting, and some have additional features. See ludwig-mode for Emacs, ludwig-vim for vim, vscode-language-ludwig for VSCode, and language-ludwig for Atom.