Lyric v4.5.0

Modules

A file is a module. The header names it; without a header the name comes from the file name.

module app.geometry;

pub struct Point { x: int, y: int, }

pub fn origin(): Point { return Point { x = 0, y = 0 }; }

fn helper(): int { return 1; }

pub exports. Everything else is visible only inside the module.

Importing

import std.io.console;                     // qualified: console.println(…)
import std.math { sqrt, pi };              // selective
import std.collections as coll;            // alias

fn main(): int {
    console.println(f"{sqrt(4.0)}");
    let list = coll.List<int>.empty();
    return list.length();
}

A qualified import binds the last path segment as the name. A selective import binds the listed names directly. An alias renames the module.

A program of several files

A module path becomes a file path under the directory of the file being compiled:

app.lyr            <- the entry file, and the root
util.lyr           <- import util
shapes/circle.lyr  <- import shapes.circle
// util.lyr
module util;

pub fn double(n: int): int { return n * 2; }

// app.lyr
import util { double };

fn main(): int { return double(21); }
lyric run app.lyr

The two files above are shown together because neither is a program on its own — which is also why they are not compiled by the test suite the way every other snippet in this guide is.

Three rules follow from where a module is found:

Everything ends up in one .lyrbc. There is no separate compilation step per file and no link step.

Saying where the modules are

The rules above need no configuration, and for a program in one directory that is the whole story. A project with a src/ directory says so in a lyric.json beside it:

{
  // what the project is called
  "name": "game",

  // where our own modules live
  "sourceRoot": "src",

  /* an SDK whose modules may declare functions without a body */
  "nativeRoots": { "engine": "sdk" },

  // other projects this one imports from, by the segment each owns
  "dependencies": { "geometry": "../geometry" },

  // the oldest toolchain that may build it
  "toolchain": "4.5",
}
lyric.json
src/main.lyr                  <- import shapes.area
src/shapes/area.lyr
sdk/engine/input.lyr          <- import engine.input
../geometry/src/geometry.lyr  <- import geometry

The file is searched for upwards from the file being compiled, so it is found from anywhere in the project. Comments and trailing commas are allowed; it is meant to be edited by hand.

Every key is optional, and without the file nothing changes: the entry file's directory is the root and no module of your own may declare a native.

Depending on another project

A dependency is a directory: a library lyric new mylib --lib wrote, a checkout beside yours, a subdirectory. Its modules are ordinary Lyric — a function without a body is an error there as it is here — and its own lyric.json counts: its nativeRoots come along, and its dependencies do too, so a library that stands on another library needs no help from the program using it.

Three rules follow from the namespace being one namespace:

Nothing is fetched and nothing is versioned: a dependency is where you say it is. What is fetched from where, pinned and verified, is a later chapter.

A key nobody knows is a warning rather than an error, so a file written for a later version still loads — but the warning is there, because a typo that does nothing is worse than one that complains.

lyric.json is read and never executed. That is what lets an editor learn the layout of a project without running anything from it.

Module constants

A module-level let is a constant. It is initialized once before main runs, in declaration order.

let VERSION = "1.0";
let BANNER = "lyric " + VERSION;

fn main(): int {
    return if (BANNER == "lyric 1.0") 0 else 1;
}

An initializer may read a constant declared before it, not one after. There is no module-level var.

Across files the order follows the imports: a module's constants are initialized after those of every module it imports, so an initializer may read them too.

// config.lyr
module config;

pub let width = 10;
pub let height = 20;

// board.lyr
module board;

import config;

pub let cellCount = config.width * config.height;

Two files again, so the test suite does not compile them — but a program of these two does. Which file you compile does not change that. config is initialized before board because board says so with its import — whether the program was entered through board, through something that imports it, or through board itself.

Type aliases

type Id = int;

fn describe(id: Id): int {
    let copy: Id = id;
    return copy;
}

fn main(): int { return describe(1); }

An alias is a name for a type, not a new type: Id and int are interchangeable.

An opaque alias is the opposite: a new identity over the same layout. Nothing converts implicitly, the explicit as to exactly the underlying and back is the one door, and equality works within the alias — everything else is refused:

opaque type Ticket = int;

fn issue(raw: int): Ticket {
    return raw as Ticket;
}

fn main(): int {
    let a = issue(7);
    let b = issue(7);
    // a + 1, a < b, or 'let t: Ticket = 7;' would all be compile errors.
    return if (a == b) a as int else 0;
}

The point is the wall: an SDK declares pub opaque type Entity = int; beside its natives, a script holds and returns the handle — and cannot leak it into arithmetic. On the wire the handle is its underlying number. The wall spans modules: an SDK of several files declares the handle once and imports it wherever a native signature names it — selectively or module-qualified, in scalar and array positions alike.

Making one is the declaring module's privilege. The inward cast — 42 as Entity — used to work from any module that could see the alias, which quietly undid the wall: a script could forge the very handle the type exists to protect. Outside the declaring module it is refused (LYR-SEM0093; 3.8 announced it as a warning, 4.0 keeps the promise the clock made). The outward cast (e as int) stays free everywhere — reading the number breaks no promise. If a value legitimately has to be rebuilt from a stored number — a saved game, a wire message — the declaring module offers a constructor function for it; issuing stays an issuer's act:

opaque type Entity = int;

// The issuer's act, offered as API — what a saved game calls instead of forging.
pub fn entityFromRaw(raw: int): Entity {
    return raw as Entity;
}

fn main(): int {
    let restored = entityFromRaw(42);
    return restored as int;
}