Everything is a term. Everything is a constraint.
File extension:.lig
Ligare is an experimental programming language and compiler written in Rust. It explores a minimal core where values, types, propositions, proofs, functions, data declarations, and macros are all represented as terms constrained by other terms.
This repository contains the compiler, the C backend, the standard library, the
formatter, the Markdown documentation generator, and the ligls language
server. The compiler is usable for experiments, but the language and package
formats are still evolving and should not be treated as a stable release.
The current implementation includes:
- A lexer, parser, type checker, evaluator, formatter, documentation generator, and C backend for
.ligsource files. - A package mode driven by
ligare.toml, with Cargo-stylenew/init,build/run/check,update,test,clean,fmt, anddoccommands. - Refinement constraints, proof blocks, structs, enums, pattern matching, modules, generics, compile-time quotation/splicing, and code generation to C.
- A standard library under
libs/std, plus theliglslanguage-server crate undercrates/ligls.
The C backend is currently the only registered backend. Some sections below describe design direction as well as implemented syntax; experimental or planned behavior is called out where relevant.
The repository pins its Rust toolchain in rust-toolchain.toml:
git clone https://github.com/ligare-lang/ligare.git
cd ligare
cargo build --release
cargo build -p ligls --releaseThe solver uses a system-installed Z3 by default (for example, libz3-dev on
Debian/Ubuntu, z3 from Homebrew on macOS, or the z3 Chocolatey package on
Windows). For a self-contained release
binary, enable the static-z3 feature (this builds Z3 from source and requires
CMake):
cargo build --release --features static-z3
cargo build -p ligls --release --features ligare/static-z3Debug builds made from this checkout discover libs/std automatically. When
using the release binaries from another directory, point the compiler and
language server at an absolute standard-library path:
export LIGARE_STD_PATH="$PWD/libs/std"
target/release/ligare --version
test -x target/release/liglsThe C backend also needs a C99-compatible compiler. Set CC when cc is not
the desired compiler.
README_zh.mdis the Chinese version of this overview.docs/kernel-judgments.mddocuments the kernel trust boundary and proof replay rules.- The documentation site contains guided lessons and CLI/reference pages.
- Rust toolchain. The repository includes
rust-toolchain.toml, sorustupwill select the pinned toolchain automatically. - A C compiler available as
ccwhen using native output with-oor package builds.
cargo build
cargo testcargo run -- tests/fixtures/test.lig
cargo run -- tests/fixtures/test.lig --eval "1 + 2"cargo run builds the compiler in debug mode. After installing a binary,
replace cargo run -- with ligare (or the path to the binary).
cargo run -- --emit-source tests/fixtures/test.ligcargo run -- new /tmp/ligare-hello
cargo run -- check --manifest-path /tmp/ligare-hello/ligare.toml
cargo run -- build --manifest-path /tmp/ligare-hello/ligare.toml
cargo run -- run --manifest-path /tmp/ligare-hello/ligare.tomlFor the checked-in example package:
cargo run -- build --manifest-path examples/test/ligare.toml
./examples/test/target/debug/testcheck validates a package without emitting backend output. clean removes
the selected package's target directory:
cargo run -- check --manifest-path examples/test/ligare.toml
cargo run -- clean --manifest-path examples/test/ligare.tomlcargo run -- fmt .
cargo run -- fmt --check .cargo run -- doc .
cargo run -- doc . -o docs/api.mdligls speaks LSP over standard input and output. Build it with
cargo build -p ligls --release, then configure your editor to launch
target/release/ligls for .lig files. The server uses the same parser,
module loader, standard-library discovery, and diagnostics as the compiler.
The standard library is the std package in libs/std. Its public
modules include:
std::io IO monad and basic input/output
std::async poll-driven Future computations
std::fmt Display-based formatting
std::string string helpers
std::data::{bool,nat,order,product}
std::mem::{list,vec} collections and length-indexed vectors
std::collections HashMap and HashSet
std::hash hashing interfaces for hash-based collections
std::fs filesystem operations
std::path filesystem path values and helpers
std::option optional values
std::result success/error values
std::primitive compiler-provided primitive declarations
std::meta attributes and compile-time support
For example, a package can import the standard I/O helpers with:
use std::io::print_line
pub def main : IO () :=
do
print_line 42
The checkout's debug build searches libs/std by default. An installed or
relocated binary needs LIGARE_STD_PATH set to an absolute path; the variable
may contain the platform's path-separated list of standard-library roots.
Usage: ligare [OPTIONS] [FILE]... [COMMAND]
| Command or option | Description |
|---|---|
ligare <files> |
Process one or more .lig files and run top-level checks/evaluations. |
--version |
Print the Ligare version; -V is also accepted. |
--eval <EXPR> |
Evaluate an expression after processing the input files. |
--emit-source |
Emit backend source instead of evaluating or compiling it. |
--backend <NAME> |
Select a registered backend; c is currently the only backend. |
-o, --output <PATH> |
Compile generated C into a native executable at PATH. |
--release |
Build in release mode with -O3 by default and write package artifacts to target/release. |
-O, --opt-level <LEVEL> |
Override native optimization with 0, 1, 2, 3, s, or z. |
new [OPTIONS] PATH |
Create a new package directory with ligare.toml and a src entry file; --lib, --bin, --name, and --vcs customize the generated package. |
init [OPTIONS] [PATH] |
Initialize the current or target directory as a package, preserving existing files; --lib, --bin, --name, and --vcs customize it. |
build [PATH] |
Build the package found at PATH or the current directory. Build options may follow the subcommand. |
run [PATH] [-- ARGS...] |
Build and run a binary package, forwarding arguments after --. |
check [PATH] |
Check a package without emitting backend source or a native executable. |
update [NAME] [VERSION] |
Refresh ligare.lock, optionally pinning one dependency. |
test [PATH] |
Run package files whose names end in _test.lig; --manifest-path selects a manifest explicitly. |
clean [PATH] |
Remove the selected package's target directory. |
fmt [--check] [PATH] |
Format .lig files, or check whether formatting is needed. |
doc [--private] [-o PATH] [PATH] |
Generate Markdown documentation from .lig files. |
Package commands accept either a project directory or
--manifest-path PATH/to/ligare.toml. build, run, and direct source
compilation accept --emit-source, --backend, -o, --release,
and -O; release and optimization options require native compilation. Use
ligare help <COMMAND> for the full command-specific help.
Development package builds default to -O0 with debug information and write
artifacts to target/debug. Release builds default to -O3 and define
NDEBUG. An explicit --opt-level overrides the profile default; for example,
ligare build --release -Os produces a size-optimized release artifact.
Package builds use ligare.toml:
[package]
name = "test"
version = "0.1.0"
type = "binary"
[dependencies]Supported package types are binary and lib. Binary packages default to
src/main.lig; library packages default to src/lib.lig. If entry is
omitted, Ligare infers the package type from the available default entry.
Dependencies may point to Git repositories or local paths:
[dependencies]
mathlib = { path = "../mathlib" }
remote_lib = { git = "https://example.com/remote_lib.git", version = "v0.1.0" }build and run write a ligare.lock file after dependency resolution.
crates/ligare_ast core term syntax, arena allocation, and desugaring
crates/ligare_front/src/{lexer,parser}.rs lexer and parser
crates/ligare_kernel core evaluation, constraint checking, inference, and erasure
crates/ligare_prover proof search and elaboration
crates/ligare_solver SMT solving and proof replay
src/compiler/ file/module pipeline, project loading, monomorphization
crates/ligare_backend backend contract and backend-independent planning
crates/ligare_backend_c/src/{c,ir}.rs C code generation and C IR
src/package/ manifest, lockfile, and dependency resolution
libs/std/ standard library package
libs/mathlib/ example math library package
crates/ligare_fmt formatter crate
crates/ligare_doc markdown documentation generator crate
crates/ligls/ experimental language server crate
tests/ integration and regression tests
tests/fixtures/ sample .lig programs used by tests
examples/test/ example Ligare package
docs/ grammar, kernel, and implementation notes
Ligare is a minimalist programming language. It recognizes only one kind of entity — the Term.
There is no separate "type" syntax, no "type vs. value" dichotomy, and no "program vs. proof" dichotomy.
Everything is a term, and every relationship is a constraint.
A term is the only existence in the language. Variables, literals, functions, data types, propositions, proofs, macros... all are terms.
Relationships between terms are established through constraints.
a : T means that term a is constrained by term T (i.e., a has type T).
The constraint relationship replaces the "type ascription" found in traditional languages, but constraints themselves are also terms and can be constrained by other terms.
Example
3 : int -- 3 is constrained by int
int : prop -- int is constrained by data
All terms have a level. Constraint relationships enforce level ordering to prevent Russell-paradox-style self-referential structures.
(Specific level rules will be defined in detail in the formalization section.)
- Constraints / Types: PascalCase (e.g.,
Nat,Point,LinkedList) - Functions / Theorems: snake_case (e.g.,
div,is_sorted,add_node)
The language has two universes.
| Universe | Meaning | Exists at runtime? |
|---|---|---|
data |
The computable data universe; all terms ultimately retained belong here | Yes |
prop |
The proposition universe, describing logical conditions | No (erased) |
theorem is a top-level command for declaring and checking a named inhabitant
of a proposition; it is not a constraint or universe. There is no proof P
wrapper: by Curry-Howard, a proof of P is simply a term checked directly
against P. Proposition terms are erased after checking. sort n classifies every expression at
level n, regardless of whether it belongs to data or prop; it is a
level classifier, not a third universe.
The indexed forms data n and prop n select the runtime-data and proposition
universe at level n; plain data and prop remain available as the base
forms. Universe checking assigns a
level variable to every term and solves constraints of the form u + k <= v
in a Coq-style graph; a positive cycle reports an inconsistent universe.
Users can define new constraints by refining an existing constraint with a predicate. This is Ligare's way of defining "subtypes."
Syntax
def nat := int where (x => x >= 0)
Interpretation
nat is a new constraint. Any term constrained by nat must:
- Be constrained by
int(which itself is constrained bydata); - Satisfy the predicate
x >= 0.
Usage
def x : nat := 10
#check x : nat -- passes
#check x : int -- also passes (nat is a subtype of int)
#check -5 : nat -- fails: -5 is not >= 0
The compiler automatically demands this proof where needed, or derives it from context.
Multiple refinements can coexist in the same program:
def pos := int where (x => x > 0)
def even := int where (x => x % 2 = 0)
def ten := int where (x => x = 10)
Refinements can also be used inline in function parameters:
def sdiv (a : int) (b : int where (x => x /= 0)) : int := a / b
Functions are defined with def (or func), using curried parameter lists. They can constrain their own parameters via where clauses, forming pre-condition contracts.
Syntax example
def div (a : int) (b : int where (x => x /= 0)) : int := a / b
Proof obligations
- The caller must provide a proof that
b /= 0(or the compiler derives it automatically). - The function body operates under the guarantee that the parameter constraints hold.
All terms in prop are erased after passing compile-time checks, with zero runtime overhead.
Function with no return type annotation
def id (x : int) := x
Recursive function
def fib (n : int) : int :=
if n < 2 then n else fib (n - 1) + fib (n - 2)
Recursive definitions must carry termination evidence. The compiler first checks structural recursion; when it cannot prove termination, provide a termination proof, use #[terminating] (an explicitly trusted axiom), or declare the return type as !. ! is an uninhabited runtime type that can be eliminated into any runtime type, but it cannot enter logical propositions.
The condition of an if is treated as a proposition. When entering a branch, the branch context automatically introduces a corresponding theorem.
Example
if x > 0 then
-- a theorem: x > 0 is automatically available here
-- it can be used to satisfy proof obligations of other constraints
div 10 x -- x /= 0 can be automatically derived from x > 0
else
-- a theorem: not (x > 0) is automatically available here
After compilation, if is still compiled into a simple conditional jump; all proof parts are erased.
Ligare supports interactive proof construction via by blocks with tactics, inspired by Lean 4.
left = right is propositional equality and may be used as a proof goal or
hypothesis. left == right is a runtime comparison whose result has type
bool; it is not accepted by equality-rewriting tactics.
Simple proof with exact
#check 5 by
exact auto : nat
Multi-tactic proof with intro
#check 0 by
intro
exact 0 : int -> int
Standalone proof term (no subject)
#check (by
intro
exact 0) : int -> int
Available tactics:
exact <term>— provide a term that satisfies the goal directlyintro [name ...]— introduce one or more Pi-type hypothesesapply <term>— apply a function to reduce the goalhave <name> := <term>— introduce a lemmarw [<evidence>, ...]— use terms that directly inhabit equality propositionssimp [<evidence>, ...]/simpa— add equality lemmas and finish with the kernel's normalizerconstructor— split conjunction goals or construct structsleft/right— choose a branch of a disjunctioncases <term>— generate Boolean or enum branchesinduction <enum-term>— generate exhaustive structural enum branchesassumption— close the goal with a matching local hypothesisrfl— close a propositional equality goal by reflexivitysolve— bounded proof search over introductions, local implications, logical connectives, assumptions, and kernel automationtrivial,decide,norm_num,omega, andlinarith— request kernel-checked automatic proof search for the current propositioncontradiction— eliminate an impossible hypothesischange <constraint>/show <constraint>— continue with a definitionally equal goal
The arithmetic tactics are names for the same fail-closed, replayable kernel decision procedure; they do not grant the tactic elaborator any proof authority. For example:
theorem identity : int -> int := by solve
theorem ordered : (a : int) -> a >= a := by omega
theorem symmetric (a b : int) (h : a = b) : b = a := by
simp [h]
Custom tactics run when the prover reaches them, so they observe the current
goal after earlier tactics have changed it. Their first parameter is always
TacticContext, which exposes both the current target and local declarations:
#[tactic]
def first_assumption (ctx : TacticContext) : Expr :=
match ctx with
| Goal target locals => match locals with
| NoLocals => Int 0
| MoreLocals local tail => match local with
| Local name constraint => Name name
theorem identity : int -> int := by
intro value
first_assumption
TacticContext contains target : Expr and a newest-first TacticLocals
list. A generated proof expression is desugared in that local scope and then
checked by the kernel.
For tactics that transform proof state instead of immediately closing the
goal, return TacticScript. Scripts can introduce locals, apply terms, add
lemmas, provide exact proofs, and call core or registered tactics:
#[tactic]
def split (ctx : TacticContext) : TacticScript :=
TacticCall "constructor" NoTacticArgs TacticDone
def Pair : prop := struct
left : int
right : bool
def pair : Pair := by
split
exact 7
exact true
The script constructors are TacticExact, TacticApply, TacticIntro,
TacticHave, and TacticCall; each carries its following script and ends in
TacticDone. An empty string passed to TacticIntro requests an anonymous
binder. Call arguments use NoTacticArgs/MoreTacticArgs.
auto is bound to the concrete current proposition and checked by the proof checker; bare auto or membership in prop is not universal evidence. Logical decisions use Proved / Refuted / Unknown: Unknown cannot prove a negation and cannot make an implication antecedent vacuously false. Integer arithmetic proofs construct and replay a linear certificate for every Boolean branch; non-linear multiplication, /, and % conservatively return Unknown.
See docs/kernel-judgments.md for the trust-boundary rules. #[terminating] is an argument-free, explicitly trusted termination axiom.
fun x => x + 1
fun x y => x + y
fun (x : int) => x + 1
fun a (b : int) => a + b
let x := 5 in x + 3
let x : int := 5 in x
let x := 5 in let y := x + 1 in y * 2
(5 : int)
(5 : nat) by exact true
int -> bool -- non-dependent arrow
(x : int) -> x -- dependent arrow
∧ P Q -- conjunction: P ∧ Q
∨ P Q -- disjunction: P ∨ Q
¬ P -- negation: ¬P
A struct definition is a constraint — it lives in the prop universe and is erased after type checking. Struct values (constructed instances) live in data and are retained at runtime.
A struct has named fields. It is the product type (∧) of Ligare: all fields exist simultaneously. Since refinement types (where clauses) already handle invariants, structs focus solely on bundling named data.
Syntax
def Point : prop := struct
x : int
y : int
Construction
def p : Point := Point.mk 3 4
def q : Point := Point{x := 3, y := 4}
def r : Point := {x := 3, y := 4}
Field projection
#check Point.x p : int
def get_x (pt : Point) : int := Point.x pt
How it works
Point.mkis an auto-generated constructor that takes field values in order.Point{...}initializes a struct by field name;{...}is also allowed when the expected struct type is known.Point.xis an auto-generated projector that extracts the named field from a struct value.- The compiler automatically generates these from the struct definition.
- Field constraints are verified at construction time.
C representation
typedef struct Point {
int64_t x;
int64_t y;
} Point;An enum definition is a constraint — it lives in the prop universe and is erased after type checking. Enum values (variant instances) live in data and are retained at runtime.
An enum has named variants, each with optional payload fields. It is the sum type (∨) of Ligare: exactly one variant holds at a time.
Enums use the enum keyword, symmetric with struct. Each variant is introduced by |:
-- Simple enumeration (no payload)
def Color : prop := enum
| Red
| Green
| Blue
-- Polymorphic enum with payload
def Option (A : prop) : prop := enum
| None
| Some of (val : A)
-- Recursive enum — essential for compiler ASTs
def Expr : prop := enum
| Lit of (n : int)
| Add of (l : Expr) (r : Expr)
| If of (c : Expr) (t : Expr) (e : Expr)
-- Multi-field payload with named parameters
def Result (T : prop) (E : prop) : prop := enum
| Ok of (value : T)
| Err of (error : E)
Variant names are constructor functions. They are automatically generated from the enum definition:
def c : Color := Color::Red
def x : Option int := Option::Some 5
def y : Option int := Option::None -- type annotation needed for inference
def e : Expr := Expr::Add (Expr::Lit 1) (Expr::Lit 2)
def ok : Result int str := Result::Ok 42
For no-payload variants like None, the type parameter cannot be inferred from arguments alone — a type annotation (: Option int) provides the necessary constraint for the compiler to resolve A = int.
Variants with refinement-constrained payloads require proof obligations at construction time:
def PosOption : prop := enum
| Nothing
| Just of (val : int where (x => x > 0))
def j : PosOption := PosOption::Just 5 -- auto proof: 5 > 0
def k : PosOption := PosOption::Just (-3) -- compile error: -3 > 0 is false
Enum values are eliminated via match expressions. Each branch covers one variant and binds its payload:
def unwrap_or (opt : Option int) (default : int) : int :=
match opt with
| Option::None => default
| Option::Some val => val
The final branch may use _ to cover every variant not matched earlier. A whole-branch
wildcard does not bind any payloads:
def is_some (opt : Option int) : bool :=
match opt with
| Option::Some _ => true
| _ => false
Theorem introduction — every match branch automatically introduces a theorem that the scrutinee is of that variant, exactly like if branches introduce the condition theorem:
match opt with
| Option::None =>
-- theorem: opt = Option::None (available in this branch)
| Option::Some val =>
-- theorem: opt = Option::Some val (available in this branch)
-- if val has a refinement constraint (e.g. val > 0),
-- that theorem is also available here
This enables safe refinement propagation through match branches:
def safe_div (opt : PosOption) (x : int) : int :=
match opt with
| PosOption::Nothing => 0
| PosOption::Just val =>
-- theorem: val > 0 (from PosOption's refinement)
-- this satisfies div's proof obligation that the divisor is non-zero
div x val
Exhaustiveness checking — the compiler verifies that every variant of the enum is covered. Missing a variant is a compile-time error.
Nested matches are naturally supported:
def eval (e : Expr) : int :=
match e with
| Expr::Lit n => n
| Expr::Add l r => eval l + eval r
| Expr::If c t e => if eval c /= 0 then eval t else eval e
Enum definitions are prop — erased at compile time. Enum values and match expressions are data — retained at runtime.
The C backend compiles enums to tagged union structs and match to switch statements, achieving zero-overhead representation:
// Option_int (A = int)
typedef struct {
int tag; // 0 = None, 1 = Some
union {
struct { int64_t val; } Some;
} data;
} Option_int;
// match opt with | None => 0 | Some val => val + 1
switch (opt.tag) {
case 0: return 0;
case 1: { int64_t val = opt.data.Some.val; return val + 1; }
}| Struct (product) | Enum (sum) | |
|---|---|---|
| Logical dual | ∧ (all hold) |
∨ (one holds) |
| Construction | Provide all fields | Choose one variant |
| Elimination | Field projection (.x) |
Pattern matching (match) |
| C representation | Contiguous fields | Tag + union |
| Universe | definition: prop, value: data |
definition: prop, value: data |
Ligare can quote source expressions as Expr values, evaluate metaprograms at compile time, and splice the resulting AST back into the program.
Implemented mechanism
-- Quote: converts a code fragment into manipulable AST data
quote { x + 1 }
-- Expression splice: the argument must evaluate to Expr
$(quote { 1 + 2 })
Safety guarantee
Spliced expressions are checked against their surrounding constraint. Top-level splices must evaluate to Definitions; generated definitions and instances pass through the normal compiler checks. Functions marked with #[tactic] or #[attr] can extend proof scripts and attributes at compile time.
Metaprogram execution is erased and does not enter the generated runtime program.
Ligare programs consist of a sequence of top-level commands:
| Command | Description |
|---|---|
def <name> <params>? : <type>? := <body> |
Define a named term or function |
#[instance] + def <name> : <type> := <body> |
Register an implicit compile-time instance |
#[no_small_int] + def ... |
Disable the generated small-integer specialization for this function |
theorem <name> <params>? : <type> := <body> |
Define a named theorem with a required result type (type-checked, then available as a term) |
#check <expr> : <type> |
Type-check an expression against a constraint |
#eval <expr> |
Evaluate an expression and display the result |
Implicit instances are declared with #[instance] on a normal definition:
#[instance]
def show_nat : Show nat := Show.mk render_nat
Example program
def nat := int where (x => x >= 0)
def x : nat := 10
theorem x_is_nat : nat := x by
exact true
#check x : int
#eval x
The compilation process is divided into two major phases:
-
Constraint checking and proof verification
Perform constraint checking on all terms and verify that all proposition obligations are satisfied. -
Erasure and code generation
Retain all terms constrained bydata, and remove all terms constrained byprop. The final product is pure, zero-overhead executable code.
Ligare uses the single core concept of "terms constrained by terms" to unify:
- The type system (constraints as terms in
prop) - Propositions and proofs
- Design by contract (refinement types)
- Product types (structs) and sum types (enums) — both as constraints in
prop - Compile-time metaprogramming with checked quote, splice, tactics, and attributes
It pursues the extreme of static safety with zero runtime burden, while maintaining a minimal set of concepts.
This document describes the currently implemented syntax and planned features; formal definitions, operational semantics, and implementation details will be added progressively.