Grid

Grid is a high-level language using a mix of imperative and functional constructs, with a focus on being easy to read and reason about.

Goals

  • Reducing the amount of boilerplate required for powerful constructs
  • Structural typing with custom types
  • Pattern matching conditionals
  • Clear type-truthiness
  • Scope-based memory management without explicit ownership or lifetime tracking
  • No garbage collector or reference counting, no borrow checking
  • No external dependencies for self-hosting compiler
  • First-class functions

Structure

A grid program can be split into multiple source files called modules that serve to group files into namespaces.

Syntax:

module name

import path/module

statements

The only required module in a project is main, which must exist in at least one source file the compiler reads. If a source file is in a subdirectory of the project root, it can be addressed via the path portion for importing into the current module as seen above.

Importing a module makes its contents available in the current module via namespacing and the member operator .

Example:

If we have the following module named main.grid.

module main

import hello
import sys

main = (args:[str]) -> int {
  sys.print(test.greeting)
}

And we have another module `hello.grid.

module hello

greeting = "Hello, world!"

Then compiling and running the project will print the line "Hello, world!" as you might expect.

The main module is special as it is where a main function must be defined, and is where execution of your program will begin. The main function always takes one argument -- an array of the program's arguments -- and must return an integer -- the exit code for the program.

Scope

Grid operates similarly to most modern languages when it comes to the concept of scope.

  • The topmost scope is the module scope; functions and constants here are visible from other modules
  • Values defined at the module scope are always constant
  • Constants are visible from any scope
  • Blocks are delineated with braces {}, and can be nested
  • Each block defines a scope, which confines variables and functions within that scope
  • Variables in outer scopes are accessible from inner scopes within the same function

Blocks

Blocks have an implicit value. The result of the last expression in a block is considered the value of the block, so if it occurs in a larger expression it can be replaced with its effective value.

Keywords

Grid has a limited set of keywords which perform specific behaviors in different scopes.

KeywordScopeBehavior
modulemoduleDefines the current module namespace
importmoduleImports other module namespaces into the current module
returnfunctionReturns from the current function, with optional value
yieldstateful functionReturns from the current function retaining execution state, with optional value
continueloopContinues the loop immediately
breakloopBreaks the loop immediately, with optional value

Functions

Functions are a way to define a named unit of code which can be called later, optionally passing data into it, and optionally receiving data back out.

Syntax:

funcname = (name:type) -> type {
  // ...
}

This is the minimum required syntax with no inputs or outputs.

funcname = () -> () {
  // ...
}

Calling

Functions are called with a similar syntax.

varname = funcname(argument)

If the function returns a value you can assign it in an expression as seen above, but this is not required.

Returns

When a function is called, any arguments passed into it become variables available to the function's scope, mapped to the names given in the definition. The value of the arguments is used to initialize the variables.

Example:

The return keyword will immediately exit the function, optionally returning a value if the function definition specifies a return type.

returnInt = () -> int {
  return 1
}

i = returnInt()

Stateful Functions

In many languages there is the concept of a closure, which is a function that encloses the scope it's defined in, essentially capturing state. Grid has a similar feature but it's implemented in a way that's easier to reason about, in the form of stateful functions.

Syntax:

Stateful functions are defined with this pattern.

f = (name:type) >> type {
  // ...
}

Just like return, the yield keyword will also exit a stateful function, optionally returning a value, but the state of the function's execution will be retained at the location of the yield if the function is defined as stateful.

Example:

cycle = (items: [str]) >> str {
  @ {
    items # {
      _, item => yield item
    }
  }
}

When we call cycle we can provide a list of strings which initializes the function, but it does not return a string value. Instead it returns a function which has the same return type as the stateful function, but takes no arguments. In this example, () -> str is the type of the returned function.

We can think of this as returning an instance or copy of the stateful function that's been initialized with the arguments passed into it.

Calling the returned function will repeat the function body each time, passing back execution and values on yield, and resuming from that point when repeated again.

Example:

color = cycle(["red", "green", "blue"]) // Initialize
// color = () -> str
color() // "red"
color() // "green"
color() // "blue"
color() // "red"

Example:

We can create another function with different initializers with the same syntax.

names = cycle(["bob", "alice"])
names() // "bob"
names() // "alice"

Types

Grid provides a set of base types that are a core part of the language, accompanied by syntax for specifying literal data of these types.

The following table lists the type pattern, an example literal in Grid syntax, and the default value for the type.

Type PatternLiteral ExampleDefaultDescription
booltruefalseBoolean value
int-1230Integer number
num-1.23e40.0Real number
char'z'''Single character
str"hello"""String of characters
`{expr}``{f()}```Interpolation string
[type][1, 2, 3][]Array of type
<type:type><"x": 1, "y": 2><>Map of type to type
(type,type)(1, "2", [3])()Anonymous tuple of types
(name:type)(x:1, y:"2", z:[3])(field:default)Structured tuple of types
(type) -> type(i:int) -> str()->()Function
(type) >> type(i:int) >> str()>>()Stateful function
{...}{ ... }{}Block

Each of these types has a default value it's initialized with. These default values allow for a clearly defined truthiness when used in pattern matching or relational operators. The (field:default) for structured tuples indicates that whatever the type of the fields in that tuple are, their defaults will be used as the value of the fields.

Truthiness

Grid evaluates truthiness based on default values. Defaults are considered false, whereas non-default values are considered true. This allows for simple and clear conditional syntax without ambiguity or type coercion. A tuple of all default values is also considered false, whereas a tuple with any non-default values is considered true.

Example:

s = ""
s ? {
  true => print("String is non-empty")
  false => print("String is empty")
}

This is equivalent to:

s = ""
s != "" ? {
  true => print("String is non-empty")
  false => print("String is empty")
}

Interpolation

String interpolation literals can be used to insert the results of expressions into a string inline.

Example:

a = 0
c = 'z'
print(`a = {a}, c = {c}`)

Tuples

Tuples are flexible data types in Grid used in several contexts. For instance, function definitions use structural tuples for the parameters, and function calls use anonymous tuples for arguments.

Anonymous tuple literals can be assigned to variables or used as type definitions. Tuple fields can be accessed using the . operator.

Example:

a = (1, "2", [3])
f = (g:(int, str)) -> () {
  print(g.0) // prints 1
}
f((1, "2"))

Custom Types

Structural tuples are the main mechanism for defining custom types in Grid.

Example:

Person = (name: str, age: int)
p = Person(name:"Alice", age:42)
print(p.name) // prints "Alice"

By assigning a name to a structural tuple, we can use that name as a custom type in other tuples.

Example:

Person = (name: str, age: int)
f = (p: Person) -> () {
  print(`{p.name}: {p.age}`) // uses the Person structure
}
p = Person(name:"Bob", age:35)
f(p)

Composing Types

Grid allows combining custom types through composition using the & (intersection) and | (union) operators.

Example:

a = (i: int)
b = (s: str)
c = a & b // (i: int, s: str)
d = a & (i: int, t: (int)) // (i: int, t: (int))
e = b | c // (s: str)

Structured Typing

Structured typing in Grid ensures that types interact seamlessly across different contexts, such as function definitions and arguments.

Using the type composition operators, we can define how composite types are evaluated in function arguments as follows:

A type is structurally valid as an argument to a function if the intersection of the argument's type and the function's definition results in a type matching the function definition.

In other words, the type of an argument is valid as long as it has at least the same fields as the function's definition requires for that argument.

Example:

Person = (name: str, age: int)
Employee = Person & (id: int, job: str)
printPerson = (p: Person) -> () {
  print(`{p.name}: {p.age}`)
}

e = Employee(id: 1, name: "Bob", age: 35, job: "Manager")
printPerson(e) // valid because Employee intersects with Person

Block

Blocks in Grid are represented by {...} and can contains various types of statements. Blocks have a value they resolve to, and can thus be assigned to variables. The last expression or value in a block is used as its effective value in larger expressions.

Example:

dataProcessor = {
  loadData()
  processData()
  exportData()
}

Variables

Variables are handles to data of a type.

Syntax:

name, name = expression

A name and value are required. The type is based on the right-hand side of the assignment:

  • If it is a literal, the type is explicit in the syntax of the literal.
  • If it is a function call, the return type of the function is used.
  • If it is the name of another variable, the value is copied.

The left-hand side can optionally be a list of names, which are used to destructure a tuple on the right-hand side. This is useful for extracting fields from function returns, for example.

If an assignment is the last statement in a block, the assigned value is considered the effective value of the block.

Memory Management

When a literal is created, memory is allocated for it. If it's assigned to a variable, that memory lasts until it goes out of scope.

Under the covers, values that map to native types will generally be allocated on the stack, whereas compound data structures may be allocated on the heap. Variables being treated as handles is implemented by them functioning essentially as pointers, without requiring explicit dereferencing or indirection.

When literals are assigned or returned, for simple types native to the architecture (int/num/bool/char generally) values may be copied if it's more efficient than changing pointers. From the Grid perspective however, semantically everything is copied.

Lastly, all objects within a scope are automatically freed at the end of that scope. For stack objects, nothing is required. For heap objects, the memory is freed via OS interfaces.

Operators

Expressions

Grid provides a variety of operators for use in expressions, with some particular interactions between different types.

CategoryOperatorsInput TypesOutput Types
Truthiness><anybool
Equality== !=anybool
Comparison< <= >= >int, float, char, strbool
Boolean&& || !boolbool
Bitwise& | ^ ~ << >>int, charint, char
Arithmetic+ - * / % **int, floatint, float
In-Place+= -= *= /=int, floatint, float
String+char, strstr
Sequence+ - += -=array, maparray, map
Type& |tupletuple

There are no type coercions in Grid, so all inputs to expression operators must be the same types, with the exception of the Truthiness operator.

Truthiness

The truthiness operator compares whether the two inputs have equivalent truthiness, returning a true or false bool accordingly.

Equality

Equality operators compare the values of inputs directly, as you might expect.

Comparison

Comparison operators work similarly to other languages for numeric types.

Characters are compared ordinally, and strings are compared character by character.

Sequence

Sequence operators work to compose arrays and maps in similar but slightly different ways.

Example:

[1, 2] + 3 // [1, 2, 3]
[1, 2] + [3, 4] // [1, 2, [3, 4]]
[1, 2] - 1 // [2]

<"a": 1> + <"b": 2> // <"a": 1, "b", 2>
<"a": 1, "b": 2> - "b" // <"a": 1>

Statements

There are a few statement operators available as well.

  • The membership operator . is used for accessing a member of a namespace or type. See those sections for details.
  • The conditional operator ? is used to conditionally act based on expressions.
  • The pattern operator => is used to match against expressions.
  • The grid operator # is used to iterate over a range of values.
  • The loop operator @ is used to repeat an expression based on conditions.
  • The function operators -> and >> are used to define a function and optional return types.

Pattern Matching

Pattern matching can be used with any of the flow-control operators. These operators take an expression as input, and have distinct behaviors based on the operator.

Patterns are composed of clauses using the => operator. The left-hand side can be a literal or map the result of the input expression to variables, matching against the pattern they occur in.

The match pattern represents truthiness (non-default) evaluation in the following ways:

  • For each literal, it must match the value of the input in that position
  • For each variable, it must be truthy in that position

The _ symbol is used to represent a pattern position which always matches.

Minimal Syntax:

expression flow-operator {
  literal => { // match if literal matches expression result
    // statements
  }
  var => { // map to var and match if non-default
    // statements using mapped variable
  }
  _ => {
    // default else case if no patterns match
  }
}

The pattern position concept is better illustrated when the input is a tuple, and we want to match some of the fields and map others.

The left-hand side can destructure the input tuple, mapping by field, or _ can match the whole input as ultimate fallback.

Example:

httpGet(url) ? { // returns (response, status) tuple
  response, 200 => { // map response var, match status literal
    print("Success: {response}")
  }
  response, 404 => {
    print("Not found")
  }
  _ => { // No pattern matched
    print("HTTP error with status: {status}")
  }
}

In this example:

  • The httpGet(url) function call returns a (response, status) tuple.
  • Depending on the status, different blocks of code are executed.
  • The default case (_) handles situations where none of the specific patterns match.

Example:

We can also use only _ in some fields of tuple matches and mapped variables in others, which in effect singles out the mapped fields for truthy evaluation, allowing us to pick which parts to match on for further sub-expressions or blocks using those variables.

authenticate(user, pass) ? { // returns (authResult, error)
  _, error => {
    print("Authentication failed")
    return -1
  }
  authResult, _ => {
    authResult.role ? {
      "admin" => {
        fetchData(authResult.userId, "admin") ? { // returns (data, fetchErr)
          _, fetchErr => {
            print("Failed to fetch admin data: {fetchErr}")
            return -1
          }
          data, _ => processAdminData(data)
        }
      }
      "user" => {
        fetchData(authResult.userId, "user") ? { // returns (data, fetchErr)
          _, fetchErr => {
            print("Failed to fetch user data: {fetchErr}")
            return -1
          }
          data, _ => processUserData(data)
        }
      }
      _ => {
        print("Unknown role: {role}")
        return -1
      }
    }
  }
}

This example illustrates how you can:

  • Authenticate a user and map the result.
  • Handle errors that occur during authentication.
  • Use pattern matching to execute role-based logic, including fetching and processing data only if the user role matches specific patterns.

Flow Control

Grid provides a few flow-control operators which are covered in detail in the following sections.

OperatorDescription
?Conditional
#Grid
@Loop

Each of these operators takes an input expression, and contains a pattern matching block to evaluate.

Conditional

The ? conditional operator allows us to match on expressions, replacing the if/else constructs in most languages. This also includes handling return values from functions.

Syntax:

expression ? {
  patterns
}

Example:

read(file) ? { // returns (data, err)
  _, err => {
    print(err)
    return -1
  }
  data, _ => print(data)
}

In this example:

  • The result of read(file) is evaluated by the pattern expressions, mapping into data and err accordingly
  • If an error is present, the conditional returns from the enclosing function

Grid

The # operator -- also known as the Grid operator -- is used to iterate over the following types:

InputOutput
strchar
[type]type
{key:value}(key, value)

Syntax:

expression # {
  patterns
}

For each item in the input, the grid operator will generate an (index, item) tuple. The index field is an int, and the item field's type depends on the input type. If only one capture variable is mapped, its type will be (int, _). If you provide an index variable and item variable, it will be destructured to those variables accordingly.

Example:

[1, 2, 3] # {
  i, n => print(`Item {i} = {n}`)
}

Loop

The @ operator is used to loop over a pattern match, repeating an optional input expression each iteration.

Syntax:

expression @ {
  pattern => ...
}

The continue and break keywords are used to immediately restart the loop or exit the loop, respectively. Additionally, the break keyword can take a value to return from the loop which can be assigned as the result of the loop expression.

Without a break keyword a loop will never exit.

The simplest form of loop has no expression, equivalent to a loop, do, or while True in other languages.

Example:

@ { ... }

Example:

When an expression is given, the loop will evaluate the expression before each iteration of the loop body.

a = 0
b = 5
a < b @ {
  true => a += 1
  false => break
}
// a == b

Example:

And a more complicated case, assuming a function that returns a tuple.

// call read function repeatedly
read(100) @ { // returns (data, err)
  _, err => {
    print(err)
    break // break out of loop
  }
  data, _ => print(data) // continue is implicit
}

Example

module main

import sys

indexOf = (s: str, t: char) -> int {
  s # {
    i, c => c == t ? {
      true => return i
    }
  }
}

// Function to handle HTTP requests
handleRequest = (clientSocket: int) -> int {
  // Read the request from the client
  request = sys.read(clientSocket, 1024) ? { // (data, err)
    _, err => {
      sys.print("Error reading request")
      return -1
    }
    data, _ => data
  }

  // Parse the HTTP verb from the request
  request[0:indexOf(request, ' ')] ? {
    // Match the verb and respond accordingly
    "GET" => {
      response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello, GET request!"
      sys.write(clientSocket, response)
    }
    "POST" => {
      response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello, POST request!"
      sys.write(clientSocket, response)
    }
    _ => {
      response = "HTTP/1.1 405 Method Not Allowed\r\nContent-Type: text/plain\r\n\r\nMethod Not Allowed"
      sys.write(clientSocket, response)
    }
  }

  // Close the client socket
  sys.close(clientSocket)
  // 0 is default
}

// Main function to start the HTTP server
main = (args: [str]) -> int {
  port = 8080

  // Open a socket
  serverSocket = sys.socket(sys.AF_INET, sys.SOCK_STREAM, 0) ? { // (sock, err)
    _, err => {
      sys.print("Error creating socket")
      return -1
    }
    sock, _ => sock
  }

  // Bind the socket to the port
  sys.bind(serverSocket, sys.sockaddr_in(port, sys.INADDR_ANY)) ? { // err
    err => {
      sys.print("Error binding socket")
      return -1
    }
  }

  // Listen for incoming connections
  sys.listen(serverSocket, 5) ? { // err
    err => {
      sys.print("Error listening on socket")
      return -1
    }
  }

  sys.print("Server listening on port ", port)

  // Accept connections in a loop
  sys.closed(serverSocket) @ { // bool
    true => break
    _ => {
      clientSocket = sys.accept(serverSocket) ? { // (sock, err)
        _, err => {
          sys.print("Error accepting connection")
          break
        }
        sock, _ => sock
      }

      // Handle the request in a separate function
      handleRequest(clientSocket)
    }
  }

  // Close the server socket
  sys.close(serverSocket)
  // 0 is default
}