Introduction
primate compiles one set of constants into idiomatic, typed code for Rust, TypeScript, and Python.
// constants/limits.prim
duration TIMEOUT = 30s
u32 MAX_RETRIES = 5
u64 MAX_UPLOAD = 100MiB
/// Severity, integer-backed for log filtering.
enum LogLevel: u8 {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
}
primate build reads that file and produces a typed module per target,
preserving your namespace structure. The TypeScript output:
// generated/constants/limits.ts
/** Severity, integer-backed for log filtering. */
export enum LogLevel {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
}
export const timeout = 30_000 as const; // milliseconds
export const maxRetries = 5 as const;
export const maxUpload = 104_857_600 as const;
The Rust and Python equivalents land in their idiomatic shapes —
std::time::Duration in Rust, timedelta in Python, IntEnum for
the integer-backed enum. See Getting started
for the full pipeline.
Why primate
If you ship to two or more language ecosystems, you’ve probably written
the same MAX_UPLOAD_SIZE more than once. The Node service has its own
copy, the Rust worker has another, the Python script has a third. They
drift. One ends up wrong. The bug shows up at 2am.
The usual fixes are awkward. A JSON config file gives up types and docs. A shared package only works when the languages can interop. Manually keeping things in sync works exactly until it doesn’t.
primate’s angle is to declare constants once and generate them in each target’s idioms — values bounds-checked at parse time, doc comments following the values to every callsite, real cross-namespace imports in the generated code. The DSL is type-first and declaration-only; there are no expressions, no computed values, no scope for arithmetic. The small surface is deliberate.
What you’ll find here
- Getting started — install, first
.primfile, generated output. - Language — the full
.primsyntax: declarations, types, values,use, attributes, formatting. - CLI —
primate build,primate fmt,primate lsp. - Plugins — write a generator for a target the built-ins don’t cover.
- Editors — VS Code, Zed, and Vim setup.
- Cookbook — recipes for common shapes: matrices, enums with metadata, cross-namespace organization.
- Reference — full grammar, every diagnostic code, the changelog.
Status
primate is at v0.1 — usable, with the Rust, TypeScript, and Python generators complete and an LSP server that works in real editors. Expect occasional churn as the language settles; the roadmap lists what’s under consideration and what’s explicitly out of scope.
Getting started
Zero to generated output in about five minutes. The example below uses
TypeScript; the same .prim source produces Rust and Python output by
adding another [[output]] block.
Install
cargo install primate --locked
That puts a primate binary at ~/.cargo/bin. Verify:
primate --version
Project layout
primate expects a primate.toml at the project root that points at a
directory of .prim files and lists the targets to generate.
my-app/
├── primate.toml
├── constants/
│ └── limits.prim
└── (generated output, paths defined in primate.toml)
A minimal config
# primate.toml
input = "constants"
[[output]]
generator = "typescript"
path = "web/src/generated/constants/"
Each [[output]] entry enables one target. The built-ins are rust,
typescript, and python. External plugins plug in here too — see
Writing a generator.
path is a directory for typescript and python (one file per
namespace); a single .rs file for rust (one pub mod per
namespace). See primate build for the rationale.
A first .prim file
// constants/limits.prim
/// How long the app waits before giving up on a slow request.
duration TIMEOUT = 30s
u32 MAX_RETRIES = 5
u64 MAX_UPLOAD = 100MiB
enum LogLevel: u8 {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
}
A few things to notice:
- Type-first. The type comes before the name —
duration TIMEOUT, notTIMEOUT: duration. There’s no inference; every constant is explicitly typed. - Suffixed literals.
30sis a duration,100MiBis a byte size. primate normalizes durations to nanoseconds and applies byte-size suffix multipliers at lex time, then bounds-checks the result against the declared type. - Doc comments.
///attaches to the next declaration and shows up in generated output where the target language supports docs. - One declaration per line. Newlines terminate; no semicolons.
- No
namespaceline. The namespace defaults to the file’s path relative toinput.constants/limits.primis in namespacelimits. For nested folders, dirs become::-separated segments (e.g.constants/net/limits.prim→net::limits). You only neednamespace foo::baras an explicit override; see Declarations for when that’s useful.
Generate
primate build
primate reads primate.toml, parses every .prim file under input,
and writes the generated files. You’ll see something like:
Generated: web/src/generated/constants/limits.ts
Generated: web/src/generated/constants/index.ts
What got generated
// web/src/generated/constants/limits.ts
// Generated by primate. Do not edit.
/** Severity, integer-backed for fast filtering. */
export enum LogLevel {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
}
/** How long the app waits before giving up on a slow request. */
export const timeout = 30_000 as const; // milliseconds
export const maxRetries = 5 as const;
export const maxUpload = 104_857_600 as const;
…and an index.ts re-exporting each namespace, so consumers can write
import { limits } from "./generated/constants".
You can also tune each generator through primate.toml — e.g.
options.duration = "temporal" on the TypeScript output emits
Temporal.Duration values instead of milliseconds. See
primate build for everything tunable.
Editor setup
primate ships an LSP server (primate lsp). The dev experience is
significantly better with it on:
- Diagnostics live in your buffer (parse errors, unknown types, length-mismatch on fixed arrays, …).
- Hover docs on type names, including types in other namespaces.
- Go-to-definition for enums and aliases, including across files.
- Find references across the workspace, following
useimports. - Format on save.
Setup per editor:
- VS Code — install from the Marketplace.
- Zed — install from the Zed extensions registry.
- Vim — drop the syntax/ftdetect files into your runtime path.
Next steps
- Language overview — the full set of declarations, types, and value literals.
- Cookbook — common shapes (matrices, lookup tables, platform-specific overrides).
- Plugins — write a generator for a language the built-ins don’t cover.
Language overview
A .prim file is a sequence of top-level items, separated by newlines.
There are six kinds of item:
| Item | Spelling | Notes |
|---|---|---|
| Constant | <type> <NAME> = <value> | One per line |
| Enum | enum Name { Variant, … } | Optionally integer-backed |
| Type alias | type Name = <type> | Reusable type expression |
use import | use ns::Name / use ns::{A, B} | Cross-namespace ergonomic |
namespace override | namespace foo::bar | Escape hatch (see below) |
| Comments | //, ///, //! | Line, doc, file-doc |
A real-world snippet:
/// Maximum upload size, enforced by the gateway.
u64 MAX_UPLOAD = 100MiB
/// Severity of a log line, integer-backed for fast filtering.
enum LogLevel: u8 {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
}
type Port = u32
Port HTTP_PORT = 8080
Port HTTPS_PORT = 8443
Identifiers and naming
primate enforces case conventions. The formatter doesn’t fix violations —
the parser surfaces a naming-convention diagnostic.
| Item | Convention | Example |
|---|---|---|
| Constants | SCREAMING_SNAKE_CASE | MAX_UPLOAD |
| Enums | PascalCase | LogLevel |
| Enum variants | PascalCase | Warn |
| Type aliases | PascalCase | Port |
| Namespaces | lower_snake_case | core::time |
Namespaces
Each .prim file belongs to exactly one namespace. By default the
namespace comes from the file’s path relative to the project’s input
directory:
constants/ ← input
├── limits.prim → namespace `limits`
├── time.prim → namespace `time`
└── net/
└── ports.prim → namespace `net::ports`
This is the recommended way to organize. Don’t write namespace foo
at the top of every file — let the directory layout do it. The
explicit form is an escape hatch when you want a file’s contents to
live somewhere other than the path implies.
// constants/legacy/old_metrics.prim
namespace metrics::v1
// — overrides the path-derived `legacy::old_metrics`.
Files sharing a namespace share a flat scope: enums and aliases declared
in one are visible in all, by bare name. Cross-namespace references go
through fully qualified paths or use statements.
Resolution rules
When you write a bare type name like LogLevel, primate looks for it in:
- The current file.
- Sibling files in the same namespace.
- Names brought into scope by
usestatements. - (Otherwise:
unknown-typeerror. Use a fully qualified path.)
A few syntactic rules
- No expressions in values. Every constant is a literal of its
declared type. No
60 * 60, noBASE * 2. - No statement-terminating semicolons. Newlines terminate.
- Inside
(),[],{},<>newlines are insignificant — handy for wrapping long type expressions or value literals. - One comment style per role.
//line,///doc,//!file doc. No block comments.
Some features you might expect (expressions, newtype nominal
types, string interpolation) aren’t in the language. See the
roadmap for what’s under consideration.
Where to read next
- Declarations —
const,enum,type,namespace. - Types — primitives, container constructors, fixed arrays.
- Values — literals, including the magic trailing comma.
usestatements.- Attributes —
@inlineand the plugin extension story. - Formatting — what
primate fmtdoes.
Declarations
There are four declaration kinds in a .prim file: const, enum,
type (alias), and namespace (one-liner override). This page covers
each.
Constants
<type> <NAME> = <literal>
The type comes first; the name is SCREAMING_SNAKE_CASE; the value is
a literal of the declared type. One per line; no semicolons.
duration TIMEOUT = 30s
u32 MAX_RETRIES = 5
u64 MAX_UPLOAD = 100MiB
string API_VERSION = "v3"
bool STRICT_MODE = true
The type is mandatory — there’s no inference at any level. This is a deliberate choice; see Overview for the rationale.
Doc comments attach to the next declaration:
/// How long the gateway waits before bailing on a slow upstream.
///
/// Bumping this value also requires bumping the load-balancer's
/// idle-timeout — they must stay aligned.
duration UPSTREAM_TIMEOUT = 30s
/// lines accumulate until the declaration; one blank line detaches
the doc block (it becomes a standalone // comment in the formatter
output).
Alignment within groups
Consecutive declarations with no blank line between them form a group.
The formatter aligns the type, name, and = columns across the group:
duration TIMEOUT = 30s
u32 MAX_RETRIES = 5
u64 MAX_UPLOAD = 100MiB
A blank line breaks the group; doc comments don’t.
Enums
enum Name { Variant, … }
By default, variants are string-tagged — they serialize as their PascalCase name in generators that distinguish.
/// Operation status.
enum Status {
Pending,
Active,
Done,
}
For an integer-backed enum, add : <int-type> after the name:
enum LogLevel: u8 {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
}
- Backing type must be an integer primitive (
i8/i16/i32/i64/u8/u16/u32/u64). - Variants without an explicit value get auto-assigned
0, 1, 2, …. - Enum bodies follow the same group-alignment rules as constants — the
formatter aligns the
=column across variants.
Trailing commas are accepted on the last variant; the formatter always emits one for multi-line enum bodies.
Type aliases
type Port = u32
type ServiceConfig = map<string, Port>
type Color = tuple<u8, u8, u8>
Aliases are real first-class types. They’re emitted as standalone type declarations in the generated code so the alias’s name shows up in hover docs, IDE tooltips, and so on.
To suppress emission and inline the underlying type at use sites, mark
with @inline:
@inline
type Bytes32 = u64
Aliases participate in cross-file resolution exactly like enums:
sibling files in the same namespace see them by bare name; other
namespaces use a fully qualified path or a use import.
Alias chains (type A = B, type B = C) are resolved transitively at
IR time, so generated code never contains a chain.
namespace
Each file belongs to one namespace. The default comes from the file’s path; the override looks like:
// constants/legacy/old_metrics.prim
namespace metrics::v1
Rules:
- One per file. Zero allowed (then the path-derived default is used).
- If present, must be the first non-comment item.
- Single line, no braces,
::as separator.
You usually shouldn’t need this. The path-derived default is the
recommended way to organize. Reach for an explicit namespace only
when:
- A file’s path doesn’t reflect where its declarations should live (the legacy-rename case above).
- Two files at different paths need to share a namespace and you don’t want to move them.
Order in the file
If a namespace override is present, it must come first. After that,
order is free — declarations don’t have to come before they’re used,
because resolution happens after the whole file (and project) is
parsed. The formatter doesn’t reorder declarations.
use imports are an exception: they go at the top of the file, after
the optional namespace line, before any other declaration. The
formatter sorts and merges them; see use statements.
Types
primate has a small, fixed set of built-in types. Users compose them
into structures with type constructors (array, tuple, etc.) and
name them with type aliases.
Primitives
Numbers
| Type | Use |
|---|---|
i32 i64 | Signed integers. |
u32 u64 | Unsigned integers. |
i8 i16 | Only as enum backing types in v1. Widened to i32 in IR. |
u8 u16 | Only as enum backing types in v1. Widened to u32 in IR. |
f32 f64 | Floats. |
The “only as enum backing” restriction reflects how rare 8/16-bit
constants are in cross-language code; widening to i32/u32 keeps
generators simple. (When fixed-size arrays of u8 are useful — e.g.
RGB triples — they’re value types, not constants in the bit-twiddling
sense; see fixed-size arrays.)
A note on type fidelity
primate’s numeric types are richer than what most targets natively
distinguish. Each generator preserves what the target supports and
widens the rest — i32 and u32 survive faithfully into Rust, but
land as number in TypeScript and int in Python. The full mapping:
| primate | Rust | TypeScript | Python |
|---|---|---|---|
i8–i64 | i8 / i16 / i32 / i64 | number | int |
u8–u64 | u8 / u16 / u32 / u64 | number (or bigint for u64 opt-in) | int |
f32 / f64 | f32 / f64 | number | float |
duration | std::time::Duration | number (ms) or Temporal.Duration | timedelta |
string | &'static str | string | str |
regex | &'static str | string | str |
url | &'static str | string | str |
This widening only affects the type annotation in generated code;
the values themselves are bounds-checked against the declared primate
type at parse time, not at generation time. u32 X = 5GiB is an
out-of-range error before any generator sees it, even though the
TypeScript output would have been number.
Boolean
bool ENABLED = true
bool DEBUG = false
string
UTF-8. Regular and raw forms:
string GREETING = "Hello, world!"
string LITERAL = "Has \"quotes\" and \\ backslashes."
string RAW = r"no\\escapes\here"
string RAW_QUOTE = r#"with "quotes" inside"#
duration
A length of time. primate normalizes durations to nanoseconds internally.
duration TIMEOUT = 30s
duration RETRY_WAIT = 500ms
duration TICK = 16ms
duration RUN_FOR = 2h
duration LEASE = 1d
duration PRECISION = 1ns
Suffixes: ns, us (or µs), ms, s, min, h, d. Generators
emit per target — std::time::Duration in Rust, milliseconds-as-number
or Temporal.Duration in TypeScript (configurable), timedelta in
Python.
Byte sizes are integer literals
Byte sizes aren’t a separate type — they’re sugar on integer literals.
A literal like 100MiB is just 104_857_600 of whatever integer type
you declared:
u64 MAX_UPLOAD = 100MiB
u32 BLOCK_SIZE = 4KiB
u32 PACKET_SIZE = 1500B
Suffixes: B, KB/MB/GB/TB (decimal, ×1000), and
KiB/MiB/GiB/TiB (binary, ×1024). Allowed on i32, i64,
u32, and u64 literals.
primate bounds-checks the suffix-multiplied result against the
declared type. u32 X = 5GiB is an out-of-range error because
5 GiB exceeds u32::MAX.
regex
A regex pattern stored as a string. Validated at parse time.
regex FILENAME = "(?i)^[a-z][a-z0-9_]*\\.txt$"
Regex values are written as ordinary strings (not /.../ literals).
This keeps / free for a future division operator.
url
A URL string, validated at parse time.
url HOMEPAGE = "https://example.com"
Type constructors
array<T> — variable-length array
array<u32> QUEUE_DEPTHS = [4, 8, 16, 32]
array<string> ALLOWED_HOSTS = ["api.example.com", "cdn.example.com"]
Sugar: T[] is equivalent to array<T>. The formatter prefers the
sugared form.
array<T, N> — fixed-size array
type Pixel = array<u32, 3> // RGB triple
type Matrix = array<Pixel, 3> // 3×3 grid
Length-mismatch is a hard error: array<u32, 3> X = [1, 2] produces a
length-mismatch diagnostic.
In Rust this generates [T; N]; in TypeScript and Python a
homogeneous tuple of N elements. See the
matrices cookbook for a worked example.
optional<T>
optional<duration> RETRY_AFTER = 30s
optional<duration> NEVER = none
Sugar: T?. Values are either a regular T literal or the keyword
none.
map<K, V>
map<string, u32> SERVICE_PORTS = {
"http": 80,
"https": 443,
"ssh": 22,
}
Map keys can be strings, identifiers, or integers; the value type is arbitrary. Trailing comma triggers multi-line formatting (see Values).
tuple<A, B, …>
type RetrySchedule = tuple<u32, duration, duration>
RetrySchedule DEFAULT = [3, 100ms, 30s]
Heterogeneous, fixed-arity. Tuple values use square brackets — see Values for the rationale.
User-defined types
enum and type declarations introduce types you can use anywhere a
primitive type can go. type is structural: type Port = u32 and
u32 are interchangeable.
Enums and aliases live in their declaring file’s namespace.
Cross-namespace references are by qualified path or via use:
core::types::LogLevel DEFAULT_LEVEL = Info
Multi-line type expressions
Inside <>, newlines are insignificant. Long type expressions can wrap:
type ServiceConfig = map<
string,
tuple<duration, u64, optional<url>, regex>,
>
Trailing commas are accepted on the type side but don’t trigger multi-line formatting — type expressions tend to be short enough that the column budget alone suffices.
Values
primate value literals are deliberately compact and unambiguous. There are no expressions; every constant is a literal of its declared type.
Numeric literals
Integers can be decimal, hex, binary, or octal, with _ as a digit
separator:
i32 SMALL = 8
i32 BIG = 1_000_000
i32 NEGATIVE = -5
i32 HEX = 0xFF
i32 BINARY = 0b1010
i32 OCTAL = 0o755
Floats:
f64 PI = 3.141_592
f64 SCIENT = 1.5e10
f64 NEGATIVE = -0.5
Hex, binary, and octal literals do not accept unit suffixes (the
30s form). Unit suffixes only apply to decimal literals.
Booleans
bool ON = true
bool OFF = false
Strings
Regular strings allow standard escapes (\n, \r, \t, \0, \\, \"):
string GREETING = "Hello, world!"
string PATH = "C:\\Users\\val"
Raw strings have no escapes and can include unescaped quotes by adding
#s:
string PATTERN = r"raw\nstring"
string SQL = r#"SELECT * FROM users WHERE name = "alice""#
duration literals
duration FAST = 50ms
duration SHORT = 5s
duration MED = 5min
duration LONG = 2h
duration LEASE = 1d
duration BACKUP = 1w
duration TINY = 1us
duration TINIER = 100ns
Suffixes: ns, us, ms, s, min, h, d, w. (m is also
accepted as an alias for min.)
Negative durations are allowed via -:
duration BACKDATED = -1d
Byte-size literals
Byte-size suffixes are sugar on integer literals (no separate type):
u32 SMALL = 512B
u32 PACKET = 1500B
u64 UPLOAD = 100MiB
u64 DISK = 1TB
Suffixes: B, KB/MB/GB/TB (decimal, ×1000), and
KiB/MiB/GiB/TiB (binary, ×1024). Allowed on i32, i64,
u32, u64. The suffix-multiplied result is bounds-checked against
the declared type — u32 X = 5GiB is out-of-range.
Percentage literals
% divides by 100 and is allowed on any float literal (integer or
decimal) assigned to an f32/f64:
f64 ROLLOUT = 5% // 0.05
f64 OPACITY = 12.5% // 0.125
f64 SAMPLE_RATE = 100% // 1.0
This is purely sugar — the generated value is a plain float in the target language; the percentage notation only exists at the source level for readability.
none
For any optional<T>, none represents “absent”:
optional<duration> RETRY_AFTER = none
optional<string> FALLBACK = "v1"
Array and tuple literals — […]
Both arrays (homogeneous) and tuples (heterogeneous) use square brackets. The parser produces a single shape; the lower pass picks array vs tuple based on the declared LHS type.
type V3 = array<u32, 3>
V3 RGB_RED = [255, 0, 0]
type RetrySchedule = tuple<u32, duration>
RetrySchedule DEFAULT = [3, 100ms]
Why […] for both? Visually consistent: ordered collections all use
[]. Matches TypeScript’s tuple syntax.
Map literals — {…}
map<string, u32> PORTS = {
"http": 80,
"https": 443,
}
Map keys can be strings, bare identifiers (treated as strings), or integers.
Magic trailing comma
A trailing comma on the last element of a collection literal is a signal to the formatter: keep this multi-line, even if it would fit on one line.
type Mat3 = array<array<u32, 3>, 3>
// Compact: fits on one line, formatter keeps it inline.
Mat3 SMALL = [[1, 0], [0, 1]]
// Trailing comma → formatter keeps it expanded as written.
Mat3 IDENTITY = [
[1, 0, 0],
[0, 1, 0],
[0, 0, 1],
]
This is the rule Prettier popularized: the user opts into multi-line layout by typing one extra character. Round-trip is stable: the formatter always emits a trailing comma when wrapping multi-line.
The rule applies to value-side […] and {…} literals. Type-side
generic arguments (tuple<A, B,>) accept a trailing comma but don’t
trigger multi-line formatting — types are usually short enough that
the column budget alone suffices.
Enum-variant values
When the LHS type is an enum, the value is a variant:
enum LogLevel: u8 {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
}
LogLevel DEFAULT_LEVEL = Info
LogLevel STRICT_LEVEL = LogLevel::Warn
Both forms are accepted: bare (when the enum is in scope) or qualified.
Cross-namespace references use the full path: core::types::LogLevel::Info.
Type-checking
primate checks every value against its declared type at lower time. Common diagnostics you’ll hit:
type-mismatch—u32 X = "foo"(string for an integer)length-mismatch—array<u32, 3> X = [1, 2](wrong arity)invalid-enum-variant—LogLevel L = Bogus(variant not in enum)
See Diagnostics for the full list.
use statements
use brings a name from another namespace into the current file’s
scope, so you can reference it by bare name instead of by fully
qualified path.
Two forms
Single:
use net::limits::Port
Port HTTP_PORT = 8080
Brace group:
use net::limits::{Port, IP, CIDR}
Port HTTP_PORT = 8080
IP FALLBACK = "10.0.0.1"
Both forms are equivalent for resolution; brace is just shorthand for
multiple use lines from the same path.
Placement
use statements appear after the optional namespace line and before
any other declarations:
namespace api::v2 // optional override
use net::limits::{Port, IP}
use core::types::LogLevel
Port HTTP_PORT = 8080
LogLevel DEFAULT_LEVEL = Info
Out-of-order use statements (e.g. interleaved with constants) are a
parse error.
Resolution
When you write a bare type name like Port in a file with
use net::limits::Port, primate resolves it as if you’d written
net::limits::Port. The resolution order in a file is:
- The current file.
- Sibling files in the same namespace.
- Names brought into scope by
usestatements. - (Otherwise:
unknown-typeerror. Use a fully qualified path.)
Diagnostics
| Code | Triggered when |
|---|---|
unresolved-import | use a::b::C where a::b::C doesn’t exist. |
import-collision | A use brings in a name that collides with a same-namespace declaration, or with another use. |
Formatter behavior
primate fmt normalizes the use block at the top of the file. The
rules:
- A single-item brace group collapses:
use a::b::{X}→use a::b::X. - Multiple
usestatements with the same path merge:use a::b::X+use a::b::{Y, Z}→use a::b::{X, Y, Z}. - Top-level
uselines sort lexicographically by path. - Items inside a brace group sort lexicographically.
Example:
// Before
use core::types::LogLevel
use net::limits::{Port}
use net::limits::{IP, CIDR}
use core::types::Status
// After `primate fmt`
use core::types::{LogLevel, Status}
use net::limits::{CIDR, IP, Port}
A /// or // comment immediately above a use line pins it: sort
and merge happen within contiguous comment-free runs only. This avoids
silently moving a comment away from the line it annotates.
Effect on generated code
use is a source-only ergonomic. Generators don’t see imports — they
work off fully resolved IR types. So use net::Port followed by
Port HTTP_PORT = 8080 generates the same output as
net::Port HTTP_PORT = 8080.
Attributes
Attributes are @name (or @name(arg, arg, …)) annotations that
attach to the declaration immediately following them. They control
how a declaration is treated by primate or by generators.
@inline
type Bytes32 = u64
Multiple attributes on one declaration stack on separate lines:
@inline
@some_plugin_attr
type Color = u32
Argument syntax: literals ("strings", integers, booleans) or bare
identifiers, comma-separated, in (). The @name(args) form is
fully reserved at parse time, so plugins can introduce custom
attributes without forking the parser.
@inline
Applies to type aliases. Suppresses the alias declaration in generated output and inlines the underlying type at every use site.
@inline
type Bytes32 = u64
Bytes32 BIG_HASH = 100KiB
In generated code, BIG_HASH is typed as the underlying u64-style
representation in each target — no Bytes32 alias appears in the
output.
Use @inline for very lightweight aliases where the name carries
no generator-side meaning. For semantic aliases that benefit from
showing up as a named type (e.g. Port), leave @inline off.
Custom attributes
Attribute names that don’t match a built-in emit a warning at parse time, not an error. Plugins receive every attribute on every declaration in their JSON request and decide how to interpret unknown names.
@cdn(url = "https://cdn.example.com")
url ASSET_BASE = "https://example.com/assets"
@cdn isn’t built-in; primate emits unknown-attribute (warning) and
passes cdn(url="https://cdn.example.com") through to any generator
that wants to act on it.
See Writing a generator for how to read attribute data inside a plugin.
Formatting
primate ships a normative formatter: there’s one canonical form for
any source. primate fmt rewrites a file to that form; the LSP can
format on save.
Run it
primate fmt path/to/file.prim # format one file in place
primate fmt # format everything under `input`
primate fmt --check # exit non-zero if any file would change
In supported editors, format document (or format-on-save, if
configured) runs primate fmt on the buffer.
Rules at a glance
- 4 spaces. No tabs anywhere.
- One declaration per line. No semicolons.
- Single space around
=and after:. - Sugared types preferred.
T[]overarray<T>,T?overoptional<T>. - Trailing comma in multi-line collections (arrays, maps, tuple values, enum bodies).
- No trailing comma in single-line collections.
- Magic trailing comma in value literals keeps multi-line layout — see Values.
Alignment within groups
Consecutive declarations with no blank line between them form a group.
Within a group, the formatter aligns the type, name, and = columns:
duration TIMEOUT = 30s
u32 MAX_RETRIES = 5
u64 MAX_UPLOAD = 100MiB
A /// doc block is part of the declaration that follows it and does
not break the group. A blank line breaks the group. A standalone //
comment on its own line breaks the group.
Enum bodies follow the same rule — variants align, and = aligns when
any variant has an explicit value:
enum LogLevel: u8 {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
}
Long-line wrapping
When a logical line would exceed column 100, the formatter wraps at the shallowest delimiter that lets the line fit:
// Before: 134 columns.
type ServiceConfig = map<string, tuple<duration, u64, optional<url>, regex, string>>
// After.
type ServiceConfig = map<
string,
tuple<duration, u64, optional<url>, regex, string>,
>
When a line is wrapped:
- One item per line.
- Trailing comma on the last item.
- Inner contents indented +4 from the line that opened the delimiter.
The wrapper recurses if the inner line is also over budget.
The 100-column budget is fixed.
use block normalization
The block of use statements at the top of a file is normalized:
- Single-item brace groups collapse:
use a::b::{X}→use a::b::X. - Same-path
uselines merge:use a::b::X+use a::b::Y→use a::b::{X, Y}. - Top-level
uselines sort by path. - Items inside a brace group sort lexicographically.
A leading comment on a use line pins that line — sort/merge
happens within contiguous comment-free runs.
See use statements for examples.
What the formatter doesn’t do
- It doesn’t reorder declarations (only
useblocks are sorted). - It doesn’t fix naming-convention violations — the parser flags those
as
naming-conventiondiagnostics; you fix them by hand. - It doesn’t rewrite literals (
100MiBstays100MiB; not normalized to1024 * 100). - It doesn’t desugar
T?→optional<T>(the sugar is preferred).
Config
primate fmt has no command-line knobs in v1. Output is fully determined
by the formatter rules above. This is intentional: one canonical form,
no .editorconfig-style negotiation.
primate build
Reads primate.toml, parses every .prim file under input, and
writes generated files per configured target.
primate build
Run from the directory containing primate.toml (or pass --config path/to/primate.toml).
Config file
# primate.toml
input = "constants"
[[output]]
generator = "typescript"
path = "web/src/generated/constants/" # directory
[[output]]
generator = "rust"
path = "src/generated/constants.rs" # file
[[output]]
generator = "python"
path = "scripts/generated/constants/" # directory
Top-level keys
input(required) — path to the directory of.primfiles, relative toprimate.toml. primate walks this dir recursively.sourcemap(optional, defaultprimate.sourcemap.json) — where the IDE sourcemap is written. The sourcemap lets the LSP jump between source.primlines and generated lines.
[[output]] entries
Each entry enables one target. generator selects a built-in
(rust, typescript, python) or a plugin (see
Plugins).
Common keys:
generator(required) — generator name.path(required) — where primate writes output, relative toprimate.toml. TypeScript and Python expect a directory; Rust expects a file.options.<key>— generator-specific options (see below).
Built-in generators
typescript
path is a directory. primate emits one .ts file per source-file
namespace plus an index.ts that re-exports each namespace. Cross-
namespace type references become real ES import statements at the
top of each file.
[[output]]
generator = "typescript"
path = "web/src/generated/constants/"
options.naming = "camelCase" # or "SCREAMING_SNAKE_CASE"
options.duration = "number" # or "temporal" — emits Temporal.Duration values
options.u64 = "number" # or "bigint"
options.enumStyle = "literal" # or "const", "enum"
Defaults are conservative: camelCase constants, number durations
in milliseconds, number for u64, string-literal enums.
TypeScript doesn’t distinguish between i32, u32, i64, f64,
etc. — they all land as number. Bounds checking happens at primate’s
parse time against the declared type. See
type fidelity.
rust
path is a file. primate emits one .rs file with a pub mod <ns>
block per namespace. Cross-namespace references become
super::<other>::X.
[[output]]
generator = "rust"
path = "src/generated/constants.rs"
options.visibility = "pub" # or "pub(crate)", "pub(super)", ""
Rust is the highest-fidelity target — i32/u32/i64/u64/f32/
f64 all survive as native types. See
type fidelity.
python
path is a directory. primate emits one .py file per source-file
namespace plus an __init__.py that re-exports each namespace as a
submodule. Cross-namespace references become relative imports
(from .other import X).
[[output]]
generator = "python"
path = "scripts/generated/constants/"
options.typing = "runtime" # or "stub" (emits a .pyi-style file)
Durations become timedelta. Integer-backed enums become IntEnum
subclasses; string-tagged enums become (str, Enum) subclasses.
Python doesn’t distinguish between integer widths — i32, u32,
i64, and u64 all land as int. Bounds checking happens at
primate’s parse time against the declared type. See
type fidelity.
Why a directory for TS and Python, but a file for Rust?
Modules in TypeScript and Python are files: cross-module references
require an import from another file. To preserve module structure,
primate generates one file per namespace.
Rust expresses modules in-file with pub mod <name> { ... }, so a
single .rs file already preserves namespace boundaries. Generating
a directory would be needless ceremony.
Behavior
primate build:
- Walks
inputrecursively, picking up every*.primfile. - Parses each file; reports diagnostics with file/line/column.
- Lowers to IR (resolves cross-file types,
useimports, alias chains). - Runs each enabled generator. Generators receive a JSON request on stdin and emit JSON on stdout (built-ins and plugins use the same protocol; see Plugins).
- Writes the generated files plus a sourcemap.
If any diagnostic is an error, primate exits non-zero and writes nothing.
Exit codes
0— all generators succeeded.1— parse, lower, or generation error. Diagnostics on stderr.2— config or filesystem error (missingprimate.toml, unwritable output path, etc.).
CI usage
Most projects want to fail the build if generated files are stale:
primate fmt --check # all .prim files are formatted
primate build # regenerate
git diff --exit-code path/to/output # generated files match what's checked in
Or use a build target that ensures primate build runs before tests.
See also
primate fmt— formatter.primate lsp— language server (used by editors).- Plugins — bring your own generator.
primate fmt
Rewrites .prim files to canonical form. There’s exactly one canonical
form, defined by Formatting. primate fmt
has no formatter knobs.
Usage
primate fmt path/to/file.prim # format one file in place
primate fmt path/to/dir # format every .prim file under dir
primate fmt # format everything under `input`
primate fmt reads primate.toml to find the input directory.
Flags
--check— don’t write; exit non-zero if any file would change. Use this in CI.--stdin— read source from stdin and write the formatted output to stdout. Used by editor “format buffer” integrations.
Examples
Format on save in CI-friendly form:
# fail the job if anything's unformatted
primate fmt --check
One-off pipe:
cat draft.prim | primate fmt --stdin > clean.prim
Behavior
primate fmt parses each file. If parsing produces errors, the
formatter refuses to rewrite and surfaces the diagnostics — it won’t
rewrite a file it can’t parse cleanly.
The generated output is byte-for-byte deterministic given the input, so the formatter is idempotent: running it twice changes nothing.
See also
- Formatting — the rules.
primate build— generates the output files.primate lsp— the LSP exposes formatting viatextDocument/formatting, which uses the same logic.
primate lsp
Starts the primate language server, speaking LSP over stdio. Editor extensions invoke this — you don’t usually run it directly.
primate lsp # search for primate.toml upward from cwd
primate lsp --config path # use this config explicitly
What the server does
| Capability | Behavior |
|---|---|
| Diagnostics | Parse + lower the workspace; surface errors per file as you type. |
| Hover | Hovering a type name shows its kind, namespace, doc comment, and (for enums) variants. |
| Go-to-definition | Click a type or qualified path → jumps to its declaration, including across files. |
| Find references | Finds every place a type is used in the workspace, following use imports. |
| Completion | Type names and keywords in type position; literals in value position. |
| Formatting | textDocument/formatting runs the same logic as primate fmt. |
| Sourcemap navigation | Custom requests for jumping between source .prim lines and generated lines. |
How it parses your workspace
The server walks each workspace folder for .prim files (just like
primate build). It maintains a content-hash cache per file so that
unchanged files aren’t re-lexed/re-parsed on every keystroke. The
“lower” pass — cross-file type resolution, use resolution — runs on
every request, but it’s the cheap part of the pipeline.
Result: typing in one file only re-parses that file, plus the constant-time lower over the cached ASTs of every other file.
Per-file vs workspace diagnostics
primate lsp parses the whole workspace to produce a file’s
diagnostics. That’s required for cross-file resolution: when you
write core::types::LogLevel in app.prim, the server only knows it’s
valid by parsing core/types.prim too.
Diagnostics for the current file are filtered to those whose source location lives in that file. If you’ve broken a sibling file, you’ll see its diagnostics on the next time you open it (not eagerly on every buffer’s change).
Logging
The server logs to stderr with a [LSP] prefix:
[LSP] Starting primate LSP server...
[LSP] Workspace folders: ["/Users/me/proj"]
[LSP] DidOpenTextDocument: file:///Users/me/proj/constants/limits.prim
[LSP] Completion request: ...
Editors capture this; check your editor’s LSP log to inspect activity.
In Zed, that’s Zed.log:
tail -f ~/Library/Logs/Zed/Zed.log
See also
- Editors: Zed, VS Code, Vim.
primate fmt— same formatting logic.primate build— same parser/lower pipeline.
Plugin protocol
primate’s built-in generators (Rust, TypeScript, Python) are part of the binary, but they implement the same protocol every plugin does: JSON request on stdin, JSON response on stdout, errors on stderr.
You can plug in any executable on PATH (or a script path) as a
generator by referencing it from primate.toml.
Wire format
primate spawns the plugin and writes a single JSON request to its stdin, then closes stdin. The plugin reads stdin, generates whatever it generates, and writes a single JSON response to stdout.
Request
{
"version": 1,
"outputPath": "scripts/generated/constants.lua",
"options": { "naming": "camelCase" },
"modules": [
{
"namespace": "limits",
"sourceFile": "constants/limits.prim",
"doc": null,
"constants": [
{
"name": "TIMEOUT",
"doc": "How long the gateway waits before bailing.",
"type": { "kind": "duration" },
"value": { "duration": { "nanoseconds": 30000000000 } },
"source": { "file": "constants/limits.prim", "line": 4, "column": 10 }
}
]
}
],
"enums": [...],
"aliases": [...]
}
Top-level fields:
version— protocol version. Currently1.outputPath— theoutputvalue from the relevant[generators.*]section ofprimate.toml, relative to project root.options— every other key from that[generators.*]section, passed through verbatim as a JSON object.modules— one per namespace; carries that namespace’s constants.enums,aliases— top-level enum and type-alias definitions.
Response
{
"files": [
{
"path": "scripts/generated/constants.lua",
"content": "-- generated by primate\n...",
"mappings": [
{ "symbol": "limits::TIMEOUT", "line": 7, "column": 1 }
]
}
],
"errors": []
}
files— one or more output files. The plugin can emit multiple files; primate writes each. (Most plugins emit just one.)mappings— symbol → generated-file location, used to populate the sourcemap so the LSP can jump from.primto generated code.errors— non-empty array signals failure. Each entry hasmessageand an optionalsource(file/line/column).
Type and value JSON shapes
Type expressions are tagged unions:
{ "kind": "u32" }
{ "kind": "string" }
{ "kind": "array", "element": { "kind": "u32" } }
{ "kind": "fixed_array", "element": { "kind": "u32" }, "length": 3 }
{ "kind": "map", "key": { "kind": "string" }, "value": { "kind": "u32" } }
{ "kind": "tuple", "elements": [ ... ] }
{ "kind": "optional", "inner": { "kind": "string" } }
{ "kind": "enum", "name": "LogLevel", "namespace": "logging" }
{ "kind": "alias", "name": "Port", "namespace": "network" }
Values are untagged (each shape has a uniquely-typed payload):
8 // integer
3.14 // float
true // bool
"hello" // string
{ "nanoseconds": 30000000000 } // duration
[1, 2, 3] // array / fixed-array / tuple
{ "key": value, ... } // map (untagged JSON object)
null // optional, none case
{ "variant": "Info", "value": 1 } // enum
Plugins typically deserialize into language-native types and re-serialize to the target syntax.
Wiring up a plugin
In primate.toml:
[[output]]
generator = "lua"
path = "scripts/generated/constants.lua"
command = "/usr/local/bin/primate-lua" # absolute or on $PATH
options.naming = "camelCase"
Or, if your plugin is a script in the project:
[[output]]
generator = "lua"
path = "scripts/generated/constants.lua"
command = ["python", "scripts/primate_lua.py"]
path can be a single file or a directory — that’s up to your
plugin. The plugin decides how many files to emit and what their
relative paths are; it’s purely a convention between you and your
generator.
primate runs the command with stdin/stdout/stderr piped, sends the request, waits for the response, and writes whatever the response says.
See also
- Writing a generator — worked example.
primate build— invokes plugins.
Writing a generator
This page walks you through building a plugin generator in Python that emits Lua. The same pattern works in any language that can read JSON on stdin and write JSON on stdout.
The full source of this example is short — about 60 lines.
Goal
Given:
// constants/limits.prim
duration TIMEOUT = 30s
u32 MAX_RETRIES = 5
Emit:
-- generated by primate-lua
local M = {}
M.TIMEOUT = 30 -- seconds
M.MAX_RETRIES = 5
return M
Step 1: read stdin, write stdout
#!/usr/bin/env python3
# scripts/primate_lua.py
import json
import sys
req = json.load(sys.stdin)
out_lines = ["-- generated by primate-lua", "local M = {}"]
for module in req["modules"]:
for c in module["constants"]:
name = c["name"]
v = c["value"]
if "nanoseconds" in v:
secs = v["nanoseconds"] / 1_000_000_000
out_lines.append(f"M.{name} = {secs} -- seconds")
else:
out_lines.append(f"M.{name} = {json.dumps(v)}")
out_lines.append("return M")
resp = {
"files": [{
"path": req["outputPath"],
"content": "\n".join(out_lines) + "\n",
"mappings": [],
}],
"errors": [],
}
json.dump(resp, sys.stdout)
Step 2: wire it into primate.toml
[generators.lua]
output = "scripts/generated/constants.lua"
command = ["python3", "scripts/primate_lua.py"]
Step 3: run it
primate build
# generated scripts/generated/constants.lua
What just happened
primate parsed .prim files, built the IR, then for the [generators.lua]
target it spawned python3 scripts/primate_lua.py. It piped a JSON
request to the plugin’s stdin (with all modules, enums, and aliases),
read the JSON response from stdout, and wrote each file in files to
disk.
Reading types
The example only handles primitive values for brevity. A real generator needs to recognize the type tags:
def render_type(t):
kind = t["kind"]
if kind in ("i32", "i64", "u32", "u64", "f32", "f64"):
return "number"
if kind == "string":
return "string"
if kind == "duration":
return "number" # seconds
if kind == "bool":
return "boolean"
if kind == "array":
return f"{render_type(t['element'])}[]"
if kind == "fixed_array":
return f"{render_type(t['element'])}[{t['length']}]"
if kind == "map":
return f"map<{render_type(t['key'])}, {render_type(t['value'])}>"
if kind == "enum":
return t["name"]
if kind == "alias":
return t["name"]
raise ValueError(f"unknown type kind: {kind}")
Generating sourcemaps
If you populate mappings in your response, the LSP can jump between
.prim source lines and your generated lines:
mappings.append({
"symbol": f"{module['namespace']}::{c['name']}",
"line": current_line_number,
"column": 1,
})
symbol is the fully-qualified constant name; line is 1-based, column
is 1-based. Keeping these accurate makes go-to-definition from generated
code into .prim work.
Returning errors
resp = {
"files": [],
"errors": [{
"message": "could not represent f128 in Lua",
"source": c["source"], # passes through file/line/column
}],
}
primate surfaces these alongside its own diagnostics; a non-empty
errors array fails the build.
Built-in generators are plugins
The Rust, TypeScript, and Python generators are linked into the
primate binary, but they implement exactly this protocol — the only
difference is that they don’t fork a subprocess. Reading their
implementations (src/generators/) is the easiest reference for what
“correct” output looks like for each shape.
See also
- Protocol — complete JSON schema reference.
Zed
primate ships a Zed extension that wires up:
- a tree-sitter grammar for syntax highlighting,
- a language server pointing at the
primate lspbinary.
Source lives at
editors/zed/
in the project repo.
Install
-
Install the
primatebinary so the extension can shell out toprimate lsp:cargo install primate --lockedThe binary lands at
~/.cargo/bin/primate. As long as that’s on yourPATH, you’re set. Otherwise, point Zed at a custom binary path insettings.json:{ "lsp": { "primate": { "binary": { "path": "/absolute/path/to/primate" } } } } -
Install the extension from the Zed registry:
- Command palette →
zed: extensions→ search “primate” → Install.
- Command palette →
-
Open any
.primfile. Syntax highlighting and inline diagnostics should appear immediately.
What you get
- Tree-sitter syntax highlighting for
.primfiles (keywords, type names, enum variants, unit suffixes on numeric literals). - LSP-driven diagnostics, hover, go-to-definition, find-references, format-on-save, and contextual completion.
Verifying it works
Open one of examples/constants/*.prim from a checkout. You should
see:
- Keywords (
enum,type,namespace,use) styled as keywords. - Type names highlighted consistently in declarations and
usestatements. - Unit suffixes (
30s,100MiB) styled separately from the digits. - Red squiggles on broken syntax.
cmd-.formats the buffer (LSPtextDocument/formattingruns the same logic asprimate fmt).- Hover on a type name shows its kind, namespace, and doc comment.
- Cmd-click on a type name jumps to its declaration.
If diagnostics don’t show, check ~/Library/Logs/Zed/Zed.log — the
extension prints [LSP] ... messages on stderr. The usual failure is
“primate not on PATH”; either cargo install primate --locked or
set lsp.primate.binary.path.
Install as a dev extension
If you’re hacking on the extension itself rather than just using it, install the local source as a dev extension:
-
Clone the project repo.
-
In
editors/zed/extension.toml, change the grammarrepositoryfrom the GitHub URL to afile://URL pointing at your local checkout (the registry build clones over HTTPS, butfile://works for local dev):[grammars.primate] repository = "file:///absolute/path/to/this/repo" rev = "main" path = "editors/zed/tree-sitter-primate" -
In Zed: command palette →
zed: install dev extension→ pickeditors/zed/.
Iterating:
| Change | What to do |
|---|---|
editors/zed/src/lib.rs (extension shim) | zed: rebuild dev extension |
editors/zed/tree-sitter-primate/grammar.js | pnpx tree-sitter-cli generate, commit, rebuild |
editors/zed/languages/primate/highlights.scm | Just save — Zed reloads queries on edit |
primate binary (the LSP) | cargo install --path . --locked to refresh |
To restart only the language server inside Zed: editor: restart language server.
Why a tree-sitter grammar lives in this repo
primate ships its parser in Rust, but Zed (and other editors) need a
tree-sitter grammar for syntax highlighting and structural queries.
The parallel grammar in editors/zed/tree-sitter-primate/ is tuned
for highlighting, not for being a spec-compliant parser. It stays in
sync with the Rust parser as the language evolves.
Highlighting gaps are almost always a fix in grammar.js (the
grammar) or languages/primate/highlights.scm (the highlight rules).
VS Code
primate ships a VS Code extension that provides syntax highlighting
and connects to the primate lsp server. Source lives at
editors/vscode/
in the project repo.
Install
-
Install the
primatebinary so the extension can shell out toprimate lsp:cargo install primate --lockedThe binary lands at
~/.cargo/bin/primate. As long as that’s on yourPATH, you’re set. If you’d rather point the extension at a custom path, setprimate.server.pathin your VS Code settings. -
Install the extension from the Marketplace:
- https://marketplace.visualstudio.com/items?itemName=valtyr.primate-vscode
- Or in VS Code: Extensions sidebar → search “primate” → Install.
-
Open any
.primfile. Syntax highlighting and inline diagnostics should appear immediately.
What you get
- Syntax highlighting via a TextMate grammar
(
editors/vscode/primate.tmLanguage.json). - LSP-driven diagnostics, hover, go-to-definition, find-references, format-on-save, and contextual completion (enum variants, unit suffixes).
- Cross-target navigation: from a constant in a
.primfile, find references jumps to its callsites in generated TypeScript / Rust / Python; from a generated symbol, go-to-definition resolves back to the originating.primline via the sourcemap. - JSON schema validation for
primate.toml.
Settings
| Setting | Default | Description |
|---|---|---|
primate.server.path | "primate" | Path to the primate executable. Defaults to looking on PATH. |
Commands
- primate: Restart LSP Server — kill and re-launch the language server. Useful when you’ve upgraded the CLI binary.
Troubleshooting
If the extension activates but diagnostics never show, the language server probably failed to start. Check Output panel → primate LSP for the error message. Common causes:
primateisn’t onPATH. Runwhich primatefrom a terminal; if empty, eithercargo install primate --lockedor setprimate.server.pathto an absolute path.- The CLI version doesn’t match the extension. Run
primate --versionto confirm. Older CLIs may not implement some LSP requests the extension expects (e.g. cross-target navigation is added in v0.1+).
Install from source (development)
If you’re hacking on the extension itself rather than just using it:
cd editors/vscode
npm install
npm run compile
Then in VS Code, open editors/vscode/ and press F5 — that
launches an “Extension Development Host” window with the in-progress
extension loaded.
To package a .vsix locally without publishing:
npx --yes @vscode/vsce package
code --install-extension primate-vscode-*.vsix
Vim
primate ships syntax and ftdetect files for Vim/Neovim at
editors/vim/. They handle highlighting; LSP features are wired
through your favorite client (coc.nvim, nvim-lspconfig, vim-lsp).
Syntax + ftdetect
Drop the files into your runtime path:
cp editors/vim/syntax/primate.vim ~/.vim/syntax/
cp editors/vim/ftdetect/primate.vim ~/.vim/ftdetect/
Or for Neovim:
cp editors/vim/syntax/primate.vim ~/.config/nvim/syntax/
cp editors/vim/ftdetect/primate.vim ~/.config/nvim/ftdetect/
After this, *.prim files auto-detect as primate and pick up syntax
highlighting (keywords, strings, numbers, doc comments, attributes).
LSP
Wire primate lsp into your LSP client. Examples below.
nvim-lspconfig
local lspconfig = require('lspconfig')
local configs = require('lspconfig.configs')
if not configs.primate then
configs.primate = {
default_config = {
cmd = { 'primate', 'lsp' },
filetypes = { 'primate' },
root_dir = lspconfig.util.root_pattern('primate.toml', '.git'),
settings = {},
},
}
end
lspconfig.primate.setup({})
coc.nvim
In coc-settings.json:
{
"languageserver": {
"primate": {
"command": "primate",
"args": ["lsp"],
"filetypes": ["primate"],
"rootPatterns": ["primate.toml", ".git"]
}
}
}
vim-lsp
if executable('primate')
au User lsp_setup call lsp#register_server({
\ 'name': 'primate',
\ 'cmd': {server_info -> ['primate', 'lsp']},
\ 'allowlist': ['primate'],
\ })
endif
Verifying it works
Open one of examples/constants/*.prim. You should see:
- Keywords (
enum,type,namespace,use) highlighted asKeyword. - Doc comments (
///) styled distinctly from regular line comments. - Numeric literals with their unit suffixes —
30s,100MiB— visually distinct from plain numbers.
If LSP is wired up, :LspHover (or your client’s equivalent) on a type
name shows its kind, namespace, and doc comment. :LspDefinition jumps
to the declaration.
Format on save
primate has no native autocmd, but you can wire primate fmt into a
buffer-local BufWritePre or use your LSP client’s formatExpr. With
nvim-lspconfig:
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = '*.prim',
callback = function() vim.lsp.buf.format() end,
})
Enums and constants
Recipes for the most common shapes you’ll write.
A flat constants file
Start here. Many projects need a handful of named values and never touch enums.
// constants/limits.prim
duration TIMEOUT = 30s
u32 MAX_RETRIES = 5
u64 MAX_UPLOAD = 100MiB
string API_VERSION = "v3"
bool STRICT_MODE = true
Generated TypeScript (generated/constants/limits.ts):
export const timeout = 30_000 as const;
export const maxRetries = 5 as const;
export const maxUpload = 104_857_600 as const;
export const apiVersion = "v3" as const;
export const strictMode = true as const;
Plus a sibling index.ts re-exporting each namespace as a sub-object,
so consumers can write
import { limits } from "./generated/constants" and reach for
limits.timeout.
A string-tagged enum
Use this when the enum is identified by name in serialized form (JSON
APIs, log fields, environment values). Variants have no explicit
= value.
// constants/job.prim
/// Operation status.
enum Status {
Pending,
Active,
Done,
Failed,
}
Generated TypeScript:
/** Operation status. */
export type Status = "Pending" | "Active" | "Done" | "Failed";
export const Status = {
Pending: "Pending",
Active: "Active",
Done: "Done",
Failed: "Failed",
} as const;
The type union gives you compile-time exhaustiveness; the const object
gives you a runtime handle (Status.Pending) for non-literal callsites.
The enumStyle option on the TypeScript generator switches between
this default (“literal”), a const object only, or a real TS enum.
An integer-backed enum
Use this when the enum lives on a wire format that wants a small integer (telemetry, binary protocols, log levels).
// constants/log.prim
/// Severity, integer-backed for fast filtering.
enum LogLevel: u8 {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
}
Generated TypeScript:
/** Severity, integer-backed for fast filtering. */
export enum LogLevel {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
}
(Rust generates #[repr(i32)] pub enum; Python generates IntEnum.)
Per-variant docs
Doc comments attach to the next variant, just like for top-level declarations:
enum LogLevel: u8 {
/// Verbose logs intended for development.
Debug = 0,
/// Normal operational logs.
Info = 1,
/// Something went wrong but the app continued.
Warn = 2,
/// Something went wrong and the operation failed.
Error = 3,
}
These docs land in the generated output (JSDoc on TypeScript variants,
docstring fields on Python enums, /// on Rust variants).
Using an enum-typed constant
Once the enum is declared, use its name as a type:
// constants/job.prim
enum Status {
Pending,
Active,
Done,
}
Status DEFAULT_STATUS = Pending
Both bare (Pending) and qualified (Status::Pending) variant
references work. Cross-namespace, you’d write job::Status::Pending
or use job::Status first.
Aliases for repetition
Type aliases reduce repetition when the same shape appears more than once:
type Port = u32
Port HTTP_PORT = 8080
Port HTTPS_PORT = 8443
Port ADMIN_PORT = 9999
The alias Port shows up in generated code as a named type
(type Port = number; in TypeScript), so call sites can talk about
“a port” rather than “a u32 that happens to be a port”.
For aliases you don’t want to surface as a named type, mark with
@inline — see Attributes.
Matrices and fixed shapes
When you have data with a fixed shape — RGB triples, 3×3 matrices,
fixed-byte buffers — use array<T, N>. The arity is part of the
type, so generators emit idiomatic fixed-size containers per target.
RGB triples
// constants/colors.prim
type Pixel = array<u32, 3>
Pixel RED = [255, 0, 0]
Pixel GREEN = [0, 255, 0]
Pixel BLUE = [0, 0, 255]
Generated TypeScript (generated/constants/colors.ts):
export type Pixel = [number, number, number];
export const red = [255, 0, 0] as const;
export const green = [0, 255, 0] as const;
export const blue = [0, 0, 255] as const;
In Rust the same Pixel becomes [u32; 3]; in Python it’s
Tuple[int, int, int].
3×3 matrices
The compact form fits on one line — the formatter keeps it inline:
type Mat3 = array<array<u32, 3>, 3>
Mat3 SMALL = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
For larger matrices the same shape gets long. Use a trailing comma on the outer literal to opt into multi-line layout:
Mat3 IDENTITY = [
[1, 0, 0],
[0, 1, 0],
[0, 0, 1],
]
The trailing , after the last row tells the formatter “keep this
multi-line, even if it would fit on one line.” See
Values for the rule.
Generated TypeScript:
export type Mat3 = [
[number, number, number],
[number, number, number],
[number, number, number],
];
export const identity = [
[1, 0, 0],
[0, 1, 0],
[0, 0, 1],
] as const;
Length-mismatch is a hard error
type V3 = array<u32, 3>
V3 SHORT = [1, 2] // ✗ length-mismatch: expected 3, got 2
V3 LONG = [1, 2, 3, 4] // ✗ length-mismatch
The error includes the expected and actual lengths and points at the literal.
Wider tables
A matrix-like value can also be a tuple<...> of heterogeneous types
when the columns have different shapes:
type RetrySchedule = tuple<u32, duration, duration>
RetrySchedule DEFAULT = [3, 100ms, 30s]
RetrySchedule AGGR = [10, 50ms, 5s]
Tuples and fixed arrays both compile to fixed-shape containers in the target languages; the difference is only in whether the elements share a type.
Lookup tables
For sparse named lookups, reach for map:
map<string, u32> SERVICE_PORTS = {
"http": 80,
"https": 443,
"ssh": 22,
"smtp": 25,
}
Trailing comma keeps it multi-line; without it, short maps stay inline.
Cross-namespace types
Real projects have more than one file. This page shows how to organize types across namespaces and reference them from elsewhere.
File layout drives namespaces
The recommended pattern: let the directory layout determine the
namespace. Don’t write namespace foo at the top of files.
constants/ ← input
├── net/
│ ├── limits.prim → namespace `net::limits`
│ └── headers.prim → namespace `net::headers`
├── log/
│ └── levels.prim → namespace `log::levels`
└── jobs.prim → namespace `jobs`
Files with the same parent share a namespace. Sibling files in
net/ see each other’s enums and aliases by bare name.
Cross-namespace by qualified path
Reference a type from another namespace by its fully qualified path:
// constants/jobs.prim
log::levels::LogLevel DEFAULT_LEVEL = Info
Generated TypeScript imports the type from the right namespace automatically.
Or with use for ergonomics
If you reference a name often, bring it into scope with use:
// constants/jobs.prim
use log::levels::LogLevel
use net::limits::{Port, IP}
LogLevel DEFAULT_LEVEL = Info
Port COORDINATOR = 9000
IP DEFAULT_BIND = "0.0.0.0"
use is purely an ergonomic — it has no effect on generated code.
See use statements for the rules.
A shared types file
Group types that multiple namespaces need into a core/types.prim
or similar:
// constants/core/types.prim
/// Used everywhere a network port is named.
type Port = u32
/// IPv4 or IPv6 address.
type IP = string
/// Severity, integer-backed for fast filtering.
enum LogLevel: u8 {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
}
Other files import via qualified path or use:
// constants/services/api.prim
use core::types::{Port, LogLevel}
Port API_PORT = 8080
LogLevel API_LOG_LEVEL = Info
When to override the namespace
The escape hatch (namespace foo::bar at the top of a file) is for
the rare case where path-derived doesn’t fit:
// constants/legacy/old_metrics.prim
namespace metrics::v1
// — overrides the path-derived `legacy::old_metrics` so we can keep
// serving these on the existing `metrics::v1` API.
Use it sparingly. If you find yourself overriding more than once or twice, that’s a signal the directory layout doesn’t reflect your intended organization — move the files instead.
Same name in two namespaces
primate allows the same type or constant name to exist in different namespaces. Within a single namespace, duplicates are an error.
// constants/net/limits.prim
type Port = u32
// constants/audio/limits.prim
type Port = u8 // OK — different namespace
If you use both at once into a third file, that’s an
import-collision error:
use net::limits::Port
use audio::limits::Port // ✗ `Port` is already imported from `net::limits`
Either use only one, or qualify both at the call site.
Where this matters in generated code
Each language preserves your namespace structure idiomatically:
- TypeScript — primate emits one
.tsfile per namespace (plus anindex.tsre-exporting each one). Cross-namespace references become real ESimportstatements at the top of each file. - Rust — primate emits a single
.rsfile with onepub mod <ns> { ... }per namespace. Cross-namespace references becomesuper::<other>::X. - Python — primate emits a package directory with one
.pyper namespace and an__init__.py. Cross-namespace references becomefrom .<other> import X.
So if limits.prim references LogLevel from logging, you get the
right import or path at the consumer site for free.
Grammar
A reference grammar for .prim files. EBNF-flavored notation; the
canonical implementation lives in src/parser/grammar.rs.
File ::= ( NamespaceLine NEWLINE )?
( UseStatement NEWLINE )*
( Item NEWLINE? )*
Item ::= Decl
| LineComment
| DocComment
| FileDocComment
| BlankLine
NamespaceLine ::= "namespace" Path
Path ::= Ident ( "::" Ident )*
UseStatement ::= "use" Path // single
| "use" Path "::" "{" UseList "}" // brace
UseList ::= Ident ( "," Ident )* ","?
Decl ::= ( DocBlock )? ( Attribute NEWLINE )*
( ConstDecl | EnumDecl | TypeAliasDecl )
ConstDecl ::= TypeExpr Ident "=" Value
// Ident is SCREAMING_SNAKE_CASE
EnumDecl ::= "enum" Ident ( ":" TypeExpr )? "{"
( EnumVariant ( "," EnumVariant )* ","? )?
"}"
EnumVariant ::= ( DocBlock )? Ident ( "=" Value )?
TypeAliasDecl ::= "type" Ident "=" TypeExpr
TypeExpr ::= Path // bare or qualified
| TypeExpr "[]" // array sugar
| TypeExpr "?" // optional sugar
| "array" "<" TypeExpr ">"
| "array" "<" TypeExpr "," IntLit ">" // fixed-size
| "optional" "<" TypeExpr ">"
| "map" "<" TypeExpr "," TypeExpr ">"
| "tuple" "<" TypeExpr ( "," TypeExpr )* ","? ">"
Value ::= IntLit | FloatLit | StrLit | BoolLit
| "none"
| Path // enum variant
| "[" ValueList? "]" // array / tuple
| "{" MapEntries? "}" // map
| "-" ( IntLit | FloatLit ) // negation
ValueList ::= Value ( "," Value )* ","?
MapEntries ::= MapEntry ( "," MapEntry )* ","?
MapEntry ::= MapKey ":" Value
MapKey ::= StrLit | Ident | IntLit
Attribute ::= "@" Ident ( "(" AttrArgs? ")" )?
AttrArgs ::= AttrArg ( "," AttrArg )* ","?
AttrArg ::= Ident | StrLit | IntLit | BoolLit
DocBlock ::= ( "///" RestOfLine NEWLINE )+
LineComment ::= "//" RestOfLine
DocComment ::= "///" RestOfLine
FileDocComment::= "//!" RestOfLine
Ident ::= [A-Za-z_][A-Za-z0-9_]*
IntLit ::= ( "0x" HexDigit+ | "0b" BinDigit+ | "0o" OctDigit+
| DecDigit+ ) UnitSuffix?
FloatLit ::= DecDigit+ "." DecDigit+ ( [eE] [+-]? DecDigit+ )? UnitSuffix?
StrLit ::= '"' StringChar* '"'
| "r" "#"* '"' RawStringChar* '"' "#"*
BoolLit ::= "true" | "false"
UnitSuffix ::= [A-Za-z]+ // e.g. ms, s, h, KiB, MiB, GiB
Rules of thumb
- Newlines terminate top-level items, with one exception: inside
<>,[],{},(), newlines are insignificant. This is what lets long type expressions and multi-line value literals work. - One declaration per line outside collection delimiters.
- No semicolons. Newlines do the same job and there’s no expression context where ASI-style ambiguity could arise.
- Trailing commas are accepted in every comma-separated list. On
value-side
[…]and{…}literals, a trailing comma signals “keep me multi-line” to the formatter — see Values.
Reserved tokens
Keywords (lex-time): namespace, enum, type, use, as, true,
false, none.
as is reserved for the future use a::b::C as D form (RFC 0003).
Lexical structure
Whitespace is space, tab, carriage return, and newline. Newlines are
significant tokens (the lexer emits Newline and, for blank-line
runs, BlankLine). Inside delimiters the parser consumes them as
trivia.
Comments are lexed as // ..., /// ..., or //! ... to end of
line. Block comments (/* */) are explicitly rejected with a
diagnostic.
Underscores are accepted in numeric literals as digit separators
(1_000_000, 0xFF_FF). They have no semantic value.
Differences from RFC text
This grammar is the implementation reference. Where it disagrees with RFC 0002 or RFC 0003, the implementation wins; the RFCs are decision records, not specs.
Diagnostics
Every error and warning primate emits has a stable code. Codes show up
in the LSP, in primate build output, and in CI failure messages.
This page lists every code, what triggers it, and how to fix it.
Parse layer
parse-error
A token didn’t fit the grammar. The error message describes what was expected vs what was found.
u32 X = 8; // ✗ semicolons aren't allowed
// parse-error: unexpected character ';'
Fix: match the grammar. See the grammar reference.
parse-failure
A higher-level structural failure where the parser bailed on a whole
declaration. Usually accompanied by a more specific parse-error.
Naming
naming-convention
A name doesn’t match its declaration’s case convention.
u32 maxRetries = 5 // ✗ constants are SCREAMING_SNAKE_CASE
// naming-convention: constant `maxRetries` must be SCREAMING_SNAKE_CASE
Fix: rename to the convention.
| Item | Convention |
|---|---|
| Constants | SCREAMING_SNAKE_CASE |
| Enums | PascalCase |
| Enum variants | PascalCase |
| Type aliases | PascalCase |
| Namespaces | lower_snake_case |
Resolution
unknown-type
A type name doesn’t resolve to a primitive, an in-scope user type, or an imported name.
SomeMissingType X = 0
// ✗ unknown-type: unknown type `SomeMissingType`
Fix: check the spelling, ensure the type is declared (or imported), or qualify with the namespace.
duplicate-name
Two declarations with the same name in the same namespace.
type Port = u32
type Port = u16 // ✗ duplicate-name: type alias `Port` is already declared
Fix: rename one, or move them into different namespaces.
duplicate-namespace
A file has more than one namespace line, or a namespace line is
not at the top.
Fix: keep at most one namespace line, and place it as the first
non-comment item in the file.
Type-checking
type-mismatch
A value doesn’t match its declared type.
u32 X = "hello"
// ✗ type-mismatch: expected integer, got string literal
Fix: correct the value, or change the declared type.
length-mismatch
A fixed-size array literal has the wrong arity.
array<u32, 3> X = [1, 2]
// ✗ length-mismatch: expected 3 elements for array<_, 3>, got 2
Fix: add or remove elements, or change the declared length.
out-of-range
A literal — or the result of multiplying it by a unit suffix — exceeds the declared primitive type’s range. Subsumes the older “unsigned integer cannot be negative” error.
i32 X = 3_000_000_000
// ✗ out-of-range: value 3000000000 does not fit in i32 (range: -2147483648..=2147483647)
u32 Y = 5GiB
// ✗ out-of-range: value 5368709120 does not fit in u32 (range: 0..=4294967295)
u32 Z = -1
// ✗ out-of-range: value -1 does not fit in u32 (range: 0..=4294967295)
Same code applies to enum variant values that overflow the backing type:
enum Big: u8 {
A = 0,
B = 300, // ✗ out-of-range: value 300 does not fit in u8
}
Fix: widen the declared type, or use a smaller value.
invalid-enum-backing
The : <type> on an enum is not an integer primitive.
enum Bad: string {
// ✗ invalid-enum-backing: enum backing type must be an integer
A,
B,
}
Fix: drop the backing for a string-tagged enum, or set it to one
of i8/i16/i32/i64/u8/u16/u32/u64.
invalid-enum-variant
A value typed as an enum doesn’t match any variant.
enum Status { Pending, Active }
Status X = Done
// ✗ invalid-enum-variant: `Done` is not a variant of enum `Status`
Fix: use one of the listed variants, or add Done to the enum.
use and imports
unresolved-import
A use statement references a name that doesn’t exist.
use net::limits::Bogus
// ✗ unresolved-import: `net::limits::Bogus` does not exist
Fix: check the path and the imported name.
import-collision
A use brings in a name that collides with another import or with a
same-namespace declaration.
use net::limits::Port
use audio::limits::Port
// ✗ import-collision: `Port` is already imported from `net::limits`
Fix: import only one, or qualify both at the call site.
Config
config-error
primate.toml is malformed or references a missing path.
Fix: the message points at the offending key. Common causes: a
missing input key, an unwritable output path, or a generator
without an output.
Internal
internal-error
primate hit an unexpected state inside the parser, lower, or generator. This is a bug — please report it with the offending source file (or a minimal repro).
Changelog
The canonical changelog lives in
CHANGELOG.md
at the repo root, kept in sync with crates.io releases by
release-plz.
For per-release notes, see the GitHub Releases page.
Roadmap
Features under consideration. No timelines — items land if a real use case shows up; otherwise they sit here. The point of this page is to make explicit what’s not in the language and why, without sprinkling those notes throughout the reference.
Likely
@deprecated(message?)attribute. Marks a constant as deprecated; generators emit a target-language deprecation marker (Rust#[deprecated], JSDoc@deprecated, etc.). Cut from v1 because it adds surface without solving a problem most projects hit; will revisit if real demand shows up.use a::b::C as Drename imports. Rust-style; useful for resolving collisions between same-named imports. Lands when the collision case becomes painful in real projects.- Configurable formatter column budget. Currently fixed at 100.
Easy to add via
primate.tomlif anyone wants a different number. /regex/flagsregex literal syntax. Considered and deferred in RFC 0004 in favor of the same string-with-(?i)form that Rust and Python use. If inline-flag ergonomics become a recurring papercut, revisit.
Maybe
newtypefor nominal types.type Port = u32is structural:Portandu32are interchangeable. Anewtype Port = u32would make them distinct, catching “I passed aCountwhere I meant aPort” in target-language code. The Rust story is clean (pub struct Port(pub u32);); the TypeScript story (branded types) is awkward; the Python story (NewType) is type-checker-only. Lands if a real project needs the discipline.- Other unit-suffix categories. Today only byte-size suffixes are
recognized on integer literals.
%(percent),Hz(frequency),m/cm/km(length), currency codes — each could exist as a parsing affordance. Picking one without a principled criterion is arbitrary, so we don’t.
Probably not
- Glob imports (
use a::*). Mass-import is a name-shadowing hazard; explicituselines make collisions and provenance clear. - Block comments (
/* */). One line, one doc, one file-doc. - Significant whitespace. Newlines terminate; indentation is cosmetic.
- Sigils on names (
$X,@X). Plain identifiers.
Out of scope
- Expressions and arithmetic. primate is a constants language. If you need to compute a value, do it in your build script and paste the result.
- String interpolation. Constants only; low value.
- Statement-terminating semicolons. Newlines work and there’s no expression context where ASI-style ambiguity could appear.