The Pure Language Reference
July 31, 2026 · View on GitHub
Pure is the strongly-typed, functional, expression-oriented language at the heart of Legend Pure. This page is a practical reference for engineers who need to read, write, or reason about Pure source code.
For the compiler internals that process this source, see the
Compiler Pipeline.
For the ###Mapping and ###Relational grammars, see the
Legend Grammar Reference.
0. The Grammar Section System
A Legend Pure source file is not a single flat language. It is a multi-section
document where each section is written in a different grammar, declared by a
###<GrammarName> header on its own line.
###Pure
// Pure language — classes, functions, enumerations, associations
###Mapping
// Mapping DSL — maps domain model to store/source implementations
###Relational
// Relational DSL — defines database schemas, tables, joins
The top-level lexer (TopAntlrLexer.g4) splits the file on the \n### token.
Everything after a ###<Name> line until the next ### belongs to the parser
registered under that name. The ParserLibrary dispatches each section to its
corresponding Parser implementation based on the string name.
This split happens before any grammar parses anything, and it is not string- or comment-aware. A line beginning with
###at column 0 always starts a new section — including inside a multi-line string literal or a documentation literal. Indent the###by one space to keep it as content.
Grammar sections available in this repository
| Section header | Parser | What it defines |
|---|---|---|
###Pure | M3AntlrParser | Classes, functions, enumerations, associations, profiles — the full Pure language documented on this page |
###Mapping | MappingParser | Mappings from domain model to store implementations — Pure, Relational, Enumeration, XStore, Operation |
###Relational | RelationalParser | Database schemas — tables, columns, joins, views, filters |
###Diagram | DiagramParser | UML-style class diagrams (layout only, no execution semantics) |
Additional grammar sections (
###Connection,###Runtime,###Service,###DataSpace, etc.) are defined inlegend-engineon top of this foundation and are not part of this repository.
The ###Pure section
The rest of this page documents the ###Pure grammar. Everything within a
###Pure section — classes, functions, enumerations, associations, and profiles —
follows the syntax described below.
A single file may contain multiple sections of different types, or multiple
sections of the same type. Elements from a ###Pure section can be referenced
by name in a ###Mapping or ###Relational section in the same file or in a
different file in the same repository.
###Pure
import my::model::*;
Class my::model::Person
{
firstName : String[1];
lastName : String[1];
}
###Mapping
import my::model::*;
Mapping my::mapping::PersonMapping
(
Person : Pure
{
~src Person
firstName : $src.firstName,
lastName : $src.lastName
}
)
1. Language Basics
Everything is an Expression
Pure has no statements — every construct is an expression that evaluates to a value.
A function body is a single expression (which may be a sequence of let bindings
ending in a final expression).
Variables
Variables are immutable and declared with let:
let x = 42;
let name = 'Alice';
let people = [^Person(firstName='Alice'), ^Person(firstName='Bob')];
The $ sigil is used to reference a variable:
let doubled = $x * 2;
Comments
// Single-line comment
/* Multi-line
comment */
Block comments do not nest: /* a /* b */ ends at the first */. Comments never carry
meaning — documentation is a '''…''' literal, not a comment.
Documentation
A '''…''' literal immediately preceding a declaration is syntactic sugar for the
meta::pure::profiles::doc doc tagged value. These two are identical after parsing:
'''
A **person** in the system.
Identity is established by `legalName`.
'''
Class model::Person
{
'''
Given name. Not guaranteed unique.
'''
firstName: String[1];
}
Class {meta::pure::profiles::doc.doc = 'A **person** in the system.\n\nIdentity is established by `legalName`.'} model::Person
{
{meta::pure::profiles::doc.doc = 'Given name. Not guaranteed unique.'} firstName: String[1];
}
There is no new profile, no new metamodel, and nothing downstream needs to change: consumers
that already read the doc tag see documentation automatically.
Documentation is a parser rule, listed explicitly at each declaration that accepts it — not a lexer-level construct. That is what keeps the identical literal in expression position an ordinary multi-line string, and it is why attachment needs no adjacency heuristic: either the literal is in a documentation position or it is a value.
Where it attaches
Class, Enum (and individual enum values), Association, Profile, Measure, function,
native function, Primitive, properties, and qualified (derived) properties.
Documentation precedes the whole declaration, before any stereotypes or tagged values:
'''
Attached to the class.
'''
Class <<meta::pure::profiles::access.private>> {meta::pure::profiles::doc.todo = 'x'} model::Person
{
}
Because attachment is syntactic, intervening whitespace and comments are simply skipped — a note written between the documentation and the declaration does not detach it:
'''
Still attached.
'''
// a note about the class
Class model::A {}
Two consecutive documentation literals, or one trailing at end of file with no declaration to attach to, are parse errors rather than silently ignored.
The same literal in expression position is a value
This is the one thing to internalize. A '''…''' before a property is documentation; inside a
derived property's body it is that property's return value:
Class model::Person
{
'''
Given name. <-- documentation
'''
firstName: String[1];
fullName(){
'''
Formatted name. <-- NOT documentation: this IS the return value
'''
}: String[1];
}
Adding a ; after that inner literal to make room for real code compiles, and silently evaluates
and discards the string on every call. There is nowhere to write documentation inside a function
or derived-property body; it goes before the declaration.
Conflict with an explicit doc.doc
An element may not carry both documentation and an explicit doc.doc tagged value. Both are
statements of intent, and silently honouring one would make the other vanish without trace, so
this is a parse error:
'''
From the documentation.
'''
Class {meta::pure::profiles::doc.doc = 'From the tagged value.'} model::A
{
}
Element has both documentation and an explicit doc.doc tagged value. Use one.
Because tag references are not resolved until after parsing, the check matches the profile
as written — either bare doc (resolved through an import) or the fully qualified
meta::pure::profiles::doc. The following are therefore not conflicts:
| Case | Why it is allowed |
|---|---|
{meta::pure::profiles::doc.todo = '…'} | Same profile, different tag |
{my::pkg::doc.doc = '…'} | A different profile that merely ends in doc |
How the content is processed
Documentation shares the Java-text-block layout of multi-line string literals and differs from them in two respects.
- Content is literal — there is no escape processing.
\n,\'and\\are content. This is the opposite of a string literal, and it is deliberate: documentation is prose. Unescaping prose silently rewrites a regex (\d+→d+), a Markdown escape (\*→*) and a Windows path (C:\temp→C:followed by a tab) — and a\unot followed by four hex digits, as inC:\users, aborts compilation with an error carrying no source location at all. - Leading and trailing blank lines are dropped, so how the literal is laid out does not change the documentation. A string literal instead keeps a trailing newline when its closing delimiter sits on its own line.
Otherwise the rules are the shared ones: line endings are normalized; the opening delimiter's line is dropped; the common leading indentation — the minimum across all non-blank lines and the closing delimiter's line — is removed; trailing whitespace is removed from each line.
Content is conventionally Markdown, but the grammar is format-agnostic: nothing here parses or validates it.
Below, ⏎ marks a newline in the source and · a space.
| Source | Value |
|---|---|
'''⏎One line.⏎''' | "One line." |
'''⏎A⏎⏎B⏎''' | "A\n\nB" — interior blank lines are kept |
'''⏎Options:⏎*·first⏎''' | "Options:\n*·first" — nothing is star-aware, so bullets are ordinary content |
'''⏎··Text⏎⏎······code⏎··''' | "Text\n\n····code" — min indent is 2, set by the closing '''; the code block keeps its relative indent |
'''⏎····indented⏎''' | "····indented" — closing ''' at column 0 sets the floor to 0, so nothing is stripped |
'''⏎''' | "" — empty |
Gotchas
- A Markdown bullet list written with
*is preserved. Nothing strips leading stars, so unlike Javadoc there is no all-or-nothing rule to reason about. - Indentation is semantic in Markdown — four-space-indented lines are code blocks. Because the
closing
''''s indentation sets the floor, put'''at the indentation your content is written at, and deeper indentation survives relative to it. - The opening
'''must be followed by a line terminator, so there is no one-line form.'''docs'''does not lex as a documentation literal. - A literal
'''cannot appear in the content and there is no escape for it — reword. - An ordinary
/* … */or/** … */block comment is just a comment. Only'''…'''in a documentation position produces adoctagged value. - The
###section split is not string-aware (see §0), so a line beginning###inside documentation will split the file into sections. - Resolution of Pure references inside documentation (
[[pkg::Class]],```pureblocks) and Markdown rendering are the consumer's concern, not the grammar's.
Implementation: parser rule documentation in M3CoreParser.g4, prefixed onto each
declaration that accepts it (inherited by M3Parser, RelationalParser and
RelationMappingParser); layout shared with string literals in
org.finos.legend.pure.m4.serialization.grammar.MultilineTextLayout; canonicalization in
DocumentationCanonicalizer; attachment in AntlrContextToM3CoreInstance.taggedValues(...).
Tests: TestDocumentationCanonicalizer (m4) and TestDocumentation (m3-core) cover parsing and
canonicalization; platform/pure/documentation.pure pins the same contract at run time on both
execution engines, where the tagged value has been through metadata serialization rather than read
straight off the AST.
2. Types and Primitives
Built-in Primitive Types
| Type | Example literals | Notes |
|---|---|---|
String | 'hello', 'it\'s' | Single-quoted; escape with \'. A multi-line form '''…''' also exists |
Integer | 42, -7 | 64-bit signed |
Float | 3.14, -0.5 | 64-bit IEEE 754 |
Decimal | 3.14d | Arbitrary precision (precisePrimitives module) |
Boolean | true, false | |
Date | %2024-01-15, %2024-01-15T10:30:00 | Legend date literal |
StrictDate | %2024-01-15 | Calendar date, no time |
DateTime | %2024-01-15T10:30:00+0000 | Date + time with optional TZ offset |
Number | (abstract) | Supertype of Integer, Float, Decimal |
Any | (abstract) | Root type; every Pure type is a subtype |
Multi-line String Literals
A string literal may be written across multiple lines using a triple-quote
delimiter ''', following Java text-block semantics:
function meta::mypackage::greeting(): String[1]
{
'''
Hello,
world!
'''
}
The value above is "Hello,\nworld!\n" — the four-space indentation shared by
every line is incidental and is removed.
'''…''' is syntax only. It produces an ordinary String — there is no new
type, no marker on the instance, and nothing downstream (the compiler, either
execution engine, serialization) can tell it apart from the equivalent
single-line literal. Anywhere a String literal is accepted in a ###Pure
section, a multi-line literal is accepted too: expressions, collection literals,
property default values, function arguments, and tagged values.
// All of these are valid
'prefix: ' + '''
body
'''
'''
abc
'''->length()
Class meta::mypackage::Thing
{
note : String[1] = '''
default note
''';
}
The five processing rules
Given the raw text between the delimiters, the parser applies these steps in
order (AntlrContextToM3CoreInstance.processMultilineString):
- Line terminators are normalized —
\r\nand\rboth become\n. - The opening delimiter line is dropped. The opening
'''must be followed by a line terminator (spaces and tabs between the two are allowed and ignored). Content starts on the next line. - Incidental indentation is removed. The amount removed is the smallest leading-whitespace run across all non-blank lines and the closing-delimiter line — the closing line participates even when it is blank.
- Trailing whitespace is stripped from every line.
- Escape sequences are processed — the same set as single-line strings
(
\n,\t,\',\\,\uXXXX, octal).
Escapes are resolved last, and that is load-bearing. Steps 3 and 4 see the raw characters, so an escaped whitespace character is invisible to them: a line ending in
\tends in the lettertas far as step 4 is concerned, and becomes a real tab only at step 5. This is how you keep whitespace the layout passes would otherwise remove — see below.
Worked examples
Below, ⏎ marks a newline in the source, · a space, and ⇥ a tab.
| Literal | Value |
|---|---|
'''⏎line1⏎line2''' | "line1\nline2" — no trailing newline |
'''⏎line1⏎line2⏎''' | "line1\nline2\n" — closing ''' on its own line adds the terminal newline |
'''⏎····hello⏎····world⏎····''' | "hello\nworld\n" — common indent removed |
'''⏎····a⏎··b⏎····''' | "··a\nb\n" — min indent is 2, set by the shortest line |
'''⏎····a⏎····b⏎''' | "····a\n····b\n" — closing ''' at column 0 sets the floor to 0, so nothing is stripped |
'''⏎a···⏎b⇥⏎''' | "a\nb\n" — trailing whitespace always stripped |
'''⏎''' | "" — empty |
Controlling the indent with the closing delimiter. Because the closing line participates in the minimum, its column is what you tune to keep or drop leading indentation. Put
'''flush left to preserve the block's indentation verbatim; align it with the content to strip all of it.
Differences from Java text blocks
Pure reuses Java's layout algorithm but not its escape extensions. Two Java text-block features are not available:
| Java feature | In Pure |
|---|---|
\s — escape meaning "a space that survives trailing-whitespace stripping" | No \s escape; it yields a literal s. Use \u0020 instead — see below |
\ at end-of-line — joins the line with the next | Not supported. The backslash is simply dropped and the newline is kept |
More generally, an unrecognized escape does not error — the backslash is
discarded and the following character is kept verbatim (\q → q). This is
pre-existing StringEscape behaviour shared with single-line strings.
Preserving significant whitespace. Pure has no \s, but it does not need
one: because escapes are resolved after the two layout passes, any escaped
whitespace survives them intact.
| Literal | Value | Why |
|---|---|---|
'''⏎a\u0020\u0020⏎''' | "a··\n" | Trailing spaces preserved — step 4 saw the 12 characters of \u0020\u0020, none of them whitespace |
'''⏎a\t⏎''' | "a⇥\n" | Same, for a tab |
'''⏎\tabc⏎\tdef⏎''' | "⇥abc\n⇥def\n" | Leading escaped tabs are not counted as indentation, so step 3 cannot strip them |
Restrictions and gotchas
-
The opening
'''must be followed by a newline.'''abc'''does not parse as a multi-line string. With the multi-line rule inapplicable, the lexer falls back to the single-line rule and produces three separateSTRINGtokens —'','abc',''— which the parser rejects (expected: '}' found: 'abc'). -
The content cannot contain
'''. The lexer closes the literal at the first'''it meets, so a run of three-or-more quotes (e.g.'''') is a token-recognition error. Two consecutive quotes ('') are fine, as are lone'and"characters — no escaping needed. To embed a literal''', escape the quotes:\'\'\'yields'''. -
⚠ A line starting with
###at column 0 terminates the section, even inside a multi-line string. The top-level lexer that splits a file into###-delimited grammar sections (see §0) runs before the Pure parser and knows nothing about string literals. This source fails to compile because the###Relationalline splits the file, leaving the'''unterminated:function meta::mypackage::bad(): String[1] { ''' ###Relational ''' // parse error: unterminated }Indenting the
###by even one space is enough to avoid this, because the section separator token is'\n###'— it must sit immediately after a newline. The workaround is free: that indent is incidental, so step 3 strips it back off and the resulting value still has###Relationalat column 0. This hazard is new with multi-line strings; a single-line literal cannot span a newline, so\n###could never occur inside one. -
###Puresections only, in practice.###Mapping,###Diagramand M4 do not accept'''…''': their lexers import onlyM4Fragment.g4, whereMultilineStringsits as an unreferenced fragment.###Relationaland relation mappings are a different case.RelationalLexer.g4andRelationMappingLexer.g4importM3CoreLexer, so they do inherit theMULTILINE_STRINGtoken, andRelationalParserinherits thetaggedValuerule that accepts it — a'''…'''tagged value on a###Relationalcolumn therefore parses. It then fails inRelationalGraphBuilder.visitTaggedValueNew, which readsctx.STRING(0)unconditionally; the resultingNullPointerExceptionsurfaces as aPureParserExceptionwith an empty message and a misreported line number. Treat multi-line tagged values in###Relationalas unsupported, but note the failure is a known gap rather than a deliberate rejection. -
Printing does not round-trip. Since the value is an ordinary
String, anything that renders it back to Pure source emits a single-line, escaped literal — not a'''block.
Implementation: lexer fragment MultilineString in M4Fragment.g4; token
MULTILINE_STRING in M3CoreLexer.g4; parser rules instanceLiteralToken and
taggedValue in M3CoreParser.g4; processing in
AntlrContextToM3CoreInstance.processMultilineString. Tests:
TestStringParsing and TestProfile.
Any and Nil
Two special types appear in function signatures throughout the standard library:
-
Any— the top type. Every type is a subtype ofAny. UseAny[*]when a parameter must accept values of any type. Return typeAny[1]means the caller mustcastormatchbefore using the result as anything specific. -
Nil— the bottom type.Nilis a subtype of every type. It never appears in user code directly; the compiler uses it in two places:- As the parameter lower bound in
matchbranch lambdas (Function<{Nil[n]->T[m]}>) so that branches with any concrete parameter type are accepted. - As the type of an empty collection literal
[](Nil[0]), making it assignable to anyT[*]orT[0..1].
- As the parameter lower bound in
For a full explanation of why
Nilis needed and whyAnycannot replace it (function parameter contravariance, covariance vs contravariance, multiplicity parameters in generic signatures), see the Pure Type System Reference.
Multiplicity (Cardinality)
Every property and function parameter carries a multiplicity annotation:
| Notation | Meaning |
|---|---|
[1] | Exactly one — mandatory |
[0..1] | Zero or one — optional |
[*] | Zero or more — collection |
[1..*] | One or more |
[2..5] | Between 2 and 5 inclusive |
name : String[1]; // required
nickname: String[0..1]; // optional
aliases : String[*]; // collection
Multiplicity parameters in generic signatures
Multiplicity parameters (conventionally m, n, o, …) appear alongside type
parameters in signatures like letFunction<T,m>, if<T,m>, and
match<T,m,n> — they allow a function to work at any cardinality. The compiler
infers them at each call site; you never supply them explicitly.
For a full explanation with worked examples see Pure Type System Reference — Multiplicity Parameters.
3. Defining Types
Class
Class meta::mypackage::Person
{
firstName : String[1];
lastName : String[1];
age : Integer[0..1];
// Derived property (computed, not stored)
fullName() { $this.firstName + ' ' + $this.lastName } : String[1];
}
Class with Constraints
Class meta::mypackage::PositiveAmount
[
mustBePositive: $this.value > 0
]
{
value : Float[1];
}
Class with Generics
Class meta::mypackage::Pair<A, B>
{
first : A[1];
second : B[1];
}
Enumeration
Enum meta::mypackage::TradeStatus
{
PENDING,
CONFIRMED,
SETTLED,
CANCELLED
}
Usage:
let status = TradeStatus.CONFIRMED;
Association
Associations define bidirectional relationships between classes without modifying either class:
Association meta::mypackage::PersonFirm
{
person : Person[*];
firm : Firm[1];
}
Profile - Tag and Stereotype
Profile meta::mypackage::classification
{
stereotypes: [internal, external, deprecated];
tags: [description, owner];
}
Class <<meta::mypackage::classification.internal>> meta::mypackage::InternalModel
{
// ...
}
Applying a tag — use {Profile.tag = 'value'} after the stereotype list
(or directly before the element keyword if there are no stereotypes):
Class <<meta::mypackage::classification.internal>>
{meta::mypackage::classification.description = 'Internal pricing model',
meta::mypackage::classification.owner = 'risk-team'}
meta::mypackage::InternalModel
{
name : String[1];
}
Both stereotypes and tags can be applied together, and multiple values are separated by commas inside the curly braces.
Tag values — concatenation and multi-line form
A tag value is written in one of two mutually exclusive forms.
1. One or more +-joined single-line strings. Note that + here is not
the string-concatenation function — the parser joins the fragments with the
default makeString() separator, ", ":
Class {doc.doc = 'first' + 'second'} meta::mypackage::Thing {}
// resulting tag value: "first, second" ← comma-space, not "firstsecond"
This is long-standing behaviour and is unchanged. It is a common surprise for
anyone expecting + to behave as it does in an expression.
2. A single multi-line string — the readable way to attach a paragraph of documentation:
Class {doc.doc = '''
Represents a documented class.
The blank line above is preserved; the shared indentation is not.
'''} meta::mypackage::DocumentedClass
{
name : String[1];
}
A multi-line tag value cannot be concatenated.
{doc.doc = 'Title: ' + '''…'''}is a parse error. The grammar rule is(MULTILINE_STRING | STRING (PLUS STRING)*)— you get either one multi-line string or a+-joined run of single-line ones, never a mix. Given that+inserts", "rather than concatenating, mixing the two forms would produce a value almost nobody intends, so the combination is rejected outright.
Built-in profiles
Pure ships several profiles out of the box. Each must be imported before the short name can be used.
meta::pure::profiles::doc — Documentation
import meta::pure::profiles::*;
| Annotation | Kind | Usage |
|---|---|---|
<<doc.deprecated>> | Stereotype | Marks an element as deprecated — tools and linters may warn on use |
{doc.doc = '…'} | Tag | Human-readable documentation string attached to any element |
{doc.todo = '…'} | Tag | Inline note recording outstanding work |
// Deprecated enum value with a doc string on a sibling value
Enum meta::mypackage::GeoType
{
<<doc.deprecated>> COUNTRY,
{doc.doc = 'A city, town, village, or other urban area.'} CITY,
REGION
}
// Deprecated class
Class <<doc.deprecated>> {doc.doc = 'Use NewThing instead.'}
meta::mypackage::OldThing
{
name : String[1];
}
// Function with a doc string
function {doc.doc = 'Returns the full name of a person.'}
meta::mypackage::fullName(p: Person[1]): String[1]
{
$p.firstName + ' ' + $p.lastName
}
// Property with a todo note
Class meta::mypackage::Order
{
{doc.todo = 'Add validation for negative amounts'}
amount : Float[1];
}
Both {doc.doc = '…'} tags and <<doc.deprecated>> stereotypes, along with other tags / stereotypes, can be applied
to individual columns inside a ###Relational store definition.
⚠ Column annotation order is the reverse of
###Pureproperties. In a###Pureclass, annotations precede the property name. In a###Relationaltable, annotations are placed after the column name but before the SQL type:ColumnName <<stereotype>> {tag = 'value'} SQLTYPE
###Relational
import meta::pure::profiles::*;
Database meta::mypackage::TradeDatabase
(
Table TradeTable
(
trade_id INT PRIMARY KEY,
trade_date DATE,
// tag only — doc string on the column
amount {doc.doc = 'Net notional value of the trade in USD.'} FLOAT,
// stereotype only — column is deprecated
old_ref <<doc.deprecated>> VARCHAR(20),
// stereotype + tag together — deprecated with an explanation
book_code <<doc.deprecated>>
{doc.doc = 'Replaced by trade_id. Will be removed in v3.'}
VARCHAR(20),
// multiple tags in one brace block
currency {doc.doc = 'ISO 4217 three-letter currency code.',
doc.todo = 'Validate against reference-data table.'}
CHAR(3)
)
)
meta::pure::profiles::equality — Model-Defined Equality
import meta::pure::profiles::*;
| Annotation | Kind | Usage |
|---|---|---|
<<equality.Key>> | Stereotype (on a property) | Marks the property as a key field for structural equality. Two instances are equal if all <<equality.Key>> properties have equal values. |
Class meta::mypackage::Product
{
<<equality.Key>> sku : String[1]; // equality is based on sku …
<<equality.Key>> site : String[1]; // … and site
description : String[0..1]; // not part of equality
}
When equal() (or ==) is called on two Product instances, only sku and
site are compared; description is ignored.
meta::pure::profiles::temporal — Milestoning
import meta::pure::profiles::*;
| Annotation | Kind | Usage |
|---|---|---|
<<temporal.businesstemporal>> | Stereotype | Adds a single business-date dimension; generates businessDate property and date-aware getAll overload |
<<temporal.processingtemporal>> | Stereotype | Adds a single processing-date dimension |
<<temporal.bitemporal>> | Stereotype | Adds both business and processing date dimensions |
See Section 11 — Milestoning for full usage detail.
meta::pure::profiles::access — Visibility
import meta::pure::profiles::*;
| Annotation | Kind | Usage |
|---|---|---|
<<access.public>> | Stereotype | Explicitly marks an element as part of the public API |
<<access.protected>> | Stereotype | Internal to the package hierarchy |
<<access.private>> | Stereotype | Not intended for use outside the defining file/module |
<<access.externalizable>> | Stereotype | Can be exposed externally (e.g. through a service layer) |
function <<access.private>>
meta::mypackage::impl::computeInternal(x: Integer[1]): Integer[1]
{
$x * 42
}
function <<access.public>>
meta::mypackage::computeResult(x: Integer[1]): Integer[1]
{
computeInternal($x)
}
meta::pure::profiles::test — Testing
The meta::pure::profiles::test and meta::pure::test::pct::PCT profiles that
drive test execution are covered in Section 13 — Writing Tests in Pure.
4. Functions
Named Function
function meta::mypackage::greet(name: String[1]): String[1]
{
'Hello, ' + $name + '!'
}
Return Values
A function body is a sequence of semicolon-terminated expressions. The value of
the last expression is the return value — there is no return keyword.
function meta::mypackage::describe(p: Person[1]): String[1]
{
let greeting = 'Hello, '; // expression 1
let name = $p.firstName + ' ' + $p.lastName; // expression 2
$greeting + $name; // expression 3 — returned
}
Semicolons — complete rules
The rules for ; in Pure are:
| Number of expressions | Required form |
|---|---|
| 1 | expr — no semicolon |
| 2 | expr; expr; |
| 3 | expr; expr; expr; |
| N | every expression terminated with ; |
The key insight: ; is a terminator on every expression when there are multiple
expressions, and is absent entirely when there is only one.
Single expression — no semicolon
When a function body or lambda body contains exactly one expression, no semicolon is written:
// Named function — one expression, no semicolon
function meta::mypackage::double(x: Integer[1]): Integer[1]
{
$x * 2
}
// Inline lambda — one expression after |, no semicolon
[1, 2, 3]->filter(x | $x > 1)
// Block lambda — one expression after |, no semicolon
[1, 2, 3]->map(x | {
$x * 2
})
Multiple expressions — every expression including the last requires ;
When there are two or more expressions, every expression must be terminated
with ;, including the final one:
function meta::mypackage::describe(p: Person[1]): String[1]
{
let greeting = 'Hello, ';
let name = $p.firstName + ' ' + $p.lastName;
$greeting + $name; // <- trailing ';' required — this is NOT optional
}
// Block lambda — all expressions terminated
[1, 2, 3]->map(x | {
let doubled = $x * 2;
$doubled + 1; // <- required
})
Empty statements / double semicolons are not permitted
Pure has no empty statement. ;; is a parse error:
// NOT valid Pure
{
let x = 1;; // ERROR — empty statement between the two semicolons
$x + 1;
}
Why let returns a value — and when that matters
let is a function with this signature:
letFunction<T,m>(left: String[1], right: T[m]): T[m]
It returns the value it binds — the same T[m] that was assigned. This means
a let as the sole expression of a function is valid Pure:
function meta::mypackage::double(x: Integer[1]): Integer[1]
{
let result = $x * 2 // sole expression — no semicolon; let returns Integer[1]
}
The compiler accepts this because let result = $x * 2 evaluates to Integer[1],
satisfying the declared return type.
Prefer a final bare expression over a final let
Even though a final let works, it is misleading to readers: let communicates
"I am naming something for later use", not "this is the result". When there is no
further use of the variable, the binding serves no purpose. Prefer returning the
expression directly:
// Avoid — sole expression is a let; works but misleading
function meta::mypackage::double(x: Integer[1]): Integer[1]
{
let result = $x * 2
}
// Prefer — intent is unambiguous; single expression, no semicolon
function meta::mypackage::double(x: Integer[1]): Integer[1]
{
$x * 2
}
// Multiple expressions — ALL terminated with ';', including the last
function meta::mypackage::describeAge(p: Person[1]): String[1]
{
let age = $p.age->toOne();
'Age: ' + $age->toString();
}
The exception is when the variable name meaningfully documents the computation for
the reader — in that case a final let is an acceptable style choice, but it should
be rare.
Function with Multiple Parameters
function meta::mypackage::add(a: Integer[1], b: Integer[1]): Integer[1]
{
$a + $b
}
Function Calling Convention — Arrow Syntax
Pure supports both direct and arrow (method-chain) call styles:
// Direct
greet('Alice')
// Arrow (first argument moved to left of ->)
'Alice'->greet()
// Chaining
[1, 2, 3]->filter(x | $x > 1)->map(x | $x * 2)
Lambda (Anonymous Function)
// Lambda syntax: parameter | body
[1, 2, 3]->filter(x | $x > 1)
// With type annotation
[1, 2, 3]->filter(x: Integer[1] | $x > 1)
// Multi-parameter lambda
pairs->map(p | $p.first + ':' + $p.second)
Native Function
A native function has its implementation provided in Java, not Pure:
native function meta::pure::functions::lang::letFunction<T>(left: String[1], right: T[1]): T[1];
5. Control Flow
if / else
if is a function, not a language keyword. It evaluates a boolean condition and
returns the result of exactly one of two branches.
Signature
// Basic: boolean condition
if<T,m>(test: Boolean[1], valid: Function<{->T[m]}>[1], invalid: Function<{->T[m]}>[1]): T[m]
// Multi-condition: list of (condition, result) pairs with a fallback
if<T,m>(condList: Pair<Function<{->Boolean[1]}>, Function<{->T[m]}>>[*], last: Function<{->T[m]}>[1]): T[m]
Why the branches are lambdas, not values
The valid and invalid parameters are typed Function<{->T[m]}> — zero-argument
functions (lambdas) — not plain values. This is lazy evaluation by necessity.
If the branches were plain values, Pure would have to evaluate both before calling
if. That would mean:
- Side-effectful branches (e.g.
println) would always execute regardless of the condition. - Expensive computations in the unused branch would always run.
- Recursive functions would never terminate — the else branch would be evaluated before the condition is checked.
By making each branch a Function<{->T[m]}>, the runtime only evaluates the branch
it actually needs. The | lambda syntax makes this concise enough that it reads
like a built-in construct:
// what you write
if($amount > 1000, | 'large', | 'small')
// what the type signature actually receives
if($amount > 1000,
{-> 'large'}, // a zero-argument lambda producing String[1]
{-> 'small'}) // only one of these is ever evaluated
Basic example
let label = if($amount > 1000,
| 'large',
| 'small');
The | before each branch is the lambda body separator — it introduces a
zero-argument lambda. $amount > 1000 is evaluated eagerly (it's a plain
Boolean[1]), but only the selected branch lambda is then invoked.
Multi-condition form
For chains of conditions the second overload accepts a list of pair(condition, result)
lambdas and a fallback:
let category = if(
[
pair(| $score >= 90, | 'A'),
pair(| $score >= 80, | 'B'),
pair(| $score >= 70, | 'C')
],
| 'F' // fallback: no condition matched
);
Conditions are evaluated left-to-right; the first true condition wins. Note that
both the condition and the result are lambdas here — neither is evaluated until
needed.
Combining boolean expressions (&&, ||)
When combining boolean expressions with && or ||, each operand must be a single
expression as understood by the parser. The key grammar distinction is:
==and!=are part of theexpressionrule — they form a complete expression and do not need extra parentheses when combined with&&or||.<,<=,>,>=are arithmetic operators in the grammar — they are not part ofexpressionand must be parenthesized when used as an operand to&&,||, or==/!=.
Function-call operands
Function-call expressions already form a complete expression. No parentheses needed,
and multiple &&/|| can be chained freely:
// Valid — single function calls as operands
$x->isNotEmpty() && $y->isNotEmpty()
// Valid — chaining && with function calls
$x->isNotEmpty() && $y->isNotEmpty() && $z->isNotEmpty()
// Unnecessary parentheses — function calls do not need them
($x->isNotEmpty()) && ($y->isNotEmpty()) // works but redundant
Standalone comparison expressions
A comparison on its own (not combined with &&/||) does not need parentheses:
// Valid — comparison expression on its own
$x > 5
$x == 5
== and != with && and ||
Because == and != are part of expression, they do not need parentheses when
combined with && or ||:
// Valid — == and != do not need parentheses with && or ||
$x == 'foo' && $y != 'bar'
$x == 'foo' || $y == 'baz'
<, <=, >, >= with &&, ||, ==, and !=
These operators are parsed as arithmetic continuations and must be parenthesized:
// Invalid — < and > used directly as operands
$x < 5 || $x > 0
// Valid — both wrapped in parentheses
($x < 5) || ($x > 0)
// Invalid — > used directly as operand to &&
$x->isNotEmpty() && $x > 5
// Valid
$x->isNotEmpty() && ($x > 5)
// Invalid — > used directly as operands to !=
$x > 0 != $y > 0
// Valid
($x > 0) != ($y > 0)
! (not) operator
! binds to the immediately following expression. Function-call chains are a
single expression and work directly. Comparisons with </<=/>/>= are not,
and require parentheses:
// Valid — ! applied to a function call
!$x->isEmpty() && $y->isNotEmpty()
// Valid — ! applied to a parenthesized comparison
!($x > 5) && $y->isNotEmpty()
// Valid — chaining ! with || and a comparison
!$x->isEmpty() || ($x->toOne() > 5)
Function-call form: and() / or()
&& and || are syntactic sugar for the native functions
meta::pure::functions::boolean::and and meta::pure::functions::boolean::or.
You can call them directly in either direct or arrow style:
// Direct function call
and($x->isNotEmpty(), $y->isNotEmpty())
or($x->isEmpty(), $y->isEmpty())
// Arrow style
$x->isNotEmpty()->and($y->isNotEmpty())
$x->isEmpty()->or($y->isEmpty())
Both forms are short-circuit: and(false, expr2) never evaluates expr2;
or(true, expr2) never evaluates expr2.
Prefer
&&/||— they are the canonical grammar form (defined viaPCT.grammarDocon the native functions) and read more naturally. Reserve the function-call form for cases where the expression is being passed as a value or invoked reflectively (e.g.and_Boolean_1__Boolean_1__Boolean_1_->eval(...)).
match
match is Pure's type-dispatch function. It tests a value against an ordered
list of typed lambdas and executes the body of the first lambda whose type and
multiplicity are both satisfied.
Signatures
// Basic: dispatch on type + multiplicity
match<T,m,n>(var: Any[*], functions: Function<{Nil[n]->T[m]}>[1..*]): T[m]
// With extra shared parameter passed to every branch
match<T,P,m,n,o>(var: Any[*], functions: Function<{Nil[n],P[o]->T[m]}>[1..*], with: P[o]): T[m]
Parameters
| Parameter | Type | Description |
|---|---|---|
var | Any[*] | The value (or collection) being dispatched |
functions | list of lambdas | Each lambda declares param: Type[multiplicity] — the first one whose type and multiplicity both match var is executed |
with (optional) | P[o] | An extra value passed as a second argument to every branch lambda |
How matching works
A branch a: SomeType[m] is selected when both conditions hold:
- Type — every element in
varis an instance ofSomeType(or a subtype of it) - Multiplicity — the size of
varsatisfies the multiplicity[m]
Branches are tested in order; the first match wins. Unmatched input throws a
PureExecutionException at runtime.
Example — annotated
$value->match([
s: String[1] | 'string: ' + $s, // branch 1: exactly one String
i: Integer[1] | 'integer: ' + $i->toString(), // branch 2: exactly one Integer
a: Any[1] | 'other' // branch 3: catch-all for any single value
])
$valueis the input —Any[*]so it can be any type or collection.- Each branch is a lambda written as
param: Type[multiplicity] | body. The lambda parameter (s,i,a) is bound to$valueinside the body. - Branches are checked top-to-bottom.
Any[1]at the end acts as a catch-all for any single value that didn't match the earlier branches.
Multiplicity matters — not just type
Unlike a simple type switch, match also checks cardinality. This means you can
have different branches for the same type at different multiplicities:
$collection->match([
s: String[0] | 'empty', // zero strings
s: String[1] | 'one: ' + $s, // exactly one string
s: String[*] | 'many: ' + $s->size()->toString() // two or more strings
])
Inheritance — most specific branch wins by ordering
Because first-match wins, place subtype branches before supertype branches:
$geo->match([
a: MA_Address[1] | 'address: ' + $a.name, // subtype first
l: MA_Location[1] | 'location: ' + $l.place, // subtype first
a: Any[1] | 'unknown geo' // supertype catch-all last
])
If Any[1] were listed first it would match everything and the subtype branches
would never be reached.
With extra parameter
The second overload passes a shared value to every branch, useful for avoiding repeated captures:
$value->match([
{i: Integer[1], suffix: String[1] | 'int_' + $suffix},
{s: String[1], suffix: String[1] | 'str_' + $suffix}
], 'result')
// If $value is 1 (Integer) → 'int_result'
// If $value is 'x' (String) → 'str_result'
6. Collections
Collection Literal Syntax
A collection literal is written with square brackets.
, is strictly a separator between elements — there is no optional trailing
comma and no empty slot syntax.
| Form | Valid? | Reason |
|---|---|---|
[] | ✅ | Empty collection |
[1] | ✅ | Single element |
[1, 2, 3] | ✅ | Comma between each adjacent pair |
[1, 2,] | ❌ | Trailing comma — nothing follows the last , |
[1, , 3] | ❌ | Double comma / empty element slot |
[, 1] | ❌ | Leading comma — nothing precedes the first , |
// Valid
let nums = [1, 2, 3];
let single = [42];
let empty = [];
// NOT valid Pure — parse errors
let bad1 = [1, 2, 3,]; // ERROR — trailing comma
let bad2 = [1, , 3]; // ERROR — empty element (double comma)
Common functions for collections
All collection functions are in meta::pure::functions::collection. The most
commonly used functions, available via arrow syntax:
| Function | Signature | Description |
|---|---|---|
filter | T[*]->filter(x|Boolean[1]): T[*] | Keep matching elements |
map | T[*]->map(x|V[1]): V[*] | Transform each element |
fold | T[*]->fold((a,b)|R, init): R | Left fold / reduce |
find | T[*]->find(x|Boolean[1]): T[0..1] | First matching element |
exists | T[*]->exists(x|Boolean[1]): Boolean[1] | Any element matches |
forAll | T[*]->forAll(x|Boolean[1]): Boolean[1] | All elements match |
size | T[*]->size(): Integer[1] | Count |
isEmpty | T[*]->isEmpty(): Boolean[1] | True if empty |
isNotEmpty | T[*]->isNotEmpty(): Boolean[1] | True if non-empty |
first | T[*]->first(): T[0..1] | First element or empty |
at | T[*]->at(i: Integer[1]): T[1] | Element at index (0-based) |
drop / take | T[*]->drop(n): T[*] | Skip / keep first n |
slice | T[*]->slice(s,e): T[*] | Sub-collection [s, e) |
sort | T[*]->sort(): T[*] | Natural sort |
reverse | T[*]->reverse(): T[*] | Reverse order |
removeDuplicates | T[*]->removeDuplicates(): T[*] | Distinct elements |
zip | T[*]->zip(V[*]): Pair<T,V>[*] | Pair two collections |
concatenate | T[*]->concatenate(T[*]): T[*] | Combine two collections |
groupBy | T[*]->groupBy(f): Map<K,List<T>> | Group into map |
toOne | T[*]->toOne(): T[1] | Assert exactly one element; throws if 0 or 2+ |
toOneMany | T[*]->toOneMany(): T[1..*] | Assert at least one element |
7. Multiplicity Casting
// Assert exactly one — throws PureExecutionException if collection ≠ 1
$collection->toOne()
$collection->toOne('Expected exactly one Person')
// Assert at least one
$collection->toOneMany()
8. Type Casting
// Cast to a specific type — throws if incompatible
$anyValue->cast(@Person)
// Safe type check
$anyValue->instanceOf(Person)
9. Instance Creation
// Create an instance with the ^ operator
let p = ^Person(firstName='Alice', lastName='Smith');
// With to-many property
let firm = ^Firm(legalName='ACME', employees=[^Person(firstName='Bob')]);
// Accessing properties
$p.firstName // 'Alice'
$p.fullName() // 'Alice Smith' (derived property)
10. Packages and Imports
Declaring a Package
Every element belongs to a package. The package is declared either inline in the
element name (Class my::pkg::Person { … }) or, less commonly, at the top of the
file with a package directive:
// Declare the package at top of file
package meta::mypackage;
Import Statements
Import statements must be at the start of the file:
// Import another package (removes need to qualify names)
import meta::pure::functions::collection::*;
import meta::mypackage::model::*;
All elements in the file that do not carry an explicit package path inherit this package.
Without an import, every type and function reference must be fully qualified:
Fully-qualified names (meta::mypackage::Person) always take precedence.
// Without import — verbose
let p: meta::mypackage::model::Person[1] = meta::mypackage::model::makePerson('Alice');
import brings an entire package into scope so the short name can be used instead.
Only wildcard imports (*) are supported — you cannot import a single name:
import meta::pure::functions::collection::*;
import meta::mypackage::model::*;
// Now short names resolve without qualification
let people = Person->getAll()->filter(p | $p.age > 18);
Scope of imports
Imports are per grammar section, per file. An import in one ###Pure section
does not affect a ###Mapping section in the same file — each section must declare
its own imports. Internally the compiler models this as an ImportGroup instance
(defined in platform/pure/grammar/m3.pure) that is created for each source file
and referenced by all elements defined within it. When the compiler resolves a short
name it walks the import groups associated with the element's source file, trying
each imported package in declaration order until a match is found.
###Pure
import meta::mypackage::model::*;
Class meta::mypackage::service::TradeService
{
// 'Product' resolves via the import above
product : Product[1];
}
###Mapping
import meta::mypackage::model::*; // must re-import — different section
Mapping meta::mypackage::mapping::TradeMapping
(
// ...
)
Common imports in the Pure standard library
| What you are using | Import |
|---|---|
Collection functions (filter, map, fold, …) | import meta::pure::functions::collection::*; |
String functions (startsWith, joinStrings, …) | import meta::pure::functions::string::*; |
| Math functions | import meta::pure::functions::math::*; |
| Date functions | import meta::pure::functions::date::*; |
| Test stereotypes | import meta::pure::profiles::*; |
11. Milestoning (Temporal Data)
Milestoning adds bitemporal tracking to classes. Apply a stereotype from the
meta::pure::profiles::temporal profile:
Class <<temporal.businesstemporal>> meta::mypackage::Product
{
name : String[1];
price: Float[1];
}
The compiler automatically adds date-range properties and rewrites property navigation to be date-aware. Three stereotypes are available:
| Stereotype | Date dimension | Generated getAll overload |
|---|---|---|
<<temporal.businesstemporal>> | Business date | Product.all(%2024-01-01) |
<<temporal.processingtemporal>> | Processing date | Product.all(%2024-01-01) |
<<temporal.bitemporal>> | Both | Product.all(%9999-12-31, %2024-01-01) |
Supplying the date: literals, variables, and today()
The date parameter to all(...) is any Date[1] expression — not just a literal.
Date literal (hardcoded):
Product.all(%2024-01-15)
->filter(p | $p.price > 100.0)
Variable (the common pattern when the date is a parameter or computed):
function meta::mypackage::getActiveProducts(asOf: StrictDate[1]): Product[*]
{
Product.all($asOf)
->filter(p | $p.price > 100.0)
}
Storing the date in a let binding works identically:
function meta::mypackage::getActiveProducts(): Product[*]
{
let asOf = %2024-01-15;
Product.all($asOf)
->filter(p | $p.price > 100.0);
}
today() — current date at runtime:
today() is available in a full Legend deployment (it is defined in
legend-engine, not in this legend-pure repository). Because today()
returns StrictDate[1] — a subtype of Date[1] — it is valid wherever a
date parameter is expected:
// All products that are active as of today
Product.all(today())
->filter(p | $p.price > 100.0)
Note:
today()is not defined inlegend-pureitself — it is provided bylegend-engine. If you are working purely within this repository's test harness, use a date literal or aStrictDate[1]parameter instead.
Querying: all, allVersions, allVersionsInRange
// All versions active at a given business date (most common)
Product.all(%2024-01-01)
// All versions active as of today (requires legend-engine's today())
Product.all(today())
// All versions across all time — no date filter
Product.allVersions()
// All versions whose effectivity overlaps a date range
Product.allVersionsInRange(%2023-01-01, %2024-01-01)
%latest is a special constant meaning "the current/open-ended record". It is
only valid as an argument to milestoned property navigation, not to all():
// Valid — %latest on a property access
order.product(%latest).name
// NOT valid — %latest is not accepted by all()
Product.all(%latest) // compile error
Date propagation through milestoned property chains
When the root class is milestoned, the date supplied to all(...) propagates
automatically through subsequent milestoned property accesses of the same
temporal dimension. You do not need to repeat the date at every step:
// Product, Classification, and Exchange are all <<temporal.businesstemporal>>
// The %2024-01-15 date from all(...) propagates to .classification and .exchange
Product.all(%2024-01-15)
->map(p | $p.classification.exchange.exchangeName)
This propagation works through filter, map, project, and similar
collection functions. Using a variable makes the intent explicit when the date
is used in multiple places:
function meta::mypackage::getExchangeNames(asOf: StrictDate[1]): String[*]
{
Product.all($asOf)
->map(p | $p.classification.exchange.exchangeName)
}
Non-milestoned root with a milestoned nested property
When the root class is not milestoned, all() takes no date argument.
However, if a property it navigates to points to a milestoned class, that
property still requires a date — nothing is propagated because there is no date
context at the root.
Class meta::mypackage::Order // NOT milestoned
{
product : meta::mypackage::Product[1];
}
Class <<temporal.businesstemporal>> meta::mypackage::Product
{
name : String[1];
}
Wrong — no date context from the non-milestoned root:
// COMPILE ERROR:
// "The property 'product' is milestoned with stereotypes: [ businesstemporal ]
// and requires date parameters: [ businessDate ]"
Order.all()
->map(o | $o.product.name)
Correct — supply the date explicitly on the milestoned property:
Order.all()
->map(o | $o.product(%2024-01-15).name)
Correct — use a variable so the date is written once:
function meta::mypackage::getOrderProductNames(asOf: StrictDate[1]): String[*]
{
Order.all()
->map(o | $o.product($asOf).name)
}
The explicit ($asOf) call is the qualified property form generated by the
milestoning compiler transformation — it is what you write when there is no
milestoning context to propagate from.
See Domain Concepts — Milestoning for the compiler implementation details.
12. Standard Library Overview
The Pure standard library is organised under meta::pure::functions:::
| Category | Package | Key functions |
|---|---|---|
| Collection | collection | filter, map, fold, find, exists, forAll, groupBy, sort, zip, removeDuplicates |
| String | string | startsWith, endsWith, contains, substring, split, joinStrings, toLower, toUpper, trim, replace, format |
| Math | math | +, -, *, /, abs, sqrt, floor, ceiling, round, mod, range |
| Date | date | today, now, year, monthNumber, dayOfMonth, dateDiff, adjust |
| Boolean | boolean | and, or, not, is, eq, equal, isEmpty, isNotEmpty |
| Lang | lang | new (^), let, if, match, cast, toOne, toOneMany, copy |
| Meta | meta | instanceOf, genericType, type, id, evaluateAndDeactivate |
| IO | io | println, print |
| Tests | tests | assertEquals, assertEq, assertTrue, assertFalse, assertEmpty, assertSize, fail |
13. Writing Tests in Pure
Pure has two distinct test mechanisms, used for different purposes.
Prefer
<<PCT.test>>for any function that should work identically on all execution platforms. Use<<test.Test>>for domain-specific logic that is tied to a single repository or runtime.
<<test.Test>> — Unit Tests
A regular unit test. Marked with the <<test.Test>> stereotype from the
meta::pure::profiles::test profile. Runs directly against a fixed runtime
(compiled or interpreted, depending on which test suite executes it).
function <<test.Test>> meta::mypackage::tests::testAddition(): Boolean[1]
{
assertEquals(5, add(2, 3));
assertEquals(0, add(-1, 1));
}
Signature: No parameters — (): Boolean[1].
Use <<test.Test>> for:
- Testing domain logic, mappings, or helper functions you have written.
- Tests that are specific to one runtime or one repository.
- Tests where the setup is entirely self-contained in Pure.
<<PCT.test>> — Platform Compatibility Tests
A PCT test verifies that a Pure standard library function behaves identically on every supported execution platform (compiled engine, interpreted engine, and any future adapters such as Alloy or in-memory engines).
function <<PCT.test>> meta::pure::functions::lang::tests::if::testSimpleIf
<Z,y>(f: Function<{Function<{->Z[y]}>[1]->Z[y]}>[1]): Boolean[1]
{
assertEq('truesentence', $f->eval(if(true, | 'truesentence', | 'falsesentence')));
assertEq('falsesentence', $f->eval(if(false, | 'truesentence', | 'falsesentence')));
}
Signature: Requires exactly one parameter — the adapter function f.
The adapter parameter — why it exists
The type of f is Function<{Function<{->Z[y]}>[1]->Z[y]}>[1] — a function that
takes a zero-argument lambda and executes it.
This indirection exists so that the same Pure test body can be executed by
different runtime adapters. The Java PCT test runner (e.g.
Test_Interpreted_EssentialFunctions_PCT) supplies a concrete f that routes
execution through its specific engine:
sequenceDiagram
participant Runner as Java PCT runner<br/>(e.g. PureTestBuilderInterpreted)
participant Pure as Pure test function<br/>(<<PCT.test>> testSimpleIf)
participant Engine as Interpreted engine
Runner->>Pure: adapter = nativeAdapter<br/>call testSimpleIf(f)
Pure->>Engine: f->eval( if(true, ...) )
Engine-->>Pure: result
Pure-->>Runner: assertion result
A different runner (e.g. the compiled engine runner) supplies a different f,
routing through the compiled engine. The Pure test body is written once and
verified on all platforms automatically.
Inside the test body, wrap every expression-under-test in $f->eval(...):
assertEq('expected', $f->eval( myFunction(args) ));
// ──────── ↑ the expression being platform-tested
<<test.Test>> vs <<PCT.test>> — comparison
<<test.Test>> | <<PCT.test>> | |
|---|---|---|
| Purpose | Test your own domain logic | Verify standard library parity across all platforms |
| Parameters | None | One adapter f parameter |
| Runs on | The runtime that executes the test suite | All registered runtimes |
| Test body | Direct assertions | Assertions wrapped in $f->eval(...) |
| Defined in | meta::pure::profiles::test profile | meta::pure::test::pct::PCT profile |
| Java runner | PureTestBuilderInterpreted / compiled test suite | Test_Interpreted_*_PCT / Test_Compiled_*_PCT |
| Use for | Your code | Pure built-in functions (if, filter, match, etc.) |
Test Setup — Shared Test Models
Because Pure has no @Before/@BeforeClass equivalent, shared test data is
handled by putting Class definitions in a dedicated _testModel.pure file
in the same package, then importing it:
// File: meta/mypackage/tests/_testModel.pure
Class meta::mypackage::tests::model::TradeTestData
{
tradeId : String[1];
amount : Float[1];
}
// File: meta/mypackage/tests/testTradeLogic.pure
import meta::mypackage::tests::model::*;
function <<test.Test>> meta::mypackage::tests::testPositiveTrade(): Boolean[1]
{
let trade = ^TradeTestData(tradeId='T001', amount=100.0);
assert($trade.amount > 0);
}
This is the established pattern throughout the Pure standard library (e.g.
platform/pure/essential/collection/_testModel.pure,
platform/pure/grammar/functions/lang/_testModel.pure).
Test Annotations Reference
meta::pure::profiles::test stereotypes and tags
import meta::pure::profiles::*;
| Stereotype | Effect |
|---|---|
<<test.Test>> | Marks a function as a runnable test |
<<test.TestCollection>> | Groups a set of related tests (informational; no execution effect) |
<<test.ToFix>> | Known-broken test; skipped by the runner |
<<test.ExcludeAlloy>> | Skipped when running in the Alloy/Legend Studio environment |
<<test.ExcludeLazy>> | Skipped in lazy-evaluation mode |
<<test.ExcludeModular>> | Skipped in modular compilation mode |
{test.excludePlatform = '…'} | Tag to skip on a named platform, e.g. 'Java compiled' or 'Java interpreted' |
meta::pure::test::pct::PCT stereotypes and tags
import meta::pure::test::pct::*;
| Annotation | Kind | Usage |
|---|---|---|
<<PCT.test>> | Stereotype | PCT test — requires one adapter parameter f; run on all platforms |
<<PCT.function>> | Stereotype | Marks a function as a standard-library function that PCT tests cover |
<<PCT.adapter>> | Stereotype | Marks an adapter function that routes execution to a specific engine |
<<PCT.platformOnly>> | Stereotype | Platform-specific function; excluded from cross-platform PCT runs |
{PCT.grammarDoc = '…'} | Tag | Canonical grammar shorthand for the function (e.g. '$first == $second') |
{PCT.grammarCharacters = '…'} | Tag | The operator characters (e.g. '=='), used by tooling |
{PCT.adapterName = '…'} | Tag | Human-readable name for a PCT adapter (e.g. 'In-Memory') |
For PCT tests, platform-specific exclusions are declared in the Java runner:
// In Test_Interpreted_EssentialFunctions_PCT.java
private static final MutableList<ExclusionSpecification> expectedFailures =
Lists.mutable.with(
one("meta::pure::functions::lang::tests::if::testMultiIf", "Not yet supported")
);
See also: Pure Type System Reference · Testing Strategy · Compiler Pipeline