Lask Language Specification
This document is the language specification of Lask that satisfies the requirements definition document (requirements.md). This specification is intended to serve as the reference for implementation, verification, and editor integration.
1. Introduction
1.1 Purpose
Lask aims to express tasks as functions and to describe automation and CI/CD execution in a single consistent language. This specification defines syntax, types, evaluation, input/output, and observability so that implementers, users, and tool developers can share the same semantics.
The design goals of Lask are as follows.
- Declarative expressiveness centered on function composition
- Readability that allows users accustomed to procedural styles to adopt the language incrementally
- Safety through pre-execution verification (syntax, references, types)
- Reproducibility by absorbing differences between execution environments
- High observability through execution logs and traces
1.2 Intended Audience
The intended audience of this specification is as follows.
- Developers implementing language processors (parsers, type checkers, executors)
- Developers implementing peripheral tools such as CLI, LSP, REPL, and log collection
- Users and operators designing operational rules that conform to the language specification
1.3 Scope of the Specification
This specification covers the following.
- Lexical specification, syntactic specification, and type system
- Declarations, expressions, modules, and control structures
- Static semantics and dynamic semantics
- CLI specification, standard input/output, and execution environments
- Serialization conventions, the error system, and observability (logs/traces/events)
1.4 Out of Scope
The following are outside the scope of this specification.
- Optimization techniques that depend on specific implementations
- Choice of implementation language and libraries
- Concrete operational infrastructure configuration (cloud products, middleware selection)
- Organization-specific deployment procedures and approval flows
- Compatibility and migration policy (specified in
compatibility.md)
1.5 Relationship to the Requirements Definition Document
requirements.md is the higher-level document that defines the requirements to be satisfied, and this specification is the normative document that translates those requirements into an implementable language specification. If there is a discrepancy between the requirements and this specification, the intent of the requirements document takes precedence and the specification is updated.
1.6 Terminology
This section defines the principal terms used in this specification.
- Function: An evaluation unit that receives input and returns a value. The basic unit of execution in this specification.
- Task: A colloquial term used in practice; in Lask, an execution target expressed as a function.
- Module: A source-file unit consisting of a set of declarations and imports.
- Execution environment: The target context in which commands are executed. Specified by environment expressions:
#local, an image reference such as#alpine:3.20, or a recipe path such as#./images/build/Dockerfile. - Project file:
lask.json(Chapter 5), which declares external Lask source code fetched over the internet. - Lock file:
lask.lock.json(Chapter 5), which records the resolution of the whole dependency graph and of the container images it requires. - Execution event: A time-series record unit representing calls, return values, and failures.
- Serialization: The process of converting a value into an external representation so that it can be stored and transferred.
- Core expression: An expression remaining after static expansion of syntactic sugar (7.6). The evaluation target of the dynamic semantics (Chapter 8).
- Syntactic sugar: Surface syntax that is normalized into core expressions. Only the semantics of the expressions after normalization are normative (7.6).
- Core function: A built-in function that is the normalization target of syntactic sugar, or is integral to a language feature. It cannot be declared or overridden by user code (7.2).
- Built-in library: The set of built-in symbols available without explicit imports (Chapter 15).
- Pre-execution error: A failure detected and reported before function evaluation begins (environment resolution, argument binding, etc.).
- Runnable: An execution environment together with run options for its container, such as
memoryornetwork(6.7, 15.5). AnEnvironmentis aRunnablewith no options, so wherever a command takes aRunnable, anEnvironmentmay be given. - Command declaration: A top-level
command { "<word>", ... } on <environment>declaration (Chapter 5), which assigns an execution environment to programs by name, so that a command naming one of them needs no[env]of its own. - Command word: The name of a program that a command declaration registers, together with the environment that provides it (Chapter 5). A module has the command words it declares and those it imports.
- Dispatch: Choosing the execution environment of a command written without
[env]by matching the programs its command string runs against the module's command words (10.9). - Secret binding: A binding whose name is marked
!!, whose value is masked wherever it appears in what the implementation writes to stderr (6.10, 12.8). - Materialization: Making an image the program requires present on the container engine (10.10), by pulling a registry reference or building a recipe, and recording the result in the lock file (10.3). Only
syncmaterializes images (11.7).
2. Notation
This chapter defines how to read the EBNF notation used throughout the specification document, and how its Lask examples are marked.
Production = production_name "=" [ Expression ] "." .
Expression = Alternative { "|" Alternative } .
Alternative = Term { Term } .
Term = production_name | token [ "…" token ] | Group | Option | Repetition .
Group = "(" Expression ")" .
Option = "[" Expression "]" .
Repetition = "{" Expression "}" .How to read the EBNF:
A = B .means "Ais defined asB".|denotes alternation.{ X }denotes zero or more repetitions ofX.[ X ]denotes thatXis optional.( X )denotes grouping."..."denotes a terminal symbol (a literal string).production_namedenotes a nonterminal symbol (a reference to another production rule).
Lask examples:
- A code block marked
laskis a complete module: on its own, asmain.lask, it passeslask check. - A code block marked
lask fragmentis not: it is an excerpt, an equivalence written with(* ... *)between its two sides, or a module that relies on files or dependencies the example does not show.
3. Lexical Specification
This chapter defines the lexical elements that constitute source code.
Common lexical elements:
unicode_charis a terminal symbol representing any Unicode scalar value except a newline.newlineis a newline (U+000A). Implementations may treat CRLF (U+000D U+000A) as a single newline.
3.1 Comments
This specification defines two kinds of comments: line comments and block comments.
line_comment = "//" { unicode_char } .
block_comment = "/*" { unicode_char | newline | block_comment } "*/" .Rules:
- Because
unicode_chardoes not include newlines, a line comment ends immediately before a newline. - Because a block comment may contain
newline, it can span multiple lines. - By including
block_commentitself in the definition ofblock_comment, nesting of block comments is permitted. - Permitting nesting allows temporarily commenting out a large region without breaking existing internal
/* ... */comments. - Implementations must track the comment depth and must determine comment termination only at the outermost
*/.
Documentation comments:
The contiguous block of comments directly above a declaration (Chapter 5) is the documentation comment of that declaration. No dedicated syntax is introduced; both line comments and block comments participate.
- The block is the run of comments that ends on the line immediately above the declaration and continues upward without a gap. A blank line or any other token terminates the block.
- The comment markers (
//,/*,*/) are stripped and the remaining lines are joined with newlines. - The first paragraph (up to the first blank line) is the summary. It is used wherever a single line is required, such as the function list of 11.6.
- The paragraphs that follow, up to the first tag line, are the description.
- A line whose first non-space character is
@is a tag line. The following tags are defined.@param <name> <text>: documentation for the parameter<name>.<name>is matched after the name mapping of 11.2, so bothout_dirandout-dirdesignate the same parameter.@return <text>: documentation for the return value.@example <text>: a usage example. Multiple occurrences are permitted and are displayed in order of appearance.@hidden: excludes the declaration from listings (11.6). Help requested by name is still displayed.
- Unknown tags are not errors; they are carried through into the description as written (forward compatibility).
- An
@paramthat names a parameter the declaration does not have is not an error either. It may be reported as the advisory diagnosticW-DOC-PARAM-UNKNOWN(14.2). - Documentation comments have no effect on static or dynamic semantics. They are consumed by editor integration (
serve) and by help display (11.6).
3.2 Identifiers
identifier = lower_id | upper_id .
lower_id = ( "a" … "z" | "_" ) { letter | decimal_digit | "_" } .
upper_id = ( "A" … "Z" | "_" ) { letter | decimal_digit | "_" } .
letter = "A" … "Z" | "a" … "z" .
decimal_digit = "0" … "9" .Rules:
- Identifiers cannot contain
-. Because-is always interpreted as a binary operator (6.2),a-bis unambiguously parsed asa - b(subtraction). - Multi-word identifiers are composed in snake case using
_(show_version). - The name mapping for calling functions from the CLI with
--separated names (show-version) is defined in 11.2. Because identifiers cannot contain-, this mapping is collision-free.
3.3 Literals and Environment Tokens
literal = null_lit | bool_lit | number_lit | string_lit .
null_lit = "null" .
bool_lit = "true" | "false" .
number_lit = decimal_digit { decimal_digit } [ "." decimal_digit { decimal_digit } ] .
string_lit = raw_string_lit | interpreted_string_lit .
raw_string_lit = "'" { raw_char | newline } "'" .
interpreted_string_lit = '"' { string_char | interpolation } '"' .
interpolation = "#{" Expression "}" .
env_head = "#" env_char { env_char } .
env_char = letter | decimal_digit | "/" | ":" | "." | "-" | "_" | "@" .env_head is a lexical token dedicated to environment expressions (6.7) that is not included in literal; this section defines only its lexical rules.
Among string literals, raw_string_lit is called a raw string and interpreted_string_lit is called an interpreted string.
String character classes and termination rules:
raw_charis aunicode_charexcluding'. A raw string may contain newlines (newline), but no means is provided to include'itself. When a string containing'is needed, use an interpreted string.string_charis aunicode_charexcluding",\, and newlines, or an escape sequence. A newline cannot be included directly in an interpreted string; use\ninstead.- A string literal terminates at the first matching closing quote that appears.
Lexical rules:
- Whitespace (spaces, tabs) is treated as a token separator except inside string literals, command strings (6.6), and comments.
- Consecutive whitespace is treated as equivalent to a single separator.
- A newline is treated as an independent token (
newline); at the top level it marks the end of a declaration (the declaration termination rules of Chapter 5), inside adoblock it marks the end of a statement (the statement termination rules of 6.5), and in a command execution expression it marks the end of the command string (6.6). However, a newline that falls under the continuation rules of 6.5 is ignored in the same way as token-separating whitespace. - Consecutive newlines (blank lines) are treated as equivalent to a single newline token. Blank lines produce no declarations or statements.
- Comments are treated the same as whitespace for the purposes of lexical analysis.
- A line comment ends at a newline, and a block comment is enclosed by
/*and*/. - Number literals do not include a sign.
-and+are always interpreted as binary operators. Negative numbers cannot be expressed as literals and are constructed with expressions such as0 - 1(unary minus is not provided). env_headis the leading token of an environment expression (6.7) and is read with longest match. It terminates at the position where a character that is not anenv_char(whitespace,],,,),}, etc.) appears.- A
#not followed by at least oneenv_charis a lexical error (except for the interpolation opener#{inside interpreted strings and command strings). - The character set of
env_charis defined so that any Docker image reference including registry, repository, tag, and digest can be written.@is included for digest specification (e.g.#alpine@sha256:...). It also covers a relative path to a Dockerfile (#./images/build/Dockerfile, 6.7). - A path in an
env_headcannot contain whitespace or any character outsideenv_char; a Dockerfile meant to be named by one must be placed at a path that can be written.
Reserved words:
- The following words are reserved words and must not be used as identifiers.
import,export,internal,from,as,type,command,do,async,await,if,else,case,for,return,try,catch,finally,true,false,nullreturnis reserved but begins no construct: Lask has no return statement (6.5). It is kept so that a program written as in another language is told what to write instead, by a syntax error (E-SYNTAX-UNEXPECTED-TOKEN) rather than an undefined name.onis not a reserved word. It is a contextual keyword recognized only within a command declaration (Chapter 5), directly after the closing brace of its command words, where no other token may appear; it remains usable as an identifier elsewhere. Because the position is lexical, an editor can showonas a keyword there without elaborating the module.stdinis not a reserved word but is a reserved identifier (9.3), and must not be declared or rebound in user code.
String interpolation:
- String interpolation can be used only in interpreted strings (
"..."). - Interpolation starts with
#{and ends with the matching}. - The interpolated part is lexed and parsed as an
Expression. - Raw strings (
'...') do not perform interpolation; their contents are treated as the string value as-is.
Escape sequences:
- Escape sequences are valid only in interpreted strings.
- The minimum supported escapes are
\\,\",\n,\r,\t. - Unicode escapes are supported in the
\u{HEX}form (HEXis one or more hexadecimal digits). - Raw strings perform no escape processing.
4. Type System
This chapter defines the types of values that Lask handles, type annotations, type inference, and type compatibility.
4.1 Classification of Types
- Base types
Any: A general-purpose type that accepts any valueNumber: Integers and floating-point numbersString: UTF-8 stringsBool: Boolean valuesNull: Absence of a valueVoid: Absence of a return valueEnvironment: The execution environment of commands (Chapter 10): where a command runs, and in which image. It holds a special position distinct from the other base types in that its values are constructed only by environment expressions (6.7) and its serialization is limited to a metadata representation (4.5, 13.1).Runnable: An environment together with the run options it is run with (10.2): what a command execution needs. Its values are constructed only byrunnable(15.5) and its sugar#head{...}(6.7), and as the sessionwith_containergives its body (15.5).Environmentconforms to it (4.4); serialization is as forEnvironment.
- Composite types
Array<T>: An array whose element type isTMap<T>: A map with string keys and value typeTRecord<field1: T1, field2: T2, ...>: A record with fixed fieldsAsyncHandle<T>: A handle referring to the resultTof an asynchronous executionFunction<T1, T2, ..., R>: A function type consisting of positional parameters only. The last type argument is the return type.
- Union types
T1 | T2 | ...: A type whose values are the values of any one of its members. It is how a value that may be absent is typed (String | Null), and it is the only type former whose members are types of the program's own choosing rather than a fixed shape.
4.2 Type Syntax
Type = UnionType .
UnionType = SingleType { "|" SingleType } .
SingleType = BaseType | ArrayType | MapType | RecordType | AsyncHandleType | FunctionType | NamedType .
BaseType = "Any" | "Number" | "String" | "Bool" | "Null" | "Void" | "Environment" .
ArrayType = "Array" "<" Type ">" .
MapType = "Map" "<" Type ">" .
RecordType = "Record" "<" [ RecordFieldType { "," RecordFieldType } ] ">" .
RecordFieldType = ( lower_id | string_lit ) [ "?" ] ":" Type .
AsyncHandleType = "AsyncHandle" "<" Type ">" .
FunctionType = "Function" "<" Type { "," Type } ">" .
NamedType = ( upper_id | QualifiedNamedType ) [ TypeArgs ] .
QualifiedNamedType = lower_id "." upper_id .
TypeArgs = "<" Type { "," Type } ">" .
TypeParams = "<" TypeParam { "," TypeParam } ">" .
TypeParam = upper_id [ ":" Bound ] .
Bound = NamedBound | Type .
NamedBound = "comparable" | "orderable" | "stringifiable" .
TypeAliasDecl = "type" upper_id [ TypeParams ] "=" Type .Representation of Function types:
Function<T1, T2, ..., R>denotes a function type that takesT1, T2, ...in positional order and returnsR.AsyncHandle<T>denotes a reference to a resultTof an asynchronous execution that is in progress or completed.Function<R>denotes a function that takes no arguments.
Definition of named types:
- Named types are defined by the type alias declaration
type. - The form is
type TypeName = Type, ortype TypeName<A, B> = Typefor an alias with parameters (below). - A type alias defined in another module is referenced either by its bare name (
upper_id), after bringing it in with a named import, or, when the defining module is brought in with a namespace import (5), qualified asnamespace.TypeName(QualifiedNamedType). Either form takes type arguments where the alias has parameters (m.Pair<Number, String>). The two forms name the same declaration; a module may use either, or both, for the same type.namespacemust be a namespace bound by aNamespaceImportin the current module, andTypeNamemust be a public type alias of the module that namespace refers to (5, "Public scope and visibility"); otherwise it is an undefined reference error.QualifiedNamedTypeis recognized only in type-annotation position (whereverTypeappears in this grammar) — it does not extend to a namespace chain (m.n.TypeName) and does not change hownamespace.symbolis resolved as a value or function reference (7.2). - Example:
type Strings = Array<String> - Example: given
import * as m from "./lib.lask"and a publictype Config = Record<...>inlib.lask,m.Configdenotes that type.
Type parameters:
- A function declaration (5) and a type alias may declare type parameters, written
<T>or<A, B>after the name. Within their scope, each stands for a type that the use site determines. - An
upper_idin type position resolves to a type parameter when one of that name is in scope, and to aNamedTypeotherwise. A qualifiednamespace.TypeNameis never a type parameter. - A type parameter's scope is: for a function, its parameter types, its return type, and the type annotations inside its body (6.5); for a type alias, the right-hand side of the alias.
- Declaring a type parameter whose name is that of a type alias visible at the declaration is a duplicate definition error (
E-NAME-DUPLICATE). Shadowing is not permitted, so a type name means one thing throughout a declaration. - A type parameter that appears in no position is permitted. Nothing determines it and nothing needs to.
- Type parameters are not written at a use site: a call instantiates them from the argument types and the expected type (4.4), and there is no syntax for giving them explicitly.
Bounds:
A type parameter may carry a bound, written after a
:, which limits the types it may be instantiated to (4.4). One parameter has at most one bound.A named bound is one of three predicates, written in lower case:
comparable: the comparable types of 6.2.orderable:NumberandString, the typessortorders (15.4).stringifiable: the stringifiable types of 6.6.
The set of named bounds is closed: a program cannot declare one. A named bound is recognized only in bound position. There it cannot be mistaken for a type, since no type is written in lower case, and elsewhere the words are ordinary identifiers. Any other lower-case word there is an undefined reference error (
E-NAME-UNDEFINED).A type bound is any other type. It admits the types that conform to it (4.4), so
<T: Number | String>admitsNumber,StringandNumber | String. It may be written through a type alias, which is how a program defines a bound of its own:lasktype Key = Number | String label<T: Key>(x: T): String = "key #{x}"A type bound must not mention a type parameter, and must not be
Void. Either is a static error (E-TYPE-ILLFORMED).A bound is a restriction on use sites, not a type class. There are no instances to declare, and a type satisfies a named bound by what it is, by the rules of the section that defines the predicate.
largest<T: orderable>(xs: Array<T>): T = last(sort(xs))
dedupe<T: comparable>(xs: Array<T>): Array<T> = unique(xs)
type Index<K: comparable> = Array<Record<key: K, at: Number>>Parameterised type aliases:
type Pair<A, B> = Record<first: A, second: B>declares an alias with parameters, referenced asPair<Number, String>.- The number of type arguments must equal the number of declared parameters. A parameterised alias referenced without arguments, a plain alias referenced with them, and a count mismatch are all static errors (
E-TYPE-ARITY). - A reference expands by substituting the arguments for the parameters, and well-formedness (below) is checked on the result, so that
type Bad<A> = Array<A>is legal whileBad<Void>isE-TYPE-ILLFORMEDat the position that wrote it. - The prohibition on a self-referencing alias (below) is unchanged and applies to the alias name however many parameters it takes.
- Examples:
type Opt<A> = A | Null,type Table<V> = Map<Array<V>>.
Union types:
T1 | T2 | ...denotes the type whose values are the values of any one member.String | Nullis the type of aStringthat may be absent.- Because every production above is defined in terms of
Type, a union may appear wherever a type may: as a type argument (Array<String | Null>), as a record field type (Record<tag: String | Null>), in a parameter or return position of a function type (Function<String, String | Null>), and as the right-hand side of a type alias (type OptionalName = String | Null). |separates the members of a union only in type position. It is unrelated to the logical operator||(6.2), and a union must be written with its members separated (String | Null, notString||Null).
Canonical form of a union:
Type identity is structural (4.4), so every union is reduced to a canonical form when it is constructed. Two type expressions denoting the same union are therefore the same type, and print identically in diagnostics.
- Flattening: a union is never a member of a union. The type syntax has no parenthesized type, so nesting arises only through a type alias: given
type AB = A | B, the typeAB | CisA | B | C. - Deduplication: repeated members are kept once.
A | AisA. - Collapsing: a union of one member is that member.
- Absorption by
Any:Any | TisAny, because every type already conforms toAny(4.4).Any | Nullis thereforeAny. - Ordering: members are held in a fixed order, so that
String | NullandNull | Stringdenote the same type. Members are ordered by the order in which their type forms are listed in 4.1, and two members of the same form by their rendered notation, except thatNullis always last. The order is total, so the canonical form of a union is unique.
Well-formedness of a union:
Voidmust not be a member of a union (E-TYPE-ILLFORMED), as in every other position other than a function return type and the type argument ofAsyncHandle.- Every member of a union must be a data type:
Number,String,Bool,Null,Environment,Any, andArray/Map/Recordcomposed of them — the same set thatcastmay target (15.8). A union having a member that containsFunction<...>orAsyncHandle<T>is a static error (E-TYPE-ILLFORMED). - The reason for the second rule is that a union is only useful if a program can get back out of it, and both ways out — the runtime type check of
cast(15.8) and the type dispatch ofcase(6.4) — are defined for data types alone. A type such asFunction<Number, Number> | Nullwould be inhabited but unusable, and it is rejected where it is written rather than where it is used.
Optional fields:
- A
?after a field name declares the field optional: the key may be absent from the value. Without it the key must be present. The marker qualifies the key, and the field's type qualifies the value, so the two questions a record field raises are asked separately and answered separately.
| Written | Key | Value |
|---|---|---|
a: String | must be present | a String |
a: String | Null | must be present | a String or null |
a?: String | may be absent | a String when present |
a?: String | Null | may be absent | a String or null when present |
- An absent key and a null value are different things and stay different: an absent optional key is not a null, and a null is not an absent key. Where the difference is observable is at the two boundaries —
castaccepts a value whose optional keys are missing and rejects one whose required keys are (15.8), and serialization omits an absent optional field while writing an explicitnullfor a null one (13.1). ?may be written on a field name in either form,a?: Stringor"X-Api-Key"?: String, and nowhere else in a type: it is not a type suffix. (In an expression,?is the conditional operator, 6.2.)- Reading an optional field yields
T | Null, an absent key reading as null (6.8). In memory, then, a program sees the two cases the same way; a program that must tell them apart moves the record to aMap<T>withcastand askshas_key(15.4).
Record field names:
- Field names may be written in identifier form (
lower_id) or string literal form (string_lit). Identity is determined by comparison as strings, andRecord<name: String>andRecord<"name": String>denote the same type. - Names that do not conform to
lower_id(e.g."APP_ENV","X-Api-Key") can be written only in string literal form. - String literals used in type notation and as object literal keys must not contain interpolation (
#{...}). - Duplicate field names within the same record type or the same object literal (including collisions between identifier form and string literal form) are a static error (
E-TYPE-FIELD-DUPLICATE). - Access to field names that do not conform to
lower_iduses string literal indexing (6.8). - A reserved word (3.3) is not usable as an identifier and therefore not as a field name in identifier form. A field whose name is a reserved word (
internal,export,type, ...) is written in string literal form (Record<"internal": Bool>,{"internal": true}) and read by string literal indexing (6.8).
Type well-formedness rules:
Voidmay appear only in the return-value position of a function type (Function<..., Void>) and as the type argument ofAsyncHandle<Void>. Its appearance as a type argument ofArrayorMap, as a record field type, as a member of a union, or in a parameter position of a function type is a static error (E-TYPE-ILLFORMED).- All types constructed during type checking, including every instantiation of a type variable (4.4), must satisfy these well-formedness rules (e.g. an instantiation making the result type of
mapbeArray<Void>isE-TYPE-ILLFORMED; constructingAsyncHandle<Void>viaspawnis legal). - A type alias (
TypeAliasDecl) must not reference itself directly or indirectly (recursion and mutual recursion are prohibited). A violation is a static error (E-TYPE-ILLFORMED). This rule guarantees that expansion ofNamedType(4.3) always terminates in a finite number of steps.
Examples:
- Base types
NumberStringEnvironment
- Array types
Array<Number>Array<Record<name: String, age: Number>>
- Map types
Map<String>Map<Array<Number>>
- Record types
Record<name: String, age: Number>Record<env: Environment, tags: Array<String>>Record<"APP_ENV": String, "REGION": String>
- Function types
Function<Number, Number, Number>Function<String, Record<message: String, ok: Bool>>Function<Number>
- Union types
String | NullNumber | NullArray<String> | NullRecord<ok: Bool> | Null
- Named types
Strings(type Strings = Array<String>)UserBuildConfigtf.TfOutputs(qualified reference to a public type aliasTfOutputsof the module bound to the namespacetf)
4.3 Type Annotations and Inference
Type annotations are optional, and the following type inference rules apply where annotations are absent.
- When an annotation is given, the annotation takes precedence, and the inferred type must conform (per the conformance relation of 4.4) to the annotated type.
- Type annotations (on variables, parameters, and return values) and the argument types of a callee propagate as the expected type for the corresponding expression, and are used for the expected-type-directed checking of literals (described below).
- A number literal is
Number, a string literal isString, a boolean literal isBool, and thenullliteral isNull. An environment expression#...(6.7) isEnvironment. - An optional field is never inferred: a record literal with no expected type infers every key it gives as required (
{a: 1}isRecord<a: Number>), so an optional field, like a union, appears only where it was written. - A union type is never inferred. It is the type of an expression only where an annotation or a callee's signature says so, and the rules below are unchanged by the existence of unions: a heterogeneous array literal is still
Array<Any>and not an array of a union, and two branches of differing types are still a type error rather than a union of the two. This keeps every union in a program one that someone wrote. - For an array literal
[e1, e2, ...]with no expected type, the type of each element is inferred; if all are the same typeT, the literal isArray<T>, and if heterogeneous, it isArray<Any>. - When the expected type is
Array<T>, if every element expression conforms toT, the literal's type isArray<T>. If any element does not conform, it is a type error (conformance is invariant (4.4), but literals can promote element-wise toAnyetc. via expected-type-directed checking). - The empty array
[]follows the expected type if one exists; with no expected type it isArray<Any>. - For an object literal
{k1: e1, ...}with no expected type, it is inferred asRecord<k1: T1, ...>(Tiis the inferred type of eachei). When a key is a string literal, that string is the field name (per the record field name rules of 4.2). - An object literal with an expected type is checked for consistency with the expected type by the following rules.
- When the expected type is
Record<...>: the literal must give every required field, may give any of the optional fields (4.2), and must give nothing else (key identity follows the field name rules of 4.2); each value expression must conform to the corresponding field type. A missing required key, an extra key and type nonconformance are type errors. A key written asb?: Stringmay be omitted, and when written must hold aString--{b: null}is a type error unless the field isb?: String | Null. - When the expected type is
Map<T>: when every value expression conforms toT, the literal's type isMap<T>. If any value does not conform, it is a type error (keys are always treated asString. 4.4). - When the expected type is
Any: inferred asRecord<...>in the same way as when there is no expected type. - When the expected type is a union: if exactly one of its members is a
Record<...>or aMap<T>, the literal is checked against that member by the rules above, sof(): Record<a: Number, b?: String> | Null = {a: 1}is well-typed. Otherwise the literal is inferred as when there is no expected type, and the inferred type must conform to the union (4.4). - Any other expected type is a type error.
- When the expected type is
- When the expected type is a union
U1 | ... | Un, the expression is checked against it by the conformance relation of 4.4, and an expected type is propagated into the branches of anIfExprand of a conditional expressionc ? a : b(6.2), and the arms of aCaseExpr(6.4), unchanged. Thereforef(): String | Null = if (c) { "x" } else { null }is well-typed: each branch is checked againstString | Null, and bothStringandNullconform to it. - When a variable or function declaration has no annotation, the type of the right-hand-side expression is adopted as the declared type.
- When a function has no return-value annotation, the type of the function body is inferred as the return type.
- When a parameter has no annotation,
Anyis adopted. - In a function call
f(a1, ..., an),fmust resolve toFunction<T1, T2, ..., Tm, R>, the argument binding must satisfy the rules of 7.5 (including keyword arguments and default-value completion), and each bound argument must conform to the type at the corresponding position. The result type isR. - In the form
Function<T1, T2, ..., R>, the last type argument is the return type, and the preceding ones are the positional parameter types. - A
NamedTypeis expanded into itsTypeAliasDeclbefore type checking, and inference is performed on the expanded type. - Overloading is not supported. One symbol has one function type.
- Abstract types (type classes, etc.) are not supported. A type parameter may carry a bound from a closed set of predicates, or a type bound (4.2), and nothing more: an operation beyond what the bounds give is taken as an argument instead (
Function<T, T, Bool>as an equality). - A declaration may declare type parameters (4.2), and its annotations — including those on bindings inside its body — may mention them. They are instantiated per call by the rules of 4.4.
Diagnostic rules on inference failure:
- If the type of an expression does not conform to the annotated type, it is a type error.
- If argument binding at a call does not satisfy the rules of 7.5, or an argument type does not conform, it is a type error.
- If multiple constraints conflict and cannot be unified into a single type, it is a type error.
- If a single final type cannot be determined, it is an inference failure error.
Examples of type inference:
n = 1is inferred asn: Number.ok = trueis inferred asok: Bool.names = ["a", "b"]is inferred asnames: Array<String>.mixed = [1, "a"]is inferred asmixed: Array<Any>.empty = []is inferred asempty: Array<Any>when there is no expected type.user = {name: "alice", age: 20}is inferred asuser: Record<name: String, age: Number>.envMap: Map<String> = {"APP_ENV": "prod", "REGION": "ap-northeast-1"}is typed asMap<String>due to the expected typeMap<String>.hdrs = {"X-Api-Key": "secret"}is inferred ashdrs: Record<"X-Api-Key": String>because there is no expected type.bad: Map<Number> = {"a": "x"}is a type error because the value"x"does not conform toNumber.- For
add(x: Number, y: Number) = x + y, the calladd(1, 2)is inferred to have result typeNumber. identity(x) = xis inferred asidentity: Function<Any, Any>because the parameter has no annotation and no default value.- For
inc(--x = 0) = x + 1, the keyword parameterx: Numberis inferred from the default value0, andincis inferred asinc: Function<Number>(no positional parameters). The callsinc()andinc(x = 5)are both legal, with result typeNumber. - Given
type Strings = Array<String>,xs: Strings = ["a"]is treated as equivalent toxs: Array<String>.
4.4 Type Semantics
Type conformance relation:
"Conformance" in type checking is a binary relation defined as follows. When the type T of an expression conforms to the type U of a required position, that expression may be placed in a position requiring U (an argument, the right-hand side of an annotated declaration, a field value, a return value, etc.).
- Reflexivity:
Tconforms toT. Type identity is determined by structural identity after expandingNamedType(4.3) and reducing unions to canonical form (4.2). - Top type: any
Tconforms toAny.Anyis the only top type. - Union introduction: a type
Tthat is not a union conforms toU1 | ... | UnwhenTconforms to someUi. - Union elimination:
T1 | ... | Tmconforms toUwhen everyTiconforms toU. - Environments:
Environmentconforms toRunnable. An environment is a runnable with no run options (10.1), so it may be placed wherever a command execution needs a runnable.Runnabledoes not conform toEnvironment. - No conformance relation exists for any pair of types other than the above.
The two union rules together give: String conforms to String | Null; String | Null conforms to String | Null | Number; String | Null conforms to Any; and String | Null does not conform to String. Moving from a union to one of its members is not a conformance step: it requires the runtime check of cast (15.8) or the type dispatch of case (6.4). The relation remains reflexive, transitive, and antisymmetric on canonical forms, and Any remains the only top type.
Invariance (no variance):
- Conformance is not lifted into the interior of type constructors (invariant).
Array<Number>does not conform toArray<Any>(element types must be identical). The same applies toMapandAsyncHandle. - The union rules are likewise not lifted:
Array<String>does not conform toArray<String | Null>, andRecord<a: String>does not conform toRecord<a: String | Null>. A union describes one position, never the interior of a constructor at that position. An array or object literal can still be written directly at the union element type through expected-type-directed checking (4.3), as inxs: Array<String | Null> = ["a", null]. Recordhas no width or depth subtyping. It conforms only when the required field set, the optional field set (4.2) and each field type are identical.Record<a: String>therefore does not conform toRecord<a?: String>, nor the reverse: whether a key has to be there is part of the type.- Function types conform only when identical. No variance (contravariance or covariance) is introduced for argument positions or return-value positions.
- Under this definition, the soundness conditions of variance associated with subtyping (contravariance in function argument positions, etc.) do not arise.
The meaning of types is defined by the following rules.
Anyis the only top type that accepts values of all types (any type conforms toAny).- An expression of type
Anymay be placed only in positions requiringAny. Moving fromAnyto a concrete type is done only by a runtime type check, in one of two forms:cast(15.8), which yields the value at the target type and fails when it is of some other kind, and the type dispatch ofcase(6.4), which tests instead of failing and narrows the scrutinee in the arm it selects. The two perform the same check (15.8) and differ only in what happens when it does not hold. No static implicit conversion is provided. - Narrowing by
caseapplies to a scrutinee of typeAnyexactly as it does to a union, with one difference that follows fromAnyhaving no members: an arm selected by the type headMnarrows the scrutinee toM, and theelsearm leaves itAny, because a type cannot be subtracted fromAnyand nothing is learned by a test that failed (6.4). Nullis a value type representing the absence of a value and can be evaluated as an expression.Voidis a type representing a computation result that returns no value, and must not be used as a value.NullandVoidare not identified with each other. PlacingVoidin a position requiringNull, or vice versa, is a type mismatch.Array<T>denotes an array whose elements are uniformlyT.- An array with mixed element types is treated as
Array<Any>. Map<T>is a built-in composite type denoting a dynamic associative array with string keys; the key type is always fixed toString.Record<...>denotes a structure whose field set and field types are statically fixed.- The division of roles is:
Map<T>for structures whose key set may vary at runtime, andRecord<...>for structures with a key set fixed by specification. T1 | T2 | ...denotes the values of any one member. A value of a union type carries no tag of its own: it is a value of exactly one member type at runtime, and which one is observed by the runtime type check ofcast(15.8) or bycasetype dispatch (6.4).AsyncHandle<T>is a runtime handle managing the lifecycle of an asynchronous computation, and is resolved toTbyawait.- Conformance between function types
Function<T1, T2, ..., R>follows the conformance relation at the beginning of this section, and holds only when the number of positional parameters, each parameter type, and the return type are all identical (promotion toAnyoccurs only in positions requiring a top-levelAny). - Parameter names, keyword parameters, and variadicity are not part of a function type. Because declared parameter information cannot be consulted when a function is applied after being passed around as a function value, binding is performed with positional arguments only, and all keyword parameters are completed with their default values (7.5). A function with a required keyword parameter has no such completion, and cannot be a function value (6.1).
- Because overloading is not permitted, multiple function types must not be assigned to a single function symbol at the same time.
- A
NamedTypeis treated as equivalent to the type obtained after expanding the correspondingTypeAliasDecl.
Polymorphic types:
- A signature contains type variables when it belongs to a built-in symbol defined by this specification (core functions and functions of the built-in library; chapters 6 and 15), or to a declaration that declares type parameters (4.2). The two are checked by one set of rules, given below.
- An
upper_idin a type annotation that is not a type parameter in scope is aNamedType, resolved as a type alias reference; if there is no corresponding declaration (for the qualified form: no such namespace, or no public type alias of that name in the module it refers to), it is an undefined reference error. - A signature containing type variables is treated as a type scheme, and is instantiated to concrete types independently for each call and then checked. Within a single call, type variables of the same name must be bound to the same concrete type.
- Instantiation is determined from the argument types and the contextual expected type. If a single concrete type cannot be determined, it is an inference failure error (4.3).
- Instantiation does not depend on the order of the arguments. An argument whose own type comes from its position — a
cast(15.8), or afail(15.7) — is therefore checked once the other arguments and the expected type have determined the type variables of the position it sits in, and may stand in any argument position whose type they determine. Where nothing determines it, it remains an inference failure. This governs checking only: the arguments of a call are still evaluated from left to right (8.3). - A type variable may occur inside a union anywhere a type variable may occur, a parameter position included. In a return type the union is built by substituting the instantiation (
find, 15.4, isFunction<Array<T>, Function<T, Bool>, T | Null>); in a parameter position it is matched like any other pattern, and the variable is determined by whichever position determines it — which, since instantiation does not depend on argument order, may be another parameter or the expected type. Where nothing determines it, the ordinary inference failure reports it. - Matching an argument against a union pattern follows the rule above for unions on both sides: the members the two have in common are dropped, and a variable left facing a single member is bound to it. An argument that is not a union at all is matched once the other positions have made the pattern concrete. So for a signature
Function<T | Null, T, T>, an argument of typeString | NullbindsTtoStringby the first route and an argument of typeStringby the second. - A union in a parameter position that mentions no type variable is an ordinary ground type and is checked by conformance like any other.
- When the contextual expected type is used to instantiate a union return type, identical members of the expected type and of the signature's union are removed from both; if exactly one member remains on each side and the signature's is a type variable, it is bound to the other (
T | Nullagainst an expectedString | NullbindsTtoString). In every other case the expected type contributes no binding, and the instantiation must come from the arguments or it is an inference failure error. - When a polymorphic function is referenced as a function value in a position other than a call (binding to a variable, passing as an argument, etc.), the type variables must be uniquely instantiable from the expected type at the reference position. A function value has no type variables of its own, so the whole of its type — parameters and result alike — has to be determined there. If instantiation is not possible, it is a type error. Example:
m: Function<Array<Number>, Function<Number, Number>, Array<Number>> = mapcan be instantiated via the expected-type annotation, butm = mapwithout an annotation is an inference failure error. - A reference whose expected type is not fully determined is written as a lambda instead, which requires only its parameter types: the body is then a call, and a call instantiates from its arguments. So where
map(xss, reverse)has no type to instantiatereverseat —map's result variable being fixed by nothing —map(xss, \(xs) -> reverse(xs))is well-typed, and the result type flows out of the lambda rather than being demanded by it. The two forms are not interchangeable in general: a value reference also loses the declaration parameter information a call keeps (7.5), so keyword arguments and default completion are available in the second and not in the first. An implementation must not silently rewrite one into the other. - Implementations may realize polymorphism by any internal mechanism, but must satisfy the observable type-checking results above.
Bounds (4.2):
- A type satisfies a named bound when it has the property the bound names (6.2, 15.4, 6.6), and a type bound
Bwhen it conforms toB. A type parameter in scope satisfies what its bound entails (below). - Wherever a signature is instantiated — at a call, and at a reference as a function value alike — the type each bounded variable is instantiated to must satisfy its bound. A violation is a static error (
E-TYPE-BOUND), whose diagnostic names the function, the variable, the bound and the type. A type alias with bounded parameters is held to them where it is applied to type arguments. - The same predicates govern the operations that have no signature:
==/!=(6.2) and acasevalue head (6.4) needcomparable, and interpolation (6.6) needsstringifiable. A violation of these isE-TYPE-BOUNDas well. - Built-in functions state their conditions as bounds in their signatures, written
sort<T: orderable>: Function<Array<T>, Array<T>>(15.1).cast(15.8) is not among them, since its target has to be a concrete data type at run time. A type parameter never is, bounded or not, so that condition is well-formedness (E-TYPE-ILLFORMED) rather than a bound.
Entailment:
- A bound entails a named bound when every type it admits satisfies that named bound.
orderableentailscomparableandstringifiable, as well as itself.comparableandstringifiableentail only themselves.- A type bound
Bentails a named bound thatBitself satisfies, except thatAnyentails nothing: every type conforms toAny. So<T: Number | String>entailscomparableandstringifiable, but notorderable, since the union admits itself and a union is not orderable.
Type parameters within the body of their declaration (rigidity):
- Within the body of a declaration that declares type parameters, each type parameter is an opaque type, distinct from every other type including every other type parameter. It conforms to itself, to
Any, and, when it has a type boundB, to whateverBconforms to. - It satisfies a named bound exactly when its own bound entails it (4.4, bounds). An unbounded type parameter is therefore not a comparable type (6.2), not ordered (15.4), and not stringifiable (6.6);
<T: comparable>can be compared with==,<T: stringifiable>interpolated, and<T: orderable>sorted, compared and interpolated. No bound makes it a legal target ofcast(15.8) or a legalcasetype head (6.4), being neither a union norAny. - A value of an unbounded type parameter's type can be bound, passed, returned, placed in an
Array/Map/Record, and serialized throughAny— and nothing else. A body that needs an operation beyond what a bound gives takes the operation as an argument (Function<T, Number>as a sort key,Function<T, T, Bool>as an equality). - A bounded type parameter is still distinct from its bound:
<T: Number>may be passed whereNumberis required, butx + 1is aNumberand not aT. - A type parameter is opaque only inside the body. At a call site it is instantiated to a concrete type, and the result is that concrete type.
- This rule has no counterpart for built-in symbols, which have no body in this specification.
4.5 Serializable and Non-serializable Types
- Directly serializable
Any,Number,String,Bool,NullArray<T>,Map<T>,Record<field1: T1, field2: T2, ...>T1 | T2 | ...(every member of a union is a data type by the well-formedness rules of 4.2, so a union is always directly serializable; a value serializes as the member it actually is, 13.1)
- Not directly serializable
Void,Environment,RunnableAsyncHandle<T>Function<T1, T2, ..., R>
- Function values are converted to reference metadata (
FunctionRef; 13.2) in execution records and external output. - Likewise,
Void,EnvironmentandRunnableare converted to a tagged metadata representation (13.1) in external output. Restoration by deserialization is not guaranteed for either (the round-trip rules of 13.1).
5. Declarations and Modules
This chapter defines modules, declarations, public symbols, and imports.
Module = { TopLevelDecl } .
TopLevelDecl = ( ImportDecl | ExportDecl
| [ Visibility ] ( CommandDecl | TypeAliasDecl | Declaration ) ) decl_end .
decl_end = newline | ";" .
Visibility = "export" | "internal" .
TypeAliasDecl = "type" upper_id [ TypeParams ] "=" Type .
Declaration = ValueDecl | FunctionDecl .
ValueDecl = lower_id [ "!!" ] [ ":" Type ] "=" Expression .
FunctionDecl = lower_id [ TypeParams ] "(" [ FunctionParameterList ] ")" [ ":" Type ] "=" Expression .
CommandDecl = "command" CommandWords "on" Expression [ "schema" Expression ] .
CommandWords = "{" string_lit { "," string_lit } "}" .
ImportDecl = "import" ( NamedImports | NamespaceImport | CommandImports ) "from" ImportPath .
ExportDecl = "export" ( NamedImports | NamespaceImport | CommandImports ) "from" ImportPath .
CommandImports = "command" CommandWords .
NamedImports = "{" ImportSpecifier { "," ImportSpecifier } "}" .
ImportSpecifier = ( lower_id | upper_id ) [ "as" ( lower_id | upper_id ) ] .
NamespaceImport = "*" "as" lower_id .
ImportPath = string_lit .Declaration termination rules:
- A top-level declaration terminates at a newline,
;, or end of file. Immediately before the end of file,decl_endmay be omitted. - Termination and continuation are determined identically to the statement termination rules of 6.5 (continuation tokens, continuation by line-leading tokens, and the handling of line-leading
(and[). By the continuation rules, the right-hand side of a declaration can span multiple lines. ;is an optional separator for writing multiple declarations on one line, and is not required at the end of a line.asandfromare not continuation tokens (6.5). Animportdeclaration must be written on one line, except inside the braces ofNamedImports(which may span multiple lines by the open-bracket continuation rule). The closing}and thefromclause must be placed on the same line.- A line-leading
(or[does not continue the preceding declaration and is interpreted as the start of a new declaration. Since a top-level declaration begins withimport,type, or an identifier, this case results in a syntax error. - The optional
!!marker on aValueDeclname declares a secret binding (6.10); it does not affect parsing of the rest of the declaration. - A
<following the name of aFunctionDeclor aTypeAliasDeclbegins its type parameters (4.2), and needs no lookahead to recognize: the name of a declaration is otherwise followed by(,!!,:or=, and a declaration never begins with an expression, so<can be nothing else there. AValueDecltakes no type parameters, having no place to instantiate them. - Type parameters are not part of a declaration's name.
first<T>declares the symbolfirst, which is what an import, an export and a duplicate-definition check see. exportandinternalare reserved words (3.3), so a leading marker is unambiguous and needs no lookahead:Visibilityappears only at the start of a top-level declaration, and forexporta following{begins anExportDeclinstead. Neither word can be a declaration name, and like every reserved word neither is alower_idin any other position (4.2 covers what this means for field names).exportis not a continuation token. AnExportDeclmust be written on one line, except inside the braces ofNamedImports; the closing}and thefromclause must be placed on the same line.schemais a contextual keyword, likeon: it is a keyword only after the environment of a command declaration, and remains usable as an identifier everywhere else. The environment expression ends before it, and it must be placed on the same line as the end of that expression; the schema expression may span lines by the open-bracket continuation rule.CommandWordsis written in braces in every form — a declaration, an import and a re-export — so that the three read alike. The braces may span lines by the open-bracket continuation rule; the closing}and theonorfromthat follows it must be placed on the same line. Afterexport command, the word after the closing brace decides the form:onbegins a command declaration,froma re-export.
Command declarations:
- A command declaration registers each name as a command word: the name of a program that the given environment provides. It binds no value name. Command words are the sole means by which a command execution expression with no environment specification obtains an environment (6.6, 10.9); a module has the words it declares and those it imports (below).
- A name is a
string_litcontaining no interpolation. The set of command words a module has must be determinable without evaluating it, for the same reason 10.2 requires an image to be named by the head of an environment expression. - A name is valid exactly when submitting it alone, as a whole command string, to the dispatch procedure of 10.9 yields exactly one candidate whose text is the name itself. Otherwise it is a static error (
E-TYPE-COMMAND-NAME). The rule is stated against the procedure so that a name that could never be recognized in a command string is rejected where it is written;docker-compose,7zandg++are valid, whilemy prog,a=b,/usr/bin/xandfoo*are not. - The environment is any expression of type
Runnable— anEnvironment, which conforms to it (4.4), or a runnable with its run options; any other type is a static error (E-TYPE-COMMAND-ENV). It is elaborated in the module that declares it, and evaluated when a command it selects runs, not when the module is loaded. - The environment must not have an effect. It must not reach, directly or through the top-level declarations it references, command execution (15.5, and so no command execution expression), the filesystem (15.11), diagnostic output (15.12), nondeterministic generation (15.13), the functions that wait (15.7), or the standard input (9.3). Reachability over-approximates as enumeration does (11.4): a reference counts whether or not it is ever called. A violation is a static error (
E-TYPE-COMMAND-EFFECT). Reading the variables of the process (15.9) is permitted. - The restriction is what an environment has to satisfy for everything that reads it without running a task. Every image a declaration can name stays enumerable (11.4), since enumeration walks its expression;
cmdcan evaluate the environment without running anything and without consuming the standard input, which belongs to the program (11.8); and the environment means the same wherever the word is used, including in a module that imports it. #localis permitted, and is how a module states which programs it runs on the host.- A declaration's words are probed when its environment, evaluated as
lask cmdevaluates it (11.8), is a container environment that names noplatform. The evaluation runs nothing and reaches no file, since the environment must have no effect (above), and a secret reference in it is left unresolved. Such a word is probed bylask sync(11.7), which records in the lock whether the image provides a program of that name (Lock file, below). A word whose environment is#localis not probed: the host's programs may be installed by the very tasks that run them. - Before
runorevalevaluates anything, and beforecmdruns its command, a probed word recorded as missing from its image is an external I/O error (E-IO-COMMAND-MISSING, exit code3) when the invocation can reach a command execution expression whose environment dispatch selected through that word (10.9); forrunandeval, reachability over-approximates as enumeration does (11.4). The image is found by evaluating the word's environment again, as the probe did. The word was declared as the name of a program the environment provides, and a command naming it would fail however it is written. The diagnostic names the word and its image. A word whose image the lock records nothing for is not checked. - A command declaration takes a visibility marker like any other declaration. Omitting it is equivalent to
export, and makes the words exported command words of the module;internalkeeps them to it. Exporting a word changes nothing in another module until that module imports the word by name. - The same word declared twice within one module, or both declared and imported, is a static error (
E-TYPE-COMMAND-DUPLICATE). The names occupy no namespace shared with values or types, so they cannot collide with a declaration, a value import, or a type alias.
node = #node:20.20.2-alpine3.23
go(--proxy: String = ""): Runnable = #golang:1.25{env: { "GOPROXY": proxy }}
command { "go", "gofmt" } on go(proxy = "direct")
command { "node", "npm", "npx" } on node
command { "docker-compose" } on #docker/compose:2.29.7
internal command { "mv", "rm", "ls" } on #localCommand schemas:
A command declaration may give its words command schemas after
schema: what options and subcommands each program takes, against which the command strings that run it are checked before execution. The expression is checked against the expected typeMap<CommandSchema>, keyed by words of the declaration; a word with no key has no schema. A key that is not one of them is a static error (E-TYPE-COMMAND-SCHEMA) where the map is a literal written in the declaration, and is ignored where it comes from a top-level value the declaration refers to: a module may publish the schemas of several words as one map,schema tools.python.schema, of which a declaration uses those it declares.The types of a schema are built-in type aliases, and must not be overridden by user-defined type aliases or value declarations:
lasktype CommandOption = Record< names: Array<String>, // "-v", "--verbose", "-run" value?: String, // "none" (the default), "required" or "optional" values?: Array<String>, // the values the option takes, when they are a closed set global?: Bool, // also accepted by every subcommand below (default false) stops?: Bool, // what follows the option and its value belongs to another program, as after `python -m mod` prefix?: Bool, // the value is written right after the name, in the same word: `-Xmx2g`, `-DNAME=value`, `-Wall` description?: String > type CommandSpec = Record< closed?: Bool, // no option or subcommand exists but those listed (default false) style?: String, // "gnu" or "single-dash"; the parent's when absent, "gnu" at the root options?: Array<CommandOption>, intermixed?: Bool, // options may follow positional arguments; default true for gnu, false for single-dash stop_at_first_positional?: Bool, // what follows the first positional argument belongs to another program subcommand_optional?: Bool, // a positional argument that names no subcommand is an argument: `grype alpine:3.20` beside `grype db update` abbreviations?: Bool, // a long option may be shortened to a unique prefix, as getopt_long and optparse allow; the parent's when absent, false at the root description?: String > type CommandSchema = Record< root: CommandSpec, subcommands?: Map<CommandSpec> // keyed by path: "test", "mod tidy" >Subcommands are keyed by their path, the words that select them separated by single spaces, because a type alias cannot refer to itself (4.2). A path of more than one word requires its parent path among the keys.
A schema must be known without evaluation, as the command words are. The schema expression must be a static value: a
null, number, boolean or string literal without interpolation; an array, map or record literal whose elements are static values; or a reference to a top-level value declaration, in this module or reached through an import, whose right-hand side is a static value and which is not a secret binding (6.10). Anything else — a call, an operator, a conditional — is a static error (E-TYPE-COMMAND-SCHEMA). A static value has no effect, so the restriction on the environment above holds of it as well.A binding that holds a schema is annotated with its type (
go_schema: CommandSchema = {...}). Without the annotation, the record literal infers every field it gives as required (4.3), and that record type does not conform toCommandSchema(4.4).What the types cannot say is checked where the schema is written, and a violation is
E-TYPE-COMMAND-SCHEMA:styleis"gnu"or"single-dash";valueis"none","required"or"optional"; an option has at least one name, and each name begins with-— or, for an option withprefix, with+—, is neither-nor--, and contains no whitespace,=or quotation character; no name is given twice within one spec together with the global options of its ancestors;valueslists at least one value and is not given for an option whosevalueis"none"; an option withprefixtakes a value; a subcommand path is words without whitespace or quotation characters, none beginning with-, and its parent path is a key.A schema belongs to the declaration: a word imported or re-exported keeps the schema the declaring module gave it.
Checking a command string against a schema:
- The check applies to a command execution expression whose environment is selected by dispatch (10.9), and to each simple command of its shell reading (6.6) whose command word is plainly written (no quotation, escape or expansion, and no
/) and is a command word of the module whose declaration has the source dispatch selected and gives the word a schema. An expression with an environment specification is not checked: the program it runs may not be the one declared. - The words after the command word are read in order, assignments and redirections left out:
- A word that follows an option requiring a value is its value, whatever it is, provided it expands to one field. If the option lists
values, a literal value not among them is an error. - An option with
stopsends the check once it and its value are read:python -m pytest -k smokechecks-mand its value, and leaves-kto pytest. - A word that may expand to other than one field (an unquoted parameter expansion, command substitution or pattern) ends the check. So does any other word that is not literal, except an option whose name and
=are written literally (--count=#{n}), whose name is checked. --ends the check.- A word beginning with
-, other than-alone, is an option. In thegnustyle--nameand--name=valuename a long option — withabbreviations, also any prefix of exactly one long name, a prefix of several being ambiguous — and-abcthe single-letter options-a,-b,-c, where the first that takes a value takes the rest of the word as it. In thesingle-dashstyle-nameand-name=valuename an option, and--namenames the option of that name if there is one and is otherwise read as-name: a program such as java has both-cpand--class-path. In either style, a word that names no option but begins with the name of an option withprefixis that option, the rest of the word its value; of several such names, the longest is taken. A word beginning with+is read the same way when it begins the name of such an option (+for rustup's and cargo's+nightly), and is otherwise a positional argument. - Any other word is a positional argument. While no positional argument has been read in the current spec and it has subcommands, it selects the subcommand of that name: the check continues in it, whose options are its own and the global options of its ancestors. With
subcommand_optional, a word that names no subcommand is a positional argument of the current spec instead of an unknown subcommand. Otherwise, withstop_at_first_positional, or when options do not intermix, it ends the check.
- A word that follows an option requiring a value is its value, whatever it is, provided it expands to one field. If the option lists
- Each of the following is reported, and the check ends at the first:
- an option the current spec does not have, or a subcommand it does not have, or an abbreviated option that is ambiguous;
- an option written with
=valuewhosevalueis"none"; - an option whose
valueis"required"at the end of the command, unless the shell reading stopped inside it; - a literal value not among the option's
values.
- An unknown option or subcommand, or an ambiguous abbreviation, is a static error (
E-TYPE-COMMAND-OPTION) when the current spec isclosed, and otherwise the advisoryW-COMMAND-OPTION(14.2), since a spec that is not closed does not claim to be complete. The other findings areE-TYPE-COMMAND-OPTIONwhatever the spec says.
go_schema: CommandSchema = {
root: { closed: true, style: "single-dash" },
subcommands: {
"test": {
closed: true,
intermixed: true,
options: [
{ names: ["-v"], description: "verbose output" },
{ names: ["-run"], value: "required" },
{ names: ["-covermode"], value: "required", values: ["set", "count", "atomic"] }
]
},
"run": { closed: true, stop_at_first_positional: true },
"mod": { closed: true },
"mod tidy": { closed: true, options: [{ names: ["-v"] }] }
}
}
command { "go", "gofmt" } on #golang:1.25 schema { "go": go_schema }
ok() = $ go test -v -run TestParse ./...
// $ go tset ./... E-TYPE-COMMAND-OPTION: 'go' has no subcommand 'tset'
// $ go test -run E-TYPE-COMMAND-OPTION: '-run' requires a value
// $ go test -covermode x E-TYPE-COMMAND-OPTION: '-covermode' takes one of 'set', 'count', 'atomic'Command imports and exports:
import command { "node", "python" } from "path"makes the named words, which must be exported command words of the target module, command words of the current module, with the environment the target declared for them. A word the target does not export is an error before type checking (E-NAME-UNDEFINED).- The words are named, and no other form brings a command word in. A named import or a namespace import brings in none, and no wildcard form is provided. An import therefore changes the meaning of a command execution expression only by naming the command word, in the import itself.
- A command word is imported under its own text; there is no renaming. The word is the name of the program the environment runs, and importing it under another name would change what runs rather than what it is called.
- An imported word is not exported.
export command { "go" } from "path"imports the words and exports them as well, asExportDecldoes for symbols. - One declaration reached along two import paths — directly, and through a module that re-exports it — gives one command word, not two. A word imported from two different declarations is
E-TYPE-COMMAND-DUPLICATE. ImportPathfollows the resolution rules below, including the restriction of an external import to the entry module of a dependency. A dependency that publishes command words from several files re-exports them from its entry module.
// tools/main.lask
go(--proxy: String = ""): Runnable = #golang:1.25{env: {"GOPROXY": proxy}}
aws(--profile: String = ""): Runnable = #amazon/aws-cli:2.36.41{env: {"AWS_PROFILE": profile}}
command { "go", "gofmt" } on go()
export command { "node", "npm" } on #node:20.20.2-alpine3.23
internal command { "helper" } on #local
// main.lask
import command { "go", "gofmt", "npm" } from "tools"
import * as tools from "tools"
command { "aws" } on tools.aws(profile = "dev")Module loading unit:
- One source file is regarded as one module.
- A module can depend on other modules via
importdeclarations.
Import path resolution:
- An
ImportPathstarting with./or../is a local import. It is resolved relative to the directory of the importing module. - Any other
ImportPath(a bare path) is an external import. It must consist of exactly one path segment, which must be a dependency name declared in the project file (see "External dependencies" below); otherwise it is a static error (E-MODULE-UNRESOLVED). - If the dependency is a single
.laskfile, the dependency name is the module path (e.g.import { send } from "notify"). - For a dependency fetched as a source tree (
git, or an archiveurl), the dependency name resolves tomain.laskat the root of the fetched tree. This is the tree's entry module, and it is the only file of the tree that an external import may reach. - A path with a remainder (e.g.
"deploy-kit/deploy.lask") is a static error (E-MODULE-DEEP-IMPORT), even when the named file exists in the fetched tree. A publisher whose public API spans several files re-exports it from the entry module (ExportDecl, below). - If the tree has no
main.lask, the import is a static error (E-MODULE-UNRESOLVED).
Public scope and visibility:
- A
DeclarationorTypeAliasDecldeclared at the top level is a public symbol of that module, unless it carries theinternalmarker. - Omitting the visibility marker is equivalent to
export. The explicit form changes nothing about the meaning of the declaration; it exists so that a module can state its public surface where the surface matters. - A declaration marked
internalis not a public symbol. It is therefore unreachable by external import, by local import from another file of the same tree, byExportDecl, by CLI invocation (11.2), and from the function listing of help display (11.6). References from within the same module are unaffected. - An implementation may report the advisory diagnostic
W-EXPORT-INCONSISTENT(14.2) when one file mixes explicit and omitted markers, because an omitted marker may then be misread as non-public. The meaning is unambiguous in either case, so this is never an error. - Local bindings defined inside a module (function parameters, bindings inside
do, etc.) are not public. - The same top-level symbol name must not be declared more than once within the same module.
Import forms:
- A named import
import { a, b } from "path"brings only the specified names, among the public symbols of the target module, into the name resolution scope of the current module. If a specified name does not exist among the public symbols, it is an error before type checking. import { a as b } from "path"brings in the public symbolaunder the nameb(renaming import). The identifiers before and after renaming must be of the same kind (lower_idwithlower_id,upper_idwithupper_id).- A namespace import
import * as m from "path"refers to the target module through the namespacem. Public symbols are not brought in implicitly and are referenced in the formm.symbol. - Type aliases (
upper_id) can be brought in by named import, which binds the bare name directly. - A namespace import also makes the target module's public type aliases available, in type-annotation position only, qualified as
m.TypeName(QualifiedNamedType, 4.2). This is the one context where a namespace member is written into a grammar position other than an expression: elsewhere, a namespace import brings in no names of its own and is referenced only asm.symbolin expressions (above). Bringing a type in by named import and referencing it through its module's namespace both denote the same declaration; a module may use either form, or both, for the same type. - A namespaced type reference resolves against the same public/internal boundary as any other namespace reference:
m.TypeNameis well-formed only whenTypeNameis a public type alias (notinternal) of the modulemrefers to. - No form is provided that unconditionally brings in all public symbols (the names to be brought in are made explicit in the declaration).
Re-export (ExportDecl):
export { a, b } from "path"is a named import whose bound names are, in addition, public symbols of the current module. TheImportSpecifierrules, includingasrenaming, are those of a named import.- Because the names are bound as declarations and not as values, a re-exported function retains its parameter list: keyword parameters, defaults, variadics, and arity are preserved, and calls through the re-exported name follow 7.5 exactly as calls through the original declaration do. (A value binding such as
f = m.fproduces a function value instead, and calls through function values cannot supply keyword arguments (6.1).) - Type aliases may be re-exported.
- A name that is not a public symbol of the target module is an error before type checking.
ImportPathmay be a local path or a dependency name. Re-exporting from a dependency republishes that dependency's symbols as part of the current module's public API.- No wildcard form is provided: there is no
export * from. The names to be published are made explicit in the declaration, for the same reason that no bulk import form is provided.
Namespace re-export:
export * as ns from "path"is a namespace import whose namespace is, in addition, a public namespace of the current module. Within the module,nsis bound as the namespace importimport * as ns from "path"binds it.- A module that imports the current one reaches its public namespaces through it: with
import * as m from "path",m.nsnames the namespace andm.ns.symbola public symbol of the modulensrefers to, and a callm.ns.f(...)keepsf's parameter list as a call throughm.fdoes. A named importimport { ns } from "path"bindsnsas a namespace of the importing module. The same holds through any number of namespaces. - A namespace is not a value:
m.nswritten where a value is expected is an error before type checking (E-NAME-UNDEFINED), as is a member that is not a public symbol of the module the namespace refers to. - It brings no name in: only
nsis published, and what it holds is reached only qualified, so the rule above still holds. A type alias is referenced through one namespace only (m.TypeName, 4.2); a public type of a re-exported namespace is reached by importing that module, or by the alias's own re-export. - The public functions and values of a namespace the entry module re-exports are invoked and listed by the CLI under their qualified name,
lask eval python.env --image ...(11.2, 11.6), through any number of namespaces. - Same-name collision rules are those of namespace imports.
- Same-name collision rules are those of named imports.
ExportDecledges participate in the acyclicity requirement of the module dependency graph.
Evaluation timing of top-level declarations:
- A function declaration defines a closure and evaluates nothing at load time.
- The right-hand side of a top-level value declaration is evaluated lazily: at most once, on first reference, and the result is cached thereafter. A failure during this evaluation propagates from the referencing site.
- Consequently, importing a module never executes commands or produces effects by itself. Evaluation occurs only when a function is invoked or a value is referenced.
Rules for same-name symbol collisions:
- If a top-level declaration of the current module and a symbol brought in by a named import (under its post-renaming name) have the same name, it is an error before type checking.
- If the post-import names of named imports collide with each other, it is an error before type checking.
- If a namespace name of a namespace import is duplicated, or has the same name as a top-level declaration or an already-imported symbol, it is an error before type checking.
Handling of circular imports:
- The module dependency graph must be a directed acyclic graph (DAG). This requirement extends across external dependencies.
- If a circular import is detected, it is a module resolution error before execution.
External dependencies:
External Lask source code fetched over the internet is declared in the project file and imported by name. Code contains only the dependency name (intent); the source location, version, and integrity information are bound outside the code.
Project file:
- The default file name is
lask.json, loaded from the base directory for module resolution. No per-invocation override of this file is provided (dependencies must be identical for every invocation). - Schema:
{
"dependencies": {
"deploy-kit": {
"git": "https://github.com/example/lask-deploy-kit",
"rev": "v1.2.0"
},
"notify": {"url": "https://example.com/tasks/notify.lask"}
},
"confirm": {
"deploy": {"when": {"env": ["prod", "production"]}},
"destroy": {},
"reset_db": {"phrase": "reset #{db}"}
}
}- The top level has two keys, both optional:
dependenciesandconfirm(below). Any other key is an error, so that a misspelt key is never silently ignored. - The top-level
dependenciesmap associates a dependency name (a string conforming tolower_id; 3.2) with an entry. - An entry has exactly one source:
git(a repository URL;rev— a tag or a full commit SHA — is required) orurl(an archive or a single.laskfile). A branch must not be given asrev. git,revandurlmust not start with-and must not contain whitespace or control characters, so that no value can be taken for an option of the tool that fetches it. Aurlmust be anhttps://,http://orfile://URL, and a redirect followed while fetching it must not move fromhttpstohttp. A violation is a static error (E-MODULE-UNRESOLVED), in the project file and inlask deps add(11.5) alike.- Secrets (credentials, tokens) must not be written in this file.
- Entries are typically recorded with
lask deps add(11.5). Rewriting the file keeps itsconfirmmap.
Confirmation:
A project states which of its functions ask for a typed confirmation before they run (11.2). This is a guard against operational mistakes, not a security boundary. What must not happen at all is for the target's own permissions to prevent. Confirmation is declared here rather than in code, as dependencies are: code states what a task does, and whether running it needs a deliberate act is a property of the project.
confirmmaps a function name to an entry. The name is a public symbol of the entry module (a re-exported function included), as the CLI names it (11.2). An entry belongs to the declaration the name resolves to.when(optional) maps a parameter name to a non-empty array of values. Confirmation is required when any one of the named parameters equals one of its values. Withoutwhen, confirmation is always required.phrase(optional) is what has to be typed.#{name}interpolates the value of the parametername. By default it is the value of the first parameter that matchedwhen, and the function's name when there is nowhen.- An entry takes no other key. Only the root project's file is read. A dependency's own
confirmdoes not apply to whoever imports it. check,run,evalandenvscheck every entry against the program, whether or not anything calls the function. The function must be a public function of the entry module, every parameterwhennames and every parameterphraseinterpolates must be a parameter of it, and every value inwhenmust conform to its parameter's type, as a CLI argument would (11.2). A violation is a static error (E-MODULE-CONFIRM-TARGET) located at the entry's key inlask.json. A protection that silently stopped applying would be worse than none.
Lock file:
- The default file name is
lask.lock.json, in the same directory. It records the resolution of the whole dependency graph, including transitive dependencies, and is intended to be committed. - Schema:
{
"lock_version": 1,
"modules": {
"deploy-kit": {
"git": "https://github.com/example/lask-deploy-kit",
"requested": "v1.2.0",
"rev": "171d51a6d4d004ea5ae4de6034f0d3c659e44221",
"hash": "sha256-..."
},
"deploy-kit>notify": {"url": "https://example.com/tasks/notify.lask", "hash": "sha256-..."}
},
"images": {
"deploy-kit#sha256-71bc...": {"kind": "recipe", "tag": "lask/deploy-kit/sha256-71bc..."},
"#alpine:3.20": {"kind": "registry", "ref": "alpine:3.20", "digest": "sha256:..."},
"#golang:1.25": {"kind": "registry", "ref": "golang:1.25", "digest": "sha256:...", "commands": {"go": "present", "gofmt": "present"}}
}
}- Keys of
modulesare dependency paths: a direct dependency is its name; a transitive dependency isparent>child. Duplicate fetches of the same content are permitted and each occurrence is recorded (no version unification is performed). revin the lock must be a full 40-hexadecimal-digit commit SHA.requestedpreserves the reference that was resolved.hash(a content hash of the fetched source) is required for every entry, in the formsha256-followed by 64 lower-case hexadecimal digits. The same entry must always yield identical source code.- A lock entry whose
hashorrevis not of its form is a static error (E-MODULE-LOCK-STALE). imagesrecords the container images the resolved graph requires (10.3). Keys are<dependency path>#<image key>, where the dependency path is empty for the root project.- A registry reference is recorded once for the whole graph, under the empty path, whichever module writes it: one reference names one image wherever it appears, and resolving it when a command runs (10.4) needs nothing but the reference. Its entry holds the reference as written (
ref) and the digest it is pinned to (digest). - An entry that no image of the program references any more is dropped when the images are next materialized.
commandsrecords, for each command word declared on the image thatsyncprobes (11.7), whether the image provides it:"present"or"missing". A word that was not probed has no key, and an entry with none has nocommands. Any other value is a static error (E-MODULE-LOCK-STALE).
Consistency:
check,run,eval, andenvsrequire a lock file that covers every declared dependency and agrees with the project file. Otherwise it is a static error (E-MODULE-LOCK-STALE).- These subcommands must not write the lock file and must not resolve a dependency the lock does not already pin.
Fetching and verification:
- Dependencies are fetched into a per-project, content-addressed cache and verified against
hashby the CLI (lask sync; 11.7). A verification failure isE-MODULE-HASH-MISMATCH. - The cache is located at
.lask/depsunder the base directory for module resolution, unlessLASK_CACHE_DIRnames another directory. A project may therefore be copied, cache included, to a machine without network access. - Where the cache is does not reach the lock file. A recipe written in a dependency is recorded, in its lock key and in the context path its hash covers (10.3), by its path under
.lask/deps, as if the cache were in its default place, and read from whereverLASK_CACHE_DIRputs it. A lock written on one machine therefore holds on another whose cache is elsewhere. - The presence of an entry is not taken as proof of its content: a cache may be shared between projects or written by something other than
sync. Every subcommand that resolves a dependency verifies its entry againsthashwhen it uses it, at most once per resolution. An entry that is itself a symbolic link does not verify.syncreplaces an entry that does not verify with a fresh fetch. - A dependency must not contain symbolic links: their content would depend on the machine that reads them, outside what
hashcovers. A source that contains one is refused when it is fetched. The content hash of a tree never follows a link; it covers the link's own path and target text. - A local import (
./,../) in a module of a dependency must resolve inside that dependency's tree; a single-file dependency has no local imports. Otherwise it isE-MODULE-UNRESOLVED. What a dependency loads is thereby what itshashcovers. - If a tag recorded as
requestedlater resolves to a different commit,syncreportsE-MODULE-REV-MOVED. This is distinguished fromE-MODULE-HASH-MISMATCH, which denotes content differing from the pinned hash for an unchanged reference. check,run,eval, andenvsmust not access the network for module resolution. If a declared dependency is not present in the cache, or fails verification, it is a static error (E-MODULE-UNRESOLVED).- An external module may itself have a
lask.json. Transitive dependencies are resolved independently per dependency (no version unification is performed; duplication across the dependency graph is permitted) and are recorded in the root project's lock file, each under its dependency path (parent>child). - A
lask.jsonthat a fetched dependency carries but that is not a valid project file is a failure of that dependency insyncanddeps add, reported with the dependency it belongs to; its dependencies are not taken to be none.
Examples:
// module: lib/math.lask
add(x: Number, y: Number): Number = x + y// module: lib/types.lask
type Strings = Array<String>
joinWithComma(xs: Strings): String = join(xs, ",")// module: app/main.lask
import { add } from "./lib/math.lask"
import * as types from "./lib/types.lask"
import { send } from "notify"
export sum2(a: Number, b: Number) = add(a, b)
labels = ["a", "b", "c"]
internal csv = types.joinWithComma(labels)
notify_all(): Void = for_each(labels, \(x) -> send(x))
// types.Strings is a qualified reference to lib/types.lask's type alias
// Strings, made available by the namespace import above; no named import
// of Strings is needed alongside it.
describe(xs: types.Strings): String = concat("labels: ", types.joinWithComma(xs))Here notify is an external dependency declared in lask.json as a single-file source. sum2, notify_all, and describe are public symbols (the marker on notify_all and describe is omitted, which is equivalent to export); csv is not.
// module: lib/deploy.lask
export rollout(--target: String = "staging"): String = $[#alpine:3.20] echo "#{target}"// module: main.lask
export { rollout } from "./lib/deploy.lask"rollout is a public symbol of main.lask with its parameter list intact, so a consumer may call rollout(target = "prod") through it.
6. Expressions
This chapter defines the expressions of Lask. Every unit of execution in Lask is expressed as an expression.
Expression = BinaryExpr [ "?" Expression ":" Expression ] .
BinaryExpr = UnaryExpr | BinaryExpr binary_op BinaryExpr .
UnaryExpr = PrimaryExpr | unary_op UnaryExpr .
PrimaryExpr = operand | AccessorExpr | CallExpr | DoExpr | IfExpr | CaseExpr | ForExpr | TryExpr | AsyncExpr | AwaitExpr .
operand = literal | lower_id | array_lit | object_lit | "(" Expression ")" | LambdaExpr | CommandExpr | EnvExpr .
array_lit = "[" [ Expression { "," Expression } ] "]" .
object_lit = "{" [ KeyValuePair { "," KeyValuePair } ] "}" .
KeyValuePair = ( lower_id | string_lit ) ":" Expression .
AccessorExpr = PrimaryExpr "." lower_id | PrimaryExpr "[" Expression "]" .
CallExpr = PrimaryExpr "(" [ Arguments ] ")" .
Arguments = Argument { "," Argument } .
Argument = [ lower_id "=" ] Expression .6.1 Function Parameters, Lambda Expressions, and Higher-Order Functions
FunctionParameterList = PositionalParameters [ "," VariadicParameter ] [ "," KeywordParameters ]
| VariadicParameter [ "," KeywordParameters ]
| KeywordParameters .
PositionalParameters = PositionalParameter { "," PositionalParameter } .
PositionalParameter = lower_id [ "!!" ] [ ":" Type ] .
VariadicParameter = "..." lower_id [ ":" ArrayType ] .
KeywordParameters = KeywordParameter { "," KeywordParameter } .
KeywordParameter = "--" lower_id [ "!!" ] [ ":" Type ] [ "=" Expression ] .
LambdaExpr = "\\" "(" [ FunctionParameterList ] ")" [ ":" Type ] "->" Expression .Meaning of the parameter notation:
- At declaration time, each parameter is clearly distinguished as a positional parameter (
name), a variadic parameter (...name), or a keyword parameter (--name). The declaration order is: the sequence of positional parameters, the variadic parameter (at most one), then the sequence of keyword parameters (enforced by the grammar). - Positional parameters are bound only by positional arguments. All are required and cannot have default values.
- Keyword parameters are bound only by name (
name = expressionin in-language calls,--name <value>on the CLI; 11.2). A keyword parameter with a default value expression is optional: when unspecified, it is completed with the default value. One without a default value expression is required: every call must bind it, and a call that does not is a static error (E-TYPE-KEYWORD). Like a positional parameter, a required keyword parameter without a type annotation has typeAny. - Required and optional keyword parameters may be declared in any order among the keyword parameters. A default value expression may refer to a required keyword parameter declared before it (8.3):
deploy(--target: String, --url = "https://#{target}.example.com"). - A default value expression is checked once, at the declaration, against the declared type of its parameter. Where the declaration has type parameters (4.2) they are opaque during that check, as everywhere else in the declaration (4.4), so the default has to be one that holds for every instantiation. This admits
--y: T = xwherexis a preceding parameter of typeT(the evaluation environment of a default includes the parameters bound before it, 8.3),--xs: Array<T> = [],--m: Map<T> = {},--y: T | Null = nulland an identity lambda, and rejects--y: T = 1, whose type would otherwise depend on the instantiation. No rule forbids a default on a parameter whose type mentions a type parameter; conformance decides it. - Binding a positional parameter by name, and binding a keyword parameter by position, are not possible (
E-TYPE-KEYWORD). --is a lexeme that appears only as the declaration marker of a keyword parameter, and is not interpreted as a sequence of the operator-.- Parameter names are used for references within the function body and for the binding interface of keyword parameters (references within the body use
namefor every kind). - A positional or keyword parameter name may be marked
!!to declare it a secret binding (6.10). A variadic parameter cannot be marked!!.
Keyword arguments:
- A call argument can specify a keyword parameter in the form
name = expression(Argument = [ lower_id "=" ] Expression; beginning of Chapter 6). - Positional arguments must be placed before all keyword arguments.
- The name of a keyword argument must match a keyword parameter name of the callee (the declaration parameter information of 7.5). Unknown names, naming of positional or variadic parameters, and duplicate bindings to the same parameter are static errors (
E-TYPE-KEYWORD). - Keyword arguments can be used only when the callee resolves statically to a function declaration or a lambda expression. Calls that resolve only to a value of function type (7.5) cannot consult the declaration parameter information, so they accept only positional arguments, and all keyword parameters are completed with their default values.
- A function declaration or lambda expression with a required keyword parameter therefore cannot become a function value: a call through the value could not bind it. Referring to such a declaration other than as the callee of a call (
map(xs, f),g = f,ns.fas an argument), and writing such a lambda anywhere but as the callee of a call, is a static error (E-TYPE-KEYWORD). A pipe (x |> f) is a call that gives no keyword arguments, so piping into such a function is a static error as well. - A recursive call of a function with a required keyword parameter binds it like any other call. Since the declaration is still being checked, its parameter information is taken from its annotations: every keyword parameter with a default must then have a type annotation, and a recursive call otherwise is a static error (
E-TYPE-KEYWORD). - Keyword arguments may be written in any order (e.g.
f(1, c = 3, b = 2)).
Variadic parameters:
- Immediately after the sequence of positional parameters, at most one variadic parameter
...namemay be declared (keyword parameters are placed after it). - The type annotation of a variadic parameter must be of the form
Array<T>. If there is no annotation, it isArray<Any>. Within the function body it is referenced as a value of typeArray<T>. - A variadic parameter cannot have a default value expression. If zero corresponding positional arguments are given, the empty array
[]is bound. - The positional arguments remaining after all positional parameters have been bound are collected in written order and bound as one array. Each argument must conform to the element type
T. - A variadic parameter cannot be bound by a keyword argument (
E-TYPE-KEYWORD). - In the function type, a variadic parameter appears as one parameter of the corresponding array type (e.g.
sum(...xs: Array<Number>): NumberisFunction<Array<Number>, Number>). Variadicity is not part of the function type and is retained as declaration parameter information (7.5). Therefore, in application via a function value (7.5), an array is passed explicitly as one positional argument.
Examples:
sum(...xs: Array<Number>): Number =
reduce(xs, 0, \(acc: Number, x: Number) -> acc + x)
s1 = sum()
s2 = sum(1, 2, 3)
f: Function<Array<Number>, Number> = sum
s3 = f([1, 2, 3])Examples:
greet(name: String, --prefix: String = "hello"): String =
concat(prefix, concat(", ", name))
g1 = greet("alice")
g2 = greet("alice", prefix = "hi")Desugaring rules for function declarations:
name(p1, p2, ..., pn) = bodyis equivalent toname = \(p1, p2, ..., pn) -> body.- The parameter specification of
FunctionDeclfollows the same rules as theFunctionParameterListofLambdaExpr. - Therefore, the notation of including parameters in a declaration is syntactic sugar for the notation of assigning a lambda expression as a value.
Equivalence example:
f(x) = x + 1
g = \(x) -> x + 1The f and g above declare essentially the same function.
6.2 Operators
unary_op = "!" .
binary_op = "*" | "/" | "+" | "-" | "==" | "!=" | "<" | "<=" | ">" | ">="
| "&&" | "||" | "|>" | "<|" | ">>" | "<<" .Operators are syntactic sugar for writing function application readably. Operators themselves cannot be user-defined, and the evaluator gives them fixed meanings.
Operator precedence, from highest to lowest, is as follows.
- Unary operator
! - Multiplicative operators
*/ - Additive operators
+- - Comparison operators
==!=<<=>>= - Logical operators
&&|| - Pipe and composition operators
|><|>><< - Conditional operator
? :
Operators of the same precedence associate from left to right, except the conditional operator, which associates from right to left.
The conditional operator c ? a : b:
c ? a : bis syntactic sugar forif (c) { a } else { b }(6.4). It is the form ofIfExprwhose branches are single expressions, and it is typed and evaluated exactly as thatIfExpris:cmust beBool, the two branches must have the same typeT, which is the type of the whole expression, an expected type is propagated into both branches unchanged (4.3), and only the selected branch is evaluated.- It binds more loosely than every binary operator, so the condition is a whole
BinaryExpr:xs |> is_empty ? 0 : 1testsis_empty(xs). Each branch is a wholeExpression, soc ? a || b : xs |> fneeds no parentheses. - It associates to the right:
a ? x : b ? y : zisa ? x : (b ? y : z), and a conditional may stand in the middle branch without parentheses, as ina ? b ? x : y : z. A multi-way choice with more than two conditions is usually clearer as the condition form ofcase(6.4). - A newline before a line-leading
?or:, and after a line-final?or:, continues the expression (6.5), so a long conditional may put each branch on its own line. This is how a branch can be a command execution expression other than the last one: a command string runs to the end of its line (6.6), so inc ? $ echo a : $ echo bthe then branch swallows: $ echo b, while the three-line form below does not.
greet(--loud = false, --name = "Lask"): String = do {
word = loud ? "HELLO" : "hello"
loud
? $[#local] echo "#{word}, #{name}!"
: $[#local] echo "#{word}, #{name}."
}The meaning of each operator is as follows.
!edenotes Boolean negation.e1 * e2,e1 / e2,e1 + e2,e1 - e2denote arithmetic operations.e1 == e2,e1 != e2denote value comparison.e1 < e2,e1 <= e2,e1 > e2,e1 >= e2denote ordering comparison.e1 && e2does not evaluate the right-hand side when the left-hand side is false, and evaluates the right-hand side only when the left-hand side is true.e1 || e2does not evaluate the right-hand side when the left-hand side is true, and evaluates the right-hand side only when the left-hand side is false.e1 |> fis syntactic sugar forf(e1).f <| e1is syntactic sugar forf(e1).f >> gdenotes function composition equivalent to\(x) -> g(f(x)).f << gdenotes function composition equivalent to\(x) -> f(g(x)).
The types of the operators are as follows.
!is typed asFunction<Bool, Bool>.*,/,+,-are typed asFunction<Number, Number, Number>.<,<=,>,>=are typed asFunction<Number, Number, Bool>.&&,||are typed asFunction<Bool, Bool, Bool>.==,!=are well-typed only when the type of one side conforms to the type of the other (4.4) and the wider of the two — the one conformed to — is a comparable type; the result type isBool. In the terms of 4.4 they are<T: comparable> Function<T, T, Bool>, instantiated at the wider type, and a type that is not comparable isE-TYPE-BOUND. When neither side is a union this is the same rule as "the two types match", because conformance between non-union types is identity or promotion toAny, andAnyis not comparable.- The comparable types are limited to
Number,String,Bool,Null,Environment,Array<T>/Map<T>/Record<...>whose element, value, and field types are all comparable types, a union all of whose members are comparable types, and a type parameter whose bound entailscomparable(4.4). This is the named boundcomparable(4.2). - Therefore
x == nullis well-typed whenx: String | Null, sinceNullconforms toString | Nulland that union is comparable. It compares values and does not by itself narrow the type ofx; narrowing is a property ofcase(6.4). - Applying
==/!=to types containingFunction,AsyncHandle,Void, orAnyis a static error. To compare values ofAny, first move to a concrete type withcast(15.8), or narrow withcase(6.4), and then compare. - Equality of structured values (
Array/Map/Record) is determined by recursive structural comparison of elements, keys, and fields. Equality ofEnvironmentfollows 8.8. e1 |> fis well-typed whene1: Tandf: Function<T, R>hold, and the type of the whole expression isR.f <| e1is well-typed whene1: Tandf: Function<T, R>hold, and the type of the whole expression isR.f >> gis well-typed whenf: Function<T, U>andg: Function<U, R>hold, and the type of the whole expression isFunction<T, R>.f << gis well-typed wheng: Function<T, U>andf: Function<U, R>hold, and the type of the whole expression isFunction<T, R>.
The pipe and composition operators are syntactic sugar for making the passing of functions readable from left to right, and parentheses can be used to make the association order explicit.
The result of comparison operators and logical operators is Bool. The result of arithmetic operators is Number, and they must be evaluable as numeric operations. The arithmetic operators, including +, are exclusively for Number; applying them to String is a type error. String concatenation is not done with an operator; use the built-in library function concat (15.3).
6.3 Asynchronous Invocation and Awaiting
AsyncExpr = "async" UnaryExpr .
AwaitExpr = "await" UnaryExpr .This section defines the syntactic sugar async for asynchronous launching, and the built-in core function await representing waiting.
Definitions as functions:
spawnis a core function that asynchronously launches the zero-argument function received as its argument and returns a result handle.awaitis a core function that receives a result handle and joins to the completion value.
Syntax rules:
async eis syntactic sugar forspawn(\() -> e). Becauseasyncneeds to delay the evaluation of its operande, it is provided as syntactic sugar rather than as a function.await eis a parenthesis-omitting form applying the core functionawaitto the operande. Writingawait (e)is also parsed by the same rule as anAwaitExprwith a parenthesized operand, and the meaning is the same.- The operand of
AsyncExpr/AwaitExpris aUnaryExpr. Thereforeawait h |> fis interpreted as(await h) |> f. To meanawait (h |> f), make it explicit with parentheses. awaitis a reserved word and can appear only in the form of anAwaitExpr. To pass it around as a function value, write\(h) -> await h.
Here spawn and await are core functions and must not be directly declared or overridden by user code.
Typing rules:
- When
e: T, the type ofasync eisAsyncHandle<T>. - When
f: Function<T>, the type ofspawn(f)isAsyncHandle<T>. - When
h: AsyncHandle<T>, the type ofawait hisT. - Passing anything other than
AsyncHandle<...>toawaitis a type error.
Evaluation rules:
async estarts evaluation at the point of the call and immediately returns a handle valueAsyncHandle<T>to the caller.await hsuspends the current evaluation untilhcompletes, and returns the result value after completion.- Applying
await hmultiple times to the same handle returns the same completion result. - If a failure occurs inside
async e, that failure is re-raised at the time ofawait h. - A handle is consumed when it is passed to
await,all, orrace, or when its computation is cancelled because atimeoutbody that started it was abandoned (15.7). A computation whose handle is never consumed is not cut short by the end of the top-level execution (run/eval, Chapter 11): the execution waits for it, including any computation it starts in turn, and then reports it as the advisoryW-ASYNC-UNAWAITED(14.2). The report names where it was started and, if it failed, its failure. It changes neither the result nor the exit code of the execution: a failure nobody awaited is reported, not raised. - A handle that can be seen from the text alone never to be consumed is reported statically as the advisory
W-ASYNC-UNUSED(14.2): an expression of typeAsyncHandle<T>orArray<AsyncHandle<T>>that is a statement of a block other than its last, so that its value is discarded (6.5); or a binding in a block of either type whose name no later statement of the block refers to. A handle that is referred to — awaited, passed to a function, stored, returned — is not reported, whether or not it is eventually consumed;W-ASYNC-UNAWAITEDcovers those at run time.
The execution environment may choose the concurrency mechanism for async (threads, an event loop, a remote execution queue, etc.) as implementation-defined, but must satisfy the typing rules and evaluation rules above.
6.4 Control Structures
IfExpr = "if" "(" Expression ")" Block [ "else" Block ] .
CaseExpr = "case" [ "(" Expression ")" ] "{" { CaseArm arm_end } "}" .
CaseArm = ( CaseHeads | TypeHeads | "else" ) "->" Expression .
CaseHeads = Expression { "," Expression } .
TypeHeads = SingleType { "," SingleType } .
ForExpr = "for" "(" lower_id ":" Expression ")" Block .
Block = "{" { DoStmt } "}" .
arm_end = newline | ";" .Control structures are provided as the block-form expressions IfExpr / CaseExpr / ForExpr, and semantically they are handled by normalization to the core function choose and the collection function map. if / case / for are reserved words and can appear only in the expression forms of this section (the function-call forms if(c, t, f) / for(xs, body) are not provided).
IfExpr / CaseExpr / ForExpr are PrimaryExpr (beginning of Chapter 6) and can appear in any position where an expression can be placed, such as the right-hand side of a binding, a function body, or an argument.
CaseExpr is the multi-way conditional. It has two forms:
- With a scrutinee,
case (e) { ... }: each arm head is either a value, compared with the value ofeby equality (==, 6.2), or a type, tested against the runtime type ofe(type dispatch). The two kinds of head may be mixed in onecase. - Without a scrutinee,
case { ... }: each arm head is a condition of typeBool, tested in order. This form takes the place of a chainedelse if, which the grammar does not provide, because theelseof anIfExpralways takes aBlock. This form has no type heads, because there is no scrutinee to dispatch on.
Arm rules:
- A value head is an ordinary expression, not a binding pattern.
case (x) { y -> ... }comparesxwith the value ofy; it never bindsy. Lask has no data constructors, so there are no destructuring patterns, and this is true of type heads as well: a type head selects an arm and narrows the scrutinee (below), and binds no new name. - A head that begins with an
upper_idis a type head, and any other head is a value head. The two are told apart by the leading token alone, with no backtracking, because anupper_idcan never begin an expression in this language: there are no data constructors, and a namespace is alower_id(7.2). A type head is written as a type (4.2) and may not itself be a union — alternatives within one arm are written with,. nullandNullare both legal heads of the same test: the value headnullcompares by equality, the type headNulldispatches on the type, and becauseNullhas exactly one value the two select the same arm and narrow identically (below). PreferNullwhen the other arms of the samecaseare type heads, andnullotherwise.- An arm may list several heads separated by
,("bash", "zsh" -> ...). The scrutinee form selects such an arm when any of its heads selects — equality for a value head, the type check for a type head — and the condition form when any of its heads istrue(which||expresses just as well). One arm's heads are either all value heads or all type heads; the two kinds are mixed across arms of onecase, never within one arm. - The body of an arm is an
Expression, not aBlock, as inLambdaExpr(6.1) — which is what an arm becomes after normalization. A sequence of statements is written as an explicitdo { ... }(6.5), and a{directly after->is an object literal. - Exactly one
elsearm is required, and it must be the last arm. Acasewith no arms, with noelsearm, with more than one, or with anelsearm that is not last, is a syntax error (E-SYNTAX-CASE-ELSE). Exhaustiveness is never inferred from the type of the scrutinee. - An arm terminates at a newline,
;, or immediately before the block terminator}, by the termination rules of 6.5. - A newline before a line-leading
elseis a continuation and not a terminator (6.5), so no arm terminator is required before theelsearm. AnIfExprin an arm body may likewise place itselseblock on the following line: anelsefollowed by aBlockcontinues thatIfExpr, and anelsefollowed by->begins theelsearm.
Definitions as functions:
chooseis a core function that receives a condition value and two branch functions and evaluates only one of them.- Iteration over collections is expressed with the purpose-specific core functions
map/filter/reduce/for_each(15.4).mapapplies the body function to each element and returns the results as an array in the same order.filterkeeps, in the same order, only the elements for which the predicate returns true.reduceaggregates a collection into a single value with an initial value and a folding function.for_eachapplies the body function to each element, discards the results, and returnsVoid(iteration for side effects).
Desugaring rules:
if (c) { ... } else { ... }is syntactic sugar forchoose(c, \() -> do { ... }, \() -> do { ... }).if (c) { ... }withoutelseis syntactic sugar forif (c) { ... } else { null }, except when the type of its block isVoid, where it isif (c) { ... } else {}.for (x: xs) { ... }is syntactic sugar formap(xs, \(x) -> do { ... }). However, when the type of the body block isVoid(including an empty block), it is syntactic sugar forfor_each(xs, \(x) -> do { ... })(Array<Void>is not constructed; 4.4).- For iteration for side effects where the result array is unnecessary, either make the body of type
Voidor usefor_eachdirectly. case (e) { p -> b; R }is syntactic sugar fordo { s = e; choose(s == p, \() -> b, \() -> C) }, wheresis a fresh name that no source program can write (it therefore captures nothing and shadows nothing) andCis the expansion of the remaining armsR. Because the scrutinee is bound once, it is evaluated exactly once however many arms are tested.- A type head expands the same way, with the condition being the runtime type test of
sagainst the head type rather than an equality:case (e) { M -> b; R }isdo { s = e; choose(<s is M>, \() -> b, \() -> C) }. The test<s is M>is the runtime type check ofcastat targetM(15.8) used as a predicate, without its conversion and without its failure. - When the scrutinee is a plain local name
xand the arm narrows it, the arm body is expanded with the narrowing in place: the branch function is\() -> do { x = <narrowed x>; b }, where<narrowed x>iscast(s)at the narrowed type when narrowing leaves exactly one member, andsitself otherwise. The rebinding is inside the branch function, so it is confined to that arm. - An arm with several heads tests their disjunction:
p1, p2 -> bproduces the conditions == p1 || s == p2in the scrutinee form, andp1 || p2in the condition form. case { c -> b; R }(the condition form) is syntactic sugar forchoose(c, \() -> b, \() -> C). It introduces no binding.- The final arm
else -> bzexpands tobzitself, as the false branch of the innermostchoose. caseintroduces no core function of its own, and no new evaluation rule: every form of it is a nestedchoose.
Here choose is a core function that exists only to serve as a normalization target, and is not exposed in the built-in library (it cannot be referenced, declared, or overridden by user code). map / filter / reduce / for_each are functions of the built-in library (15.4) and at the same time core functions serving as normalization targets, and likewise must not be overridden.
Typing rules:
chooseis typed asFunction<Bool, Function<T>, Function<T>, T>.mapis typed asFunction<Array<T>, Function<T, U>, Array<U>>.filteris typed asFunction<Array<T>, Function<T, Bool>, Array<T>>.reduceis typed asFunction<Array<T>, U, Function<U, T, U>, U>.for_eachis typed asFunction<Array<T>, Function<T, U>, Void>(the return typeUof the body is arbitrary and the result is discarded. The type variableUis used rather thanAnybecause conformance of function types is limited to identity (4.4), in order to accept bodies of arbitrary return type through instantiation).- An
IfExpris well-typed only when the condition expression isBooland the types of the two branch blocks are the sameT, and the type of the whole expression isT. - An
ifwithoutelseis well-typed when the condition expression isBooland its block has a typeT. Its type isT | Null, reduced to canonical form (4.2): when the condition is false its value isnull. ATthat already admitsNull, or isAny, is unchanged by this. WhenTisVoid— a block run for its effect — the type isVoid, as with an emptyelse. - With an expected type
E(4.3), the block is checked againstE, andNullmust also conform toE, unlessEisVoid. Sof(c: Bool): String | Null = if (c) { "x" }is well-typed andf(c: Bool): String = if (c) { "x" }is a type error (E-TYPE-MISMATCH), whose remedy is anelse. - Without an expected type, a block whose type is given only by its context, such as
fail(e)(15.7), is checked asVoid, the type an emptyelsewould give it.if (bad) { fail(e) }therefore stands as a statement that stops the function whenbadholds. T | Nullmust be a type, soTmust be a data type (4.2) when the value is used; a block of a function type or ofAsyncHandle<T>isE-TYPE-ILLFORMED, and wants anelse. A statement that is not the last of its block discards its value (6.5), and there the block may have any type.- A
CaseExprwith a scrutinee is well-typed only when every value head's type conforms (4.4) to the typeSof the scrutinee, and, when the expression has at least one value head,Sis a comparable type (6.2). These are exactly the rules of==, because the expansion of a value head is a chain of==. A scrutinee of typeAnyis therefore a type error in acasehaving any value head, becauseAnyis not comparable: dispatch on its type with type heads (below), or move to a concrete type withcast(15.8) first. - A type head is well-typed only when
Sis a union and the head is one of its members, or whenSisAnyand the head is a typecastmay target (a data type; 4.2, 15.8).E-TYPE-MISMATCHfor a head that is not a member of the union, andE-TYPE-ILLFORMEDfor a head that is not a data type. Dispatching on the type of a value of any other type decides nothing statically — the answer is already known — so it is rejected rather than being allowed as dead code. - The two admissible scrutinees are exactly the two types whose runtime kind is not settled statically.
Anyadmits every data type as a head, because nothing is known about it; a union admits only its own members, because everything else is known not to occur. - A
caseall of whose heads are type heads does not requireSto be comparable, since it performs no equality test. This is what makescaseusable on anAny:Anyis not comparable (6.2), so a value head on anAnyscrutinee stays a type error while a type head is well-typed. - A
CaseExprwithout a scrutinee is well-typed only when every arm head has typeBool. - A
CaseExpris well-typed only when the bodies of all arms, theelsearm included, have the same typeT, and the type of the whole expression isT. - Two heads of the same
CaseExprthat are literals denoting the same value are a static error (E-TYPE-CASE-DUPLICATE): the later arm can never be selected. Heads that are not literals are not compared with one another. Two type heads of the sameCaseExprdenoting the same type are a static error for the same reason; two type heads denoting different types are not compared, even when one can match values the other also matches (below). - A
ForExpr, when the target expression isArray<T>and the type of the body block isR(Rother thanVoid), has the typeArray<R>as the whole expression. - When the type of the body block is
Void, theForExpris normalized tofor_eachand the type of the whole expression isVoid.
Narrowing of the scrutinee:
Within the body of an arm, the scrutinee is known to be narrower than its declared type. That knowledge is given to the program under the following rules, which apply only when the scrutinee is written as a plain local name. Any other scrutinee still dispatches; it simply has no name whose type could be narrowed, because the name the normalization binds is fresh and unwritable (7.3).
- In the body of an arm whose heads are the type heads
M1, ..., Mk, the name has typeM1 | ... | Mk, reduced to canonical form (4.2). With the usual single headM, that isM. - In the body of an arm selected by a value head of type
H, the name has typeHwhenHis a member of the union, and its declared type otherwise. Equality with a value of typeHcan only succeed for a value of typeH. With several value heads it is the union of their types, by the same rule. - In the body of the
elsearm, the name has the declared union with every member matched by a preceding arm subtracted: a type headMsubtractsM, and the value headnullsubtractsNull, becauseNullhas exactly one value and matching it exhausts the member. Any other value head subtracts nothing —"a"does not exhaustString. Subtracting every member but one leaves that member, and subtracting nothing leaves the declared type. When the preceding arms subtract every member, theelsearm is unreachable but still required and still type-checked, and the name keeps its declared type there: there is no empty type. - When the scrutinee's declared type is
Anyrather than a union, the arm rules above apply unchanged — an arm with the type headsM1, ..., Mknarrows the name toM1 | ... | Mk— and theelsearm leaves the nameAny. A type cannot be subtracted fromAny, sinceAnyhas no members to remove, and a failed test tells a program nothing about what the value is. - Narrowing is a property of
casealone.if (x == null) { ... } else { ... }does not narrowxin either block.
The narrowed name is a binding of the arm body, so it shadows the outer binding inside that body and is invisible outside it (7.3). Where the narrowed type is a single type other than the declared one, the narrowed name is bound to the result of the same check-and-convert that cast performs at that type (15.8), so that a record value narrowed to Map<T>, or the reverse, reaches the body in the representation its type promises. Where the narrowed type is a union of two or more members, or is the declared type unchanged, the value is passed to the body as it is.
// The scrutinee is a name, so the else arm knows it is a String.
port(): Number = do {
p = find_env("PORT") // p: String | Null
case (p) {
Null -> 8080
else -> to_number(p) // p: String here
}
}Evaluation rules:
- An
IfExprfirst evaluates only the condition expression, and evaluates only the then block if true, or only the else block if false (followingchooseof 8.5). Without anelseblock, a false condition givesnull, or nothing when the block isVoid. - A
ForExprevaluates the target collection, then traverses the elements from left to right, applies the body block, and aggregates the results into an array in the same order (followingmapof 8.5). - A
CaseExprwith a scrutinee evaluates the scrutinee exactly once, before any arm head. It then tests the heads in order, top to bottom and left to right within an arm, and stops at the first head that selects: a value head selects when it is equal to the scrutinee, and a type head selects when the scrutinee passes the runtime type check for that type. The remaining heads are not evaluated or tested, and only the body of the selected arm is evaluated. When no head selects, the body of theelsearm is evaluated. - A type head is tested and never evaluated: it is a type, not an expression, so it can neither fail nor have an effect.
- Because 15.8 accepts a record value and a map value for either of
Record<...>andMap<T>, two type heads of onecasecan both match the same runtime value. Order decides, as it does for value heads: the first arm that selects wins. - A
CaseExprwithout a scrutinee evaluates the conditions in order and stops at the first that istrue, evaluating only that arm's body. When none istrue, the body of theelsearm is evaluated. - If evaluation of the scrutinee or of an arm head fails, the whole expression fails at that point, and no further head or body is evaluated (8.10).
- The interior of a
Blockfollows the same statement rules as adoblock (6.5), and the value of the block is the value of its final statement. The value of an empty block with no statements isVoid(6.5).
Examples:
// Dispatch on a value. The scrutinee is evaluated once.
completion_dir(shell: String): String = case (shell) {
"bash" -> "$HOME/.bash_completion.d"
"zsh" -> "$HOME/.zsh/completions"
"fish" -> "$HOME/.config/fish/completions"
else -> fail({ code: 4, message: "unknown shell: #{shell}" })
}
// Several heads in one arm, and a statement sequence as an arm body.
deploy(--target: String = "dev"): String = case (target) {
"dev", "staging" -> $[#local] ./deploy.sh #{target}
"prod" -> do {
$[#local] ./check_approval.sh
$[#local] ./deploy.sh prod
}
else -> fail({ code: 2, message: "unknown target: #{target}" })
}
// The condition form, in place of a chained else if.
level(n: Number): String = case {
n >= 500 -> "error"
n >= 400 -> "warn"
else -> "info"
}
// Type dispatch over a union, with the scrutinee narrowed in each arm.
render(v: Number | String | Null): String = case (v) {
Null -> "(none)"
Number -> to_string(v) // v: Number here
else -> v // v: String here, the only member left
}
// The common case: a lookup that may find nothing.
base_url(): String = do {
u = find_env("BASE_URL") // u: String | Null
case (u) {
Null -> "http://localhost:3000"
else -> u // u: String here
}
}
// Dispatch on the shape of decoded input. The scrutinee is Any, so any
// data type may be a head, and the else arm leaves it Any.
summarize(): String = do {
v = from_json(stdin) // v: Any
case (v) {
String -> v // v: String here
Array<String> -> join(v, ", ") // v: Array<String> here
Null -> "(empty)"
else -> to_json(v) // v: Any still
}
}6.5 Procedural Notation
DoExpr = "do" Block .
DoStmt = ( BindStmt | ExprStmt ) stmt_end .
BindStmt = lower_id [ "!!" ] [ ":" Type ] "=" Expression .
ExprStmt = Expression .
stmt_end = newline | ";" .Here Block is the block defined in 6.4 ("{" { DoStmt } "}"), and newline is the newline token defined in 3.3. Immediately before the block terminator }, stmt_end may be omitted.
Binding annotations:
- A
BindStmttakes an optional type annotation, in the same shape as aValueDecl(5): the!!marker attaches to the name and the annotation follows it, as ina!!: String = e. - The annotation is the expected type for the right-hand side and the declared type of the binding, by the rules of 4.3. The right-hand side must conform to it (4.4). Without an annotation the binding adopts the type of its right-hand side, as before.
- The annotation follows the type well-formedness rules of 4.2, and
Voidis not among the positions those rules allow: a binding cannot be annotatedVoid(E-TYPE-ILLFORMED), because aVoidvalue must not be used as a value (4.4). - When the annotated binding is the last statement of the block, its declared type is the type of the block, and so must conform to whatever the block's context requires.
- This is how an expression that needs an expected type gets one inside a block.
cast(15.8) is the case that motivates it:cfg: Record<name: String> = cast(from_json(stdin))concretizes the target from the annotation, where an unannotated binding would have nowhere to state it. The block forms ofif/case/forare expressions (theIfExpr/CaseExpr/ForExprof 6.4), so no dedicated statement forms exist. They appear as anExprStmtor as the right-hand side of aBindStmt.
Statement termination rules:
- A statement inside a
doblock terminates at a newline,;, or immediately before the block terminator}. ;is an optional separator for writing multiple statements on one line, and is not required at the end of a line (writing it there is not an error).- A newline that falls under any of the following is not regarded as statement termination and is treated as continuation of the statement.
- A newline at a position where a bracket opened inside the statement (
(,[,{) has not yet been closed - A newline at a position where the token at the end of the line is a continuation token. The continuation tokens are limited to the following:
=,,,:,?,->, binary operators (|>,+,==, etc.; thebinary_opof 6.2),! - A newline where the leading token of the next line is a binary operator (
|>,+,==, etc.), or the?or:of a conditional expression (6.2) - A newline where the leading token of the next line is
else(continuation after the closing}of the then block of anIfExpr). Inside acaseblock the same rule is what lets anelsearm begin a line (6.4) - A newline where the leading token of the next line is
catchorfinally(continuation after the closing}of a block of aTryExpr)
- A newline at a position where a bracket opened inside the statement (
asandfromare neither continuation tokens nor leading tokens of continuation. A line-leadingasorfromdoes not continue the preceding statement or declaration (see the constraints onimportdeclarations in Chapter 5).- A line-leading
(or[is interpreted as the start of a new statement. The call argument list or index access of the preceding statement must not begin with a line-leading(or[. - Because numeric literals do not include a sign (3.3), a line-leading
-or+is always uniquely interpreted as continuation of the preceding statement (a binary operator). - In the rules and examples from this section onward, the statement separator
;may be read as a newline. The two are synonymous as statement termination. - These rules (determination of termination and continuation) also apply identically to the termination of top-level declarations (the declaration termination rules of Chapter 5).
Procedural notation is syntactic sugar to ease gradual migration to the expression-centered core language. do is syntactic sugar for sequential evaluation, and if (...) { ... } else { ... } / case (...) { ... } / for (...) { ... } are normalized by the rules of 6.4 into expressions that use choose / map.
Purpose and design policy:
- While allowing a procedural appearance, the semantics are unified into function application and expression evaluation.
dogives only sequential evaluation order and introduces no execution model of its own.- Control is always lowered to functions (
chooseand collection functions such asmap), avoiding duplication of evaluation rules.
Desugaring rules:
- The desugaring rules for
if (...) { ... } else { ... }/case (...) { ... }/for (...) { ... }follow 6.4. do { e }is equivalent toe.do { v = e1; s2; ...; sn; }is normalized to an expression that first evaluatese1, binds it tov, and then evaluatess2 ... sn.do { e1; s2; ...; sn; }is normalized to an expression that discards the value ofe1and evaluatess2 ... sn.
Sequential execution sugar:
do { stmt1; stmt2; ...; expr; }is syntactic sugar for sequential execution that evaluates each statement in order and returns the value of the final expression.- Bindings inside
dobecome visible in order and can be referenced by subsequent statements.
No early return:
- Lask has no
returnstatement. The value of a function is the value of its body, and the value of a block is that of its last statement, so a function leaves only at its end. Writingreturnis a syntax error (E-SYNTAX-UNEXPECTED-TOKEN; 3.3). - A choice between values is written as one:
if (c) { a } else { b },c ? a : b(6.2), orcase(6.4), with the rest of the function in the branch that continues it. - A condition under which a function must stop is a failure:
if (bad) { fail(e) }(6.4, 15.7) stops it, and the statements after it run only whenbaddoes not hold.
Typing rules:
- A
BindStmtis well-typed if its right-handExpressionis well-typed. - An
ExprStmtis well-typed at any typeT, but contextually its value is not passed to the next statement. - The type of the whole
dois the type of the last statement (the trailingExprStmtor an expression equivalent to it). If it has no statements, it isVoid. - Empty blocks (
do {}, and including emptyBlocks of theIfExpr/ForExprof 6.4) are permitted. The type of an empty block isVoid, and its evaluation result isVoid. - Even when the right-hand side of a
BindStmthas typeVoid, the binding itself is possible, but the bound name cannot be referenced as a value (4.4). - The typing rules for
IfExpr/CaseExpr/ForExprfollow 6.4 (the same when they appear as the right-hand side of aBindStmtor as the final statement).
Evaluation rules:
- Statements inside
doare always evaluated from top to bottom. - If evaluation of the right-hand side of a
BindStmtfails, the wholedofails at that point and subsequent statements are not evaluated. - The evaluation rules for
IfExpr/CaseExpr/ForExprfollow 6.4.
Scoping rules:
- An identifier introduced by a
BindStmtinside adoblock is in effect within the same block after that statement. - Bindings inside a block of
if/forand inside the body of acasearm (6.4) do not leak outside it. The name the normalization of acasescrutinee binds is fresh and cannot be referenced by any expression written in the source (6.4). - The iteration variable
xoffor (x: xs)is in effect only inside the body block.
Examples:
build() = do {
image = "app"
tag = "latest"
$[#local] docker build -t #{image}:#{tag} .
"ok"
}
classify(n: Number) = do {
if (n > 0) {
"pos"
} else {
"non-pos"
}
}
labels(xs: Array<String>) = do {
for (x: xs) {
concat("item:", x)
}
}
publish(tag: String): String = do {
if (tag == "") { fail(error(2, "no tag")) }
r = $*[#local] ./release.sh #{tag}
r.code != 0 ? r.stderr : "released"
}
notify(--verbose = false) = do {
if (verbose) { log("notifying") }
$[#local] ./notify.sh
}6.6 Command Execution Expressions
CommandExpr = "$" [ stream ] [ "[" Expression "]" ] shell_string .
stream = "1" | "2" | "*" .
shell_string = { shell_char | interpolation | raw_hole } .
raw_hole = "#!{" Expression "}" .
shell_char = unicode_char .Here shell_char is unicode_char (the common lexical element of Chapter 3; excluding newlines). A command string terminates at the end of the line (details follow the lexical rules of this section).
A command execution expression is syntactic sugar for concisely writing shell command execution. Semantically it is handled by normalization to the built-in function run. The stream specifiers 1, 2, and * derive from file descriptor numbers and select which part of the command's result is received (1 = standard output, 2 = standard error, * = the entire result).
Definition as a function:
runis a core function that executes a command in the specified execution environment and returns the entire result (exit code, standard output, standard error).- Its signature is
run(env: Runnable, cmd: String): CommandResult; anEnvironmentconforms toRunnable(4.4). The execution environment is a required positional parameter: there is no default execution environment (10.1). runsucceeds regardless of the exit code as long as the command completes (8.7). Failures occur only for execution infrastructure faults (unresolvable environment, inability to connect, inability to launch the command, etc.).
The CommandResult type:
type CommandResult = Record<code: Number, stdout: String, stderr: String>is defined as a built-in type alias.CommandResultmust not be overridden by user-defined type aliases or value declarations.codeis the exit code of the command, andstdoutandstderrare the contents of the respective streams. The construction rules follow 8.7.
Desugaring rules:
$*[env] cmdis syntactic sugar forrun(env, "cmd").$[env] cmdand$1[env] cmdare syntactic sugar for the following expression (ris a fresh identifier that does not collide with others).laskdo { r = run(env, "cmd") if (r.code == 0) { r.stdout } else { fail({code: r.code, message: r.stderr}) } }$2[env] cmdis syntactic sugar for the expression above with its success branch replaced byr.stderr.A form written without an environment specification (
$ cmd,$1 cmd,$2 cmd,$* cmd) expands identically, with the environment supplied by dispatch (10.9) over the command string. When dispatch selects no environment the expression is a static error (E-TYPE-COMMAND-NOENV); there is no fallback.Only core functions (normalization to
run,fail, andchoose), a record literal and the internal escaping of the holes (Escaping, below) appear in the expanded forms, so the behavior can never be changed by user definitions.#{e}and#!{e}inside ashell_stringare evaluated first as string interpolation, and the post-interpolation string is passed as the command body. The value of each#{e}hole is escaped before it is substituted, and that of each#!{e}hole is not (Escaping, below).
Lexical rules:
- The command string extends from immediately after
$(immediately after the stream specifier if present, or after the closing]if an environment specification is present) to the end of the line. The newline is the terminator of the command string and is treated as the newline token of 3.3. The stream specifier and environment specification must follow$without intervening whitespace. - Leading and trailing whitespace of the command string is removed, and internal whitespace is preserved as is.
- If the line ends with
\, it is a continuation line; the\and the newline are removed and the next line is concatenated. - Inside the command string, the shell's
;, quotation marks, pipes, etc. can be used without escaping. #{starts an interpolation, and#!{a raw hole. To include a literal#{or#!{, write\#{or\#!{. A#followed by neither{nor!{is treated as an ordinary character, so$ echo '#!/bin/sh'needs no escape.- A raw hole exists only in a command string. In an interpreted string (3.4),
#!{is ordinary text:"#!{x}"is the five characters it shows. - Inside
do, the newline that terminates a command string simultaneously serves as statement termination (6.5). - A command string always terminates at a newline, but the enclosing expression can be continued by a binary operator on the next line following the continuation rules of 6.5. For example, placing
|> trimon the line after$[#local] git --versionis interpreted asrun(#local, "git --version") |> trim. - All tokens on the same line after a command execution expression become part of the command string. Therefore, no subsequent tokens of the expression (closing brackets, etc.) can be placed on the same line. To process it inside an expression, first bind it and then use it, or call
rundirectly.
Here run is a core function and must not be directly declared or overridden by user code (15.5).
Escaping:
- A hole substitutes its value as text: the shell expands what is substituted to exactly the stringified value (6.2), and never parses it as shell source. Which escaping that takes depends on the shell quoting the hole is written in, determined from the literal text of the command string before it with the POSIX rules for backslash,
'...'and"..."(a\outside single quotes escapes the next character):- Outside quotes, the value is substituted as one shell word, as
shell_quotewrites it (15.5):$ cp #{src} dist/copies the filesrcnames, whatever characters it holds. - Inside
"...", each\,",$and`of the value is preceded by\:$ echo "hello, #{name}"prints the name. - Inside
'...', each'of the value is written'\'':$ sh -c 'grep -q "^#{name}:" /etc/passwd'.
- Outside quotes, the value is substituted as one shell word, as
- A hole in
$(...)or backquotes is escaped for the quoting in force where the substitution is written; the shell's own reparsing of the substitution is not followed. - A raw hole
#!{e}is substituted without escaping, so the shell parses the value:$ go build #!{flags}passes each flag inflagsas a word of its own. It is written with its own delimiter, not with a function, so that what the shell parses is visible where the hole is written: no expression inside an ordinary hole, whatever function it calls, is substituted unescaped. Because the shell parses the value, a value from outside the program (an argument, a file, a command's output) must not reach a raw hole: it would run what it contains. - The escaping is POSIX shell escaping. The shell of a
localenvironment on Windows iscmd.exe(10.2), which does not read it; a command meant for it writes its holes as raw holes and quotes them itself. - Only holes of a command execution expression are escaped. A string passed to
run(15.5) is a command line as it is, so a value interpolated into it is quoted withshell_quotewhere it needs to be. - The escaping is part of the desugaring: it is written with internal builtins that no program can name, so user definitions cannot change it.
Shell syntax:
- A command string must be well formed as POSIX shell syntax, as far as it can be read from the text alone. A violation is a static error (
E-TYPE-COMMAND-SYNTAX), reported whether the environment is specified or selected by dispatch (10.9), and before dispatch is attempted. The check exists so that a command that every POSIX shell would reject is rejected where it is written, not when it runs. - The string is read before interpolation, after the lexical normalization above. Quoting follows the POSIX rules for
\,'...'and"..."; a command substitution$(...)or`...`is read by the same rules as a script of its own; a parameter expansion${...}and an arithmetic expansion$((...))are read to their closing brackets and their content is not read; a word beginning with#begins a comment that runs to the end of its line. A hole is one opaque part of the word it is written in: escaped (Escaping, above), it can never act as an operator or a quote. - Reading stops at the first construct whose meaning depends on the shell dialect or on a value, and nothing after it is checked:
- a raw hole
#!{e}, which the shell parses; - a reserved word in command position:
if,then,else,elif,fi,do,done,case,esac,while,until,for,{,},!,[[,]],function,select,time,coproc; - a here-document (
<<), process substitution (<(or>(),|&, and ANSI-C quoting$'...'; - a
(that does not begin a command, as in a function definitionf()or an array assignmenta=(...); - a hole inside a comment, which may hold a newline that ends it.
- a raw hole
- Before that point, each of the following is an error:
- a quoted region, command substitution, parameter expansion, arithmetic expansion or group that is not closed before the end of the string;
- a
)that closes nothing; - an operator (
&&,||,|,;,&) with no command before it, including two operators in a row;;;, which ends a clause of acasecommand and is read only aftercase, which stops reading; &&,||or|with no command after it. A newline may come between the operator and the command;- a redirection operator (
<,>,>>,<&,>&,<>,>|) with no word after it; - a group
( )that contains no command.
- A command consisting only of assignments or redirections is a command, as in the shell:
FOO=1and> out.txtare well formed. A trailing;or&ends a list and is well formed. - The check is not made where the environment may be the
localenvironment of a Windows host, whose shell iscmd.exe(10.2): on such a host it is made only when the environment is known from the text to be a container environment, written as a container environment expression directly or through top-level bindings. - Examples:
$[#local] make &&,$[#local] cat >,$[#local] echo "unterminatedand$[#local] | grep xareE-TYPE-COMMAND-SYNTAX.$[#local] for f in *.go; do gofmt -l $f; done,$[#local] echo #!{flags} &&and$[#local] echo hi # it'sare not, because reading stops atfor, at the raw hole, and at the comment.
Environment specification rules:
Environmentis a basic type defined in 4.1, and the responsibilities of execution environments are defined in Chapter 10.envis an expression of typeRunnable: anEnvironment, which conforms to it (4.4), or a runnable. It is usually written as an environment expression (6.7) —#local, an image reference such as#alpine:3.20, a recipe such as#./images/build/Dockerfile, or any of these with run options,#alpine:3.20{memory: "4g"}— but any expression of either type (a variable reference, a call, etc.) can be placed there.- If resolution of
envfails, it is a pre-execution error. - When an environment specification is present it is the environment, and dispatch is not performed whatever the command string contains. When it is absent the environment is the one dispatch selects (10.9), and its absence is a static error when dispatch selects none. There is no default execution environment (10.1).
- Whitespace after
$is not significant. The command string begins immediately after$, after the stream specifier, or after the closing], and its leading whitespace is removed, so$go testand$ go testare the same expression. Only a stream specifier and an environment specification may follow$without intervening whitespace. - Detailed environment resolution rules and responsibilities follow Chapter 10.
Typing rules:
runis typed asFunction<Runnable, String, CommandResult>(7.5).- As a result of the desugaring, the expression type of
$ ...,$1 ..., and$2 ...isString, and the expression type of$* ...isCommandResult. - If
envdoes not conform toRunnable, it is a type error. - The interpolation
#{e}, and the raw hole#!{e}, must be of a stringifiable type. If it cannot be stringified, it is a static error (E-TYPE-BOUND). - The stringifiable types are
String,Number,Bool,Any, a union all of whose members are stringifiable, and a type parameter whose bound entailsstringifiable(4.4). This is the named boundstringifiable(4.2). Every other type,Nullincluded, is not stringifiable, andto_string(15.3) accepts exactly the same set.String | Nullis therefore not stringifiable: a value that may be absent cannot be interpolated into a command, and must be resolved bycase(6.4) orcast(15.8) first.Anyis stringifiable statically and may still fail at runtime (15.3), because its content is unknown until then.
Evaluation rules:
- Evaluation follows the rules for the desugared expression (8.3, 8.7). The command string (including interpolations, evaluated left to right) is fixed first, and then
envis evaluated. $* ...returns aCommandResultafter the command completes, regardless of the exit code.$ ...($1 ...) and$2 ...return the contents of the selected stream when the exit code is0. When it is nonzero, thefailof the expanded form causes a failure, and anErrorvalue (code= the command's exit code,message= the contents of standard error output) can be caught bytry/catch(6.9). If not caught, the command's exit code becomes the process exit code as is (8.10, 11.3).- The handling of standard input, standard output, and standard error follows the input/output contract of Chapter 9.
Notes on security and portability:
- When embedding interpolated values into a command, the implementation should provide a safe escaping strategy.
- Shell dialect differences are execution-environment dependent; the specification prescribes only the evaluation procedure of the command string.
Examples:
show_version() = do {
v = $[#local] git --version
v
}
build_in_docker(env: Environment) = do {
out = $[env] sh -lc 'echo inside-container'
out
}
show_in_fixed_image() =
$[#alpine:3.20] uname -a
// golangci-lint is registered as a command word, so the environment is
// supplied by dispatch (10.9).
command { "golangci-lint" } on #golangci/golangci-lint:v1.64.8
lint(): String = do {
r = $* golangci-lint run
if (r.code == 0) { "clean" } else { r.stdout }
}6.7 Environment Expressions
EnvExpr = env_head [ EnvArgList ] [ RunOptions ] .
EnvArgList = "(" [ EnvArgument { "," EnvArgument } ] ")" .
EnvArgument = lower_id "=" Expression .
RunOptions = "{" [ RunOption { "," RunOption } ] "}" .
RunOption = lower_id ":" Expression .An environment expression names a command execution target (Chapter 10). Its head names the target: the host, an image in a registry, or a recipe that builds an image. The optional argument list in parentheses gives the image options that decide which image it is (10.2); the expression without braces is of type Environment. The optional list in braces gives the run options that decide how it runs, and makes it a Runnable (4.1): it is sugar for runnable (15.5).
Heads:
#local: local execution on the host (thelocalprofile of 10.2). It takes neither list.#./path: a recipe. A head whose body begins with.is a path, relative to the directory of the module the expression is written in, naming the Dockerfile the image is built from (the recipe form of 10.2). Example:#./images/build/Dockerfile.#reference: a registry reference. Any other head is a reference to an image in a registry, with an optional registry, tag or digest (the registry-reference form of 10.2). Examples:#alpine:3.20,#ghcr.io/astral-sh/ruff:0.15.1,#ubuntu@sha256:9cee....
The head is the only place an image is named. No string, variable, argument or other expression can name one, so the images a program uses are those written as heads in its source files, and are known by reading them (10.3).
Syntax rules:
env_headis the lexical token defined in 3.3.- The opening
(ofEnvArgListmust be placed immediately afterenv_head, and the opening{ofRunOptionsimmediately afterenv_heador after the closing)ofEnvArgList, without intervening whitespace. A(or{with intervening whitespace is not part of the environment expression. - Every argument is named. A positional argument is a static error (
E-TYPE-ENV-CONSTRUCT). EnvArgListtakes the image options of the head's form (10.2):platformfor either form, andcontextandbuild_argsfor a recipe. Each is given as a string literal containing no interpolation (build_args: a table of such literals), since it is read without evaluating the program (10.3). A run option, an unknown name, a duplicate, or a value that is not such a literal is a static error (E-TYPE-ENV-CONSTRUCT).RunOptionstakes the run options of 10.2, by the same names as the keyword parameters ofrunnable. A run option takes any expression of its type, including a function's parameters and values read from outside the program (15.9). An image option, an unknown name or a duplicate is a static error (E-TYPE-ENV-CONSTRUCT). A key is alower_id; the braces are not an object literal and build no value of their own.#localwith either list, even an empty one, is a static error (E-TYPE-ENV-CONSTRUCT).- A Docker image whose reference is
localis written with its namespace,#library/local. - Environment profile names such as
local/dockerare not reserved words. In positions without#, they can be used as ordinary identifiers.#dockeris the registry reference to the imagedocker. - Because
env_charincludes., an accessor.cannot follow immediately afterenv_head(it is absorbed into the token). If processing is needed, first bind it to a variable.
Desugaring rules:
#head(image options){k1: e1, ..., kn: en}is syntactic sugar forrunnable(#head(image options), k1 = e1, ..., kn = en)(15.5). It refers to the built-inrunnablewhatever the namerunnableis bound to in scope.- The sugar is available on a head only. Run options for an environment held in a variable are given with
runnable(e, ...).
Typing rules:
- An
EnvExprwithoutRunOptionsis of typeEnvironment; one withRunOptionsis of typeRunnable. - Arguments are checked against the options of 10.2 using the call consistency of 7.5 (type conformance) and the rules of this section.
Evaluation rules:
- Argument expressions are evaluated left to right in written order.
- The evaluation result is a value consisting of the profile, the image the head names with its image options, and, for a runnable, the run options given (8.8). Runtime environment resolution follows 10.4.
- If evaluation of an argument expression fails, the whole environment expression fails.
Examples:
e1 = #local
e2 = #alpine:3.20
e3 = #alpine:3.20{memory: "4g", env: { "TOKEN": get_env("TOKEN") }}
e4 = #ubuntu@sha256:9cee2b382fe2412cd77d5d437d15a93da8de373813621f2e4d406e3df0cf0e7c
e5 = #./infra/Dockerfile(context = ".", build_args = { "GO_VERSION": "1.25" })
e6 = #node:24.21.0-alpine3.24(platform = "linux/amd64"){memory: "2g"}
// A function that lets its caller choose the image, and says how to run it.
python(--image: Environment = #python:3.12.14-alpine3.24, --index_url: String | Null = null): Runnable =
runnable(image, env = { "PYTHONUNBUFFERED": "1", "PIP_INDEX_URL": index_url })
e7 = python(image = #python:3.13.2-alpine3.24)
run_in(env: Runnable): String =
$[env] uname -a6.8 Accessor Expressions
AccessorExpr = PrimaryExpr "." lower_id | PrimaryExpr "[" Expression "]" .An accessor expression denotes field reference on a record (.) and index reference on arrays and maps ([...]).
Distinction from module namespace references:
- In the form
m.symbol, when the leadingmresolves as a namespace name of a namespace import (Chapter 5), the whole expression is handled as name resolution (the fourth priority of 7.2) and is not an accessor expression. - If a local binding with the same name as the namespace name exists, the priority order of 7.2 applies (the local binding takes precedence, in which case it is type-checked as a field access).
- Any other
.is interpreted as a field access.
Typing rules for field access:
e.fis well-typed only when the type ofeisRecord<..., f: T, ...>(a record type having the fieldf), and the type of the expression isT. Where the field is optional (f?: T, 4.2), the type of the expression isT | Null: an absent key reads as null, which is what keeps field access from failing.- If the target record type does not have the field
f, or if the target's type is other thanRecord(includingMap,Any, and a union), it is a static error (E-TYPE-ACCESS). A value of typeAnyor of a union type is referenced after moving to a concrete type, withcast(15.8) or by narrowing withcase(6.4) — and that holds for a union even when every member of it has the field. - Only field names in identifier form (
lower_id) can be referenced with.f. Field names not conforming tolower_id(4.2) are referenced with a string-literal index (described below). - Value retrieval from a
Mapuses[...]orget(15.4). Because the field set of aRecordis statically fixed (4.4) and an absent optional key reads as null, field access does not fail at runtime.
Typing rules for index access:
e[i]is well-typed whene: Array<T>andi: Number, and the type of the expression isT. Indexing is zero-based.e[k]is well-typed whene: Map<T>andk: String, and the type of the expression isT.e[k]is also well-typed when the type ofeisRecord<...>andkis a string literal containing no interpolation whose string matches a field name; the type of the expression is that field's type.e.fande["f"]refer to the same field.- An index expression on a record other than a string literal, and a string literal that does not match any field name, are static errors (
E-TYPE-ACCESS) (because the key set is statically fixed; useMapfor dynamic keys). - Any other combination of target type and index type is a static error (
E-TYPE-ACCESS).
Evaluation rules:
- The evaluation procedure and failure contract (runtime errors for out-of-range indices and absent keys) are defined in 8.9.
m[k]has the same failure contract asget(m, k).
Examples:
user = { name: "alice", age: 20 }
userName = user.name
xs = [10, 20, 30]
first = xs[0]
envMap: Map<String> = { "APP_ENV": "prod" }
appEnv = envMap["APP_ENV"]
hdrs = { "X-Api-Key": "secret" }
apiKey = hdrs["X-Api-Key"]6.9 Error Handling Expressions
TryExpr = "try" Block ( CatchClause [ FinallyClause ] | FinallyClause ) .
CatchClause = "catch" "(" lower_id ")" Block .
FinallyClause = "finally" Block .An error handling expression is an expression that catches failures during evaluation (runtime errors and external I/O errors) and joins to an alternative computation. Semantically it is handled by normalization to the core functions recover and fail.
The Error type:
type Error = Record<code: Number, message: String>is defined as a built-in type alias.Errormust not be overridden by user-defined type aliases or value declarations.codedenotes an exit code andmessagea human-readable message. The mapping from failures toErrorvalues (including the carrying over of a command's exit code and standard error) is defined in 8.10.
Definitions as functions:
recoveris a core function that receives a body function and a handler function, catches failures of the body, and joins to the handler.failis a core function that receives anErrorvalue and raises that failure. It is used for re-raising within a handler and for raising user-defined errors.erroris a helper function for constructing error values (15.7).
Here recover and fail are core functions and must not be directly declared or overridden by user code.
Desugaring rules:
try B catch (e) His syntactic sugar forrecover(\() -> do { B }, \(e) -> do { H }).try B finally Fis syntactic sugar fordo { v = recover(\() -> do { B }, \(e) -> do { F; fail(e) }); F; v }(vis a fresh identifier that does not collide with others).Fis evaluated exactly once on both the success and the failure path.try B catch (e) H finally Fis equivalent totry (try B catch (e) H) finally F(catchis applied first, andfinallyis applied to the whole).
Typing rules:
recoveris typed asFunction<Function<T>, Function<Error, T>, T>.failis typed asFunction<Error, T>. The return typeTis instantiated from the context's expected type (4.4).- A
TryExprwithcatchis well-typed only when the types of the body block and thecatchblock are the sameT, and the type of the whole expression isT. - The
eofcatch (e)is in effect only inside thecatchblock, with typeError. - The type of the
finallyblock is arbitrary, and its value is discarded. The expression type oftry B finally Fis the type of the body block.
Catch rules:
- The targets of catching are runtime errors and external I/O errors that occur during evaluation of the body block. Syntax and static errors are detected before evaluation and are therefore not targets.
- A failure that occurs inside a
catchblock or afinallyblock is not caught by the sameTryExprand propagates outward. - When
TryExprs are nested, the innermostTryExprcatches first.
Examples:
build(): String = do {
out = try {
$[#local] make build
} catch (e) {
if (e.code == 2) {
$[#local] make clean
$[#local] make build
} else {
fail(e)
}
} finally {
$[#local] rm -rf ./tmp
}
out
}6.10 Secret Bindings
(* !! attaches to the name in ValueDecl (5), PositionalParameter, and
KeywordParameter (6.1). It is not a standalone production. *)A secret binding is a ValueDecl (5), BindStmt (6.5), positional parameter, or keyword parameter (6.1) whose name is marked !!. It declares that whatever value ends up bound to that name must be masked out of what is written to stderr — logs, execution events and error diagnostics — wherever it later appears, per the sensitive-information protection contract of 12.8.
Semantics:
!!is a single lexeme that appears only as the secret marker of a binding, and is not interpreted as a sequence of the operator!(mirroring the rule for--in 6.1). Consequently!!eis not a double negation; write! !efor that.!!carries no meaning in the static type system.a!!: Stringgivesathe typeString, exactly asa: Stringwould; there is no distinct "secret" type, and secrecy is not part of any function type or unified during type checking.!!is a marker on the binding, evaluated as a side effect when the value is bound: for aValueDeclorBindStmt, at evaluation of its right-hand side; for a parameter, at argument binding (8.3), regardless of whether the bound value came from a caller-supplied argument or a keyword parameter's default-value expression.The effect of binding a value to a
!!-marked name is to register that value with the masking mechanism of 12.8. Registration is by value, not by name or source: once a value is registered, every future occurrence of that exact value in observation data is masked, however it got there (12.8).
Desugaring rules:
!!is syntactic sugar for wrapping the bound expression with the core functionmark_secret(15.9):laska!!: String = e (* is equivalent to *) a: String = mark_secret(e)- A
!!-marked parameter is equivalent to an unmarked parameter of the same kind, with an assignment prepended to the function body that rebinds the parameter throughmark_secret:laskThe same transform applies to af(x!!: String) = body (* is equivalent to *) f(x: String) = do { x = mark_secret(x); body }!!-marked keyword parameter, after default-value completion (8.3). mark_secretis a core function (15.9) and must not be directly declared or overridden by user code (7.2).
Typing rules:
!!is permitted only where the declared or inferred type isStringorString | Null. A!!marker on a name of any other type is a static error (E-TYPE-SECRET-NON-STRING).- A secret that may be absent is registered only when it is present. When the value is
null, nothing is registered: an absent secret has no text to mask, and in particular the text an absent value would print as is never registered, so it is never masked wherever else it appears in a log. This lets a secret parameter default tonulland say "not given" the way any other optional parameter does, and lets a credential be read withfind_env(15.9) when the variable may be unset.
Scope of the masking effect:
- Masking (12.8) applies only to what the implementation writes to stderr: the execution and command execution logs (12.2, 12.3), execution events (12.6), and error diagnostics (14.3).
- Masking never applies to a
CommandResult'sstdout/stderr(8.7) or to a function's return value or a caught error value as observed by the running program (a!!-marked value flows through ordinary evaluation unchanged). In particular,eval's primary result on stdout (9.5, 11.3) is never masked: if a program's return value is derived from a secret binding, outputting it faithfully is the whole point of that program, and the primary-result contract of 11.3 takes precedence. - The matching rule is exact-value substring matching (12.8), not static or dynamic taint tracking through the type system. A transformation that changes the value's character sequence (e.g.
to_upper,replace, slicing, encoding) breaks the match for the transformed result; a transformation that only embeds the value unchanged inside a larger string (e.g.concat, string interpolation) does not. - A variadic parameter cannot be marked
!!(6.1).
Examples:
deploy(
// `region` is not sensitive, so it stays readable in the log even
// though it is read the same way as the key below.
--region: String = get_env("AWS_DEFAULT_REGION"),
--secret_key!!: String = get_env("AWS_SECRET_ACCESS_KEY")
) = do {
// `secret_key` is masked in the command log below, whether it came
// from the default expression or was supplied directly as
// `lask run deploy --secret_key ...`.
$[#amazon/aws-cli:2.31.9] aws configure set aws_secret_access_key #{secret_key} --region #{region}
}7. Static Semantics
This chapter defines the semantics that can be verified before execution.
Static verification is performed in the following order.
- Module dependency resolution (including cycle detection)
- Top-level symbol table construction
- Name resolution
- Type annotation consistency and type inference
- Call consistency checking
7.1 Verification Context
Static verification uses the following three kinds of environments.
- Module environment
M: the set of import-resolved modules - Type environment
Δ: type aliases and type name resolution information - Value environment
Γ: type bindings of variables, functions, and parameters
Type judgments are written in the form Γ ⊢ e : T.
7.2 Name Resolution Order
Identifiers are resolved in the following order of precedence.
- Innermost local bindings (lambda parameters,
dobindings,foriteration variables) - Top-level declarations of the current module
- Symbols brought in by named imports (under their renamed names)
namespace.symbolreferences of namespace imports- Symbols of the built-in library (Chapter 15) and the reserved identifier
stdin(9.3)
Resolution rules:
- If multiple candidates exist at the same precedence level, it is an ambiguous reference error.
- If no candidate exists at any precedence level, it is an undefined reference error.
- Reserved words must not be bound as identifiers.
- Symbols of the built-in library are resolved at the lowest level (fifth precedence), so a user-defined symbol of the same name always takes priority (shadowing; 15.1).
- However, core function names specified as non-overridable (
spawn,choose,map,filter,reduce,for_each,run,recover,fail, Chapter 6;get_env,mark_secret, 6.10/15.9) andstdin(9.3) must not be declared or bound at any of the first through fourth precedence levels. A violation is a duplicate definition error (E-NAME-DUPLICATE).
7.3 Scope and Shadowing
Scope rules:
- Lambda parameters are valid only within the lambda body.
- A
BindStmtin adois valid within the same block for the statements that follow the declaring statement. - Bindings inside
if/for/tryblocks, and inside the body of acasearm, do not leak outward. - The name that the normalization of a
casescrutinee (6.4) binds is fresh: it is visible to no expression written in the source, and shadows nothing. - When a
casenarrows a scrutinee written as a plain local name (6.4), the narrowed name is a binding of the arm body. It shadows the outer binding of the same name within that body only, and the outer binding is unaffected in every other arm and after the expression. This is ordinary shadowing under the rules below, not a rebinding in the same scope. - The
xinfor (x: xs)is valid only within the iteration body. - The
eincatch (e)is valid only within thecatchblock.
Shadowing rules:
- Hiding a same-named binding of an outer scope from an inner scope is permitted.
- Rebinding the same name within the same scope is a duplicate definition error.
- At the top level, value declarations and function declarations of the same name must not be duplicated.
7.4 Integration of Type Annotations and Inference
The relationship between type annotations and inference is as follows.
- If an annotation is present, verification uses the annotated type as the expected type.
- If no annotation is present, the type is inferred by the rules of 4.3.
- If the inferred type does not conform (4.4) to the annotated type, it is a type mismatch error.
Supplementary rules:
- A
NamedTypeis expanded to itsTypeAliasDeclbefore checking, substituting its type arguments for the alias's parameters where it has them (4.2). - An
upper_idthat is a type parameter of the enclosing declaration is not expanded. Within the body it stands for itself (4.4); at a call it is instantiated. - Because overloading is not permitted, one symbol has only one function type.
Anyis broadly accepted on the receiving side (any type conforms toAny), but the transition fromAnyto a concrete type can only be performed by a runtime type check:cast(15.8), or the type dispatch ofcase(6.4) (4.4).
7.5 Call Consistency
A call f(a1, ..., an) is typable only when f resolves to Function<T1, ..., Tm, R>.
Declaration parameter information:
- The parameters of a function declaration or lambda expression consist of positional parameters (all required; let their count be
m), at most one variadic parameter, and a set of keyword parameters (name, type, and a default-value expression, which a required keyword parameter does not have) (6.1). - Only positional parameters (and the array type of the variadic parameter) appear in the type argument sequence of the function type
Function<T1, ..., Tm, R>. Parameter names, keyword parameters, and variadicity are not included in the function type; they are retained as static information attached to the declaration (declaration parameter information). Keyword binding, default-value completion, and variadic collection all depend on this information and are available only when the callee can be statically resolved to a declaration.
Verification items (argument binding):
- When
fstatically resolves to a function declaration or lambda expression, arguments are bound to parameters by the following procedure.- Positional arguments are bound to the positional parameters in declaration order, from the front. If there are fewer than
mpositional arguments, it isE-TYPE-ARITY. - Positional arguments beyond
mare collected in written order into a single array and bound if a variadic parameter exists (variadic collection); otherwise it isE-TYPE-ARITY. If there are zero positional arguments corresponding to the variadic parameter, an empty array is bound. - Keyword arguments are bound to keyword parameters whose names match. Unknown names, name-based specification of positional or variadic parameters, and duplicate bindings are
E-TYPE-KEYWORD(6.1). - Keyword parameters left unbound are completed at call time with their default-value expressions according to the rules of 8.3 (default-value completion). A required keyword parameter left unbound is
E-TYPE-KEYWORD(6.1).
- Positional arguments are bound to the positional parameters in declaration order, from the front. If there are fewer than
- When
fresolves only as a value of function type (application to a function-typed parameter, application of the result of evaluating an expression, values with only aFunction<...>annotation, etc.), the declaration parameter information cannot be referenced, so only positional arguments are permitted and all positional parameters must be given explicitly (n = m). Use of keyword arguments isE-TYPE-KEYWORD, and all keyword parameters are completed with their defaults. A function with a required keyword parameter never becomes such a value (6.1).
Verification items (types and result):
- Each bound argument must conform to the type
Tiof its corresponding parameter position. - The result type
Rmust be passable to subsequent expressions.
Consistency of helper functions (normalization targets of syntactic sugar):
spawn:Function<Function<T>, AsyncHandle<T>>await:Function<AsyncHandle<T>, T>choose:Function<Bool, Function<T>, Function<T>, T>map:Function<Array<T>, Function<T, U>, Array<U>>filter:Function<Array<T>, Function<T, Bool>, Array<T>>reduce:Function<Array<T>, U, Function<U, T, U>, U>for_each:Function<Array<T>, Function<T, U>, Void>recover:Function<Function<T>, Function<Error, T>, T>fail:Function<Error, T>run:Function<Runnable, String, CommandResult>
Type variables in signatures (T, U, etc.) are instantiated and checked per call according to the polymorphism rules of 4.4. This applies to a call of a declaration that declares type parameters exactly as it does to a built-in: the argument binding of this section is unchanged, and each bound argument is checked against its parameter type with the instantiation applied. Keyword parameters and variadic collection are unaffected by instantiation, being declaration parameter information rather than part of the function type.
Where a variadic parameter's element type is a type parameter, the first collected argument determines it and the remaining ones are checked against it. A union is never inferred (4.3), so mixed arguments require the union to be written — on the declaration (...xs: Array<String | Number>) or as the expected type of the call.
async / if / case / for / $ ... are statically verified as sugar over the helper functions above. await e is verified as an application of the core function await.
Consistency of environment expressions:
- The image options of an environment expression
#head(...)and the run options ofrunnable(e, ...)and its sugar#head{...}(6.7, 15.5) are named arguments bound to the options of 10.2 by name, and each is checked for type conformance against its option. An option left unbound is not completed with a value: it stays unset. runnableresolves statically as a built-in, so its run options are always available as keyword arguments. Called as a function value, it takes its environment alone (positional only, as above), and gives no run option.- Violations concerning these arguments (a positional argument where a name is required, an unknown name, a duplicate binding, an image option given to
runnableor an image option not given as a literal, a run option in a head's parentheses) are reported asE-TYPE-ENV-CONSTRUCT(7.7).
7.6 Static Expansion Order of Syntactic Sugar
The expansion order during static verification is as follows.
async e->spawn(\() -> e)await e-> core function applicationawait(e)(normalization of the parenthesis-omitted form)c ? a : b->if (c) { a } else { b }(6.2)if (c) { ... } else { ... }->choose(c, \() -> do { ... }, \() -> do { ... }); withoutelse, the false branch is\() -> null, or\() -> do {}for aVoidblock (6.4)case (e) { ... }/case { ... }-> nestedchoose(6.4); the scrutinee form first binds the scrutinee to a fresh name, and an arm with a type head expands to a runtime type test, with the narrowing rebinding of 6.4 inside the branch functionfor (x: xs) { ... }->map(xs, \(x) -> do { ... })(orfor_each(xs, \(x) -> do { ... })when the type of the body block isVoid; 6.4)try ... catch (...) { ... }/finally { ... }-> expansion to expressions usingrecover/fail(6.9)- Sequential-execution expansion of
do { ... } - Classification of environment heads into the
local, registry-reference and recipe forms, and expansion of#head{...}->runnable(#head, ...)(6.7) - Dispatch of command execution expressions with no environment specification (10.9), determining the environment of each from its command string
- Command sugar expansion of
$ cmd/$[env] cmd
Type checking is performed on the core expressions after expansion. The meaning of the expansion result must be equivalent to that of the original syntax.
Two expansions are type-directed, and every other expansion is purely syntactic.
- The choice of expansion target in step 6 (
map/for_each) depends on the type of the body block. Implementations must type the body block first and determine the expansion target from that result. - The expansion of a
casearm in step 5 depends on the type of the scrutinee: whether a head is a value head or a type head is syntactic, but the narrowing rebinding a type head or anullhead introduces (6.4) is determined by the scrutinee's type. Implementations must type the scrutinee first.
7.7 Static Errors
The error kinds reported by static verification include at least the following.
E-NAME-UNDEFINED: undefined referenceE-NAME-DUPLICATE: duplicate definitionE-TYPE-MISMATCH: type mismatchE-TYPE-ARITY: function argument count mismatch (shortage or excess of positional arguments; 7.5), or a type argument count that does not match the parameters of a type alias (4.2)E-TYPE-KEYWORD: invalid keyword argument (unknown name, name-based specification of positional or variadic parameters, duplicate binding, application to a function-typed value; 6.1, 7.5)E-TYPE-CALL: invalid call (calling a non-function value, etc.)E-TYPE-COMMAND-ENV: invalid command execution environment type, at a command execution expression or a command declaration (6.6, Chapter 5)E-TYPE-COMMAND-NOENV: a command execution expression with no environment specification for which dispatch selects no environment (6.6, 10.9)E-TYPE-COMMAND-CONFLICT: one command string selects environments not known to be one (10.9)E-TYPE-COMMAND-EFFECT: the environment of a command declaration can reach an effect (Chapter 5)E-TYPE-COMMAND-NAME: a command declaration names a program that could never be recognized in a command string (Chapter 5)E-TYPE-COMMAND-DUPLICATE: the same command word is declared twice in one module, or both declared and imported, or imported from two declarations (Chapter 5)E-TYPE-COMMAND-SYNTAX: a command string is not well formed as POSIX shell syntax (6.6)E-TYPE-COMMAND-SCHEMA: the schemas of a command declaration are not static values, name a word the declaration does not have, or are invalid in a way their type cannot express (Chapter 5)E-TYPE-COMMAND-OPTION: a command string invokes a program in a way its command schema rules out (Chapter 5)E-TYPE-ENV-CONSTRUCT: invalid environment expression or run options (an argument list or run options on#local, a positional argument where a name is required, an unknown or duplicate option, a run option in a head's parentheses, an image option given as a run option or not given as a literal, a recipe path escaping the module tree; 6.7, 10.2, 10.3, 15.5)E-TYPE-ACCESS: invalid accessor (field access on a non-Record, unknown field, invalid index type)E-TYPE-FIELD-DUPLICATE: duplicate record field name or object literal key (4.2)E-TYPE-CASE-DUPLICATE: two literal heads of onecaseexpression denote the same value, or two type heads of onecaseexpression denote the same type, so the later arm is unreachable (6.4)E-TYPE-ILLFORMED: violation of type well-formedness rules (invalid position ofVoid, recursive type alias, a union member that is not a data type, acasetype head on anAnyscrutinee that is not a data type, invalid target type ofcast; 4.2, 6.4, 15.8)E-TYPE-SECRET-NON-STRING:!!applied to a binding whose type is notString(6.10)E-TYPE-BOUND: a type variable instantiated at a type that does not satisfy its bound, at a call, a reference, or a type alias application; or an operand of==/!=, acasescrutinee with value heads, or an interpolation outside the predicate it needs (4.2, 4.4, 6.2, 6.4, 6.6)E-MODULE-CYCLE: module circular dependencyE-MODULE-UNRESOLVED: unresolvable import (undeclared dependency name, or a declared dependency not present or not verified in the cache; Chapter 5)E-MODULE-DEEP-IMPORT: an external import naming a path inside a dependency tree rather than its entry module (Chapter 5)E-MODULE-LOCK-STALE: the lock file is missing, incomplete, or inconsistent with the project file (Chapter 5)E-MODULE-CONFIRM-TARGET: aconfirmentry of the project file names a function, a parameter or a value that does not fit the program (Chapter 5)
Error diagnostics include at least the following.
- Error code
- Location information (line and column, or span)
- Expected type and actual type (where applicable)
- Resolution candidates (for ambiguous references)
8. Dynamic Semantics
This chapter defines the evaluation order of expressions and their runtime meaning.
Dynamic semantics is defined over the core expressions statically expanded in 7.6. An evaluation result is either a "success value" or a "failure".
8.1 Evaluation Relation
The evaluation relation is written as follows.
⟨ρ, A, e⟩ ⇓ v: under environmentρand asynchronous stateA, expressioneevaluates to valuev.⟨ρ, A, e⟩ ⇑ err: under environmentρand asynchronous stateA, expressionestops with failureerr.
Here ρ is the value binding environment and A is the set of AsyncHandle states (running, success, failure).
Principles of evaluation order:
- Function arguments are evaluated from left to right.
- Array and record elements are also evaluated from left to right.
- Short-circuit operations (
&&,||) follow the rules of 6.2.
8.2 Values and Closures
Runtime values include at least the following.
- Basic values (
Number,String,Bool,Null) - Environment values
Environment(an environment kind and a normalized parameter set) - Array values, map values, record values
- Function closures (a lambda body and its definition-time environment)
- Asynchronous handle values
AsyncHandle<T>
Function values evaluate to closures, and free variables are bound in the definition-time environment.
8.3 Function Application
The evaluation procedure for f(a1, ..., an) is as follows.
- Evaluate
fand confirm that it is a function closure. - Evaluate the arguments from left to right in written order (positional arguments, followed by keyword arguments in written order).
- Bind positional arguments to the positional parameters in declaration order, bind those exceeding the number of positional parameters as array elements of the variadic parameter (preserving evaluation order), and bind keyword arguments to keyword parameters whose names match (7.5).
- The default-value expression of a keyword parameter is evaluated only when the corresponding argument is not given.
- Evaluate the function body in the extended environment and return the result.
Default argument rules:
- Default-value expressions are evaluated at call time (not at definition time).
- The evaluation environment of a default-value expression includes the previously bound arguments.
- If the evaluation of a default-value expression fails, the entire function call fails.
8.4 Sequential Execution of do
do { s1; s2; ...; sn } is evaluated from top to bottom (the statement separator ; is synonymous with a newline; see 6.5).
- A
BindStmtevaluates the right-hand side, binds it to the identifier, and carries the environment forward to subsequent statements. - An
ExprStmtevaluates the expression and discards the value (except for the final statement). - The result of the whole
dois the evaluation result of the final statement. If it contains no statements, the result isVoid. - If an intermediate statement fails, subsequent statements are not evaluated and the whole
dofails.
8.5 Control Structures (Core Functions)
Evaluation of choose(c, t, f):
- Evaluate
c. - If
c = true, evaluate onlyt(). - If
c = false, evaluate onlyf(). - The unselected branch is not evaluated.
case (6.4) has no core function of its own. Its scrutinee form is normalized to a binding of the scrutinee followed by a nested choose, so the scrutinee is evaluated once and the rule above governs the rest; its condition form is a nested choose directly.
Evaluation of the condition of a type-head arm (6.4):
- The scrutinee has already been evaluated and bound, so nothing further is evaluated to test a type head.
- The bound value is checked against the head type by the runtime type check rules of
cast(15.8). The check yieldstrueorfalse; unlikecastit neither converts the value nor fails. - When the arm is selected and the scrutinee is a narrowed name whose narrowed type calls for the conversion of 6.4, the value bound in the branch is the result of the corresponding
cast, so that a record value narrowed toMap<T>, or the reverse, is converted before the body runs. The conversion follows a check that has just succeeded, or, in anelsearm, a narrowing that leaves one member of the scrutinee's own type, so it cannot fail.
Evaluation of map(xs, body):
- Evaluate
xsto obtain an array value. - Traverse the elements from left to right, evaluating
body(xi)for each elementxi. - Collect each result into an array in the same order and return it.
- If any iteration fails, stop there and the whole expression fails.
Evaluation of filter(xs, pred):
- Evaluate
xsto obtain an array value. - Traverse the elements from left to right, evaluating
pred(xi)for each elementxi. - Collect only the elements whose result is
trueinto an array in the same order and return it. - If any predicate evaluation fails, stop there and the whole expression fails.
Evaluation of reduce(xs, init, f):
- Evaluate
xsto obtain an array value. - Evaluate
initas the initial accumulator value. - Traverse the elements from left to right, evaluating
f(acc, xi)for the accumulatoraccand each elementxi, and take the result as the new accumulator value. - Return the accumulator value after traversal completes. If any application fails, stop there and the whole expression fails.
Evaluation of for_each(xs, body):
- Evaluate
xsto obtain an array value. - Traverse the elements from left to right, evaluating
body(xi)for each elementxiand discarding the result. - Return
Voidafter traversal completes. - If any iteration fails, stop there and the whole expression fails.
8.6 Asynchrony (Core Functions)
Evaluation of spawn(thunk):
- Evaluate
thunkas a function value. - Create a new handle
hand setA[h] = running. - Submit
thunk()for asynchronous execution. - Return
hto the caller immediately.
On asynchronous task completion:
- On normal completion,
A[h] = success(v). - On failure,
A[h] = failure(err).
Evaluation of await(h):
- Evaluate
h. - If
A[h] = running, wait until completion. - If
A[h] = success(v), returnv. - If
A[h] = failure(err), rethrowerr.
Multiple awaits on the same h return the same completion result.
At the end of a top-level execution, before its result is output or its failure reported:
- While some
hcreated during the execution has never been passed toawait,all, orrace, nor cancelled bytimeout(15.7), wait untilA[h]is no longerrunning. - Report each such
hasW-ASYNC-UNAWAITED(6.3), in the order the handles were created, with its failure ifA[h] = failure(err).
8.7 Command Execution (Core Function)
Evaluation of run(env, cmd):
- Evaluate
env. It is always present: command sugar supplies it from the environment specification or from dispatch (6.6, 10.9), and a direct call must pass it. - Evaluate
cmdto a string (for command sugar containing interpolation, the interpolation expressions are evaluated from left to right and the finalized string is passed). - Resolve
envto an execution environment (10.4). - Execute the command synchronously in the resolved environment. During execution, the child process's standard output and standard error are relayed to stderr in real time according to the rules of 12.3 (the relay does not affect the evaluation result).
- After the command completes, construct and return a
CommandResultvalue (6.6). It is a success regardless of the exit code.
Construction rules for CommandResult:
code: set to the process's exit code as is. Termination by signal may be mapped according to shell convention (128 + signal number).stdout/stderr: set to the contents of each stream (up to an implementation-defined size limit; on overflow, a summary preserving the head and tail may be used).runfails only in the case of execution infrastructure failures (environment unresolvable, connection failure, command unable to start, etc.) (external I/O error; Chapter 14).
Error value conversion of failures caused by non-zero exit (carrying over the exit code and standard error output) follows 8.10.
Stopping an abandoned command:
- The evaluation of
runis abandoned when the computation it belongs to is cancelled before the command exits:racecancels the computations of the handles that did not finish first,allcancels the computations of the other handles once one has failed (15.6), andtimeoutabandons a body that outlives its limit, together with the computations the body started (15.7). - An abandoned command is stopped. The process, and every process it started, receive a termination request; whatever remains after an implementation-defined grace period is terminated forcibly. A command running in a container is stopped together with its container, which is removed. A command running in the container of a session (15.5) is stopped without it: its own processes in the container are stopped, and the container and the session's other processes stay. The container runs under an init process unless its environment turns that off (10.2), so the request reaches a command that is not written to handle it.
- The command keeps the process group it was started in, so a command that reads the terminal (a password prompt) and an interrupt from the terminal behave as they would for any child process. The processes to stop are therefore found from the process tree when the command is abandoned, and a process that has left the tree by then (a daemon that detached itself) is not reached.
- An abandoned
runreturns no result. Its command execution log records the command askilledin place of the exit line (12.3).
8.8 Evaluation of Environment Expressions (Core Expression)
An environment expression #head(image options) (6.7) is a core expression evaluated by the following procedure.
- Evaluate the argument expressions from left to right in written order.
- Return an
Environmentvalue consisting of the profile (localordocker) and the image the head names, with its image options. - If the evaluation of an argument expression fails, the entire environment expression fails.
The sugar #head(image options){run options} has been expanded to runnable(#head(image options), run options) (7.6). An application of runnable(e, k1 = e1, ..., kn = en) (15.5) is evaluated as follows.
- Evaluate
eto anEnvironmentvalue, then the run option expressions from left to right in written order. - If the profile of
eislocaland any run option is set, the application fails (E-RUNTIME-VALUE):localtakes none. - Return a
Runnablevalue consisting ofeand the run options given. An option givennullis not set (10.2). - If the evaluation of
eor of a run option expression fails, the entire application fails.
There is no other way to give run options. runnable takes an Environment, so the options of a runnable are given once, where it is made, and no rule decides how one set of options combines with another.
Resolution of an environment or runnable value to an execution environment (10.4) is performed not at evaluation time but immediately before command launch by run.
Equality comparison:
==/!=(6.2) onEnvironmentandRunnablevalues is determined by structural equality of the profile, the image with its image options, and the set of run options that are set. AnEnvironmentand aRunnablewith no run option set are equal:#alpine:3.20 == runnable(#alpine:3.20), and#alpine:3.20{memory: null} == #alpine:3.20.
8.9 Evaluation of Accessor Expressions (Core Expression)
Evaluation of field access e.f:
- Evaluate
eto obtain a record value. - Return the value of field
f. The existence of the field is guaranteed by static verification (6.8), so no failure occurs at this stage.
Evaluation of index access e[i]:
- Evaluate
e. - Evaluate
i. - If
eis an array value: ifiis a non-negative integer less than the number of elements, return the corresponding element. A non-integer, negative number, or out-of-range value is a runtime error (E-RUNTIME-ACCESS). - If
eis a map value: if keyiexists, return the corresponding value. If absent, it is a runtime error (E-RUNTIME-ACCESS). The failure contract is identical to that ofget(m, k)(15.4). - If
eis a record value: the index is restricted to a string literal, and the existence of the field is guaranteed by static verification (6.8), so the value of the corresponding field is returned. No failure occurs at this stage.
If the evaluation of a subexpression fails, the entire accessor expression fails.
8.10 Failure Propagation and Recovery
Failure propagation rules:
- If a subexpression fails, the failure propagates to the enclosing expression unless explicitly caught.
choosepropagates only the failure of the selected branch.map/filter/reduce/for_eachreturn the first failure that occurs as the failure of the whole.awaitrethrows the corresponding asynchronous failure.recovercatches failures of the body (described later in this section). Failures inside the handler are not caught and propagate.
Error value conversion rules:
When a failure is caught, and when it reaches the top level, the failure is mapped to an Error value (6.9) by the following rules.
- Runtime error:
codeis2(the same value as the exit code classification of 11.3). - External I/O error:
codeis3(same as above). - Giving up on waiting:
codeis124, the exit status of GNUtimeout, for both atimeoutwhose limit passed and anuntilwhose delays ran out (15.7). Neither is a failure of the program being waited for, so neither takes the runtime error code2, and a handler can tell the two cases apart from other failures by the code alone. - Failure via
fail(err): the givenerris used as is. Failures arising from non-zero exits of the command execution expressions$,$1, and$2fall under this case; by the sugar expansion (6.6),code= the command's exit code andmessage= the contents of standard error output (signal mapping and capture limits follow theCommandResultconstruction rules of 8.7). - Secret information contained in
messageis masked or removed according to the rules of 12.8.
Evaluation of recover(body, handler):
- Evaluate
body(). - If it succeeds, return its value.
handleris not evaluated. - If it fails, map the failure to an
Errorvalueerr, evaluatehandler(err), and return its result. - Failures during the evaluation of
handlerare not caught by thisrecoverand propagate outward.
Evaluation of fail(err):
- Evaluate
errto obtain anErrorvalue. - Raise a failure carrying that
Errorvalue.
Termination rules for uncaught failures:
- A failure that propagates to the top level (the outermost shell of the evaluation started by the CLI) is converted to an
Errorvalue and terminates the process. The process exit code is thecodeof theErrorvalue (11.3, 14.8). - When reflecting into the exit code, if
codeis not an integer between 1 and 255 (0, negative, non-integer, or greater than 255), it is normalized to1. TheErrorvalue itself is not modified. - Error diagnostic output to stderr follows Chapter 14.
9. Standard I/O and Data Flow
This chapter defines standard input, standard output, standard error, and data passing between functions.
9.1 I/O Channel Model
The Lask runtime must distinguish and handle at least the following three channels.
- Standard input (stdin): input data received from the outside before execution starts
- Standard output (stdout): the channel for outputting evaluation results (
eval; 9.5) - Standard error (stderr): the channel for outputting diagnostic and failure information
Channel separation rules:
- Implementations must logically separate stdout and stderr.
- The main result must be output to stdout, and diagnostic information must be output to stderr.
- Child process output obtained from a command execution expression (6.6) is converted to a value and returned to expression evaluation according to the rules of 8.7, while simultaneously being relayed to stderr in real time as the command execution log (12.3).
9.2 Ingestion of Standard Input
Ingestion of standard input targets run / eval and is performed before function evaluation begins by the following procedure, when the function called can refer to stdin: when it, or a declaration it reaches, or the environment of one of the program's command declarations, names stdin (9.3). A function that cannot refer to it cannot observe its contents, so stdin is then not read at all, and the run does not wait for it to close. Because repl uses stdin as an interactive input channel, it does not perform the ingestion of this section, nor does it bind the stdin reference variable (9.3).
- Read stdin until EOF.
- Fix the entire input that was read as a single input snapshot.
- Decode the input snapshot to a UTF-8 string by the rules of 9.4 and produce a
Stringvalue. - Bind the conversion result to the reference variable of 9.3 before starting function evaluation.
Rules:
- Behavior in which additional stdin is received during execution and reflected into evaluation is outside the scope of this specification.
- Empty stdin is also treated as a success case and is converted to the empty string
"". - If an I/O failure occurs before EOF is obtained, it is a runtime error.
9.3 Standard Input Reference Variable
The runtime must provide a read-only reference variable stdin for accessing the decoded standard input.
Rules:
stdinis treated as a reserved identifier and must not be rebound by user definitions.- The binding of
stdinis established exactly once per execution unit. - The visibility of
stdintakes effect from the top-level environment at the start of evaluation, and it is referenceable according to the ordinary name resolution rules (7.2). stdinis treated as an immutable value; destructive updates are not permitted.stdincannot be used inrepl. Because no binding is provided, a reference is an undefined reference error (E-NAME-UNDEFINED).
Type rules:
- The static type of
stdinis fixed toString. It must not be changed by implementation or CLI settings. - Structuring such as line splitting or JSON interpretation is performed within the language by applying functions of the built-in library (e.g.,
split(stdin, "\n")(15.3),from_json(stdin)(15.8)). The transition from the result offrom_json(Any) to a concrete type usescast(15.8) in combination, orcasetype dispatch (6.4) where the input may take more than one shape.
9.4 Standard Input Decoding
Standard input is always decoded as a UTF-8 string and bound to stdin as a String value.
Rules:
- The input snapshot must be decoded as UTF-8.
- No CLI options are provided for selecting input structuring (line splitting, JSON interpretation, etc.). This is because if the decoding method depended on settings outside the language, the type of
stdinand the meaning of the program would change per invocation. Structuring is performed explicitly within the language as in 9.3.
Failure rules:
- Input that cannot be decoded as UTF-8 is a runtime error.
- Decoding errors must be mapped to the external I/O error classification of Chapter 14 (
E-IO-DATA-DECODE).
9.5 Role of Standard Output
Standard output is the main channel for passing evaluation results to the outside.
Rules:
- When
evalcompletes function execution, the evaluation result is output to stdout in the specified encoding scheme (11.3). rundoes not output the evaluation result to stdout. The main purpose ofrunis task execution, and observation of results is done via execution logs and execution events (Chapter 12).- If the evaluation result of
evalisVoid, the stdout output may be empty. - Nothing other than the return value must be output to stdout. Diagnostics, logs, and relays of child process output are all output to stderr (9.6, 12.3).
- Details of the output encoding scheme follow 11.3 and Chapter 13.
9.6 Role of Standard Error
Standard error is the dedicated channel for execution failures, warnings, and diagnostics.
Rules:
- stderr is the destination for all logs. At least the following must be output to stderr.
- Command execution logs (real-time relay of the child process's standard output and standard error; 12.3)
- Execution logs (12.2), including the lines emitted by the built-in
log(15.12) - Error diagnostics for syntax, static verification, and runtime failures (Chapter 14)
- The contents of stderr may include machine-readable error codes (Chapter 14) in addition to human-readable messages.
- The presence or absence of output to stderr must not affect the meaning of stdout (the main result).
- A command's standard error output is, in parallel with the relay to stderr (12.3), captured into the
stderrofCommandResult(8.7) and into themessageof the failure values of$,$1, and$2(6.6).
9.7 Inter-Function Data Flow and Pipes
Data flow in Lask is expressed by function application, return values, and the pipe operator (6.2).
Data passing rules:
- Value passing between functions is performed only through positional arguments, keyword arguments, and return values.
e |> fis sugar forf(e), passing the left-hand result to the right-hand function.f >> gdenotes function composition and is treated at evaluation time as a single data flow that passes the result offtog.map(xs, body)aggregates element-wise data flow into an array. When the result is not needed and the purpose is side effects, usefor_each(xs, body).
Rules for connecting to I/O:
- The only entry point from external input into internal expressions is the
stdinbinding. - The exits from internal expressions to external output are the stdout output of the evaluation result in
eval(9.5) and the stderr output for failures and diagnostics. - Command execution expressions reconnect external process output to the expression data flow as
Stringvalues.
Example:
normalize() = do {
text = stdin
text
|> trim
|> to_lower
}
fanout(xs: Array<String>) =
map(xs, \(x) -> concat("item:", x))9.8 Secret References
A secret reference is a process environment variable whose value names a secret held in an external store rather than holding it. It is resolved when a program reads the variable (15.9), so that a program reads a credential the same way whether the environment holds the credential itself or says where to fetch it. What changes between one environment and another is the environment, never the program:
# production
export LASK_SECRETS="vault"
export VAULT_ADDR="https://vault.example.com"
export VAULT_ROLE_ID="..." VAULT_SECRET_ID="..."
export AWS_ACCESS_KEY_ID="{vault://aws/creds/deploy#access_key}"
export AWS_SECRET_ACCESS_KEY="{vault://aws/creds/deploy#secret_key}"
# development
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."aws(key_id: String, secret: String) =
#amazon/aws-cli:2.31.9{env: { "AWS_ACCESS_KEY_ID": key_id, "AWS_SECRET_ACCESS_KEY": secret }}
deploy(
--key_id!!: String = get_env("AWS_ACCESS_KEY_ID"),
--secret!!: String = get_env("AWS_SECRET_ACCESS_KEY")
): String = $[aws(key_id, secret)] aws sts get-caller-identityForm:
SecretReference = "{" , Scheme , "://" , Body , "}" ;
Scheme = lower , { lower | digit | "+" | "." | "-" } ;
Body = char , { char } ; (* scheme-specific *)- A variable is a reference only when its whole value has this form. A reference embedded in a longer value (
"x-{vault://...}") is not recognized, and the value is read as it is. - A value that starts with
{, a scheme and://but does not end the reference with its closing}is a malformed reference (E-IO-SECRET-REF), never an ordinary value: a mistyped reference must not be handed on as a password. - The scheme selects the store. The schemes an implementation provides are listed below; any other is
E-IO-SECRET-PROVIDER.
Enabling stores:
- The process environment variable
LASK_SECRETSlists the schemes a run may resolve, separated by commas or white space (LASK_SECRETS=vault). A reference whose scheme is not listed isE-IO-SECRET-PROVIDER, whatever else is configured. - The list is read from the environment, not from the program, so that which stores a program may reach is decided by whoever runs it.
Reading:
get_env,find_envandget_env_or(15.9) resolve a reference and return the value it names. A reference that cannot be resolved is an external I/O error (14.6) whose message names the variable and the reference and never a value or a credential. In particularfind_envdoes not returnNullfor it: the variable is set, and what it names must exist.has_envdoes not resolve a reference, and makes no request: a variable holding one is set.- A value resolved from a reference is registered for masking (12.8). This is the one case in which a value is registered because of where it came from: writing a reference is how the environment declares the value secret. Binding it to a
!!-marked name (6.10) remains good practice and changes nothing. - A run reads each secret at most once. Two references that name the same secret (the same path and version, differing in their field at most) are answered by the same read, also when they are read concurrently (6.3). For a store that issues a new credential on every read, this is what makes the fields of one credential agree.
- A resolved value is kept in memory for the rest of the run only. Nothing is written to disk.
- Resolution never changes the process environment. A command reads the variables of its execution environment (10.6), and a
localcommand that reads the variable itself sees the reference, not the secret; a value reaches a command only as the program passes it on, for instance as#alpine:3.20{env: {"TOKEN": get_env("TOKEN")}}. - Nothing that does not evaluate a program resolves a reference:
check,serve, completion and help never reach a store.cmd --help(11.8) reports a command whose environment reads a reference without resolving it.
The vault scheme (HashiCorp Vault, and stores serving the same HTTP API, such as OpenBao):
VaultBody = Path , [ "?version=" , digit , { digit } ] , "#" , Field ;
Path = Name , { "/" , Name } ;- The path is written as
vault kv gettakes it, and the field names one key of the secret:{vault://secret/app#password}. The field is required. - Whether the path is on a key/value version 2 mount is asked of the server. There the path is read at its
data/location andversionselects a version, the latest when omitted. On any other mount the path is read as written, and aversionisE-IO-SECRET-REF. When the server will not say, the path is read as written. - Configuration comes from Vault's own variables, so that an environment prepared for the Vault CLI serves unchanged:
VAULT_ADDR(required),VAULT_NAMESPACE, andVAULT_CACERT, which trusts the certificate authority in that file instead of the system's. - Credentials are, in order of precedence:
VAULT_TOKEN; AppRole login withVAULT_ROLE_IDandVAULT_SECRET_ID; the tokenvault loginleaves in~/.vault-token. None of them isE-IO-SECRET-PROVIDER. A run logs in at most once. - A response is awaited for at most ten seconds.
- A request carries the token, so it follows at most one redirect: the
307or308with which a standby node names the active one, as the Vault CLI follows it. Any other redirect, a second one, or one fromhttpstohttpis not followed and isE-IO-SECRET-UNREACHABLE.
Errors (14.6), all catchable and exiting with code 3 when uncaught (14.8):
| Code | Condition |
|---|---|
E-IO-SECRET-PROVIDER | The scheme is unknown or not enabled in LASK_SECRETS, or the store is not configured. |
E-IO-SECRET-REF | The reference is malformed. |
E-IO-SECRET-UNREACHABLE | The store cannot be reached or does not answer, or cannot serve reads (for Vault: sealed or uninitialized). |
E-IO-SECRET-AUTH | Login fails, or the credentials do not allow reading the path. |
E-IO-SECRET-NOT-FOUND | The path, the version or the field does not exist. |
10. Execution Environments
This chapter defines the execution environments that can be specified when invoking commands, and their responsibilities.
10.1 The Environment Type and Environment Expressions
Environment is a built-in primitive type (4.1) representing an execution target: where a command runs, and in which image. Runnable (4.1) is an environment together with the run options it is run with (10.2), and is what run (6.6) receives as its first positional argument. Environment values are constructed only by evaluating an environment expression (6.7), and Runnable values only by runnable (15.5) and its sugar #head{...} (6.7), and as the session with_container gives its body (15.5). An Environment conforms to Runnable (4.4): it is a runnable with no run options.
Rules:
Environmentmust not be overridden by user-defined type aliases or value declarations.Environmentvalues must be resolvable to at least the following 2 profiles.local:#local(local execution)docker: a registry-reference head#<image>or a recipe head#./<Dockerfile>(container execution)
- The
envargument ofrunmust be normalized to anEnvironmentorRunnablevalue at evaluation time. - There is no default execution environment. Every command execution expression determines its environment from the expression itself — from its environment specification, or by dispatch over its command string (6.6, 10.9) — and an expression that determines none is a static error (
E-TYPE-COMMAND-NOENV). Local execution is written#local, like any other environment, or is registered for a program by a command declaration (Chapter 5). - No CLI option or other external configuration may supply or replace the environment of a command, or name its image. A value from outside the program may reach a run option, as an argument passed to a function that gives it to
runnable(15.5); it cannot reach the image. - The rule replaces an earlier implicit fallback to
#local. Its purpose is that a program's dependence on the host be stated rather than assumed: under a fallback, deleting a command declaration or mistyping a program name left the command running against whatever the host happened to provide, which is the failure Lask exists to prevent.
Type conformance:
- The type and signature of
runfollow the definition in 7.5. - Passing a value other than
EnvironmentorRunnableto theenvargument is a type error.
10.2 Target Environment Profiles and Their Options
The execution environment profiles specified by this specification are local and docker. Both are self-contained on the host running Lask. Environments that span hosts are not provided: their contents cannot be pinned, they carry state between runs, and no boundary can be placed around what they may do. A remote host is reached by running a client for it inside a container ($[#alpine:3.20] ssh ...), which keeps the tool that reaches it pinned.
local: local process execution on the calling host- Written
#local. It takes no options.
- Written
docker: execution inside a container- The image is named by the head of the environment expression (6.7), in one of two forms.
- Registry reference:
#<reference>, an image in a registry. It may carry a registry, a tag or a digest, or none of them: a bare repository name names itslatest. Whatever a reference names upstream, the lock pins the image it resolved to when it was materialized (10.3), so a reference that moves upstream does not move a run. - Recipe:
#./<path>, a Dockerfile, with the image optionscontext, naming its build context, andbuild_args, supplying the build arguments the recipe hash covers (10.3).contextdefaults to the directory containing the Dockerfile.contextandbuild_argsgiven to a registry reference are static errors (E-TYPE-ENV-CONSTRUCT), because there is then no build for them to reach.
- Registry reference:
- Image options:
platform(String) for either form, andcontext(String) andbuild_args(Map<String>) for a recipe. They decide which image is materialized and run:platformselects a variant of a multi-platform image, or the platform a recipe is built for. They are given only in a head's own argument list, as literals containing no interpolation (6.7), so that the set of images a program uses is determinable by reading its source (10.3). - The Dockerfile and
contextare relative to the directory of the module in which the expression is written, and must resolve inside the tree of that module. A path escaping that tree is a static error (E-TYPE-ENV-CONSTRUCT). A recipe is therefore covered by the module's content hash (Chapter 5) and cannot be altered without invalidating the pin. - Both keep naming the same file wherever the environment is later used: from a module that imports it, or from a project that depends on the tree. A library can therefore ship the recipes of its environments beside them.
- Run options configure the container. They are given to
runnable(15.5), or in braces after a head (6.7), and make aRunnable. Implementations may provide further run options (e.g.memory,cpus); an unknown option name must be a static error (7.7). - A run option takes any expression of its type, like an argument anywhere else. It is resolved when a command runs.
- The order options are written in is not part of the value. Two runnables with the same image and the same options set are structurally equal (8.8), so dispatch does not see them as two environments (10.9).
- A run option must not be able to unsettle what this chapter fixes elsewhere: the image, the process launch method, the mount of the base directory (10.5), the streams (9.1, 11.8), or the rule that images are materialized only by
sync(10.3). An option that would override one of those is outside what may be provided. - Notation examples:
#alpine:3.12,#./infra/Dockerfile(context = "."),#node:24.21.0-alpine3.24(platform = "linux/amd64"); with run options,#alpine:3.12{memory: "4g"},runnable(e, memory = "4g")
- The image is named by the head of the environment expression (6.7), in one of two forms.
The run options this implementation provides:
| Group | Options |
|---|---|
| Resource limits | memory, memory_swap, memory_reservation, cpus, cpu_shares, cpuset_cpus, cpuset_mems, pids_limit, shm_size, blkio_weight, ulimits |
| Execution context | workdir, user, env, hostname, init |
| Confinement | read_only, tmpfs, cap_drop |
| Network | network, dns, dns_search, add_hosts, publish |
| Host filesystem | volumes |
ulimits,tmpfs,cap_drop,dns,dns_search,publishandvolumesareArray<String | Null>;envandadd_hostsareMap<String | Null>;initandread_onlyareBool | Null;cpus,cpu_shares,pids_limitandblkio_weightareNumber | Null; the rest areString | Null.- A list or table option also takes a value already typed
Array<String>orMap<String>. Containers are invariant (4.4), so such a value does not conform to the nullable element type; it holds no null to leave out, and means the same. - An option given
nullis left out, as if it had not been written; so is anullelement of a list or anullvalue of a table, and an empty list or table gives nothing. This is how an argument says "not given" — a function that builds a runnable passes its own optional parameters straight through. Any other value is passed as given, the empty string included:env = {"PAGER": ""}sets the variable to the empty string. workdiris the explicit working directory 10.5 gives precedence over the default.envis the explicit specification of 10.6, and is therefore the highest-precedence source of the variable set.- A value of
envmust not appear on the command line of a process lask starts, which every user of the host can read:envis where a program passes a credential to a container (9.8). This implementation names each variable on the engine client's command line (10.10) and sets its value in the client's environment, from which the client takes it. A variable the client reads itself is passed with its value on the command line instead, since setting it for the client would change which engine it reaches or how. In any case, that is a name ending with_PROXY, orPATH,HOMEorSSH_AUTH_SOCK, and, by engine:docker: a name beginning withDOCKER_.podman: a name beginning withCONTAINER_,CONTAINERS_,PODMAN_,BUILDAH_orXDG_, orTMPDIRorREGISTRY_AUTH_FILE.nerdctl: a name beginning withCONTAINERD_,NERDCTL_,BUILDKIT_,CNI_orXDG_, orTMPDIR.
read_only,tmpfsandcap_dropnarrow the permission boundary of 10.7;publishandvolumeswiden it, and a module that uses them says so where the environment is written.- A
Boolparameter leftfalseis the daemon's own default and is not passed; nothing is inferred from its absence. initis the exception: it is on unless set tofalse. The container runs an init process as PID 1, which passes the termination request of 8.7 on to the program; a program that is PID 1 itself has no default signal handlers and would ignore it until the grace period ran out.init = falseruns the program as PID 1.
Each profile has at least the following execution attributes.
- Process launch method
- Working directory resolution rules
- Environment variable injection rules
- Permission boundary and access constraints
How this implementation launches a command:
docker: the container is started with the image's entrypoint replaced by/bin/sh, and the command string is run as/bin/sh -c <command string>. An image must therefore provide/bin/sh; an image without a shell (a distroless orscratch-based tool image) cannot run a command, and is wrapped in an image that has one.local: the command string is run by the host's shell,/bin/sh -con POSIX systems andcmd.exe /con Windows.
Implementations may extend with additional profiles, but must not break compatible behavior with the above 2 profiles.
10.3 Container Image Resolution and Materialization
A docker environment names its image by its head, as a registry reference or as a recipe (10.2). Both forms are resolved to a concrete, immutable image ahead of execution and recorded in the lock file (Chapter 5).
Statically known images:
- An image is named only by a head (6.7), and its image options are literals. The images a program can use are therefore found by reading its source files, without evaluating them, and every one of them can be materialized and pinned ahead of execution.
- The images a program requires are those named by the heads reachable from its entry module (11.4): from its declarations, keyword parameter defaults included, and from the environments of its command words, through every declaration they reference, in any module. A head in a module of the graph that nothing reachable references is not required.
- Reachability over-approximates, as 11.4 does. A function's default image counts whenever the function is reachable, even where every caller passes another.
Materialization:
- A registry reference is pulled and resolved to a digest. The digest is recorded in the lock and verified on subsequent pulls; a mismatch is
E-IO-IMAGE-DIGEST. - A reference the lock already pins is pulled by its digest, not by what is written: a machine that has never seen the image gets the very image the lock names, whatever the tag names upstream today. A reference the lock does not pin is pulled as written, and the digest it resolves to is recorded; it is then pulled once more by that digest, so that the daemon holds the pinned image under a name of its own, independent of the tag.
- A reference written with a digest (
#alpine@sha256:...) pins itself: it is recorded with that digest. - A reference with
platformis pulled for that platform. The digest the lock records is the one the reference resolves to, which for a multi-platform image is that of its index and covers every platform; the same reference used with several platforms is one lock entry, materialized once per platform. - The pin moves only when the program does. Writing another reference — a new tag — materializes a new image; the same tag pointing somewhere new upstream changes nothing until its entry is removed from the lock.
- A recipe is built, for its
platformif one is given. The resulting image is taggedlask/<dependency path>/<recipe hash>, where the recipe hash covers the Dockerfile, the build context, the declared build arguments, and the platform. A changed recipe therefore yields a different image and cannot reuse a cached one. - Builds are performed with no host mount other than the declared context, without privileged mode, and without host networking.
Timing:
check,run,eval,envs, andcmdmust not build or pull an image. A build instruction is arbitrary code execution, and making it reachable from an implicit path would reintroduce the load-time execution that Chapter 5 otherwise excludes. An implementation must keep the container runtime from pulling on their behalf, including for a platform the image on the daemon was not materialized for.replis the exception for pulls. It evaluates what is typed at it, which no one else runs and no lock records, so a reference the lock pins runs as pinned and any other runs as written, the daemon pulling it if it must. It builds nothing.- Images are materialized only by
lask sync(11.7). - If a required image is absent when a command is to be executed, it is an external I/O error (
E-IO-IMAGE-MISSING). The diagnostic must name the command that materializes it.
10.4 Environment Resolution Rules
Environment resolution is performed immediately before command launch by run.
Resolution procedure:
- Evaluate the
envexpression to obtain anEnvironmentvalue (profile, image and options). - Classify the value's profile as one of
local/docker. - For
docker, resolve its image to the image recorded in the lock file (10.3). - Normalize into an executable concrete runtime configuration.
Resolution rules:
- A
dockervalue is resolved to the digest or the content-addressed local tag recorded for it in the lock file (10.3), and runs as that image: a registry reference as its repository at the pinned digest. If the lock has no entry, or the image is absent from the target daemon, it isE-IO-IMAGE-MISSING, and the diagnostic nameslask sync. An image is told absent only by a daemon that answers: when the daemon cannot be reached, it isE-IO-ENV-RESOLVE, whichlask syncwould not mend. - A reference written with a digest resolves to itself, entry or not.
- Every image a
dockervalue can carry was written as a head (6.7), so a missing entry means only that the lock is behind the program; there is no image the lock cannot record.
Failure rules:
- Invalid heads and options are static errors (
E-TYPE-ENV-CONSTRUCT) and do not occur in the runtime resolution of this section. The only means of specifying an execution environment is the in-language environment expression (no means of supplying an execution environment from the CLI is provided; 11.1). - Absence of the execution infrastructure (container engine unreachable, image not materialized, etc.) is a pre-execution error.
- Resolution failure must be classified as an external I/O or runtime error in Chapter 14.
Determinism:
- For the same
Environmentvalue, the same environment definition file content, and the same execution settings, the environment resolution result must be deterministic.
10.5 Working Directory Rules
The working directory (cwd) is the base for relative path resolution during command execution.
Rules:
- The default cwd for
localis the project base directory where execution started. - The default cwd for
dockeris the working directory determined by the implementation inside the container.- This implementation bind-mounts the project base directory, read-write, at
/workin the container and uses/workas the default cwd. A relative path in a command therefore names the same file on the host and in the container, and a file the command writes there is on the host afterwards.
- This implementation bind-mounts the project base directory, read-write, at
- An explicit specification takes precedence over the default cwd.
- If the cwd does not exist or is inaccessible, it is a pre-execution error.
Portability requirements:
- Implementations must absorb differences in path separators and absolute/relative resolution so that the intent of the same script is preserved.
10.6 Environment Variable Rules
The execution environment constructs the environment variable set E at process launch.
Construction rules:
- Obtain the execution environment's default variables as the base set.
- Apply override variables from implementation settings or CLI specification.
- Exclude variables prohibited by security policy.
Conflict rules:
- If the same key is supplied by multiple sources, resolve with the precedence: explicit specification > implementation settings > environment defaults.
Compatibility rules:
- Case sensitivity of variable names is execution-infrastructure dependent, but must be handled with consistent rules within the same infrastructure.
10.7 Permission Boundary
Each profile must explicitly have the following permission boundaries.
- Filesystem access boundary
- Network access boundary
- Executable command boundary
Rules:
localis bounded above by the calling process's privileges.dockeris bounded above by the privileges within the container isolation boundary.- The filesystem functions (15.11) are bounded by the same boundary as command execution in the environment they are given: they see exactly the filesystem that environment's commands see, and never the host's filesystem by default. There is no filesystem access that does not name an environment.
- If execution is impossible due to insufficient privileges, it is a runtime error, and the cause must be reported in a diagnosable form.
Security requirements:
- Implementations must not output credentials or secret values in plaintext to logs.
- Implementations may record command execution requests with the minimum auditable information (environment kind, target, exit status).
10.8 Responsibilities for Absorbing Environment Differences
In this specification, absorbing environment differences refers to the responsibility of making the same Lask program deployable to different execution infrastructures while preserving its meaning.
Responsibilities:
- Unify the return value and failure contract of the command execution API (
run) across environments. - Standardize the observation interface for exit codes, stdout, and stderr.
- Map environment-specific failures to the common error classification of this specification (Chapter 14).
- To the extent possible, internalize implementation differences such as retries and connection initialization, and do not leak them into language-level semantics.
Non-responsibilities:
- Complete absorption of shell dialect differences themselves is not required.
- Semantic differences of external tools specified by the user (behavioral differences of the commands themselves) are outside the responsibility of this specification.
Example:
showLocal() = $[#local] pwd
showDocker() =
$[#alpine:3.20] pwd
showBuilt() =
$[#./infra/Dockerfile(context = ".")] pwd10.9 Command Dispatch
Dispatch determines the environment of a command execution expression that carries no environment specification (6.6), from the programs its command string invokes. It is performed during static expansion (7.6) and never at run time.
Dispatch is a lexical procedure over the command string as written. It is not an interpretation of shell syntax: beyond the structure enumerated below, no dialect-dependent construct is given meaning, and 6.6's position that the specification prescribes only the evaluation procedure of the command string is otherwise unchanged.
Determinacy principle:
- Dispatch decides only where the command words are determined by the text alone, and selects nothing everywhere else. It may fail to determine an environment for a command string whose programs are obvious to a reader; it must never determine one from a construct it cannot read.
- Selecting nothing is
E-TYPE-COMMAND-NOENV(7.7), so an incomplete analysis costs an explicit environment specification, never a command running where the author did not intend.
Input:
- The procedure operates on the command string before interpolation, after the lexical normalization of 6.6 (leading and trailing whitespace removed, continuation lines joined).
- Each interpolation
#{...}, and each raw hole#!{...}, is one opaque character, belonging to no other class. A hole may expand to any text, so no analysis may look through one.
Regions and segmentation:
- Quoted regions. A
'opens a region ending at the next'; a"opens a region ending at the next unescaped". A character preceded by\is literal. Nothing inside a quoted region is a separator or opens a region. - Nested regions. A command substitution
$(to its matching), a backquoted substitution`to the next`, and a grouping(to its matching)each open a nested region. Its content is segmented by these same rules and contributes its own command words; in the enclosing text the region occupies part of one word. - Separators. Outside quoted and nested regions,
&&,||,|,;,&, and a newline are separators, matched longest-first. The text between two separators, before the first, or after the last, is a segment. - Redirection operators. An
&that immediately follows>or<, or immediately precedes>, and a|that immediately follows>, belong to a redirection operator (>&,<&,&>,>|, as in2>&1) and are not separators: they are ordinary characters of the word they stand in. Nothing else of a redirection is given meaning. - If a quoted or nested region is not closed before the end of the string, the command string is not analysable: dispatch selects nothing, and the diagnostic must state that the string could not be segmented rather than that no command was declared. Such a string is usually already
E-TYPE-COMMAND-SYNTAX(6.6), which is reported first; dispatch reaches it only where the shell syntax check does not, as with a quotation character in a comment.
Words and command words:
- A word is a maximal run of characters containing no whitespace that lies outside a quoted or nested region. Whitespace inside
'...',"...",$(...)or`...`does not divide a word, and an interpolation hole never divides one. - Within a segment, leading whitespace is skipped; then zero or more assignment words are skipped, an assignment word being a word whose text before its first
=is non-empty and matches[A-Za-z_] [A-Za-z0-9_]*; the next word, if any, is that segment's command word. A segment with no such word contributes nothing.
Candidates:
- A command word is a candidate when it contains no quotation character, no
\, no interpolation hole, and no nested region, and contains no/. A path such as/usr/bin/npmis therefore never a candidate: matching is on the word as written and never on a basename. - No further restriction is placed on its text. Command words are declared as string literals (Chapter 5), so candidacy need not anticipate what a program may be called.
- A candidate matches when its text is identical to a command word of the module — one it declares or one it imports (Chapter 5). Matching is exact; no normalization is applied. A word matching none is neutral: it neither selects nor prevents selection.
Selection:
- Let
Dbe the sequence of the command declarations of the matched candidates, in order of occurrence. For an imported word, its declaration is the one in the module that declared it. - If
Dis empty, dispatch selects nothing. - Selection compares the source of each declaration's environment, which is determined from the text alone: a literal environment expression — all of its arguments literals, or lists and tables of literals — is its own source, whether written in the declaration or reached through top-level bindings, and two such are compared by structural equality (8.8); otherwise the source is the last top-level value binding reached from the declaration, if any; and otherwise it is the declaration itself.
- If every element of
Dhas the same source as the first, dispatch selects the environment of the first. That environment is evaluated when the command runs (Chapter 5); what dispatch decides statically is which declaration supplies it. - Otherwise it is a static error (
E-TYPE-COMMAND-CONFLICT), whose diagnostic must name at least two of the conflicting command words with their environments. - Selection is unanimity, not majority: the number of times an environment is named plays no part, and order plays none either. Two declarations naming the same literal environment, or the same binding, do not conflict. Two declarations whose environments are separate calls do, even when the calls would return equal values, because that is known only by running them; listing both words in one declaration, or declaring both on one binding, says what the text could not.
#localparticipates on equal terms. A command string invoking both a program registered on#localand a program registered on a container therefore conflicts, and the author states the environment explicitly. A command string is one process in one environment, so a string claiming both must say which it means.
Undeterminable command words:
- A command word containing an interpolation hole cannot be resolved before execution. It is not a candidate and contributes nothing; if no other candidate matches, the expression is
E-TYPE-COMMAND-NOENV, and the diagnostic must state that the command word is not statically determinable. - Dispatch is never attempted at run time on the interpolated string. An environment not determined statically is neither enumerable (11.4) nor pinnable (10.3).
Examples, given command { "go" } on #golang:1.25, command { "node", "npm", "npx" } on #node:20.20.2-alpine3.23, command { "python", "pip" } on #python:3.12.14-alpine3.24, and command { "ls" } on #local:
| command string | command words | selection |
|---|---|---|
go test -v ./... | go | #golang:1.25 |
go test ./... 2>&1 | tee log | go, tee | #golang:1.25 |
cd web && npm ci && npm test | cd, npm, npm | the Node image |
pip install -r req.txt && python -m unittest | pip, python | the Python image |
FOO=1 PATH=/x npm ci | npm | the Node image |
MSG="hello world" npm ci | npm | the Node image |
npm ci --prefix $(pwd)/web | npm, pwd | the Node image |
ls dist | ls | #local |
`which go` test | none (the first word contains a nested region) | nothing |
sudo go build | sudo | nothing |
env FOO=1 go test | env | nothing |
#{bin} -chdir=infra init | none (undeterminable) | nothing |
ls dist && npm publish | ls, npm | E-TYPE-COMMAND-CONFLICT |
10.10 Container Engine
A container environment runs on a container engine: the store that holds the images of 10.3 and runs the containers of 10.5.
Selection:
- The engines an implementation drives are listed below. The process environment variable
LASK_CONTAINER_ENGINEselects one by name (LASK_CONTAINER_ENGINE=podman), in any case. - When
LASK_CONTAINER_ENGINEis unset or empty, the engine is the first ofdocker,podmanandnerdctlwhose client is found onPATH, anddockerwhen none is. - A value that names no engine is not a static error. A program that runs nothing in a container runs; anything that needs the engine fails with
E-IO-ENV-RESOLVE, whose diagnostic quotes the value and lists the engines. - The selection is a property of the machine, not of the program: a project file does not name an engine, and the same program and lock file run on any of them. A pinned digest names the same image on every engine (10.3).
Engines:
- This implementation drives an engine through its Docker-compatible client, run as a subprocess:
docker(Docker Engine),podman(Podman, rootful or rootless), andnerdctl(containerd, with BuildKit for recipes). The client is found onPATHunder the engine's name. - Each operation of 10.3 to 10.5 means the same on every engine. Where the clients differ, the implementation absorbs the difference (10.8):
- Whether the engine can be reached is asked of the engine, not of the client:
docker versionfor Docker, and the engine's information for the others. - A registry reference is read as Docker reads it, a name with no registry being on Docker Hub. Podman resolves such a name through its own configuration, which may name other registries or none, so it is pulled by its Docker Hub name.
- A failure of the engine itself — the container never ran — is
E-IO-ENV-RESOLVE, and never taken for the exit code of the command (8.7). Docker and Podman report it with exit code 125; nerdctl with exit code 1 and a fatal log line, which is what tells it apart. - The variables of the
envoption are given to the client by name, as 10.2 requires, except those the client reads itself (10.2), which differ by engine.
- Whether the engine can be reached is asked of the engine, not of the client:
- A client that runs the engine elsewhere and does not pass its own environment on — such as a nerdctl that runs inside a virtual machine on macOS — cannot receive a variable by name, and a value of
envgiven that way does not reach the container. Such a client is outside what this implementation supports forenv.
11. CLI Specification
This chapter defines the external contract of the CLI.
11.1 Subcommands
The CLI must provide the following subcommands. Its help lists them in three groups, in this order.
Run tasks:
run: executes the specified function. The evaluation result is not output to stdout (11.3).eval: executes the specified function and outputs the evaluation result to stdout. The syntax is identical torun(11.2).cmd: invokes a declared command (Chapter 5) in its declared environment (11.8).repl: provides an execution environment for interactively evaluating expressions and functions (11.9).
Set up the project:
sync: fetches and verifies the external dependencies, materializes the container images the program requires, and writes the lock file, reporting its progress and listing what it resolved (11.7).deps: lists, graphs, adds and removes external dependencies (11.5).envs: lists the execution environments used by tasks, what the lock file resolves them to, what requires them, and whether each image is present, and which programs tasks run in each (11.4).secrets: lists the secret references in the environment and checks that the stores they name can be read from (11.10).
Develop:
check: returns static validation results.fmt: formats the project's.laskfiles in a single style, or checks that they are formatted (11.11).serve: provides editor integration (Language Server Protocol) functionality.completion: prints the shell completion script.version: prints the version.
Basic invocation syntax:
lask <subcommand> [options]Common options:
--module <path>: specifies the target module for execution. When unspecified,main.laskin the execution directory is used.--format <text|json>: specifies the display format of CLI responses.--trace-id <id>: specifies the execution trace identifier.--no-color: disables decorative colors in the output.--help/-h: displays usage. Forrun/eval, when a function name is given, the help of that function is displayed (11.6).
Minimal syntax per subcommand:
lask serve [--stdio | --tcp --port <n>]
lask check [--module <path>]
lask fmt [--module <path>] [--check] [<path> ...]
lask fmt --stdin [--check]
lask run [--module <path>] <function> [args ...]
lask eval [--module <path>] <function> [args ...]
lask run [--module <path>] [<function>] --help
lask eval [--module <path>] [<function>] --help
lask repl [--module <path>]
lask envs list [--module <path>] [<function>]
lask envs commands [--module <path>] [--verbose] [<function>]
lask sync [--module <path>] [--frozen] [--prune]
lask deps list [--module <path>]
lask deps graph [--module <path>] [<name>] [--depth <n>]
lask deps add <name> (--git <url> [--rev <rev>] | --url <url>) [--module <path>]
lask deps rm <name> [--module <path>]
lask cmd [--module <path>] <command> [args ...]
lask cmd --help [--module <path>]
lask secrets list [--module <path>] [<function>]
lask secrets check [--module <path>] [<function>] [--read]Policy on environment specification:
- Specification of the execution environment is performed only via in-language environment expressions (6.7). No option (
--envetc.) is provided for specifying or overriding the execution environment from the CLI. - Consequently, all execution environment specifications pass static validation (7.5, 7.7), and invalid heads or options never reach runtime environment resolution (10.4).
- To switch the execution environment per invocation, receive it via a function parameter of type
Environmentand pass it through to the command. Its image is whichever head the caller wrote (6.7): a CLI argument can supply a run option to such a function, never an image. - There is no default execution environment (10.1).
cmd(11.8) is no exception: it runs a declared command in that command's declared environment, and provides no option for choosing another.
11.2 Function Invocation
- The CLI calls functions directly. The function to call is specified as the first positional argument of
run/eval(no--functionoption is provided). - Function arguments are given as positional arguments and as keyword arguments in option form following common CLI conventions.
- The interpretation and decoding mode of arguments can be specified.
Function invocation syntax:
lask run [--module <path>] [--arg-decode <mode>] [lask options ...] <function> [args ...]
lask eval [--module <path>] [--arg-decode <mode>] [lask options ...] <function> [args ...]- The invocation syntax of
runandevalis identical. The rules of this section (function-name mapping, keyword arguments, binding, decoding) apply equally to both. - When
--moduleis unspecified,main.laskis the target. Therefore the minimal form islask run <function> [args ...]/lask eval <function> [args ...]. - Options of
laskitself (--module,--arg-decode,--format, etc.) must be placed before the function name. All tokens after the function name are interpreted as arguments to the function (a boundary rule to prevent collisions betweenlaskoptions and function keyword arguments). - The sole exception to this boundary rule is
--help(11.6). When--helpappears after the function name as a standalone token, it is intercepted by the CLI and the help of the function is displayed instead of the function being called. -his not intercepted after the function name, because it would collide with the short form of a single-character keyword parameter.-his accepted only before the function name.- For
cmd, no token after the command name is intercepted,--helpincluded: those tokens are the invoked program's own arguments (11.8). The help of the subcommand is obtained withlask cmd --help, with no command name. --after the function name ends the scan for--helpand for keyword arguments: every following token is passed to the function verbatim as a positional argument, including one that begins with-.--help=<value>is likewise not intercepted and binds to the parameterhelpin the ordinary way.
Function-name and parameter-name mapping rules:
- All occurrences of
-in function names and keyword argument names are replaced with_before name resolution (allowing kebab-case invocation). Example:lask run show-versionresolves the top-level functionshow_version, and--out-dir distbinds to the parameterout_dir. - Matching is performed with the name after replacement; if there is no match, it is an undefined reference error.
- Since identifiers cannot contain
-(3.2), this mapping never maps multiple candidates to the same name, and resolution is always unique. - camelCase names (
buildAndTestetc.) are not subject to the mapping and are specified with their names as is.
Keyword argument forms:
- A keyword argument for parameter
pis given as--p <value>or--p=<value>. - If the parameter name is a single character, the short form
-p <value>may also be used. Since no identifier begins with a digit (3.2), a token such as-1is never a short form: it is a positional argument (a negative number underautoorjsondecoding). - Example: for
retry(cmd: String, --n: Number = 3),lask run retry "make test" -n 5corresponds to the function callretry("make test", n = 5).
Flags:
- A keyword parameter of type
BoolorBool | Nullis a flag. Written without a value (--dry-run, or-vfor a single-character name), it bindstrue, whatever the decoding mode. - A flag takes the token after it as its value only when that token is exactly
trueorfalse, so--dry-run trueand--dry-run falsekeep their meaning. Any other token after a flag is read as if the flag were not there:lask run tag --all v1bindsall = trueand the positional argumentv1. --flag=<value>gives the value in the token itself, as for any keyword argument, and is the form to use fornull.- A positional argument that is the text
trueorfalsecannot directly follow a flag; it is written before the flag, or after--.
Binding rules:
- Resolve the target module (
--module, defaultmain.lask) and obtain the function specified by the first positional argument, among the public symbols of that module: those it declares, and those it re-exports under the name it publishes them by (Chapter 5). A re-exported function is invoked as the declaration it names, with its parameter list intact. A declaration markedinternalis not reachable. - Bind the positional arguments after the function name, from left to right, to the positional parameters in declaration order. Positional arguments exceeding the number of positional parameters are collected into the variadic parameter (variadic collection in 7.5).
- Bind keyword arguments to parameters with matching names. Unknown names and duplicate bindings are pre-execution errors (corresponding to
E-TYPE-KEYWORDin 7.5). - Keyword parameters that were not bound are completed with the default values from the function definition (default-value completion in 7.5). A required keyword parameter (6.1) that was not bound is a pre-execution error (
missing keyword argument: '--target'). - If the number of positional arguments does not match the number of positional parameters (excluding collection by a variadic parameter), it is a pre-execution error.
Argument decoding modes:
--arg-decode text: treats all arguments asString.--arg-decode json: interprets each argument as a JSON value.--arg-decode auto: JSON if interpretable as JSON, otherwiseString.- The default when unspecified is
auto. - Decoding applies to the values of both positional and keyword arguments.
Type conformance rules:
- After binding, each argument must satisfy the call conformance of 7.5.
- In
automode, when ambiguous,Stringtakes precedence. - A parameter whose type is a union is bound when the decoded value conforms to one of its members (4.4); no member is preferred over another, and the decoding mode alone decides what the token becomes. So for a parameter of type
Number | Null,--n 8080binds aNumberand--n nullbindsNullunderautoorjson, while undertextboth areStringand neither conforms. - Decoding failure or type mismatch must be reported as an error before function evaluation begins.
- A function that declares type parameters (4.2) is invoked with each type parameter instantiated as follows, and its arguments are decoded and checked against the resulting types.
- An unbounded one is instantiated at
Any. This is sound because it is opaque within the body (4.4): whatever the CLI hands over, the body can only pass it along. - One with a type bound
Bis instantiated atB, the widest type it admits. - One with a named bound is decoded at
Any, then instantiated from the types of the decoded values, as a call instantiates it from the types of its arguments (4.4). Each value in a position of the parameter's type contributes the type a literal of it would have (4.3). The values must agree on one type, and that type must satisfy the bound; otherwise it is a usage error (E-CLI-USAGE) before evaluation begins. A body may compare, sort or interpolate such a value, so it cannot be handed one outside its bound.
- An unbounded one is instantiated at
- Functions with positional parameters of type
EnvironmentorRunnableare excluded from direct CLI invocation (since no decoding mode can construct such a value, this is a pre-execution error). Keyword parameters of these types are completed with their default values, but values cannot be supplied from the CLI. To select the environment externally, receive aStringetc. and choose inside the function among environments written there (case (target) { "amd64" -> #alpine:3.20(platform = "linux/amd64") ... }); every image stays written as a head (6.7). A run option may be taken from such a value directly, and given torunnable(15.5).
Confirmation:
- When the project file asks for confirmation before the function the CLI calls (Chapter 5), the CLI obtains it after the arguments are bound and before anything is evaluated or stdin is read. A refused confirmation therefore leaves nothing done.
- Only the called function's entry is consulted. Functions it calls are not asked about: a prompt in the middle of a run would come after earlier steps had taken effect.
- A
whencondition is judged on the bound arguments. A keyword parameter the command line leaves out has its default when that default is a literal. Any other default is known only once the run has started, so a condition on it is taken to hold. - When stdin and stderr are both terminals, the CLI writes to stderr the function, the arguments that matched
when, and the phrase, and reads one line from stdin. The run proceeds only if the line, with surrounding white space removed, equals the phrase. --confirm, alaskoption placed before the function name, approves the confirmation without asking. It takes no value.- Without a terminal and without
--confirm, the run is refused. - The environment variable
LASK_CONFIRMmay be set totty, where runs without a terminal (an agent's, for instance) must not confirm:--confirmis then a usage error (E-CLI-USAGE), and only a typed confirmation counts. Any other non-empty value is a usage error. - A refused confirmation is
E-CLI-NOT-CONFIRMED, with exit code4. Its message names the function and the expected phrase.
Difference between run and eval:
- The only difference between
runandevalis the handling of the evaluation result.evaloutputs the evaluation result to stdout, andrundoes not (11.3). - The semantics of binding, evaluation, failure propagation, and exit codes are identical for both.
evalis intended for passing return values via shell pipes to other commands or anotherlaskinvocation, composing tasks ad hoc. For one-off task execution, the output of return values is noise, sorunis normally used.
11.3 Input/Output Contract
The CLI's input/output follows the contract of Chapter 9 and must satisfy the following.
Standard input contract:
- stdin is always read as a UTF-8 string and bound to
stdinas aStringvalue (9.2–9.4). No option to select a decoding mode (--stdin-decodeetc.) is provided. - Structuring such as line splitting or JSON interpretation is performed inside the function by applying
split/from_jsonetc. run/evalaccept stdin.repluses stdin for interactive input, and therefore does not provide thestdinreference variable (9.2, 9.3).check/servemay accept stdin, but the meaning must be defined in the subcommand specification.cmdconnects stdin to the invoked program rather than binding it as a value (11.8). It evaluates no function, so thestdinreference variable of 9.3 does not arise.
Standard output contract:
--stdout-encode <text|json|pretty-json>may be specified.- The default when unspecified is
json. textis human-readable display,jsonis machine-readable display, andpretty-jsonis formatted JSON display.evaloutputs the evaluation result to stdout with the specified encoding.rundoes not output the evaluation result to stdout.runmust not output anything to stdout.cmdproduces no evaluation result. Its stdout is the invoked program's stdout, passed through unchanged and never relayed (11.8), so the rule for evaluation results does not apply to it and the output oflask cmdcan be piped.- The one exception is help display (11.6). When
--helpis given, no function is evaluated, so the contract for evaluation results does not apply andrunwrites the help to stdout. checkoutputs diagnostic results to stdout.- For both
run/eval, the output destination for execution logs and diagnostics is stderr (9.6, Chapter 12).
Standard error contract:
- stderr is the output destination for logs (9.6). During execution of
run/eval, the command execution log (12.3) is output to stderr in real time. - On execution failure, the error code, a summary, and, as necessary, position information are output to stderr.
- When
--format jsonis specified, stderr output uses JSON Lines as the canonical form (12.2). - Secrets must not be output in plaintext to stderr.
Exit code contract:
0: success- Uncaught failure: the
codeof theErrorvalue (8.10). Command failure passes through that command's exit code; other runtime errors default to2; external I/O errors (including image materialization failure and environment resolution failure) default to3;failpasses through the specified value. Ifcodeis not an integer in 1–255, it is normalized to1. 1: syntax or static validation error (detected before evaluation; noErrorvalue is generated)4: CLI usage error (invalid option, missing argument, etc.), including a refused confirmation (E-CLI-NOT-CONFIRMED, 11.2)
Overlap of exit codes:
- If a command fails with exit code
1or4, it cannot be distinguished from a syntax error or CLI usage error by the process exit code alone. Exit codes are primary information for recovery and script integration; accurate discrimination of the kind is performed with the machine-readable diagnostics on stderr (error codes; Chapter 14).
Representative examples:
# Type check only (main.lask is the default target)
lask check
# Function execution (local; JSON is interpreted via from_json(stdin) inside main.lask)
echo '{"name":"alice"}' | lask run greet
# Execution with keyword arguments (in main.lask: greet(name: String, --prefix: String = "hello"))
lask run greet alice --prefix hi
# Variadic arguments (in main.lask: sum(...xs: Array<Number>))
lask run sum 1 2 3
# Execution on a fixed image (in ci.lask: build() = $[#alpine:3.20] ...)
lask run --module ci.lask build
# Execution in a container built from a recipe (in ops.lask:
# provision() = $[#./infra/Dockerfile] ...)
lask run --module ops.lask provision
# The environments provision uses, and whether each image is present
lask envs list provision11.4 Environment List (envs)
envs list enumerates the execution environments used by tasks and reports, for each, what the lock file resolves it to, what requires it, and whether its image is on the container engine.
Syntax:
lask envs list [--module <path>] [<function>]Enumeration rules:
- If no function is specified, the environments reachable from the module are enumerated (static scan of environment heads, 6.7): those in its declarations, keyword parameter defaults included, and in the environment of every command word the module has, declared or imported, through every declaration they reference in any module (Chapter 5). A declaration of an imported module that nothing reachable references is not scanned.
- If
<function>is specified, the enumeration is limited to the environments used on the call graph reachable from that function. Function-name mapping follows 11.2. - Reachability is an over-approximation. A superset of the environments that may be used must be reported; under-reporting is not permitted. Functions passed around as function values are included conservatively.
- What is enumerated is the heads: every image is named by one (6.7), so every environment is enumerated concretely. Run options change no image, and add no entry.
Columns:
ENVIRONMENT: the head, in the notation of 6.7 (#local,#alpine:3.20,#./infra/Dockerfile). A registry reference used with aplatformlists the platforms it is used with,nativefor the daemon's own. The content-hash directory of a cached dependency in a path is shown as….KIND:local,registryorrecipe.PINNED: what the lock resolves it to: the digest of a registry reference, the content-addressed tag of a recipe, shown to its first twelve hexadecimal digits (sha256:be7c8de0160a,lask/b61fd44bd7eb).—when there is nothing to resolve or the lock does not pin it yet.REQUIRED BY: where each head naming it is written: the module — a file of the project by its path, a dependency by its name — and the declaration, or the command word of the entry module whose environment it is;(default --<name>)when the head is the default of a keyword parameter (10.3). A head that dispatch copies into a command string (10.9) is shown where it is written. Two are shown, and how many more.STATUS:okforlocal; for adockerenvironmentpresent,missing (lask sync),not pinned (lask sync), or?when the daemon cannot be reached.
Rules:
- The container engine is asked once whether it can be reached, and then whether each pinned image is present. Nothing is pulled or built, and no command runs in any environment.
- When the daemon cannot be reached, a warning on stderr says so and why, and every
dockerstatus is?. envs listreports; it does not fail. A missing or unpinned image, or a daemon that cannot be reached, is a status, and the exit code is0.- After the table, one line counts the environments by kind and by status.
Output and exit codes:
- The table is on stdout. With
--format jsonit is an array with one object per environment:environment,kind,pinned(in full, ornull),required_by(every place, in full) andstatus. - Exit codes:
0when the environments were listed;1for static errors (invalid module, invalid lock file);4for CLI usage errors (nonexistent function name, etc.) (following the classification in 11.3).
Execution example:
$ lask envs list
ENVIRONMENT KIND PINNED REQUIRED BY STATUS
#local local — main.lask: deploy ok
#golang:1.22 registry sha256:6f3c8a1e09bd main.lask: build present
#node:24.21.0-alpine3.24 registry sha256:ebfe2f904627 tools: node (default --image) missing (lask sync)
#./infra/Dockerfile recipe lask/71bc04d9e2aa main.lask: provision present
4 environments (1 local, 2 registry, 1 recipe): 1 ok, 2 present, 1 missing; 3 pinned in lask.lock.jsonenvs commands enumerates the command executions reachable from the module and reports, for each execution environment, the programs they run.
Syntax:
lask envs commands [--module <path>] [--verbose] [<function>]Enumeration rules:
- What is enumerated is every command execution expression (6.6), and every call of
run(15.5), in the declarations reachable by the rules ofenvs list; with<function>, on the call graph reachable from that function. Reachability over-approximates in the same way. - A command word declared on an environment (Chapter 5) that no reachable command runs is not enumerated: it is listed by
lask cmd --help(11.8). Nor are the built-in functions that take an environment without running a command (15.11). - Nothing is evaluated, no command runs, and the container engine is not contacted.
Environment rules:
- The environments a command may run in are determined statically from its environment expression — for a command whose environment dispatch selected (10.9), the environment of the command declaration that supplied it — and over-approximate, as enumeration does.
- Every head the expression reaches is an environment the command may run in: a head written in it; what the expression of a top-level value binding it references reaches; and every head reachable from a function it references. An entry of a top-level map or record named by a literal key reaches only what that entry does, and a call of a top-level function does not reach the default of a keyword parameter the call supplies.
- A parameter whose type can hold an environment —
Environment,Runnable,Any, or a type containing one of them — makes a command whose environment expression can take its value dynamic: what a caller passes is not enumerated. A local binding takes what its expression reaches. The default of a keyword parameter is still reached, and its head is listed. - A dynamic command is listed under
(dynamic)in addition to the heads it reaches. - The session a
with_containerbody receives as its parameter (15.5) reaches what the call's first argument reaches, so a command run with it is listed under the environment the session is opened on.
Program rules:
- The programs of a command are read from its command string as written, before interpolation, by the segmentation of 10.9: the command word of each segment, nested regions included, in order of occurrence.
- Unlike dispatch, every command word is a program, candidate or not. A path is shown as written (
tools/report.sh), and so is a word containing a hole (#{bin}/lask). - A shell reserved word in command position is not a program. When the command word is
!,{,},if,then,elif,else,fi,while,until,do,doneoresac, it is skipped, together with the assignment words after it, and the next word is the command word; a redirection in command position (> out,2>&1) is skipped in the same way, with its target; a segment whose command word isfor,case,selectorincontributes nothing. These rules apply only to this listing, and change nothing in dispatch, which gives no meaning to a reserved word, nor to a redirection in command position. - A command string that cannot be segmented has the one program
(unanalysable). A command line passed torunis a value, not written text, and has the one program(computed).
Columns:
ENVIRONMENT: the head, in the notation ofenvs list, or(dynamic). Environments are listed#localfirst, then registry references and recipes in order of their notation, then(dynamic).PROGRAM: one row for each program run in the environment, in order of its text.USED BY: the declarations whose commands run it, in the notation ofenvs list, two shown and how many more. A declaration whose command may run in more than one environment, or is dynamic, is followed by its environment expression as written in parentheses (main.lask: test (envs[env])).
Rules (--verbose):
- With
--verbose, each environment is followed by one line for each command that may run in it: where it is written (<module>:<line>), the declaration it is in, the command string as written, with its continuation lines joined, and, when it is not the head of the environment it is listed under, its environment expression as written, orcommand "<word>"for a command whose environment dispatch selected.
Output and exit codes:
- The table is on stdout, followed by one line counting the environments, the programs and the commands.
- With
--format jsonthe output is the same whether or not--verboseis given: an array with one object per environment:environment,kind(local,registry,recipeordynamic),programs(an array ofprogramandused_by, every place in full) andcommands(an array ofcommand,programs,module,line,declarationandvia, the environment expression,nullwhen it is the head of the environment). - Exit codes: as for
envs list.
Execution example:
$ lask envs commands
ENVIRONMENT PROGRAM USED BY
#local rm main.lask: clean
#alpine:3.20 make main.lask: pick (envs[env])
#debian:13 make main.lask: pick (envs[env])
#golang:1.22 go main.lask: build, main.lask: test
#node:24.21.0-alpine3.24 cd main.lask: test_web
#node:24.21.0-alpine3.24 npm main.lask: test_web
(dynamic) cat main.lask: show
5 environments and (dynamic): 6 programs in 6 commands
$ lask envs commands --verbose pick
#alpine:3.20
main.lask:9 pick make ← envs[env]
#debian:13
main.lask:9 pick make ← envs[env]
2 environments: 1 program in 1 command11.5 Dependency Management (deps)
deps lists, graphs, edits and removes the external dependencies declared in the project file (Chapter 5). Fetching the declared dependencies and writing the lock file is sync (11.7).
Syntax:
lask deps list [--module <path>]
lask deps graph [--module <path>] [<name>] [--depth <n>]
lask deps add <name> (--git <url> [--rev <rev>] | --url <url>) [--module <path>]
lask deps rm <name> [--module <path>]Which dependencies are used:
- A direct dependency is used when a
.laskfile of the project imports it:import ... from "<name>"(named or as a namespace, a deep import included),import command { ... } from "<name>", or a re-export from it (Chapter 5). - Every
.laskfile under the project directory counts, not only the modules the entry module reaches, so that a module run with--modulekeeps what it imports. Hidden directories (the module cache among them),node_modules, and a subdirectory with alask.jsonof its own (another project) are not searched. A file that does not parse is searched forfrom "<name>"as text. - A transitive dependency is used when the dependency that declares it is.
Rules (list):
listreports every dependency, direct and transitive (by its lock path,parent>child), with what declares it, what the lock records, and one status. It reads the project file, the lock file, and the cache; it reaches no network.- Columns:
NAME,SOURCE(git <repository>orurl <url>),REQUESTED(what the declaring project file requests),LOCKED(what the lock records as requested),REV(the pinned commit, to seven digits),HASH(the content hash, to twelve),STATUS. - The status is the first of the following that holds:
not locked (lask sync): declared, with no lock entry.not in lask.json (lask sync drops it)(not declared by its parentfor a transitive one): a lock entry nothing declares.unknown: its parent is not cached (lask sync): a transitive dependency whose parent's tree, which declares it, is not in the cache.stale: <reason> (lask sync): the lock entry disagrees with the declaration: another repository or URL, or another requested reference (locked v0.3.0, declared v0.4.0). This is exactly the disagreement for which reading the program isE-MODULE-LOCK-STALE(Chapter 5), and for whichsyncpins the entry afresh. Nothing else makes an entry stale: a newer revision upstream is not known without the network, and a tag moved upstream is found bysync(E-MODULE-REV-MOVED).unused (lask sync --prune): a direct dependency no.laskfile of the project imports.not cached (lask sync): the cache does not hold the content the lock pins.ok.
listreports; it does not fail on a status. After the table, one line counts the dependencies, direct and transitive, and their statuses.
Rules (graph):
graphshows the dependency graph the lock file records, as a tree from the entry module, each dependency with its source, requested reference and pinned commit.- With
<name>, only the paths that reach a dependency of that name are shown, and it is marked. A name the lock does not hold is a usage error. - With
--depth <n>, onlynlevels below the entry module are shown.
Rules (add):
addresolves the specified source, computes its content hash, verifies the policy, records the entry under<name>inlask.json(creating the file if it does not exist), records the resolution inlask.lock.json, and places the verified source in the cache.<name>must conform tolower_id(3.2). If an entry with the same name already exists, it is replaced.- Before recording,
addpresents the images the dependency uses. In an interactive session it requires confirmation. - The recorded hash follows the trust-on-first-use model: the content trusted at recording time is pinned, and any subsequent change to the published source is detected by
sync(11.7). --gitaccepts--rev(a tag or a full commit SHA); when omitted, the repository's default branch head is resolved once and pinned.--urlaccepts an archive or a single.laskfile (Chapter 5).
Rules (rm):
rmremoves a direct dependency: its entry inlask.json, its lock entry and those of the dependencies under it, the cache entries no remaining lock entry uses, and the lock entries of the images the program no longer requires (10.3; the program is read again to find them). It reaches no network.- A dependency a
.laskfile of the project still imports is not removed: the imports are listed, and it is a usage error. A transitive dependency is not removed either: the dependency that declares it is named. - Images on the container engine are not removed, since the engine may serve other projects; the ones no longer required are named.
- The cache is cleaned only when it is the project's own (
.lask/deps), not oneLASK_CACHE_DIRnames, which other projects may share.
Output and exit codes:
listandgraphwrite a table and a tree to stdout; with--format json, an array and a nested object.0: the operation completed successfully.1: the project file or the lock file is malformed or violates the schema (Chapter 5); or a policy violation was detected before fetching.3: a fetch or verification failure inadd(network failure,E-MODULE-HASH-MISMATCH,E-MODULE-REV-MOVED).4: CLI usage error, includingrmof a dependency still imported or not declared, andgraphof a name the lock does not hold.
11.6 Help Display (--help)
--help displays the usage of the functions defined in a module. Its purpose is to make the tasks a repository provides discoverable without reading the module source.
Syntax:
lask run [--module <path>] [lask options ...] <function> --help
lask eval [--module <path>] [lask options ...] <function> --help
lask run [--module <path>] [lask options ...] --help
lask eval [--module <path>] [lask options ...] --help- Only
runandevalprovide function help. As in 11.2, the two are identical in this respect. - When a function name is given, the help of that function is displayed. When it is omitted, the CLI option help is displayed, followed by the list of callable functions in the target module.
- Function-name mapping follows 11.2, so
lask run show-version --helpdisplays the help ofshow_version. - A function that declares type parameters (4.2) is displayed with them and their bounds, as
first<T>orlargest<T: orderable>, where its declaration is being described: the heading of its help, the list of a module's functions, and an editor hover. Where the name is instead something to type — the usage line, a completion candidate, the function argument oflask run— it appears plain, since the type parameters are not written at a use site (4.2). A function value carries no type parameters, having been instantiated at the reference position (4.4), soFunctionRef(13.2) never shows one. - The interception rules for
--help(standalone token,-h,--,--help=<value>) are defined in 11.2. - If the function declares a keyword parameter named
help,--helpstill displays the help. That parameter can be supplied only as--help=<value>. An implementation may report the advisory diagnosticW-CLI-PARAM-SHADOWED(14.2). - Help display takes precedence over argument binding. Binding errors (11.2) are not reported when
--helpis present:lask run build --out_dir 1 --helpdisplays the help and exits0.
Sources of help information:
- The documentation comment of the declaration (3.1) supplies the summary, the description, and the
@param/@return/@exampletexts. - The signature (the kind of each parameter, its type, and its default value) is taken from the result of static verification (7.5).
- Default values are displayed as their source text and must not be evaluated, since evaluating them may have side effects.
- The default value of a parameter marked
!!(6.10) is displayed as<secret>and must never be displayed in plaintext (12.8). - The environments used are enumerated by the reachability analysis of 11.4. A recipe-form environment is shown by its Dockerfile path. When the enumeration yields only
#local, the section is omitted. - When the project file asks for confirmation before the function (Chapter 5), a
Confirmation:section states the condition (requires confirmation when env is prod or production), and JSON output carries it asconfirm(nullotherwise). An editor hover shows the same.
Rendering rules:
- Function and parameter names are displayed in the kebab-case form of 11.2, the form the command line is written in: the parameter
out_diris shown as--out-dir. The name as declared remains accepted (11.2). This holds wherever the CLI writes a name for a person to read or type — the help, the function list, usage errors and their suggestions (14.3), and completion candidates, except that a candidate for a word already typed with_keeps that form. JSON output carries names as declared (3.2), since a tool reads it against the declarations. - In the usage line, a positional parameter is rendered
<name>, a variadic parameter[<name> ...], and a keyword parameter[--name <Type>]. A required keyword parameter (6.1) is rendered without the brackets,--name <Type>, and a flag (11.2) without the type,[--name]. - In the parameter list, a required keyword parameter is marked
(required)after its type, in place of a default value. - A section with no content is omitted (no summary, no parameters,
#localonly, and so on). - The function list holds the module's functions — declarations written as functions, and bindings whose value is a function — whether it declares them or re-exports them (Chapter 5). Plain value bindings are callable (11.2) and have help of their own, but are not listed.
- A re-exported function is listed and described under the name the module publishes, from the declaration it names: the documentation comment, parameters and location are those of the file that declares it.
- The function list omits declarations marked
@hidden(3.1), and shows only the name for functions that have no summary.
Text output (--format text, the default). For the declaration:
// Build the project and publish the artifact.
//
// The build runs in the `builder` environment; the artifact is
// uploaded only when `--publish` is set.
//
// @param target Build target name.
// @param out_dir Directory the artifact is written to.
// @param publish Whether to upload the artifact.
// @return The path of the produced artifact.
// @example lask run build release --out-dir dist --publish true
build(target: String, --out_dir: String = "dist", --publish: Bool = false): String =
$[#./infra/builder.Dockerfile] make #{target} OUT=#{out_dir}lask run --module ci.lask build --help outputs:
build - Build the project and publish the artifact.
Usage:
lask run build <target> [--out-dir <String>] [--publish]
The build runs in the `builder` environment; the artifact is
uploaded only when `--publish` is set.
Parameters:
target : String
Build target name.
--out-dir : String = "dist"
Directory the artifact is written to.
--publish : Bool = false
Whether to upload the artifact.
Returns:
String
The path of the produced artifact.
Environments:
builder docker golang:1.22
Examples:
lask run build release --out-dir dist --publish true
Defined at ci.lask:12lask run --help appends the function list to the CLI option help:
Functions in main.lask:
unittest Run the unit test suite.
doctest Run the doctests.
test Run the unit tests and the doctests in parallel.
install Install the lask binary.
uninstallJSON output (--format json) is the structured form of the same information, intended for reuse by editor integration.
{
"kind": "function-help",
"module": "ci.lask",
"function": "build",
"location": {"line": 12, "column": 1},
"summary": "Build the project and publish the artifact.",
"description": "The build runs in the `builder` environment; the artifact is\nuploaded only when `--publish` is set.",
"params": [
{"name": "target", "kind": "positional", "type": "String",
"default": null, "required": false, "secret": false, "doc": "Build target name."},
{"name": "out_dir", "kind": "keyword", "type": "String",
"default": "\"dist\"", "required": false, "secret": false, "doc": "Directory the artifact is written to."},
{"name": "publish", "kind": "keyword", "type": "Bool",
"default": "false", "required": false, "secret": false, "doc": "Whether to upload the artifact."}
],
"returns": {"type": "String", "doc": "The path of the produced artifact."},
"environments": [{"name": "builder", "kind": "docker", "target": "golang:1.22"}],
"examples": ["lask run build release --out-dir dist --publish true"]
}kindisfunction-helpfor the help of a single function, andfunction-listfor the function list. The latter holdsfunctions, an array of objects withname,summary, andsignature.kindfor a parameter is one ofpositional/variadic/keyword.defaultisnullfor a parameter that has no default value, and"<secret>"for a parameter marked!!.requiredistruefor a required keyword parameter (6.1), andfalsefor every other parameter. A positional parameter is always required by its kind, so the field speaks only of keyword parameters.- A field whose information is unavailable is
null; a section that is omitted in text output is an empty array.
Output destination and exit codes:
- Help is written to stdout (the exception in 11.3). Diagnostics are written to stderr as usual.
0: the help was displayed.4: the specified name is not a top-level declaration of the module. The usage and, where possible, candidate names (14.3) are written to stderr asE-CLI-USAGE. A declaration that is not a function is not an error: a plain value binding is callable with no arguments (11.2), so its help is displayed with an empty parameter list.1: the target module could not be parsed. Diagnostics are written to stderr and no help is displayed.- Static errors (14.4) do not prevent help display. The help is rendered from the information that is available, a type that could not be determined is displayed as
?, the diagnostics are written to stderr, and the exit code is0. Help is most needed while a module is broken, so this is a deliberate exception to the rule of 14.4 that static errors stoprun/eval; no function is evaluated in this case either. - For
lask run --help/lask eval --helpwithout a function name, the CLI option help must always be displayed with exit code0. If the target module cannot be loaded, the function list is omitted.
11.7 Synchronization (sync)
sync brings the dependency cache, the container images and the lock file in line with the program: it fetches and verifies the external dependencies declared in the project file (Chapter 5), materializes the container images the program requires (10.3), and records both in lask.lock.json.
Syntax:
lask sync [--module <path>] [--frozen] [--prune]Rules (modules):
syncresolves every declared dependency — including transitive dependencies — fetches it into the per-project cache (Chapter 5), and verifies it against its hash.- A reference recorded as
revis resolved to a full commit SHA once and pinned thereafter. A subsequent resolution of the same reference to a different SHA isE-MODULE-REV-MOVED. - Fetched sources are stored content-addressed; re-running
syncwith an unchanged project file performs no network access for already-verified entries. - A hash verification failure is reported as
E-MODULE-HASH-MISMATCHand the entry must not be placed in the cache. - An entry whose lock record is stale (11.5) is pinned afresh: its new content is not held to the hash recorded for the old source.
- A direct dependency no
.laskfile of the project imports (11.5) is reported with a warning. With--pruneit is removed first, asdeps rmremoves one (11.5): fromlask.json, from the lock with the dependencies under it, and from the project's own cache.
Rules (images):
syncmaterializes every image the program requires (10.3): every head reachable from the entry module, in the root project or in any dependency. Registry references are pulled and verified against their locked digest, and recipes are built, each for itsplatformif one is given.- An image named only in a dependency's declarations that nothing reachable references is neither materialized nor recorded in the lock. The lock entry of an image nothing references any more is dropped.
- When any image fails to materialize, the images recorded in the lock are left as they were: no pin is added, replaced or dropped, and the summary reports the lock file unchanged (or updated for its modules only). A failed
synctherefore never costs a pin that still worked. - Images are content-addressed, so an image whose registry digest or recipe hash is unchanged is not re-materialized.
- The images are materialized after the modules, since reading the program needs its modules; a program that does not compile keeps its modules synced and reports the static error. A project that declares no dependency still has its images materialized.
syncexecutes no command inside the resulting containers, except the probe below.
Rules (command probes):
- After the images are materialized,
syncprobes the command words of every declaration whose words are probed (Chapter 5), in every module the program loads, whose image materialized: in a container of the image, run with the run options its environment evaluated to and the base directory mounted as for a command (10.5),/bin/shreports for each word whethercommand -vfinds it. Words declared on one image with different run options are probed in a container per set of options, and a word any of them finds is present. The result is recorded in the image's lock entry ascommands. - An image whose lock entry is unchanged and already records every word declared on it is not probed again.
- A word the image does not provide is reported as
E-IO-COMMAND-MISSING, naming the word, the image and the declaration, andsyncexits with3. The images and their pins are still recorded, and so is the word as"missing", so thatrunrefuses the commands that use it (Chapter 5) until the declaration or the image is corrected. - An image whose
/bin/shcannot be started provides none of its words: lask runs every command string through it (10.2), so each is recorded as"missing", and the diagnostic says so. - A probe that cannot be run for another reason, such as an engine that stops answering, records nothing for that image and is reported as the failure it is (
E-IO-ENV-RESOLVE). - Probe results are part of the lock: under
--frozen, a probe that would changecommandsisE-MODULE-LOCK-STALE.
General rules:
- Apart from
deps add, which fetches the source it records (11.5),syncis the only subcommand permitted to access the network or to start an image build. All other subcommands resolve modules and images exclusively from the cache and the lock file, except thatreplmay pull an image (10.3). synctakes no selector: it always brings the whole program in line.--frozenfails instead of writing the lock file when resolution would change it — its modules or its images (E-MODULE-LOCK-STALE). It is the intended form for continuous integration: it asserts that the committed lock is what resolution produces. With--prune, a dependency it would remove is such a change, and nothing is written.
Progress and the summary:
- While it runs,
syncreports on stderr what it is doing: aModulesand anImages (<n>)heading, and for each module and image, when it starts (resolving, checking, pulling, building) and how it ended (fetched,present,pulled,built,pruned, orfailedwith its error code), with the time it took. - On a terminal, the item in progress is a single line redrawn in place, with the latest detail: for a pull, how many of the image's layers are complete; for a build, the step that is running. Elsewhere each item is a timestamped line when it starts and when it ends, with a line every 30 seconds in between, so that a long pull is seen to progress. With
--format jsoneach of these is a JSON Lines event. - When it ends, whether it succeeded or not,
syncwrites to stdout aModulestable —NAME,SOURCE,REQUESTED,REV,HASH,STATUS,TIME, as indeps list(11.5) — and anImagestable —ENVIRONMENT,KIND,PINNED,REQUIRED BY,STATUS,TIME, as inenvs list(11.4) — then one line counting both by status, the total time, and whetherlask.jsonandlask.lock.jsonwere updated. With--format jsonthe summary is one object,modulesandimages. - The diagnostic of each failure follows on stderr in full, and the images the lock no longer records are named as kept on the container engine.
Exit codes:
0: the operation completed successfully.1: the project file or the lock file is missing (while dependencies are declared), malformed, stale under--frozen, or violates the schema (Chapter 5); a policy violation was detected before fetching; or the program has a static error.3: a fetch, verification, or image materialization failure, or a declared command missing from its image (network failure,E-MODULE-HASH-MISMATCH,E-MODULE-REV-MOVED,E-IO-IMAGE-DIGEST,E-IO-IMAGE-MISSING,E-IO-COMMAND-MISSING).4: CLI usage error.
11.8 Command Invocation (cmd)
cmd invokes a command declared by the target module (Chapter 5) in that command's declared environment, without evaluating a function.
Syntax:
lask cmd [--module <path>] [lask options ...] <command> [args ...]
lask cmd --help [--module <path>]Name resolution rules:
<command>is resolved against the command words the target module has — those it declares,internalones included, and those it imports (Chapter 5) — by exact text. The-to_mapping of 11.2 must not be applied: this is a program name and not a function name, solask cmd docker-compose upresolves the command worddocker-compose.- A name that is not a command word is a CLI usage error (
E-CLI-USAGE), and the diagnostic must namelask cmd --help. It may carry candidate command words (14.3). - No option for supplying or overriding the execution environment is provided (11.1, 10.4).
Argument boundary rules:
- Options of
laskitself must be placed before<command>, following the boundary rule of 11.2. - All tokens after
<command>are passed to the program verbatim, with no interception. Unlike 11.2 there is no exception for--help:lask cmd go --helpmust reach the program (11.6). - Arguments are not decoded.
--arg-decode(11.2) does not apply, and an argument is the string the invoking shell produced.
Execution rules:
- The program is executed with
<command>as its first argument vector entry and the following tokens as the remaining entries, each preserved as one argument. No shell is created and no shell metacharacter is interpreted. This differs from a command execution expression (6.6), which passes one string to a shell inside the environment, and it is whycmdcannot lose or re-split an argument containing whitespace. - The command's environment is evaluated before the program starts. It can reach no effect (Chapter 5), so the evaluation runs nothing and reads nothing from the standard input, which is the program's. A failure to evaluate it — a process variable it reads being unset, for instance — is an error before the program starts.
- Working directory and environment variable rules are those of 10.5 and 10.6.
cmdmust not pull or build an image (10.3). An absent image isE-IO-IMAGE-MISSING, and the diagnostic must name the subcommand that materializes it.cmdrequires a lock file covering every declared dependency, on the same terms asrun/eval/envs(Chapter 5).
Stream rules:
- The program's stdin and stdout are always those of the
cmdprocess.cmdproduces no evaluation result, so its stdout belongs to the program (11.3), andlask cmdcan therefore be piped exactly as the program it runs can. - When stdin, stdout and stderr are all terminals, stderr is attached to the program as well, and a terminal is allocated in the
dockerprofile so that prompts, pagers and progress rendering behave as they do when the program is run directly. - Otherwise the program's standard error is relayed as the command execution log (12.3). The relay is applied to standard error only: relaying standard output would either duplicate it or withhold it from the pipe, and interactivity and pipeability both take precedence over the completeness of the relay.
- The start line and the exit line of 12.3 are emitted in every case, so every invocation is recorded with its environment, its argument vector and its exit status.
INTandTERMare forwarded to the program, and the container must be removed on exit, including on signal.
Exit code rules:
- The program's exit code becomes the process exit code as is, consistent with the exit code contract of 11.3 and subject to the overlap of exit codes stated there.
- Failures before the program starts follow the existing classification:
1for a static error,3for an external I/O error,4for a CLI usage error.
Rules (--help):
lask cmd --help(or-h), with no command name, displays the CLI option help followed by the list of command words, in the waylask run --helplists functions (11.6).- The list holds every command word the target module has, declared or imported: the name, its resolved environment, and whether the image is present on the target daemon. Structured output is available with
--format json. - An environment that cannot be evaluated is reported with the failure in place of its target, and the listing continues.
- The list is produced with no network access and no build, on the same terms as
envs(11.4). - The option help is displayed whatever state the target module is in. When the module does not compile, its diagnostics go to stderr, the list is omitted, and the exit code is
0.
Execution example:
$ lask cmd --help
Usage: lask cmd [--module PATH] [--format text|json] [--trace-id ID] [--no-color]
[-h|--help] [COMMAND] [ARGS...]
...
Commands in main.lask:
go docker golang:1.25
npm docker node:20.20.2-alpine3.23
terraform docker hashicorp/terraform:1.16.2 MISSING (lask sync)
mv local local
$ lask cmd go test ./... > report.txt
2026-09-12T12:56:40.217Z [#golang:1.25:1] $ go test ./...
2026-09-12T12:56:41.002Z [#golang:1.25:1] 2| warning: unused variable
2026-09-12T12:56:41.002Z [#golang:1.25:1] exit 011.9 Interactive Session (repl)
repl reads the target module once at start and then accepts one input per line. Each input is a session command, a top-level declaration, or an expression.
Input rules:
- An input starting with
:is a session command. No declaration or expression starts with:, so an unknown command is reported as such (unknown command ':foo') and is never parsed as code. - A declaration is added to the session if the session still compiles with it; otherwise its diagnostics are reported and the session is unchanged.
- An expression is evaluated against the session and its result printed. It is not kept in the session.
- The target module's text is the one read at start or by the last successful
:reload. Imported modules and the lock file are read from disk on every input, so a change to them takes effect without a reload.
Session commands:
| Command | Meaning |
|---|---|
:reload, :r | Read the target module again (below). |
:quit, :q, :exit | End the session. End of input does the same. |
A session command takes no arguments; one given arguments is an error and changes nothing.
Reload rules (:reload):
- The target module is read again and compiled on its own, without the session's declarations.
- If it compiles, the declarations typed at the prompt are re-applied to it in the order they were typed, and the result replaces the session.
- Re-applying a declaration compiles it and does not evaluate it, and expressions are not replayed. A reload therefore runs no function and no command.
- A reload is all or nothing. If the target module does not exist, does not compile, or no longer compiles with one of the declarations typed at the prompt (because the module now defines the same name, or no longer defines a name the declaration uses), the diagnostics are reported, naming that declaration in the last case, and the session is kept as it was before the reload, module text and declarations alike. A reload never discards what was typed at the prompt.
Execution example:
$ lask repl
lask> double(n: Number): Number = n * 2
lask> double(size())
6
(main.lask is edited: size() now returns 5)
lask> :r
Ok, reloaded main.lask. (1 REPL declaration re-applied)
lask> double(size())
1011.10 Secret References (secrets)
secrets reports on the secret references (9.8) in the process environment: which variables hold one, and whether the stores they name can be reached, logged in to and read from. It is meant to be run before a task, on the machine and with the environment the task will run with.
lask secrets list [--module <path>] [<function>]
lask secrets check [--module <path>] [<function>] [--read]Scope:
- Without a function, every variable of the process environment is considered, and no module is read:
secretsalso works outside a project. - With a function, only the variables it can read are considered: the names given as string literals to
get_env,find_envandget_env_orin the declarations it reaches, and in the environments of the module's command declarations. Reachability over-approximates as in 11.4. When a name is computed, or one of these functions is passed as a value, any variable may be read, and every variable is considered. - Neither subcommand prints a secret value or a credential.
list:
- Reports each variable in scope that holds a reference: its name, the scheme, the reference, and whether the reference is well formed and its store enabled and configured. It makes no request to any store.
- It exits
0: it reports, and a problem it finds is reported as a status, not as a failure.
check goes through each store in stages and stops a store at its first failed stage; the references to a store that stopped are reported as skipped:
| Stage | What is established | Vault |
|---|---|---|
config | The scheme is enabled in LASK_SECRETS and the store is configured (9.8). | VAULT_ADDR, credentials |
reachable | The store answers and can serve reads. Reported as skipped for a store that has no such notion. | sys/health |
auth | The credentials log in. The report names the method, the lifetime and the policies of the session. | token lookup, AppRole login |
| each reference | The credentials may read the path and, where the store keeps versions, the version exists. The value is not read. | sys/capabilities-self, the key/value metadata |
- The stores checked are those enabled in
LASK_SECRETSand those the references in scope name, so that a store can be checked before any variable refers to it. - A reference is not read by default, because reading can have effects: a store that issues a credential on every read issues one.
--readalso reads each reference and checks that its field exists; a credential issued by that read under a lease is revoked at once. - It exits
0when every stage and every reference passed, and3otherwise (14.8). - With
--format json, the report is one JSON document on stdout:{"stores": [...], "malformed": [...]}, where each store has itsscheme,target,stagesandreferences, and each stage or reference has astatusofok,NGorskipped, amessage, and the errorcodewhen it failed.
Example:
$ lask secrets check deploy
vault https://vault.example.com
config ok enabled in LASK_SECRETS, https://vault.example.com
reachable ok active, v1.20.4
auth ok approle, ttl 1h, policies [default, deploy]
AWS_ACCESS_KEY_ID vault://aws/creds/deploy#access_key ok readable (value not read)
SSL_KEY vault://secret/cert?version=3#key NG E-IO-SECRET-NOT-FOUND: vault: 'secret/cert?version=3': version 3 does not exist or was deleted (latest 2)11.11 Formatting (fmt)
fmt rewrites .lask source in a single style without changing the program it denotes.
lask fmt [--module <path>] [--check] [<path> ...]
lask fmt --stdin [--check]Targets:
- Each
<path>is a file, or a directory whose.laskfiles are formatted, recursively. Without one, the target is the directory of the entry module (--module, defaultmain.laskin the execution directory): the whole project. - A directory whose name begins with
.is not entered — among them.lask, where external dependencies are cached (11.5) — and neither is a symbolic link to a directory. - With
--stdin, standard input is formatted to standard output instead, and no<path>may be given.
What changes, and what does not:
Spacing is normalized: one space around binary operators,
=and->, after,and:, and none inside brackets, except the braces of a record literal, an import list or a command word list, which take one space inside unless they are empty ({ "a": 1 },import { a, b } from "lib",command { "go" } on #golang:1.25).Indentation is two spaces per level.
else,catchandfinallyfollow the closing brace of the block before them on its line.Each statement, declaration and
casearm is on a line of its own. A block or acasewhose content the source writes on the line of its braces keeps it there.Line breaks inside an expression stay where the source has them, and only there: between the items of a bracketed list, before its closing bracket, and around a binary operator,
?,:,=and->. No line is broken or joined for its length. A continuation line that begins with an operator is indented one level, unless the expression starts a line of its own, whose indentation it then keeps:laskgreet(name: String): String = " hello, #{name} " |> trimThe
->ofcasearms that the source writes in one column stay in one column.Comments are kept, each with the declaration, statement,
casearm or list item it stands above or beside, so a documentation comment (3.1) stays directly above its declaration. A comment that ends a line keeps the space the source puts before it, so a column of such comments stays aligned. An item with a comment in any other place is kept exactly as written.Runs of blank lines become one blank line; a blank line is never added.
Literals keep their spelling (raw or interpreted strings, escapes, number digits). Command expressions (6.6) are kept exactly as written, since their text is the command. Parentheses are kept.
Formatting is idempotent: formatting a formatted file changes nothing.
Rules:
- A file with a syntax error is left as it is, and its diagnostics are reported as
checkreports them (14.3). The other files are still formatted. - Before a file is written, the result is checked to lex to the same tokens and comments, and to parse to the same module, as the source. A result that fails this check is a defect of the implementation: the file is left as it is and the failure is reported.
- The files that were changed (with
--check, the files that are not formatted) are listed on stdout, one per line, or as a JSON array of paths with--format json. - It exits
0when nothing failed; with--check, only when every file is formatted already. A file that does not parse, or is not formatted under--check, exits1; a file that cannot be read or written exits3(14.8); a<path>that does not exist is a CLI usage error and exits4.
Example:
$ lask fmt --check
lib/deploy.lask
main.lask
$ lask fmt
lib/deploy.lask
main.lask
$ lask fmt --check
$12. Observability
This chapter defines the means of observing execution.
12.1 Observation Targets and Design Principles
Observability is the requirement to provide, in a consistent format, the information needed for understanding execution, failure analysis, and operational auditing.
Implementations must treat at least the following as observation targets.
- Function call boundaries (start, end, failure)
- Environment execution boundaries (
#local/ image and recipe heads) - External command execution (start, exit code, output summary)
- Type validation and runtime diagnostics
Design principles:
- Low intrusiveness: the presence or absence of observation must not change the language semantics (Chapters 7, 8).
- Correlatable: logs and events belonging to the same execution must be traceable.
- Machine-readable: at least one structured format (JSON etc.) must be provided.
- Secret protection: sensitive information must not be exposed by default.
12.2 Execution Log
The execution log is time-series diagnostic information and must contain at least the following fields.
timestamp: time (UTC recommended)level:debug/info/warn/errortraceId: the identifier defined in 12.5message: human-readable messagecontext: arbitrary auxiliary information (function name, module name, environment kind, etc.)
Output rules:
infoand below are classified as operational logs, andwarn/erroras diagnostic logs.error-level logs must be cross-referenceable with the corresponding failure event (12.6).- The line format of the default text display is implementation-defined. However, each line contains
levelandmessage, and the command execution log (12.3) follows the text-format provisions. - When
--format jsonis specified, logs, command execution logs, error diagnostics, and execution events output to stderr must be output as JSON Lines (one object per line) (canonical form). The line kind can be discriminated by the presence of fields (kind= execution event (13.3),code+stage= diagnostics (14.3), which are advisories when they also carryseverity: "warning"(14.2) and errors otherwise,stream/event= command execution log (12.3)).
12.3 Command Execution Log
During execution of run (8.7), the implementation must relay the child process's standard output and standard error to its own stderr in real time as the command execution log. The relay is performed in parallel with the capture into CommandResult (8.7) and must not affect the evaluation result (value semantics).
Relay rules:
- The relay is performed sequentially line by line (line buffering). Output must not be accumulated until the child process exits.
- The targets are
run/evalexecution andcmd(11.8). The relayed content is identical regardless of which sugar ($,$1,$2,$*) was used for execution. - For
cmd(11.8), the start line and the exit line are always emitted. Standard output is never relayed, because it is the program's own output channel and is passed through unchanged; standard error is relayed as2|lines unless all three streams are terminals, in which case it is attached to the terminal directly, a prefixed line-buffered relay being unable to carry a prompt, a pager, or a progress display. - For each command execution, a 1-based sequence number (execution number) that is unique within the top-level execution is assigned. Execution numbers are not duplicated even under concurrent execution (
async). - Each log line contains a timestamp and an environment summary with the execution number. The executed command is recorded only on the start line; subsequent lines are correlated by the execution number (the command is not recorded on every line).
- The secret masking of 12.8 applies to relayed content. Output volume limits and suppression measures (rate limiting, suppression options, etc.) may be provided as implementation-defined.
Text format (default):
Each line is output with the following structure.
<timestamp> [<environment summary>:<execution number>] <kind> <content>timestamp: UTC ISO 8601 format.- Environment summary: follows the notation of environment expressions (6.7).
#local,#<image>(registry reference),#./<path>(recipe). - Execution number: the sequence number assigned by the relay rules.
- Kind:
$(start line; records the executed command string as the content),1|(child process's standard output),2|(child process's standard error),exit <code>(exit, with exit code),killed(the command was abandoned and stopped before it exited; 8.7).1and2are file descriptor numbers (the same scheme as the stream specifiers in 6.6). - The start line (
$) must be emitted for every command execution, and so must exactly one of theexitline and thekilledline. The command string on the start line may be truncated to an implementation-defined length.
Output example:
2026-07-23T10:15:04.123Z [#golang:1.22:1] $ go build ./...
2026-07-23T10:15:04.320Z [#golang:1.22:1] 1| compiling module...
2026-07-23T10:15:04.751Z [#golang:1.22:1] 2| warning: unused variable
2026-07-23T10:15:05.002Z [#golang:1.22:1] exit 0JSON format (with --format json):
- Each line of the command execution log is output as one object of the JSON Lines of 12.2.
- Required fields for all lines:
timestamp,level,traceId,exec(execution number). - Start line: in addition to
event: "start",command(the executed command string) andenv(the metadata representation of 13.1; named environments includename) are required. - Relay lines:
stream(1or2) andmessage(the line's content) are required.commandandenvmay be omitted (correlated with the start line viaexec). - Exit line:
event: "exit"andcode(exit code) are required. - Killed line:
event: "killed"is required. It has nocode: the command did not exit on its own.
Level rules:
- Relay lines (
1|,2|) and the start line ($) areinfo. exitisinfowhen the exit code is0, andwarnwhen non-zero.killediswarn.
12.4 Stack Traces
Implementations must be able to generate and record stack traces at all times upon execution failure. Generated stack traces must satisfy the following.
- Contain at least function names, call order, and, where possible, source positions (line/column or span).
- When crossing asynchronous boundaries (
spawn/await), the parent-child relationship must be traceable. - Failures involving command execution (
run) may include the environment kind and a summary of the executed command.
Omission rules:
- Internal runtime frames may be omitted in user-facing display.
- However, even with omission, the frames necessary to identify the cause of the failure must be retained.
12.5 Trace Identifier
The trace identifier traceId is an identifier for correlating observation information of the same execution unit.
Generation and propagation rules:
- When the CLI's
--trace-idis specified, that value is adopted. - When unspecified, the implementation automatically generates an identifier unlikely to collide.
- Logs, events, and errors belonging to the same top-level execution share the same
traceId.
Consistency rule:
- Multiple top-level executions must not be mixed under a single
traceId.
12.6 Execution Event Output
Execution events are represented in the ExecutionEvent format of 13.3.
Event emission rules:
- Emit a
CallEventwhen a function call starts. - Emit a
ReturnEventon normal termination. - Emit a
FailEventon failure termination.
Minimum guarantees:
- Each
CallEventmust ultimately correspond to exactly oneReturnEventorFailEvent. - Event order must preserve causal order for the same function execution.
- When command execution (
run) is involved, theArgumentsSummarymay include an environment kind summary. - Even when a failure is caught by
try/catch(6.9), theFailEventcorresponding to the failed function call is emitted. Catching must not cancel the event.
12.7 In-flight Diagnostics
Implementations may provide progress diagnostics for long-running executions or interactive modes.
Targets:
serve: analysis start/completion, error counts, re-analysis triggersrepl: evaluation start/end, summary of the most recent errorrun/eval: execution stages (resolution, type validation, evaluation, external command execution)
Rules:
- In-flight diagnostics must not corrupt the primary result (the stdout output of
eval). - Human-oriented progress display must be output to stderr or a dedicated channel.
- When
--format jsonis specified, progress diagnostics may be output as structured events.
12.8 Protection of Sensitive Information and Retention Policy
Because sensitive information may be mixed into observation data, implementations must satisfy the following.
- Values the program has declared sensitive — credentials, private keys, and tokens, marked as secret bindings (6.10) — are masked or removed.
- Command arguments are summarized as necessary, avoiding full-text output.
- Even on authentication failure at an external boundary, the secrets themselves must not be output.
Masking mechanism:
- A value is in scope for masking once it is registered as sensitive. Registration happens for every value bound to a
!!-marked name, and for every explicitmark_secretcall (6.10). - Registration is opt-in and is never inferred from a value's origin. In particular, reading a value with
get_env(15.9) does not register it: most environment variables (a region, a log level) are not sensitive, and masking them would degrade the usefulness of logs without improving safety. A credential read from the environment is marked at its binding, as in--secret_key!!: String = get_env("..."). - The one exception is a value resolved from a secret reference (9.8), which is registered when it is resolved. It is not inferred: the environment declared the value secret by holding a reference to it rather than the value.
- Registration is by value, not by name, type, or source: masking is applied by finding registered values as exact substrings of observation data and replacing each match with a fixed mask, regardless of which command, binding, or interpolation the value passed through to get there.
- A registered value shorter than 8 characters is matched only where it stands as a word of its own: an occurrence preceded or followed by a letter, a digit or
_is left alone. A value that short found inside another word is not the secret appearing, and masking it there would hide unrelated output while the letters around the mask gave the secret away (none***istentforx). A value of 8 characters or more is matched wherever it appears. - Masking is applied to everything the implementation writes to stderr, at the point it is produced: the execution log (12.2), the command execution log (12.3: the logged command text, the environment metadata, and relayed output lines), execution events (12.6: arguments, results and error values), and error diagnostics (14.3: the message, the error value and the stack trace), including a failure reported in an advisory. The message of a failed command execution is its stderr (6.6), so a secret a command prints reaches the final diagnostic as well as the relayed lines.
- Masking is never applied to a
CommandResult'sstdout/stderr(8.7) or to any other value as observed by the running program — an error value caught bytry/catch(6.9) included — nor toeval's primary result on stdout (9.5/11.3): those must remain faithful to the real value. - A registered value that contains a line break is also registered line by line, since output is relayed one line at a time (12.3) and the whole value never appears within one line. A line shorter than 8 characters is not registered on its own: it is a fragment rather than a credential, and masking it would hide unrelated output.
- Because matching is by exact substring, a value that has been transformed (case conversion, replacement, slicing, encoding, hashing, ...) since registration is no longer found and is not masked. This is a known limitation, not a defect: full protection would require tracking sensitive values through transformations (taint tracking), which this specification does not require of implementations.
Retention policy:
- The implementation shall define default retention periods and size limits.
- Rotation must not break event consistency (12.6).
- Where audit requirements exist, a tamper-evident storage format may be adopted.
13. Serialization Conventions
This chapter defines the external representation conventions for data values, function values, and events.
13.1 Data Values
This section defines the canonical serialization format for data values.
Format profiles:
json: machine-readable canonical JSON formatpretty-json: a pretty-printed format carrying the same information asjsontext: human-readable format (implementation-defined). However, it must not cause information loss
For json / pretty-json, the following mapping rules must be satisfied.
Number: maps to a JSON number.String: maps to a JSON string.Bool: maps to a JSON boolean.Null: maps to JSON null.Void: not directly serializable (4.5). When output is required, convert to the tagged metadata{"$type":"Void"}(treated equivalently toFunctionRefin 13.2).Environment,Runnable: not directly serializable (4.5). When output is required, convert to the tagged metadata{"$type":"Environment","kind":"<environment kind>","params":{...}}.kindis the environment kind name, andparamsis the normalized parameter set: the image with its image options, and the run options that are set (e.g.,{"$type":"Environment","kind":"docker","params":{"image":"alpine:3.20"}}). A value with at least one run option set is tagged"$type":"Runnable"instead; a runnable with none is its environment (8.8). A session (15.5) is converted as the environment it was started on. Parameters containing secret information are masked according to the rules in Chapter 12.Array<T>: maps to a JSON array preserving element order.Map<T>: maps to a JSON object with string keys.Record<...>: maps to a JSON object with field names as keys. An optional field (4.2) that is absent contributes no key at all; one that is present contributes its key, including when its value is null. This is where the difference between an absent key and a null value is written down.
Rules for unions:
T1 | T2 | ...maps according to the serialization rules of the member the value actually is.String | Nulltherefore maps to a JSON string or to JSON null, and nothing records which member was chosen.- Deserialization does not reconstruct a union:
from_json/decodeproduceAny(15.8), and moving from there to a union iscast(15.8) like any other type.
Rules for Any:
Anymaps according to the serialization rules of the underlying value.- If the underlying value is
VoidorEnvironment, convert it to the tagged metadata representation; if the underlying value is a function value, convert it to aFunctionRef(13.2).
Round-trip rules for tagged metadata:
from_json/decode(15.8) do not treat the$typefield specially, and read tagged metadata as an ordinary object (RecordorMap).- Therefore, restoring
Void,Environment, or function values from their serialized results (round-trip conversion) is not guaranteed.
Numeric rules:
NaN,+Infinity, and-Infinitycannot be represented directly in canonical JSON, so by default they are a serialization error.- If an implementation provides an extended representation, it must be explicitly enabled via a CLI option.
Key and ordering rules:
Record<...>may be output in definition order.Map<T>does not guarantee ordering.- Duplicate identical keys are not permitted.
Character encoding:
- Strings must be input and output as UTF-8.
- Control characters and quotation marks must be escaped according to JSON rules.
13.2 Function Values
Function<T1, T2, ..., R> is not directly serializable.
Rules:
- Function bodies (code) and closure environments must not be externalized as-is.
- When a function value needs to be output externally, it must be converted to the reference metadata
FunctionRef.
Minimum fields of FunctionRef:
$type: fixed value"FunctionRef"module: defining module identifiername: function namearity: number of positional parameters (min 7.5)variadic: set totrueif the function has a variadic parameter (6.1). May be omitted if it does not.type: string representation of the function type (e.g.,Function<String, Number>)
Example:
{
"$type": "FunctionRef",
"module": "app/main.lask",
"name": "greet",
"arity": 1,
"type": "Function<String, String>"
}Rules for anonymous functions and closures:
- For function values without a stable reference name,
namemay be an implementation-generated identifier (e.g.,<lambda@L10C5>). - The actual values of closed-over free variables must not be included in
FunctionRefby default.
13.3 Execution Events
ExecutionEvent = CallEvent | ReturnEvent | FailEvent .
CallEvent = "call" EventCommon .
ReturnEvent = "return" EventCommon .
FailEvent = "fail" EventCommon ErrorInfo .
EventCommon = TraceId FunctionRef ArgumentsSummary ResultSummary Timestamp .Execution events follow the emission rules in 12.6 and must include the following fields in json or pretty-json.
kind:call/return/failtraceId: correlation ID for the same executiontimestamp: event timefunction:FunctionRefargs: argument summary (required forcall)result: return value summary (required forreturn)error: error information (required forfail)env: execution environment summary (environment kindkindand normalized parameter summaryparams. For a recipe-form environment (10.2), the Dockerfile path may be included asdockerfile)
Minimum fields of ErrorInfo:
code: error code (Chapter 14)message: human-readable summarydetail: optional details (within a range that can be safely disclosed)
Causal consistency rules:
- For the same function execution, exactly one
returnorfailmust correspond after thecall. timestampshould maintain non-decreasing order within the same trace.- Child events of asynchronous execution share the same
traceId, and parent-child relationship IDs may be attached as needed.
Execution events retain at least the following.
- Traceable identifier
- Target function
- Argument summary
- Return value summary
- Error information on failure
Example:
{
"kind": "fail",
"traceId": "trc-01J8Y2...",
"timestamp": "2026-07-11T12:34:56Z",
"function": {
"$type": "FunctionRef",
"module": "ops/deploy.lask",
"name": "deploy",
"arity": 1,
"type": "Function<String, String>"
},
"args": {"summary": ["prod"]},
"env": {"kind": "docker", "params": {"image": "alpine:3.20@sha256:6f3c..."}},
"error": {
"code": "E-IO-IMAGE-MISSING",
"message": "image not materialized: run 'lask sync'",
"detail": {"timeoutSec": 10}
}
}14. Error System
This chapter defines error classification and the minimum requirements for diagnostic information.
14.1 Error Classification
Errors in this specification are classified into the following 4 categories by the stage at which they occur.
- Syntax error: invalidity detected during lexical analysis and parsing
- Static error: invalidity detected during name resolution, type checking, and module resolution
- Runtime error: invalidity occurring during evaluation (division by zero, non-zero command exit, etc.)
- External I/O error: failure at external boundaries such as files, networks, container images, and environment resolution
Classification rules:
- A single error must have exactly one primary classification.
- When there are compound causes, adopt the classification of the first observed root cause.
14.2 Error Code Conventions
Error codes take the form E-<CATEGORY>-<DETAIL>.
Category list:
SYNTAX: syntax errorNAME: name resolution errorTYPE: type checking errorMODULE: module resolution errorRUNTIME: error during evaluationIO: external I/O errorCLI: CLI usage error
Naming rules:
- Use only uppercase Latin letters and hyphens.
Representative codes:
E-SYNTAX-UNEXPECTED-TOKENE-SYNTAX-CASE-ELSEE-NAME-UNDEFINEDE-NAME-DUPLICATEE-TYPE-MISMATCHE-TYPE-ARITYE-TYPE-CALLE-TYPE-COMMAND-ENVE-TYPE-COMMAND-NOENVE-TYPE-COMMAND-CONFLICTE-TYPE-COMMAND-EFFECTE-TYPE-COMMAND-NAMEE-TYPE-COMMAND-DUPLICATEE-TYPE-COMMAND-SYNTAXE-TYPE-COMMAND-SCHEMAE-TYPE-COMMAND-OPTIONE-TYPE-ENV-CONSTRUCTE-TYPE-ACCESSE-TYPE-FIELD-DUPLICATEE-TYPE-CASE-DUPLICATEE-TYPE-KEYWORDE-TYPE-ILLFORMEDE-TYPE-SECRET-NON-STRINGE-TYPE-BOUNDE-MODULE-CYCLEE-MODULE-UNRESOLVEDE-MODULE-HASH-MISMATCHE-MODULE-CONFIRM-TARGETE-RUNTIME-DIV-BY-ZEROE-RUNTIME-COMMAND-NONZEROE-RUNTIME-ACCESSE-RUNTIME-CASTE-RUNTIME-VALUEE-RUNTIME-REGEXE-RUNTIME-TIMEOUTE-RUNTIME-UNTIL-EXHAUSTEDE-RUNTIME-SESSION-CLOSEDE-IO-STDIN-READE-IO-ENV-RESOLVEE-IO-FSE-IO-DATA-DECODEE-IO-SECRET-PROVIDERE-IO-SECRET-REFE-IO-SECRET-UNREACHABLEE-IO-SECRET-AUTHE-IO-SECRET-NOT-FOUNDE-CLI-USAGEE-CLI-NOT-CONFIRMED
Advisory codes:
- Codes of the form
W-<CATEGORY>-<DETAIL>are advisory diagnostics. They report a probable mistake that is not an error: analysis continues, evaluation is not prevented, and the exit code is unaffected. - Advisory diagnostics follow the output requirements of 14.3 and additionally carry
severity: "warning". A diagnostic without aseverityfield is an error. - Representative codes:
W-DOC-PARAM-UNKNOWN: an@paramin a documentation comment (3.1) names a parameter the declaration does not have.W-CLI-PARAM-SHADOWED: a keyword parameter is namedhelp, so it cannot be supplied as--helpfrom the CLI (11.6).W-REGEX-PATTERN: a regular expression written as a string literal with no interpolation (15.3) is malformed, and the call will fail whenever it is reached.W-ASYNC-UNUSED: a handle is discarded, or bound to a name nothing refers to, so it is never awaited (6.3). Reported statically, withstage: "static".W-COMMAND-OPTION: a command string uses an option or subcommand that a command schema which is not closed does not list (Chapter 5). Reported statically, withstage: "static".W-ASYNC-UNAWAITED: an asynchronous computation was never awaited, so the execution waited for it at its end (6.3, 8.6). Reported at run time, withstage: "runtime", alocationfor theasyncthat started it, and, if it failed, afailureobject carrying itscodeandmessage.
- Reporting advisory diagnostics is optional, with one exception:
W-ASYNC-UNAWAITEDis always reported, because a failure it carries is otherwise invisible.checkandserveare the expected places for the static ones;runandevalreport the run-time ones on stderr.
14.3 Minimum Requirements for Diagnostic Information
An implementation must include at least the following when reporting an error.
code: error codemessage: human-readable messagestage:syntax/static/runtime/io/clitraceId: the identifier of 12.5, when availablelocation: line and column, or span, where possible
Additional requirements related to types and names:
- For type mismatches, include
expectedandactual. - For name resolution failures, candidates or a summary of the search scope may be included. Candidates are given as follows:
- The pool is the names of the same kind in scope where the name was written: for an undefined name, the locals, top-level declarations, imported names, builtins and namespaces; for a type, the type names and aliases; for a namespace member or a named import, the target module's public symbols; for a command word, the module's command words; for a keyword argument, the function's keyword parameters; for a record field, the record's fields; for an environment option, the image and run options. On the command line the pool is the entry module's public functions, its command words (
lask cmd, 11.8), or the function's keyword parameters, each given as the CLI writes it (11.2):cowsay-hello,--dry-run. - Names are compared ignoring case, with
-and_taken as one character. The distance is the edit distance in which inserting, deleting or substituting a character, or transposing two adjacent ones, each count as one edit. - A name is a candidate when its distance from the written name is at most max(1, ⌊length / 3⌋), where length is that of the written name. Candidates are sorted by distance, then by name, and at most three are given.
- In text output, candidates follow the other notes as one more note:
note: did you mean 'hello'?, ordid you mean 'a', 'b' or 'c'?for several. In JSON output they are the arraysuggestions, and the notes are the arraynotes; each is present only when not empty.
- The pool is the names of the same kind in scope where the name was written: for an undefined name, the locals, top-level declarations, imported names, builtins and namespaces; for a type, the type names and aliases; for a namespace member or a named import, the target module's public symbols; for a command word, the module's command words; for a keyword argument, the function's keyword parameters; for a record field, the record's fields; for an environment option, the image and run options. On the command line the pool is the entry module's public functions, its command words (
Additional requirements related to commands:
- The message of a failed command execution is its standard error output (6.6). When that is empty or only whitespace, as for a tool that reports on standard output, the diagnostic's
messagesays instead that the command exited with its code and wrote nothing to standard error, and that its output is in the command execution log (12.3). Only the diagnostic changes: theErrorvalue a program catches keeps the emptymessage. - A syntax error in a module where a command string holds a closing bracket that it never opened carries a note naming that command's line: the command ran to the end of its line (6.6) and took the bracket of an enclosing call with it.
Additional requirements related to reporting several errors:
- Independent errors are all reported; an error that only follows from another is not. A top-level declaration, a type alias, a command declaration and a command import are each checked on their own, and each reports at most its first error.
- A declaration that uses one that failed is still checked when the failed one's annotations give all of its type: a function declaration with its return type and the type of every keyword parameter with a default written (a required one without an annotation is
Any, 6.1), or a value declaration with its type written. Otherwise the declaration that uses it reports nothing. Likewise, a type that refers to a failed type alias, and a command string whose command words include one whose declaration or import failed, report nothing. - A declaration that holds an error of name resolution (7.1-7.3) is not type checked; the declarations that do not are.
- A syntax error is reported for each top-level declaration that has one: parsing resumes at the next token in column 1. A module with a syntax error is not checked further. A lexical error is the only error reported for its module.
- Diagnostics are reported in order of file and position, each once. Text output shows at most 50 errors and then the number left out; JSON output carries every one.
JSON format example:
{
"code": "E-TYPE-MISMATCH",
"message": "argument type mismatch",
"stage": "static",
"traceId": "trc-01J8Y2...",
"location": {"line": 12, "column": 8},
"expected": "Number",
"actual": "String"
}A name resolution failure with a candidate, in text and in JSON:
main.lask:2:10-2:14: E-NAME-UNDEFINED [static]: undefined name: 'helo'
note: did you mean 'hello'?{
"code": "E-NAME-UNDEFINED",
"message": "undefined name: 'helo'",
"stage": "static",
"location": {"line": 2, "column": 10},
"suggestions": ["hello"]
}14.4 Static Errors
Static errors are reported by pre-execution verification (Chapter 7).
Minimum targets:
E-NAME-UNDEFINEDE-NAME-DUPLICATEE-TYPE-MISMATCHE-TYPE-ARITYE-TYPE-CALLE-TYPE-COMMAND-ENVE-TYPE-COMMAND-NOENVE-TYPE-COMMAND-CONFLICTE-TYPE-COMMAND-EFFECTE-TYPE-COMMAND-NAMEE-TYPE-COMMAND-DUPLICATEE-TYPE-COMMAND-SYNTAXE-TYPE-COMMAND-SCHEMAE-TYPE-COMMAND-OPTIONE-TYPE-ENV-CONSTRUCTE-TYPE-ACCESSE-TYPE-FIELD-DUPLICATEE-TYPE-CASE-DUPLICATEE-TYPE-KEYWORDE-TYPE-ILLFORMEDE-TYPE-SECRET-NON-STRINGE-TYPE-BOUNDE-MODULE-CYCLEE-MODULE-UNRESOLVEDE-MODULE-DEEP-IMPORTE-MODULE-LOCK-STALEE-MODULE-CONFIRM-TARGET
Rules:
- If there is one or more static errors, evaluation by
run/evalmust not be started. - When multiple errors are detected, analysis may continue as far as possible so that they are reported together.
14.5 Runtime Errors
Runtime errors occur during evaluation under the dynamic semantics (Chapter 8).
Representative examples:
E-RUNTIME-DIV-BY-ZERO: invalid arithmeticE-RUNTIME-COMMAND-NONZERO: failure caused by a non-zero exit of a command execution expression ($,$1,$2; 6.6)E-RUNTIME-ACCESS: index out of range or missing key (8.9), and the failure of aget_-family built-in asked for something that is not there (15.1, 15.4, 15.9)E-RUNTIME-CAST: failure of the runtime type check ofcast(15.8)E-RUNTIME-VALUE: a built-in function received an argument outside its domain, or was asked for a value its result format cannot represent (15.2, 15.3, 15.4, 15.8, 15.13); orrunnablewas given a run option for alocalenvironment (8.8, 15.5)E-RUNTIME-REGEX: a malformed regular expression pattern (15.3)E-RUNTIME-TIMEOUT: the limit of atimeoutpassed before its body finished (15.7). ItsErrorhascode124(8.10).E-RUNTIME-UNTIL-EXHAUSTED: the delays of anuntilran out before its condition held (15.7). ItsErrorhascode124(8.10).E-RUNTIME-SESSION-CLOSED: a command was run with the session of awith_containerwhose body had returned, or was running in it when it did (15.5).
Rules:
- Runtime errors propagate from the point of occurrence to enclosing expressions (8.10).
- On failure, a
FailEventmust be emitted according to the observation rules of Chapter 12.
14.6 External I/O Errors
External I/O errors occur at boundaries external to the language.
Representative examples:
E-IO-STDIN-READ: stdin read failureE-IO-ENV-RESOLVE: execution environment resolution failure, including a container engine that cannot be reached or cannot run the container (10.4, 10.10)E-IO-IMAGE-MISSING: a required container image has not been materialized (10.3)E-IO-COMMAND-MISSING: a command word declared on a container image names a program the image does not provide, assyncprobed it (Chapter 5, 11.7)E-IO-IMAGE-DIGEST: a pulled image's digest does not match the pinned digest (10.3)E-MODULE-REV-MOVED: a pinned reference now resolves to a different commit (Chapter 5)E-IO-FS: filesystem access failure (the filesystem functions of 15.11)E-IO-DATA-DECODE: failure decoding input data (stdin decoding in 9.4,from_json/decodein 15.8)E-IO-SECRET-PROVIDER,E-IO-SECRET-REF,E-IO-SECRET-UNREACHABLE,E-IO-SECRET-AUTH,E-IO-SECRET-NOT-FOUND: failure resolving a secret reference (9.8)
Rules:
- External I/O errors may be reported with retryability information attached, where possible.
- Confidential information (keys, tokens, passwords) must not be included in
detail.
14.7 Recoverability and Propagation Rules
Recoverability:
recoverable: recoverable by retrying, correcting input, or changing configurationnon-recoverable: a failure for which continuing the process is not appropriate
Default classification:
- Syntax and static errors are
recoverable(assuming source correction) - Runtime errors are implementation-defined, but
non-recoverableby default - External I/O errors are
recoverableornon-recoverabledepending on the cause
Propagation rules:
- Expressions containing
do/ collection functions (map,filter,reduce,for_each) /awaitfollow the failure propagation rules of 8.10. - Unless explicitly caught by
try/catch(6.9), a failure propagates to the top level, and the process exits with thecodeof theErrorvalue (8.10, 11.3). - The
recoverable/non-recoverableclassification is information for diagnostics and operations, and does not affect catchability. Both runtime errors and external I/O errors occurring during evaluation can be caught.
14.8 Correspondence to CLI Exit Codes
The CLI follows the exit code contract of 11.3 and maps error classifications as follows.
- Syntax and static errors ->
1 - Runtime errors -> the
codeof the uncaughtErrorvalue (default2. Command failures pass through the child process's exit code. 8.10) - External I/O errors -> the
codeof the uncaughtErrorvalue (default3) - CLI usage errors ->
4
Rules:
- When multiple classifications exist simultaneously, adopt the exit code of the fatal classification that occurred first.
- With
--format json, an error object corresponding to the exit code must be output to stderr. - Exit code normalization (values outside 1-255 become
1) and the handling of exit code overlap follow 11.3.
15. Built-in Library
This chapter defines the functions, operators, and environment operations provided by the language implementation by default.
15.1 Provision Policy
The built-in library is the set of built-in symbols usable without explicit import.
Policy:
- Referentially transparent pure functions are preferred.
- Functions with external side effects are clearly distinguished by name and contract, and are grouped into sections of their own: command execution (15.5), filesystem access (15.11), diagnostic output (15.12), and nondeterministic generation (15.13). Every other section of this chapter is pure, except for the functions of 15.7 that wait (
retry,retry_if,until,timeout), which pass time and write to the execution log. - Access to the filesystem is never implicit. Every function that reads or writes it takes the target
Environmentas a required positional argument (the last one; 15.11), just asrundoes, so the filesystem a program touches is always the one it names. - There is no overloading: one built-in name has exactly one signature, because a name resolves to a single type scheme (4.4). Where the same operation is wanted for both
StringandArray<T>, the array form carries the_arraysuffix (concat_array,contains_array,index_of_array), and where the same operation is wanted for bothArray<T>andMap<T>, the map case is written by composing withkeys/values/entriesrather than by a second function.
Naming of the absent case:
A built-in that looks something up says in its name what it does when the thing is not there. The four shapes are exhaustive, and a new built-in that can come up empty takes one of them.
| Shape | Contract | Result | Members |
|---|---|---|---|
get_… | Presence is presupposed. Absence is a failure (E-RUNTIME-ACCESS). | T | get, get_env, and by the same reading first, last, m[k] |
find_… | A search. Absence is an ordinary result, not a failure. | T | Null, or -1 where the result is an index | find, find_env, find_index |
…_or | Total. The caller supplies the result for the absent case. | T | get_or, get_env_or |
has_… | Presence alone is asked. | Bool | has_key, has_env |
get_andfind_differ only in what absence means, so the pair is written as two functions rather than one with a flag:get_envis for a variable the task requires, andfind_envfor one it merely accepts.- A
find_function that returns a value reports absence asNull, which is why it needs a union type (4.2). Afind_function that returns a position reports it as-1: a position is aNumber, and-1is a position no array or string has, so the absent case needs no separate type.index_of(15.3) follows the same convention without the prefix, being the string counterpart ofindex_of_array.
Publication rules:
- Symbols of the built-in library are positioned at the lowest level of name resolution (the 5th rank in 7.2). If a user definition declares a symbol with the same name, it may shadow it according to the ordinary name resolution rules.
- However, core function names specified as "must not be overridden" in Chapter 6 cannot be declared or bound (7.2).
Typing rules:
- Type variables appearing in the signatures of this chapter (
T,U, etc.) follow the polymorphism rules of 4.4, which a declaration with type parameters (4.2) follows equally. What remains particular to this chapter is the naming of the absent case above. A condition on a type variable is a bound (4.2), written in the signature as a declaration writes it (sort<T: orderable>: Function<Array<T>, Array<T>>) and checked by the same rules; a built-in has no condition its signature cannot state.
15.2 Numeric Operations
The built-in library provides at least the following functions.
add:Function<Number, Number, Number>sub:Function<Number, Number, Number>mul:Function<Number, Number, Number>div:Function<Number, Number, Number>mod:Function<Number, Number, Number>abs:Function<Number, Number>floor:Function<Number, Number>ceil:Function<Number, Number>round:Function<Number, Number>min:Function<Number, Number, Number>max:Function<Number, Number, Number>sum:Function<Array<Number>, Number>pow:Function<Number, Number, Number>sqrt:Function<Number, Number>clamp:Function<Number, Number, Number, Number>
Semantics:
add/sub/mul/div/modhave meaning equivalent to the arithmetic operators of 6.2.- If the divisor of
divis 0, it isE-RUNTIME-DIV-BY-ZERO. - If the right-hand side of
modis 0, it is alsoE-RUNTIME-DIV-BY-ZERO. min/maxreturn the smaller and the larger of the two arguments respectively, and return the left argument when they are equal.sum(xs)adds the elements from left to right. The sum of an empty array is0.pow(base, exponent)raisesbasetoexponent. The exponent need not be an integer, andpow(0, 0)is1. A result that is not a real number (for example a negative base with a fractional exponent) isE-RUNTIME-VALUE.sqrt(x)for a negativexisE-RUNTIME-VALUE.clamp(value, low, high)returnslowwhenvalue < low,highwhenvalue > high, andvalueotherwise.low > highisE-RUNTIME-VALUE.
15.3 String Operations
The built-in library provides at least the following functions.
length:Function<String, Number>concat:Function<String, String, String>trim:Function<String, String>to_lower:Function<String, String>to_upper:Function<String, String>split:Function<String, String, Array<String>>join:Function<Array<String>, String, String>replace:Function<String, String, String, String>contains:Function<String, String, Bool>starts_with:Function<String, String, Bool>ends_with:Function<String, String, Bool>index_of:Function<String, String, Number>substring:Function<String, Number, Number, String>pad_start:Function<String, Number, String, String>pad_end:Function<String, Number, String, String>repeat:Function<String, Number, String>lines:Function<String, Array<String>>to_string<T: stringifiable>:Function<T, String>to_number:Function<String, Number>regex_test:Function<String, String, Bool>regex_match:Function<String, String, Array<String>>regex_replace:Function<String, String, String, String>
Semantics:
- Strings are treated as UTF-8.
lengthmay count in units of characters, but the implementation must keep the counting rule consistent.index_ofandsubstringcount in the same unit aslength.- The behavior of
split/joinwhen the delimiter string is empty may be implementation-defined. concatconcatenates 2 strings. To concatenate 3 or more, use nested applications ofconcatorjoin.- String concatenation is done with
concat(orjoin).+is exclusive toNumber, and applying it toStringis a type error (6.2). contains(s, needle)is true whenneedleoccurs anywhere ins. An emptyneedleis always true.starts_with(s, prefix)/ends_with(s, suffix)test the ends ofs. An empty argument is always true.index_of(s, needle)returns the zero-based position of the first occurrence ofneedle, or-1when it does not occur. An emptyneedlereturns0. The absent case is a value and not a failure: the result is a position, and-1is a position no string has (15.1). A search that returns the thing found rather than its position reports absence asNullinstead — seefind(15.4).substring(s, start, end)returns the half-open range[start, end). Both endpoints are clamped to[0, length(s)], and anendat or beforestartyields the empty string, sosubstringnever fails. Non-integer endpoints are truncated toward zero.pad_start(s, width, pad)/pad_end(s, width, pad)prepend or append repetitions ofpaduntil the result reacheswidthcharacters, truncating the last repetition as needed. Whenlength(s)is already at leastwidth, orpadis empty,sis returned unchanged.repeat(s, n)concatenatesncopies ofs.nis truncated toward zero, andn <= 0yields the empty string.lines(s)splitsson\nand removes one trailing\rfrom each line, so both LF and CRLF text split identically. A trailing newline does not produce a final empty element, and the empty string yields the empty array. This is the form to use on thestdoutof aCommandResult(6.6).to_string(v)produces the same text that interpolatingvinto a string would (6.6): aStringunchanged, aNumberin the canonical form of 13.1, and aBoolastrueorfalse. A runtime value of any other kind,Nullincluded, isE-RUNTIME-VALUE; useto_json(15.8) for structured values.- The type of
vmust be stringifiable (6.6), which is the bound ofT.to_stringof aString | Nullis therefore a static error (E-TYPE-BOUND), and the union is narrowed withcase(6.4) first.Anyis stringifiable and remains accepted, with the runtime check above as its guard. to_number(s)decodess, ignoring surrounding whitespace, as a number in the JSON number grammar (13.1). Any other text isE-IO-DATA-DECODE. This is the form to use on a number read out of command output, and it replaces going throughfrom_jsonandcast.
Regular expressions:
- The pattern argument of
regex_test/regex_match/regex_replaceis a POSIX extended regular expression (ERE): literal characters,., bracket expressions[...]including the named classes[:digit:]and the like, the anchors^and$, alternation|, groups(...), and the quantifiers*,+,?and{m,n}. In addition, the escapes\d\w\s\D\W\S, which POSIX itself does not have, stand for the classes they conventionally denote;\d\w\smay also appear inside a bracket expression, while their negated forms may not, because a bracket expression has no way to express them. - Backreferences, lookaround and the lazy
?quantifier suffix are deliberately not provided: every one of them requires backtracking. Matching is therefore linear in the length of the input whatever the pattern is. An implementation must not extend the syntax with a construct that makes matching super-linear — a task definition runs unattended, and a pattern that backtracked catastrophically would stall the run rather than report a diagnosable error. - Matching is POSIX leftmost-longest, not leftmost-first: among the matches that start at the earliest position, the longest one is chosen, so
a|abmatchesabin the subjectab. Quantifiers are greedy for the same reason, and a pattern that relies on a lazy quantifier is written with a negated class instead —"([^\"]*)"in place of"(.*?)". regex_test(s, pattern)is true when the pattern matches anywhere ins.regex_match(s, pattern)returns, for the leftmost-longest match, an array whose element0is the whole matched text and whose elementiis the text of thei-th capture group, with a group that did not participate yielding the empty string. When there is no match it returns the empty array, so a caller tests withis_empty(15.4) rather than against a null: a successful match always has at least element0, so the empty array cannot be confused with one, and the result type staysArray<String>.regex_replace(s, pattern, replacement)replaces every non-overlapping match from left to right. Inreplacement,$0to$9stand for the whole match and the capture groups, and$$stands for a literal$. A match of the empty string advances one character before the next attempt.- A pattern is normally written as a raw string (3.3), which performs no escape processing:
regex_match(s, '(\\d+)'). In an interpreted string every backslash of the pattern has to be doubled. - A malformed pattern is
E-RUNTIME-REGEX. When the pattern is a string literal containing no interpolation, an implementation may additionally report it statically as the advisoryW-REGEX-PATTERN(14.2), since the call is then certain to fail.
15.4 Array, Map, and Record Operations
The built-in library provides at least the following functions.
map:Function<Array<T>, Function<T, U>, Array<U>>filter:Function<Array<T>, Function<T, Bool>, Array<T>>reduce:Function<Array<T>, U, Function<U, T, U>, U>for_each:Function<Array<T>, Function<T, U>, Void>append:Function<Array<T>, T, Array<T>>concat_array:Function<Array<T>, Array<T>, Array<T>>get:Function<Map<T>, String, T>has_key:Function<Map<T>, String, Bool>keys:Function<Map<T>, Array<String>>values:Function<Map<T>, Array<T>>size:Function<Array<T>, Number>is_empty:Function<Array<T>, Bool>first:Function<Array<T>, T>last:Function<Array<T>, T>slice:Function<Array<T>, Number, Number, Array<T>>take:Function<Array<T>, Number, Array<T>>drop:Function<Array<T>, Number, Array<T>>reverse:Function<Array<T>, Array<T>>sort<T: orderable>:Function<Array<T>, Array<T>>sort_by<T, U: orderable>:Function<Array<T>, Function<T, U>, Array<T>>contains_array<T: comparable>:Function<Array<T>, T, Bool>index_of_array<T: comparable>:Function<Array<T>, T, Number>find:Function<Array<T>, Function<T, Bool>, T | Null>find_index:Function<Array<T>, Function<T, Bool>, Number>every:Function<Array<T>, Function<T, Bool>, Bool>any:Function<Array<T>, Function<T, Bool>, Bool>flatten:Function<Array<Array<T>>, Array<T>>flat_map:Function<Array<T>, Function<T, Array<U>>, Array<U>>zip:Function<Array<T>, Array<U>, Array<Record<first: T, second: U>>>unique<T: comparable>:Function<Array<T>, Array<T>>range:Function<Number, Number, Array<Number>>enumerate:Function<Array<T>, Array<Record<index: Number, value: T>>>set:Function<Map<T>, String, T, Map<T>>remove:Function<Map<T>, String, Map<T>>merge:Function<Map<T>, Map<T>, Map<T>>get_or:Function<Map<T>, String, T, T>entries:Function<Map<T>, Array<Record<key: String, value: T>>>from_entries:Function<Array<Record<key: String, value: T>>, Map<T>>map_values:Function<Map<T>, Function<T, U>, Map<U>>
Semantics:
map/filter/reduce/for_eachtraverse the input array from left to right (evaluation rules in 8.5).reducerequires an initial value.for_eachdiscards each application result and returnsVoid. It is used for iteration whose purpose is side effects.map,filter,reduce, andfor_eachare core functions that include the normalization targets of the control structures of 6.4, and must not be overridden by user code (subject to the exception provision of 15.1).getresults in a runtime error (E-RUNTIME-ACCESS) when the key is absent. This is the same failure contract as index accessm[k](6.8, 8.9), and it is what theget_prefix means throughout the library (15.1). To tolerate a missing key, useget_or, or check in advance withhas_key.- Every function in this section is pure and returns a new value. No built-in mutates the array, map, or record it is given (8.2).
Size and emptiness:
size(xs)is the number of elements, andis_empty(xs)issize(xs) == 0. Both are array-only: the number of entries in a map issize(keys(m))and its emptinessis_empty(keys(m)), because the built-in library has no overloading (15.1). The number of characters in a string islength(15.3).
Element access:
first(xs)/last(xs)return the first and last element. On an empty array both areE-RUNTIME-ACCESS, the same failure contract asxs[0](8.9).slice(xs, start, end)returns the half-open range[start, end), clamping both endpoints to[0, size(xs)], so it never fails.take(xs, n)isslice(xs, 0, n)anddrop(xs, n)isslice(xs, n, size(xs)); both clampnlikewise and never fail.reverse(xs)returns the elements in the opposite order.
Ordering:
sort(xs)returns the elements in ascending order. The element type is bounded byorderable, so it isNumberorString; any other element type is a static error (E-TYPE-BOUND).sort_by(xs, key)sorts by the value ofkeyapplied once to each element, under the same bound on the key typeU, and is stable: elements with equal keys keep their input order.Stringordering is lexicographic by Unicode code point. This definition is local tosort/sort_by, and does not extend the ordering operators<<=>>=of 6.2, which remainNumber-only.
Searching:
contains_array(xs, v)andindex_of_array(xs, v)compare with the structural equality of==(6.2), so the element type is bounded bycomparable; any other element type is a static error (E-TYPE-BOUND).index_of_arrayreturns-1when the value does not occur.find(xs, p)returns the first element for whichpis true, orNullwhen there is none.pis applied from left to right and no further element is tested once one is true. The result typeT | Nullis a union (4.2), so the caller resolves it withcase(6.4) before using the element:laskfirst_flag(args: Array<String>): String = do { a = find(args, \(x: String) -> starts_with(x, "--")) case (a) { Null -> "" else -> a } }find_index(xs, p)returns the index of the first element for whichpis true, or-1. It reports a position rather than a value, so the absent case is-1and notNull(15.1). Usefindwhen the element is wanted andfind_indexwhen the position is.A type variable inside a union is permitted in the return type of a signature, which is what
findrelies on, and not in a parameter type (4.4). One consequence of the canonical form of a union (4.2) is worth noting: searching anArray<Any>givesAny | Null, which isAny, so the absent case is then not distinguishable by type. That is inherent toAny, and the answer is the usual one —castthe decoded value to a concrete element type before searching it.every(xs, p)is true whenpholds for every element, and is true for an empty array.any(xs, p)is true whenpholds for at least one element, and is false for an empty array. Both stop applyingpat the first element that decides the result.The name
allis not used forevery, because it is already the asynchronous helper of 15.6.
Reshaping:
flatten(xss)concatenates the inner arrays in order, removing exactly one level of nesting.flat_map(xs, f)isflatten(map(xs, f)), withfapplied left to right.zip(xs, ys)pairs elements at equal positions into records{first: ..., second: ...}, and truncates to the shorter of the two inputs.unique(xs)removes later elements equal to an earlier one, keeping the first occurrence and the original order. It compares with==, so the element type is bounded bycomparable, as forcontains_array.range(start, end)returns the ascending integers fromstartup to but not includingend, and the empty array whenend <= start. Both arguments must be integers; a non-integer isE-RUNTIME-VALUE. It is how aforexpression (6.4) iterates a number of times, sincefortraverses an array and has no numeric form.enumerate(xs)pairs each element with its zero-based index as records{index: ..., value: ...}. It is how aforexpression iterates with an index available, since the body offorreceives only the element.
Map operations:
set(m, k, v),remove(m, k), andmerge(m1, m2)return new maps and leave their arguments unchanged.setreplaces the value when the key is already present;removereturns the map unchanged when the key is absent;mergegives the right operand priority on a key present in both.get_or(m, k, fallback)returns the value bound tok, orfallbackwhen the key is absent. It never fails, and it is the form to prefer over testing withhas_keyand then callingget, which looks the key up twice.entries(m)returns one record{key: ..., value: ...}per entry, andfrom_entries(es)is its inverse, giving a later element priority on a duplicate key. The two exist so that a map can be traversed withmap/filter/reduce, which take arrays.entriesandkeystraverse in ascending Unicode code point order of the key, so that a traversal of a map is deterministic.values(m)ismap(entries(m), \(e) -> e.value)and follows the same order.map_values(m, f)appliesfto every value and keeps the keys unchanged.
15.5 Command Execution Functions
The built-in library provides at least the following functions.
run:Function<Runnable, String, CommandResult>runnable:Function<Environment, Runnable>, with the run options of 10.2 as keyword parametersshell_quote:Function<String, String>with_container:Function<Runnable, Function<Runnable, T>, T>
Contract:
- Follows the rules of 6.6, 8.7, and Chapter 10.
CommandResultis the built-in type alias defined in 6.6. runsucceeds regardless of the exit code as long as the command completes. The diagnostic code for failures caused by a non-zero exit of the command execution expressions$,$1, and$2(6.6) isE-RUNTIME-COMMAND-NONZERO.- Environment resolution failure is
E-IO-ENV-RESOLVE.
Runnables:
runnable(env, k1 = v1, ...)returnsenvwith the run options given (10.2): how the image runs, never which image it is. Each run option is a keyword parameter defaulting tonull, and an option givennullis not set.#head{k1: v1, ...}is its sugar (6.7).- It takes an
Environmentonly. ARunnableis not one (4.4), so the options of a runnable are given once, where it is made (8.8). - An image option given to it is a static error (
E-TYPE-ENV-CONSTRUCT): the image is named by a head, with its image options (6.7). - A run option for a
localenvironment isE-TYPE-ENV-CONSTRUCTwhen the environment is#localwritten as the argument, andE-RUNTIME-VALUEwhen it is reached as a value.runnable(e)with no run option is a runnable for any environment.
python(--image: Environment = #python:3.12.14-alpine3.24, --user: String | Null = null): Runnable =
runnable(image, user = user, env = { "PYTHONUNBUFFERED": "1" })Quoting:
shell_quote(s)returns a single POSIX shell word that expands to exactlys: the value wrapped in single quotes, with every embedded'rewritten as'\''.- A hole of a command execution expression is already escaped (6.6), so it needs no
shell_quote; one there quotes the value twice, and the argument carries literal quotation characters.shell_quoteis for a command line built as a string, such as one passed torunor tosh -c:
upload(env: Runnable, src: String, dst: String): CommandResult =
run(env, "aws s3 cp #{shell_quote(src)} #{shell_quote(dst)}")shell_quoteis a pure function, and is placed here rather than among the string operations of 15.3 because its contract is about command construction. There is no function that marks a hole to be substituted unescaped: that is the raw hole#!{e}of 6.6.- Masking (12.8) matches registered values as exact substrings. Escaping a value rewrites the characters it escapes, so a registered secret is also registered in each form the escaping of 6.6 and
shell_quotewrite it (every'as'\''; every\,",$and`preceded by\), and the command line that carries it is masked as well.
Container sessions:
with_container(env, body)evaluatesbody(session)with one container ofenvkept running for the extent of the call, and returns what the body returns, or fails as it fails.sessionis aRunnable: a command run with it — by a command execution expression (6.6), byrun, or by a filesystem function (15.11) — runs in that container rather than in a container of its own.- It exists so that commands that depend on each other can be written one per line. A container environment starts a container for each command (10.2), so without a session such commands are joined into one command string with
&&. - The container is started as the container of a command is: the image the lock pins (10.3, 10.4), the run options of
env(10.2), the base directory mounted at/work(10.5), and an init process unlessinitisfalse. It is started at the call, before the body is evaluated: an image that is not materialized isE-IO-IMAGE-MISSING, and an engine that cannot start the container isE-IO-ENV-RESOLVE. - What lasts from one command to the next is the container: its filesystem, including the files outside
/work, and the processes a command leaves running in the background. The state of a shell does not: each command is run by a/bin/shof its own, so acdor anexportends with its command. Every command starts with the working directory, the user and the variables of the run options ofenv(workdir,user,env; 10.2), which are given once, where the session is opened. - The container is removed when the body is done: when it returns, when it fails, and when it is abandoned (8.7, 15.7). Its removal stops every process in it.
- The session is closed before its container is removed. A command run with a closed session — by a computation the body started that runs on after it (6.3), or with a session the body returned — fails without running, with the diagnostic code
E-RUNTIME-SESSION-CLOSED. A command still running in the session when it closes is stopped with the container, and fails the same way. A computation of the body that does not use the session runs on as usual. - A session ends with the run that opened it, however the run ends: an implementation must not leave its container running once lask has exited, killed or not.
- Given
#local, or a session that is open,with_containerevaluatesbody(env): the host keeps its state already, and a session is one already. A function can therefore take any runnable and open a session on it, and a session can be given to a function that opens one. - A command in a session is logged with the environment summary of
env(12.3). The execution log (12.2) records atinfothe start of the session, naming its container andenv, and the removal of its container. - A session is serialized as the environment
envit was started on (13.1). - This implementation starts the container with a
/bin/shthat reads its standard input to the end, and holds the other end of that input: closing it ends the container, which is removed then, and lask's exit closes it however lask exits. A command is run with the engine client'sexecas/bin/sh -c <command string>, marked by a variable of its own,LASK_SESSION_EXEC, which the processes it starts inherit; an abandoned command is stopped by sending its marked processes the termination request of 8.7.
// Install a tool outside /work, start a server in the background, and
// test against it: three steps, each on a line of its own.
test_api(): String = with_container(#node:24.21.0-alpine3.24{workdir: "/work/api"}, \(box) -> do {
$[box] apk add --no-cache curl
$[box] npm ci
$[box] npm start > /tmp/server.log 2>&1 &
until(backoff_fixed(1, 30), \(r: CommandResult) -> r.code == 0, \() -> do {
$*[box] curl -fs localhost:3000/health
})
$[box] npm test
})- A command execution expression runs to the end of its line (6.6), so the body is written as a
doblock whose closing})stands on a line of its own.
15.6 Parallel and Asynchronous Helper Functions
The built-in library provides at least the following functions.
spawn:Function<Function<T>, AsyncHandle<T>>await:Function<AsyncHandle<T>, T>all:Function<Array<AsyncHandle<T>>, Array<T>>race:Function<Array<AsyncHandle<T>>, T>
Semantics:
spawn/awaitfollow the rules of 6.3 and 8.6.- Because
awaitis a reserved word, it can only be applied in theAwaitExprform (await h); when passing it as a function value, write\(h) -> await h(6.3). allwaits for all handles to complete and returns the result array in input order.racereturns the first result to succeed or fail. The computations of the other handles are cancelled, and a command one of them is running is stopped (8.7).
Failure rules:
awaiton a handle whose computation failed re-raises that failure unchanged (6.3, 8.6): the sameErrorvalue, with its code and message, and therefore the same exit code if it goes uncaught (11.3).allandracedo the same with the failure they receive.allfails as soon as any one of them fails, whichever position it holds in the array, with that failure. The computations of the other handles are cancelled, and a command one of them is running is stopped (8.7), so none is left running after the failure is reported.raceon an empty array isE-RUNTIME-VALUE: there is no result to return.
15.7 Error Handling Functions
The built-in library provides at least the following functions.
recover:Function<Function<T>, Function<Error, T>, T>fail:Function<Error, T>error:Function<Number, String, Error>
The Error type:
Erroris the built-in type aliasRecord<code: Number, message: String>(6.9). It must not be overridden by user definitions.
Semantics:
recover/failfollow the rules of 6.9 and 8.10. Both are core functions and must not be overridden by user code (7.2).error(code, message)is a helper function that constructs anErrorvalue equivalent to{code: code, message: message}.- The return type
Toffailis concretized from the context's expected type (4.4).
Failure rules:
failraises a failure carrying the givenErrorvalue. If uncaught, the process exits withcodeas the exit code (8.10, 11.3).
Retrying and waiting:
The built-in library also provides at least the following functions.
retry:Function<Array<Number>, Function<T>, T>retry_if:Function<Array<Number>, Function<Error, Bool>, Function<T>, T>until:Function<Array<Number>, Function<T, Bool>, Function<T>, T>timeout:Function<Number, Function<T>, T>backoff_fixed:Function<Number, Number, Array<Number>>backoff_linear:Function<Number, Number, Number, Array<Number>>backoff_exponential:Function<Number, Number, Number, Array<Number>>
They are functions rather than syntax, for the same reason recover is: the body is passed as a zero-argument function, so they compose as calls do.
alpine = #alpine:3.22.2
build(): String = $[alpine] echo built
// At most 60 seconds per attempt, and three more attempts after 1, 2 and 4 seconds.
per_attempt(): String = retry(backoff_exponential(1, 2, 3), \() -> timeout(60, \() -> build()))
// At most 300 seconds in all, the waits between attempts included.
in_all(): String = timeout(300, \() -> retry(backoff_fixed(5, 10), \() -> build()))Interval strategies:
- A strategy is an
Array<Number>: the delay, in seconds, before each further attempt or check. Its length is therefore the bound:[]means one attempt and no retry, and a strategy ofndelays allowsn + 1attempts. Being ordinary data, a strategy can be written as a literal, built by the functions below, logged, and transformed with the functions of 15.4. backoff_fixed(delay, count)iscountdelays ofdelay:backoff_fixed(2, 3)is[2, 2, 2].backoff_linear(initial, step, count)is[initial, initial + step, ...],countlong:backoff_linear(1, 2, 3)is[1, 3, 5].backoff_exponential(initial, factor, count)is[initial, initial * factor, ...],countlong:backoff_exponential(1, 2, 4)is[1, 2, 4, 8].- A cap is a composition rather than a function:
map(backoff_exponential(1, 2, 8), \(d) -> min(d, 30)). Jitter, being nondeterministic, isbackoff_jitter(15.13). delay,initial,stepandfactormust be finite and non-negative, andcounta non-negative integer. Anything else, or a strategy growing past a representable delay, isE-RUNTIME-VALUE.
Evaluation of retry(delays, body) and retry_if(delays, when, body):
- Every element of
delaysmust be finite and non-negative, or the call fails withE-RUNTIME-VALUEbefore the first attempt, so that whether a strategy is rejected does not depend on how the attempts go. - Evaluate
body(). If it succeeds, return its value. - If it fails, map the failure to an
Errorvalueerr(8.10). If no delay remains, or, forretry_if,when(err)isfalse, re-raise the failure unchanged: the sameErrorvalue, and therefore the same exit code if it goes uncaught, asawaitdoes (6.3).retryretries any failure. - Otherwise wait for the next delay and continue at step 2. Each retry writes one line to the execution log (12.2) naming the attempt, the
codeof the failure, and the delay. - A failure of
whenis not caught and propagates, as that of arecoverhandler does.
Nothing distinguishes a command that is safe to repeat from one that is not. Retrying one that is not, such as a deployment that half-succeeded, is the author's decision, and a retry keeps no record of what an earlier attempt changed.
Evaluation of until(delays, done, body):
retry_if reacts to a failure, until to a value. A resource still being created answers 404, which is an answer rather than an error.
// The HTTP status of url, without failing on an error status.
status(url: String): CommandResult =
$*[#local] curl -s -o /dev/null -w '%{http_code}' #{url}
// Poll every 5 seconds, 100 times at most, and give up after 10 minutes in any case.
wait_ready(url: String): CommandResult =
timeout(600, \() ->
until(backoff_fixed(5, 100), \(r: CommandResult) -> r.code == 0 && r.stdout == "200", \() -> status(url)))- Validate
delaysasretrydoes. - Evaluate
v = body(), thendone(v). If it istrue, returnv. The first check is made at once, without a delay. - If it is
falseand a delay remains, wait for it and continue at step 2. Each miss writes one line to the execution log. - If it is
falseand no delay remains, fail with theError{code: 124, message: ...}and the diagnostic codeE-RUNTIME-UNTIL-EXHAUSTED(8.10, 14.5). The message states the number of checks and a summary of the last value, masked according to 12.8. - A failure of
bodyor ofdoneis not caught and propagates. Waiting through failures is written explicitly, with$*(6.6) orretry_if.
Evaluation of timeout(seconds, body):
secondsmust be finite and positive; fractions are allowed. Anything else isE-RUNTIME-VALUE.- Evaluate
body(), measuring the time from the call. If it finishes, successfully or not, beforesecondshave passed, its result is the result of the call. - Once
secondshave passed without it finishing, the body is abandoned: the command it is running is stopped (8.7), and every computation it started withasync, directly or through the computations those started, is cancelled, and its commands stopped. The call then fails with theError{code: 124, message: "timed out after <seconds>s"}and the diagnostic codeE-RUNTIME-TIMEOUT(8.10, 14.5). There is no grace setting and no per-environment default. - The failure is raised at the
timeoutcall, not inside the body. Atryinside the body does not see it, and itsfinallydoes not run, because abandoning the body is not a failure of the body; atryaround the call sees it. Clean-up that must run belongs outside thetimeout. - A computation the body started is cancelled only when the body is abandoned. When the body finishes in time, what it started runs on as usual (6.3).
timeout bounds what it is given, so where it is written decides what it bounds. timeout(30, \() -> await h) bounds the wait for h, and h runs on after the limit; async timeout(30, \() -> work()) bounds work itself.
15.8 Serialization and Type-Migration Helper Functions
The built-in library provides at least the following functions.
to_json:Function<Any, String>from_json:Function<String, Any>encode:Function<Any, String, String>decode:Function<String, String, Any>cast:Function<Any, T>base64_encode:Function<String, String>base64_decode:Function<String, String>sha256:Function<String, String>md5:Function<String, String>
Format arguments:
- The 2nd argument of
encode/decodeis a format specification string, and at leastjson,pretty-json,yaml,toml,csv, anddotenvare accepted. yamlfollows the YAML 1.2 core schema. Decoding maps scalars to values by the same rules the JSON list below gives, and encoding emits a block-style document.tomldecodes a table to an object under the same rule as a JSON object, and decodes a date or time as aString, since the language has no date type. Encoding is defined only when the top-level value is an object.csvfollows RFC 4180 with a header row. Decoding yields an array of objects keyed by the header, in which every value is aString; no type is inferred from the text. Encoding takes an array of objects, uses the field set of the first element as the header, and requires every element to carry that same field set with onlyString,Number, orBoolvalues.dotenvdecodesNAME=valuelines to an object whose values are allString, ignoring blank lines and lines whose first non-space character is#, and stripping one layer of surrounding quotation from a value. Only the first=of a line separates the name from the value, so a value may contain further ones. Encoding emits oneNAME=valueline per entry, quoting a value that needs it, and is defined only for string-valued objects.- A value that the requested format cannot represent is
E-RUNTIME-VALUE; input that is not well-formed in the requested format isE-IO-DATA-DECODE. - The format argument selects a syntax, not a schema. Every format decodes to the ordinary value kinds of 4.1, and a decoded value is moved to a concrete type with
cast, exactly as forjson. - Every format represents a decoded object the same way
from_jsondoes. The choice betweenRecord<...>andMap<Any>is the implementation's (see the JSON conversion rules below), but one implementation must make it identically for every format, so thatcastbehaves the same whichever syntax the data arrived in.
JSON conversion rules of from_json / decode:
- JSON number ->
Number - JSON string ->
String - JSON boolean ->
Bool - JSON null ->
Null - JSON array -> array value (elements have these rules applied recursively)
- JSON object ->
Record<...>orMap<Any>(implementation choice. However, it must be consistent within the same implementation) - Tagged metadata representations (objects with a
$typefield) are also not treated specially, and are converted as ordinary objects under the above rules. Restoration ofVoid,Environment, andFunctionRefis not performed (round-trip rules of 13.1).
Type migration via cast:
cast(v)checks at runtime whether the valuevconforms to the target typeT, and if it conforms, returnsvas a value of typeT. Together with the type dispatch ofcase(6.4), which runs the same check without failing, it is one of the two means of migration fromAnyor from a union to a concrete type (4.4). Usecastwhere any other kind of value is an error, andcasewhere the other kinds are to be handled.- The target type
Tmust be uniquely concretized from the expected type at the reference position (4.4) (e.g.,user: Record<name: String> = cast(from_json(stdin))). - The target type
Tis limited to data types (Number,String,Bool,Null,Environment,Any,Array/Map/Recordcomposed of them, and unions of those). Acastto a type containingVoid,Function, orAsyncHandleis a static error (E-TYPE-ILLFORMED). Every well-formed union is a legal target, because 4.2 already restricts a union's members to this same set. castis also the way out of a union when only one member is expected and any other is an error:cast(v)at targetStringon av: String | Nullsucceeds when the value is a string and fails withE-RUNTIME-CASTwhen it is null. When the absent case is to be handled rather than rejected, usecase(6.4), which tests without failing.
Runtime type check rules for cast:
- Basic types: the kind of the runtime value matches the target type.
Array<T>: the value is an array value and every element conforms at runtime toT.Map<T>: every value conforms at runtime toT.Record<...>: every required field of the target type is present, no key outside its field set is present, and each value conforms at runtime to the corresponding field type. An optional field (4.2) may be absent, and is checked only when present. A missing required key fails, as it always has.- Record values and map values are mutually acceptable. When the structural conditions are satisfied, the implementation converts to the target type's representation (record or map) as needed (to absorb the implementation choice for JSON objects in
from_json). T1 | T2 | ...: the value conforms when it conforms to some member. Members are tried in the canonical order of 4.2 and the first that matches decides the result, which matters only where two members can accept one value, asRecord<...>andMap<T>can by the rule above.- Positions of
Anywithin the target type pass without checking. - If the check fails, it is a runtime error (
E-RUNTIME-CAST). The diagnostics should include the failing position (field path, index).
Encoding and digests:
base64_encode(s)encodes the UTF-8 bytes ofsin standard base64 with padding (RFC 4648 section 4).base64_decode(s)accepts both the standard and the URL-safe alphabet, with or without padding, and isE-IO-DATA-DECODEon input that is not valid base64 or that decodes to bytes that are not valid UTF-8.sha256(s)andmd5(s)return the digest of the UTF-8 bytes ofsas lowercase hexadecimal, 64 and 32 characters respectively.md5is provided only for interoperating with external tools that require it, such as checksum and entity-tag comparison. It must not be used where collision resistance matters; usesha256.- A digest or an encoding of a registered secret (12.8) is a new value that no longer contains the registered one as a substring, and is therefore not masked.
Contract:
- Follows the Serialization Conventions of Chapter 13.
- Invalid JSON is reported as
E-IO-DATA-DECODE. - An unsupported format specification is reported consistently as either
E-CLI-USAGEorE-IO-DATA-DECODE.
15.9 Environment Access and Secret Marking Functions
The built-in library provides at least the following functions.
get_env:Function<String, String>find_env:Function<String, String | Null>has_env:Function<String, Bool>get_env_or:Function<String, String, String>mark_secret<T: String | Null>:Function<T, T>.TisString,String | Null, orNull, which registers nothing; any other type isE-TYPE-BOUND.
Semantics:
get_env(name)returns the value of the process environment variablename. It presupposes that the variable is set, and a variable that is not set is a runtime error (E-RUNTIME-ACCESS) whose diagnostic names the variable (15.1, 14.5). This is the form for a variable the task requires in order to run at all, such as a credential: the failure names what is missing, at the point it is missing, instead of letting an absent value travel on.get_envis a core function (7.2) and must not be directly declared or overridden by user code.find_env(name)returns the value ofname, orNullwhen it is not set. Its result type is the unionString | Null(4.2), which the caller resolves withcase(6.4) — or withcast(15.8) when an unset variable is to be treated as an error after all. This is the form for a variable the task merely accepts.has_env(name)is true whennameis set in the process environment, including when it is set to the empty string.get_env_or(name, fallback)returns the value ofnamewhen it is set, andfallbackwhen it is not. It always returns aString, so it is the form to prefer wherever a missing variable has a sensible default, and it says at the call site what that default is.- The four differ only in what an unset variable means — a failure, a
Null, aBool, or a fallback — and follow the naming rules of 15.1.find_envis the only one of them whose result can be absent, andget_env_or(name, fallback)is exactly thecaseoverfind_env(name)that returnsfallbackforNull. - All four read the process environment of the Lask process itself. They do not read the variables of an execution
Environment(10.6); a command reads those through the shell it runs in. find_env,has_envandget_env_orare ordinary built-in symbols and may be shadowed by a user definition (15.1). Onlyget_envandmark_secretare core functions.- A variable holding a secret reference (9.8) is resolved by
get_env,find_envandget_env_or, which return the value it names;has_envdoes not resolve it. get_envdoes not register what it returns for masking (12.8): reading a value from the environment says nothing about whether it is sensitive. The exception is a value resolved from a secret reference, which is registered (9.8). Bind a credential read this way to a!!-marked name (6.10) to have it masked.find_envandget_env_orbehave the same way.!!acceptsString | Null(6.10), so a credential that may be unset can be read withfind_envand still be masked when present.mark_secret(v)registersvfor masking (12.8) and returnsvunchanged. Whenvisnull, nothing is registered (6.10).mark_secretis a core function and must not be directly declared or overridden by user code (7.2). It is the desugaring target of!!secret bindings (6.10); user code may also call it directly to register a value that isn't declared with!!.- Calling
mark_secrethas no effect on the type of its argument (Stringin,Stringout;String | Nullin,String | Nullout) and no effect on control flow: it is not a source of failure.
15.10 Path Operations
The built-in library provides at least the following functions.
path_join:Function<Array<String>, String>dirname:Function<String, String>basename:Function<String, String>extname:Function<String, String>normalize_path:Function<String, String>is_absolute_path:Function<String, Bool>
Semantics:
- These are pure, lexical operations on strings. They never consult the filesystem, and therefore take no
Environment:dirnameof a path that does not exist is still its parent, andnormalize_pathnever resolves a symbolic link. - Paths are POSIX paths whatever the host operating system is:
/is the only separator, and no drive letter or backslash is recognized. A path computed on the host therefore still means the same thing inside a container, which is the case these functions exist for (10.8). path_join(parts)joins the parts with a single/, dropping empty parts, and normalizes the result. A part that is absolute discards everything before it. An empty array yields the empty string.dirname(p)returns everything before the final component:"a/b/c"gives"a/b", a path with no separator gives".", and a child of the root gives"/".basename(p)returns the final component, ignoring trailing separators:"a/b/c.txt"gives"c.txt"and"a/b/"gives"b".extname(p)returns the final extension of the final component, including the dot:"a/b/c.tar.gz"gives".gz". A final component with no dot, or one whose only dot is its first character, gives the empty string, so".env"gives"".normalize_path(p)collapses repeated separators and resolves.and..lexically. A leading/is preserved, a..that would ascend past the root of an absolute path is dropped, and a leading..in a relative path is preserved because there is no way to resolve it without touching the filesystem.is_absolute_path(p)is true whenpbegins with/.- The name
path_joincarries its prefix becausejoinis already the string operation of 15.3 and the library has no overloading (15.1).
15.11 Filesystem Functions
The built-in library provides at least the following functions.
read_file:Function<String, Runnable, String>write_file:Function<String, String, Runnable, Void>file_exists:Function<String, Runnable, Bool>remove_file:Function<String, Runnable, Void>make_dir:Function<String, Runnable, Void>list_dir:Function<String, Runnable, Array<String>>glob:Function<String, Runnable, Array<String>>
The environment argument:
- Every function in this section takes the target
Environmentas its last positional argument, and acts on the filesystem that environment sees: the host's filesystem for#local, and the container's filesystem for a container environment. There is no filesystem access that does not name an environment, and there is no default one (10.1) — an unqualified read would otherwise mean the host, which is exactly the silent fall back to the host that this specification rejects for commands (10.1, 10.4). - The argument is positional and required.
- Command dispatch (10.9) never applies to these functions. Dispatch selects an environment from the program a command string invokes, and these calls contain no command word, so the environment is always written out.
- Relative paths resolve against the working directory of the given environment (10.5).
- Every access is bounded by that environment's permission boundary (10.7). A container environment reaches only what was mounted into it, so a path that exists on the host is not readable through a container environment unless it was mounted there.
- These functions are effectful and are not referentially transparent (15.1). Two reads of the same path in the same environment may differ, and the order of their evaluation within a
doblock is the order they are written in (6.5, 8.4).
Semantics:
read_file(path, env)returns the entire contents of the file decoded as UTF-8. Contents that are not valid UTF-8 areE-IO-DATA-DECODE, because the language has no binary value type (4.1); move bytes with a command, or withbase64_encode(15.8) applied on the side that produces them.write_file(path, contents, env)creates the file, or truncates it when it already exists, and writescontentsas UTF-8 with no trailing newline added. It creates no parent directory: a missing parent isE-IO-FS, andmake_diris how one is created.file_exists(path, env)is true when a file or a directory exists at the path. It follows symbolic links, and it returns false rather than failing when a component of the path is missing.remove_file(path, env)removes a file. Removing a path that does not exist succeeds and does nothing, so cleanup needs no prior test; a path that is a directory isE-IO-FS. There is deliberately no recursive removal function: a destructive traversal stays an explicit command, where it is visible in the command execution log (12.3).make_dir(path, env)creates the directory together with every missing parent. An existing directory succeeds and changes nothing; an existing non-directory isE-IO-FS.list_dir(path, env)returns the names of the entries directly inside the directory, not their paths, excluding.and... A path that is not a directory isE-IO-FS.glob(pattern, env)returns the paths that match the pattern, expressed relative to the environment's working directory when the pattern is relative. The pattern syntax is*for any run of characters within one component,?for a single character within one component,[...]for a character class, and**for any number of components, valid only as a whole component. A pattern that matches nothing returns the empty array and is not a failure. A component beginning with.matches only a pattern component that also begins with..list_dirandglobreturn their results sorted in ascending Unicode code point order, so that a program that reads a directory produces the same result on every machine. Relying on the order a filesystem happens to return entries in is a common source of pipelines that are reproducible only by accident.
Failure rules:
- Environment resolution failure is
E-IO-ENV-RESOLVE, as it is forrun(15.5). - Every failure of the access itself — a missing path, a permission denial, an exhausted disk — is
E-IO-FS. The diagnostic must identify the path and the environment (14.3). - The contents of a file must never be placed in a diagnostic. A path is not treated as confidential, but the data at it is.
- These calls emit no command execution log (12.3), because no command is executed. An implementation may record them in the execution log (12.2) with the minimum auditable information: the operation, the path, and the environment summary, mirroring the stance of 10.7 on command execution requests.
- Masking (12.8) applies to logs and diagnostics as it does elsewhere. It does not apply to the contents of a file: writing a registered secret with
write_filewrites it in the clear, which is the correct behavior for a file a later command must read, and a hazard for a file that outlives the run.
15.12 Diagnostic Output Functions
The built-in library provides at least the following functions.
log:Function<String, Void>
Semantics:
log(message)emits one execution log line (12.2) at the informational level and returnsVoid. Its destination is stderr (9.6).- The built-in library provides no function that writes to stdout. Stdout carries the evaluation result and nothing else (9.5), and a function that could write to it would break that contract for every caller that consumes a task's output.
logis how a task reports its progress. It needs no execution environment, unlike$ echo, which is a command and therefore must name an environment to run in (10.1) — an entire container materialized to print one line.- Because
rundoes not print the evaluation result (9.5, 11.3),logis also how an intermediate value is observed duringrun, whereevalis not what is being used. - The output is subject to secret masking (12.8): a registered value appearing as a substring of
messageis masked. - The line format follows 12.2 and is implementation-defined. In the JSON log format the argument appears as the
messagefield. logis effectful and is not referentially transparent (15.1). Its position within adoblock determines when its line is emitted (6.5, 8.4), and alogin a branch that is not taken emits nothing.
15.13 Nondeterministic Functions
The built-in library provides at least the following functions.
uuid:Function<String>random_string:Function<Number, String>backoff_jitter:Function<Array<Number>, Array<Number>>
Semantics:
uuid()returns a newly generated version 4 UUID in the canonical lowercase hyphenated form of 36 characters.random_string(n)returnsncharacters drawn uniformly from0-9anda-z.nmust be a non-negative integer; anything else isE-RUNTIME-VALUE.backoff_jitter(delays)replaces each delaydwith a draw from the uniform distribution over[0, d](full jitter), so that clients retrying the same failure do not retry in step (15.7). Every element must be finite and non-negative, or it isE-RUNTIME-VALUE. Its draws need not be cryptographically secure.uuidandrandom_stringmust draw from a cryptographically secure source of randomness. A generated name is frequently used where a collision would be a failure of the run, and a weak generator makes those collisions correlated across machines.- All three are nondeterministic: they are the only built-in functions whose result differs between calls with the same arguments without any external input. They are separated into this section for that reason (15.1).
uuidandrandom_stringexist for names that must not collide — a generated file written into a directory that a concurrent run also writes to, a temporary resource suffix, an invalidation token. Without them, such a name has to be a constant, and two runs of the same task race for it.- A generated value must not be treated as a secret merely because it is unpredictable. Use
mark_secret(15.9) when a value is to be masked.
15.14 Error Contract
Functions of the built-in library must be consistent with the Error System of Chapter 14.
Minimum requirements:
- Generate a diagnostic with
codeandmessageon each failure path. - Attach
locationandtraceIdwhere possible. - Maintain determinism, returning the same error code for the same condition.
16. Examples
This chapter presents representative examples spanning the whole specification. Each example lists the command and expected behavior, assuming the code is placed in main.lask in the execution directory. Since run does not output the evaluation result to stdout (11.3), use eval to confirm values. Output examples use the default json display.
16.1 Minimal Program
A minimally structured module example is shown.
hello() = "hello, lask"This module publishes one zero-argument function hello, returning a String value.
Command and expected behavior:
$ lask eval hello
"hello, lask"
$ lask run helloevaloutputs the evaluation result to stdout and exits with exit code0.runexecutes the function but does not output the evaluation result to stdout (execution logs go to stderr). It exits with exit code0.
16.2 Functions with Type Annotations
An example of functions with type annotations and default arguments is shown.
greet(name: String, --prefix: String = "hello"): String =
concat(prefix, concat(", ", name))
add(x: Number, y: Number): Number =
x + ygreet("alice") returns "hello, alice", and greet("alice", prefix = "hi") returns "hi, alice".
Command and expected behavior:
$ lask eval greet alice
"hello, alice"
$ lask eval greet alice --prefix hi
"hi, alice"
$ lask eval add 1 2
3
$ lask run greet alice --prefix hinameis a positional parameter and is bound only by position. Specification by name such as--name aliceis not possible (a CLI usage error corresponding toE-TYPE-KEYWORD).prefixis a keyword parameter and is bound only by name. When unspecified, it is completed with the default value"hello"(7.5).- The arguments of
addare bound positionally asNumbervalues, with1and2decoded by the default--arg-decode auto. - The last
runexample executes with the same syntax and binding aseval, but the evaluation result is not output to stdout, and it exits with exit code0. lask run greetwithout the positional parameternameis a pre-execution error and the function is not evaluated (CLI usage error; exit code4; 11.3).
16.3 Higher-Order Functions and Composition
Examples of higher-order functions, the pipe operator, and the composition operator are shown.
applyTwice(f: Function<Number, Number>, x: Number): Number =
f(f(x))
inc(x: Number): Number = x + 1
double(x: Number): Number = x * 2
pipeline(n: Number): Number =
n
|> inc
|> double
incThenDouble: Function<Number, Number> =
inc >> doublepipeline(3) and incThenDouble(3) both return 8.
Command and expected behavior:
$ lask eval pipeline 3
8
$ lask eval incThenDouble 3
8incThenDoubleis a top-level declaration of a function value, and can be invoked from the CLI just like a function declaration. Since this is application of a value of function type, binding is by positional arguments only (7.5).applyTwicehas a parameter of function type, so it cannot be invoked directly from the CLI (no decoding scheme can construct a function value, so it becomes a pre-execution error due to type mismatch). CallingapplyTwice(inc, 3)from within the language returns5.
A declaration that declares type parameters (4.2) is written and used the same way, with the parameters instantiated per call.
type Opt<A> = A | Null
first_or<T>(xs: Array<T>, fallback: T): T =
if (is_empty(xs)) { fallback } else { xs[0] }
find_first<T>(xs: Array<T>, p: Function<T, Bool>): Opt<T> = find(xs, p)
shortest(words: Array<String>): String = first_or(sort_by(words, length), "")
flag_of(args: Array<String>): String = do {
hit = find_first(args, \(a: String) -> starts_with(a, "--"))
case (hit) {
Null -> "(none)"
else -> hit
}
}$ lask eval first_or '[1,2]' 0
1
$ lask eval first_or '[]' '"none"'
"none"
$ lask eval shortest '["bb","a","ccc"]'
"a"
$ lask eval flag_of '["x","--v","y"]'
"--v"first_oris instantiated atNumberin the first call and atStringin the second. Both calls run the same body: within it,Tis opaque (4.4), which is why the body can return an element or the fallback but cannot compare, order or interpolate one.shortestandflag_ofcall generic functions without naming a type anywhere: the instantiation comes from the argument types (4.4).lask eval first_or ...instantiatesTatAny(11.2), which is sound for the same reason: the body only moves the value it is given.Opt<A>is a parameterised type alias, andfind_firstreturnsOpt<T>, which expands toT | Null(4.2).
16.4 Arrays, Maps, and Records
Examples of array, map, and record values, and the use of built-in library functions and accessor expressions (6.8) are shown.
labels(xs: Array<String>): Array<String> =
map(xs, \(x: String) -> concat("item:", x))
firstLabel(xs: Array<String>): String =
labels(xs)[0]
user: Record<name: String, age: Number> =
{ name: "alice", age: 20 }
userName(): String = user.name
cfg: Record<env: Environment, retries: Number> =
{ env: #alpine:3.20, retries: 3 }
retryCount(): Number = cfg.retries
envMap: Map<String> =
{ "APP_ENV": "prod", "REGION": "ap-northeast-1" }
appEnv(): String = envMap["APP_ENV"]
region(): String =
get(envMap, "REGION")Command and expected behavior:
$ lask eval labels '["a", "b"]'
["item:a", "item:b"]
$ lask eval firstLabel '["a", "b"]'
"item:a"
$ lask eval userName
"alice"
$ lask eval retryCount
3
$ lask eval appEnv
"prod"
$ lask eval region
"ap-northeast-1"- The argument of
labelsis interpreted as a JSON array by the default--arg-decode autoand bound toArray<String>. user.nameandcfg.retriesare record field accesses (.is exclusive toRecord; 6.8). Referencing a nonexistent field is a static error (E-TYPE-ACCESS).labels(xs)[0]is array index access (0-based). An out-of-range index is a runtime error (E-RUNTIME-ACCESS; 8.9).envMap["APP_ENV"]is map index access, and has the same failure contract asget(envMap, "APP_ENV")(a missing key isE-RUNTIME-ACCESS).regionperforms the same retrieval using the built-in library'sget.user,cfg, andenvMapare value declarations that are not functions, and are not targets of direct invocation from the CLI.
16.5 Procedural Notation and Command Execution with Environments
Examples of do, if, for, and $[env] ... are shown.
buildAndTest(env: Environment): String = do {
buildOut = $[#local] sh -lc "echo local-build"
testOut = if (length(buildOut) > 0) {
$[env] sh -lc "echo container-test"
} else {
"skip"
}
lines = for (x: ["lint", "unit", "e2e"]) {
concat("phase:", x)
}
concat(join(lines, ","), concat(":", trim(testOut)))
}
ci(): String = buildAndTest(#alpine:3.20)Command and expected behavior:
$ lask run ci
$ lask eval ci
"phase:lint,phase:unit,phase:e2e:container-test"- In both cases,
echo local-buildis executed locally, followed byecho container-testexecuted inside thealpine:3.20container.rundoes not output the evaluation result, andevaloutputs the return value to stdout. buildAndTesthas a parameter of typeEnvironment, so it is excluded from direct CLI invocation (11.2). It is invoked via a function with the environment fixed, likeci.- If any command exits non-zero, the failure propagates and
laskexits with that command's exit code (8.10, 11.3).
During execution, a command execution log like the following flows to stderr in real time (12.3):
2026-07-23T10:15:04.101Z [#local:1] $ sh -lc "echo local-build"
2026-07-23T10:15:04.113Z [#local:1] 1| local-build
2026-07-23T10:15:04.114Z [#local:1] exit 0
2026-07-23T10:15:04.360Z [#alpine:3.20:2] $ sh -lc "echo container-test"
2026-07-23T10:15:04.372Z [#alpine:3.20:2] 1| container-test
2026-07-23T10:15:04.373Z [#alpine:3.20:2] exit 016.6 Asynchronous Execution
Corresponding examples of async (sugar) and spawn/await (core functions) are shown.
fetchA(): String = $[#local] sh -lc "echo A"
fetchB(): String = $[#local] sh -lc "echo B"
mergeAB(): String = do {
a = async fetchA()
b = async fetchB()
concat(trim(await a), trim(await b))
}Semantically, this example normalizes to spawn(\() -> fetchA()), spawn(\() -> fetchB()), and core function applications await(...).
Command and expected behavior:
$ lask eval mergeAB
"AB"fetchAandfetchBare started concurrently byasync, and both completions are awaited viaawaitbefore concatenation. Regardless of the completion order of the 2 commands, the result is always"AB".- If either asynchronous task fails, the failure is re-raised at the corresponding
await(8.6).
16.7 CLI Execution Examples
# Type checking
lask check --module app/main.lask
# Local execution (main.lask is the default target. The result is not output to stdout)
lask run hello
lask run --module app/main.lask hello
# Function evaluation (outputs the evaluation result to stdout)
lask eval greet alice
# Execution with keyword arguments (greet(name: String, prefix: String = "hello"))
lask run greet alice --prefix hi
# Fixed-image execution (build specifies $[#alpine:3.20] inside the function)
lask run --module ci/main.lask build
# Execution in a container built from a recipe (provision uses
# #./infra/Dockerfile; images come from the lock file)
lask run --module ops.lask provision
# The environments provision uses, and whether each image is present
lask envs list provisionExpected behavior:
lask check: outputs diagnostic results to stdout. Exit code0if there are no static errors,1if there are.lask run hello: executes the function and exits with exit code0without outputting the evaluation result to stdout.lask eval greet alice: outputs"hello, alice"to stdout (the defaultjsondisplay).lask run greet alice --prefix hi: executes with the positional argumentaliceand the keyword argument--prefix hibound. The evaluation result is not output.build: executes the build inside a container (the evaluation result is not output). If the container engine cannot be reached, a pre-execution error occurs (E-IO-ENV-RESOLVE) and it exits with exit code3.provision: executes commands in a container built from the declared recipe. If the image has not been materialized, anE-IO-IMAGE-MISSINGdiagnostic is output to stderr and it exits with exit code3.lask envs list provision: lists the environmentsprovisionuses, what the lock resolves each to, and whether each image is on the container engine. Exit code0whatever their status (11.4).
16.8 Execution Event Example
An event output example on failure involving command execution with environments (run) is shown. The following is the FailEvent emitted when a required image has not been materialized during execution of lask run deploy prod.
{
"kind": "fail",
"traceId": "trc-20260711-0001",
"timestamp": "2026-07-11T12:34:56Z",
"function": {
"$type": "FunctionRef",
"module": "ops/main.lask",
"name": "deploy",
"arity": 1,
"type": "Function<String, String>"
},
"args": {"summary": ["prod"]},
"env": {"kind": "docker", "params": {"image": "alpine:3.20@sha256:6f3c..."}},
"error": {
"code": "E-IO-IMAGE-MISSING",
"message": "image not materialized: run 'lask sync'",
"detail": {"timeoutSec": 10}
}
}Expected behavior:
- If this failure is not caught by
try/catch, the failure is mapped to anErrorvalue (code: 3), and thelaskprocess exits with exit code3. AnE-IO-IMAGE-MISSINGdiagnostic is output to stderr (8.10, 11.3). - The
FailEventis emitted regardless of whether the failure is caught (12.6).
16.9 Error Handling and Exit Codes
An example of try / catch / finally and the pass-through of exit codes and standard error is shown.
release(): String = do {
out = try {
$[#local] ./release.sh
} catch (e) {
if (e.code == 75) {
"retry-later"
} else {
fail(e)
}
} finally {
$[#local] rm -rf ./tmp
}
out
}Command and expected behavior:
$ lask run release- On success, it exits with exit code
0(the return value is not output to stdout. To confirm the value, uselask eval release). Thefinallyblock ensures./tmphas been deleted. - If
./release.shfails with exit code75, the failure is caught,"retry-later"is returned, and it terminates normally (process exit code0). - Other failures are re-raised by
fail(e). If it reaches the top level uncaught, thelaskprocess exits with that command's exit code, ande.message(the command's standard error output) is output as diagnostics (8.10, 11.3). - The
finallyblock is evaluated exactly once on every path: success, catch, or re-raise.