Skip to content

akonwi/ard

Repository files navigation

Ard Lang

What's Ard?

Language Description

Ard is a modern, programming language designed for legibility, simplicity, and type-safety. It combines elements from JavaScript, Swift, Go, and Rust.

Contribution and commit message guidelines live in CONTRIBUTING.md.

Goals

  • Readability: Ard code should be easy to read and understand.
  • Simple: There should be one obvious way to do things.
  • Safety: Type errors are caught at compile time and runtime errors must be handled.
  • Reliable: Built on Go's runtime, so programs can be fast and efficient.

Basic Syntax

Ard uses a clean, expressive syntax designed for readability and ease of use. Note: trying to follow Go's philosophy for readablity left to right, rather than usual Spiraling in C based syntax.

Variables and Constants

  • Use let for constants and mut for variables
  • let variables cannot be reassigned or mutated
  • Variable types can be inferred or explicitly declared
let name: Str = "Alice"
mut age = 30

Mutable References

Ard uses mut for mutable access. A mutable function parameter receives a mutable reference to caller-owned storage, so the caller must pass an addressable mutable value:

fn update_person(person: mut Person) {
    person.age = 99  // Mutates the caller's value
}

mut alice = Person { name: "Alice", age: 30 }
update_person(alice)
// alice.age is now 99

Immutable values cannot be passed where mutable access is required:

let alice = Person { name: "Alice", age: 30 }
update_person(alice) // Error: expected a mutable Person

Struct fields can also hold mutable references:

struct Context {
    tree: mut ViewTree,
}

let ctx = Context { tree: tree }
ctx.tree.add_child(child)

The ctx binding is immutable, but ctx.tree is mutable access to the referenced ViewTree. Field assignment writes through the reference; it does not rebind the field slot.

Nullable mutable references use grouping before ?:

use ard/maybe

let maybe_tree: (mut ViewTree)? = maybe::none()

Increment/Decrement short hand

The syntax for this is slightly different from other languages. Rather than '+=' or '-=', in Ard, the = comes first (left to right readability)

age =+ 1
age =- 2

There is no ++ or --.

Functions

  • Use fn keyword to define functions
  • A Return type is specified after the parameter list
    • In order to declare the function as non-returning, omit the return type
  • There is no return keyword. The last expression is the returned value
fn greet(name: Str) Str {
  "Hello, {name}!"
}

Named Arguments

Functions can be called with named arguments allowing them to be in any order.

fn greet(name: Str, age: Int) Str {
  "Hello, {name}! You are {age.to_str()} years old."
}

// positional arguments (order matters)
greet("Alice", 25)

// named arguments (order doesn't matter)
greet(age: 30, name: "Bob")

When using named arguments, all arguments must be named. Mixing positional arguments with named arguments is not supported.

Nullable Parameters

Parameters declared as nullable (T?) can be omitted at the call site when the omission is unambiguous. The compiler fills omitted nullable parameters with none, and wraps plain T values as some(value) when passed to a T? parameter.

fn connect(host: Str, port: Int?) {}

connect("localhost")      // port = none
connect("localhost", 80)  // port = some(80)

For positional calls, only trailing nullable parameters can be omitted. Named calls can skip nullable parameters in any position:

fn f(timeout: Int?, name: Str) {}

f(name: "worker") // timeout = none

Functions are first class and can therefore be used as arguments

fn map(list: [Int], do: fn(Int) Int) [Int] {
  let mapped: [Int] = []
  for i in list {
    mapped.push(do(i))
  }
  mapped
}

map([1,2,3], fn(i: Int) Int { i*2 })

Control Flow

Ard supports common control flow structures:

if some_condition {
    // ...
} else if another_condition {
    // ...
} else {
    // ...
}

for item in array {
  // ...
}

for key, val in map {
  io::print("key: {key} = value({val})")
}

// check condition, then do block
while condition {
  // ...
}

// match expressions, similar to a switch statements
let string = match some_bool {
  true => "It's true"
  false => "It's false"
}

// matching on integers
let grade = match score {
  0..59 => "F"
  60..69 => "D"
  70..79 => "C"
  80..89 => "B"
  90..100 => "A"
  _ => "Invalid score"
}

// mixing specific values and ranges
let message = match value {
  0 => "zero"
  1..10 => "small number"
  42 => "the answer"
  100..1000 => "big number"
  _ => "something else"
}

Iteration

C style for loop:

for mut i = 0; i <= 5; i =+1 {
  io::print(i.to_str())
}

Numeric inclusive range:

for i in 1..10 {
	io::print(i) // 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
}

Iterating over a list:

let fruits = ["apple", "banana", "cherry"]
for fruit in fruits {
  io::print(fruit)
}

Types

Built-in types

  • Str
  • Int
  • Float64
  • Bool
  • [Int] - List
  • [Str:Int] - Map

Structs

Structs can be used to define a collection of arbitrary data types, i.e. objects:

struct Person {
  name: Str
  age: Int
}

let person = Person {
  name: "Alton",
  age: 30
}
person.name // "Alton"

Structs can have methods. Use an impl block to define methods on a struct. Within a method, its properties can be accessed with the @ prefix

impl Person {
  fn get_intro() Str {
    "My name is {@name}"
  }

  fn greet(other: Person) Str {
    "Hello, {other.name}"
  }
}

person.get_intro() // "My name is Alton"

Static functions can be scoped to a struct by declaring the function with a name prefixed with the struct name:

struct Todo {
  title: Str,
  completed: Bool
}

// a helper function for a constructor
fn Todo::new(title: Str) Todo {
  Todo { title: title, completed: false }
}

let first = Todo::new("clean")

Enums

Enums are used to enumerate a discrete set of values. They are simply labeled integers and cannot have associated values.

enum Status {
  active,
  inactive,
  pending
}

The static operator (::) is used to access variants. The static operator avoids naming conflicts between the variants and instance properties on the enum.

Enum values can be used in match expressions to handle different cases:

let status = Status::inactive
match status {
  Status::active => "Active",
  Status::inactive => "Inactive",
  Status::pending => "Pending"
}

Nullable Values

To declare a type that could be present or not, add ? to the end. This declares it as a built-in Maybe type. Values can be created with the ard/maybe package from the standard library. A maybe type can either have a value (some) or be empty (none).

use ard/maybe

mut maybe_name: Str? = maybe::none()
maybe_name = maybe::some("Joe")

match maybe_name {
  n => "Hello, {n}",
  _ => "Hello, stranger"
}

Maybe types have is_some() and is_none() methods to peek at presence without consuming the value. To access the value, without the need for a match expression, use the or(default: $V) method, which returns the value if it is present, or the provided default value.

let maybe_name: Str? = maybe::some("Joe")
let name: Str = maybe_name.or("Anonymous")

Type Unions

Type unions are used to define a type that can be one of several types.

type Printable = Str | Int
let value: Printable = "Hello"
let stuff: [Printable] = ["Hello", 42]

To do conditional logic on a value of a type union, use a match expression and within each case, the value is bound to a variable called it:

for item in stuff {
  match item {
    Str(s) => io::print("String: {s}"),
    Int(i) => io::print("Number: {it}")
  }
}

Pattern Matching

Ard supports powerful pattern matching with the match expression. Different types support different patterns:

Integer Matching

Match on specific integer values or ranges:

let category = match age {
  0..12 => "child"
  13..19 => "teenager"
  21 => "legal drinking age"
  65..120 => "senior"
  _ => "adult"
}

Integer ranges are inclusive (1..10 includes both 1 and 10). When patterns overlap, the first match wins.

Other Pattern Types

  • Booleans: Match on true or false
  • Enums: Match on enum variants like Status::active
  • Maybe types: Match on value binding or _ for none
  • Type unions: Match on type
  • Results: Match on ok(value) or err patterns

Qualified static paths

A static path is a sequence of name::thing. Ard has a preference for simple paths where possible, i.e. only one ::. In order to reach further into a package for something, make that import explicit with use name/thing/nested, so that deeply nested access can be simply called with nested::thing.

Direct Go interop

Ard can import Go packages directly with use go:. Exported Go struct fields use normal Ard field syntax for reads, writes, and keyed construction:

use go:image as image
use go:net/http as gohttp

let rect = image::Rectangle{
  Min: image::Point{X: 0, Y: 0},
  Max: image::Point{X: 80, Y: 24},
}

fn mark_ok(resp: mut gohttp::Response) {
  resp.StatusCode = gohttp::StatusOK
}

Go pointer nil behavior is preserved. Use ard/unsafe::is_nil(value) for explicit host nil checks, or unsafe { ... } when a trusted interop block may panic and should return T!Str.

Errors

Ard does not have exceptions. Instead, errors are represented as values. The built-in Result<$Val, $Err> type can be used as a special type union of a success value and an error value.

Result Declaration Sugar

For convenience, Result<T, E> can be written using the sugar syntax T!E. Both forms are equivalent:

// These are equivalent:
fn divide_verbose(a: Int, b: Int) Result<Int, Str> { ... }
fn divide_concise(a: Int, b: Int) Int!Str { ... }
fn divide(a: Int, b: Int) Int!Str {
  match b == 0 {
    true => Result::err("Cannot divide by zero"),
    false => Result::ok(a/b),
  }
}

Similar to type unions, results can be matched to control conditional execution.

match divide(42, 0) {
  ok(num) => io::print(num.to_str()),
  err => io::print(err),
}

The only way to ignore errors is to use the .or() method to provide a default value if the result is not ok.

let num = divide(a, b).or(0)
io::print("got {num.to_str()})

Another alternative to ignoring the error is to propagate it to callers. This can be achieved with the try keyword.

// attempt at (a / b) + 10
fn do_math(a Int, b Int) Int!Str {
  let num = try divide(a, b)
  Result::ok(num + 10)
}

The try keyword will unwrap the result and if the result is an error, it will act as an early return to pass on the failure result.

unsafe { ... } marks an interop block where Go/runtime panics are converted into Str errors. If the block's final value has type T, the unsafe block has type T!Str, so it can be handled with normal Result methods or try:

let path = try unsafe {
  request.URL.Path
}

unsafe only recovers panics in the same goroutine, does not undo partial mutation, and currently rejects break inside the block.

Testing

Ard has a built-in test framework. Tests are declared with test fn and must return Void!Str:

use ard/testing

fn add(a: Int, b: Int) Int { a + b }

test fn test_add() Void!Str {
  try testing::assert(add(1, 2) == 3, "Expected add(1, 2) to equal 3")
  testing::pass()
}

Run tests with ard test:

ard test              # run all tests in the project
ard test math.ard     # run tests in a specific file
ard test --filter add # filter by name
ard test --fail-fast  # stop on first failure

Tests can be co-located in the same file as the code they test, or placed under a /test directory for integration-style tests that only access public APIs.

The ard/testing module provides helpers: pass(), fail(message), and assert(condition, message).

Compiler backend parity checks

For Go backend IR work, these are the core parity/hardening regression gates (run from /compiler):

  • go test -tags integration ./go_backend
  • go test ./go ./checker
  • go test ./...

Modules

See the docs in modules

Async Programs

See the docs in async

About

A general purpose programming language

Resources

License

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors