Skip to content

Instantly share code, notes, and snippets.

@alob-mtc
Last active August 12, 2026 17:57
Show Gist options
  • Select an option

  • Save alob-mtc/8139d4bf955b167cc29024f574286bf6 to your computer and use it in GitHub Desktop.

Select an option

Save alob-mtc/8139d4bf955b167cc29024f574286bf6 to your computer and use it in GitHub Desktop.
ProScript Language Spec

The ProScript Programming Language

Language Specification 0.1

ProScript is a statically typed, garbage-collected programming language for highly concurrent application software. It is designed primarily for web services and secondarily for command-line applications.

ProScript combines:

  • Go-like operational simplicity, deployment, and lightweight concurrency;
  • algebraic data types, exhaustive pattern matching, and static modeling associated with Rust-like languages;
  • an interpreted edit-run experience backed by a language-owned virtual machine; and
  • automatic memory and resource management without ordinary ownership, borrowing, or lifetime annotations.

This document defines ProScript language version 0.1 and its canonical syntax. It is intended to be read as a language manual. All examples use the .pr source-file extension.

Contents

  1. Language goals
  2. Conformance and execution
  3. Source text and lexical structure
  4. Literals
  5. Packages, modules, and imports
  6. Declarations and names
  7. Types
  8. Bindings, constants, and value semantics
  9. Functions, methods, and generics
  10. Protocols
  11. Expressions and evaluation
  12. Statements and control flow
  13. Patterns
  14. Errors, panics, and recovery
  15. Managed memory and arenas
  16. Deterministic resources
  17. Concurrency
  18. Structural debugging, JSON, and template metadata
  19. Native extensions
  20. Diagnostics and tooling
  21. Compatibility and evolution
  22. Language 0.1 boundary

1. Language goals

1.1 Primary workload

ProScript is optimized for application servers that:

  • handle many concurrent network connections;
  • spend substantial time waiting on databases, files, queues, and remote services;
  • require predictable failure and cleanup behavior;
  • benefit from expressive request, response, event, and error types; and
  • must remain easy to prototype, inspect, and deploy.

Command-line applications are a secondary first-class workload.

CPU-intensive operations may use optimized standard-library implementations or native extensions. ProScript does not promise that arbitrary interpreted application code matches optimized native code.

1.2 Design priorities

When language goals conflict, ProScript prioritizes:

  1. safety;
  2. clarity and local reasoning;
  3. simplicity in ordinary programs;
  4. rapid iteration;
  5. practical server performance; and
  6. maximum low-level flexibility.

The language automates memory placement, garbage collection, arena promotion, resource claims, and task scheduling. Consequential programmer intent remains visible through constructs such as spawn, explicit error types, channel operations, arena, native declarations, and explicit discards.

1.3 Non-goals

ProScript 0.1 is not intended for:

  • kernels, device drivers, or bare-metal software;
  • hard real-time systems;
  • software requiring stable managed addresses or direct pointer arithmetic;
  • unrestricted foreign-memory manipulation; or
  • programs whose primary purpose is manual memory-layout control.

2. Conformance and execution

2.1 Source semantics

A conforming implementation must accept every valid program defined by this specification and must preserve its observable behavior. Optimizations may change representation, allocation placement, scheduling details, or execution tier only when the change is unobservable under the language rules.

Source code is the compatibility contract. Bytecode is an internal, implementation-versioned format.

2.2 Checked execution

Before executing a target, the implementation parses, resolves, and statically checks every package reachable from that target. An unrelated file outside the reachable package graph does not prevent execution.

Validated package-level caches may be reused when their source, compiler mode, dependencies, and relevant configuration are unchanged.

2.3 Runtime model

ProScript source runs directly through a ProScript-owned runtime. An implementation may:

  • interpret an abstract syntax tree;
  • lower source to internal bytecode;
  • cache checked bytecode;
  • transparently compile bytecode ahead of time; or
  • package source-derived code with a matching runtime.

These choices do not create distinct source-language modes.

The permanent execution model consists of a bytecode virtual-machine tier and an optional ahead-of-time native tier. A JIT is not required. Every tier must agree on types, arithmetic, panics, cleanup, scheduling-visible behavior, and all other source semantics.

2.4 Distribution and portability

Source-only execution is the normal development workflow. Tooling may also produce target-specific self-contained artifacts that require no separate ProScript installation.

Pure ProScript code has platform-independent semantics except where a type is explicitly target-dependent, such as int and uint. Native packages declare their supported operating systems and architectures. An unsupported target is reported before an artifact is produced.

3. Source text and lexical structure

3.1 Source files

The sole ProScript source-file extension is lowercase .pr on every platform. The compiler, formatter, language server, package loader, test discovery, editor integrations, and every other ProScript-aware tool recognize exactly that extension. .pros, .proscript, .ps, .pro, uppercase variants, and all other spellings are not source-file aliases.

Filename stems otherwise have no language-level meaning: they do not determine package identity, type names, visibility, or entry points. A filename ending in _test.pr is selected only by the test tool.

3.2 Encoding

Source files are Unicode text encoded as UTF-8. Source text is not Unicode-normalized.

The source newline is U+000A LINE FEED. The lexical whitespace characters are exactly:

U+0020  space
U+0009  horizontal tab
U+000D  carriage return
U+000A  line feed

Other Unicode whitespace outside comments and literals is a lexical error.

A single UTF-8 byte-order mark is accepted only at the beginning of an ordinary source file. NUL is rejected.

Carriage return is whitespace rather than a newline. Within a raw string, carriage returns are discarded, making equivalent LF and CRLF sources produce the same raw-string value.

3.3 Shebang

A source file may begin with a Unix-style shebang at byte offset zero:

#!/usr/bin/env proscript

package main

Everything from #! through the first line feed, or through end of file, is ignored by the ProScript parser. No whitespace or byte-order mark may precede the shebang. #! elsewhere is invalid.

3.4 Comments

Line comments begin with // and continue through the end of the line:

// A line comment.
let retries = 3; // A trailing comment.

Documentation comments begin with /// and document the following declaration:

/// Creates a user after validating the request.
pub fn create_user(request: CreateUserRequest) Result<User, CreateUserError> {
    // ...
}

ProScript has no block-comment syntax.

3.5 Identifiers

Identifiers contain ASCII letters, ASCII digits, and underscores. The first character must be a letter or underscore.

identifier = (letter | "_") (letter | digit | "_")*

Identifiers are case-sensitive and are not normalized or case-folded.

The single token _ is the discard pattern or discard assignment target. A longer underscore-prefixed name is an ordinary identifier.

Canonical casing is:

  • PascalCase for types and enum variants;
  • snake_case for packages, functions, methods, fields, parameters, and local bindings;
  • SCREAMING_SNAKE_CASE for constants; and
  • lowercase for language keywords.

Acronyms use word casing: HttpServer, JsonPayload, UserId, http_server, and user_id.

3.6 Reserved keywords

The globally reserved keywords are:

package  import

pub
const  let  mut
fn  self
struct  enum  type  opaque
resource  cleanup
protocol  conform

if  else  match
for  in  break  continue
return

spawn  select  case  default
arena
recover  with

native  offload

true  false

A keyword cannot be used in any identifier position. ProScript has no raw identifier escape.

map and set are contextual literal introducers rather than reserved keywords. In expression position, map { and set { always begin collection literals. Elsewhere, both words are ordinary identifiers.

When an identifier named map or set occurs immediately before a block-opening brace, it must be parenthesized:

if (set) {
    apply();
}

match (map) {
    // ...
}

Primitive type names, predeclared variants, and compiler operations are predeclared names rather than keywords. They cannot be redeclared or shadowed.

3.7 Tokens and line breaks

Outside comments and literals, a newline is ordinary whitespace. ProScript does not insert semicolons automatically.

Multi-character tokens such as ::, =>, +=, &&, and ..= cannot be split by whitespace.

There is no backslash line continuation.

An ordinary source file canonically ends with one newline. The parser accepts a file whose final token is immediately followed by end of file.

3.8 Blocks and comma-delimited lists

Executable blocks always use braces:

if ready {
    process();
}

Indentation, a colon, then, do, begin, and end never delimit a block. There is no standalone bare-block statement. A newline may precede an opening brace because newlines are ordinary whitespace, although canonical formatting places the brace on the header line.

Every comma-delimited list requires exactly one comma between adjacent entries. One trailing comma is accepted in any nonempty list independently of line breaks. Leading commas, repeated commas, and missing separators are invalid.

The canonical formatter removes a trailing comma from a single-line list and emits one in a multiline list.

4. Literals

4.1 Boolean literals

The Boolean literals are true and false. They have type bool.

ProScript has no truthiness. Numeric and string values do not act as Boolean values.

4.2 Integer literals

Integer literals support decimal, binary, octal, and hexadecimal forms:

42
0b1101_0010
0o755
0xFF_A2_00

The base prefixes are lowercase. Hexadecimal digits may use either case. Legacy leading-zero octal notation is invalid.

Underscores may appear only between digits. Integer literals have no type suffix. A leading sign is a unary operator, not part of the literal.

An integer literal takes the integer type required by its immediate context. An unconstrained integer literal defaults to int. The selected type must represent the value.

In expression position, unary - applied to an unsuffixed integer literal through zero or more grouping parentheses is one signed contextual-literal form. The compiler checks the final negated mathematical value, so -128, -(128), and -((128)) are all valid in an int8 context. Recognition does not cross another prefix operator, arithmetic, a conversion, or another general expression. Rune and byte literals retain their fixed types and are excluded.

An additional outer prefix is an ordinary operation on the typed inner value. Consequently, let value: int8 = - -128; type-checks and the outer negation panics at runtime. The equivalent operation in a package constant is rejected during compile-time evaluation. This grouping allowance is expression-only; parenthesized range-pattern bounds remain invalid.

4.3 Floating-point literals

Floating-point literals use decimal or scientific notation:

1.5
1e-3
2.5E6
6.022_140_76e23

A decimal point requires digits on both sides. Exponents require at least one digit. Underscores may appear between digits. Floating literals have no type suffix and do not support hexadecimal notation.

A floating literal takes the floating type required by context. An unconstrained floating literal defaults to float64.

4.4 Rune literals

A rune literal uses single quotes and has the fixed type rune:

'A'
'世'
'\n'
'\u2764'

It contains exactly one Unicode scalar value, literally or through one escape. Recognized escapes are:

  • \a, \b, \f, \n, \r, \t, \v, \\, and \';
  • exactly three octal digits;
  • \x plus exactly two hexadecimal digits;
  • \u plus exactly four hexadecimal digits; and
  • \U plus exactly eight hexadecimal digits.

Surrogates and values above U+10FFFF are invalid. A multi-scalar grapheme is not one rune literal.

4.5 Byte literals

A byte literal uses b'…' and has the fixed type byte:

b'A'
b'\n'
b'\xff'
b'\377'

It accepts one ASCII source character, one single-byte special escape, one two-digit hexadecimal escape, or one three-digit octal escape in the byte range. Unicode escapes and non-ASCII source characters are invalid.

A byte literal denotes a numeric byte. It is not a UTF-8 encoding operation.

4.6 Interpreted strings

An interpreted string uses double quotes and has type string:

"hello"
"Hello, 世界"
"first\nsecond"
"created user ${user.id}"

Recognized escapes are \a, \b, \f, \n, \r, \t, \v, \\, \", \u with four hexadecimal digits, and \U with eight hexadecimal digits.

Octal and \x byte escapes are invalid in strings. A physical newline cannot appear inside an interpreted string.

4.7 Raw strings

A raw string uses backticks:

let query = `SELECT id, email
FROM users
WHERE active = true`;

Backslashes have no escape meaning, physical newlines are allowed, and interpolation is disabled. A raw string cannot contain a backtick.

Both string forms produce immutable, valid UTF-8 string values.

4.8 String interpolation

Interpolation uses ${name}, ${name.field}, or ${self.field} inside an interpreted string:

"user ${user.id}"
"owner ${request.user.display_name}"
"label ${self.display_name}"

An interpolation slot accepts only:

  • a value-binding or constant name;
  • the receiver keyword self, inside a type-owned method body; or
  • a chain of field accesses beginning at either of the above.

self in a slot remains a field-access base only, exactly as it is elsewhere. A slot consisting of ${self} alone is invalid, including when the receiver is a primitive-backed nominal; bind a temporary to interpolate the receiver itself.

Calls, conversions, indexing, arithmetic, conditionals, blocks, and other expressions are not permitted in a slot. Bind a temporary first:

let next_attempt = attempts + 1;
let message = "starting attempt ${next_attempt}";

The final value must be a primitive or a primitive-backed nominal type. Canonical rendering is:

  • strings as their contents;
  • integers in decimal;
  • finite floats in the canonical ES6-style shortest round-trip text form, preserving -0;
  • non-finite floats as exactly NaN, +Inf, and -Inf;
  • Boolean values as true or false;
  • bytes, runes, and every other integer alias in decimal; and
  • open or opaque primitive-backed nominals through their underlying primitive.

Aggregates, resources, capabilities, closures, protocol values, and unit cannot be interpolated.

Slots evaluate from left to right. Rendering cannot fail. \${ emits the literal characters ${.

The three non-finite float spellings apply identically to float32, float64, and float-backed nominals. Every NaN sign and payload renders as NaN. These strings are interpolation output rather than source literals or JSON numbers; JSON encoding continues to reject non-finite floats with JsonError. Structural Debug formatting remains a separate contract.

byte and rune are exact aliases of uint8 and int32; alias spelling cannot change runtime behavior. Consequently 'A' interpolates as the decimal integer 65. Character text requires an explicit standard-library conversion or formatting operation whose resulting string is bound before interpolation.

Adjacent string literals do not concatenate. Use + or interpolation.

5. Packages, modules, and imports

5.1 Package unit

One directory is one package compilation and namespace unit. All selected .pr files in that directory contribute declarations to the package. Subdirectories are separate packages.

All selected ordinary source files in one directory package must declare the same package name. Different written names do not create co-located packages; they are a compile-time package-header mismatch.

A module is the distribution unit identified by proscript.toml. It may contain multiple packages.

5.2 Package declaration

Every source file begins, after an optional shebang, with:

package users_api

For an ordinary package, the package name is an ASCII snake_case identifier and must exactly equal the final component of the file's canonical package path.

An executable command package instead declares:

package main

package main is exempt from directory-name agreement. Its directory may have a different name or one that is not a valid ordinary package identifier. The command package retains the distinct canonical logical identity supplied by target and module resolution; command packages from different locations do not merge merely because they share the written name main.

Every selected ordinary source file in that command directory must declare package main. A file with another package header is a compile-time error. Whether another package may import a command package is not defined by this exception.

The package declaration has no semicolon.

5.3 Imports

Imports follow the package declaration and are file-local. Imports do not execute code.

Whole-package import:

import std.http

Single-item import:

import std.http.Request

Grouped item import:

import std.http {
    Request,
    Response,
}

Whole-package alias:

import cloud_provider.http as cloud_http

as is contextual to whole-package imports. Individual item aliases, wildcard imports, public imports, and re-exports do not exist.

An import path is a canonical dot-separated logical path rooted at:

  • std;
  • the current module identity; or
  • a declared dependency identity.

Filesystem-relative imports do not exist. Source files in the same package do not import one another.

Self-imports and package cycles are compile-time errors. A cycle diagnostic must report a concrete cycle path.

5.4 Package initialization

Packages have no runtime initialization phase:

  • imports execute no code;
  • package-level let is invalid;
  • package-level constants are evaluated during checking;
  • no init function is discovered; and
  • file and declaration order have no runtime meaning.

Execution begins at the selected entry point.

5.5 Manifest and lockfile

A module with external dependencies, native dependencies, multiple packages, publishing, or self-contained packaging has a proscript.toml manifest:

[package]
name = "billing_api"
version = "0.3.1"
language = "0.1"

[dependencies]
zstd = "1.2"

The generated proscript.lock records the exact dependency graph, source identities, versions, integrity information, and native artifacts.

A standalone standard-library-only package main command may run without either file.

One manifest describes one module. Workspace manifests and runtime package initializers do not exist in ProScript 0.1.

5.6 Visibility

Declarations are package-private by default. pub makes a declaration visible to importing packages:

pub struct User {
    pub id: UserId,
    pub name: string,
    password_hash: string,
}

Visibility does not depend on capitalization.

A pub struct exports the type name, but every field is independently package-private by default. Mark a field pub to expose it to importing packages. A field is externally accessible only when both it and its containing struct are public. A public enum still exposes every variant and payload; a private enum exposes none of them.

All files in the declaring directory package may access every field. Importers may access, mutate, interpolate, and pattern-match only public fields. When a public struct has any private field, importers cannot directly construct it, because construction must initialize every field and private fields cannot be named there. Such types expose public associated constructors or factories. External patterns must use final .. whenever private fields exist.

Privacy does not remove a field from equality, copying, or compiler-generated structural providers. Debug, JSON, and template exposure remain controlled by their own eligibility and directives; private secrets still require redaction, skipping, or provider denial. Private fields may use package-private types, while every public field type must be externally nameable.

Methods and associated functions have independent visibility:

pub fn User::display_name(self) string {
    return self.name;
}

A public type does not make all its methods public.

Every type appearing in a public signature must be externally nameable, including nested generic arguments, callable types, protocol bounds, enum payloads, public fields, constants, aliases, and both positions of Result<T, E>.

Public structs with private fields, public opaque nominal types, and public resources provide representation sealing appropriate to their distinct value, nominal, and lifecycle semantics.

Restricted visibility, friend access, file-private declarations, wildcard exports, and re-exports do not exist.

5.7 Entry point

Only a package declared package main is an executable command. It contains exactly one non-public main function with either signature:

fn main()

fn main() Result<unit, E>

In the second form, E must support Debug. Ok(unit) exits successfully. Err(error) prints a diagnostic representation and exits unsuccessfully.

main accepts no parameters and cannot be pub.

The entry function is selected across the complete directory package. Its source file need not be named main.pr, and a source-file target need not name the file containing it. Multiple source files may belong to package main, but a second package-level declaration named main is a duplicate declaration.

A function named main in an ordinary package is an ordinary package function and does not make that package executable.

6. Declarations and names

6.1 Declaration namespace

A package has one namespace for package-level constants, functions, structs, enums, nominal types, aliases, resources, and protocols. Two declarations cannot use the same name even when they belong to different declaration categories.

Each type has a separate member namespace for its fields, methods, and associated functions. Overloading is not supported: a member name denotes at most one callable or field in that namespace.

For an enum, every variant and every type-owned callable also belong to one collision-free exact-name set. An associated function, read-only method, or mut self method cannot use the exact name of a variant. This is checked across the whole directory package independently of file and declaration order. Resolution never uses receiver category, call shape, argument or result types, visibility, or variant/callable precedence to escape the collision.

Imports are file-local and cannot collide with one another or with visible package declarations.

Name resolution is independent of source order. It never uses capitalization, expected types, generic arity, or callable shape to resolve ambiguity.

Compiler-predeclared names cannot be shadowed or redeclared. These include:

  • primitive types and unit;
  • core type families such as Option, Result, Array, Map, Set, Range, and Task;
  • structural protocols and providers;
  • Some, None, Ok, and Err; and
  • compiler operations such as panic and timeout.

6.2 Local name uniqueness

A function has one active lexical value namespace spanning parameters, local bindings, patterns, loops, and closures.

A declaration cannot shadow or redeclare an active local name. A nested block may reuse a name only after the earlier binding is no longer in scope.

Generic parameters similarly cannot duplicate or shadow an active generic parameter.

6.3 Unused bindings

An unused named local binding is a compile-time error. Prefixing a binding with an underscore explicitly permits it to remain unused:

let _request_id = request.id;

The name still denotes a real value and participates in uniqueness rules.

Use _ when the value itself is deliberately discarded.

7. Types

7.1 Primitive types

The primitive type names are:

bool
string

int  int8  int16  int32  int64
uint uint8 uint16 uint32 uint64

float32 float64

byte
rune
unit

byte is an exact alias of uint8. rune is an exact alias of int32.

int is a signed target-word integer and uint is its unsigned counterpart. Their width is either 32 or 64 bits for a target. ProScript has no uintptr, 128-bit primitive integers, or arbitrary-precision runtime integer.

unit is both the unit type and its sole value:

fn record() {
    return;
}

let result: Result<unit, StorageError> = Ok(unit);

unit is not permitted as a struct or resource field type. Use an empty struct for a named zero-data record and an empty enum variant for a state.

ProScript has no primitive complex-number types.

7.2 Strings and binary data

Every string is immutable valid UTF-8 text. Malformed UTF-8 cannot inhabit string.

Binary data uses Array<byte>. There is no separate built-in Bytes type and no implicit conversion between string and Array<byte>.

String equality and hashing compare the exact Unicode scalar sequence, which is equivalent to the exact UTF-8 sequence. No normalization, case folding, or locale processing occurs implicitly.

String ordering is exact lexicographic UTF-8 ordering.

string has no integer indexing, numeric slicing, or unqualified length operation. Named text APIs must make byte, rune, or grapheme units explicit.

Because rune is an alias of int32, an arbitrary runtime rune need not be a valid Unicode scalar. Any operation that inserts a rune into a string validates it and cannot create malformed UTF-8.

7.3 Arrays

Array<T> is an ordered, variable-length structural value collection.

Array literals use brackets:

let users = [
    first_user,
    second_user,
];

let empty: Array<User> = [];

Array indexing uses array[index]. The index must be an integer in range. Out-of-range reads or writes panic. A checked get operation returns an Option.

Index assignment replaces an existing element and never grows the array. ProScript has no built-in slice expression.

7.4 Maps

Map<K, V> is a structural value collection from eligible keys to values.

Map literals use contextual map and ::

let headers = map {
    "content-type": "application/json",
    "accept": "application/json",
};

let empty: Map<string, string> = map {};

Map order is unspecified. Maps use named lookup and mutation methods rather than bracket indexing because key absence is ordinary data.

The core operation signatures are:

Map<K, V>.get(key: K) Option<V>
Map<K, V>.set(key: K, value: V) unit
Map<K, V>.remove(key: K) unit

get is a read-only observation. Its non-unit result must be used or explicitly discarded. set and remove require a writable map place and always return unit. Setting an existing key replaces its value; removing an absent key is a successful no-op. A caller that needs the previous value observes it explicitly with get; no mutator overload returns it.

A map literal behaves as sequential insertion into an initially empty map. Entries evaluate in source order, with each key followed by its value, exactly once. A later key equal to an earlier key replaces the earlier associated value. Duplicate source entries do not produce a compile-time error or runtime panic, even when the duplicate is statically apparent.

Eligible map keys are:

  • bool;
  • string;
  • the integer family, including byte and rune; and
  • nominal types whose ultimate underlying type is one of those primitives.

Floats, structs, enums, collections, resources, handles, protocol values, and other types are not map keys. User-defined hashing and equality do not extend the key set.

7.5 Sets

Set<T> is a structural value collection of unique eligible values:

let permissions = set {
    "read",
    "write",
};

let empty: Set<string> = set {};

Set element eligibility is the same scalar whitelist as map-key eligibility. A set literal behaves as sequential insertion into an initially empty set. Its elements evaluate in source order exactly once. Repeated equal elements collapse to one member without an error or panic, even when the duplicate is statically apparent. Set order is unspecified.

The core operation signatures are:

Set<T>.contains(value: T) bool
Set<T>.add(value: T) unit
Set<T>.remove(value: T) unit

contains is a read-only observation whose result must be used or explicitly discarded. add and remove require a writable set place and return unit whether they change membership or encounter an already-present or absent element. No mutator overload reports the prior membership state.

Collection mutator arguments evaluate exactly once from left to right. The mutation occurs only after argument evaluation completes; a failed or panicking argument leaves the collection unchanged.

These source-literal rules do not alter the separately defined JSON duplicate handling rules.

7.6 Option and Result

Absence uses Option<T>:

enum Option<T> {
    Some(T),
    None,
}

Recoverable failure uses Result<T, E>:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Some, None, Ok, and Err are predeclared unqualified variants. There is no null, nullable reference, or implicit absence.

Result places no universal protocol constraint on E. APIs that report an error generically impose Debug or another relevant protocol locally.

7.7 Structs

A struct declares named fields:

struct User {
    id: UserId,
    name: string,
    email: string,
}

Fields use optional pub followed by name: Type and comma separation. Fields are package-private by default independently of the struct declaration. A multiline declaration is formatted with a trailing comma and has no semicolon after its closing brace.

Struct construction names every field exactly once:

let user = User {
    id: user_id,
    name: payload.name,
    email: payload.email,
};

Every field must be initialized. Struct declarations have no default field initializers, and construction has no spread or update syntax.

Inside the declaring package, construction may name every field. An importing package may directly construct a public struct only when every field is public. If any field is private, importers use a public associated constructor or factory; they cannot omit hidden fields or supply a construction ...

A generic struct construction writes its complete owner type-argument vector:

let box = Box<int> {
    value: 42,
};

Fields, destinations, expected results, and later uses do not infer that vector. Every owner argument appears once in declaration order. Empty, partial, and placeholder vectors are invalid.

Structs have structural value semantics and never support whole-struct == or !=, regardless of their fields.

7.8 Enums

An enum declares a closed set of variants:

enum CreateUserError {
    InvalidEmail,
    Storage(DatabaseError),
    Conflict(UserId, string),
}

Variants are constructed and named through :::

CreateUserError::InvalidEmail
CreateUserError::Storage(error)

A generic enum writes its complete owner type-argument vector before ::, including for payload-free values and payload-bearing constructor values:

Outcome<int, string>::Success(value)
Outcome<int, string>::Pending

let wrap: fn(int) Outcome<int, string> =
    Outcome<int, string>::Success;

Payloads, destinations, expected results, and later uses do not infer that vector. Every owner argument appears once in declaration order. Empty, partial, and placeholder vectors are invalid.

The parser commits to a generic aggregate head when a syntactically valid nonempty type-argument list is followed by { for struct construction or :: for enum selection. This decision uses tokens only. Before commitment, bounded speculation restores the input for ordinary expression parsing; after commitment, errors recover within the construction and do not reinterpret the angle brackets as comparisons.

Enum equality is derived structurally when every payload type is comparable. An enum containing a non-comparable payload is not comparable.

A payload-bearing variant constructor is a callable value:

let wrap_error: fn(DatabaseError) CreateUserError =
    CreateUserError::Storage;

Anonymous functions and payload-bearing variant constructors are the only sources of callable values. A payload-free variant is an ordinary value, not a zero-parameter callable.

7.9 Nominal types and aliases

An open nominal type creates a distinct type with public explicit construction:

type RequestId string;

An opaque nominal type restricts construction from its underlying type to its declaring package:

pub type opaque UserId string;

An exact alias creates another name for the same type:

type UserTable = Map<UserId, User>;

Aliases may be generic:

type Table<Key, Value> = Map<Key, Value>;

Aliases have no independent identity, methods, visibility authority, or conformances. Every generic alias parameter must be used in its target. Alias cycles are invalid.

A fully instantiated transparent alias may be used as a struct or enum construction head when normalization produces the corresponding concrete aggregate:

type IntBox = Box<int>;
let box = IntBox { value: 42 };

type BoxAlias<Value> = Box<Value>;
let other_box = BoxAlias<string> { value: "ready" };

type IntOutcome<Failure> = Outcome<int, Failure>;
let pending = IntOutcome<string>::Pending;

Open and opaque nominal declarations are not generic.

An explicit conversion between a nominal type and its immediately declared underlying type uses call notation:

let request_id = RequestId(raw);
let raw_again = string(request_id);

Construction of an opaque nominal is legal only in its declaring package. Observation through conversion to the immediate underlying type is legal wherever the opaque value is usable.

Conversions traverse one declared nominal edge at a time. They do not skip nominal layers or convert directly between unrelated nominal types with equal representations.

Primitive-backed nominals inherit only observation-producing comparisons: ==, !=, and, when ordered, <, <=, >, and >=. Operands must have the same nominal type. Arithmetic, bitwise operations, shifts, unary operators, and concatenation do not inherit.

7.10 Recursive types

Structs, enums, and resources may refer recursively to themselves without source-level pointer or box syntax. The compiler supplies unobservable managed indirection where required.

A recursive type must have a finite construction path. Generic recursion must recur with the same parameter vector rather than expanding indefinitely.

Nominal underlyings cannot be recursive.

7.11 Callable types

A callable signature type uses fn:

fn(Request) Response
fn(int, int) Result<int, ArithmeticError>

Parameter names are omitted in callable types. An omitted result type means unit.

Named functions and methods are not first-class values. Anonymous functions and enum variant constructors are the callable-value sources.

7.12 Protocol types

A protocol name may appear as a static type. A conforming concrete value widens implicitly at a protocol-typed assignment, argument, field, element, or return.

This permits fully typed heterogeneous collections without variadics or Any:

let items: Array<Displayable> = [
    user,
    order,
    invoice,
];

log.info("request observed", [
    request_id,
    method,
    path,
]);

Protocol values:

  • expose only protocol methods;
  • are not downcastable;
  • are not comparable;
  • are not map keys or set elements;
  • are not structurally JSON-eligible; and
  • cannot cross task boundaries.

Ordinary values widen with ordinary value semantics. A resource widened to a protocol value retains its resource identity and lifetime claim.

There is no implicit widening or conversion from one protocol-value type to another. A bare protocol name is the protocol-value syntax; there is no dyn or any marker.

7.13 Equality

Primitive equality follows each primitive type's rules. Equality requires compatible static types and cannot be overloaded.

Enums compare by variant and then by corresponding payloads. An enum is comparable only when every possible payload is comparable.

Collections have structural equality when their element domains are comparable:

  • arrays compare corresponding elements in order;
  • maps compare key/value associations independently of storage or iteration order; and
  • sets compare membership independently of order.

Different collection types are not comparable. A non-comparable element, key, or value makes the containing collection non-comparable. In particular, a collection containing a struct is not comparable because structs never support whole-value equality.

Resources, synchronized handles, channels, tasks, closures, protocol values, and native handles do not receive automatic identity equality. An abstraction that needs a meaningful identity comparison exposes a named operation or an ordinary comparable identifier.

Structural equality invokes no user code, cannot suspend, and cannot observe allocation identity, capacity, hash-table layout, or shared backing storage.

8. Bindings, constants, and value semantics

8.1 Immutable bindings

let introduces an immutable local binding:

let user = load_user()?;
let retries: int = 3;

The binding cannot be reassigned and cannot serve as the root of ordinary structural mutation.

8.2 Mutable bindings

let mut permits reassignment and transitive mutation through ordinary value fields:

let mut retries = 0;

for retries < maximum_retries {
    retries += 1;
}

mut controls the receiving binding or parameter. It does not change the static type.

Capability handles may expose safe identity operations through immutable bindings. Calling such an operation does not reassign the binding.

8.3 Delayed initialization

A local may be declared before initialization:

let user;

if cached {
    user = cached_user;
} else {
    user = load_user()?;
}

return user;

The compiler must prove that every read occurs after initialization.

An immutable delayed binding may be initialized exactly once on each reachable path. A mutable delayed binding may be assigned again after initialization.

The type may be written explicitly:

let user: User;

When omitted, all local constraints must determine one unambiguous type.

There is no implicit zero value or runtime initialized flag.

8.4 Parameters

Parameters are immutable bindings by default:

fn process(request: Request) Response {
    // ...
}

mut permits local reassignment and ordinary nested value mutation:

fn increment(mut counter: Counter) Counter {
    counter.value += 1;
    return counter;
}

Parameter mutation does not structurally mutate the caller's ordinary value. Capability-bearing fields retain their own identity semantics.

8.5 Constants

const declares an immutable compile-time value:

package server

const DEFAULT_PORT = 8080;
pub const MAX_REQUEST_SIZE: uint64 = 1_048_576;

fn configured_port() int {
    let port = DEFAULT_PORT;
    return port;
}

Constants may appear only at package scope. A constant written at the top level of any source file belongs to the entire directory package, not to that individual file. Every source file in the package may name it without an import, independently of file or declaration order.

A constant is package-private by default. pub const makes it visible to importing packages.

Constants participate in the package declaration namespace. They cannot duplicate another package declaration or use a protected compiler-predeclared name.

Declaring const inside a function, anonymous function, loop, conditional, match arm, arena, recovery block, or any other executable or nested lexical scope is a compile-time error. Function-local immutable values use let. Conversely, let cannot appear at package scope.

A constant expression may use:

  • primitive scalar, string, and unit literals;
  • payload-free enum variants;
  • other constants;
  • grouping;
  • supported primitive unary operators; and
  • supported primitive arithmetic, comparison, Boolean, bitwise, shift, and string-concatenation operators.

It cannot call a user function or method, allocate a collection or aggregate, construct a payload-bearing enum or resource, use control flow, access a field or index, spawn, suspend, panic, perform I/O, or use runtime data.

The final compile-time result is stored as the constant value. Constant arithmetic must be valid and representable; invalid operations are compile-time errors.

The constant's type is inferred unless an explicit : Type annotation selects or documents it. Every constant has one static type. Constants cannot contain structs, arrays, maps, sets, resources, or other composed object graphs.

Constants are evaluated during checking. The language does not define const fn, runtime global initialization, a stable address, native static storage, or another observable storage strategy for a constant.

8.6 Ordinary aggregate value semantics

Ordinary structs, enums, arrays, maps, sets, and closures are structural values. Assignment, parameter passing, return, and capture produce logically independent ordinary structures.

let original = Counter { value: 0 };
let mut second = original;
second.value += 1;

// original.value is still 0.

The implementation may use copy-on-write storage, persistent structures, shared immutable backing, copy elision, or in-place optimization when later mutation cannot reveal unwanted aliasing.

Capability-bearing elements preserve their own semantics transitively. Copying a struct that contains a channel endpoint, Shared<T>, native handle, or resource does not duplicate the underlying capability.

8.7 Resource identity semantics

A resource is not a structural value. Assignment, parameter passing, return, storage, capture, and protocol erasure create handles to the same lifecycle identity.

Mutation through a resource identity is visible through its aliases. Lifetime claims prevent a valid alias from becoming dangling.

8.8 Closures and capture

Closures capture:

  • immutable ordinary values by snapshot;
  • safely shareable handles by protected identity; and
  • mutable locals through a same-task binding cell.

A closure that captures a mutable local is not shareable across tasks.

mut self is not an ordinary mutable local for this rule. It carries temporary mutation authority over the caller's writable receiver place, and that authority ends when the method call performs transactional copy-back. Consequently, an anonymous function cannot directly capture self from a mut self method. This is rejected whether the closure reads or mutates self, escapes, is passed onward, or is immediately invoked. There is no escape-analysis exception.

An explicit ordinary value copy makes detachment visible:

fn Counter::incrementer(mut self) fn() {
    let mut detached = self;

    return fn() {
        detached.value += 1;
    };
}

The closure retains detached through a same-task binding cell. Calls to it do not later mutate the caller's receiver place. let snapshot = self; similarly creates an immutable ordinary value for snapshot capture. Capability-bearing fields retain their existing identity semantics through these copies.

Concurrent-callback requirements are inferred and propagated through function and method types. Framework authors do not write a manual “thread-safe” annotation to make a callback safe.

9. Functions, methods, and generics

9.1 Named functions

A named function declares every parameter and every non-unit result type:

pub fn create_user(
    request: CreateUserRequest,
    database: DatabasePool,
) Result<User, CreateUserError> {
    // ...
}

The result type follows the parameter list directly. ProScript does not use -> or a result colon. Omitting the result type declares unit.

Parameters use name: Type; a mutable parameter uses mut name: Type. Multiline parameter lists are formatted with a trailing comma.

Named functions require explicit return for returned values:

fn load() Result<Config, LoadError> {
    return Ok(config);
}

A final expression in a named function is not an implicit return.

9.2 Anonymous functions

An anonymous function omits only the function name:

fn(request: Request) Response {
    return handle(request);
}

When an immediate expected callable type uniquely determines parameter types, anonymous parameter annotations may be omitted:

router.post("/users", fn(request) Response {
    return handle(request);
});

Without such an expected type, each parameter type is required. The result type is never inferred from context; omission declares unit.

Anonymous functions use explicit return just like named functions.

9.3 Associated functions and methods

Type-owned callables are declared at package scope:

pub fn UserId::parse(raw: string) Result<UserId, InvalidUserId> {
    // ...
}

pub fn User::display_name(self) string {
    return self.name;
}

fn Counter::increment(mut self) {
    self.value += 1;
    return;
}

An associated function has no receiver and is called with Type::name(...). A method's first parameter is exactly self or mut self.

self permits observation and identity-safe capability operations. mut self permits ordinary nested mutation and complete replacement of the receiver:

fn Counter::reset(mut self) {
    self = Counter { value: 0 };
    return;
}

Replacement follows ordinary assignment and cleanup rules and is visible through the caller's writable place.

A read-only self method may be called on an immutable binding or temporary. A mut self method requires an existing writable place; the compiler does not create a hidden mutable temporary.

Receiver fields and methods are always selected explicitly through self. There is no implicit unqualified receiver-member lookup.

Structs, enums, open and opaque nominal types, and resources may own methods and associated functions. An alias cannot.

Only the package that declares a type may declare its methods, associated functions, or conformances.

Named functions, associated functions, and methods cannot be used as values. Wrap one in an anonymous function when a callable value is required:

let parser: fn(string) Result<UserId, InvalidUserId> =
    fn(raw: string) Result<UserId, InvalidUserId> {
        return UserId::parse(raw);
    };

The name new has no compiler-designated constructor meaning. It is an ordinary associated-function name and follows the same rules as every other name.

An enum variant and any callable owned by that enum cannot share an exact name:

enum State {
    Ready,
}

fn State::Ready() State { // compile-time error
    return State::Ready;
}

The restriction applies equally to associated functions, read-only methods, and mut self methods. It is a declaration-time package-wide rule, not call-site overload resolution. No alternate syntax or precedence chooses one declaration over the other.

9.4 Generic declarations

Generic parameters use angle brackets:

struct Cache<Key, Value> {
    entries: Map<Key, Value>,
}

fn identity<Value>(value: Value) Value {
    return value;
}

A bound follows a parameter name with :. Multiple bounds use +:

fn render<Value: Displayable + Debug>(value: Value) string {
    return value.display();
}

Bound order is insignificant. Duplicate bounds and method-name collisions between composed protocols are compile-time errors.

ProScript has no where clauses, specialization, conditional conformance, or bounded-method extension syntax.

9.5 Generic calls

Explicit generic arguments use angle brackets:

decode<User>(body)
json.decode<Array<User>>(body)
UserRepository::load<User>(database)
request.json<CreateUserPayload>()

After a direct callable head, <...> is parsed as generic arguments when it contains a valid nonempty type-argument list and the closing > is followed by (. Otherwise < and > are comparisons.

Parsing does not consult name resolution or capitalization. Explicit generic application is not available on arbitrary runtime callable values.

Nested generic closers may be adjacent:

Map<string, Array<Option<User>>>

When the complete type-argument list is omitted, a direct generic package function, associated function, or method call infers callable-local generic parameters only by structurally matching declared parameter types against the static types of explicit arguments:

fn first<Value>(values: Array<Value>) Value {
    return values[0];
}

let user = first(users); // Value = User

Constraints from all arguments must agree. Matching descends through generic type constructors and callable signatures, expands aliases, preserves nominal identity, and inserts no conversion. Ordinary numeric-literal defaulting and contextual representability participate. An anonymous function parameter may be contextually typed after other arguments determine its callable parameter type.

Inference never uses the expected result type, assignment or return destination, implementation or anonymous-function body, later uses, runtime values, or dynamic dispatch. Every generic parameter must have one complete bound-satisfying solution; otherwise the call must provide the complete explicit list:

fn unavailable<Value>() Value {
    panic("value unavailable");
}

let user = unavailable<User>(); // explicit: no argument determines Value

Partial explicit type-argument lists are not supported.

This call-site inference applies only to callable-local generic parameters. It does not infer owner arguments for generic struct construction or enum variant construction and value selection.

9.6 Generic owners

A method or associated function owned by a generic type repeats the owner's declared parameter names:

fn Cache<Key, Value>::get(self, key: Key) Option<Value> {
    // ...
}

Owner bounds are inherited. Callable-local generic parameters follow the callable name:

fn Cache<Key, Value>::map<Output: Debug>(
    self,
    transform: fn(Value) Output,
) Array<Output> {
    // ...
}

A generic conformance uses the same owner head:

conform PageIterator<Element>: Iterator<Element>;

Type-owned generic-call inference occurs in this order:

  1. Determine and normalize the complete owner instantiation from the written owner vector for an associated-function call or the receiver's exact type for a method call.
  2. Substitute those owner arguments through the callable signature and inherited owner bounds.
  3. Infer only callable-local parameters from explicit ordinary arguments under the bounded call-inference rule, or accept one complete explicit callable-local vector.
  4. Validate every substituted owner and callable-local bound.

The receiver provides no additional callable-local inference evidence. Owner parameters are never re-inferred from ordinary arguments. Result expectations, destinations, bodies, later uses, runtime values, and partial explicit vectors provide no inference evidence. Transparent aliases normalize before this process and cannot create a second owner instantiation.

Conformance must apply uniformly for every permitted owner instantiation.

10. Protocols

10.1 Declaration

A protocol declares required instance-method signatures:

pub protocol Displayable {
    fn display(self) string,
}

pub protocol Iterator<Element> {
    fn next(mut self) Option<Element>,
}

Requirements:

  • have no body;
  • begin with self or mut self;
  • declare every additional parameter type;
  • use the ordinary direct result notation; and
  • are comma-separated.

Protocols cannot declare fields, constants, associated functions, or default method bodies.

10.2 Explicit conformance

Conformance is declared separately in the conforming type's package:

conform User: Displayable;

fn User::display(self) string {
    return self.name;
}

The compiler checks exact method names, receiver mutability, parameters, and results. Matching methods without conform do not create structural conformance.

A type may conform at most once to a protocol family. For a generic protocol, each instantiation is distinct, but a type may select only one instantiation of that family:

conform Pager: Iterator<Page>;

Retroactive conformance for imported types is not permitted.

A conformance declaration has no visibility modifier. It is externally usable when both the conforming type and protocol are externally nameable. A method that satisfies a public protocol may remain package-private for direct lookup; dispatch through the public protocol value remains available.

10.3 Generic use

A generic parameter constrained by a protocol exposes exactly that protocol's methods. Conformance is checked at each instantiation.

Protocol values provide dynamic dispatch. Generic bounds provide statically specializable dispatch and preserve the concrete type.

10.4 Iteration protocols

The standard iteration protocols are:

protocol Iterator<Element> {
    fn next(mut self) Option<Element>,
}

protocol Iterable<Element> {
    fn iterate(self) Iterator<Element>,
}

A for ... in source may conform to either. An Iterable first produces an iterator. An Iterator is used directly through a compiler-managed mutable local.

Value-semantic iterable sources naturally iterate a snapshot copy. A capability-backed iterable defines its own documented live behavior.

11. Expressions and evaluation

11.1 Evaluation order

Expressions evaluate from left to right in written source order unless a specific construct states otherwise.

This includes:

  • callable targets and receivers;
  • call arguments;
  • struct fields in construction order;
  • enum payloads;
  • array, map, and set elements;
  • select operands before arbitration; and
  • interpolation slots.

An implementation may reorder only when errors, panics, suspension, allocation, resource acquisition, cleanup, and every other observable effect remain unchanged.

11.2 Grouping

Parentheses group one expression:

(a + b) * c

ProScript has no tuple expression. (value,) and comma-separated parenthesized values are invalid.

11.3 Calls and postfix operations

Calls use parentheses:

load_user(id)
database.users.insert(user)
UserId::parse(raw)

Postfix operations bind more tightly than unary and binary operators. They include:

  • calls;
  • field and method selection with .;
  • type and package qualification with :: or package .;
  • array indexing; and
  • error propagation with ?.

Named call arguments do not exist. Arguments are positional.

11.4 Operator precedence

From tightest to loosest:

  1. postfix operations;
  2. unary -, !, and ^;
  3. *, /, %, <<, >>, &, and &^;
  4. +, -, |, and ^;
  5. ==, !=, <, <=, >, and >=;
  6. &&;
  7. ||; and
  8. finite range delimiters .. and ..=.

Ordinary binary operators are left-associative. A range delimiter is non-associative, so one range expression cannot be an endpoint of another even when parenthesized. Each endpoint is otherwise a complete non-range expression:

start + 1..limit * 2 // (start + 1)..(limit * 2)
0..3..5              // syntax error

Assignment is a statement and has no expression precedence.

&& and || require bool operands and short-circuit from left to right. There is no truthiness.

Comparison chaining parses left-associatively and is normally rejected by typing:

a < b < c // parses as (a < b) < c

11.5 Integer arithmetic

Integer operators require operands of the same integer type unless an explicit conversion is written.

Runtime +, -, *, unary negation, division overflow, and their compound forms panic when the exact result is not representable. Behavior does not vary between debug and release modes.

The expression-position negative-literal rule checks the final mathematical value of -literal, including grouping around only the literal, before materializing its signed integer value. A further outer negation is an ordinary runtime operation and follows this overflow rule. Package constants instead evaluate that operation before execution and reject overflow statically.

Integer division truncates toward zero. Remainder has the dividend's sign. Division or remainder by zero panics.

Shift counts must be nonnegative and representable. A shift producing bits outside the fixed-width destination never introduces arbitrary precision. The right operand may have any integer type. A negative constant count is a compile-time error and a negative runtime count panics. Nonnegative counts have no upper bound and a count at least as large as the left type's width does not itself panic. Left shift truncates to the fixed width; right shift is arithmetic for signed left operands and logical for unsigned left operands.

Bitwise operators are &, |, ^, &^, <<, and >>. Prefix ^ is bitwise complement.

For a signed minimum value, division by -1 panics because the quotient is not representable. Remainder by -1 is zero.

Named standard-library operations provide deliberate wrapping, checked, saturating, and other arithmetic policies.

11.6 Floating-point arithmetic

float32 and float64 follow IEEE 754.

Runtime operations preserve NaN, infinities, and signed zero. Runtime floating-point division by zero produces IEEE results rather than a panic.

NaN compares unequal to every value, including itself. Ordered comparisons with NaN are false. Positive and negative zero compare equal.

Primitive floating operations round each source-level result independently. An optimizer cannot silently fuse multiplication and addition. A named fused operation may request single-rounding behavior.

% is not defined for floats.

Constant division by zero is a compile-time error for both integer and floating constants.

11.7 Numeric conversions

Primitive conversion uses call notation:

let wider = int64(value);
let count = int(float_value);

There are no implicit mixed numeric operations.

Integer-to-integer conversion succeeds only when the value is representable in the destination; otherwise it panics. It never silently truncates or wraps.

Integer-to-float and float-to-integer conversions follow their target representability and rounding rules. Converting NaN, infinity, or an out-of-range float to an integer panics.

Narrowing a finite float64 beyond the float32 finite range produces signed infinity according to IEEE 754.

Validation-oriented code uses named checked operations returning typed results.

11.8 String concatenation

string + string produces a new logical string:

let path = "/users/" + user_id_text;

No operand is implicitly stringified. All other uses of + with strings are compile-time errors.

11.9 Struct construction in control-flow headers

An unparenthesized top-level struct construction is not permitted directly in an if, if let, match, condition-form for, or iterator-for header. Parenthesize it or place it inside another delimiter:

if (AccessCheck {
    user: user,
    route: route,
}).allowed() {
    authorize();
}

if authorize(AccessCheck {
    user: user,
    route: route,
}) {
    process();
}

The rule is syntactic and does not use type lookup.

11.10 Block values

An expression-owned block obtains its value from its final expression without a semicolon:

let response = match result {
    Ok(user) => {
        audit(user);
        Response::ok(user)
    },

    Err(error) => handle_error(error),
};

Ordinary statements inside the block retain semicolons.

Named and anonymous function bodies do not implicitly return their final expression.

11.11 Value-producing if

An ordinary if may produce a value when it has a final else:

let status = if healthy {
    "up"
} else {
    "down"
};

Every normally completing branch must produce the same exact static type after ordinary contextual typing. A branch that terminates with return, break, continue, or panic does not participate in the type join.

Statement-form if may omit else and produces unit.

ProScript has no ternary operator.

11.12 Match expressions

match evaluates one scrutinee once and selects the first matching arm:

let response = match result {
    Ok(user) => Response::json(Status::Created, user),

    Err(CreateUserError::InvalidEmail) => {
        Response::unprocessable_entity()
    },

    Err(error) => handle_error(error),
};

Arms use pattern => expression and are comma-separated. A multiline match is formatted with a trailing comma.

Every match must be exhaustive. Fully unreachable arms and alternatives produce warnings. Partial overlap is permitted, and source order resolves it.

Match guards do not exist. Use an if expression inside an arm.

11.13 Error propagation

Postfix ? unwraps Ok(value) or propagates Err(error):

let user = load_user(id)?;

Propagation requires the enclosing function to return Result<_, E> with the exact same error type E. ProScript performs no implicit error conversion, variant lifting, or From-style adaptation.

Use an explicit map_error or match to adapt an error.

11.14 Panic

panic(message) produces a structured panic:

panic("invariant violated")

The argument is a string or an existing Panic value. Arbitrary panic payloads do not exist.

Passing an existing Panic re-propagates it while preserving its original origin and stack information and recording the new propagation point.

Panic is terminating control flow for type checking, but ProScript has no source-visible never type.

12. Statements and control flow

12.1 Semicolons

Ordinary statements end with ;:

let user = load_user()?;
audit(user);
retries += 1;
return Ok(user);

A standalone braced control-flow statement has no trailing semicolon. A braced expression embedded in an ordinary statement is terminated by the containing statement's semicolon.

Empty statements are invalid; repeated semicolons are errors.

12.2 Expression statements and discard

An expression statement must have type unit. Discarding a non-unit value requires:

_ = expression;

The expression evaluates exactly once. Its resulting value follows ordinary cleanup rules.

There is no discard expression and no compound discard assignment.

12.3 Assignment places

A writable place consists of a writable root followed by zero or more struct field and array-index steps:

counter
user.profile.name
users[index].status

Writable roots include:

  • let mut bindings;
  • mutable parameters and receivers;
  • mutable pattern bindings;
  • compiler-provided mutable protected views; and
  • a delayed binding during whole initialization.

Immutable bindings, constants, temporaries, call results, map keys, set elements, optional chains, and protocol values are not writable places.

Maps and sets mutate through methods.

12.4 Plain assignment

Plain assignment is:

place = expression;

Evaluation occurs in three phases:

  1. evaluate and validate the writable root and selectors left to right;
  2. evaluate the right-hand side; and
  3. commit once through the saved logical path.

The implementation never retains a physical managed address across right-hand side evaluation. At commit, it follows the saved selectors through the root's then-current value and revalidates array bounds. Unrelated same-root changes made by right-hand-side evaluation are preserved. If evaluation fails or panics, no assignment commit occurs.

The discard target _ is valid only with plain assignment.

Multiple assignment and assignment expressions do not exist.

12.5 Compound assignment

ProScript supports +=, -=, *=, /=, %=, <<=, >>=, &=, |=, ^=, and &^=.

A compound assignment:

  1. evaluates and reads its place once;
  2. evaluates the right operand;
  3. applies the corresponding typed binary operator; and
  4. commits once.

String += is valid. A delayed binding and _ cannot be compound targets. When right-operand evaluation changes the same writable root, the compound assignment's final write updates the originally selected logical leaf and preserves unrelated changes.

ProScript has no ++ or --.

12.6 Conditional statements

if condition {
    statements
} else if other_condition {
    statements
} else {
    statements
}

Conditions have type bool. Parentheses are optional grouping, not required syntax. Braces are always required.

12.7 if let

if let tests one refutable pattern:

if let Some(user) = cache.get(key) {
    greet(user);
} else if let Some(guest) = trial_session(key) {
    tour(guest);
} else {
    onboard();
}

The scrutinee evaluates once. Pattern bindings exist only in the success block. The full match-pattern grammar is accepted.

An irrefutable pattern makes an else path unreachable and produces the ordinary unreachable-code warning.

if let is statement-only. It does not combine with &&. ProScript has no while let or let else.

12.8 Loops

ProScript uses one for keyword.

Condition loop:

for retries < maximum_retries {
    retries += 1;
}

Unconditional loop:

for {
    process_next();
}

Iterator loop:

for user in users {
    process(user);
}

The iterator pattern may be:

  • one identifier;
  • mut plus one identifier; or
  • one flat irrefutable struct pattern.

The source expression evaluates once.

Built-in iterable sources are:

  • Array<T>, in index order;
  • Map<K, V>, as MapEntry<K, V> values in unspecified order;
  • Set<T>, in unspecified order;
  • Receiver<T>, as a live competing stream ending when drained and closed; and
  • Range<T>, as a finite ascending integer sequence.

User-defined sources implement Iterable<Element> or Iterator<Element>.

Each iteration binds an independent ordinary value. Value-semantic collections iterate a snapshot of the source. Capability-backed iterables may define live behavior.

Finite first-class integer ranges use:

let exclusive = 0..3;  // 0, 1, 2
let inclusive = 0..=3; // 0, 1, 2, 3

for attempt in 0..maximum_retries {
    retry(attempt);
}

Both endpoints are required and must have one exact primitive integer type. An immediately expected Range<T> contextually types both endpoints. Without that context, an untyped literal may take the opposite endpoint's type when representable; two untyped literals default normally to int. Differently typed endpoints receive no implicit conversion. The exact aliases byte and rune work through uint8 and int32; integer-backed nominals do not qualify.

Range<T> is a compiler-predeclared value-semantic type, nameable in types and automatically iterable, with unobservable representation and no source construction except a range expression. It has no v1 structural pattern, automatic equality, hashing, map-key eligibility, JSON representation, or range-specific method surface.

The start and end expressions evaluate once from left to right. Iteration advances by one. start..end excludes the end and start..=end includes it. Equal endpoints produce zero and one elements respectively; a start greater than the end produces an empty range. An inclusive range ending at the maximum value of its type yields that value and stops without overflow.

The range delimiter is non-associative and has lower precedence than every ordinary binary operator. Open-ended ranges, descending or stepped iteration, floating and nominal ranges, for i in integer, and bracket slicing are not supported. Range patterns retain their separate forms and rules; in particular, an empty range value is valid while a statically empty range pattern is rejected. Range values are not compile-time constant-compatible.

12.9 Break and continue

break; exits the nearest loop. continue; begins its next iteration.

They are statements and must appear in a block when used as a match arm:

match receiver.receive() {
    Some(value) => process(value),

    None => {
        break;
    },
}

Labels and value-carrying breaks do not exist. Loops do not produce values.

12.10 Return

return; exits a unit-returning function. return expression; exits a value-returning function.

return always exits the innermost named or anonymous function, not merely an expression block.

13. Patterns

13.1 Pattern positions

Full refutable patterns appear in:

  • match arms; and
  • if let.

Irrefutable patterns appear in:

  • initialized let destructuring; and
  • iterator-loop bindings.

13.2 Binding and discard

An identifier binds the matched value. _ discards it. A binding is immutable unless prefixed by mut:

Ok(mut user) => {
    user.login_count += 1;
    persist(user)
},

An underscore-prefixed identifier is a real binding whose unused status is explicitly permitted.

13.3 Whole-subvalue binding

name @ pattern binds the entire matched subvalue and also applies the nested pattern:

event @ BackgroundEvent::UserCreated(
    User { id, .. },
) => record(event, id),

mut name @ pattern makes the whole binding mutable.

13.4 Literal, constant, and variant patterns

Patterns may contain:

  • Boolean, integer, floating-point, rune, byte, and string literals, including negative numeric literal forms;
  • eligible compile-time constants;
  • payload-free enum variants;
  • _;
  • bindings; and
  • parenthesized grouping.

An unqualified identifier resolves as a constant or payload-free variant when such a value exists; otherwise it binds. A qualified path never introduces a binding.

Literal and constant patterns require an exact expected type and use that type's compiler-defined equality. A floating literal follows IEEE equality; there is no NaN literal pattern.

When the scrutinee is an exact nominal whose layered underlying chain ends in the corresponding primitive, a bare literal leaf may be contextually typed as that exact nominal. The eligible literal families are:

  • integer-family literals, including byte and rune;
  • string literals; and
  • Boolean literals.

This contextual typing exists only in pattern position. It does not implicitly construct a nominal in an expression or broaden the constant-value domain. The literal's terminal primitive must match the nominal's terminal primitive; floating-point and non-primitive terminals are ineligible.

For an opaque nominal, a bare literal pattern is available only in the declaring package. Other packages use exported constants of the exact nominal type, bindings, or _. A Boolean-backed nominal has the closed coverage domain true and false; a string-backed nominal remains open and requires a catch-all or binding for exhaustiveness.

type UserStatus string;
type FeatureEnabled bool;

match status {
    "active" => handle_active(),
    _ => handle_other(),
}

match enabled {
    true => enable(),
    false => disable(),
}

13.5 Enum patterns

Enum payload patterns mirror construction:

DatabaseError::UniqueViolation("email")
DatabaseError::UniqueViolation(field)
DatabaseError::UniqueViolation(_)

Payload patterns are positional and may be nested.

For a generic enum, the pattern omits owner type arguments:

Outcome::Success(value)
Outcome::Pending

The scrutinee's exact static type determines the instantiation.

13.6 Struct patterns

Struct patterns use named fields:

User {
    id,
    name: display_name,
    ..
}

A bare field binds the same local name. field: pattern applies another pattern. One final .. ignores all omitted fields. Without .., every field must appear exactly once.

Outside the struct's declaring package, a pattern may mention only public fields. If any private field exists, final .. is mandatory. These rules also apply to flat let and iterator struct patterns.

Resource types do not support structural patterns.

For a generic struct, the pattern writes Box { ... }, never Box<Type> { ... }. The scrutinee's exact static type determines the instantiation.

13.7 Array patterns

Array patterns use brackets:

[]
["users", user_id]
[first, .., last]

Without .., the length must match exactly. At most one .. may appear and matches zero or more ignored elements. It cannot bind the rest.

Array patterns are full refutable patterns and are not accepted in let or iterator bindings.

13.8 Flat let destructuring

An initialized let may use one flat irrefutable struct pattern:

let RequestObservation {
    request_id,
    path,
    ..
} = observation;

let User { id, mut name, .. } = load_user()?;

It permits same-name bindings, field: name renaming, per-binding mut, and one final ...

Nested patterns, literals, enum patterns, alternation, ranges, and @ are invalid in this position. let mut does not prefix a destructuring pattern; mark each mutable binding.

13.9 Alternative patterns

Alternatives use |:

CreateUserError::EmptyName |
CreateUserError::InvalidEmail => reject(),

A leading | is permitted; a trailing | is not. Every alternative must bind the same names with the same types and mutability.

Alternation may appear at any nested pattern position.

@ binds more tightly than |. Group alternatives when one whole-value binding covers them.

13.10 Range patterns

Integer-family, byte, and rune range patterns are:

start..end
start..=end
start..
..end
..=end

.. excludes the upper bound; ..= includes it. A standalone .. is invalid as a complete arm pattern.

Bounds may be:

  • integer literals;
  • rune literals;
  • a negative integer literal;
  • an eligible constant; or
  • a contextually typed literal for an eligible nominal scrutinee.

Calls and general expressions are invalid as bounds.

The expression-position allowance for grouping inside a negative integer literal does not apply here. A negative range bound remains directly - plus an integer literal; a parenthesized bound is invalid.

Empty ranges are compile-time errors. Coverage and overlap are computed over the exact finite or ordered domain of the scrutinee type.

Opaque nominal range literals are available only in the declaring package.

Nominal range patterns remain limited to integer-family terminals, including byte and rune. The string and Boolean nominal literal-leaf rules do not create string or Boolean ranges.

14. Errors, panics, and recovery

14.1 Recoverable errors

Expected failure is ordinary typed data, conventionally represented by Result<T, E>. Error types are usually enums:

enum CreateUserError {
    InvalidEmail,
    DuplicateEmail,
    Storage(DatabaseError),
}

No universal error interface is imposed on E. Generic reporting boundaries require Debug explicitly.

Every non-unit expression result must be consumed, returned, propagated, matched, assigned, or explicitly discarded. This prevents accidental loss of typed failures.

14.2 Structured panic

A panic represents a violated program, runtime, or native invariant rather than an expected domain outcome.

The immutable Panic value contains:

  • a message;
  • a broad PanicKind;
  • a source location;
  • a stack trace; and
  • related panic and error context.

The runtime-owned diagnostic model is:

enum PanicKind {
    Explicit,
    Assertion,
    Runtime,
    Native,
}

struct SourceLocation {
    file: string,
    line: uint32,
    column: uint32,
}

struct StackFrame {
    function: string,
    location: Option<SourceLocation>,
}

type StackTrace = Array<StackFrame>;

struct ErrorContext {
    type_name: string,
    propagation_location: SourceLocation,
    debug_report: Option<string>,
}

Application code inspects a panic through:

failure.message()           // string
failure.kind()              // PanicKind
failure.location()          // Option<SourceLocation>
failure.stack_trace()       // StackTrace
failure.related_panics()    // Array<Panic>
failure.interrupted_error() // Option<ErrorContext>

These return the corresponding immutable diagnostic values. Application code cannot fabricate source locations, stack traces, native panics, or related panic groups.

Runtime faults such as bounds errors, integer overflow, invalid direct conversions, and detected native contract violations become structured panics.

Runtime exhaustion that cannot preserve ordinary unwinding and cleanup, such as catastrophic allocation failure in the panic path, terminates the process immediately. Expected operating-system exhaustion remains a typed error or backpressure outcome.

14.3 Recovery boundary

Recovery uses:

let response = recover {
    create_user_endpoint(request, state)
} with failure {
    log.error("request handler panicked", [failure]);
    Response::internal_server_error()
};

The body executes in the current task. If it completes normally, the recovery expression produces its value. If it panics:

  1. the task unwinds to the boundary;
  2. every exited lexical cleanup runs;
  3. the immutable Panic is bound to the handler name; and
  4. the handler produces the recovery expression's value.

Every completing normal and recovery path must have the same exact result type after alias normalization and ordinary contextual literal typing. A surrounding expected type may check both paths, including by widening both to an explicitly expected protocol type. Without that context, the expression does not infer a common protocol supertype or insert a conversion. A return or panic path does not constrain the join; if neither path completes, the expression is internally noncompleting without exposing a source-level bottom type. Recovery adds no implicit Option, Result, union, or numeric widening.

Recovery resumes after the boundary, not at the panic site. A handler panic continues unwinding outward. A task cannot recover another task's panic. Recovery catches only panic; it does not intercept an ordinary Err, a channel outcome, or a losing selection branch.

14.4 Unhandled panic

An unhandled panic in any green task is process-fatal. Before termination, the panicking task performs its required unwinding and deterministic cleanup when the runtime remains capable of doing so.

Task<T> retains only normal completion values. Task failure is not silently converted into a task-result variant. Work whose failure must be observed returns Result explicitly.

14.5 Cleanup panics

Automatic cleanup returns unit. Expected finalization failure must be exposed by an explicit typed operation such as commit, finish, or close.

If automatic cleanup cannot uphold its invariant, it panics.

When cleanup panics during another panic:

  • the initiating panic remains primary;
  • cleanup panics are attached in occurrence order;
  • all remaining cleanups are attempted; and
  • nested panic groups are flattened.

When cleanup panics while an Err(E) is leaving a scope, the cleanup panic takes control. It records the interrupted error's static type and propagation location and records an immutable Debug report when E supports Debug.

15. Managed memory and arenas

15.1 Managed heap

Ordinary dynamic allocations live in a precise tracing garbage-collected heap. Cycles are supported and collectible.

Programs do not manually free managed memory and do not use ordinary ownership, borrowing, or lifetime annotations.

The ProScript 0.1 production collector is precise, concurrent, and non-moving. The language nevertheless treats managed addresses as unstable and unobservable, allowing future collectors to move objects without changing source semantics.

The collector prioritizes low and reasonably predictable pause times for concurrent servers over maximum collection throughput. This is a performance objective, not a hard real-time pause bound.

Ordinary source cannot obtain or retain a raw managed address. Native code uses opaque handles, exact stable-payload leases, runtime-owned output reservations, or transparent copied fallback storage under P-357.

Garbage-collection timing never performs correctness-critical non-memory resource cleanup.

15.2 Explicit arenas

An arena is a scoped allocation context:

arena {
    let response = build_large_response();
    send(response);
}

An optional name may identify an arena for diagnostics or advanced APIs when such an API requires it:

arena scratch {
    build_index();
}

Eligible allocations made directly or by ordinary called and transparently suspending functions use the innermost active arena automatically. Allocator parameters do not propagate through ordinary APIs.

Nested arenas are permitted.

15.3 Arena escape

An arena-backed value may safely escape:

fn create_user() User {
    let out;

    arena scratch {
        let user = build_user();
        out = user;
    }

    return out;
}

The runtime promotes the reachable escaped graph to the nearest longer-lived parent arena or to the managed heap. It may allocate a predictably escaping value directly in its eventual region.

Promotion preserves language-level identity and updates aliases. Placement and movement are unobservable except through documented performance diagnostics.

15.4 Spawn from an arena

There is one spawn behavior. Spawned work may outlive the current arena. Captured arena-backed graphs are selectively promoted before the task becomes live. The arena is not retained and does not wait merely to preserve captured memory.

The arena allocation capability itself cannot escape into spawned work.

16. Deterministic resources

resource applies to domain-level lifecycle responsibilities as well as files, sockets, and database handles. It represents identity-bearing temporary authority, reservation, or responsibility whose unfinished lifecycle requires deterministic fallback work. All aliases observe the same lifecycle identity.

Examples include inventory reservations, payment authorization holds, distributed job leases, idempotency claims, quota or concurrency permits, and provisioned external accounts that require compensation if activation never completes.

Ordinary durable business data remains value-semantic. Types such as User, Order, Invoice, and Money remain structs or enums merely because they are mutable or persisted. A resource is appropriate when identity, automatic lifetime registration, exactly-once cleanup invocation, and mandatory fallback work are all part of the abstraction's correctness contract.

The usual design exposes an explicit typed success operation and reserves cleanup for the unfinished path:

resource InventoryReservation {
    inventory: InventoryService,
    reservation_id: ReservationId,
    state: ReservationState,

    cleanup(mut self) {
        if self.state == ReservationState::Active {
            self.inventory.release_for_cleanup(self.reservation_id);
        }
    }
}

pub fn InventoryReservation::commit(
    mut self,
) Result<OrderId, InventoryError> {
    let order_id = self.inventory.commit_reservation(
        self.reservation_id,
    )?;

    self.state = ReservationState::Committed;
    return Ok(order_id);
}

commit reports expected business failure as typed data. Cleanup observes the protocol state and releases only an unfinished reservation. It is not a hidden success path, general-purpose defer, or distributed transaction system.

Cleanup returns unit and cannot propagate expected failure with ?. Resource implementations must handle cleanup failure explicitly, delegate to a unit-returning cleanup-safe protocol, or panic when they cannot uphold the automatic invariant. External compensation still requires ordinary idempotency, timeouts, retry, and reconciliation. Cleanup is guaranteed during orderly exit, error propagation, and panic unwinding in a running process, not after abrupt process or machine termination.

16.1 Resource declaration

An identity-bearing resource is declared explicitly:

pub resource DatabaseTransaction {
    connection: DatabaseConnection,
    state: TransactionState,

    cleanup(mut self) {
        if self.state == TransactionState::Active {
            self.rollback_for_cleanup();
        }
    }
}

General form:

resource Name<Parameters> {
    field: Type,

    cleanup(mut self) {
        statements
    }
}

A resource may have zero or more fields and may be generic or recursively shaped under the ordinary recursive-type rules.

Every resource has exactly one cleanup(mut self) clause. It:

  • follows every field and is the final body item;
  • has no fn, visibility, additional parameter, generics, or result type;
  • returns unit;
  • may suspend;
  • is invoked only by the runtime;
  • cannot be called, referenced, selected, overridden, or used as protocol conformance; and
  • is not part of the ordinary member namespace.

Ordinary methods and associated functions remain package-scope declarations:

pub fn DatabaseTransaction::commit(
    mut self,
) Result<unit, DatabaseError> {
    // ...
}

16.2 Resource visibility and construction

pub resource exports the type but not its representation. Fields and direct named-field construction remain restricted to the declaring package.

Importing packages obtain resources through public associated functions and use them through public methods.

Resource declarations and fields do not accept structural-provider directives. Resources do not support struct patterns.

16.3 Cleanup receiver

The cleanup receiver is a non-escaping finalizing view. Inside cleanup, whole self may be used only as a field-access base or method receiver.

Cleanup cannot:

  • replace self;
  • pass whole self as an argument;
  • return or store it;
  • capture it in a closure or spawned task;
  • send it through a channel; or
  • establish another lifetime-domain claim.

The runtime rejects indirect claim creation after cleanup begins.

16.4 Registration and lexical lifetime

Resources use ordinary let and let mut. There is no resource-specific binding:

let file = File::open(path)?;

Every newly created resource, including an unnamed temporary, registers automatically with the nearest lexical lifetime domain.

Passing, returning, storing, capturing, or widening a resource automatically establishes the destination domain's claim before the source domain can release its own. This is identity-preserving sharing, not ownership transfer, and does not invalidate the source binding.

Cleanup runs:

  • on normal block completion;
  • before an early return;
  • during typed error propagation;
  • during panic unwinding; and
  • before arena memory reclamation.

Cleanups execute in reverse registration order. A loop iteration is a lexical cleanup domain, so iteration-local resources do not accumulate until the surrounding function returns.

Cleanup may transparently suspend the current green task. Remaining cleanups wait and run sequentially in order. A task is not complete until its cleanup finishes.

16.5 Construction safety

Multi-part expressions evaluate left to right. Successfully initialized resource-bearing components register immediately in a hidden construction domain until the complete value exists.

If a later field, argument, or collection element fails or panics, earlier components clean in reverse order. On success, their claims transfer atomically to the completed destination.

Partially initialized outer values are never observable.

16.6 Resource-bearing aggregates

An ordinary aggregate containing a resource automatically retains the required claims. This behavior is recursive through structs, enums, collections, closures, channels, tasks, and protocol values.

The aggregate remains a structural value, while contained resource handles retain their identities.

Removing or replacing a resource-bearing value does not perform hidden suspending cleanup during the mutation. The nearest lexical cleanup domain adopts the displaced value and releases it at that scope's ordered cleanup phase.

16.7 Domain-rooted graphs

Resource claims belong to external lifetime domains rather than every internal reference edge. Domains include lexical scopes, tasks, aggregates, channels, and runtime roots.

Internal graph edges determine reachability but do not independently keep a resource alive. A rootless cyclic graph cannot defer deterministic cleanup until an unrelated garbage-collection cycle.

16.8 Cleanup lifecycle

Each resource has the runtime cleanup state:

CleanupPending
  -> Cleaning
      -> Cleaned
      -> CleanupFailed(Panic)

Final applicable claim release atomically begins cleanup. Cleanup is invoked exactly once and is never retried automatically. No new claim can be created after Cleaning begins.

This runtime lifecycle is distinct from resource-defined protocol states such as Open, Committed, RolledBack, or Closed.

Explicit methods update ordinary private protocol state and return typed outcomes. The eventual cleanup invocation observes that state and performs only remaining fallback work:

commit succeeds
  -> protocol state becomes Committed
  -> final claim release invokes cleanup
  -> cleanup observes Committed and performs no rollback

The language guarantees safe lifetime and once-only cleanup invocation. A resource implementation remains responsible for correctly coordinating its external protocol.

16.9 Resource capabilities

Resources do not automatically support:

  • equality or ordering;
  • hashing or map-key eligibility;
  • structural Debug;
  • structural JSON encoding or decoding; or
  • cross-task sharing.

A user resource is task-confined unless the compiler derives safe shareability from recognized concurrency primitives and the complete exposed operation set. Safe source has no annotation that overrides the derivation.

Resources may explicitly conform to ordinary user protocols. Widening to a protocol value preserves resource identity and the receiving domain's claim.

ProScript has no general defer, user-visible garbage-collector finalizer, manual drop, use binding, or public weak reference.

17. Concurrency

17.1 Green tasks and transparent suspension

ProScript functions are ordinary functions. A suspending operation suspends only the current green task and does not block an operating-system worker.

There is no async fn, standalone await, public future, or promise type.

17.2 Spawn

spawn starts one independent green task and returns Task<T>:

let task = spawn serve(router);

The operand must be call-shaped, with its final postfix operation being a function, method, associated-function, or callable-value invocation.

The spawning task evaluates the callable target, receiver, and arguments once from left to right. If evaluation fails or panics, no task is created.

Before the task becomes runnable, the runtime:

  • snapshots ordinary captured data;
  • promotes escaping arena graphs;
  • establishes claims on safely shareable resources; and
  • rejects non-shareable identities.

Only the selected callable body runs in the new task.

A spawned call cannot require writable authority over a caller-owned receiver place. Calls to mut self methods and compiler-defined writable-place collection operations are therefore rejected, including through nested receiver places:

spawn users.set(user.id, user);  // rejected
spawn values.push(value);        // rejected
spawn values.pop();              // rejected
spawn counter.increment();       // rejected when increment uses mut self
spawn groups[index].push(value); // rejected

The child neither mutates and discards a receiver snapshot nor copies a value back when it completes or is awaited. Read-only calls may operate on eligible ordinary snapshots. An ordinary function with a mut parameter may also be spawned because that parameter receives an independent value; returning and awaiting an updated value is the explicit ordinary-value path.

The rejection is about caller-place authority, not state change generally. Operations on safely shareable capabilities and protected handles remain legal when their receiver does not require a writable caller place. This includes Shared<T>.lock, channel operations, and concurrency-safe resource or native handle methods:

let task = spawn registry.lock(fn(mut users) {
    users.set(user.id, user);
});

Here the callback receives an exclusive protected view inside the child; it does not acquire writable authority over the parent's registry binding.

A task handle is a must-use value. Explicitly discard it to detach observation:

_ = spawn emit_metrics(snapshot);

Discarding the handle does not cancel the task or change its resource lifetime. The spawned task is not lexically joined to its parent and may outlive the parent scope.

17.3 Task observation

Wait for task completion with:

let result = task.await();

await is a method name, not a standalone keyword. It transparently suspends the current green task.

Task<T> retains one normal completion value. Multiple concurrent and repeated awaits observe logically independent ordinary copies of that same value while capabilities retain their semantics.

The result becomes observable only after the task's lexical cleanup finishes. The task domain retains resource claims reachable from its completion value until observation transfers the necessary claims or the retained result is released.

Tasks have no cancel, close, or general supervisor operation. Programs stop work through channels, typed messages, deadlines, normal return, or process termination.

17.4 Safe cross-task values

A task boundary accepts:

  • ordinary value-semantic data, copied logically; and
  • safely shareable handles, preserving protected identity.

Every other identity-bearing value is rejected statically. There is no affine ownership transfer that invalidates the source binding.

Safe ProScript prevents:

  • managed-memory data races;
  • dangling or post-cleanup resource access;
  • simultaneous exclusive protected views; and
  • escape of protected state.

It does not guarantee freedom from deadlock, starvation caused by program protocols, business-logic races, legal-operation ordering mistakes, or external distributed races.

17.5 Shareability derivation

Shareability is derived from compiler-recognized safe primitives, including:

  • channel endpoints and owner-task client handles;
  • Shared<T>;
  • atomic values with defined operations;
  • audited runtime and native handles; and
  • ordinary value data.

Wrapping a non-shareable identity does not conceal it. Safe source cannot assert shareability through an unchecked annotation or marker protocol.

17.6 Channels

Typed channels expose shareable Sender<T> and Receiver<T> endpoints. Ordinary channels are multi-producer, multi-consumer: each accepted value is received exactly once by one competing receiver. Broadcast is a distinct library abstraction.

The default channel is unbuffered. A bounded channel requires an explicit nonnegative capacity. An unbounded channel uses an explicitly named constructor.

Normal send:

sender.send(value) // Result<unit, SendError<T>>

It suspends while an unbuffered receiver or bounded capacity is unavailable. It never silently drops due to pressure.

Immediate send:

sender.try_send(value) // Result<unit, TrySendError<T>>

The outcomes are:

enum SendError<T> {
    Closed(T),
}

enum TrySendError<T> {
    Full(T),
    Closed(T),
}

Every failed send returns the unsent value.

Suspending receive:

receiver.receive() // Option<T>

None means the channel is closed and drained. An open empty channel suspends.

Immediate receive:

enum TryReceive<T> {
    Message(T),
    Empty,
    Closed,
}

Dropping the final sender-domain claim closes the channel automatically. New sends fail; already accepted values drain before receivers observe permanent closure.

Dropping the final receiver-domain claim permanently disconnects the receiving side. Pending and future sends fail with their unsent values. Buffered values that can no longer be received are discarded and their resource claims are released through ordinary cleanup.

Ordinary endpoints cannot globally close a shared channel. A separate shareable administrative control capability performs idempotent graceful close without retaining endpoint liveness.

Channels have no abort operation.

Timeouts for channel operations compose through select; channel endpoints do not define a separate timeout or deadline call family.

17.7 Channel ordering and resources

Sequential sends by one producer preserve FIFO acceptance order. Concurrent producers have no predetermined relative order.

Accepted values leave the channel in acceptance order, although competing consumer tasks may complete processing out of order.

Selection among waiting senders or receivers resists systematic starvation but does not promise FIFO waiter service or a bounded waiting time.

When a channel accepts a value, it atomically retains claims for reachable safely shareable resources. Receiving transfers those claims without a protection gap. A failed or losing send creates no channel claim. Non-shareable resources cannot be sent.

17.8 Select

select waits on several typed operations and commits exactly one:

let outcome = select {
    case message = receiver.receive() => handle_message(message),
    case result = worker.await() => handle_result(result),
    case _ = sender.send(value) => sent(),
    case timeout(deadline) => timed_out(),
    default => not_ready(),
};

Selectable operations are:

  • channel send;
  • channel receive;
  • task await;
  • timeout(duration); and
  • privileged runtime or native wait operations with the same atomic contract.

Every branch operand evaluates exactly once from top to bottom before readiness arbitration, including when default eventually wins.

A case result target is one immutable identifier or _. A non-unit result must be bound or explicitly discarded.

If no operation is ready and there is no default, the task suspends. At most one default is permitted, and it must be final.

When several operations are ready, one wins nondeterministically. Source order provides no priority. Repeated arbitration must resist systematic starvation, but the language promises no bounded wait or specific queue algorithm.

A losing branch withdraws its registration without committing:

  • no message is consumed;
  • no send is accepted;
  • no losing timer remains; and
  • a losing task await does not cancel the task.

Branches form an expression and must produce compatible types, like match. Cases and default are comma-separated, with the ordinary optional trailing-comma rule.

std.time.Duration is nonnegative. A zero timeout is immediately eligible. Negative duration construction fails according to the duration API.

17.9 Shared synchronized state

Shared<T> is a safely shareable handle providing scoped access:

let metrics = Shared<Metrics>::new(initial_metrics);

metrics.lock(fn(mut state) {
    state.requests += 1;
    return;
});

T must be recursively ordinary value-semantic state and cannot contain a resource. Shared<T> does not make a task-confined resource shareable.

The callback parameter selects the acquisition mode:

  • fn(state) acquires shared read access;
  • fn(mut state) acquires exclusive access; and
  • a zero-parameter Shared::gate() callback acquires exclusively.

The protected view cannot escape. Access remains held across transparent suspension and releases on every normal, error, or panic exit.

Shared<T> is non-reentrant for the same identity. It supports no mode upgrade, public guard, manual unlock, poisoning, or try_lock.

Re-entry is rejected statically when provable and otherwise produces a structured runtime panic. Waiter selection resists systematic starvation but promises neither FIFO service nor a bounded waiting time.

A coordination-only gate is:

let migration_gate = Shared::gate();

migration_gate.lock(fn() Result<unit, MigrationError> {
    return migrate(database, cache);
});

Shared<T> protects ordinary in-memory state. It is not a wrapper that makes a task-confined resource shareable.

17.10 Concurrent resources

A task-confined resource cannot cross a task boundary, be sent through a channel, or be captured by a concurrently invoked callback.

A resource is safely shareable only when the compiler can derive that property from recognized protected primitives across its complete exposed operation set. Such a resource commonly uses internally synchronized state or delegates operations to an owner task. Concurrent calls preserve the resource's typed protocol and cannot create a managed-memory data race.

Safe source cannot declare an unchecked shareability annotation. Binding mutability does not grant or remove concurrent authority.

18. Structural debugging, JSON, and template metadata

18.1 Structural providers

The compiler recognizes a closed set of structural providers and configuration families:

  • Debug;
  • json::Encode;
  • json::Decode;
  • the shared JSON configuration family json;
  • the shared template configuration family.

Eligible user-defined types obtain provider capabilities through static structural analysis. Configuration families contribute only statically validated metadata; they do not create runtime protocols or conformances. A declaration does not contain a derives list. The compiler may synthesize specialized code, but it does not use Any, runtime reflection, runtime protocol discovery, or runtime field enumeration.

Structural eligibility is recursive. A participating field must support the provider unless a valid provider policy causes the generated operation not to visit that field. Generic type eligibility is conditional on the required capabilities of its type arguments.

External packages cannot define new structural-provider paths, compiler generators, or field-directive vocabularies. They may define and explicitly implement ordinary protocols.

18.2 Directive syntax

A field directive group follows the field type and precedes the field comma:

struct User {
    id: Uuid #[json::Encode(skip)],
    name: string #[json(name: "displayName")],
    password_hash: string #[
        Debug(redact),
        json::Encode(skip),
    ],
}

Field-level template metadata is also permitted through the same directive shape:

struct UserExtra {
    id: Uuid #[template(skip)],
    name: string #[template(name: "displayName")],
    password_hash: string #[
        Debug(redact),
        template(skip),
    ],
}

An enum-variant directive group follows the variant name and any payload:

enum UserStatus {
    Pending #[json(name: "pending")],
    Active,
    Suspended #[json(name: "suspended")],
}

A whole-type group follows the type name and generic parameters and precedes the body:

struct Credentials #[
    Debug(deny),
    json::Encode(deny),
] {
    username: string,
    password: string,
}

There is at most one group at each attachment point. Directives are comma-separated. A trailing comma is accepted in any nonempty group and is canonical in a multiline group.

Directive arguments belong to a closed, statically validated vocabulary. They are not arbitrary runtime expressions.

18.3 Structural Debug

An ordinary struct or enum has structural Debug when all values visited by its generated representation are debug-eligible. Debug(redact) replaces a field's normal structural representation and therefore does not require the field type itself to implement Debug.

Debug(deny) on a whole type prevents automatic synthesis even if every field is otherwise eligible. Only the package defining the type may then provide a deliberate explicit implementation. Denial applies only to Debug; it does not hide fields or alter JSON behavior.

Structural directives configure generated representations; they are not access-control or information-flow rules. Debug(redact) does not skip JSON, JSON skipping does not redact Debug, and code that can access a sensitive field can still display or transmit that field deliberately.

Resources do not receive structural Debug automatically.

Debug is distinct from ordinary interpolation. Interpolation accepts only its closed primitive rendering set and does not fall back to Debug.

18.4 Template configuration directives

The shared template configuration family is a closed, standard-library-only metadata channel for template-oriented projection. It does not define a runtime protocol, enable runtime reflection, or permit user-defined template directive vocabularies.

The permitted field-level options are:

  • template(name: "field_name") to use an explicit template binding name.
  • template(skip) to omit a field from template projection.
struct UserExtra {
    id: Uuid #[template(skip)],
    name: string #[template(name: "displayName")],
    password_hash: string #[Debug(redact), template(skip)],
}

template(name: "field_name") is the template equivalent of the existing json(name: "..."): it replaces the source field name for template-facing rendering contracts only.

template(skip) is a template-only skip flag and does not participate in JSON behavior.

The compiler validates the directive path and every option at compile time. Template metadata may be emitted through the ordinary structured-metadata mechanism used by the standard library. It does not change interpolation or formatter behavior and introduces no format specifiers or runtime-dispatched template hooks.

18.5 Structural JSON capabilities

Structural JSON encoding and decoding are independent:

  • an eligible struct may satisfy root json::Encode;
  • an eligible struct may independently satisfy root json::Decode;
  • a user enum can participate recursively only after traversal has crossed a participating struct field; and
  • json::Value is a standard-library-owned root JSON value with explicit encoding and decoding.

Encoding capability does not imply decoding capability, or conversely.

json::Encode(deny) and json::Decode(deny) prevent automatic synthesis for their respective directions. Only the defining package may replace a denial with a deliberate manual implementation.

Structural JSON is intended for data-transfer shapes. A domain type whose construction enforces invariants may deny decoding and use an explicit input DTO or a manual decoder.

The following types have no automatic structural JSON representation:

  • unit;
  • Result<T, E>;
  • directly nested Option<Option<T>>;
  • resources;
  • functions, tasks, channels, and synchronized handles except where a standard-library type explicitly owns a representation; and
  • opaque nominal types without a deliberate implementation by their defining package.

A field skipped by a provider does not need to qualify for that provider.

18.6 JSON object fields

The default JSON member name is the field's exact source name:

struct User {
    created_at: Timestamp,
}

The corresponding member is "created_at". ProScript performs no automatic case conversion, case-insensitive matching, or Unicode normalization.

json(name: "wireName") replaces the name in both encoding and decoding:

created_at: Timestamp #[json(name: "createdAt")],

The source name is not retained as an alias. Direction-specific names and multiple aliases do not exist. Different input and output contracts use different types.

json::Encode(skip) omits a field during structural encoding. It does not alter decoding.

An ordinary non-optional participating field is required during decoding. A missing member produces a typed JsonError; the decoder does not invent zero, false, empty, or other implicit values.

A missing Option<T> member decodes as None. A present JSON null also decodes as None. A non-null value decodes as Some(value).

Encoding an Option<T> emits the field by default:

  • Some(value) emits the encoded value; and
  • None emits JSON null.

json::Encode(omit_none) may appear only on an Option<T> field. It omits the member for None and emits every Some(value), including zero-like or empty values. There is no general omitempty or omit_zero policy.

An ordinary field may declare a missing-member default:

struct CreateUserRequest {
    send_welcome_email: bool #[
        json::Decode(default: true),
    ],
}

The default is used only when the member is absent. A present null, false value, or wrong-typed value is decoded normally and is not mistaken for absence.

The default must be a compile-time constant-compatible flat value of the field type. It cannot call a function or method, read runtime state, use control flow, access a field or index, or construct a composed value. A decode default is invalid on Option<T> and json::Field<T>.

Unknown members are rejected by default. A type can deliberately allow them:

struct ForwardCompatibleEvent #[
    json::Decode(allow_unknown),
] {
    event_id: string,
}

This policy affects only unmapped members at that type. It does not weaken required-field, type, malformed-input, or duplicate-name validation.

Duplicate member names in one JSON object are always a typed JsonError, including duplicate unknown names and names expressed with different escape spellings. Duplicate detection occurs on decoded member names before field mapping. There is no permissive override.

18.7 Three-state JSON fields

json::Field<T> models the three states required by partial-update contracts:

// Declared by the standard json package.
pub enum Field<T> {
    Missing,
    Null,
    Value(T),
}

For a direct participating struct field:

JSON state ProScript state
member absent json::Field::Missing
member present as null json::Field::Null
member present as a valid T json::Field::Value(value)

Encoding performs the inverse mapping. Missing omits the containing member, Null emits JSON null, and Value(value) emits the value.

json::Field<T> is structurally meaningful only as the direct type of a struct field. It is not a structural JSON root and cannot appear beneath an array, map, set, option, enum payload, or other container. Its immediate payload type cannot be Option<U>, because that would make Null and Value(None) indistinguishable.

18.8 Enum JSON representation

A payload-free enum variant beneath a participating struct field is a JSON string containing its exact source name:

enum UserStatus {
    Pending,
    Active,
}

UserStatus::Active encodes as "Active". A variant-level json(name: "...") replaces that exact name in both directions.

A payload-bearing variant uses an externally tagged one-member object:

enum BackgroundEvent {
    UserCreated(User),
    Range(int, int),
}
{
  "UserCreated": {
    "id": "019c...",
    "name": "Ada"
  }
}

A single payload is emitted directly. Multiple positional payloads are emitted as a JSON array in declaration order:

{"Range":[1,10]}

Decoding requires exactly one member, an exact known variant name, and the correct payload shape. The ProScript 0.1 structural representation provides no internal tagging, adjacent tagging, untagged matching, flattening, numeric discriminants, or representation switch.

The only enum-variant JSON directive is json(name: "..."). An empty json(), field policy, skip policy, or whole-type policy on a variant is invalid.

A user enum cannot be the direct root of structural JSON encoding or decoding. A root container also does not establish eligibility:

Response::json(Status::Ok, UserStatus::Active); // invalid
request.json<Array<UserStatus>>();              // invalid

The contract must cross a struct field:

struct UserStatusResponse {
    status: UserStatus,
}

A root collection of structs remains valid when enum values occur beneath fields of those structs.

18.9 Dynamic JSON

The standard library provides the sealed recursive type:

// Declared by the standard json package.
pub enum Value {
    Null,
    Bool(bool),
    Number(Number),
    String(string),
    Array(Array<Value>),
    Object(Map<string, Value>),
}

json::Value can be encoded and decoded at the root. It contains JSON data only; it cannot hold arbitrary ProScript values, resources, capabilities, functions, channels, or runtime objects. Pattern matching over it is exhaustive.

json::Number is an immutable validated JSON-number token. Decoding a dynamic tree preserves the token's exact spelling and precision rather than converting through float64. Encoding writes it as a JSON number, not a string.

Construction from arbitrary text is fallible. Construction from a typed integer or finite float uses that type's canonical JSON spelling. Checked conversions to numeric primitives return Result; no implicit numeric conversion exists.

json::Number equality compares exact token spelling, so 1, 1.0, and 1e0 are distinct. It is not a map key or set element.

18.10 Maps and sets in JSON

An eligible Map<K, V> encodes as a JSON object. Every allowed map-key type has one canonical member-name representation:

  • string uses its exact value;
  • integers use canonical base-10 text;
  • bool uses "true" or "false";
  • byte and rune follow their underlying integer types; and
  • an open primitive-backed nominal uses the canonical representation of its ultimate primitive.

Integer key text has no leading +, no leading zeros except "0", and no "-0". Decoding rejects noncanonical or out-of-range names.

An opaque nominal key does not automatically gain JSON map-key behavior. Floats, structs, enums, and all other non-whitelisted map keys remain invalid.

Default map-member output order is unspecified. Struct fields retain declaration order. The JSON encoder provides an explicit operation-wide deterministic mode that recursively sorts maps and sets, at additional time and memory cost. Deterministic mode is not a cryptographic canonical-JSON guarantee.

An eligible Set<T> encodes as a JSON array. Default element order is unspecified. Decoding rejects duplicate elements rather than silently collapsing them. Deterministic mode sorts elements using their compiler-defined scalar ordering.

18.11 Numeric, string, and byte JSON values

Every integer type encodes as an unquoted JSON number, including values outside JavaScript's exact integer range. Decoding parses directly into the exact target integer domain. Numeric strings, fractional forms, exponent forms, negative values for unsigned targets, and out-of-range values are rejected.

Finite float32 and float64 values encode using Go-style shortest round-tripping formatting at the original width. Fixed notation is used for zero and magnitudes from 1e-6 inclusive to 1e21 exclusive; lowercase scientific notation is used outside that interval. Exponents are not zero-padded, and negative zero is emitted as -0.

NaN and positive or negative infinity cannot be represented in JSON. Encoding them, or decoding a number that overflows the target float, returns a typed JsonError.

JSON strings are valid UTF-8. Encoding escapes quotation marks, backslashes, control characters, and characters required by JSON. It does not additionally escape /, <, >, &, U+2028, U+2029, or ordinary non-ASCII text. Malformed UTF-8 and invalid Unicode escape sequences are decoding errors.

Array<byte>—and therefore Array<uint8>—encodes as a standard padded RFC 4648 base64 JSON string. A numeric JSON array is not accepted as an alternative binary representation.

18.12 Nominal types and JSON

An open nominal type delegates structural JSON behavior to its immediate underlying type while retaining its static identity.

An opaque nominal type receives neither encoding nor decoding from its underlying type. Its defining package may provide a deliberate explicit implementation. External packages cannot do so, and ordinary generated code does not insert an underlying conversion automatically.

An exact alias has exactly the JSON behavior of its target.

19. Native extensions

19.1 Trust boundary

Public native packages target the versioned, language-owned ps_ffi_v1 ABI. Arbitrary C, C++, Rust, or platform libraries are adapted to that ABI by native package code.

The safe source language provides no:

  • direct C-header import;
  • arbitrary foreign-symbol declaration;
  • raw pointer or foreign-layout type;
  • variadic native call;
  • public native-to-ProScript callback; or
  • unsafe application-source block.

Native adapter code is trusted. It can corrupt the process if it violates the ABI, but its complexity does not enter the safe ProScript type system.

19.2 Declarations and execution classes

Native declarations appear only at package scope in a manifest-authorized package and have no ProScript body:

native fn hash_block(
    input: Array<byte>,
) Result<Array<byte>, HashError>;

native offload fn compress(
    input: Array<byte>,
    level: int32,
) Result<Array<byte>, CompressionError>;

pub native fn public_hash(
    input: Array<byte>,
) Result<Array<byte>, HashError>;

pub native offload fn public_compress(
    input: Array<byte>,
    level: int32,
) Result<Array<byte>, CompressionError>;

pub native type CipherHandle;

native fn is inline. Its adapter promises short, bounded, non-blocking execution on the current scheduler worker.

native offload fn runs potentially blocking or long-running work through the runtime's bounded native worker facility. The calling green task transparently suspends. Call sites use the function normally; no public future or additional await exists.

A native function declaration is package-private when unmarked. A leading pub directly exports either the inline or offloaded form as a safe typed ProScript function. Its raw symbol, ABI representation, artifact identity, and transport remain private. A package instead uses a private native declaration and ordinary public wrapper when it needs validation, richer application types, or a public signature outside the closed native set.

A native type is an opaque runtime-registered resource handle. It may be public. Safe source cannot construct it, inspect its token, cast it, or override its lifecycle or shareability metadata.

19.3 Binding types

The source-level native binding type set is closed to:

  • fixed-width signed and unsigned integers, including byte and rune;
  • float32, float64, bool, and unit;
  • valid UTF-8 string;
  • Array<byte>;
  • a locally declared native type; and
  • an outer Result<Success, Error> whose ABI mapping is statically known.

Target-width int and uint, arbitrary arrays, maps, sets, structs, user enums, options, protocol values, tasks, channels, closures, callbacks, variadics, managed references, pointers, and shared layouts cannot cross the ABI directly. Ordinary wrapper code validates and converts richer types.

19.4 Marshaling and storage

Marshaling preserves semantic copy isolation without requiring physical copy-in/copy-out:

  • fixed-width scalar arguments use their canonical ABI values without payload allocation;
  • strings and Array<byte> may use read-only call-scoped views over stable contiguous payload storage;
  • the runtime holds an exact payload lease until the native entry returns, and an admitted offloaded job holds its leases until every completion or failure path has finished;
  • native code cannot retain an input pointer beyond the entry and receives no managed wrapper address, collector reference, or arena address;
  • an implementation that cannot provide stable storage uses independent copied call storage instead;
  • physically shared byte storage remains unobservable under value semantics, and a mutable Array<byte> detaches through copy-on-write or an equivalent strategy before a write could affect another logical array;
  • strings cross as UTF-8 bytes with explicit length, and embedded NUL remains data;
  • variable output uses a runtime reservation that the adapter writes, after which the runtime validates, seals, and adopts the same payload without a required post-return copy;
  • string output is UTF-8-validated before sealing, while byte output validates its reservation identity, length, and capacity;
  • output cannot alias input, and lengths and capacities use validated fixed-width ABI integers; and
  • every output reservation and input lease is completed or released exactly once on success, expected error, panic, malformed output, allocation failure, and result-construction failure.

Zero length may use a null data pointer only with zero length and capacity. Copied transport remains a transparent fallback and may be selected for small values when measured faster. Public ProScript pinning, borrowing, pointers, lifetimes, and ownership-transfer operations do not exist. Stable payload, lease, unfinished-output, pooled-capacity, and copy-on-write memory all count toward runtime pressure and backpressure.

19.5 Native handles

Every native handle contains a runtime-registered opaque token with type, generation, and lifecycle validation. An operation acquires a lease before entering native code and releases it afterward, preventing cleanup from racing a live operation.

Native metadata classifies a handle as task-confined by default or as shareable-by-construction. It also classifies cleanup as inline or offloaded. Only audited runtime metadata can claim shareability.

19.6 Offload backpressure and failure

The offload facility has bounded workers and bounded admission. A green task acquires admission before large inputs are copied or retained for a queued job. After admission the job acquires exact payload leases, and releases them on every completion or failure path. Saturation suspends the task until capacity becomes available; it does not create unbounded threads, unbounded queued payload retention, or a new application error branch.

Admission is starvation-resistant but has no FIFO or bounded-wait promise. ProScript has no task cancellation, so queued or running native work is not implicitly cancelled.

Expected failures map to the declared typed Result. Detected ABI contract faults—including invalid status values, invalid UTF-8 promises, malformed buffers, stale handles, wrong handle types, and foreign unwinding—produce PanicKind::Native. Undetectable in-process memory corruption is outside the safe-source guarantee.

19.7 Native package metadata

The package manifest pins:

  • the ps_ffi_v1 ABI major;
  • target-specific artifacts or target-build recipes;
  • exported binding metadata; and
  • artifact integrity identity.

The lockfile pins the exact selected artifacts. Source cannot load an arbitrary filesystem path or symbol. A target without a matching artifact or declared build recipe is unsupported and is rejected before execution or packaging.

Incompatible ABI majors use distinct ABI names.

19.8 Generated safe Rust authoring

For a Rust-backed native package, its checked ProScript native declarations are the single source of truth for binding signatures, execution classes, expected errors, and native-handle identities. ProScript tooling generates both a binding-specific safe Rust implementation interface and the private raw ps_ffi_v1 glue. Normal extension authors implement the safe interface rather than handwritten C-compatible entry points or duplicated numeric status maps.

The generated layer owns raw ABI entry points and metadata, validation, scalar conversion, call-bounded read-only string and byte views, runtime output writers, opaque typed handle adapters, expected-error mapping, Rust-panic containment, and exactly-once cleanup. Its safe Rust input-view lifetime cannot outlive the invocation. Its output abstraction cannot safely fabricate a reservation, exceed its bounds, alias an input, or finish twice.

The generated Rust API is reproducible derived build material associated with the selected toolchain and declarations. It is checked for staleness before an artifact is accepted. It is not the public binary ABI: exact Rust identifiers, module organization, generated paths, and code-generation strategy are not ProScript language compatibility promises. The stable cross-artifact contract remains the C-compatible ps_ffi_v1 ABI.

Ordinary adapter implementation requires no handwritten raw pointer, extern symbol, status table, or ABI-level unsafe code. Adapter-internal audited unsafe Rust remains possible when required by the wrapped library and remains outside the safe-source guarantee. Tooling supplies scaffolding, regeneration and staleness checking, native building, and integration with check, run, and package; exact convenience-command and manifest-table spellings are tooling and packaging details.

Applications call the package's ordinary public ProScript API, whether that is a direct public native declaration or an ordinary wrapper. The call site contains no native marker, callback, future, explicit await, pointer, borrow, pin, output buffer, or manual release operation.

19.9 Direct exports and simplified Rust implementation

pub native fn and pub native offload fn are direct safe exports. Their public compatibility shape is the exact ProScript function signature, which must satisfy both ordinary exported-signature nameability and the closed native binding set. Native backing and inline/offload execution class are not part of that compatibility fingerprint; an exact public signature may move between an ordinary ProScript body and either native class without changing callers.

The normal Rust implementation consists of one function per binding marked:

#[proscript_native::implements]
fn compress(
    input: InputBytes<'_>,
    level: i32,
    output: &mut ByteOutput<'_>,
) -> Result<(), CompressionError> {
    // Rust implementation
}

The marker takes no duplicate binding name, execution class, signature, symbol, or status arguments. Generated context and exact function name identify the binding. Generated private glue owns registration and export, so ordinary code contains no adapter struct, generated-trait implementation, export macro, raw symbol, pointer, or numeric status table. Missing, extra, duplicate, and mismatched implementations produce a joined diagnostic over the ProScript and Rust locations.

String and byte results admit two generated safe Rust profiles. A convenience implementation returns owned String or Vec<u8> storage and incurs one reported copy into the runtime reservation. A direct-output implementation receives the generated text/byte writer and returns unit on success, permitting zero-copy production into that reservation. An outer expected-error Result uses the generated exact error type in both profiles. Tooling reports the selected profile and expected payload-copy count.

proscript check automatically regenerates stale hidden bindings and checks local Rust implementations. run and package reuse matching cached native artifacts or build them before continuing. Generated glue and build products are derived tool-managed material rather than ordinary source. Optional native scaffold, regeneration, build, and report commands may exist, but are not required in the normal edit-run path. Consumers of a matching pinned prebuilt artifact require no installed Rust, Cargo, C compiler, or native headers. For a manifest-authorized local Rust build, these commands may invoke trusted Cargo procedural macros and build scripts. That is native build-time code, not ProScript application execution, and is outside the safe-source guarantee. Matching pinned prebuilt artifacts never invoke it. Native cache identity binds the declaration schema, Rust sources/dependency lock, target, SDK/ABI versions, and relevant toolchain identity.

20. Diagnostics and tooling

20.1 Command-line workflow

One proscript command owns the standard workflow:

proscript check [target]
proscript run [target] [-- program arguments]
proscript test [target]
proscript fmt [paths]
proscript fmt --check [paths]
proscript package [target]

check parses, resolves, and statically validates the reachable package graph without executing application code.

run performs the same validation and executes the selected target.

package creates a target-specific self-contained artifact.

The ordinary edit-run workflow has no required user-facing compilation command.

For check and run, a .pr file target selects its containing directory package rather than an independent source compilation unit. The tool normalizes an existing target before deriving its parent, so bare, ./, parent-relative, and absolute spellings select consistently; lexical . and .. are not package names. File targeting selects the same deterministic set of ordinary direct sibling .pr files as targeting the containing directory. _test.pr remains excluded except during proscript test. A named file does not acquire entry-point significance, and fmt retains exact path selection rather than expanding a file target to its package.

20.2 Diagnostics

Every compiler diagnostic has:

  • a stable categorical code within the selected language version;
  • one primary source span and a concise message;
  • related source spans where relevant; and
  • actionable notes that do not assume a speculative rewrite is safe.

--message-format=json produces a machine-readable representation whose schema is versioned independently of human prose.

Rendered wording, color, and layout are not source-compatibility contracts. Warnings do not make a program invalid unless a tool invocation explicitly promotes them to errors. A target with a reachable static error is never executed.

Generic runtime reporting boundaries use Debug; ProScript does not define a second universal diagnostic protocol.

20.3 Canonical formatting

proscript fmt is idempotent and semantics-preserving. fmt --check performs no writes and exits unsuccessfully when formatting would change a file.

Canonical formatting:

  • uses four spaces per structural indentation level and no indentation tabs;
  • places opening braces on the declaration or control-flow header line;
  • aligns closing delimiters with their opener;
  • emits one ordinary statement per line;
  • removes trailing whitespace;
  • collapses excessive blank-line runs;
  • ends an ordinary file with one line feed;
  • removes an optional trailing comma from a single-line list;
  • emits a trailing comma in a multiline list;
  • normalizes spacing around tokens;
  • preserves literal contents and comment prose; and
  • never reorders declarations, fields, imports, cases, or other program elements.

The formatter has no hard line-width limit and preserves the author's choice between single-line and multiline list or chain layout where both are valid.

20.4 Tests

A source filename ending in _test.pr belongs to its directory package only during proscript test.

The test tool discovers package-private package-scope functions named test_* with either signature:

fn test_name()
fn test_name() Result<unit, E>

For the second form, E must support Debug. Test functions take no parameters. There is no test keyword or test annotation.

Each test runs inside a same-task recovery boundary. A panic fails that test after ordinary unwind and cleanup. Tests run sequentially within one package by default; a test that needs concurrency creates tasks explicitly. Different packages may run in separate worker processes.

Assertions and fixtures are ordinary library APIs.

20.5 Shared compiler semantics

The formatter, language server, editor integrations, documentation tools, and linters reuse compiler parsing, name resolution, and type information. They do not define alternative language semantics.

21. Compatibility and evolution

21.1 Language versions

A project manifest declares its source-language contract:

[package]
name = "billing_api"
version = "0.3.1"
language = "0.1"

The compiler checks the project under that declared mode and never silently reinterprets it using a newer incompatible grammar. A manifest-free standalone package main command uses the installed toolchain's current language mode.

Before language 1.0, a new minor language version may make documented breaking changes. Patch releases do not intentionally change the behavior of accepted programs.

Starting with language 1.0, a breaking source or semantic change requires a new major language version. ProScript uses language versions rather than a separate edition mechanism.

21.2 Package versions and resolution

Package releases use semantic versioning. A dependency requirement "1.2" means >=1.2.0, <2.0.0; an exact requirement uses "=1.2.3".

Resolution selects one version for each canonical package identity in the dependency graph. Conflicting requirements are errors rather than loading multiple type-incompatible copies under one identity.

The lockfile records the exact selected version, source identity, integrity digest, native artifacts, and complete graph.

The standard library is selected with the toolchain and imported through std. It is not resolved as an independent manifest dependency. A toolchain supporting an older language mode provides that mode's predeclared names and standard contracts.

21.3 Public API compatibility

Public compatibility follows exported static shape. The following changes are breaking:

  • adding or removing a public enum variant;
  • adding, removing, or changing a public struct field;
  • changing a public callable parameter or result type;
  • strengthening a public generic bound;
  • adding a requirement to a public protocol;
  • removing a visible conformance; or
  • exposing a type whose identity or visibility no longer matches the public signature.

Tooling may compare exported package metadata and report these changes.

Source behavior and documented package APIs are compatibility contracts. Cached bytecode is disposable and implementation-internal. A self-contained artifact carries its matching runtime. Native package compatibility is tied to an explicit ps_ffi_vN major.

22. Language 0.1 boundary

The following constructs are not part of ProScript:

  • Any, downcasting, general reflection, and a source-visible never type;
  • null values outside JSON, tuples, multiple returns, and variadic functions;
  • macros, pipeline operators, operator overloading, and user-defined precedence;
  • labeled loops and value-producing loops;
  • public futures or promises, async fn, a second await syntax, task cancellation, task groups, nurseries, supervisors, and channel abort;
  • general defer, garbage-collector finalizers, public weak references, pointers, pinning, stable managed addresses, and ownership or borrowing syntax;
  • protocol default bodies, protocol inheritance, associated types, higher-kinded types, conditional or overlapping conformance, specialization, and where clauses;
  • restricted visibility, re-exports, wildcard imports, package runtime initialization, workspace manifests, source build tags, and platform-selecting filename conventions;
  • direct foreign-header binding, public native callbacks, foreign-layout sharing, borrowed managed buffers, and unsafe application-source blocks;
  • interpolation format specifiers or protocol fallback; and
  • user-defined structural providers or an open attribute system.

The absence of these constructs is part of the language's simplicity and safety model. A library cannot simulate them by weakening the static or runtime guarantees defined here.

HTTP, database, filesystem, networking, cryptography, compression, process, and other service APIs belong to the standard library and package ecosystem. They use the language mechanisms defined by this specification but are not compiler intrinsics.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment