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 headerParserWhat it defines
###PureM3AntlrParserClasses, functions, enumerations, associations, profiles — the full Pure language documented on this page
###MappingMappingParserMappings from domain model to store implementations — Pure, Relational, Enumeration, XStore, Operation
###RelationalRelationalParserDatabase schemas — tables, columns, joins, views, filters
###DiagramDiagramParserUML-style class diagrams (layout only, no execution semantics)

Additional grammar sections (###Connection, ###Runtime, ###Service, ###DataSpace, etc.) are defined in legend-engine on 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:

CaseWhy 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.

  1. 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:\tempC: followed by a tab) — and a \u not followed by four hex digits, as in C:\users, aborts compilation with an error carrying no source location at all.
  2. 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.

SourceValue
'''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 a doc tagged 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]], ```pure blocks) 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

TypeExample literalsNotes
String'hello', 'it\'s'Single-quoted; escape with \'. A multi-line form '''…''' also exists
Integer42, -764-bit signed
Float3.14, -0.564-bit IEEE 754
Decimal3.14dArbitrary precision (precisePrimitives module)
Booleantrue, false
Date%2024-01-15, %2024-01-15T10:30:00Legend date literal
StrictDate%2024-01-15Calendar date, no time
DateTime%2024-01-15T10:30:00+0000Date + 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):

  1. Line terminators are normalized\r\n and \r both become \n.
  2. 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.
  3. 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.
  4. Trailing whitespace is stripped from every line.
  5. 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 \t ends in the letter t as 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.

LiteralValue
'''line1line2'''"line1\nline2" — no trailing newline
'''line1line2'''"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 featureIn 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 nextNot 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 (\qq). 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.

LiteralValueWhy
'''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 separate STRING tokens — '', '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 ###Relational line 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 ###Relational at 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.

  • ###Pure sections only, in practice. ###Mapping, ###Diagram and M4 do not accept '''…''': their lexers import only M4Fragment.g4, where MultilineString sits as an unreferenced fragment.

    ###Relational and relation mappings are a different case. RelationalLexer.g4 and RelationMappingLexer.g4 import M3CoreLexer, so they do inherit the MULTILINE_STRING token, and RelationalParser inherits the taggedValue rule that accepts it — a '''…''' tagged value on a ###Relational column therefore parses. It then fails in RelationalGraphBuilder.visitTaggedValueNew, which reads ctx.STRING(0) unconditionally; the resulting NullPointerException surfaces as a PureParserException with an empty message and a misreported line number. Treat multi-line tagged values in ###Relational as 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 of Any. Use Any[*] when a parameter must accept values of any type. Return type Any[1] means the caller must cast or match before using the result as anything specific.

  • Nil — the bottom type. Nil is 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 match branch 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 any T[*] or T[0..1].

For a full explanation of why Nil is needed and why Any cannot 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:

NotationMeaning
[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::*;
AnnotationKindUsage
<<doc.deprecated>>StereotypeMarks an element as deprecated — tools and linters may warn on use
{doc.doc = '…'}TagHuman-readable documentation string attached to any element
{doc.todo = '…'}TagInline 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 ###Pure properties. In a ###Pure class, annotations precede the property name. In a ###Relational table, 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::*;
AnnotationKindUsage
<<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::*;
AnnotationKindUsage
<<temporal.businesstemporal>>StereotypeAdds a single business-date dimension; generates businessDate property and date-aware getAll overload
<<temporal.processingtemporal>>StereotypeAdds a single processing-date dimension
<<temporal.bitemporal>>StereotypeAdds both business and processing date dimensions

See Section 11 — Milestoning for full usage detail.

meta::pure::profiles::access — Visibility
import meta::pure::profiles::*;
AnnotationKindUsage
<<access.public>>StereotypeExplicitly marks an element as part of the public API
<<access.protected>>StereotypeInternal to the package hierarchy
<<access.private>>StereotypeNot intended for use outside the defining file/module
<<access.externalizable>>StereotypeCan 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 expressionsRequired form
1exprno semicolon
2expr; expr;
3expr; expr; expr;
Nevery 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 the expression rule — 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 of expression and 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 via PCT.grammarDoc on 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

ParameterTypeDescription
varAny[*]The value (or collection) being dispatched
functionslist of lambdasEach 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:

  1. Type — every element in var is an instance of SomeType (or a subtype of it)
  2. Multiplicity — the size of var satisfies 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
])
  • $value is 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 $value inside 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.

FormValid?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:

FunctionSignatureDescription
filterT[*]->filter(x|Boolean[1]): T[*]Keep matching elements
mapT[*]->map(x|V[1]): V[*]Transform each element
foldT[*]->fold((a,b)|R, init): RLeft fold / reduce
findT[*]->find(x|Boolean[1]): T[0..1]First matching element
existsT[*]->exists(x|Boolean[1]): Boolean[1]Any element matches
forAllT[*]->forAll(x|Boolean[1]): Boolean[1]All elements match
sizeT[*]->size(): Integer[1]Count
isEmptyT[*]->isEmpty(): Boolean[1]True if empty
isNotEmptyT[*]->isNotEmpty(): Boolean[1]True if non-empty
firstT[*]->first(): T[0..1]First element or empty
atT[*]->at(i: Integer[1]): T[1]Element at index (0-based)
drop / takeT[*]->drop(n): T[*]Skip / keep first n
sliceT[*]->slice(s,e): T[*]Sub-collection [s, e)
sortT[*]->sort(): T[*]Natural sort
reverseT[*]->reverse(): T[*]Reverse order
removeDuplicatesT[*]->removeDuplicates(): T[*]Distinct elements
zipT[*]->zip(V[*]): Pair<T,V>[*]Pair two collections
concatenateT[*]->concatenate(T[*]): T[*]Combine two collections
groupByT[*]->groupBy(f): Map<K,List<T>>Group into map
toOneT[*]->toOne(): T[1]Assert exactly one element; throws if 0 or 2+
toOneManyT[*]->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 usingImport
Collection functions (filter, map, fold, …)import meta::pure::functions::collection::*;
String functions (startsWith, joinStrings, …)import meta::pure::functions::string::*;
Math functionsimport meta::pure::functions::math::*;
Date functionsimport meta::pure::functions::date::*;
Test stereotypesimport 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:

StereotypeDate dimensionGenerated getAll overload
<<temporal.businesstemporal>>Business dateProduct.all(%2024-01-01)
<<temporal.processingtemporal>>Processing dateProduct.all(%2024-01-01)
<<temporal.bitemporal>>BothProduct.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 in legend-pure itself — it is provided by legend-engine. If you are working purely within this repository's test harness, use a date literal or a StrictDate[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:::

CategoryPackageKey functions
Collectioncollectionfilter, map, fold, find, exists, forAll, groupBy, sort, zip, removeDuplicates
StringstringstartsWith, endsWith, contains, substring, split, joinStrings, toLower, toUpper, trim, replace, format
Mathmath+, -, *, /, abs, sqrt, floor, ceiling, round, mod, range
Datedatetoday, now, year, monthNumber, dayOfMonth, dateDiff, adjust
Booleanbooleanand, or, not, is, eq, equal, isEmpty, isNotEmpty
Langlangnew (^), let, if, match, cast, toOne, toOneMany, copy
MetametainstanceOf, genericType, type, id, evaluateAndDeactivate
IOioprintln, print
TeststestsassertEquals, 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/>(&lt;&lt;PCT.test&gt;&gt; testSimpleIf)
    participant Engine as Interpreted engine

    Runner->>Pure: adapter = nativeAdapter<br/>call testSimpleIf(f)
    Pure->>Engine: f-&gt;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>>
PurposeTest your own domain logicVerify standard library parity across all platforms
ParametersNoneOne adapter f parameter
Runs onThe runtime that executes the test suiteAll registered runtimes
Test bodyDirect assertionsAssertions wrapped in $f->eval(...)
Defined inmeta::pure::profiles::test profilemeta::pure::test::pct::PCT profile
Java runnerPureTestBuilderInterpreted / compiled test suiteTest_Interpreted_*_PCT / Test_Compiled_*_PCT
Use forYour codePure 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::*;
StereotypeEffect
<<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::*;
AnnotationKindUsage
<<PCT.test>>StereotypePCT test — requires one adapter parameter f; run on all platforms
<<PCT.function>>StereotypeMarks a function as a standard-library function that PCT tests cover
<<PCT.adapter>>StereotypeMarks an adapter function that routes execution to a specific engine
<<PCT.platformOnly>>StereotypePlatform-specific function; excluded from cross-platform PCT runs
{PCT.grammarDoc = '…'}TagCanonical grammar shorthand for the function (e.g. '$first == $second')
{PCT.grammarCharacters = '…'}TagThe operator characters (e.g. '=='), used by tooling
{PCT.adapterName = '…'}TagHuman-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