Skip to content

valentinradu/formalang

Repository files navigation


FormaLang

A statically typed, declarative DSL compiler written in Rust

crates.io docs.rs license ci

Why  ·  Quick Start  ·  Language Tour  ·  Rust API  ·  Full Docs



You write .fv files; the library parses and validates them, and you get a fully type-resolved IR back. What you do with that IR (generate code, drive a UI framework, configure a system) is up to your backend. The first such backend is formawasm, which lowers the IR to WebAssembly.

.fv source → formalang library → IrModule → your Backend → output

Contents


Why FormaLang?

You're building a Rust application that needs to accept user-authored logic: UI definitions, configuration with computation, state machines, scripted rules. The usual options each have a sharp edge:

  • Ship Rust as the user-facing language. Rust is a great host but a poor guest: it's AOT-compiled, lifetimes and the borrow checker land on whoever writes the file, and you can't load .rs snippets at runtime without dragging in a full toolchain.
  • Embed Lua, Rhai, or JavaScript. These are dynamically typed. Errors that should have been caught when the file was loaded surface only when the offending branch runs, usually in production.
  • Use JSON, YAML, or TOML. No expressions, no functions, no real types. The moment your config grows a conditional, you reinvent half a language inside string templates.

FormaLang fills that gap:

  • Statically typed and fully resolved. The library hands back an IrModule where every type, name, and overload is already settled. A broken .fv fails at load, not when the user clicks the button that runs the bad branch.
  • Embeddable by design. A pure compiler frontend with no runtime, no I/O, no globals, no sandbox to maintain. The output is data: walk it, transform it, emit whatever you want.
  • Small surface for users. Structs, enums, traits, closures, generics, modules. No lifetimes, no async, no unsafe, no macros. Someone fluent in Swift or Rust can read it on day one.
  • Backend-agnostic. Drive a UI framework, generate code for any target, configure a runtime, layer custom IR passes. The compiler stops at the IR; you decide what comes next.

Quick Start

Add to Cargo.toml:

[dependencies]
formalang = "0.0.5-beta"

Compile a source string:

use formalang::compile_to_ir;

let source = r#"
    pub struct User {
        name: String,
        age: I32
    }
"#;

let module = compile_to_ir(source).unwrap();
println!("{}", module.structs[0].name); // User

Language Tour

Primitives

let text: String = "hello"
let count: I32 = 42
let big: I64 = 9_223_372_036_854_775_807
let ratio: F64 = 3.14
let small: F32 = 0.5F32          // type-suffix pins literal precision
let flag: Boolean = true
let logo: Path = /assets/logo.svg
let pattern: Regex = r/[a-z]+/i
let nothing: String? = nil       // optional; any type can be made optional with ?

Numeric primitives are width-tagged: I32, I64, F32, F64. Unsuffixed integer literals default to I32; unsuffixed float literals default to F64. Suffix syntax is uppercase and adjacent to the digits (42I64, 3.14F32).

Structs

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

pub struct User {
    name: String,
    email: String,
    nickname: String?,       // optional field
    score: I32
}

// Instantiate with named arguments
let p = Point(x: 10, y: 20)
let u = User(name: "Alice", email: "[email protected]", nickname: nil, score: 0)
// Mutability is a property of the binding, not the field; to mutate any
// field of `u` you bind it with `let mut u = User(...)`.

Methods (impl blocks)

pub struct Counter {
    value: I32
}

impl Counter {
    fn increment(self) -> I32 {
        self.value + 1
    }

    fn reset(self) -> Counter {
        Counter(value: 0)
    }
}

Parameter Conventions

Every function parameter has a convention controlling how the argument is received. The call site always looks the same as f(x); only the function declaration changes.

// default: immutable; the callee reads the value
fn area(radius: I32) -> I32 {
    radius * radius
}

// mut: callee may mutate; argument binding must be let mut
fn bump(mut n: I32) -> I32 {
    n
}

// sink: ownership transfer; caller cannot use the binding after the call
fn consume(sink label: String) -> String {
    label
}

// Self conventions work the same way
impl Counter {
    fn view(self) -> I32 { self.value }         // default (immutable self)
    fn increment(mut self) -> I32 { self.value } // mut self
}

Traits

Traits declare field and method requirements. Any struct that satisfies all of them can declare conformance.

pub trait Named {
    name: String
}

pub trait Shape {
    color: String
    fn area(self) -> I32
}

// Declare conformance
pub struct Circle {
    name: String,
    color: String,
    radius: I32
}

impl Named for Circle {}            // fields checked against struct definition

impl Shape for Circle {
    fn area(self) -> I32 {
        self.radius * self.radius   // simplified
    }
}

// Trait composition
pub trait NamedShape: Named + Shape {
    label: String
}

// A trait can also stand in as a value type. The IR lowers method
// calls on a trait-typed binding through the trait's vtable, so two
// branches that produce different concrete types implementing the
// same trait unify cleanly.
fn area(kind: I32, side: I32, w: I32, h: I32) -> I32 {
    let s: Shape = if kind == 0 {
        Square(name: "sq", color: "black", side: side)
    } else {
        Rectangle(name: "rect", color: "white", width: w, height: h)
    }
    s.area()
}

Enums

pub enum Status {
    pending
    active
    done
}

pub enum Message {
    text(content: String)
    image(url: String, size: I32)
    quit
}

// Instantiate with leading dot
let s: Status = .active
let m: Message = .text(content: "hello")

Let bindings

let x = 42
let name: String = "Alice"
pub let MAX: I32 = 100
let mut counter: I32 = 0    // mutable binding

Arrays, Dictionaries, Tuples

// Arrays
let tags: [String] = ["a", "b", "c"]
let matrix: [[I32]] = [[1, 2], [3, 4]]

// Dictionaries
let config: [String: I32] = ["timeout": 30, "retries": 3]
let empty: [String: Boolean] = [:]

// Tuples (all fields must be named)
let point = (x: 10, y: 20)
let name = point.x

// Indexing returns an Optional. The bound may be out of range or the
// key absent, so `xs[i]` and `d[k]` yield `T?` / `V?`. Use `if let` to
// consume the inner value.
let timeout: I32? = config["timeout"]
let first: String? = tags[0]

Control Flow

// if: branches on a Boolean.
if user.score > 0 {
    greet(name: user.name)
} else {
    welcome()
}

// if let: Rust-style optional unwrap. Both branches required.
if let nickname = user.nickname {
    greet(name: nickname)        // nickname is bound to the unwrapped value
} else {
    greet(name: user.name)
}

// for: iterates arrays, returns array of results
for item in items {
    process(item: item)
}

// match: exhaustive, on enums (and on Optional, treated as .some / .none)
match message {
    .text(content): display(value: content),
    .image(url, size): showImage(src: url),
    .quit: stop()
}

Closures

Closure types describe a callable shape; closure expressions construct one. Both wrap their parameter list in parentheses so every -> in the language is preceded by ).

pub enum Event {
    pressed,
    textChanged(value: String),
    resized(width: I32, height: I32)
}

pub struct Button<E> {
    onPress:  () -> E,                  // no parameters
    onChange: (String) -> E,            // single parameter
    onResize: (I32, I32) -> E,          // multiple parameters
    onSubmit: ((String) -> E)?          // optional closure
}

Closure expressions wrap their parameter list in parentheses — even for a single parameter — so every -> in the language is preceded by ):

// Untyped — parameter types come from the binding annotation or call context
let onPress  = () -> .pressed
let onChange = (x) -> .textChanged(value: x)
let onResize = (w, h) -> .resized(width: w, height: h)

// Typed parameters — annotate inline with `name: Type`
let increment = (n: I32) -> n + 1
let combine   = (x: I32, y: I32) -> x + y

Closures capture values from their surrounding scope. The ClosureConversionPass lifts each closure into a top-level function plus a synthetic env struct, so backends only ever consume named functions.

fn make_adder(sink n: I32) -> (I32) -> I32 {
    (x: I32) -> x + n          // captures n
}

let add5 = make_adder(n: 5)

Closure parameters carry the same conventions as regular function parameters (mut, sink). The convention constrains the caller of the closure:

pub struct Form<E> {
    onScale:   (mut I32) -> E,    // caller must pass a mutable binding
    onConsume: (sink String) -> E // caller's binding is moved
}

Closures are pure and single-expression: no statements, no side effects in the language itself. Effects live in the host runtime, reached through extern declarations.

Generics

pub struct Box<T> {
    value: T
}

pub struct Pair<A, B> {
    first: A,
    second: B
}

pub trait Layout { width: I32 }

pub struct Container<T: Layout> {   // constrained type parameter
    items: [T],
    gap: I32
}

pub enum Result<T, E> {
    ok(value: T)
    error(err: E)
}

let b = Box<String>(value: "hello")
let r: Result<String, I32> = .ok(value: "success")

// Type-argument inference: when every generic parameter shows up in a
// field position, the type args can be omitted at the call site.
let inferred = Box(value: 42)        // Box<I32>
let pair = Pair(first: 10, second: true)  // Pair<I32, Boolean>

Destructuring

// Arrays
let [first, second, ...rest] = items
let [_, second, ...] = items    // skip with _

// Structs (by field name)
let {name, age} = user
let {name as username} = user   // rename

// Enums (extract associated data)
let (content) = some_text_message

Modules

// Inline module
pub mod geometry {
    pub struct Point { x: I32, y: I32 }
    pub enum Direction { north, south, east, west }
}

let p: geometry::Point = geometry::Point(x: 0, y: 0)

// Import from other .fv files
use geometry::Point
use ui::{Button, Text}
use data::models::User

Files map to module paths: use geometry::shapes::Circle resolves to geometry/shapes.fv. Only pub items can be imported. Circular imports are a compile error.

Extern declarations

Describe functions and method surfaces provided by the host runtime; they have no FormaLang body. There is no extern type; host-provided types are declared as regular structs and given an extern impl so their methods are resolved by the host.

pub struct Canvas {}
pub struct Connection {}

extern fn create_canvas() -> Canvas
extern fn connect(url: String) -> Connection
extern fn log(message: String)

extern impl Canvas {
    fn width(self) -> I32
    fn height(self) -> I32
    fn clear(self)
}

Function overloading

fn format(value: I32) -> String { "number" }
fn format(value: String) -> String { "string" }
fn format(value: I32, precision: I32) -> String { "precise" }

The compiler resolves overloads by the named-argument label set. Ambiguous or unresolvable calls are compile errors.


Rust API

Entry points

Function Returns Use case
compile_to_ir(src) Result<IrModule, Vec<CompilerError>> Code generation (canonical)
compile_with_analyzer(src) Result<(File, SemanticAnalyzer), …> LSP hover / completion
compile_and_report(src, filename) Result<IrModule, String> CLI: compile + human-readable errors
parse_only(src) Result<File, …> Syntax check only

Custom module resolver (to load .fv files from anywhere):

use formalang::{compile_to_ir_with_resolver, FileSystemResolver};
use std::path::PathBuf;

let resolver = FileSystemResolver::new(PathBuf::from("./src"));
let module = compile_to_ir_with_resolver(source, resolver)?;

The IrModule

let module = compile_to_ir(source)?;

module.structs    // Vec<IrStruct>
module.traits     // Vec<IrTrait>
module.enums      // Vec<IrEnum>
module.functions  // Vec<IrFunction>   (extern fns: extern_abi = Some(_), body = None)
module.impls      // Vec<IrImpl>
module.lets       // Vec<IrLet>
module.imports    // Vec<IrImport>
module.modules    // Vec<IrModuleNode>  (preserves source `mod foo { ... }` hierarchy)

// ID-based lookup
let id = module.struct_id("User").unwrap();
let s  = module.get_struct(id).unwrap();

All types in the IR are fully resolved; no unresolved references remain.

Pipeline (passes + backends)

use formalang::{compile_to_ir, Pipeline};
use formalang::ir::{DeadCodeEliminationPass, ConstantFoldingPass};

let module = compile_to_ir(source)?;

let output = Pipeline::new()
    .pass(DeadCodeEliminationPass::default())
    .pass(ConstantFoldingPass::default())
    .emit(module, &my_backend)?;

Implement IrPass to write your own transforms, and Backend to emit code:

use formalang::{Backend, CompilerError, IrPass};
use formalang::ir::IrModule;

struct MyPass;

impl IrPass for MyPass {
    fn name(&self) -> &str { "my_pass" }
    fn run(&mut self, module: IrModule) -> Result<IrModule, Vec<CompilerError>> {
        // transform and return
        Ok(module)
    }
}

struct MyBackend;

impl Backend for MyBackend {
    type Output = String;
    type Error = std::convert::Infallible;

    fn generate(&self, module: &IrModule) -> Result<String, Self::Error> {
        Ok(format!("// {} structs", module.structs.len()))
    }
}

Error reporting

use formalang::{compile_to_ir, reporting::report_errors};

match compile_to_ir(source) {
    Ok(_) => {}
    Err(errors) => {
        eprintln!("{}", report_errors(&errors, source, "file.fv"));
    }
}

File extension

FormaLang source files use the .fv extension.


What is not built in

FormaLang is a pure compiler frontend. It does not include:

  • A runtime or interpreter
  • Code generation for any specific target
  • A standard library (bring your own via extern declarations)
  • A package manager

These are responsibilities of the embedding application and its backends.


Further reading


License

Dual-licensed under either of:

at your option.

About

Statically typed, declarative language designed to be embedded in Rust applications. Compiles .fv files to a fully type-resolved IR; bring your own backend.

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages